feat: Add team deletion flow for cloud-hosted (#5717)

This commit is contained in:
Tom Moor
2023-08-21 20:24:46 -04:00
committed by GitHub
parent 5c07694f6b
commit 418d3305b2
26 changed files with 461 additions and 71 deletions

View File

@ -32,7 +32,7 @@ function Home() {
void pins.fetchPage();
}, [pins]);
const canManageTeam = usePolicy(team).manage;
const can = usePolicy(team);
return (
<Scene
@ -49,7 +49,7 @@ function Home() {
>
{!ui.languagePromptDismissed && <LanguagePrompt />}
<Heading>{t("Home")}</Heading>
<PinnedDocuments pins={pins.home} canUpdate={canManageTeam} />
<PinnedDocuments pins={pins.home} canUpdate={can.update} />
<Documents>
<Tabs>
<Tab to="/home" exact>

View File

@ -37,7 +37,13 @@ export default function Notices() {
Please use a Google Workspaces account instead.
</Trans>
)}
{notice === "maximum-teams" && (
{notice === "pending-deletion" && (
<Trans>
The workspace associated with your user is scheduled for deletion and
cannot at accessed at this time.
</Trans>
)}
{notice === "maximum-reached" && (
<Trans>
The workspace you authenticated with is not authorized on this
installation. Try another?

View File

@ -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<HTMLFormElement>(null);
const [accent, setAccent] = useState<null | undefined | string>(
team.preferences?.customTheme?.accent
@ -125,6 +129,14 @@ function Details() {
[showToast, t]
);
const showDeleteWorkspace = () => {
dialogs.openModal({
title: t("Delete workspace"),
content: <TeamDelete onSubmit={dialogs.closeAllModals} />,
isCentered: true,
});
};
const onSelectCollection = React.useCallback(async (value: string) => {
const defaultCollectionId = value === "home" ? null : value;
setDefaultCollectionId(defaultCollectionId);
@ -222,6 +234,7 @@ function Details() {
</SettingRow>
{team.avatarUrl && (
<SettingRow
border={false}
name={TeamPreference.PublicBranding}
label={t("Public branding")}
description={t(
@ -287,6 +300,28 @@ function Details() {
<Button type="submit" disabled={auth.isSaving || !isValid}>
{auth.isSaving ? `${t("Saving")}` : t("Save")}
</Button>
{can.delete && (
<>
<p>&nbsp;</p>
<Heading as="h2">{t("Danger")}</Heading>
<SettingRow
name="delete"
border={false}
label={t("Delete workspace")}
description={t(
"You can delete this entire workspace including collections, documents, and users."
)}
>
<span>
<Button onClick={showDeleteWorkspace} neutral>
{t("Delete workspace")}
</Button>
</span>
</SettingRow>
</>
)}
</form>
</Scene>
</ThemeProvider>

View File

@ -184,7 +184,7 @@ function Members() {
</Flex>
<PeopleTable
data={data}
canManage={can.manage}
canManage={can.update}
isLoading={isLoading}
page={page}
pageSize={limit}

View File

@ -47,6 +47,7 @@ function Preferences() {
dialogs.openModal({
title: t("Delete account"),
content: <UserDelete />,
isCentered: true,
});
};
@ -131,8 +132,7 @@ function Preferences() {
/>
</SettingRow>
<p>&nbsp;</p>
<Heading as="h2">{t("Danger")}</Heading>
<SettingRow
name="delete"
label={t("Delete account")}

View File

@ -70,7 +70,7 @@ function Shares() {
<Scene title={t("Shared Links")} icon={<LinkIcon />}>
<Heading>{t("Shared Links")}</Heading>
{can.manage && !canShareDocuments && (
{can.update && !canShareDocuments && (
<>
<Notice icon={<WarningIcon />}>
{t("Sharing is currently disabled.")}{" "}
@ -95,7 +95,7 @@ function Shares() {
<SharesTable
data={data}
canManage={can.manage}
canManage={can.update}
isLoading={isLoading}
page={page}
pageSize={limit}

122
app/scenes/TeamDelete.tsx Normal file
View File

@ -0,0 +1,122 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type FormData = {
code: string;
};
type Props = {
onSubmit: () => 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<FormData>();
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 (
<Flex column>
<form onSubmit={formHandleSubmit(handleSubmit)}>
{isWaitingCode ? (
<>
<Text type="secondary">
<Trans>
A confirmation code has been sent to your email address, please
enter the code below to permanantly destroy this workspace.
</Trans>
</Text>
<Input
placeholder={t("Confirmation code")}
autoComplete="off"
autoFocus
maxLength={8}
required
{...inputProps}
/>
</>
) : (
<>
<Text type="secondary">
<Trans>
Deleting the <strong>{{ workspaceName }}</strong> workspace will
destroy all collections, documents, users, and associated data.
You will be immediately logged out of {{ appName }}.
</Trans>
</Text>
</>
)}
{env.EMAIL_ENABLED && !isWaitingCode ? (
<Button type="submit" onClick={handleRequestDelete} neutral>
{t("Continue")}
</Button>
) : (
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
danger
>
{formState.isSubmitting
? `${t("Deleting")}`
: t("Delete workspace")}
</Button>
)}
</form>
</Flex>
);
}
export default observer(TeamDelete);

View File

@ -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.
</Trans>
</Text>
<Text type="secondary">
<Trans
defaults="<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned."
components={{
em: <strong />,
}}
/>
</Text>
<Input
placeholder="CODE"
placeholder={t("Confirmation code")}
autoComplete="off"
autoFocus
maxLength={8}
@ -105,10 +97,14 @@ function UserDelete() {
{t("Continue")}
</Button>
) : (
<Button type="submit" disabled={formState.isSubmitting} danger>
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
danger
>
{formState.isSubmitting
? `${t("Deleting")}`
: t("Delete My Account")}
: t("Delete my account")}
</Button>
)}
</form>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, any>
> {
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 (
<EmailTemplate>
<Header />
<Body>
<Heading>Your workspace deletion request</Heading>
<p>
You requested to permanantly delete your {env.APP_NAME} workspace.
Please enter the code below to confirm your workspace deletion.
</p>
<EmptySpace height={5} />
<p>
<CopyableCode>{deleteConfirmationCode}</CopyableCode>
</p>
</Body>
<Footer />
</EmailTemplate>
);
}
}

View File

@ -134,10 +134,18 @@ export function OAuthStateMismatchError(
}
export function MaximumTeamsError(
message = "The maximum number of teams has been reached"
message = "The maximum number of workspaces has been reached"
) {
return httpErrors(400, message, {
id: "maximum_teams",
id: "maximum_reached",
});
}
export function TeamPendingDeletionError(
message = "The workspace is pending deletion"
) {
return httpErrors(403, message, {
id: "pending_deletion",
});
}
@ -160,7 +168,7 @@ export function MicrosoftGraphError(
}
export function TeamDomainRequiredError(
message = "Unable to determine team from current domain or subdomain"
message = "Unable to determine workspace from current domain or subdomain"
) {
return httpErrors(400, message, {
id: "domain_required",

View File

@ -1,3 +1,4 @@
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { URL } from "url";
@ -176,6 +177,22 @@ class Team extends ParanoidModel {
return url.href.replace(/\/$/, "");
}
/**
* Returns a code that can be used to delete the user's team. The code will
* be rotated when the user signs out.
*
* @returns The deletion code.
*/
public getDeleteConfirmationCode(user: User) {
return crypto
.createHash("md5")
.update(`${this.id}${user.jwtSecret}`)
.digest("hex")
.replace(/[l1IoO0]/gi, "")
.slice(0, 8)
.toUpperCase();
}
/**
* Preferences that decide behavior for the team.
*

View File

@ -12,7 +12,6 @@ it("should allow reading only", async () => {
});
const abilities = serialize(user, team);
expect(abilities.read).toEqual(true);
expect(abilities.manage).toEqual(false);
expect(abilities.createTeam).toEqual(false);
expect(abilities.createAttachment).toEqual(true);
expect(abilities.createCollection).toEqual(true);
@ -28,7 +27,6 @@ it("should allow admins to manage", async () => {
});
const abilities = serialize(admin, team);
expect(abilities.read).toEqual(true);
expect(abilities.manage).toEqual(true);
expect(abilities.createTeam).toEqual(false);
expect(abilities.createAttachment).toEqual(true);
expect(abilities.createCollection).toEqual(true);
@ -46,7 +44,6 @@ it("should allow creation on hosted envs", async () => {
});
const abilities = serialize(admin, team);
expect(abilities.read).toEqual(true);
expect(abilities.manage).toEqual(true);
expect(abilities.createTeam).toEqual(true);
expect(abilities.createAttachment).toEqual(true);
expect(abilities.createCollection).toEqual(true);

View File

@ -14,11 +14,22 @@ allow(User, "share", Team, (user, team) => {
allow(User, "createTeam", Team, () => {
if (!env.isCloudHosted()) {
throw IncorrectEditionError("createTeam only available on cloud");
throw IncorrectEditionError("Functionality is only available on cloud");
}
return true;
});
allow(User, ["update", "manage"], Team, (user, team) => {
allow(User, "update", Team, (user, team) => {
if (!team || user.isViewer || user.teamId !== team.id) {
return false;
}
return user.isAdmin;
});
allow(User, ["delete", "audit"], Team, (user, team) => {
if (!env.isCloudHosted()) {
throw IncorrectEditionError("Functionality is only available on cloud");
}
if (!team || user.isViewer || user.teamId !== team.id) {
return false;
}

View File

@ -1,9 +1,14 @@
import env from "@server/env";
import { buildEvent, buildUser } from "@server/test/factories";
import { seed, getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#events.list", () => {
beforeEach(() => {
env.DEPLOYMENT = "hosted";
});
it("should only return activity events", async () => {
const { user, admin, document, collection } = await seed();
// audit event

View File

@ -42,7 +42,7 @@ router.post(
}
if (auditLog) {
authorize(user, "manage", user.team);
authorize(user, "audit", user.team);
where.name = Event.AUDIT_EVENTS;
}

View File

@ -48,7 +48,7 @@ router.post(
type,
};
const team = await Team.findByPk(user.teamId);
authorize(user, "manage", team);
authorize(user, "update", team);
const [exports, total] = await Promise.all([
FileOperation.findAll({

View File

@ -53,3 +53,11 @@ export const TeamsUpdateSchema = BaseSchema.extend({
});
export type TeamsUpdateSchemaReq = z.infer<typeof TeamsUpdateSchema>;
export const TeamsDeleteSchema = BaseSchema.extend({
body: z.object({
code: z.string(),
}),
});
export type TeamsDeleteSchemaReq = z.infer<typeof TeamsDeleteSchema>;

View File

@ -1,7 +1,11 @@
import invariant from "invariant";
import Router from "koa-router";
import teamCreator from "@server/commands/teamCreator";
import teamDestroyer from "@server/commands/teamDestroyer";
import teamUpdater from "@server/commands/teamUpdater";
import ConfirmTeamDeleteEmail from "@server/emails/templates/ConfirmTeamDeleteEmail";
import env from "@server/env";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
@ -11,9 +15,11 @@ import { authorize } from "@server/policies";
import { presentTeam, presentPolicies } from "@server/presenters";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { safeEqual } from "@server/utils/crypto";
import * as T from "./schema";
const router = new Router();
const emailEnabled = !!(env.SMTP_HOST || env.ENVIRONMENT === "development");
router.post(
"team.update",
@ -44,6 +50,63 @@ router.post(
}
);
router.post(
"teams.requestDelete",
rateLimiter(RateLimiterStrategy.FivePerHour),
auth(),
async (ctx: APIContext) => {
const { user } = ctx.state.auth;
const { team } = user;
authorize(user, "delete", team);
if (emailEnabled) {
await new ConfirmTeamDeleteEmail({
to: user.email,
deleteConfirmationCode: team.getDeleteConfirmationCode(user),
}).schedule();
}
ctx.body = {
success: true,
};
}
);
router.post(
"teams.delete",
rateLimiter(RateLimiterStrategy.TenPerHour),
auth(),
validate(T.TeamsDeleteSchema),
transaction(),
async (ctx: APIContext<T.TeamsDeleteSchemaReq>) => {
const { auth, transaction } = ctx.state;
const { code } = ctx.input.body;
const { user } = auth;
const { team } = user;
authorize(user, "delete", team);
if (emailEnabled) {
const deleteConfirmationCode = team.getDeleteConfirmationCode(user);
if (!safeEqual(code, deleteConfirmationCode)) {
throw ValidationError("The confirmation code was incorrect");
}
}
await teamDestroyer({
team,
user,
transaction,
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
}
);
router.post(
"teams.create",
rateLimiter(RateLimiterStrategy.FivePerHour),

View File

@ -33,3 +33,12 @@ export const UsersUpdateSchema = BaseSchema.extend({
});
export type UsersUpdateReq = z.infer<typeof UsersUpdateSchema>;
export const UsersDeleteSchema = BaseSchema.extend({
body: z.object({
code: z.string().optional(),
id: z.string().uuid().optional(),
}),
});
export type UsersDeleteSchemaReq = z.infer<typeof UsersDeleteSchema>;

View File

@ -28,7 +28,6 @@ import {
assertSort,
assertPresent,
assertArray,
assertUuid,
} from "@server/validation";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
@ -449,13 +448,15 @@ router.post(
"users.delete",
rateLimiter(RateLimiterStrategy.TenPerHour),
auth(),
async (ctx: APIContext) => {
const { id, code = "" } = ctx.request.body;
validate(T.UsersDeleteSchema),
transaction(),
async (ctx: APIContext<T.UsersDeleteSchemaReq>) => {
const { transaction } = ctx.state;
const { id, code } = ctx.request.body;
const actor = ctx.state.auth.user;
let user: User;
if (id) {
assertUuid(id, "id must be a UUID");
user = await User.findByPk(id, {
rejectOnEmpty: true,
});
@ -478,6 +479,7 @@ router.post(
user,
actor,
ip: ctx.request.ip,
transaction,
});
ctx.body = {

View File

@ -680,6 +680,7 @@
"The domain associated with your email address has not been allowed for this workspace.": "The domain associated with your email address has not been allowed for this workspace.",
"Unable to sign-in. Please navigate to your workspace's custom URL, then try to sign-in again.<1></1>If you were invited to a workspace, you will find a link to it in the invite email.": "Unable to sign-in. Please navigate to your workspace's custom URL, then try to sign-in again.<1></1>If you were invited to a workspace, you will find a link to it in the invite email.",
"Sorry, a new account cannot be created with a personal Gmail address.<1></1>Please use a Google Workspaces account instead.": "Sorry, a new account cannot be created with a personal Gmail address.<1></1>Please use a Google Workspaces account instead.",
"The workspace associated with your user is scheduled for deletion and cannot at accessed at this time.": "The workspace associated with your user is scheduled for deletion and cannot at accessed at this time.",
"The workspace you authenticated with is not authorized on this installation. Try another?": "The workspace you authenticated with is not authorized on this installation. Try another?",
"We could not read the user info supplied by your identity provider.": "We could not read the user info supplied by your identity provider.",
"Your account uses email sign-in, please sign-in with email to continue.": "Your account uses email sign-in, please sign-in with email to continue.",
@ -751,6 +752,7 @@
"Settings saved": "Settings saved",
"Logo updated": "Logo updated",
"Unable to upload new logo": "Unable to upload new logo",
"Delete workspace": "Delete workspace",
"These settings affect the way that your knowledge base appears to everyone on the team.": "These settings affect the way that your knowledge base appears to everyone on the team.",
"Display": "Display",
"The logo is displayed at the top left of the application.": "The logo is displayed at the top left of the application.",
@ -768,6 +770,8 @@
"Choose a subdomain to enable a login page just for your team.": "Choose a subdomain to enable a login page just for your team.",
"Start view": "Start view",
"This is the screen that workspace members will first see when they sign in.": "This is the screen that workspace members will first see when they sign in.",
"Danger": "Danger",
"You can delete this entire workspace including collections, documents, and users.": "You can delete this entire workspace including collections, documents, and users.",
"Export data": "Export data",
"Export deleted": "Export deleted",
"A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete.",
@ -865,6 +869,9 @@
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.",
"Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.",
"A confirmation code has been sent to your email address, please enter the code below to permanantly destroy this workspace.": "A confirmation code has been sent to your email address, please enter the code below to permanantly destroy this workspace.",
"Confirmation code": "Confirmation code",
"Deleting the <1>{{workspaceName}}</1> workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.": "Deleting the <1>{{workspaceName}}</1> workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.",
"Your are creating a new workspace using your current account — <em>{{email}}</em>": "Your are creating a new workspace using your current account — <em>{{email}}</em>",
"Workspace name": "Workspace name",
"When your new workspace is created, you will be the admin, meaning you will have the highest level of permissions and the ability to invite others.": "When your new workspace is created, you will be the admin, meaning you will have the highest level of permissions and the ability to invite others.",
@ -873,9 +880,8 @@
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",
"Trash is empty at the moment.": "Trash is empty at the moment.",
"A confirmation code has been sent to your email address, please enter the code below to permanantly destroy your account.": "A confirmation code has been sent to your email address, please enter the code below to permanantly destroy your account.",
"<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.": "<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.",
"Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.": "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.",
"Delete My Account": "Delete My Account",
"Delete my account": "Delete my account",
"Today": "Today",
"Yesterday": "Yesterday",
"Last week": "Last week",