From 8e86416b0b20dd6694336eadf6f85c33f4e9f4cc Mon Sep 17 00:00:00 2001 From: Juanfran Date: Wed, 13 May 2026 13:15:09 +0200 Subject: [PATCH] :sparkles: Cascade owned organization deletion on account removal --- backend/src/app/nitrate.clj | 31 +++++ backend/src/app/rpc/commands/profile.clj | 35 +++++- backend/src/app/rpc/management/nitrate.clj | 27 +++++ .../rpc_management_nitrate_test.clj | 114 ++++++++++++++++++ .../app/main/ui/settings/delete_account.cljs | 53 +++++++- .../app/main/ui/settings/delete_account.scss | 82 +++++++++++++ frontend/translations/en.po | 12 ++ frontend/translations/es.po | 12 ++ 8 files changed, 359 insertions(+), 7 deletions(-) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 61f652c1ef..fe0fd8f941 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -258,6 +258,35 @@ [:vector schema:org-summary] params))) +(def ^:private schema:org-summary-counts + [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:slug ::sm/text] + [:team-count ::sm/int] + [:member-count ::sm/int]]) + +(defn- get-owned-orgs-summary-api + [cfg {:keys [profile-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :get + (str baseuri + "/api/users/" + profile-id + "/owned-organizations-summary") + [:vector schema:org-summary-counts] + params))) + +(defn- delete-owned-orgs-api + [cfg {:keys [profile-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :post + (str baseuri + "/api/users/" + profile-id + "/delete-owned-organizations") + nil params))) + (defn- set-team-org-api [cfg {:keys [organization-id team-id is-default] :as params}] (let [baseuri (cf/get :nitrate-backend-uri) @@ -385,6 +414,8 @@ :get-org-membership-by-team (partial get-org-membership-by-team-api cfg) :get-org-summary (partial get-org-summary-api cfg) :get-owned-orgs (partial get-owned-orgs-api cfg) + :get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg) + :delete-owned-orgs (partial delete-owned-orgs-api cfg) :add-profile-to-org (partial add-profile-to-org-api cfg) :remove-profile-from-org (partial remove-profile-from-org-api cfg) :remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-api cfg) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 9d81c493dc..27cef605a6 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -485,8 +485,15 @@ {:deleted-at deleted-at} {:id profile-id}) - ;; Api call to nitrate - (nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id}) + ;; Delete owned organizations on the fly (no grace period). + ;; Nitrate iterates the user's owned orgs and, per org, calls + ;; Penpot back via ::notify-organization-deletion which renames + ;; the org's teams (prefixed with "[OrgName] ", including the + ;; user's "Your Penpot" team) and soft-deletes empty ones. + (when (contains? cf/flags :nitrate) + (nitrate/call cfg :delete-owned-orgs {:profile-id profile-id}) + ;; Remove the user from any remaining org memberships. + (nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id})) ;; Schedule cascade deletion to a worker (wrk/submit! {::db/conn conn @@ -495,7 +502,6 @@ :deleted-at deleted-at :id profile-id}}) - (-> (rph/wrap nil) (rph/with-transform (session/delete-fn cfg))))) @@ -522,6 +528,29 @@ (let [editors (db/exec! cfg [sql:get-subscription-editors profile-id])] {:editors editors})) +;; --- QUERY: Owned Organizations Summary (for delete-account modal) + +(def ^:private schema:owned-organization-summary + [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:slug ::sm/text] + [:team-count ::sm/int] + [:member-count ::sm/int]]) + +(def ^:private schema:get-owned-organizations-summary-result + [:vector schema:owned-organization-summary]) + +(sv/defmethod ::get-owned-organizations-summary + "List organizations owned by the current profile with team and member counts. + Used by the delete-account modal to warn the user about cascading deletion." + {::doc/added "2.18" + ::sm/result schema:get-owned-organizations-summary-result} + [cfg {:keys [::rpc/profile-id]}] + (if (contains? cf/flags :nitrate) + (or (nitrate/call cfg :get-owned-orgs-summary {:profile-id profile-id}) []) + [])) + ;; --- HELPERS (def sql:owned-teams diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index db8e6e0e06..1bccedef3f 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -544,6 +544,33 @@ LEFT JOIN profile AS p nil)) +;; API: delete-all-org-invitations + +(def ^:private sql:delete-all-org-invitations + "DELETE FROM team_invitation AS ti + WHERE ti.org_id = ? + OR ti.team_id = ANY(?);") + +(def ^:private schema:delete-all-org-invitations-params + [:map + [:organization-id ::sm/uuid]]) + +(sv/defmethod ::delete-all-org-invitations + "Delete every pending invitation associated with an organization (org-level + team-level). + Called from Nitrate when an organization is about to be deleted, so users that click + their invitation token hit the existing invalid-token landing page." + {::doc/added "2.18" + ::sm/params schema:delete-all-org-invitations-params + ::rpc/auth false} + [cfg {:keys [organization-id]}] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) + team-ids (->> (:teams org-summary) + (map :id))] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (db/exec! conn [sql:delete-all-org-invitations organization-id ids-array])))) + nil)) + ;; API: remove-from-org diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index c5de0bf6c4..246b85302a 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -561,6 +561,120 @@ (t/is (= (:id outside-team) (:team-id (first remaining-target)))) (t/is (= 1 (count remaining-other)))))) +(t/deftest delete-all-org-invitations-removes-org-and-org-team-invitations + (let [profile (th/create-profile* 1 {:is-active true}) + team-1 (th/create-team* 1 {:profile-id (:id profile)}) + team-2 (th/create-team* 2 {:profile-id (:id profile)}) + outside-team (th/create-team* 3 {:profile-id (:id profile)}) + org-id (uuid/random) + org-summary {:id org-id + :teams [{:id (:id team-1)} + {:id (:id team-2)}]} + params {::th/type :delete-all-org-invitations + :organization-id org-id}] + + ;; Should be deleted: org-level invitation. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to "alice@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + ;; Should be deleted: team-level invitation in team-1 (belongs to org). + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to "bob@example.com" + :created-by (:id profile) + :role "admin" + :valid-until (ct/in-future "48h")}) + + ;; Should be deleted: team-level invitation in team-2 (belongs to org), + ;; even if expired. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "carol@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-past "1h")}) + + ;; Should remain: invitation to a team outside the org. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id outside-team) + :org-id nil + :email-to "dan@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + ;; Should remain: invitation to a different organization. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id (uuid/random) + :team-id nil + :email-to "erin@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [calls (atom []) + out (with-redefs [nitrate/call (fn [_cfg method params] + (swap! calls conj {:method method :params params}) + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + present? (fn [email] (seq (th/db-query :team-invitation {:email-to email})))] + (t/is (th/success? out)) + (t/is (nil? (:result out))) + + ;; get-org-summary was called with the right organization-id. + (t/is (= 1 (count @calls))) + (t/is (= :get-org-summary (-> @calls first :method))) + (t/is (= {:organization-id org-id} (-> @calls first :params))) + + ;; Org-level + team-in-org invitations are deleted. + (t/is (not (present? "alice@example.com"))) + (t/is (not (present? "bob@example.com"))) + (t/is (not (present? "carol@example.com"))) + + ;; Invitations outside the org survive. + (t/is (present? "dan@example.com")) + (t/is (present? "erin@example.com"))))) + +(t/deftest delete-all-org-invitations-handles-org-with-no-teams + (let [profile (th/create-profile* 1 {:is-active true}) + org-id (uuid/random) + params {::th/type :delete-all-org-invitations + :organization-id org-id}] + + ;; Org-level invitation should still be deleted. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to "alice@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary {:id org-id :teams []} + nil))] + (management-command-with-nitrate! params)) + remaining (th/db-query :team-invitation {:org-id org-id})] + (t/is (th/success? out)) + (t/is (nil? (:result out))) + (t/is (empty? remaining))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Tests: remove-from-org ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/ui/settings/delete_account.cljs b/frontend/src/app/main/ui/settings/delete_account.cljs index 6f939a6390..9cd19f1f59 100644 --- a/frontend/src/app/main/ui/settings/delete_account.cljs +++ b/frontend/src/app/main/ui/settings/delete_account.cljs @@ -7,10 +7,13 @@ (ns app.main.ui.settings.delete-account (:require-macros [app.main.style :as stl]) (:require + [app.config :as cf] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] [app.main.data.profile :as du] + [app.main.repo :as rp] [app.main.store :as st] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.i18n :as i18n :refer [tr]] @@ -29,17 +32,34 @@ {::mf/register modal/components ::mf/register-as :delete-account} [] - (let [on-accept + (let [orgs* (mf/use-state nil) + orgs (deref orgs*) + has-orgs? (seq orgs) + + expanded* (mf/use-state true) + expanded? (deref expanded*) + on-toggle (mf/use-fn #(swap! expanded* not)) + + on-accept (mf/use-fn #(st/emit! (modal/hide) (du/request-account-deletion (with-meta {} {:on-error on-error}))))] + (mf/with-effect [] + (if (contains? cf/flags :nitrate) + (let [sub (->> (rp/cmd! :get-owned-organizations-summary {}) + (rx/subs! + (fn [result] (reset! orgs* (or result []))) + (fn [_] (reset! orgs* []))))] + (fn [] + (rx/dispose! sub))) + (reset! orgs* []))) + [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} (tr "modals.delete-account.title")] [:button {:class (stl/css :modal-close-btn) :on-click modal/hide!} deprecated-icon/close]] @@ -47,7 +67,33 @@ [:div {:class (stl/css :modal-content)} [:& context-notification {:level :warning - :content (tr "modals.delete-account.info")}]] + :content (tr "modals.delete-account.info")}] + + (when has-orgs? + [:div {:class (stl/css :orgs-section)} + [:button {:class (stl/css :orgs-section-toggle) + :type "button" + :aria-expanded expanded? + :on-click on-toggle} + [:span {:class (stl/css :orgs-section-title)} + (tr "modals.delete-account.owned-orgs.list-title")] + [:> icon* {:icon-id i/arrow + :size "s" + :class (stl/css-case :orgs-section-arrow true + :expanded expanded?)}]] + (when expanded? + [:ul {:class (stl/css :org-list)} + (for [{:keys [id name team-count member-count]} orgs] + [:li {:class (stl/css :org-item) :key id} + [:div {:class (stl/css :org-avatar)} + (when (seq name) (subs name 0 1))] + [:div {:class (stl/css :org-info)} + [:span {:class (stl/css :org-name)} name] + [:div {:class (stl/css :org-counts)} + [:span (tr "modals.delete-account.owned-orgs.teams-count" + (i18n/c (or team-count 0)))] + [:span (tr "modals.delete-account.owned-orgs.members-count" + (i18n/c (or member-count 0)))]]]])])])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} @@ -59,4 +105,3 @@ :on-click on-accept :data-testid "delete-account-btn"} (tr "modals.delete-account.confirm")]]]]])) - diff --git a/frontend/src/app/main/ui/settings/delete_account.scss b/frontend/src/app/main/ui/settings/delete_account.scss index 4b0b6408c9..6d043a5fbe 100644 --- a/frontend/src/app/main/ui/settings/delete_account.scss +++ b/frontend/src/app/main/ui/settings/delete_account.scss @@ -5,6 +5,10 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as t; +@use "ds/spacing.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .modal-overlay { @extend %modal-overlay-base; @@ -48,6 +52,84 @@ color: var(--modal-title-foreground-color); } +.orgs-section { + @include t.use-typography("body-medium"); + + display: flex; + flex-direction: column; + gap: var(--sp-l); +} + +.orgs-section-toggle { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--sp-xs); + padding: 0; + border: none; + background: transparent; + cursor: pointer; +} + +.orgs-section-title { + color: var(--color-accent-tertiary); +} + +.orgs-section-arrow { + color: var(--color-accent-tertiary); + transition: transform 0.15s ease; +} + +.orgs-section-arrow.expanded { + transform: rotate(90deg); +} + +.org-list { + display: flex; + flex-direction: column; + gap: var(--sp-l); + margin: 0; + padding: 0; + list-style: none; +} + +.org-item { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--sp-s); +} + +.org-avatar { + width: $sz-32; + height: $sz-32; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: var(--modal-section-background-color); + color: var(--modal-title-foreground-color); + text-transform: uppercase; + font-weight: 600; +} + +.org-info { + display: flex; + flex-direction: column; +} + +.org-name { + color: var(--modal-title-foreground-color); +} + +.org-counts { + display: flex; + flex-direction: row; + gap: var(--sp-s); + color: var(--df-secondary); +} + .action-buttons { @extend %modal-action-btns; } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index fcc362f357..7f280de647 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -3520,6 +3520,18 @@ msgstr "By removing your account you’ll lose all your current projects and arc msgid "modals.delete-account.title" msgstr "Are you sure you want to delete your account?" +#: src/app/main/ui/settings/delete_account.cljs +msgid "modals.delete-account.owned-orgs.list-title" +msgstr "Organizations that will be deleted" + +#: src/app/main/ui/settings/delete_account.cljs +msgid "modals.delete-account.owned-orgs.teams-count" +msgstr "%s teams" + +#: src/app/main/ui/settings/delete_account.cljs +msgid "modals.delete-account.owned-orgs.members-count" +msgstr "%s members" + #: src/app/main/ui/comments.cljs:889 msgid "modals.delete-comment-thread.accept" msgstr "Delete conversation" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 4c9afa83a8..212b919508 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -3423,6 +3423,18 @@ msgstr "Si borras tu cuenta perderás todos tus proyectos y archivos." msgid "modals.delete-account.title" msgstr "¿Seguro que quieres borrar tu cuenta?" +#: src/app/main/ui/settings/delete_account.cljs +msgid "modals.delete-account.owned-orgs.list-title" +msgstr "Organizaciones que se eliminarán" + +#: src/app/main/ui/settings/delete_account.cljs +msgid "modals.delete-account.owned-orgs.teams-count" +msgstr "%s equipos" + +#: src/app/main/ui/settings/delete_account.cljs +msgid "modals.delete-account.owned-orgs.members-count" +msgstr "%s miembros" + #: src/app/main/ui/comments.cljs:889 msgid "modals.delete-comment-thread.accept" msgstr "Eliminar conversación"