diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index ac1ea0b78..51522b58c 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -205,9 +205,12 @@ {:name "0065-add-trivial-spelling-fixes" :fn (mg/resource "app/migrations/sql/0065-add-trivial-spelling-fixes.sql")} - + {:name "0066-add-frame-thumbnail-table" :fn (mg/resource "app/migrations/sql/0066-add-frame-thumbnail-table.sql")} + + {:name "0067-add-team-invitation-table" + :fn (mg/resource "app/migrations/sql/0067-add-team-invitation-table.sql")} ]) diff --git a/backend/src/app/migrations/sql/0067-add-team-invitation-table.sql b/backend/src/app/migrations/sql/0067-add-team-invitation-table.sql new file mode 100644 index 000000000..b62310efc --- /dev/null +++ b/backend/src/app/migrations/sql/0067-add-team-invitation-table.sql @@ -0,0 +1,15 @@ +CREATE TABLE team_invitation ( + team_id uuid NOT NULL REFERENCES team(id) ON DELETE CASCADE, + email_to text NOT NULL, + role text NOT NULL, + valid_until timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + PRIMARY KEY(team_id, email_to) +); + + +ALTER TABLE team_invitation + ALTER COLUMN email_to SET STORAGE external, + ALTER COLUMN role SET STORAGE external; \ No newline at end of file diff --git a/backend/src/app/rpc/mutations/teams.clj b/backend/src/app/rpc/mutations/teams.clj index e6cc7288b..d33d09fb8 100644 --- a/backend/src/app/rpc/mutations/teams.clj +++ b/backend/src/app/rpc/mutations/teams.clj @@ -23,6 +23,7 @@ [app.util.services :as sv] [app.util.time :as dt] [clojure.spec.alpha :as s] + [cuerdas.core :as str] [datoteka.core :as fs])) ;; --- Helpers & Specs @@ -359,12 +360,19 @@ :role role)) nil))) +(def sql:upsert-team-invitation + "insert into team_invitation(team_id, email_to, role, valid_until) + values (?, ?, ?, ?) + on conflict(team_id, email_to) do + update set role = ?, valid_until = ?, updated_at = now();") + (defn- create-team-invitation [{:keys [conn tokens team profile role email] :as cfg}] (let [member (profile/retrieve-profile-data-by-email conn email) + token-exp (dt/in-future "48h") itoken (tokens :generate {:iss :team-invitation - :exp (dt/in-future "48h") + :exp token-exp :profile-id (:id profile) :role role :team-id (:id team) @@ -386,6 +394,10 @@ :code :email-has-permanent-bounces :hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce")) + + (db/exec-one! conn [sql:upsert-team-invitation + (:id team) (str/lower email) (name role) token-exp (name role) token-exp]) + (eml/send! {::eml/conn conn ::eml/factory eml/invite-to-team :public-uri (:public-uri cfg) @@ -418,3 +430,24 @@ :email email :role role))) team))) + + +;; --- Mutation: Update invitation role + +(s/def ::update-team-invitation-role + (s/keys :req-un [::profile-id ::team-id ::email ::role])) + +(sv/defmethod ::update-team-invitation-role + [{:keys [pool] :as cfg} {:keys [profile-id team-id email role] :as params}] + (db/with-atomic [conn pool] + (let [perms (teams/get-permissions conn profile-id team-id) + team (db/get-by-id conn :team team-id)] + + (when-not (:is-admin perms) + (ex/raise :type :validation + :code :insufficient-permissions)) + + (db/update! conn :team-invitation + {:role (name role) :updated-at (dt/now)} + {:team-id (:id team) :email-to (str/lower email)}) + nil))) diff --git a/backend/src/app/rpc/mutations/verify_token.clj b/backend/src/app/rpc/mutations/verify_token.clj index 106580316..3eb268ffd 100644 --- a/backend/src/app/rpc/mutations/verify_token.clj +++ b/backend/src/app/rpc/mutations/verify_token.clj @@ -13,8 +13,9 @@ [app.metrics :as mtx] [app.rpc.mutations.teams :as teams] [app.rpc.queries.profile :as profile] - [app.util.services :as sv] - [clojure.spec.alpha :as s])) + [app.util.services :as sv] + [clojure.spec.alpha :as s] + [cuerdas.core :as str])) (defmulti process-token (fn [_ _ claims] (:iss claims))) @@ -100,11 +101,18 @@ :opt-un [:internal.tokens.team-invitation/member-id])) (defn- accept-invitation - [{:keys [conn] :as cfg} {:keys [member-id team-id role] :as claims}] - (let [params (merge {:team-id team-id + [{:keys [conn] :as cfg} {:keys [member-id team-id role member-email] :as claims}] + (let [ + member (profile/retrieve-profile conn member-id) + invitation (db/get-by-params conn :team-invitation + {:team-id team-id :email-to (str/lower member-email)} + {:check-not-found false}) + ;; Update the role if there is an invitation + role (or (some-> invitation :role keyword) role) + params (merge {:team-id team-id :profile-id member-id} (teams/role->params role)) - member (profile/retrieve-profile conn member-id)] + ] ;; Insert the invited member to the team (db/insert! conn :team-profile-rel params {:on-conflict-do-nothing true}) @@ -115,7 +123,12 @@ (db/update! conn :profile {:is-active true} {:id member-id})) - (assoc member :is-active true))) + (assoc member :is-active true) + + ;; Delete the invitation + (db/delete! conn :team-invitation + {:team-id team-id :email-to (str/lower member-email)}))) + (defmethod process-token :team-invitation [{:keys [session] :as cfg} {:keys [profile-id token]} {:keys [member-id] :as claims}] diff --git a/backend/src/app/rpc/queries/teams.clj b/backend/src/app/rpc/queries/teams.clj index 49fbd66c1..75b568cfb 100644 --- a/backend/src/app/rpc/queries/teams.clj +++ b/backend/src/app/rpc/queries/teams.clj @@ -229,3 +229,20 @@ (defn retrieve-team-stats [conn team-id] (db/exec-one! conn [sql:team-stats team-id team-id])) + + +;; --- Query: Team invitations + +(s/def ::team-id ::us/uuid) +(s/def ::team-invitations + (s/keys :req-un [::profile-id ::team-id])) + +(def sql:team-invitations + "select email_to as email, role, (valid_until < now()) as expired from team_invitation where team_id = ?") + + +(sv/defmethod ::team-invitations + [{:keys [pool] :as cfg} {:keys [profile-id team-id]}] + (with-open [conn (db/open pool)] + (check-read-permissions! conn profile-id team-id) + (db/exec! conn [sql:team-invitations team-id]))) diff --git a/backend/test/app/services_teams_test.clj b/backend/test/app/services_teams_test.clj index 4124325b3..ef7f34d4d 100644 --- a/backend/test/app/services_teams_test.clj +++ b/backend/test/app/services_teams_test.clj @@ -35,12 +35,19 @@ ;; invite external user without complaints (let [data (assoc data :email "foo@bar.com") - out (th/mutation! data)] + out (th/mutation! data) + ;;retrieve the value from the database and check its content + invitation (db/exec-one! + th/*pool* + ["select count(*) as num from team_invitation where team_id = ? and email_to = ?" + (:team-id data) "foo@bar.com"])] ;; (th/print-result! out) (t/is (nil? (:result out))) - (t/is (= 1 (:call-count (deref mock))))) + (t/is (= 1 (:call-count (deref mock)))) + (t/is (= 1 (:num invitation)))) + ;; invite internal user without complaints (th/reset-mock! mock) @@ -159,4 +166,61 @@ +(t/deftest query-team-invitations + (let [prof (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id prof)}) + data {::th/type :team-invitations + :profile-id (:id prof) + :team-id (:id team)}] + ;;insert an entry on the database with an enabled invitation + (db/insert! th/*pool* :team-invitation + {:team-id (:team-id data) + :email-to "test1@mail.com" + :role "editor" + :valid-until (dt/in-future "48h")}) + + + ;;insert an entry on the database with an expired invitation + (db/insert! th/*pool* :team-invitation + {:team-id (:team-id data) + :email-to "test2@mail.com" + :role "editor" + :valid-until (dt/in-past "48h")}) + + (let [out (th/query! data)] + (t/is (nil? (:error out))) + (let [result (:result out) + one (first result) + two (second result)] + (t/is (= 2 (count result))) + (t/is (= "test1@mail.com" (:email one))) + (t/is (= "test2@mail.com" (:email two))) + (t/is (false? (:expired one))) + (t/is (true? (:expired two))))))) + + +(t/deftest update-team-invitation-role + (let [prof (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id prof)}) + data {::th/type :update-team-invitation-role + :profile-id (:id prof) + :team-id (:id team) + :email "TEST1@mail.com" + :role :admin}] + + ;;insert an entry on the database with an invitation + (db/insert! th/*pool* :team-invitation + {:team-id (:team-id data) + :email-to "test1@mail.com" + :role "editor" + :valid-until (dt/in-future "48h")}) + + (let [out (th/mutation! data) + ;;retrieve the value from the database and check its content + result (db/get-by-params th/*pool* :team-invitation + {:team-id (:team-id data) :email-to "test1@mail.com"} + {:check-not-found false})] + (t/is (nil? (:error out))) + (t/is (nil? (:result out))) + (t/is (= "admin" (:role result))))))