+ You requested to permanantly delete your Outline account. Please + enter the code below to confirm your account deletion. +
+
+
+ {props.children}
+
+);
+
+export default CopyableCode;
diff --git a/server/models/User.ts b/server/models/User.ts
index 04ca6b5d4..2497107c5 100644
--- a/server/models/User.ts
+++ b/server/models/User.ts
@@ -215,6 +215,22 @@ class User extends ParanoidModel {
return stringToColor(this.id);
}
+ /**
+ * Returns a code that can be used to delete this user account. The code will
+ * be rotated when the user signs out.
+ *
+ * @returns The deletion code.
+ */
+ get deleteConfirmationCode() {
+ return crypto
+ .createHash("md5")
+ .update(this.jwtSecret)
+ .digest("hex")
+ .replace(/[l1IoO0]/gi, "")
+ .slice(0, 8)
+ .toUpperCase();
+ }
+
// instance methods
/**
@@ -550,7 +566,7 @@ class User extends ParanoidModel {
suspendedCount: string;
viewerCount: string;
count: string;
- } = results as any;
+ } = results;
return {
active: parseInt(counts.activeCount),
diff --git a/server/routes/api/users.test.ts b/server/routes/api/users.test.ts
index b0906874d..6eda1a814 100644
--- a/server/routes/api/users.test.ts
+++ b/server/routes/api/users.test.ts
@@ -329,48 +329,35 @@ describe("#users.delete", () => {
expect(res.status).toEqual(400);
});
- it("should allow deleting user account", async () => {
+ it("should require correct code", async () => {
+ const user = await buildAdmin();
+ await buildUser({
+ teamId: user.teamId,
+ isAdmin: false,
+ });
+ const res = await server.post("/api/users.delete", {
+ body: {
+ code: "123",
+ token: user.getJwtToken(),
+ },
+ });
+ expect(res.status).toEqual(400);
+ });
+
+ it("should allow deleting user account with correct code", async () => {
const user = await buildUser();
await buildUser({
teamId: user.teamId,
});
const res = await server.post("/api/users.delete", {
body: {
+ code: user.deleteConfirmationCode,
token: user.getJwtToken(),
},
});
expect(res.status).toEqual(200);
});
- it("should allow deleting user account with admin", async () => {
- const admin = await buildAdmin();
- const user = await buildUser({
- teamId: admin.teamId,
- lastActiveAt: null,
- });
- const res = await server.post("/api/users.delete", {
- body: {
- token: admin.getJwtToken(),
- id: user.id,
- },
- });
- expect(res.status).toEqual(200);
- });
-
- it("should not allow deleting another user account", async () => {
- const user = await buildUser();
- const user2 = await buildUser({
- teamId: user.teamId,
- });
- const res = await server.post("/api/users.delete", {
- body: {
- token: user.getJwtToken(),
- id: user2.id,
- },
- });
- expect(res.status).toEqual(403);
- });
-
it("should require authentication", async () => {
const res = await server.post("/api/users.delete");
const body = await res.json();
diff --git a/server/routes/api/users.ts b/server/routes/api/users.ts
index 5438114f2..f3215854e 100644
--- a/server/routes/api/users.ts
+++ b/server/routes/api/users.ts
@@ -1,3 +1,4 @@
+import crypto from "crypto";
import Router from "koa-router";
import { Op, WhereOptions } from "sequelize";
import userDemoter from "@server/commands/userDemoter";
@@ -5,6 +6,7 @@ import userDestroyer from "@server/commands/userDestroyer";
import userInviter from "@server/commands/userInviter";
import userSuspender from "@server/commands/userSuspender";
import { sequelize } from "@server/database/sequelize";
+import ConfirmUserDeleteEmail from "@server/emails/templates/ConfirmUserDeleteEmail";
import InviteEmail from "@server/emails/templates/InviteEmail";
import env from "@server/env";
import { ValidationError } from "@server/errors";
@@ -23,6 +25,7 @@ import {
import pagination from "./middlewares/pagination";
const router = new Router();
+const emailEnabled = !!(env.SMTP_HOST || env.ENVIRONMENT === "development");
router.post("users.list", auth(), pagination(), async (ctx) => {
let { direction } = ctx.body;
@@ -367,19 +370,43 @@ router.post("users.resendInvite", auth(), async (ctx) => {
};
});
-router.post("users.delete", auth(), async (ctx) => {
- const { id } = ctx.body;
- const actor = ctx.state.user;
- let user = actor;
+router.post("users.requestDelete", auth(), async (ctx) => {
+ const { user } = ctx.state;
+ authorize(user, "delete", user);
- if (id) {
- user = await User.findByPk(id);
+ if (emailEnabled) {
+ await ConfirmUserDeleteEmail.schedule({
+ to: user.email,
+ deleteConfirmationCode: user.deleteConfirmationCode,
+ });
+ }
+
+ ctx.body = {
+ success: true,
+ };
+});
+
+router.post("users.delete", auth(), async (ctx) => {
+ const { code = "" } = ctx.body;
+ const { user } = ctx.state;
+ authorize(user, "delete", user);
+
+ const deleteConfirmationCode = user.deleteConfirmationCode;
+
+ if (
+ emailEnabled &&
+ (code.length !== deleteConfirmationCode.length ||
+ !crypto.timingSafeEqual(
+ Buffer.from(code),
+ Buffer.from(deleteConfirmationCode)
+ ))
+ ) {
+ throw ValidationError("The confirmation code was incorrect");
}
- authorize(actor, "delete", user);
await userDestroyer({
user,
- actor,
+ actor: user,
ip: ctx.request.ip,
});
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index ff400ef52..78e52a662 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -712,8 +712,9 @@
"There are no templates just yet.": "There are no templates just yet.",
"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.",
- "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 Outline 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 Outline and all your API tokens will be revoked.",
+ "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.",
"Note: Signing back in will cause a new account to be automatically reprovisioned.": "Note: 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 Outline 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 Outline and all your API tokens will be revoked.",
"Delete My Account": "Delete My Account",
"Profile picture": "Profile picture",
"You joined": "You joined",