mirror of
https://github.com/penpot/penpot.git
synced 2026-05-20 07:23:42 +00:00
✨ Cascade owned organization deletion on account removal
This commit is contained in:
parent
6f41a2b729
commit
8e86416b0b
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@ -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")]]]]]))
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user