diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index 462e86c625..f99f2d4300 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -58,7 +58,9 @@ (assoc response :cookies {cookie-name {:path "/" :http-only true :value id - :same-site (if cors? :none :strict) + :same-site (cond (not secure?) :lax + cors? :none + :else :strict) :secure secure?}}))) (defn- clear-cookies diff --git a/backend/src/app/rpc/mutations/demo.clj b/backend/src/app/rpc/mutations/demo.clj index 8072b38e28..12786757f5 100644 --- a/backend/src/app/rpc/mutations/demo.clj +++ b/backend/src/app/rpc/mutations/demo.clj @@ -36,7 +36,8 @@ :is-active true :deleted-at (dt/in-future cf/deletion-delay) :password password - :props {:onboarding-viewed true}}] + :props {} + }] (when-not (contains? cf/flags :demo-users) (ex/raise :type :validation diff --git a/backend/src/app/rpc/mutations/profile.clj b/backend/src/app/rpc/mutations/profile.clj index fb53e9d945..cd621763e3 100644 --- a/backend/src/app/rpc/mutations/profile.clj +++ b/backend/src/app/rpc/mutations/profile.clj @@ -335,9 +335,9 @@ ;; --- MUTATION: Logout (s/def ::logout - (s/keys :req-un [::profile-id])) + (s/keys :opt-un [::profile-id])) -(sv/defmethod ::logout +(sv/defmethod ::logout {:auth false} [{:keys [session] :as cfg} _] (with-meta {} {:transform-response (:delete session)})) diff --git a/backend/src/app/rpc/mutations/teams.clj b/backend/src/app/rpc/mutations/teams.clj index ab71fbcb28..ef65882157 100644 --- a/backend/src/app/rpc/mutations/teams.clj +++ b/backend/src/app/rpc/mutations/teams.clj @@ -104,24 +104,53 @@ ;; --- Mutation: Leave Team +(declare role->params) + +(s/def ::reassign-to ::us/uuid) (s/def ::leave-team - (s/keys :req-un [::profile-id ::id])) + (s/keys :req-un [::profile-id ::id] + :opt-un [::reassign-to])) (sv/defmethod ::leave-team - [{:keys [pool] :as cfg} {:keys [id profile-id] :as params}] + [{:keys [pool] :as cfg} {:keys [id profile-id reassign-to]}] (db/with-atomic [conn pool] (let [perms (teams/get-permissions conn profile-id id) members (teams/retrieve-team-members conn id)] - (when (:is-owner perms) + (cond + ;; we can only proceed if there are more members in the team + ;; besides the current profile + (<= (count members) 1) + (ex/raise :type :validation + :code :no-enough-members-for-leave + :context {:members (count members)}) + + ;; if the `reassign-to` is filled and has a different value + ;; than the current profile-id, we proceed to reassing the + ;; owner role to profile identified by the `reassign-to`. + (and reassign-to (not= reassign-to profile-id)) + (let [member (d/seek #(= reassign-to (:id %)) members)] + (when-not member + (ex/raise :type :not-found :code :member-does-not-exist)) + + ;; unasign owner role to current profile + (db/update! conn :team-profile-rel + {:is-owner false} + {:team-id id + :profile-id profile-id}) + + ;; assign owner role to new profile + (db/update! conn :team-profile-rel + (role->params :owner) + {:team-id id :profile-id reassign-to})) + + ;; and finally, if all other conditions does not match and the + ;; current profile is owner, we dont allow it because there + ;; must always be an owner. + (:is-owner perms) (ex/raise :type :validation :code :owner-cant-leave-team - :hint "reasing owner before leave")) - - (when-not (> (count members) 1) - (ex/raise :type :validation - :code :cant-leave-team - :context {:members (count members)})) + :hint "releasing owner before leave")) (db/delete! conn :team-profile-rel {:profile-id profile-id @@ -129,7 +158,6 @@ nil))) - ;; --- Mutation: Delete Team (s/def ::delete-team @@ -156,7 +184,6 @@ ;; --- Mutation: Team Update Role (declare retrieve-team-member) -(declare role->params) (s/def ::team-id ::us/uuid) (s/def ::member-id ::us/uuid) diff --git a/backend/src/app/rpc/queries/profile.clj b/backend/src/app/rpc/queries/profile.clj index b68c6a5f17..a3ca758f5e 100644 --- a/backend/src/app/rpc/queries/profile.clj +++ b/backend/src/app/rpc/queries/profile.clj @@ -37,10 +37,15 @@ (sv/defmethod ::profile {:auth false} [{:keys [pool] :as cfg} {:keys [profile-id] :as params}] - (if profile-id - (retrieve-profile pool profile-id) - {:id uuid/zero - :fullname "Anonymous User"})) + + ;; We need to return the anonymous profile object in two cases, when + ;; no profile-id is in session, and when db call raises not found. In all other + ;; cases we need to reraise the exception. + (or (ex/try* + #(some->> profile-id (retrieve-profile pool)) + #(when (not= :not-found (:type (ex-data %))) (throw %))) + {:id uuid/zero + :fullname "Anonymous User"})) (def ^:private sql:default-profile-team "select t.id, name diff --git a/backend/src/app/rpc/queries/teams.clj b/backend/src/app/rpc/queries/teams.clj index 4072372374..49fbd66c15 100644 --- a/backend/src/app/rpc/queries/teams.clj +++ b/backend/src/app/rpc/queries/teams.clj @@ -21,8 +21,10 @@ tpr.is_admin, tpr.can_edit from team_profile_rel as tpr + join team as t on (t.id = tpr.team_id) where tpr.profile_id = ? - and tpr.team_id = ?") + and tpr.team_id = ? + and t.deleted_at is null") (defn get-permissions [conn profile-id team-id] diff --git a/backend/test/app/services_profile_test.clj b/backend/test/app/services_profile_test.clj index b51bcd8c0f..ba82c0f0e8 100644 --- a/backend/test/app/services_profile_test.clj +++ b/backend/test/app/services_profile_test.clj @@ -6,6 +6,7 @@ (ns app.services-profile-test (:require + [app.common.uuid :as uuid] [app.db :as db] [app.rpc.mutations.profile :as profile] [app.test-helpers :as th] @@ -153,11 +154,8 @@ :profile-id (:id prof)} out (th/query! params)] ;; (th/print-result! out) - (let [error (:error out) - error-data (ex-data error)] - (t/is (th/ex-info? error)) - (t/is (= (:type error-data) :not-found)))) - )) + (let [result (:result out)] + (t/is (= uuid/zero (:id result))))))) (t/deftest registration-domain-whitelist (let [whitelist #{"gmail.com" "hey.com" "ya.ru"}] diff --git a/backend/test/app/services_teams_test.clj b/backend/test/app/services_teams_test.clj index 6e2aaeea7b..4124325b3b 100644 --- a/backend/test/app/services_teams_test.clj +++ b/backend/test/app/services_teams_test.clj @@ -33,7 +33,6 @@ :role :editor :profile-id (:id profile1)}] - ;; invite external user without complaints (let [data (assoc data :email "foo@bar.com") out (th/mutation! data)] @@ -136,9 +135,10 @@ :profile-id (:id profile1)} out (th/query! data)] ;; (th/print-result! out) - (t/is (nil? (:error out))) - (let [result (:result out)] - (t/is (= 0 (count result))))) + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= (:type error-data) :not-found)))) ;; run permanent deletion (let [result (task {:max-age (dt/duration 0)})] diff --git a/frontend/resources/images/form/adobe-xd.png b/frontend/resources/images/form/adobe-xd.png new file mode 100644 index 0000000000..f2946ae396 Binary files /dev/null and b/frontend/resources/images/form/adobe-xd.png differ diff --git a/frontend/resources/images/form/figma.png b/frontend/resources/images/form/figma.png new file mode 100644 index 0000000000..5e3bccb1a7 Binary files /dev/null and b/frontend/resources/images/form/figma.png differ diff --git a/frontend/resources/images/form/invision.png b/frontend/resources/images/form/invision.png new file mode 100644 index 0000000000..551b8e2c52 Binary files /dev/null and b/frontend/resources/images/form/invision.png differ diff --git a/frontend/resources/images/form/never-used.png b/frontend/resources/images/form/never-used.png new file mode 100644 index 0000000000..cda0947ecf Binary files /dev/null and b/frontend/resources/images/form/never-used.png differ diff --git a/frontend/resources/images/form/sketch.png b/frontend/resources/images/form/sketch.png new file mode 100644 index 0000000000..b923c607c8 Binary files /dev/null and b/frontend/resources/images/form/sketch.png differ diff --git a/frontend/resources/images/form/use-for-1.jpg b/frontend/resources/images/form/use-for-1.jpg new file mode 100644 index 0000000000..b1fa3da55e Binary files /dev/null and b/frontend/resources/images/form/use-for-1.jpg differ diff --git a/frontend/resources/images/form/use-for-2.jpg b/frontend/resources/images/form/use-for-2.jpg new file mode 100644 index 0000000000..449ab4d9f1 Binary files /dev/null and b/frontend/resources/images/form/use-for-2.jpg differ diff --git a/frontend/resources/images/form/use-for-3.jpg b/frontend/resources/images/form/use-for-3.jpg new file mode 100644 index 0000000000..1d8c242d76 Binary files /dev/null and b/frontend/resources/images/form/use-for-3.jpg differ diff --git a/frontend/resources/images/form/use-for-4.jpg b/frontend/resources/images/form/use-for-4.jpg new file mode 100644 index 0000000000..140c6539dc Binary files /dev/null and b/frontend/resources/images/form/use-for-4.jpg differ diff --git a/frontend/resources/images/form/uxpin.png b/frontend/resources/images/form/uxpin.png new file mode 100644 index 0000000000..02b5d93314 Binary files /dev/null and b/frontend/resources/images/form/uxpin.png differ diff --git a/frontend/resources/styles/main-default.scss b/frontend/resources/styles/main-default.scss index 5801d9c0b4..53d0baedf1 100644 --- a/frontend/resources/styles/main-default.scss +++ b/frontend/resources/styles/main-default.scss @@ -89,3 +89,4 @@ @import "main/partials/handoff"; @import "main/partials/exception-page"; @import "main/partials/share-link"; +@import "main/partials/af-signup-questions"; diff --git a/frontend/resources/styles/main/partials/af-signup-questions.scss b/frontend/resources/styles/main/partials/af-signup-questions.scss new file mode 100644 index 0000000000..b88300583e --- /dev/null +++ b/frontend/resources/styles/main/partials/af-signup-questions.scss @@ -0,0 +1,197 @@ +// 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) UXBOX Labs SL + +.af-form { + background-color: $color-white; + color: $color-gray-60 !important; + max-width: 760px !important; + overflow-y: auto; + padding: 3rem; + width: 100% !important; + + h1, h3 { + font-family: 'worksans', sans-serif !important; + margin-bottom: .8rem; + font-weight: 500 !important; + } + + h1 { + font-size: $fs38; + } + + strong { + font-weight: 500; + } + + p, label { + font-family: 'worksans', sans-serif !important; + font-size: $fs14; + } + + form { + max-width: 760px; + width: 100%; + } + + button { + font-family: 'worksans', sans-serif !important; + } + + .af-choice, + .af-choice-multiple { + display: flex; + flex-wrap: wrap; + } + + .af-choice-option { + max-width: 33%; + width: 100%; + + label { + font-family: 'worksans', sans-serif !important; + font-size: $fs14; + padding-left: 0; + } + } + + .af-choice-multiple { + .af-choice-option { + max-width: 50%; + width: 100%; + } + } + + .af-divider-block { + /* margin-bottom: 2rem; */ + + p { + &::after, + &::before { + border-color: transparent; + } + } + } + + .af-dropdown-text, + .text { + font-family: 'worksans', sans-serif !important; + } + + .af-step-next { + display: flex; + margin-top: 2rem; + } + + .af-step-next button { + color: $color-black; + background-color: $color-primary; + max-width: 180px; + margin-left: auto; + } + + .af-step-previous { + margin-top: -40px; + } + + .af-step-button { + text-align: left; + } + + .af-field-input { + margin: 0.5rem 0; + } + + .af-choice-option input:checked+label:before, + .af-legal input:checked+label:before { + background-color: $color-primary; + } + + .af-field-use_of_penpot .af-choice-option input:checked+label, + .af-field-previous_design_tool .af-choice-option input:checked+label { + &::before { + background-color: transparent; + border: 2px solid $color-primary; + } + } + + .af-field-use_of_penpot .af-choice-option label { + padding-top: 6rem; + background-size: 120px; + min-height: 150px; + } + + .af-field-use_of_penpot .af-choice-option:nth-child(1) label { + background-image: url("../images/form/use-for-1.jpg"); + } + .af-field-use_of_penpot .af-choice-option:nth-child(2) label { + background-image: url("../images/form/use-for-2.jpg"); + } + .af-field-use_of_penpot .af-choice-option:nth-child(3) label { + background-image: url("../images/form/use-for-3.jpg"); + } + .af-field-use_of_penpot .af-choice-option:nth-child(4) label { + background-image: url("../images/form/use-for-4.jpg"); + } + + .af-field-use_of_penpot label, + .af-field-previous_design_tool label { + display: flex; + padding-top: 5rem; + justify-content: center; + background-size: 50px; + background-repeat: no-repeat; + background-position: center 1rem; + margin: 1rem !important; + min-height: 130px; + position: relative; + text-align: center; + + &:hover { + background-color: transparent; + box-shadow: 0px 10px 20px rgba(0,0,0,.2); + } + + &::before { + background-color: transparent; + border-radius: 4px; + min-width: 100%; + min-height: 100%; + position: absolute; + top: 0; + left: 0; + margin: 0; + } + + &::after { + display: none !important; + } + } + + .af-field-previous_design_tool .af-choice-option:nth-child(1) label { + background-image: url("../images/form/figma.png"); + + } + + .af-field-previous_design_tool .af-choice-option:nth-child(2) label { + background-image: url("../images/form/sketch.png"); + } + + .af-field-previous_design_tool .af-choice-option:nth-child(3) label { + background-image: url("../images/form/adobe-xd.png"); + } + + .af-field-previous_design_tool .af-choice-option:nth-child(4) label { + background-image: url("../images/form/uxpin.png"); + } + + .af-field-previous_design_tool .af-choice-option:nth-child(5) label { + background-image: url("../images/form/invision.png"); + } + + .af-field-previous_design_tool .af-choice-option:nth-child(6) label { + background-image: url("../images/form/never-used.png"); + } +} diff --git a/frontend/resources/styles/main/partials/exception-page.scss b/frontend/resources/styles/main/partials/exception-page.scss index d46261f89c..f0815818b1 100644 --- a/frontend/resources/styles/main/partials/exception-page.scss +++ b/frontend/resources/styles/main/partials/exception-page.scss @@ -12,6 +12,7 @@ display: flex; align-items: center; padding: 32px; + z-index: 1000; cursor: pointer; diff --git a/frontend/resources/styles/main/partials/modal.scss b/frontend/resources/styles/main/partials/modal.scss index bff16696cb..6e73287067 100644 --- a/frontend/resources/styles/main/partials/modal.scss +++ b/frontend/resources/styles/main/partials/modal.scss @@ -859,23 +859,23 @@ background-position: left top; background-size: 11%; } - + .modal-left:hover { background-image: url("/images/on-solo-hover.svg"); background-size: 15%; } - + .modal-right { background-image: url("/images/on-teamup.svg"); background-position: right top; background-size: 28%; } - + .modal-right:hover { background-image: url("/images/on-teamup-hover.svg"); background-size: 32%; } - + .modal-right, .modal-left { background-repeat: no-repeat; @@ -1001,17 +1001,17 @@ .template-item { width: 275px; border: 1px solid $color-gray-10; - + display: flex; flex-direction: column; text-align: left; border-radius: $br-small; - + &:not(:last-child) { margin-bottom: 22px; } } - + .template-item-content { // height: 144px; flex-grow: 1; @@ -1020,7 +1020,7 @@ border-radius: $br-small $br-small 0 0; } } - + .template-item-title { padding: 6px 12px; height: 64px; @@ -1135,3 +1135,49 @@ } } + + + +.questions-form { + .modal-overlay { + z-index: 2001; + } + + .modal-container { + background-image: url("../images/deco-left.png"), url("../images/deco-right.png"); + background-repeat: no-repeat; + background-position: 10% 50px, 90% 50px; + background-size: 65px; + display: flex; + flex-direction: row; + height: 100vh; + justify-content: center; + width: 100vw; + + .af-form { + --primary-color: #00C38B; + --input-background-color: #ffffff; + --label-font-size: $fs16; + --field-error-font-color: #E65244; + --message-success-font-color: #49D793; + --message-fail-font-color: #E65244; + --invalid-field-border-color: #E65244; + --dropdown-background-color: #ffffff; + --primary-font-color: #000; + --input-border-color: rgb(224, 230, 240); + --input-border-radius: 3px; + --button-border-radius: 3px; + --message-border-radius: 3px; + --checkbox-border-radius: 3px; + --dropdown-option-background-color: rgba(0,195,139,1); + --dropdown-option-active-background-color: rgba(0,138,98,1); + --invalid-field-background-color: rgba(238.51780000000002,205.7178,204.11780000000002,1); + --message-fail-background-color: rgba(238.51780000000002,205.7178,204.11780000000002,1); + --message-success-background-color: rgba(171,232,197,1); + } + } + + .modal-overlay { + background-color: rgba(0,0,0,0.9); + } +} diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 3dbe3e1277..975688c3af 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -78,6 +78,7 @@ (def translations (obj/get global "penpotTranslations")) (def themes (obj/get global "penpotThemes")) (def sentry-dsn (obj/get global "penpotSentryDsn")) +(def onboarding-form-id (obj/get global "penpotOnboardingQuestionsFormId")) (def flags (atom (parse-flags global))) (def version (atom (parse-version global))) diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 982e268f3d..6f728bbe2d 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -16,6 +16,7 @@ [app.main.repo :as rp] [app.util.i18n :as i18n :refer [tr]] [app.util.router :as rt] + [app.util.timers :as tm] [beicon.core :as rx] [cljs.spec.alpha :as s] [potok.core :as ptk])) @@ -60,6 +61,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (declare fetch-projects) +(declare fetch-team-members) (defn initialize [{:keys [id] :as params}] @@ -84,6 +86,7 @@ (rx/merge (ptk/watch (df/load-team-fonts id) state stream) (ptk/watch (fetch-projects) state stream) + (ptk/watch (fetch-team-members) state stream) (ptk/watch (du/fetch-teams) state stream) (ptk/watch (du/fetch-users {:team-id id}) state stream))))) @@ -237,13 +240,14 @@ (update :dashboard-files d/merge files)))))) (defn fetch-recent-files - [] - (ptk/reify ::fetch-recent-files - ptk/WatchEvent - (watch [_ state _] - (let [team-id (:current-team-id state)] - (->> (rp/query :team-recent-files {:team-id team-id}) - (rx/map recent-files-fetched)))))) + ([] (fetch-recent-files nil)) + ([team-id] + (ptk/reify ::fetch-recent-files + ptk/WatchEvent + (watch [_ state _] + (let [team-id (or team-id (:current-team-id state))] + (->> (rp/query :team-recent-files {:team-id team-id}) + (rx/map recent-files-fetched))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Data Selection @@ -396,16 +400,13 @@ (let [{:keys [on-success on-error] :or {on-success identity on-error rx/throw}} (meta params) - team-id (:current-team-id state)] - (rx/concat - (when (uuid? reassign-to) - (->> (rp/mutation! :update-team-member-role {:team-id team-id - :role :owner - :member-id reassign-to}) - (rx/ignore))) - (->> (rp/mutation! :leave-team {:id team-id}) - (rx/tap on-success) - (rx/catch on-error))))))) + team-id (:current-team-id state) + params (cond-> {:id team-id} + (uuid? reassign-to) + (assoc :reassign-to reassign-to))] + (->> (rp/mutation! :leave-team params) + (rx/tap #(tm/schedule on-success)) + (rx/catch on-error)))))) (defn invite-team-member [{:keys [email role] :as params}] diff --git a/frontend/src/app/main/data/users.cljs b/frontend/src/app/main/data/users.cljs index 3356c0ae90..271c133a43 100644 --- a/frontend/src/app/main/data/users.cljs +++ b/frontend/src/app/main/data/users.cljs @@ -7,12 +7,12 @@ (ns app.main.data.users (:require [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.spec :as us] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.events :as ev] [app.main.data.media :as di] - [app.main.data.modal :as modal] [app.main.repo :as rp] [app.util.i18n :as i18n] [app.util.router :as rt] @@ -93,6 +93,8 @@ ;; --- EVENT: fetch-profile +(declare logout) + (def profile-fetched? (ptk/type? ::profile-fetched)) @@ -105,18 +107,18 @@ ptk/UpdateEvent (update [_ state] - (-> state - (assoc :profile-id id) - (assoc :profile profile))) + (cond-> state + (is-authenticated? profile) + (-> (assoc :profile-id id) + (assoc :profile profile)))) ptk/EffectEvent (effect [_ state _] - (let [profile (:profile state)] - (when (not= uuid/zero (:id profile)) - (swap! storage assoc :profile profile) - (i18n/set-locale! (:lang profile)) - (some-> (:theme profile) - (theme/set-current-theme!))))))) + (when-let [profile (:profile state)] + (swap! storage assoc :profile profile) + (i18n/set-locale! (:lang profile)) + (some-> (:theme profile) + (theme/set-current-theme!)))))) (defn fetch-profile [] @@ -145,55 +147,84 @@ (rx/mapcat (fn [profile] (if (= uuid/zero (:id profile)) (rx/empty) - (rx/of (fetch-teams)))))))))) + (rx/of (fetch-teams))))) + (rx/observe-on :async)))))) ;; --- EVENT: login (defn- logged-in + "This is the main event that is executed once we have logged in + profile. The profile can proceed from standard login or from + accepting invitation, or third party auth signup or singin." [profile] - (ptk/reify ::logged-in - IDeref - (-deref [_] profile) + (letfn [(get-redirect-event [] + (let [team-id (:default-team-id profile)] + (rt/nav' :dashboard-projects {:team-id team-id})))] - ptk/WatchEvent - (watch [_ _ _] - (let [team-id (get-current-team-id profile)] - (->> (rx/concat - (rx/of (profile-fetched profile) - (fetch-teams)) + (ptk/reify ::logged-in + IDeref + (-deref [_] profile) - (->> (rx/of (rt/nav' :dashboard-projects {:team-id team-id})) - (rx/delay 1000)) - - (when-not (get-in profile [:props :onboarding-viewed]) - (->> (rx/of (modal/show {:type :onboarding})) - (rx/delay 1000)))) - - (rx/observe-on :async)))))) + ptk/WatchEvent + (watch [_ _ _] + (when (is-authenticated? profile) + (->> (rx/of (profile-fetched profile) + (fetch-teams) + (get-redirect-event)) + (rx/observe-on :async))))))) (s/def ::login-params (s/keys :req-un [::email ::password])) +(declare login-from-register) + (defn login [{:keys [email password] :as data}] (us/verify ::login-params data) (ptk/reify ::login ptk/WatchEvent - (watch [_ _ _] + (watch [_ _ stream] (let [{:keys [on-error on-success] :or {on-error rx/throw on-success identity}} (meta data) params {:email email :password password :scope "webapp"}] - (->> (rx/timer 100) - (rx/mapcat #(rp/mutation :login params)) - (rx/tap on-success) - (rx/catch on-error) - (rx/map (fn [profile] - (with-meta profile - {::ev/source "login"}))) - (rx/map logged-in)))))) + + ;; NOTE: We can't take the profile value from login because + ;; there are cases when login is successfull but the cookie is + ;; not set properly (because of possible misconfiguration). + ;; So, we proceed to make an additional call to fetch the + ;; profile, and ensure that cookie is set correctly. If + ;; profile fetch is successful, we mark the user logged in, if + ;; the returned profile is an NOT authenticated profile, we + ;; proceed to logout and show an error message. + + (rx/merge + (->> (rp/mutation :login params) + (rx/map fetch-profile) + (rx/catch on-error)) + + (->> stream + (rx/filter profile-fetched?) + (rx/take 1) + (rx/map deref) + (rx/filter (complement is-authenticated?)) + (rx/tap on-error) + (rx/map #(ex/raise :type :authentication)) + (rx/observe-on :async)) + + (->> stream + (rx/filter profile-fetched?) + (rx/take 1) + (rx/map deref) + (rx/filter is-authenticated?) + (rx/map (fn [profile] + (with-meta profile + {::ev/source "login"}))) + (rx/tap on-success) + (rx/map logged-in) + (rx/observe-on :async))))))) (defn login-from-token [{:keys [profile] :as tdata}] @@ -221,44 +252,46 @@ (rx/map (fn [profile] (with-meta profile {::ev/source "register"}))) - (rx/map logged-in)))))) + (rx/map logged-in) + (rx/observe-on :async)))))) ;; --- EVENT: logout (defn logged-out - [] - (ptk/reify ::logged-out - ptk/UpdateEvent - (update [_ state] - (select-keys state [:route :router :session-id :history])) + ([] (logged-out {})) + ([_params] + (ptk/reify ::logged-out + ptk/UpdateEvent + (update [_ state] + (select-keys state [:route :router :session-id :history])) - ptk/WatchEvent - (watch [_ _ _] - (rx/of (rt/nav :auth-login))) + ptk/WatchEvent + (watch [_ _ _] + ;; NOTE: We need the `effect` of the current event to be + ;; executed before the redirect. + (->> (rx/of (rt/nav :auth-login)) + (rx/observe-on :async))) - ptk/EffectEvent - (effect [_ _ _] - (reset! storage {}) - (i18n/reset-locale)))) + ptk/EffectEvent + (effect [_ _ _] + (reset! storage {}) + (i18n/reset-locale))))) (defn logout - [] - (ptk/reify ::logout - ptk/WatchEvent - (watch [_ _ _] - (->> (rp/mutation :logout) - (rx/delay-at-least 300) - (rx/catch (constantly (rx/of 1))) - (rx/map logged-out))))) + ([] (logout {})) + ([params] + (ptk/reify ::logout + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/mutation :logout) + (rx/delay-at-least 300) + (rx/catch (constantly (rx/of 1))) + (rx/map #(logged-out params))))))) ;; --- EVENT: register -;; TODO: remove -(s/def ::invitation-token ::us/not-empty-string) - (s/def ::register - (s/keys :req-un [::fullname ::password ::email] - :opt-un [::invitation-token])) + (s/keys :req-un [::fullname ::password ::email])) (defn register "Create a register event instance." @@ -347,20 +380,33 @@ (rx/empty))) (rx/ignore)))))) - (defn mark-onboarding-as-viewed ([] (mark-onboarding-as-viewed nil)) ([{:keys [version]}] (ptk/reify ::mark-oboarding-as-viewed ptk/WatchEvent - (watch [_ state _] + (watch [_ _ _] (let [version (or version (:main @cf/version)) - props (-> (get-in state [:profile :props]) - (assoc :onboarding-viewed true) - (assoc :release-notes-viewed version))] + props {:onboarding-viewed true + :release-notes-viewed version}] (->> (rp/mutation :update-profile-props {:props props}) (rx/map (constantly (fetch-profile))))))))) + +(defn mark-questions-as-answered + [] + (ptk/reify ::mark-questions-as-answered + ptk/UpdateEvent + (update [_ state] + (update-in state [:profile :props] assoc :onboarding-questions-answered true)) + + ptk/WatchEvent + (watch [_ _ _] + (let [props {:onboarding-questions-answered true}] + (->> (rp/mutation :update-profile-props {:props props}) + (rx/map (constantly (fetch-profile)))))))) + + ;; --- Update Photo (defn update-photo diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 39350c180e..ba8ca731b7 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -13,6 +13,7 @@ [app.main.data.users :as du] [app.main.sentry :as sentry] [app.main.store :as st] + [app.util.i18n :refer [tr]] [app.util.router :as rt] [app.util.timers :as ts] [cljs.pprint :refer [pprint]] @@ -48,7 +49,9 @@ ;; here and not in app.main.errors because of circular dependency. (defmethod ptk/handle-error :authentication [_] - (ts/schedule (st/emitf (du/logout)))) + (let [msg (tr "errors.auth.unable-to-login")] + (st/emit! (du/logout {:capture-redirect true})) + (ts/schedule 500 (st/emitf (dm/warn msg))))) ;; That are special case server-errors that should be treated diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 515deca043..9c1d79c451 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -6,6 +6,7 @@ (ns app.main.ui (:require + [app.config :as cf] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.auth :refer [auth]] @@ -17,6 +18,8 @@ [app.main.ui.icons :as i] [app.main.ui.messages :as msgs] [app.main.ui.onboarding] + [app.main.ui.onboarding.questions] + [app.main.ui.releases] [app.main.ui.render :as render] [app.main.ui.settings :as settings] [app.main.ui.static :as static] @@ -32,7 +35,7 @@ (mf/defc main-page {::mf/wrap [#(mf/catch % {:fallback on-main-error})]} - [{:keys [route] :as props}] + [{:keys [route profile]}] (let [{:keys [data params]} route] [:& (mf/provider ctx/current-route) {:value route} (case (:name data) @@ -70,13 +73,32 @@ :dashboard-font-providers :dashboard-team-members :dashboard-team-settings) + [:* #_[:div.modal-wrapper #_[:& app.main.ui.onboarding/onboarding-templates-modal] - [:& app.main.ui.onboarding/onboarding-modal] + #_[:& app.main.ui.onboarding/onboarding-modal] #_[:& app.main.ui.onboarding/onboarding-team-modal] ] - [:& dashboard {:route route}]] + (when-let [props (some-> profile (get :props {}))] + (cond + (and cf/onboarding-form-id + (not (:onboarding-questions-answered props false)) + (not (:onboarding-viewed props false))) + + [:& app.main.ui.onboarding.questions/questions + {:profile profile + :form-id cf/onboarding-form-id}] + + (not (:onboarding-viewed props)) + [:& app.main.ui.onboarding/onboarding-modal {}] + + (and (:onboarding-viewed props) + (not= (:release-notes-viewed props) (:main @cf/version)) + (not= "0.0" (:main @cf/version))) + [:& app.main.ui.releases/release-notes-modal {}])) + + [:& dashboard {:route route :profile profile}]] :viewer (let [{:keys [query-params path-params]} route @@ -124,12 +146,14 @@ (mf/defc app [] - (let [route (mf/deref refs/route) - edata (mf/deref refs/exception)] + (let [route (mf/deref refs/route) + edata (mf/deref refs/exception) + profile (mf/deref refs/profile)] [:& (mf/provider ctx/current-route) {:value route} - (if edata - [:& static/exception-page {:data edata}] - [:* - [:& msgs/notifications] - (when route - [:& main-page {:route route}])])])) + [:& (mf/provider ctx/current-profile) {:value profile} + (if edata + [:& static/exception-page {:data edata}] + [:* + [:& msgs/notifications] + (when route + [:& main-page {:route route :profile profile}])])]])) diff --git a/frontend/src/app/main/ui/auth/recovery_request.cljs b/frontend/src/app/main/ui/auth/recovery_request.cljs index e5a16089f2..31c1eb2302 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.cljs +++ b/frontend/src/app/main/ui/auth/recovery_request.cljs @@ -30,8 +30,7 @@ (mf/use-callback (fn [_ _] (reset! submitted false) - (st/emit! (dm/info (tr "auth.notifications.recovery-token-sent")) - (rt/nav :auth-login)))) + (st/emit! (dm/info (tr "auth.notifications.recovery-token-sent"))))) on-error (mf/use-callback diff --git a/frontend/src/app/main/ui/context.cljs b/frontend/src/app/main/ui/context.cljs index e91e4e82af..4fd5d6a1fc 100644 --- a/frontend/src/app/main/ui/context.cljs +++ b/frontend/src/app/main/ui/context.cljs @@ -15,8 +15,9 @@ ;; for text shapes in the export process (def text-plain-colors-ctx (mf/create-context false)) -(def current-route (mf/create-context nil)) -(def current-team-id (mf/create-context nil)) +(def current-route (mf/create-context nil)) +(def current-profile (mf/create-context nil)) +(def current-team-id (mf/create-context nil)) (def current-project-id (mf/create-context nil)) -(def current-page-id (mf/create-context nil)) -(def current-file-id (mf/create-context nil)) +(def current-page-id (mf/create-context nil)) +(def current-file-id (mf/create-context nil)) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 1292c6eafb..500cb83548 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -7,9 +7,7 @@ (ns app.main.ui.dashboard (:require [app.common.spec :as us] - [app.config :as cf] [app.main.data.dashboard :as dd] - [app.main.data.modal :as modal] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] @@ -22,7 +20,6 @@ [app.main.ui.dashboard.search :refer [search-page]] [app.main.ui.dashboard.sidebar :refer [sidebar]] [app.main.ui.dashboard.team :refer [team-settings-page team-members-page]] - [app.util.timers :as tm] [rumext.alpha :as mf])) (defn ^boolean uuid-str? @@ -77,9 +74,8 @@ nil)]) (mf/defc dashboard - [{:keys [route] :as props}] - (let [profile (mf/deref refs/profile) - section (get-in route [:data :name]) + [{:keys [route profile] :as props}] + (let [section (get-in route [:data :name]) params (parse-params route) project-id (:project-id params) @@ -94,18 +90,8 @@ (mf/use-effect (mf/deps team-id) - (st/emitf (dd/initialize {:id team-id}))) - - (mf/use-effect - (mf/deps) (fn [] - (let [props (:props profile) - version (:release-notes-viewed props)] - (when (and (:onboarding-viewed props) - (not= version (:main @cf/version)) - (not= "0.0" (:main @cf/version))) - (tm/schedule 1000 #(st/emit! (modal/show {:type :release-notes - :version (:main @cf/version)}))))))) + (st/emit! (dd/initialize {:id team-id})))) [:& (mf/provider ctx/current-team-id) {:value team-id} [:& (mf/provider ctx/current-project-id) {:value project-id} diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index 531df875e4..4ac841f2fd 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -115,7 +115,7 @@ (st/emit! (dm/success (tr "dashboard.success-move-file")))) (if (or navigate? (not= team-id current-team-id)) (st/emit! (dd/go-to-files team-id project-id)) - (st/emit! (dd/fetch-recent-files) + (st/emit! (dd/fetch-recent-files team-id) (dd/clear-selected-files)))) on-move diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index f91e1de5c0..dc181f6e4f 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -327,8 +327,9 @@ on-finish-import (mf/use-callback + (mf/deps (:id team)) (fn [] - (st/emit! (dd/fetch-recent-files) + (st/emit! (dd/fetch-recent-files (:id team)) (dd/clear-selected-files)))) import-files (use-import-file project-id on-finish-import) @@ -366,7 +367,7 @@ on-drop-success (fn [] (st/emit! (dm/success (tr "dashboard.success-move-file")) - (dd/fetch-recent-files) + (dd/fetch-recent-files (:id team)) (dd/clear-selected-files))) on-drop diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 76da2bc5e7..d25620b048 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -97,9 +97,10 @@ on-import (mf/use-callback + (mf/deps (:id project) (:id team)) (fn [] (st/emit! (dd/fetch-files {:project-id (:id project)}) - (dd/fetch-recent-files) + (dd/fetch-recent-files (:id team)) (dd/clear-selected-files))))] [:div.dashboard-project-row {:class (when first? "first")} @@ -163,15 +164,15 @@ (mf/use-effect (mf/deps team) (fn [] - (when team - (let [tname (if (:is-default team) - (tr "dashboard.your-penpot") - (:name team))] - (dom/set-html-title (tr "title.dashboard.projects" tname)))))) + (let [tname (if (:is-default team) + (tr "dashboard.your-penpot") + (:name team))] + (dom/set-html-title (tr "title.dashboard.projects" tname))))) (mf/use-effect + (mf/deps (:id team)) (fn [] - (st/emit! (dd/fetch-recent-files) + (st/emit! (dd/fetch-recent-files (:id team)) (dd/clear-selected-files)))) (when (seq projects) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index fef197689b..4190887617 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -28,6 +28,7 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.object :as obj] [app.util.router :as rt] + [beicon.core :as rx] [cljs.spec.alpha :as s] [goog.functions :as f] [rumext.alpha :as mf])) @@ -287,27 +288,39 @@ members-map (mf/deref refs/dashboard-team-members) members (vals members-map) - on-rename-clicked - (st/emitf (modal/show :team-form {:team team})) - - on-leaved-success - (fn [] - (st/emit! (modal/hide) - (du/fetch-teams))) - - leave-fn - (st/emitf (dd/leave-team (with-meta {} {:on-success on-leaved-success}))) - - leave-and-reassign-fn - (fn [member-id] - (let [params {:reassign-to member-id}] - (st/emit! (dd/go-to-projects (:default-team-id profile)) - (dd/leave-team (with-meta params {:on-success on-leaved-success}))))) - - delete-fn + on-success (fn [] (st/emit! (dd/go-to-projects (:default-team-id profile)) - (dd/delete-team (with-meta team {:on-success on-leaved-success})))) + (modal/hide) + (du/fetch-teams))) + + on-error + (fn [{:keys [code] :as error}] + (condp = code + :no-enough-members-for-leave + (rx/of (dm/error (tr "errors.team-leave.insufficient-members"))) + + :member-does-not-exist + (rx/of (dm/error (tr "errors.team-leave.member-does-not-exists"))) + + :owner-cant-leave-team + (rx/of (dm/error (tr "errors.team-leave.owner-cant-leave"))) + + (rx/throw error))) + + leave-fn + (fn [member-id] + (let [params (cond-> {} (uuid? member-id) (assoc :reassign-to member-id))] + (st/emit! (dd/leave-team (with-meta params + {:on-success on-success + :on-error on-error}))))) + delete-fn + (fn [] + (st/emit! (dd/delete-team (with-meta team {:on-success on-success + :on-error on-error})))) + on-rename-clicked + (fn [] + (st/emit! (modal/show :team-form {:team team}))) on-leave-clicked (st/emitf (modal/show @@ -324,7 +337,7 @@ {:type ::leave-and-reassign :profile profile :team team - :accept leave-and-reassign-fn}))) + :accept leave-fn}))) on-delete-clicked (st/emitf @@ -501,7 +514,7 @@ [:li {:on-click (partial on-click :settings-password)} [:span.icon i/lock] [:span.text (tr "labels.password")]] - [:li {:on-click (partial on-click (du/logout))} + [:li {:on-click #(on-click (du/logout) %)} [:span.icon i/exit] [:span.text (tr "labels.logout")]] diff --git a/frontend/src/app/main/ui/onboarding.cljs b/frontend/src/app/main/ui/onboarding.cljs index d70f633411..0f31f117d3 100644 --- a/frontend/src/app/main/ui/onboarding.cljs +++ b/frontend/src/app/main/ui/onboarding.cljs @@ -6,32 +6,16 @@ (ns app.main.ui.onboarding (:require - [app.common.spec :as us] [app.config :as cf] - [app.main.data.dashboard :as dd] - [app.main.data.messages :as dm] [app.main.data.modal :as modal] [app.main.data.users :as du] - [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.components.forms :as fm] - [app.main.ui.icons :as i] + [app.main.ui.onboarding.questions] + [app.main.ui.onboarding.team-choice] + [app.main.ui.onboarding.templates] [app.main.ui.releases.common :as rc] - [app.main.ui.releases.v1-10] - [app.main.ui.releases.v1-4] - [app.main.ui.releases.v1-5] - [app.main.ui.releases.v1-6] - [app.main.ui.releases.v1-7] - [app.main.ui.releases.v1-8] - [app.main.ui.releases.v1-9] - [app.util.dom :as dom] - [app.util.http :as http] [app.util.i18n :as i18n :refer [tr]] - [app.util.object :as obj] - [app.util.router :as rt] [app.util.timers :as tm] - [beicon.core :as rx] - [cljs.spec.alpha :as s] [rumext.alpha :as mf])) ;; --- ONBOARDING LIGHTBOX @@ -189,297 +173,3 @@ :slide @slide :navigate navigate :skip skip)))]])) - -(s/def ::name ::us/not-empty-string) -(s/def ::team-form - (s/keys :req-un [::name])) - -(mf/defc onboarding-choice-modal - {::mf/register modal/components - ::mf/register-as :onboarding-choice} - [] - (let [;; When user choices the option of `fly solo`, we proceed to show - ;; the onboarding templates modal. - on-fly-solo - (fn [] - (tm/schedule 400 #(st/emit! (modal/show {:type :onboarding-templates})))) - - ;; When user choices the option of `team up`, we proceed to show - ;; the team creation modal. - on-team-up - (fn [] - (st/emit! (modal/show {:type :onboarding-team}))) - ] - - [:div.modal-overlay - [:div.modal-container.onboarding.final.animated.fadeInUp - [:div.modal-top - [:h1 (tr "onboarding.choice.title")] - [:p (tr "onboarding.choice.desc")]] - [:div.modal-columns - [:div.modal-left - [:div.content-button {:on-click on-fly-solo} - [:h2 (tr "onboarding.choice.fly-solo")] - [:p (tr "onboarding.choice.fly-solo-desc")]]] - [:div.modal-right - [:div.content-button {:on-click on-team-up} - [:h2 (tr "onboarding.choice.team-up")] - [:p (tr "onboarding.choice.team-up-desc")]]]] - [:img.deco {:src "images/deco-left.png" :border "0"}] - [:img.deco.right {:src "images/deco-right.png" :border "0"}]]])) - -(mf/defc onboarding-team-modal - {::mf/register modal/components - ::mf/register-as :onboarding-team} - [] - (let [form (fm/use-form :spec ::team-form - :initial {}) - on-submit - (mf/use-callback - (fn [form _] - (let [tname (get-in @form [:clean-data :name])] - (st/emit! (modal/show {:type :onboarding-team-invitations :name tname})))))] - - [:div.modal-overlay - [:div.modal-container.onboarding-team - [:div.title - [:h2 (tr "onboarding.choice.team-up")] - [:p (tr "onboarding.choice.team-up-desc")]] - - [:& fm/form {:form form - :on-submit on-submit} - - [:div.team-row - [:& fm/input {:type "text" - :name :name - :label (tr "onboarding.team-input-placeholder")}]] - - [:div.buttons - [:button.btn-secondary.btn-large - {:on-click #(st/emit! (modal/show {:type :onboarding-choice}))} - (tr "labels.cancel")] - [:& fm/submit-button - {:label (tr "labels.next")}]]] - - [:img.deco {:src "images/deco-left.png" :border "0"}] - [:img.deco.right {:src "images/deco-right.png" :border "0"}]]])) - -(defn get-available-roles - [] - [{:value "editor" :label (tr "labels.editor")} - {:value "admin" :label (tr "labels.admin")}]) - -(s/def ::email ::us/email) -(s/def ::role ::us/keyword) -(s/def ::invite-form - (s/keys :req-un [::role ::email])) - -;; This is the final step of team creation, consists in provide a -;; shortcut for invite users. - -(mf/defc onboarding-team-invitations-modal - {::mf/register modal/components - ::mf/register-as :onboarding-team-invitations} - [{:keys [name] :as props}] - (let [initial (mf/use-memo (constantly - {:role "editor" - :name name})) - form (fm/use-form :spec ::invite-form - :initial initial) - - roles (mf/use-memo #(get-available-roles)) - - on-success - (mf/use-callback - (fn [_form response] - (let [project-id (:default-project-id response) - team-id (:id response)] - (st/emit! - (modal/hide) - (rt/nav :dashboard-projects {:team-id team-id})) - (tm/schedule 400 #(st/emit! - (modal/show {:type :onboarding-templates - :project-id project-id})))))) - - on-error - (mf/use-callback - (fn [_form _response] - (st/emit! (dm/error "Error on creating team.")))) - - ;; The SKIP branch only creates the team, without invitations - on-skip - (mf/use-callback - (fn [_] - (let [mdata {:on-success (partial on-success form) - :on-error (partial on-error form)} - params {:name name}] - (st/emit! (dd/create-team (with-meta params mdata)))))) - - ;; The SUBMIT branch creates the team with the invitations - on-submit - (mf/use-callback - (fn [form _] - (let [mdata {:on-success (partial on-success form) - :on-error (partial on-error form)} - params (:clean-data @form)] - (st/emit! (dd/create-team-with-invitations (with-meta params mdata))))))] - - [:div.modal-overlay - [:div.modal-container.onboarding-team - [:div.title - [:h2 (tr "onboarding.choice.team-up")] - [:p (tr "onboarding.choice.team-up-desc")]] - - [:& fm/form {:form form - :on-submit on-submit} - - [:div.invite-row - [:& fm/input {:name :email - :label (tr "labels.email")}] - [:& fm/select {:name :role - :options roles}]] - - [:div.buttons - [:button.btn-secondary.btn-large - {:on-click #(st/emit! (modal/show {:type :onboarding-choice}))} - (tr "labels.cancel")] - [:& fm/submit-button - {:label (tr "labels.create")}]] - [:div.skip-action - {:on-click on-skip} - [:div.action "Skip and invite later"]]] - [:img.deco {:src "images/deco-left.png" :border "0"}] - [:img.deco.right {:src "images/deco-right.png" :border "0"}]]])) - -(mf/defc template-item - [{:keys [name path image project-id]}] - (let [downloading? (mf/use-state false) - link (str (assoc cf/public-uri :path path)) - - on-finish-import - (fn [] - (st/emit! (dd/fetch-files {:project-id project-id}) - (dd/fetch-recent-files) - (dd/clear-selected-files))) - - open-import-modal - (fn [file] - (st/emit! (modal/show - {:type :import - :project-id project-id - :files [file] - :on-finish-import on-finish-import}))) - on-click - (fn [] - (reset! downloading? true) - (->> (http/send! {:method :get :uri link :response-type :blob :mode :no-cors}) - (rx/subs (fn [{:keys [body] :as response}] - (open-import-modal {:name name :uri (dom/create-uri body)})) - (fn [error] - (js/console.log "error" error)) - (fn [] - (reset! downloading? false))))) - ] - - [:div.template-item - [:div.template-item-content - [:img {:src image}]] - [:div.template-item-title - [:div.label name] - (if @downloading? - [:div.action "Fetching..."] - [:div.action {:on-click on-click} "+ Add to drafts"])]])) - -(mf/defc onboarding-templates-modal - {::mf/register modal/components - ::mf/register-as :onboarding-templates} - ;; NOTE: the project usually comes empty, it only comes fullfilled - ;; when a user creates a new team just after signup. - [{:keys [project-id] :as props}] - (let [close-fn (mf/use-callback #(st/emit! (modal/hide))) - profile (mf/deref refs/profile) - project-id (or project-id (:default-project-id profile))] - [:div.modal-overlay - [:div.modal-container.onboarding-templates - [:div.modal-header - [:div.modal-close-button - {:on-click close-fn} i/close]] - - [:div.modal-content - [:h3 (tr "onboarding.templates.title")] - [:p (tr "onboarding.templates.subtitle")] - - [:div.templates - [:& template-item - {:path "/github/penpot-files/Penpot-Design-system.penpot" - :image "https://penpot.app/images/libraries/cover-ds-penpot.jpg" - :name "Penpot Design System" - :project-id project-id}] - [:& template-item - {:path "/github/penpot-files/Material-Design-Kit.penpot" - :image "https://penpot.app/images/libraries/cover-material.jpg" - :name "Material Design Kit" - :project-id project-id}]]]]])) - - -;;; --- RELEASE NOTES MODAL - -(mf/defc release-notes - [{:keys [version] :as props}] - (let [slide (mf/use-state :start) - klass (mf/use-state "fadeInDown") - - navigate - (mf/use-callback #(reset! slide %)) - - next - (mf/use-callback - (mf/deps slide) - (fn [] - (if (= @slide :start) - (navigate 0) - (navigate (inc @slide))))) - - finish - (mf/use-callback - (st/emitf (modal/hide) - (du/mark-onboarding-as-viewed {:version version}))) - ] - - (mf/use-effect - (mf/deps) - (fn [] - (st/emitf (du/mark-onboarding-as-viewed {:version version})))) - - (mf/use-layout-effect - (mf/deps @slide) - (fn [] - (when (not= :start @slide) - (reset! klass "fadeIn")) - (let [sem (tm/schedule 300 #(reset! klass nil))] - (fn [] - (reset! klass nil) - (tm/dispose! sem))))) - - (rc/render-release-notes - {:next next - :navigate navigate - :finish finish - :klass klass - :slide slide - :version version}))) - -(mf/defc release-notes-modal - {::mf/wrap-props false - ::mf/register modal/components - ::mf/register-as :release-notes} - [props] - (let [versions (methods rc/render-release-notes) - version (obj/get props "version")] - (when (contains? versions version) - [:div.relnotes - [:> release-notes props]]))) - -(defmethod rc/render-release-notes "0.0" - [params] - (rc/render-release-notes (assoc params :version "1.10"))) diff --git a/frontend/src/app/main/ui/onboarding/questions.cljs b/frontend/src/app/main/ui/onboarding/questions.cljs new file mode 100644 index 0000000000..7a70b8660c --- /dev/null +++ b/frontend/src/app/main/ui/onboarding/questions.cljs @@ -0,0 +1,48 @@ +;; 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) UXBOX Labs SL + +(ns app.main.ui.onboarding.questions + "External form for onboarding questions." + (:require + [app.main.data.users :as du] + [app.main.store :as st] + [app.util.dom :as dom] + [goog.events :as ev] + [promesa.core :as p] + [rumext.alpha :as mf])) + +(defn load-arengu-sdk + [container-ref email form-id] + (letfn [(on-init [] + (when-let [container (mf/ref-val container-ref)] + (-> (.embed js/ArenguForms form-id container) + (p/then (fn [form] + (.setHiddenField ^js form "email" email)))))) + + (on-submit-success [_event] + (st/emit! (du/mark-questions-as-answered))) + ] + + (let [script (dom/create-element "script") + head (unchecked-get js/document "head") + lkey1 (ev/listen js/document "af-submitForm-success" on-submit-success)] + + (unchecked-set script "src" "https://sdk.arengu.com/forms.js") + (unchecked-set script "onload" on-init) + (dom/append-child! head script) + + (fn [] + (ev/unlistenByKey lkey1))))) + +(mf/defc questions + [{:keys [profile form-id]}] + (let [container (mf/use-ref)] + (mf/use-effect (partial load-arengu-sdk container (:email profile) form-id)) + [:div.modal-wrapper.questions-form + [:div.modal-overlay + [:div.modal-container {:ref container}]]])) + + diff --git a/frontend/src/app/main/ui/onboarding/team_choice.cljs b/frontend/src/app/main/ui/onboarding/team_choice.cljs new file mode 100644 index 0000000000..6e5961a8fa --- /dev/null +++ b/frontend/src/app/main/ui/onboarding/team_choice.cljs @@ -0,0 +1,181 @@ +;; 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) UXBOX Labs SL + +(ns app.main.ui.onboarding.team-choice + (:require + [app.common.spec :as us] + [app.main.data.dashboard :as dd] + [app.main.data.messages :as dm] + [app.main.data.modal :as modal] + [app.main.store :as st] + [app.main.ui.components.forms :as fm] + [app.util.i18n :as i18n :refer [tr]] + [app.util.router :as rt] + [app.util.timers :as tm] + [cljs.spec.alpha :as s] + [rumext.alpha :as mf])) + +(s/def ::name ::us/not-empty-string) +(s/def ::team-form + (s/keys :req-un [::name])) + +(mf/defc onboarding-choice-modal + {::mf/register modal/components + ::mf/register-as :onboarding-choice} + [] + (let [;; When user choices the option of `fly solo`, we proceed to show + ;; the onboarding templates modal. + on-fly-solo + (fn [] + (tm/schedule 400 #(st/emit! (modal/show {:type :onboarding-templates})))) + + ;; When user choices the option of `team up`, we proceed to show + ;; the team creation modal. + on-team-up + (fn [] + (st/emit! (modal/show {:type :onboarding-team}))) + ] + + [:div.modal-overlay + [:div.modal-container.onboarding.final.animated.fadeInUp + [:div.modal-top + [:h1 (tr "onboarding.welcome.title")] + [:p (tr "onboarding.welcome.desc3")]] + [:div.modal-columns + [:div.modal-left + [:div.content-button {:on-click on-fly-solo} + [:h2 (tr "onboarding.choice.fly-solo")] + [:p (tr "onboarding.choice.fly-solo-desc")]]] + [:div.modal-right + [:div.content-button {:on-click on-team-up} + [:h2 (tr "onboarding.choice.team-up")] + [:p (tr "onboarding.choice.team-up-desc")]]]] + [:img.deco {:src "images/deco-left.png" :border "0"}] + [:img.deco.right {:src "images/deco-right.png" :border "0"}]]])) + +(mf/defc onboarding-team-modal + {::mf/register modal/components + ::mf/register-as :onboarding-team} + [] + (let [form (fm/use-form :spec ::team-form + :initial {}) + on-submit + (mf/use-callback + (fn [form _] + (let [tname (get-in @form [:clean-data :name])] + (st/emit! (modal/show {:type :onboarding-team-invitations :name tname})))))] + + [:div.modal-overlay + [:div.modal-container.onboarding-team + [:div.title + [:h2 (tr "onboarding.choice.team-up")] + [:p (tr "onboarding.choice.team-up-desc")]] + + [:& fm/form {:form form + :on-submit on-submit} + + [:div.team-row + [:& fm/input {:type "text" + :name :name + :label (tr "onboarding.team-input-placeholder")}]] + + [:div.buttons + [:button.btn-secondary.btn-large + {:on-click #(st/emit! (modal/show {:type :onboarding-choice}))} + (tr "labels.cancel")] + [:& fm/submit-button + {:label (tr "labels.next")}]]] + + [:img.deco {:src "images/deco-left.png" :border "0"}] + [:img.deco.right {:src "images/deco-right.png" :border "0"}]]])) + +(defn get-available-roles + [] + [{:value "editor" :label (tr "labels.editor")} + {:value "admin" :label (tr "labels.admin")}]) + +(s/def ::email ::us/email) +(s/def ::role ::us/keyword) +(s/def ::invite-form + (s/keys :req-un [::role ::email])) + +;; This is the final step of team creation, consists in provide a +;; shortcut for invite users. + +(mf/defc onboarding-team-invitations-modal + {::mf/register modal/components + ::mf/register-as :onboarding-team-invitations} + [{:keys [name] :as props}] + (let [initial (mf/use-memo (constantly + {:role "editor" + :name name})) + form (fm/use-form :spec ::invite-form + :initial initial) + + roles (mf/use-memo #(get-available-roles)) + + on-success + (mf/use-callback + (fn [_form response] + (let [project-id (:default-project-id response) + team-id (:id response)] + (st/emit! + (modal/hide) + (rt/nav :dashboard-projects {:team-id team-id})) + (tm/schedule 400 #(st/emit! + (modal/show {:type :onboarding-templates + :project-id project-id})))))) + + on-error + (mf/use-callback + (fn [_form _response] + (st/emit! (dm/error "Error on creating team.")))) + + ;; The SKIP branch only creates the team, without invitations + on-skip + (mf/use-callback + (fn [_] + (let [mdata {:on-success (partial on-success form) + :on-error (partial on-error form)} + params {:name name}] + (st/emit! (dd/create-team (with-meta params mdata)))))) + + ;; The SUBMIT branch creates the team with the invitations + on-submit + (mf/use-callback + (fn [form _] + (let [mdata {:on-success (partial on-success form) + :on-error (partial on-error form)} + params (:clean-data @form)] + (st/emit! (dd/create-team-with-invitations (with-meta params mdata))))))] + + [:div.modal-overlay + [:div.modal-container.onboarding-team + [:div.title + [:h2 (tr "onboarding.choice.team-up")] + [:p (tr "onboarding.choice.team-up-desc")]] + + [:& fm/form {:form form + :on-submit on-submit} + + [:div.invite-row + [:& fm/input {:name :email + :label (tr "labels.email")}] + [:& fm/select {:name :role + :options roles}]] + + [:div.buttons + [:button.btn-secondary.btn-large + {:on-click #(st/emit! (modal/show {:type :onboarding-choice}))} + (tr "labels.cancel")] + [:& fm/submit-button + {:label (tr "labels.create")}]] + [:div.skip-action + {:on-click on-skip} + [:div.action "Skip and invite later"]]] + [:img.deco {:src "images/deco-left.png" :border "0"}] + [:img.deco.right {:src "images/deco-right.png" :border "0"}]]])) + diff --git a/frontend/src/app/main/ui/onboarding/templates.cljs b/frontend/src/app/main/ui/onboarding/templates.cljs new file mode 100644 index 0000000000..91a886d346 --- /dev/null +++ b/frontend/src/app/main/ui/onboarding/templates.cljs @@ -0,0 +1,88 @@ +;; 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) UXBOX Labs SL + +(ns app.main.ui.onboarding.templates + (:require + [app.config :as cf] + [app.main.data.dashboard :as dd] + [app.main.data.modal :as modal] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.icons :as i] + [app.util.dom :as dom] + [app.util.http :as http] + [app.util.i18n :as i18n :refer [tr]] + [beicon.core :as rx] + [rumext.alpha :as mf])) + +(mf/defc template-item + [{:keys [name path image project-id]}] + (let [downloading? (mf/use-state false) + link (str (assoc cf/public-uri :path path)) + + on-finish-import + (fn [] + (st/emit! (dd/fetch-recent-files))) + + open-import-modal + (fn [file] + (st/emit! (modal/show + {:type :import + :project-id project-id + :files [file] + :on-finish-import on-finish-import}))) + on-click + (fn [] + (reset! downloading? true) + (->> (http/send! {:method :get :uri link :response-type :blob :mode :no-cors}) + (rx/subs (fn [{:keys [body] :as response}] + (open-import-modal {:name name :uri (dom/create-uri body)})) + (fn [error] + (js/console.log "error" error)) + (fn [] + (reset! downloading? false))))) + ] + + [:div.template-item + [:div.template-item-content + [:img {:src image}]] + [:div.template-item-title + [:div.label name] + (if @downloading? + [:div.action "Fetching..."] + [:div.action {:on-click on-click} "+ Add to drafts"])]])) + +(mf/defc onboarding-templates-modal + {::mf/wrap-props false + ::mf/register modal/components + ::mf/register-as :onboarding-templates} + ;; NOTE: the project usually comes empty, it only comes fullfilled + ;; when a user creates a new team just after signup. + [{:keys [project-id] :as props}] + (let [close-fn (mf/use-callback #(st/emit! (modal/hide))) + profile (mf/deref refs/profile) + project-id (or project-id (:default-project-id profile))] + [:div.modal-overlay + [:div.modal-container.onboarding-templates + [:div.modal-header + [:div.modal-close-button + {:on-click close-fn} i/close]] + + [:div.modal-content + [:h3 (tr "onboarding.templates.title")] + [:p (tr "onboarding.templates.subtitle")] + + [:div.templates + [:& template-item + {:path "/github/penpot-files/Penpot-Design-system.penpot" + :image "https://penpot.app/images/libraries/cover-ds-penpot.jpg" + :name "Penpot Design System" + :project-id project-id}] + [:& template-item + {:path "/github/penpot-files/Material-Design-Kit.penpot" + :image "https://penpot.app/images/libraries/cover-material.jpg" + :name "Material Design Kit" + :project-id project-id}]]]]])) diff --git a/frontend/src/app/main/ui/releases.cljs b/frontend/src/app/main/ui/releases.cljs new file mode 100644 index 0000000000..df4706ac14 --- /dev/null +++ b/frontend/src/app/main/ui/releases.cljs @@ -0,0 +1,83 @@ +;; 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) UXBOX Labs SL + +(ns app.main.ui.releases + (:require + [app.main.data.modal :as modal] + [app.main.data.users :as du] + [app.main.store :as st] + [app.main.ui.releases.common :as rc] + [app.main.ui.releases.v1-4] + [app.main.ui.releases.v1-5] + [app.main.ui.releases.v1-6] + [app.main.ui.releases.v1-7] + [app.main.ui.releases.v1-8] + [app.main.ui.releases.v1-9] + [app.util.object :as obj] + [app.util.timers :as tm] + [rumext.alpha :as mf])) + +;;; --- RELEASE NOTES MODAL + +(mf/defc release-notes + [{:keys [version] :as props}] + (let [slide (mf/use-state :start) + klass (mf/use-state "fadeInDown") + + navigate + (mf/use-callback #(reset! slide %)) + + next + (mf/use-callback + (mf/deps slide) + (fn [] + (if (= @slide :start) + (navigate 0) + (navigate (inc @slide))))) + + finish + (mf/use-callback + (st/emitf (modal/hide) + (du/mark-onboarding-as-viewed {:version version}))) + ] + + (mf/use-effect + (mf/deps) + (fn [] + (st/emitf (du/mark-onboarding-as-viewed {:version version})))) + + (mf/use-layout-effect + (mf/deps @slide) + (fn [] + (when (not= :start @slide) + (reset! klass "fadeIn")) + (let [sem (tm/schedule 300 #(reset! klass nil))] + (fn [] + (reset! klass nil) + (tm/dispose! sem))))) + + (rc/render-release-notes + {:next next + :navigate navigate + :finish finish + :klass klass + :slide slide + :version version}))) + +(mf/defc release-notes-modal + {::mf/wrap-props false + ::mf/register modal/components + ::mf/register-as :release-notes} + [props] + (let [versions (methods rc/render-release-notes) + version (obj/get props "version")] + (when (contains? versions version) + [:div.relnotes + [:> release-notes props]]))) + +(defmethod rc/render-release-notes "0.0" + [params] + (rc/render-release-notes (assoc params :version "1.10"))) diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index fc1fe6e085..e604a4a5b2 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -6,10 +6,9 @@ (ns app.main.ui.static (:require - [app.main.data.users :as du] - [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.icons :as i] + [app.util.globals :as globals] [app.util.i18n :refer [tr]] [app.util.object :as obj] [app.util.router :as rt] @@ -19,14 +18,7 @@ {::mf/wrap-props false} [props] (let [children (obj/get props "children") - on-click (mf/use-callback - (fn [] - (let [profile (deref refs/profile)] - (if (du/is-authenticated? profile) - (let [team-id (du/get-current-team-id profile)] - (st/emit! (rt/nav :dashboard-projects {:team-id team-id}))) - (st/emit! (rt/nav :auth-login {}))))))] - + on-click (mf/use-callback #(set! (.-href globals/location) ""))] [:section.exception-layout [:div.exception-header {:on-click on-click} diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 82a94e0ed6..398961057a 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -166,7 +166,7 @@ (defn append-child! [el child] - (.appendChild el child)) + (.appendChild ^js el child)) (defn get-first-child [el] diff --git a/frontend/src/app/util/forms.cljs b/frontend/src/app/util/forms.cljs index d2b5d5ce99..c97e384fce 100644 --- a/frontend/src/app/util/forms.cljs +++ b/frontend/src/app/util/forms.cljs @@ -37,10 +37,16 @@ [& {:keys [initial] :as opts}] (let [state (mf/useState 0) render (aget state 1) - state-ref (mf/use-ref {:data (if (fn? initial) (initial) initial) - :errors {} - :touched {}}) - form (mf/use-memo #(create-form-mutator state-ref render opts))] + + get-state (mf/use-callback + (mf/deps initial) + (fn [] + {:data (if (fn? initial) (initial) initial) + :errors {} + :touched {}})) + + state-ref (mf/use-ref (get-state)) + form (mf/use-memo (mf/deps initial) #(create-form-mutator state-ref render get-state opts))] (mf/use-effect (mf/deps initial) @@ -72,7 +78,7 @@ (not= cleaned ::s/invalid)))))) (defn- create-form-mutator - [state-ref render opts] + [state-ref render get-state opts] (reify IDeref (-deref [_] @@ -80,7 +86,9 @@ IReset (-reset! [it new-value] - (mf/set-ref-val! state-ref new-value) + (if (nil? new-value) + (mf/set-ref-val! state-ref (get-state)) + (mf/set-ref-val! state-ref new-value)) (render inc)) ISwap diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index 41ac92a38e..15b55b1c5e 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -88,6 +88,7 @@ :credentials credentials :referrerPolicy "no-referrer" :signal signal}] + (-> (js/fetch (str uri) params) (p/then (fn [response] (vreset! abortable? false) diff --git a/frontend/src/app/util/router.cljs b/frontend/src/app/util/router.cljs index 27bfe4374f..f6a74e7233 100644 --- a/frontend/src/app/util/router.cljs +++ b/frontend/src/app/util/router.cljs @@ -19,17 +19,16 @@ ;; --- Router API +(defn map->Match + [data] + (r/map->Match data)) + (defn resolve ([router id] (resolve router id {} {})) ([router id path-params] (resolve router id path-params {})) ([router id path-params query-params] (when-let [match (r/match-by-name router id path-params)] - (if (empty? query-params) - (r/match->path match) - (let [query (u/map->query-string query-params)] - (-> (u/uri (r/match->path match)) - (assoc :query query) - (str))))))) + (r/match->path match query-params)))) (defn create [routes] @@ -161,7 +160,3 @@ (e/unlistenByKey key))))) (rx/take-until stoper) (rx/subs #(on-change router %))))))) - - - - diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 87c089241e..09cf0ff064 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -3245,4 +3245,16 @@ msgid "workspace.updates.update" msgstr "Update" msgid "workspace.viewport.click-to-close-path" -msgstr "Click to close the path" \ No newline at end of file +msgstr "Click to close the path" + +msgid "errors.team-leave.member-does-not-exists" +msgstr "The member you try to assign does not exist." + +msgid "errors.team-leave.owner-cant-leave" +msgstr "Owner can't leave team, you must reassign the owner role." + +msgid "errors.team-leave.insufficient-members" +msgstr "Insufficient members to leave team, you probably want to delete it." + +msgid "errors.auth.unable-to-login" +msgstr "Looks like you are not authenticated or session expired."