Cascade owned organization deletion on account removal

This commit is contained in:
Juanfran 2026-05-13 13:15:09 +02:00
parent 6f41a2b729
commit 8e86416b0b
8 changed files with 359 additions and 7 deletions

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -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")]]]]]))

View File

@ -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;
}

View File

@ -3520,6 +3520,18 @@ msgstr "By removing your account youll 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"

View File

@ -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"