2026-05-26 13:34:19 +02:00

1644 lines
63 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.dashboard.team
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.config :as cfg]
[app.main.data.common :as dcm]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.nitrate :as dnt]
[app.main.data.notifications :as ntf]
[app.main.data.team :as dtm]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.alert]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.file-uploader :refer [file-uploader]]
[app.main.ui.components.forms :as fm]
[app.main.ui.components.org-avatar :refer [org-avatar*]]
[app.main.ui.dashboard.change-owner]
[app.main.ui.dashboard.subscription :refer [members-cta*
show-subscription-members-banner?
team*]]
[app.main.ui.dashboard.team-form]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.combobox :refer [combobox*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.notifications.badge :refer [badge-notification]]
[app.main.ui.notifications.context-notification :refer [context-notification]]
[app.util.dom :as dom]
[app.util.forms :as uforms]
[app.util.i18n :as i18n :refer [tr]]
[app.util.timers :as tm]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private arrow-icon
(deprecated-icon/icon-xref :arrow (stl/css :arrow-icon)))
(def ^:private menu-icon
(deprecated-icon/icon-xref :menu (stl/css :menu-icon)))
(def ^:private org-menu-icon
(deprecated-icon/icon-xref :menu (stl/css :org-menu-icon)))
(def ^:private warning-icon
(deprecated-icon/icon-xref :msg-warning (stl/css :warning-icon)))
(def ^:private success-icon
(deprecated-icon/icon-xref :msg-success (stl/css :success-icon)))
(def ^:private image-icon
(deprecated-icon/icon-xref :img (stl/css :image-icon)))
(def ^:private user-icon
(deprecated-icon/icon-xref :user (stl/css :user-icon)))
(def ^:private document-icon
(deprecated-icon/icon-xref :document (stl/css :document-icon)))
(def ^:private group-icon
(deprecated-icon/icon-xref :group (stl/css :group-icon)))
(mf/defc header
{::mf/wrap [mf/memo]
::mf/props :obj}
[{:keys [section team profile]}]
(let [on-nav-members (mf/use-fn #(st/emit! (dcm/go-to-dashboard-members)))
on-nav-settings (mf/use-fn #(st/emit! (dcm/go-to-dashboard-settings)))
on-nav-invitations (mf/use-fn #(st/emit! (dcm/go-to-dashboard-invitations)))
on-nav-webhooks (mf/use-fn #(st/emit! (dcm/go-to-dashboard-webhooks)))
route (mf/deref refs/route)
invite-email (-> route :query-params :invite-email)
members-section? (= section :dashboard-team-members)
settings-section? (= section :dashboard-team-settings)
invitations-section? (= section :dashboard-team-invitations)
webhooks-section? (= section :dashboard-team-webhooks)
permissions (:permissions team)
can-invite? (dnt/can-send-invitations?
{:organization (:organization team)
:profile-id (:id profile)
:team-permissions permissions})
invitations (:invitations team)
on-invite-member
(mf/use-fn
(mf/deps team invite-email)
(fn []
(st/emit! (dtm/check-and-invite-members {:team-id (:id team)
:origin :team
:invite-email invite-email}))))]
(mf/with-effect [team invite-email]
(when invite-email
(on-invite-member)))
[:header {:class (stl/css :dashboard-header :team) :data-testid "dashboard-header"}
[:div {:class (stl/css :dashboard-title)}
[:h1 (cond
members-section? (tr "labels.members")
settings-section? (tr "labels.settings")
invitations-section? (tr "labels.invitations")
webhooks-section? (tr "labels.webhooks")
:else nil)]]
[:nav {:class (stl/css :dashboard-header-menu)}
[:ul {:class (stl/css :dashboard-header-options)}
[:li {:class (when members-section? (stl/css :active))}
[:a {:on-click on-nav-members} (tr "labels.members")]]
[:li {:class (when invitations-section? (stl/css :active))}
[:a {:on-click on-nav-invitations} (tr "labels.invitations")]]
(when (contains? cfg/flags :webhooks)
[:li {:class (when webhooks-section? (stl/css :active))}
[:a {:on-click on-nav-webhooks} (tr "labels.webhooks")]])
[:li {:class (when settings-section? (stl/css :active))}
[:a {:on-click on-nav-settings} (tr "labels.settings")]]]]
[:div {:class (stl/css :dashboard-buttons)}
(if (and (or invitations-section? members-section?) (not-empty invitations))
[:button
{:class (stl/css :btn-secondary :btn-small)
:type "button"
:disabled (not can-invite?)
:on-click on-invite-member
:data-testid "invite-member"}
(tr "dashboard.invite-profile")]
[:div {:class (stl/css :blank-space)}])]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INVITATIONS MODAL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn get-available-roles
[permissions]
(->> [{:value "viewer" :label (tr "labels.viewer")}
{:value "editor" :label (tr "labels.editor")}
(when (:is-admin permissions)
{:value "admin" :label (tr "labels.admin")})]
(filterv identity)))
(def ^:private schema:invite-member-form
[:map {:title "InviteMemberForm"}
[:role :keyword]
[:emails [::sm/set {:min 1} ::sm/email]]
[:team-id ::sm/uuid]])
(defn- do-invite-members!
[params origin]
(st/emit! (-> (dtm/create-invitations params)
(with-meta {::ev/origin origin}))
(dtm/fetch-invitations)
(dtm/fetch-members)))
(mf/defc invite-members-modal
{::mf/register modal/components
::mf/register-as :invite-members
::mf/props :obj}
[{:keys [team origin invite-email]}]
(let [members (get team :members)
perms (get team :permissions)
team-id (get team :id)
roles (mf/with-memo [perms]
(get-available-roles perms))
initial (mf/with-memo [team-id invite-email]
(if invite-email
{:role "editor" :team-id team-id :emails #{invite-email}}
{:role "editor" :team-id team-id}))
form (fm/use-form :schema schema:invite-member-form
:initial initial)
error-text (mf/use-state "")
current-data-emails (into #{} (dm/get-in @form [:clean-data :emails]))
current-members-emails (into #{} (map :email) members)
on-success
(fn [_form {:keys [total]}]
(when (pos? total)
(st/emit! (ntf/success (tr "notifications.invitation-email-sent"))))
(st/emit! (modal/hide)
(dtm/fetch-members)
(dtm/fetch-invitations)))
on-error
(fn [_form cause]
(let [{:keys [type code] :as error} (ex-data cause)]
(cond
(and (= :validation type)
(= :profile-is-muted code))
(st/emit! (ntf/error (tr "errors.profile-is-muted"))
(modal/hide))
(and (= :validation type)
(= :max-invitations-by-request code))
(swap! error-text (tr "errors.maximum-invitations-by-request-reached" (:threshold error)))
(and (= :restriction type)
(= :max-quote-reached code))
(swap! error-text (tr "errors.max-quota-reached" (:target error)))
(or (= :member-is-muted code)
(= :email-has-permanent-bounces code)
(= :email-has-complaints code))
(swap! error-text (tr "errors.email-spam-or-permanent-bounces" (:email error)))
(and (= :restriction type)
(= :email-domain-is-not-allowed code))
(st/emit! (ntf/error (tr "errors.email-domain-not-allowed"))
(modal/hide))
:else
(st/emit! (ntf/error (tr "errors.generic"))
(modal/hide)))))
on-submit
(fn [form]
(let [params (:clean-data @form)
mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}]
(st/emit! (dtm/check-and-submit-invite-members (with-meta params mdata) origin do-invite-members!))))]
[:div {:class (stl/css-case :modal-team-container true
:modal-team-container-workspace (= origin :workspace)
:hero (= origin :hero))}
[:& fm/form {:on-submit on-submit :form form}
[:div {:class (stl/css :modal-title)}
(tr "modals.invite-team-member.title")]
(when (= :workspace origin)
[:div {:class (stl/css :invite-team-member-text)}
(tr "modals.invite-team-member.text")])
(when-not (= "" @error-text)
[:& context-notification {:content @error-text
:level :error}])
(when (some current-data-emails current-members-emails)
[:& context-notification {:content (tr "modals.invite-member.repeated-invitation")
:level :warning}])
[:div {:class (stl/css :role-select)}
[:p {:class (stl/css :role-title)}
(tr "onboarding.choice.team-up.roles")]
[:& fm/select {:name :role :options roles}]]
[:div {:class (stl/css :invitation-row)}
[:& fm/multi-input {:type "email"
:class (stl/css :email-input)
:name :emails
:auto-focus? true
:trim true
:valid-item-fn sm/parse-email
:caution-item-fn current-members-emails
:label (tr "modals.invite-member.emails")}]]
[:div {:class (stl/css :action-buttons)}
[:> fm/submit-button*
{:label (tr "modals.invite-member-confirm.accept")
:class (stl/css :accept-btn)
:disabled (and (boolean (some current-data-emails current-members-emails))
(empty? (remove current-members-emails current-data-emails)))}]]]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INVITE RESTRICTED MEMBERS MODAL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(mf/defc invite-restricted-members-modal
{::mf/register modal/components
::mf/register-as :invite-restricted-members}
[{:keys [on-accept blocked-emails]}]
(let [expanded* (mf/use-state false)
expanded? (deref expanded*)
on-toggle (mf/use-fn #(swap! expanded* not))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-restricted-container :modal-container)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)}
(tr "modals.invite-restricted-members.title")]
[:button {:class (stl/css :modal-close-btn)
:on-click modal/hide!} deprecated-icon/close]]
[:div {:class (stl/css :modal-content)}
[:p (tr "modals.invite-restricted-members.description")]
[:& context-notification {:content (tr "modals.invite-restricted-members.warning")
:level :warning}]
[:div {:class (stl/css :restricted-emails-section)}
[:button {:class (stl/css :restricted-emails-toggle)
:type "button"
:aria-expanded expanded?
:on-click on-toggle}
[:span {:class (stl/css :restricted-email-summary)}
(tr "modals.invite-restricted-members.blocked-addresses")]
[:> icon* {:icon-id i/arrow
:size "s"
:class (stl/css-case :restricted-emails-arrow true
:expanded expanded?)}]]
(when expanded?
[:ul {:class (stl/css :restricted-email-list)}
(for [email blocked-emails]
[:li {:key email} email])])]]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons :modal-invitation-action-buttons)}
[:> button*
{:class (stl/css :cancel-button)
:variant "secondary"
:type "button"
:on-click modal/hide!}
(tr "modals.invite-restricted-members.cancel")]
[:> button*
{:class (stl/css :accept-btn)
:variant "primary"
:type "button"
:on-click (fn []
(modal/hide!)
(on-accept))}
(tr "modals.invite-restricted-members.send")]]]]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MEMBERS SECTION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(mf/defc member-info
{::mf/props :obj}
[{:keys [member profile]}]
(let [is-you? (= (:id profile) (:id member))]
[:*
[:img {:class (stl/css :member-image)
:src (cfg/resolve-profile-photo-url member)}]
[:div {:class (stl/css :member-info)}
[:div {:class (stl/css :member-name)} (:name member)
(when is-you?
[:span {:class (stl/css :you)} (tr "labels.you")])]
[:div {:class (stl/css :member-email)} (:email member)]]]))
(mf/defc rol-info*
[{:keys [member team on-set-admin on-set-editor on-set-owner on-set-viewer profile]}]
(let [member-is-owner (:is-owner member)
member-is-admin (and (:is-admin member) (not member-is-owner))
member-is-editor (and (:can-edit member) (and (not member-is-admin) (not member-is-owner)))
member-is-viewer (and (not member-is-editor) (not member-is-admin) (not member-is-owner))
show? (mf/use-state false)
permissions (:permissions team)
is-owner (:is-owner permissions)
is-admin (:is-admin permissions)
is-you (= (:id profile) (:id member))
can-change-rol (or is-owner is-admin)
not-superior (or (and (not member-is-owner) is-admin)
(and can-change-rol (or member-is-admin member-is-editor member-is-viewer)))
role (cond
member-is-owner "labels.owner"
member-is-admin "labels.admin"
member-is-editor "labels.editor"
:else "labels.viewer")
on-show (mf/use-fn #(reset! show? true))
on-hide (mf/use-fn #(reset! show? false))]
[:*
(if (and can-change-rol not-superior (not (and is-you is-owner)))
[:div {:class (stl/css :rol-selector :has-priv)
:role "combobox"
:aria-labelledby "role-label-id"
:on-click on-show}
[:span {:class (stl/css :rol-label)
:id "role-label-id"} (tr role)]
arrow-icon]
[:div {:class (stl/css :rol-selector)}
[:span {:class (stl/css :rol-label)} (tr role)]])
[:& dropdown {:show @show? :on-close on-hide :dropdown-id (str "member-role-" (:id member))}
[:ul {:class (stl/css :roles-dropdown)
:role "listbox"}
[:li {:on-click on-set-viewer
:class (stl/css :rol-dropdown-item)}
(tr "labels.viewer")]
[:li {:on-click on-set-editor
:class (stl/css :rol-dropdown-item)}
(tr "labels.editor")]
[:li {:on-click on-set-admin
:class (stl/css :rol-dropdown-item)}
(tr "labels.admin")]
(when is-owner
[:li {:on-click (partial on-set-owner member)
:class (stl/css :rol-dropdown-item)}
(tr "labels.owner")])]]]))
(mf/defc member-actions*
[{:keys [member team on-delete on-leave profile]}]
(let [is-owner? (:is-owner member)
owner? (dm/get-in team [:permissions :is-owner])
admin? (dm/get-in team [:permissions :is-admin])
show? (mf/use-state false)
is-you? (= (:id profile) (:id member))
can-delete? (or owner? admin?)
on-show (mf/use-fn #(reset! show? true))
on-hide (mf/use-fn #(reset! show? false))]
(when (or is-you? (and can-delete? (not (and is-owner? (not owner?)))))
[:*
[:button {:class (stl/css :menu-btn)
:on-click on-show}
menu-icon]
[:& dropdown {:show @show? :on-close on-hide :dropdown-id (str "member-actions-" (:id member))}
[:ul {:class (stl/css :actions-dropdown)}
(when is-you?
[:li {:on-click on-leave
:class (stl/css :action-dropdown-item)
:key "is-you-option"}
(tr "dashboard.leave-team")])
(when (and can-delete? (not is-you?) (not (and is-owner? (not owner?))))
[:li {:on-click on-delete
:class (stl/css :action-dropdown-item)
:key "is-not-you-option"} (tr "labels.remove-member")])]]])))
(defn- set-role! [member-id role]
(let [params {:member-id member-id :role role}]
(st/emit! (dtm/update-member-role params))))
(mf/defc team-member*
{::mf/wrap [mf/memo]}
[{:keys [team member total-members profile]}]
(let [member-id (:id member)
on-set-admin (mf/use-fn (mf/deps member-id) (partial set-role! member-id :admin))
on-set-editor (mf/use-fn (mf/deps member-id) (partial set-role! member-id :editor))
on-set-viewer (mf/use-fn (mf/deps member-id) (partial set-role! member-id :viewer))
owner? (dm/get-in team [:permissions :is-owner])
on-set-owner
(mf/use-fn
(mf/deps member)
(fn [member _event]
(let [params {:type :confirm
:title (tr "modals.promote-owner-confirm.title")
:message (tr "modals.promote-owner-confirm.message" (:name member))
:scd-message (tr "modals.promote-owner-confirm.hint")
:accept-label (tr "modals.promote-owner-confirm.accept")
:on-accept (partial set-role! member-id :owner)
:accept-style :primary}]
(st/emit! (modal/show params)))))
on-success
(mf/use-fn #(rx/of (dcm/go-to-dashboard-recent :team-id :default)))
on-error
(mf/use-fn
(fn [{:keys [code] :as error}]
(condp = code
:no-enough-members-for-leave
(rx/of (ntf/error (tr "errors.team-leave.insufficient-members")))
:member-does-not-exist
(rx/of (ntf/error (tr "errors.team-leave.member-does-not-exists")))
:owner-cant-leave-team
(rx/of (ntf/error (tr "errors.team-leave.owner-cant-leave")))
(rx/throw error))))
on-delete-accepted
(mf/use-fn
(mf/deps team on-success on-error)
(fn []
(st/emit! (dtm/delete-team (with-meta team {:on-success on-success
:on-error on-error})))))
on-leave-accepted
(mf/use-fn
(mf/deps on-success on-error)
(fn [member-id]
(let [params (cond-> {} (uuid? member-id) (assoc :reassign-to member-id))]
(st/emit! (dtm/leave-current-team (with-meta params
{:on-success on-success
:on-error on-error}))))))
on-leave-and-close
(mf/use-fn
(mf/deps on-delete-accepted)
(fn []
(st/emit! (modal/show
{:type :confirm
:title (tr "modals.leave-confirm.title")
:message (tr "modals.leave-and-close-confirm.message" (:name team))
:scd-message (tr "modals.leave-and-close-confirm.hint")
:accept-label (tr "modals.leave-confirm.accept")
:on-accept on-delete-accepted}))))
on-change-owner-and-leave
(mf/use-fn
(mf/deps profile team on-leave-accepted)
(fn []
(st/emit! (dtm/fetch-members)
(modal/show
{:type :leave-and-reassign
:profile profile
:team team
:accept on-leave-accepted}))))
on-leave
(mf/use-fn
(mf/deps on-leave-accepted)
(fn []
(st/emit! (modal/show
{:type :confirm
:title (tr "modals.leave-confirm.title")
:message (tr "modals.leave-confirm.message")
:accept-label (tr "modals.leave-confirm.accept")
:on-accept on-leave-accepted}))))
on-delete
(mf/use-fn
(mf/deps member-id)
(fn []
(let [on-accept #(st/emit! (dtm/delete-member {:member-id member-id}))
params {:type :confirm
:title (tr "modals.delete-team-member-confirm.title")
:message (tr "modals.delete-team-member-confirm.message")
:accept-label (tr "modals.delete-team-member-confirm.accept")
:on-accept on-accept}]
(st/emit! (modal/show params)))))
on-leave'
(cond (= 1 total-members) on-leave-and-close
(= true owner?) on-change-owner-and-leave
:else on-leave)]
[:div {:class (stl/css :table-row)}
[:div {:class (stl/css :table-field :field-name)}
[:& member-info {:member member :profile profile}]]
[:div {:class (stl/css :table-field :field-roles)}
[:> rol-info* {:member member
:team team
:on-set-admin on-set-admin
:on-set-editor on-set-editor
:on-set-viewer on-set-viewer
:on-set-owner on-set-owner
:profile profile}]]
[:div {:class (stl/css :table-field :field-actions)}
[:> member-actions* {:member member
:profile profile
:team team
:on-delete on-delete
:on-leave on-leave'}]]]))
(mf/defc team-members*
{::mf/private true}
[{:keys [team profile]}]
(let [members (get team :members)
total-members
(count members)
owner
(mf/with-memo [members]
(d/seek :is-owner members))
members
(mf/with-memo [team]
(->> (:members team)
(sort-by :created-at)
(remove :is-owner)
(vec)))]
[:div {:class (stl/css :dashboard-table :team-members)}
[:div {:class (stl/css :table-header)}
[:div {:class (stl/css :table-field :title-field-name)} (tr "labels.member")]
[:div {:class (stl/css :table-field :title-field-role)} (tr "labels.role")]]
[:div {:class (stl/css :table-rows)}
[:> team-member*
{:member owner
:team team
:profile profile
:total-members total-members}]
(for [item members]
[:> team-member*
{:member item
:team team
:profile profile
:key (dm/str (:id item))
:total-members total-members}])]]))
(mf/defc team-members-page*
[{:keys [team profile]}]
(mf/with-effect [team]
(dom/set-html-title
(tr "title.team-members"
(if (:is-default team)
(tr "dashboard.your-penpot")
(:name team)))))
(mf/with-effect [team]
(st/emit! (dtm/fetch-members)))
[:*
[:& header {:section :dashboard-team-members
:team team
:profile profile}]
[:section {:class (stl/css :dashboard-container :dashboard-team-members)}
[:> team-members*
{:profile profile
:team team}]
(when (and (contains? cfg/flags :subscriptions)
(show-subscription-members-banner? team profile))
[:> members-cta* {:team team}])]])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INVITATIONS SECTION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(mf/defc invitation-role-selector*
[{:keys [can-invite role status on-change]}]
(let [show? (mf/use-state false)
label (cond
(= role :owner) (tr "labels.owner")
(= role :admin) (tr "labels.admin")
(= role :editor) (tr "labels.editor")
:else (tr "labels.viewer"))
on-hide (mf/use-fn #(reset! show? false))
on-show (mf/use-fn #(reset! show? true))
on-change'
(mf/use-fn
(mf/deps on-change)
(fn [event]
(let [role (-> (dom/get-current-target event)
(dom/get-data "role")
(keyword))]
(on-change role event))))]
[:*
(if (and can-invite (= status :pending))
[:div {:class (stl/css :rol-selector :has-priv)
:on-click on-show}
[:span {:class (stl/css :rol-label)} label]
arrow-icon]
[:div {:class (stl/css :rol-selector)}
[:span {:class (stl/css :rol-label)} label]])
[:& dropdown {:show @show? :on-close on-hide :dropdown-id "invitation-role-selector"}
[:ul {:class (stl/css :roles-dropdown)}
[:li {:data-role "admin"
:class (stl/css :rol-dropdown-item)
:on-click on-change'}
(tr "labels.admin")]
[:li {:data-role "editor"
:class (stl/css :rol-dropdown-item)
:on-click on-change'}
(tr "labels.editor")]
[:li {:data-role "viewer"
:class (stl/css :rol-dropdown-item)
:on-click on-change'}
(tr "labels.viewer")]]]]))
(mf/defc invitation-actions*
{::mf/private true}
[{:keys [invitation team-id]}]
(let [email (:email invitation)
copied* (mf/use-state false)
copied? (deref copied*)
on-error
(mf/use-fn
(mf/deps email)
(fn [cause]
(let [{:keys [type code] :as error} (ex-data cause)]
(cond
(and (= :validation type)
(= :profile-is-muted code))
(rx/of (ntf/error (tr "errors.profile-is-muted")))
(and (= :validation type)
(= :member-is-muted code))
(rx/of (ntf/error (tr "errors.member-is-muted")))
(and (= :restriction type)
(or (= :email-has-permanent-bounces code)
(= :email-has-complaints code)))
(rx/of (ntf/error (tr "errors.email-has-permanent-bounces" email)))
:else
(rx/throw cause)))))
on-copy-success
(mf/use-fn
(fn []
(reset! copied* true)
(tm/schedule 1000 #(reset! copied* false))
(st/emit! (ntf/success (tr "notifications.invitation-link-copied"))
(modal/hide))))
on-copy
(mf/use-fn
(mf/deps email team-id)
(fn []
(let [params (with-meta {:email email :team-id team-id}
{:on-success on-copy-success
:on-error on-error})]
(st/emit!
(-> (dtm/copy-invitation-link params)
(with-meta {::ev/origin :team}))))))]
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.copy-invitation-link")
:on-click on-copy
:icon (if copied? "tick" "clipboard")}]))
(mf/defc invitation-row*
{::mf/wrap [mf/memo]
::mf/private true}
[{:keys [invitation can-invite team-id selected on-select-change]}]
(let [expired? (:expired invitation)
email (:email invitation)
role (:role invitation)
status (if expired? :expired :pending)
type (if expired? :warning :default)
badge-content
(if (= status :expired)
(tr "labels.expired-invitation")
(tr "labels.pending-invitation"))
is-selected? (fn [email]
(contains? @selected email))
on-change
(mf/use-fn
(mf/deps can-invite on-select-change)
(fn [event]
(when can-invite
(let [email (-> (dom/get-current-target event)
(dom/get-data "attr"))]
(on-select-change email)))))
on-change-role
(mf/use-fn
(mf/deps email team-id)
(fn [role _event]
(let [params {:email email :team-id team-id :role role}
mdata {:on-success #(st/emit! (dtm/fetch-invitations))}]
(st/emit! (dtm/update-invitation-role (with-meta params mdata))))))]
[:div {:class (stl/css :table-row :table-row-invitations)}
[:div {:class (stl/css :table-field :field-email)}
(if can-invite
[:div {:class (stl/css :input-wrapper)}
[:label
[:span {:class (stl/css-case :input-checkbox true
:global/checked (is-selected? email))}
deprecated-icon/status-tick]
[:input {:type "checkbox"
:id (dm/str "email-" email)
:data-attr email
:value email
:checked (is-selected? email)
:on-change on-change}]
email]]
[:div email])]
[:div {:class (stl/css :table-field :field-roles)}
[:> invitation-role-selector*
{:can-invite can-invite
:role role
:status status
:on-change on-change-role}]]
[:div {:class (stl/css :table-field :field-status)}
[:& badge-notification {:type type :content badge-content}]]
[:div {:class (stl/css :table-field :field-actions)}
(when ^boolean can-invite
[:> invitation-actions*
{:invitation invitation
:team-id team-id}])]]))
(mf/defc empty-invitation-table*
{::mf/private true}
[{:keys [can-invite team]}]
(let
[route (mf/deref refs/route)
invite-email (-> route :query-params :invite-email)
on-invite-member (mf/use-fn
(mf/deps team invite-email)
(fn []
(st/emit! (dtm/check-and-invite-members {:team-id (:id team)
:origin :team
:invite-email invite-email}))))]
[:div {:class (stl/css :empty-invitations)}
[:div (tr "labels.no-invitations")]
(if ^boolean can-invite
[[:div (tr "labels.no-invitations-gather-people")]
[:div {:class (stl/css :empty-invitations-buttons)}
[:a
{:class (stl/css :btn-empty-invitations)
:on-click on-invite-member
:data-testid "invite-member"}
(tr "dashboard.invite-profile")]]
[:div {:class (stl/css :blank-space)}]]
[:div {:class (stl/css :no-permission-text)} (tr "dashboard.invitations.no-permission")])]))
(mf/defc invitation-modal
{::mf/register modal/components
::mf/register-as :invitation-modal}
[{:keys [selected delete on-confirm]}]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-invitation-container :modal-container)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)}
(if delete
(tr "dashboard.invitation-modal.title.delete-invitations")
(tr "dashboard.invitation-modal.title.resend-invitations"))]
[:button {:class (stl/css :modal-close-btn)
:on-click modal/hide!} deprecated-icon/close]]
[:div {:class (stl/css :modal-invitation-content)}
[:p
(if delete
(tr "dashboard.invitation-modal.delete")
(tr "dashboard.invitation-modal.resend"))]
[:div {:class (stl/css :invitation-list)}
(for [{:keys [email role]} selected]
[:p {:key email}
(str "- " email " (" (tr (str "labels." (name role))) ")")])]]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons :modal-invitation-action-buttons)}
(when-not delete
[:> button*
{:class (stl/css :cancel-button)
:variant "secondary"
:type "button"
:on-click modal/hide!}
(tr "labels.cancel")])
[:> button*
{:class (stl/css :accept-btn)
:variant "primary"
:type "button"
:on-click on-confirm}
(if delete
(tr "labels.continue")
(tr "labels.resend"))]]]]])
(def schema:organization-form [:map {:title "SelectOrgForm"}
[:selected-id ::sm/uuid]])
(mf/defc render-org-combobox-avatar*
[{:keys [avatar]}]
[:> org-avatar* {:org (:organization avatar)
:size (:size avatar)}])
(mf/defc select-organization-modal
{::mf/register modal/components
::mf/register-as :select-organization-modal}
[{:keys [organizations orgs-allowed current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key]}]
(let [valid-organizations (mf/with-memo [organizations]
(remove #(= (:id %) current-organization-id) organizations))
options (mf/with-memo [valid-organizations orgs-allowed]
(mapv (fn [organization]
(let [org-id (:id organization)
;; orgs-allowed is a map of org-id and a boolean indicating if it is allowed
enabled? (or (nil? orgs-allowed)
(true? (get orgs-allowed org-id)))]
(cond-> {:id (str org-id)
:label (:name organization)
:disabled (not enabled?)
:dimmed (not enabled?)
:avatar {:render-fn render-org-combobox-avatar*
:organization organization
:size "xl"}}
(not enabled?)
(assoc :title (tr "dashboard.team-organization.disabled-org-tooltip")))))
valid-organizations))
form (fm/use-form :schema schema:organization-form :initial {})
on-change
(mf/use-fn
(mf/deps form)
(fn [id]
(uforms/on-input-change form :selected-id id)))
on-confirm'
(mf/use-fn
(mf/deps on-confirm form)
(fn []
(on-confirm (dm/get-in @form [:clean-data :selected-id]))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-select-org-container :modal-container)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-select-org-title)}
(tr title-key)]
[:button {:class (stl/css :modal-close-btn)
:on-click modal/hide!} deprecated-icon/close]]
(when text-key
[:div {:class (stl/css :modal-content :modal-select-org-text)} (tr text-key)])
[:div
(when info-message-key
[:div {:class (stl/css :modal-select-org-info)}
(tr info-message-key)])
[:div {:class (stl/css :modal-select-org-content)}
(tr choose-key)]
[:> combobox* {:id "selected-id"
:class (stl/css :team-member)
:options options
:select-only true
:default-selected (or (some-> (get-in @form [:data :selected-id]) str) "")
:placeholder (tr placeholder-key)
:on-change on-change}]]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons :modal-invitation-action-buttons)}
[:> button*
{:class (stl/css :cancel-button)
:variant "secondary"
:type "button"
:on-click modal/hide!}
(tr cancel-key)]
[:> button*
{:class (stl/css :accept-btn)
:variant "primary"
:type "button"
:disabled (not (:valid @form))
:on-click on-confirm'}
(tr accept-key)]]]]]))
(mf/defc invitation-section*
{::mf/private true}
[{:keys [team profile]}]
(let [permissions (get team :permissions)
invitations (mf/use-state (get team :invitations))
team-id (get team :id)
can-invite? (dnt/can-send-invitations?
{:organization (:organization team)
:profile-id (:id profile)
:team-permissions permissions})
selected (mf/use-state #{})
;; Sort state: {:field :status/:role, :direction :asc/:desc}
sort-state (mf/use-state {:field nil :direction :asc})
selected-invitations (mf/with-memo [selected invitations]
(filterv #(contains? @selected (:email %)) @invitations))
on-select-change
(mf/use-fn
(mf/deps can-invite? selected)
(fn [email]
(when can-invite?
(if (contains? @selected email)
(swap! selected disj email)
(swap! selected conj email)))))
on-confirm-delete
(mf/use-fn
(mf/deps selected team-id)
(fn []
(doseq [email @selected]
(let [params {:email email :team-id team-id}
mdata {:on-success #(st/emit! (ntf/success (tr "notifications.invitation-deleted"))
(dtm/fetch-invitations)
(modal/hide))}]
(st/emit! (dtm/delete-invitation (with-meta params mdata)))))
(reset! selected #{})))
on-delete
(mf/use-fn
(mf/deps selected-invitations team-id)
(fn []
(st/emit! (modal/show :invitation-modal {:selected selected-invitations :delete true :on-confirm on-confirm-delete}))))
on-error
(fn [form]
(let [{:keys [type code] :as error} (ex-data form)]
(cond
(and (= :validation type)
(= :profile-is-muted code))
(st/emit! (ntf/error (tr "errors.profile-is-muted"))
(modal/hide))
(and (= :validation type)
(= :max-invitations-by-request code))
(st/emit! (ntf/error (tr "errors.maximum-invitations-by-request-reached" (:threshold error))))
(and (= :restriction type)
(= :max-quote-reached code))
(st/emit! (ntf/error (tr "errors.max-quote-reached" (:target error))))
(or (= :member-is-muted code)
(= :email-has-permanent-bounces code)
(= :email-has-complaints code))
(st/emit! (ntf/error (tr "errors.email-spam-or-permanent-bounces" (:email error))))
:else
(st/emit! (ntf/error (tr "errors.generic"))
(modal/hide)))))
on-resend-success
(mf/use-fn
(fn []
(st/emit! (ntf/success (tr "notifications.invitation-email-sent"))
(modal/hide)
(dtm/fetch-invitations))
(reset! selected #{})))
on-confirm-resend
(mf/use-fn
(mf/deps selected-invitations team-id on-resend-success)
(fn []
(modal/hide!)
(let [params (with-meta {:invitations selected-invitations
:team-id team-id
:resend? true}
{:on-success on-resend-success
:on-error on-error})]
(st/emit!
(-> (dtm/create-invitations params)
(with-meta {::ev/origin :team}))))))
on-resend
(mf/use-fn
(mf/deps team-id selected-invitations)
(fn []
(st/emit! (modal/show :invitation-modal {:selected selected-invitations :on-confirm on-confirm-resend}))))
on-order-by-status
(mf/use-fn
(mf/deps sort-state)
(fn []
(let [current-field (:field @sort-state)
current-direction (:direction @sort-state)
new-direction (if (= current-field :status)
(if (= current-direction :asc) :desc :asc)
:asc)]
(swap! sort-state assoc :field :status :direction new-direction)
(swap! invitations #(let [sorted (sort-by (juxt :expired :email) %)]
(if (= new-direction :desc)
(reverse sorted)
sorted))))))
on-order-by-role
(mf/use-fn
(mf/deps sort-state)
(fn []
(let [current-field (:field @sort-state)
current-direction (:direction @sort-state)
new-direction (if (= current-field :role)
(if (= current-direction :asc) :desc :asc)
:asc)]
(swap! sort-state assoc :field :role :direction new-direction)
(swap! invitations #(let [sorted (sort-by (juxt :role :email) %)]
(if (= new-direction :desc)
(reverse sorted)
sorted))))))]
(mf/with-effect [team]
(reset! invitations (get team :invitations))
(reset! sort-state {:field nil :direction :asc}))
[:div {:class (stl/css :invitations)}
(when (and (not can-invite?)
(seq @invitations))
[:div {:class (stl/css :empty-invitations)}
[:div {:class (stl/css :no-permission-text)}
(tr "dashboard.invitations.no-permission")]])
(when (and can-invite?
(> (count @selected) 0))
[:*
[:div {:class (stl/css :invitations-actions)}
[:div
(tr "team.invitations-selected" (i18n/c (count @selected)))]
[:div
[:> button* {:variant "secondary"
:type "button"
:on-click on-resend}
(tr "labels.resend-invitation")]]
[:> icon-button* {:on-click on-delete
:variant "destructive"
:aria-label (tr "labels.delete-invitation")
:icon "delete"}]]])
[:div {:class (stl/css :table-header)}
[:div {:class (stl/css :title-field-name)} (tr "labels.invitations")]
[:div {:class (stl/css :title-field-role)} (tr "labels.role")
[:> icon-button* {:variant "action"
:class (stl/css-case :sort-active (= (:field @sort-state) :role)
:sort-inactive (not= (:field @sort-state) :role))
:aria-label (tr "dashboard.order-invitations-by-role")
:icon (if (= (:field @sort-state) :role)
(if (= (:direction @sort-state) :asc) "arrow-down" "arrow-up")
"arrow-down")
:on-click on-order-by-role}]]
[:div {:class (stl/css :title-field-status)} (tr "labels.status")
[:> icon-button* {:variant "action"
:class (stl/css-case :sort-active (= (:field @sort-state) :status)
:sort-inactive (not= (:field @sort-state) :status))
:aria-label (tr "dashboard.order-invitations-by-status")
:icon (if (= (:field @sort-state) :status)
(if (= (:direction @sort-state) :asc) "arrow-down" "arrow-up")
"arrow-down")
:on-click on-order-by-status}]]]
(if (empty? @invitations)
[:> empty-invitation-table* {:can-invite can-invite? :team team}]
[:div {:class (stl/css :table-rows)}
(for [invitation @invitations]
[:> invitation-row*
{:key (:email invitation)
:invitation invitation
:can-invite can-invite?
:team-id team-id
:selected selected
:on-select-change on-select-change}])])]))
(mf/defc team-invitations-page*
[{:keys [team profile]}]
(mf/with-effect [team]
(dom/set-html-title
(tr "title.team-invitations"
(if (:is-default team)
(tr "dashboard.your-penpot")
(:name team)))))
(mf/with-effect [(:id team) (:members team)]
(st/emit! (dtm/fetch-invitations)))
[:*
[:& header {:section :dashboard-team-invitations
:team team
:profile profile}]
[:section {:class (stl/css :dashboard-team-invitations)}
[:> invitation-section* {:team team :profile profile}]
(when (and (contains? cfg/flags :subscriptions)
(show-subscription-members-banner? team profile))
[:> members-cta* {:team team}])]])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; WEBHOOKS SECTION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:webhook-form
[:map {:title "WebhookForm"}
[:uri [::sm/uri {:max 4069 :prefix #"^http[s]?://"
:error/code "errors.webhooks.invalid-uri"}]]
[:mtype ::sm/text]])
(def valid-webhook-mtypes
[{:label "application/json" :value "application/json"}
{:label "application/transit+json" :value "application/transit+json"}])
(defn- extract-status
[error-code]
(-> error-code (str/split #":") second str/trim))
(defn- translate-error-hint
[hint]
(cond
(= hint "invalid-uri")
(tr "errors.webhooks.invalid-uri")
(= hint "ssl-validation-error")
(tr "errors.webhooks.ssl-validation")
(= hint "timeout")
(tr "errors.webhooks.timeout")
(= hint "connection-error")
(tr "errors.webhooks.connection")
(str/starts-with? hint "unexpected-status")
(tr "errors.webhooks.unexpected-status" (extract-status hint))
(str/starts-with? hint "blocked-request")
(tr "errors.webhooks.connection")
:else
(tr "errors.webhooks.unexpected")))
(mf/defc webhook-modal
{::mf/register modal/components
::mf/register-as :webhook}
[{:keys [webhook]}]
(let [initial (mf/with-memo []
(or (some-> webhook (update :uri str))
{:is-active false :mtype "application/json" :uri "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}))
form (fm/use-form :schema schema:webhook-form
:initial initial)
on-success
(mf/use-fn
(fn [_]
(let [message (tr "dashboard.webhooks.create.success")]
(rx/of (ntf/success message)
(modal/hide)))))
on-error
(mf/use-fn
(fn [form cause]
(let [{:keys [type code hint] :as error} (ex-data cause)]
(if (and (= type :validation)
(= code :webhook-validation))
(let [message (translate-error-hint hint)]
(swap! form assoc-in [:extra-errors :uri] {:message message})
(rx/empty))
(rx/throw cause)))))
on-create-submit
(mf/use-fn
(fn [form]
(let [cdata (:clean-data @form)
mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}
params {:uri (:uri cdata)
:mtype (:mtype cdata)
:is-active (:is-active cdata)}]
(st/emit! (dtm/create-webhook
(with-meta params mdata))))))
on-update-submit
(mf/use-fn
(fn [form]
(let [params (:clean-data @form)
mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}]
(st/emit! (dtm/update-webhook
(with-meta params mdata))))))
on-submit
(mf/use-fn
(fn [form]
(let [data (:clean-data @form)]
(if (:id data)
(on-update-submit form)
(on-create-submit form)))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:& fm/form {:form form :on-submit on-submit}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)}
(if webhook
(tr "modals.edit-webhook.title")
(tr "modals.create-webhook.title"))]
[:button {:class (stl/css :modal-close-btn)
:on-click modal/hide!} deprecated-icon/close]]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :fields-row)}
[:& fm/input {:type "text"
:auto-focus? true
:form form
:name :uri
:label (tr "modals.create-webhook.url.label")
:placeholder (tr "modals.create-webhook.url.placeholder")}]]
[:div {:class (stl/css :fields-row)}
[:div {:class (stl/css :select-title)} (tr "dashboard.webhooks.content-type")]
[:& fm/select {:options valid-webhook-mtypes
:default "application/json"
:name :mtype}]]
[:div {:class (stl/css :fields-row)}
[:& fm/input {:type "checkbox"
:class (stl/css :custom-input-checkbox)
:form form
:name :is-active
:label (tr "dashboard.webhooks.active")}]
[:div {:class (stl/css :hint)} (tr "dashboard.webhooks.active.explain")]]]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
[:input {:class (stl/css :cancel-button)
:type "button"
:value (tr "labels.cancel")
:on-click modal/hide!}]
[:> fm/submit-button*
{:label (if webhook
(tr "modals.edit-webhook.submit-label")
(tr "modals.create-webhook.submit-label"))}]]]]]]))
(mf/defc webhooks-hero*
[]
[:div {:class (stl/css :webhooks-hero-container)}
[:h2 {:class (stl/css :hero-title)}
(tr "labels.webhooks")]
[:> i18n/tr-html* {:class (stl/css :hero-desc)
:content (tr "dashboard.webhooks.description")}]
[:button {:class (stl/css :hero-btn)
:on-click #(st/emit! (modal/show :webhook {}))}
(tr "dashboard.webhooks.create")]])
(mf/defc webhook-actions*
{::mf/private true}
[{:keys [on-edit on-delete can-edit]}]
(let [show? (mf/use-state false)
on-show (mf/use-fn #(reset! show? true))
on-hide (mf/use-fn #(reset! show? false))]
(if can-edit
[:*
[:button {:class (stl/css :menu-btn)
:on-click on-show}
menu-icon]
[:& dropdown {:show @show? :on-close on-hide :dropdown-id "webhook-actions"}
[:ul {:class (stl/css :webhook-actions-dropdown)}
[:li {:on-click on-edit
:class (stl/css :webhook-dropdown-item)} (tr "labels.edit")]
[:li {:on-click on-delete
:class (stl/css :webhook-dropdown-item)} (tr "labels.delete")]]]]
[:span {:title (tr "dashboard.webhooks.cant-edit")
:class (stl/css :menu-disabled)}
[:> icon* {:icon-id i/menu}]])))
(mf/defc webhook-item*
{::mf/wrap [mf/memo]
::mf/private true}
[{:keys [webhook permissions]}]
(let [error-code (:error-code webhook)
id (:id webhook)
creator-id (:profile-id webhook)
profile (mf/deref refs/profile)
user-id (:id profile)
can-edit (or (:can-edit permissions)
(= creator-id user-id))
on-edit
(mf/use-fn
(mf/deps webhook)
(fn []
(st/emit! (modal/show :webhook {:webhook webhook}))))
on-delete-accepted
(mf/use-fn
(mf/deps id)
(fn []
(let [params {:id id}
mdata {:on-success #(st/emit! (dtm/fetch-webhooks))}]
(st/emit! (dtm/delete-webhook (with-meta params mdata))))))
on-delete
(mf/use-fn
(mf/deps on-delete-accepted)
(fn []
(let [params {:type :confirm
:title (tr "modals.delete-webhook.title")
:message (tr "modals.delete-webhook.message")
:accept-label (tr "modals.delete-webhook.accept")
:on-accept on-delete-accepted}]
(st/emit! (modal/show params)))))
last-delivery-text
(if (nil? error-code)
(tr "webhooks.last-delivery.success")
(dm/str (tr "errors.webhooks.last-delivery")
(cond
(= error-code "ssl-validation-error")
(dm/str " " (tr "errors.webhooks.ssl-validation"))
(str/starts-with? error-code "unexpected-status")
(dm/str " " (tr "errors.webhooks.unexpected-status" (extract-status error-code)))
:else
(dm/str " " (tr "errors.webhooks.unexpected")))))]
[:div {:class (stl/css :table-row :webhook-row)}
[:div {:class (stl/css :table-field :last-delivery)
:title last-delivery-text}
(if (nil? error-code)
success-icon
warning-icon)]
[:div {:class (stl/css :table-field :uri)}
[:div (dm/str (:uri webhook))]]
[:div {:class (stl/css :table-field :active)}
[:div (if (:is-active webhook)
(tr "labels.active")
(tr "labels.inactive"))]]
[:div {:class (stl/css :table-field :actions)}
[:> webhook-actions*
{:on-edit on-edit
:on-delete on-delete
:can-edit can-edit}]]]))
(mf/defc webhooks-list*
{::mf/private true}
[{:keys [webhooks permissions]}]
[:div {:class (stl/css :table-rows :webhook-table)}
(for [webhook webhooks]
[:> webhook-item*
{:webhook webhook
:key (dm/str (:id webhook))
:permissions permissions}])])
(mf/defc webhooks-page*
[{:keys [team]}]
(let [webhooks (:webhooks team)]
(mf/with-effect [team]
(dom/set-html-title
(tr "title.team-webhooks"
(if (:is-default team)
(tr "dashboard.your-penpot")
(:name team)))))
(mf/with-effect []
(st/emit! (dtm/fetch-webhooks)))
[:*
[:& header {:team team :section :dashboard-team-webhooks}]
[:section {:class (stl/css :dashboard-container :dashboard-team-webhooks)}
[:*
[:> webhooks-hero* {}]
(if (empty? webhooks)
[:div {:class (stl/css :webhooks-empty)}
[:div (tr "dashboard.webhooks.empty.no-webhooks")]
[:div (tr "dashboard.webhooks.empty.add-one")]]
[:> webhooks-list*
{:webhooks webhooks
:permissions (:permissions team)}])]]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SETTINGS SECTION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(mf/defc team-settings-page*
[{:keys [team]}]
(let [nitrate? (contains? cfg/flags :nitrate)
finput (mf/use-ref)
members (get team :members)
stats (get team :stats)
owner (d/seek :is-owner members)
permissions (:permissions team)
can-edit (or (:is-owner permissions)
(:is-admin permissions))
profile (mf/deref refs/profile)
profile-id (:id profile)
all-organizations (mf/deref refs/teams)
all-organizations (mf/with-memo [all-organizations]
(->> (vals all-organizations)
(filter :is-default)
(filter :organization)
(map dtm/team->organization)))
;; Filter to orgs where user is allowed to create/add teams
organizations (mf/with-memo [all-organizations profile-id]
(->> all-organizations
(filter (fn [org]
(let [perm (get-in org [:permissions :create-teams])
is-owner? (= profile-id (:owner-id org))]
(or (= perm "any") is-owner?))))))
;; Keep parity with UX requirement: hide only when user belongs to one org.
can-change-organization? (mf/with-memo [all-organizations]
(> (count all-organizations) 1))
can-add-to-organization? (mf/with-memo [organizations all-organizations]
(and (pos? (count all-organizations))
(not (:is-default team))))
show-org-options-menu*
(mf/use-state false)
show-org-options-menu?
(deref show-org-options-menu*)
on-show-options-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-org-options-menu* not)))
close-org-options-menu
(mf/use-fn #(reset! show-org-options-menu* false))
on-image-click
(mf/use-fn #(dom/click (mf/ref-val finput)))
on-file-selected
(fn [file]
(st/emit! (dtm/update-team-photo file)))
on-remove-team-from-org
(mf/use-fn
(mf/deps team)
(fn []
(st/emit! (dnt/show-remove-team-from-org-modal {:team-id (:id team)}))))
on-add-team-to-org
(mf/use-fn
(mf/deps team)
(fn []
(st/emit! (dnt/show-add-team-to-org-modal {:team-id (:id team)}))))
on-change-team-org
(mf/use-fn
(mf/deps team)
(fn []
(st/emit! (dnt/show-change-team-org-modal {:team-id (:id team)}))))]
(mf/with-effect [team]
(dom/set-html-title (tr "title.team-settings"
(if (:is-default team)
(tr "dashboard.your-penpot")
(:name team)))))
(mf/with-effect []
(st/emit! (dtm/fetch-members)
(dtm/fetch-stats)))
[:*
[:& header {:section :dashboard-team-settings :team team}]
[:section {:class (stl/css :dashboard-team-settings)}
[:div {:class (stl/css :settings-container)}
[:div {:class (stl/css :block :info-block)}
[:div {:class (stl/css :team-icon)}
(when can-edit
[:button {:class (stl/css :update-overlay)
:on-click on-image-click}
image-icon])
[:img {:class (stl/css :team-image)
:src (cfg/resolve-team-photo-url team)}]
(when can-edit
[:& file-uploader {:accept "image/jpeg,image/png"
:multi false
:ref finput
:on-selected on-file-selected}])]
[:div {:class (stl/css :block-label)}
(tr "dashboard.team-info")]
[:div {:class (stl/css :block-text)}
(:name team)]]
(when nitrate?
[:div {:class (stl/css :block)}
[:div {:class (stl/css :block-label)}
(tr "dashboard.team-organization")]
(let [organization (:organization team)]
(if organization
[:div {:class (stl/css :block-content)}
[:div {:class (stl/css :org-block-content)}
[:> org-avatar* {:org (dtm/team->organization team) :size "xxxl"}]
[:span {:class (stl/css :block-text)}
(:name organization)]
(when (and (:is-owner permissions) (not (:is-default team)))
[:*
[:> button* {:variant "ghost"
:type "button"
:class (stl/css-case :org-options-btn (not show-org-options-menu?) :org-options-btn-open show-org-options-menu?)
:on-click on-show-options-click}
org-menu-icon
[:& dropdown {:show show-org-options-menu? :on-close close-org-options-menu :dropdown-id "org-options"}
[:ul {:class (stl/css :org-dropdown)
:role "listbox"}
(when can-change-organization?
[:li {:on-click on-change-team-org
:class (stl/css :org-dropdown-item)}
(tr "dashboard.team-organization.change")])
[:li {:on-click on-remove-team-from-org
:class (stl/css :org-dropdown-item)}
(tr "dashboard.team-organization.remove")]]]]])]]
[:*
[:div {:class (stl/css :block-content)}
[:span {:class (stl/css :block-text)}
(tr "dashboard.team-organization.none")]]
(when can-add-to-organization?
[:div {:class (stl/css :block-content)}
[:span {:class (stl/css :block-text)}
[:a {:on-click on-add-team-to-org} (tr "dashboard.team-organization.add")]]])]))])
[:div {:class (stl/css :block)}
[:div {:class (stl/css :block-label)}
(tr "dashboard.team-members")]
[:div {:class (stl/css :block-content)}
[:img {:class (stl/css :owner-icon)
:src (cfg/resolve-profile-photo-url owner)}]
[:span {:class (stl/css :block-text)}
(str (:name owner) " (" (tr "labels.owner") ")")]]
[:div {:class (stl/css :block-content)}
user-icon
[:span {:class (stl/css :block-text)}
(tr "dashboard.num-of-members" (count members))]]]
[:div {:class (stl/css :block)}
[:div {:class (stl/css :block-label)}
(tr "dashboard.team-projects")]
[:div {:class (stl/css :block-content)}
group-icon
[:span {:class (stl/css :block-text)}
(tr "labels.num-of-projects" (i18n/c (dec (:projects stats))))]]
[:div {:class (stl/css :block-content)}
document-icon
[:span {:class (stl/css :block-text)}
(tr "labels.num-of-files" (i18n/c (:files stats)))]]]
(when (contains? cfg/flags :subscriptions)
[:> team* {:is-owner (:is-owner permissions) :team team}])]]]))