diff --git a/app/actions/definitions/debug.tsx b/app/actions/definitions/debug.tsx
deleted file mode 100644
index 5f11c5687..000000000
--- a/app/actions/definitions/debug.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { ToolsIcon, TrashIcon } from "outline-icons";
-import * as React from "react";
-import stores from "~/stores";
-import { createAction } from "~/actions";
-import { DebugSection } from "~/actions/sections";
-import env from "~/env";
-import { deleteAllDatabases } from "~/utils/developer";
-
-export const clearIndexedDB = createAction({
- name: ({ t }) => t("Delete IndexedDB cache"),
- icon: ,
- keywords: "cache clear database",
- section: DebugSection,
- perform: async ({ t }) => {
- await deleteAllDatabases();
- stores.toasts.showToast(t("IndexedDB cache deleted"));
- },
-});
-
-export const development = createAction({
- name: ({ t }) => t("Development"),
- keywords: "debug",
- icon: ,
- iconInContextMenu: false,
- section: DebugSection,
- visible: ({ event }) =>
- env.ENVIRONMENT === "development" ||
- (event instanceof KeyboardEvent && event.altKey),
- children: [clearIndexedDB],
-});
-
-export const rootDebugActions = [development];
diff --git a/app/actions/definitions/developer.tsx b/app/actions/definitions/developer.tsx
new file mode 100644
index 000000000..0d8dff773
--- /dev/null
+++ b/app/actions/definitions/developer.tsx
@@ -0,0 +1,50 @@
+import { ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
+import * as React from "react";
+import stores from "~/stores";
+import { createAction } from "~/actions";
+import { DeveloperSection } from "~/actions/sections";
+import env from "~/env";
+import { client } from "~/utils/ApiClient";
+import { deleteAllDatabases } from "~/utils/developer";
+
+export const clearIndexedDB = createAction({
+ name: ({ t }) => t("Delete IndexedDB cache"),
+ icon: ,
+ keywords: "cache clear database",
+ section: DeveloperSection,
+ perform: async ({ t }) => {
+ await deleteAllDatabases();
+ stores.toasts.showToast(t("IndexedDB cache deleted"));
+ },
+});
+
+export const createTestUsers = createAction({
+ name: "Create test users",
+ icon: ,
+ section: DeveloperSection,
+ visible: () => env.ENVIRONMENT === "development",
+ perform: async () => {
+ const count = 10;
+
+ try {
+ await client.post("/developer.create_test_users", { count });
+ stores.toasts.showToast(`${count} test users created`);
+ } catch (err) {
+ stores.toasts.showToast(err.message, { type: "error" });
+ }
+ },
+});
+
+export const developer = createAction({
+ name: ({ t }) => t("Developer"),
+ keywords: "debug",
+ icon: ,
+ iconInContextMenu: false,
+ section: DeveloperSection,
+ visible: ({ event }) =>
+ env.ENVIRONMENT === "development" ||
+ (event instanceof KeyboardEvent && event.altKey),
+ children: [clearIndexedDB, createTestUsers],
+});
+
+export const rootDeveloperActions = [developer];
diff --git a/app/actions/root.ts b/app/actions/root.ts
index 9894e0d72..fb19a3868 100644
--- a/app/actions/root.ts
+++ b/app/actions/root.ts
@@ -1,5 +1,5 @@
import { rootCollectionActions } from "./definitions/collections";
-import { rootDebugActions } from "./definitions/debug";
+import { rootDeveloperActions } from "./definitions/developer";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootSettingsActions } from "./definitions/settings";
@@ -11,5 +11,5 @@ export default [
...rootUserActions,
...rootNavigationActions,
...rootSettingsActions,
- ...rootDebugActions,
+ ...rootDeveloperActions,
];
diff --git a/app/actions/sections.ts b/app/actions/sections.ts
index 2963e980f..00a69c4d5 100644
--- a/app/actions/sections.ts
+++ b/app/actions/sections.ts
@@ -2,7 +2,7 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
-export const DebugSection = ({ t }: ActionContext) => t("Debug");
+export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
diff --git a/server/commands/userInviter.ts b/server/commands/userInviter.ts
index 2d732fc8a..498b18a93 100644
--- a/server/commands/userInviter.ts
+++ b/server/commands/userInviter.ts
@@ -6,7 +6,7 @@ import Logger from "@server/logging/Logger";
import { User, Event, Team } from "@server/models";
import { UserFlag } from "@server/models/User";
-type Invite = {
+export type Invite = {
name: string;
email: string;
role: Role;
diff --git a/server/routes/api/developer.ts b/server/routes/api/developer.ts
new file mode 100644
index 000000000..bddf1b6f1
--- /dev/null
+++ b/server/routes/api/developer.ts
@@ -0,0 +1,58 @@
+import { Context, Next } from "koa";
+import Router from "koa-router";
+import randomstring from "randomstring";
+import userInviter, { Invite } from "@server/commands/userInviter";
+import env from "@server/env";
+import Logger from "@server/logging/Logger";
+import auth from "@server/middlewares/authentication";
+import { presentUser } from "@server/presenters";
+
+const router = new Router();
+
+function dev() {
+ return async function checkDevelopmentMiddleware(ctx: Context, next: Next) {
+ if (env.ENVIRONMENT !== "development") {
+ throw new Error("Attempted to access development route in production");
+ }
+
+ return next();
+ };
+}
+
+router.post("developer.create_test_users", dev(), auth(), async (ctx) => {
+ const { count = 10 } = ctx.body;
+ const { user } = ctx.state;
+ const invites = Array(count)
+ .fill(0)
+ .map(() => {
+ const rando = randomstring.generate(10);
+
+ return {
+ email: `${rando}@example.com`,
+ name: `${rando.slice(0, 5)} Tester`,
+ role: "member",
+ } as Invite;
+ });
+
+ Logger.info("utils", `Creating ${count} test users`, invites);
+
+ // Generate a bunch of invites
+ const response = await userInviter({
+ user,
+ invites,
+ ip: ctx.request.ip,
+ });
+
+ // Convert from invites to active users by marking as active
+ await Promise.all(
+ response.users.map((user) => user.updateActiveAt(ctx.request.ip, true))
+ );
+
+ ctx.body = {
+ data: {
+ users: response.users.map((user) => presentUser(user)),
+ },
+ };
+});
+
+export default router;
diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts
index 180af9da0..1e15be118 100644
--- a/server/routes/api/index.ts
+++ b/server/routes/api/index.ts
@@ -1,6 +1,7 @@
import Koa from "koa";
import bodyParser from "koa-body";
import Router from "koa-router";
+import env from "@server/env";
import { NotFoundError } from "@server/errors";
import errorHandling from "@server/middlewares/errorHandling";
import methodOverride from "@server/middlewares/methodOverride";
@@ -10,6 +11,7 @@ import auth from "./auth";
import authenticationProviders from "./authenticationProviders";
import collections from "./collections";
import utils from "./cron";
+import developer from "./developer";
import documents from "./documents";
import events from "./events";
import fileOperationsRoute from "./fileOperations";
@@ -70,6 +72,10 @@ router.use("/", groups.routes());
router.use("/", fileOperationsRoute.routes());
router.use("/", webhookSubscriptions.routes());
+if (env.ENVIRONMENT === "development") {
+ router.use("/", developer.routes());
+}
+
router.post("*", (ctx) => {
ctx.throw(NotFoundError("Endpoint not found"));
});
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index f9e9b6711..bd028ab50 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -7,7 +7,7 @@
"Unstar": "Unstar",
"Delete IndexedDB cache": "Delete IndexedDB cache",
"IndexedDB cache deleted": "IndexedDB cache deleted",
- "Development": "Development",
+ "Developer": "Developer",
"Open document": "Open document",
"New document": "New document",
"Download": "Download",