From 418d3305b20e86fd1dfd4092e62bd73a464d83b1 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 21 Aug 2023 20:24:46 -0400 Subject: [PATCH] feat: Add team deletion flow for cloud-hosted (#5717) --- app/scenes/Home.tsx | 4 +- app/scenes/Login/Notices.tsx | 8 +- app/scenes/Settings/Details.tsx | 37 +++++- app/scenes/Settings/Members.tsx | 2 +- app/scenes/Settings/Preferences.tsx | 4 +- app/scenes/Settings/Shares.tsx | 4 +- app/scenes/TeamDelete.tsx | 122 ++++++++++++++++++ app/scenes/UserDelete.tsx | 20 ++- app/stores/AuthStore.ts | 19 ++- server/commands/accountProvisioner.test.ts | 2 +- server/commands/teamDestroyer.ts | 33 +++++ server/commands/teamProvisioner.ts | 6 + server/commands/userDestroyer.ts | 53 ++++---- .../templates/ConfirmTeamDeleteEmail.tsx | 60 +++++++++ server/errors.ts | 14 +- server/models/Team.ts | 17 +++ server/policies/team.test.ts | 3 - server/policies/team.ts | 15 ++- server/routes/api/events/events.test.ts | 5 + server/routes/api/events/events.ts | 2 +- .../api/fileOperations/fileOperations.ts | 2 +- server/routes/api/teams/schema.ts | 8 ++ server/routes/api/teams/teams.ts | 63 +++++++++ server/routes/api/users/schema.ts | 9 ++ server/routes/api/users/users.ts | 10 +- shared/i18n/locales/en_US/translation.json | 10 +- 26 files changed, 461 insertions(+), 71 deletions(-) create mode 100644 app/scenes/TeamDelete.tsx create mode 100644 server/commands/teamDestroyer.ts create mode 100644 server/emails/templates/ConfirmTeamDeleteEmail.tsx diff --git a/app/scenes/Home.tsx b/app/scenes/Home.tsx index 034a30067..5a09d0b7d 100644 --- a/app/scenes/Home.tsx +++ b/app/scenes/Home.tsx @@ -32,7 +32,7 @@ function Home() { void pins.fetchPage(); }, [pins]); - const canManageTeam = usePolicy(team).manage; + const can = usePolicy(team); return ( {!ui.languagePromptDismissed && } {t("Home")} - + diff --git a/app/scenes/Login/Notices.tsx b/app/scenes/Login/Notices.tsx index 71e38abdb..5b91caf0e 100644 --- a/app/scenes/Login/Notices.tsx +++ b/app/scenes/Login/Notices.tsx @@ -37,7 +37,13 @@ export default function Notices() { Please use a Google Workspaces account instead. )} - {notice === "maximum-teams" && ( + {notice === "pending-deletion" && ( + + The workspace associated with your user is scheduled for deletion and + cannot at accessed at this time. + + )} + {notice === "maximum-reached" && ( The workspace you authenticated with is not authorized on this installation. Try another? diff --git a/app/scenes/Settings/Details.tsx b/app/scenes/Settings/Details.tsx index 1caa6620a..a41b45dc5 100644 --- a/app/scenes/Settings/Details.tsx +++ b/app/scenes/Settings/Details.tsx @@ -20,18 +20,22 @@ import Switch from "~/components/Switch"; import Text from "~/components/Text"; import env from "~/env"; import useCurrentTeam from "~/hooks/useCurrentTeam"; +import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; import isCloudHosted from "~/utils/isCloudHosted"; +import TeamDelete from "../TeamDelete"; import ImageInput from "./components/ImageInput"; import SettingRow from "./components/SettingRow"; function Details() { - const { auth, ui } = useStores(); + const { auth, dialogs, ui } = useStores(); const { showToast } = useToasts(); const { t } = useTranslation(); const team = useCurrentTeam(); const theme = useTheme(); + const can = usePolicy(team); + const form = useRef(null); const [accent, setAccent] = useState( team.preferences?.customTheme?.accent @@ -125,6 +129,14 @@ function Details() { [showToast, t] ); + const showDeleteWorkspace = () => { + dialogs.openModal({ + title: t("Delete workspace"), + content: , + isCentered: true, + }); + }; + const onSelectCollection = React.useCallback(async (value: string) => { const defaultCollectionId = value === "home" ? null : value; setDefaultCollectionId(defaultCollectionId); @@ -222,6 +234,7 @@ function Details() { {team.avatarUrl && ( {auth.isSaving ? `${t("Saving")}…` : t("Save")} + + {can.delete && ( + <> +

 

+ + {t("Danger")} + + + + + + + )}
diff --git a/app/scenes/Settings/Members.tsx b/app/scenes/Settings/Members.tsx index da970d8be..242650b33 100644 --- a/app/scenes/Settings/Members.tsx +++ b/app/scenes/Settings/Members.tsx @@ -184,7 +184,7 @@ function Members() { , + isCentered: true, }); }; @@ -131,8 +132,7 @@ function Preferences() { /> -

 

- + {t("Danger")} }> {t("Shared Links")} - {can.manage && !canShareDocuments && ( + {can.update && !canShareDocuments && ( <> }> {t("Sharing is currently disabled.")}{" "} @@ -95,7 +95,7 @@ function Shares() { void; +}; + +function TeamDelete({ onSubmit }: Props) { + const [isWaitingCode, setWaitingCode] = React.useState(false); + const { auth } = useStores(); + const { showToast } = useToasts(); + const team = useCurrentTeam(); + const { t } = useTranslation(); + const { + register, + handleSubmit: formHandleSubmit, + formState, + } = useForm(); + + const handleRequestDelete = React.useCallback( + async (ev: React.SyntheticEvent) => { + ev.preventDefault(); + + try { + await auth.requestDeleteTeam(); + setWaitingCode(true); + } catch (error) { + showToast(error.message, { + type: "error", + }); + } + }, + [auth, showToast] + ); + + const handleSubmit = React.useCallback( + async (data: FormData) => { + try { + await auth.deleteTeam(data); + await auth.logout(); + onSubmit(); + } catch (error) { + showToast(error.message, { + type: "error", + }); + } + }, + [auth, onSubmit, showToast] + ); + + const inputProps = register("code", { + required: true, + }); + const appName = env.APP_NAME; + const workspaceName = team.name; + + return ( + +
+ {isWaitingCode ? ( + <> + + + A confirmation code has been sent to your email address, please + enter the code below to permanantly destroy this workspace. + + + + + ) : ( + <> + + + Deleting the {{ workspaceName }} workspace will + destroy all collections, documents, users, and associated data. + You will be immediately logged out of {{ appName }}. + + + + )} + {env.EMAIL_ENABLED && !isWaitingCode ? ( + + ) : ( + + )} +
+
+ ); +} + +export default observer(TeamDelete); diff --git a/app/scenes/UserDelete.tsx b/app/scenes/UserDelete.tsx index 13ce212e6..884080303 100644 --- a/app/scenes/UserDelete.tsx +++ b/app/scenes/UserDelete.tsx @@ -30,7 +30,7 @@ function UserDelete() { ev.preventDefault(); try { - await auth.requestDelete(); + await auth.requestDeleteUser(); setWaitingCode(true); } catch (error) { showToast(error.message, { @@ -71,16 +71,8 @@ function UserDelete() { enter the code below to permanantly destroy your account. - - , - }} - /> - ) : ( - )} diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index c0c153717..cdbeb11aa 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -241,13 +241,14 @@ export default class AuthStore { } }; - @action - requestDelete = () => client.post(`/users.requestDelete`); + requestDeleteUser = () => client.post(`/users.requestDelete`); + + requestDeleteTeam = () => client.post(`/teams.requestDelete`); @action deleteUser = async (data: { code: string }) => { await client.post(`/users.delete`, data); - runInAction("AuthStore#updateUser", () => { + runInAction("AuthStore#deleteUser", () => { this.user = null; this.team = null; this.collaborationToken = null; @@ -258,6 +259,18 @@ export default class AuthStore { }); }; + @action + deleteTeam = async (data: { code: string }) => { + await client.post(`/teams.delete`, data); + runInAction("AuthStore#deleteTeam", () => { + this.user = null; + this.availableTeams = this.availableTeams?.filter( + (team) => team.id !== this.team?.id + ); + this.policies = []; + }); + }; + @action updateUser = async (params: { name?: string; diff --git a/server/commands/accountProvisioner.test.ts b/server/commands/accountProvisioner.test.ts index 778ad91fd..e3609354c 100644 --- a/server/commands/accountProvisioner.test.ts +++ b/server/commands/accountProvisioner.test.ts @@ -362,7 +362,7 @@ describe("accountProvisioner", () => { } expect(error.message).toEqual( - "The maximum number of teams has been reached" + "The maximum number of workspaces has been reached" ); }); diff --git a/server/commands/teamDestroyer.ts b/server/commands/teamDestroyer.ts new file mode 100644 index 000000000..1bad762e0 --- /dev/null +++ b/server/commands/teamDestroyer.ts @@ -0,0 +1,33 @@ +import { Transaction } from "sequelize"; +import { Event, User, Team } from "@server/models"; + +export default async function teamDestroyer({ + user, + team, + ip, + transaction, +}: { + user: User; + team: Team; + ip: string; + transaction?: Transaction; +}) { + await Event.create( + { + name: "teams.delete", + actorId: user.id, + teamId: team.id, + data: { + name: team.name, + }, + ip, + }, + { + transaction, + } + ); + + return team.destroy({ + transaction, + }); +} diff --git a/server/commands/teamProvisioner.ts b/server/commands/teamProvisioner.ts index 38832b927..15182308c 100644 --- a/server/commands/teamProvisioner.ts +++ b/server/commands/teamProvisioner.ts @@ -4,6 +4,7 @@ import { DomainNotAllowedError, InvalidAuthenticationError, MaximumTeamsError, + TeamPendingDeletionError, } from "@server/errors"; import { traceFunction } from "@server/logging/tracing"; import { Team, AuthenticationProvider } from "@server/models"; @@ -58,6 +59,7 @@ async function teamProvisioner({ model: Team, as: "team", required: true, + paranoid: false, }, ], }); @@ -65,6 +67,10 @@ async function teamProvisioner({ // This authentication provider already exists which means we have a team and // there is nothing left to do but return the existing credentials if (authP) { + if (authP.team.deletedAt) { + throw TeamPendingDeletionError(); + } + return { authenticationProvider: authP, team: authP.team, diff --git a/server/commands/userDestroyer.ts b/server/commands/userDestroyer.ts index 87741d0c2..1d15e7086 100644 --- a/server/commands/userDestroyer.ts +++ b/server/commands/userDestroyer.ts @@ -1,16 +1,17 @@ -import { Op } from "sequelize"; +import { Op, Transaction } from "sequelize"; import { Event, User } from "@server/models"; -import { sequelize } from "@server/storage/database"; import { ValidationError } from "../errors"; export default async function userDestroyer({ user, actor, ip, + transaction, }: { user: User; actor: User; ip: string; + transaction?: Transaction; }) { const { teamId } = user; const usersCount = await User.count({ @@ -20,7 +21,9 @@ export default async function userDestroyer({ }); if (usersCount === 1) { - throw ValidationError("Cannot delete last user on the team."); + throw ValidationError( + "Cannot delete last user on the team, delete the workspace instead." + ); } if (user.isAdmin) { @@ -41,33 +44,23 @@ export default async function userDestroyer({ } } - const transaction = await sequelize.transaction(); - let response; - - try { - response = await user.destroy({ - transaction, - }); - await Event.create( - { - name: "users.delete", - actorId: actor.id, - userId: user.id, - teamId, - data: { - name: user.name, - }, - ip, + await Event.create( + { + name: "users.delete", + actorId: actor.id, + userId: user.id, + teamId, + data: { + name: user.name, }, - { - transaction, - } - ); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } + ip, + }, + { + transaction, + } + ); - return response; + return user.destroy({ + transaction, + }); } diff --git a/server/emails/templates/ConfirmTeamDeleteEmail.tsx b/server/emails/templates/ConfirmTeamDeleteEmail.tsx new file mode 100644 index 000000000..e0d7cf1dd --- /dev/null +++ b/server/emails/templates/ConfirmTeamDeleteEmail.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; +import env from "@server/env"; +import BaseEmail, { EmailProps } from "./BaseEmail"; +import Body from "./components/Body"; +import CopyableCode from "./components/CopyableCode"; +import EmailTemplate from "./components/EmailLayout"; +import EmptySpace from "./components/EmptySpace"; +import Footer from "./components/Footer"; +import Header from "./components/Header"; +import Heading from "./components/Heading"; + +type Props = EmailProps & { + deleteConfirmationCode: string; +}; + +/** + * Email sent to a user when they request to delete their workspace. + */ +export default class ConfirmTeamDeleteEmail extends BaseEmail< + Props, + Record +> { + protected subject() { + return `Your workspace deletion request`; + } + + protected preview() { + return `Your requested workspace deletion code`; + } + + protected renderAsText({ deleteConfirmationCode }: Props): string { + return ` +You requested to permanantly delete your ${env.APP_NAME} workspace. Please enter the code below to confirm the workspace deletion. + +Code: ${deleteConfirmationCode} +`; + } + + protected render({ deleteConfirmationCode }: Props) { + return ( + +
+ + + Your workspace deletion request +

+ You requested to permanantly delete your {env.APP_NAME} workspace. + Please enter the code below to confirm your workspace deletion. +

+ +

+ {deleteConfirmationCode} +

+ + +