diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx
index 47a16500c..636a78f2a 100644
--- a/app/actions/definitions/documents.tsx
+++ b/app/actions/definitions/documents.tsx
@@ -14,7 +14,7 @@ import {
} from "outline-icons";
import * as React from "react";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
-import DocumentTemplatize from "~/scenes/DocumentTemplatize";
+import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import history from "~/utils/history";
@@ -306,18 +306,13 @@ export const createTemplate = createAction({
if (!activeDocumentId) {
return;
}
-
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create template"),
- content: (
-
- ),
+ isCentered: true,
+ content: ,
});
},
});
diff --git a/app/components/Button.tsx b/app/components/Button.tsx
index 8f4a22527..21c570f9c 100644
--- a/app/components/Button.tsx
+++ b/app/components/Button.tsx
@@ -111,7 +111,7 @@ const RealButton = styled.button<{
}
&:disabled {
- background: none;
+ background: ${lighten(0.05, props.theme.danger)};
}
&.focus-visible {
diff --git a/app/components/ConfirmationDialog.tsx b/app/components/ConfirmationDialog.tsx
new file mode 100644
index 000000000..ac5baffa1
--- /dev/null
+++ b/app/components/ConfirmationDialog.tsx
@@ -0,0 +1,58 @@
+import { observer } from "mobx-react";
+import * as React from "react";
+import Button from "~/components/Button";
+import Flex from "~/components/Flex";
+import Text from "~/components/Text";
+import useStores from "~/hooks/useStores";
+import useToasts from "~/hooks/useToasts";
+
+type Props = {
+ onSubmit: () => void;
+ children: JSX.Element;
+ submitText?: string;
+ savingText?: string;
+ danger?: boolean;
+};
+
+function ConfirmationDialog({
+ onSubmit,
+ children,
+ submitText,
+ savingText,
+ danger,
+}: Props) {
+ const [isSaving, setIsSaving] = React.useState(false);
+ const { dialogs } = useStores();
+ const { showToast } = useToasts();
+
+ const handleSubmit = React.useCallback(
+ async (ev: React.SyntheticEvent) => {
+ ev.preventDefault();
+ setIsSaving(true);
+ try {
+ await onSubmit();
+ dialogs.closeAllModals();
+ } catch (err) {
+ showToast(err.message, {
+ type: "error",
+ });
+ } finally {
+ setIsSaving(false);
+ }
+ },
+ [onSubmit, dialogs, showToast]
+ );
+
+ return (
+
+
+
+ );
+}
+
+export default observer(ConfirmationDialog);
diff --git a/app/components/DocumentTemplatizeDialog.tsx b/app/components/DocumentTemplatizeDialog.tsx
new file mode 100644
index 000000000..ad643f974
--- /dev/null
+++ b/app/components/DocumentTemplatizeDialog.tsx
@@ -0,0 +1,52 @@
+import invariant from "invariant";
+import { observer } from "mobx-react";
+import * as React from "react";
+import { useTranslation, Trans } from "react-i18next";
+import { useHistory } from "react-router-dom";
+import ConfirmationDialog from "~/components/ConfirmationDialog";
+import useStores from "~/hooks/useStores";
+import useToasts from "~/hooks/useToasts";
+import { documentUrl } from "~/utils/routeHelpers";
+
+type Props = {
+ documentId: string;
+};
+
+function DocumentTemplatizeDialog({ documentId }: Props) {
+ const history = useHistory();
+ const { showToast } = useToasts();
+ const { t } = useTranslation();
+ const { documents } = useStores();
+ const document = documents.get(documentId);
+ invariant(document, "Document must exist");
+
+ const handleSubmit = React.useCallback(async () => {
+ const template = await document?.templatize();
+ if (template) {
+ history.push(documentUrl(template));
+ showToast(t("Template created, go ahead and customize it"), {
+ type: "info",
+ });
+ }
+ }, [document, showToast, history, t]);
+
+ return (
+
+ ,
+ }}
+ />
+
+ );
+}
+
+export default observer(DocumentTemplatizeDialog);
diff --git a/app/components/Modal.tsx b/app/components/Modal.tsx
index df4351b28..0d3c814a6 100644
--- a/app/components/Modal.tsx
+++ b/app/components/Modal.tsx
@@ -82,9 +82,11 @@ const Modal: React.FC = ({
{title}
)}
-
-
-
+
+
+
+
+
{children}
@@ -261,6 +263,11 @@ const Small = styled.div`
&[aria-expanded="true"] {
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
+ vertical-align: middle;
+ }
+
+ ${Header} {
+ align-items: start;
}
`;
diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx
index 1619d9aed..ec2070708 100644
--- a/app/menus/DocumentMenu.tsx
+++ b/app/menus/DocumentMenu.tsx
@@ -9,7 +9,6 @@ import {
MoveIcon,
HistoryIcon,
UnpublishIcon,
- ShapesIcon,
PrintIcon,
ImportIcon,
NewDocumentIcon,
@@ -29,7 +28,6 @@ import Document from "~/models/Document";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
-import DocumentTemplatize from "~/scenes/DocumentTemplatize";
import CollectionIcon from "~/components/CollectionIcon";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
@@ -39,7 +37,7 @@ import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import Switch from "~/components/Switch";
import { actionToMenuItem } from "~/actions";
-import { pinDocument } from "~/actions/definitions/documents";
+import { pinDocument, createTemplate } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMobile from "~/hooks/useMobile";
@@ -103,7 +101,6 @@ function DocumentMenu({
setShowPermanentDeleteModal,
] = React.useState(false);
const [showMoveModal, setShowMoveModal] = React.useState(false);
- const [showTemplateModal, setShowTemplateModal] = React.useState(false);
const file = React.useRef(null);
const handleOpen = React.useCallback(() => {
@@ -330,6 +327,7 @@ function DocumentMenu({
visible: !document.isStarred && !!can.star,
icon: ,
},
+ // Pin document
actionToMenuItem(pinDocument, context),
{
type: "separator",
@@ -357,14 +355,8 @@ function DocumentMenu({
onClick: handleImportDocument,
icon: ,
},
- {
- type: "button",
- title: `${t("Create template")}…`,
- onClick: () => setShowTemplateModal(true),
- visible:
- !!can.update && !document.isTemplate && !document.isDraft,
- icon: ,
- },
+ // Templatize document
+ actionToMenuItem(createTemplate, context),
{
type: "button",
title: t("Duplicate"),
@@ -518,19 +510,6 @@ function DocumentMenu({
/>
)}
- {can.update && (
- setShowTemplateModal(false)}
- isOpen={showTemplateModal}
- isCentered
- >
- setShowTemplateModal(false)}
- />
-
- )}
>
)}
>
diff --git a/app/models/Team.ts b/app/models/Team.ts
index 516bb2e0f..e9565fde4 100644
--- a/app/models/Team.ts
+++ b/app/models/Team.ts
@@ -19,6 +19,10 @@ class Team extends BaseModel {
@observable
sharing: boolean;
+ @Field
+ @observable
+ inviteRequired: boolean;
+
@Field
@observable
collaborativeEditing: boolean;
diff --git a/app/scenes/DocumentTemplatize.tsx b/app/scenes/DocumentTemplatize.tsx
deleted file mode 100644
index 0db4135d3..000000000
--- a/app/scenes/DocumentTemplatize.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import invariant from "invariant";
-import { observer } from "mobx-react";
-import * as React from "react";
-import { useState } from "react";
-import { useTranslation, Trans } from "react-i18next";
-import { useHistory } from "react-router-dom";
-import Button from "~/components/Button";
-import Flex from "~/components/Flex";
-import Text from "~/components/Text";
-import useStores from "~/hooks/useStores";
-import useToasts from "~/hooks/useToasts";
-import { documentUrl } from "~/utils/routeHelpers";
-
-type Props = {
- documentId: string;
- onSubmit: () => void;
-};
-
-function DocumentTemplatize({ documentId, onSubmit }: Props) {
- const [isSaving, setIsSaving] = useState();
- const history = useHistory();
- const { showToast } = useToasts();
- const { t } = useTranslation();
- const { documents } = useStores();
- const document = documents.get(documentId);
- invariant(document, "Document must exist");
-
- const handleSubmit = React.useCallback(
- async (ev: React.SyntheticEvent) => {
- ev.preventDefault();
- setIsSaving(true);
-
- try {
- const template = await document.templatize();
-
- if (template) {
- history.push(documentUrl(template));
-
- showToast(t("Template created, go ahead and customize it"), {
- type: "info",
- });
- }
-
- onSubmit();
- } catch (err) {
- showToast(err.message, {
- type: "error",
- });
- } finally {
- setIsSaving(false);
- }
- },
- [document, showToast, history, onSubmit, t]
- );
-
- return (
-
-
-
- );
-}
-
-export default observer(DocumentTemplatize);
diff --git a/app/scenes/Login/Notices.tsx b/app/scenes/Login/Notices.tsx
index 91cead7cd..639e6e13d 100644
--- a/app/scenes/Login/Notices.tsx
+++ b/app/scenes/Login/Notices.tsx
@@ -71,6 +71,14 @@ export default function Notices() {
admin.
)}
+ {notice === "invite-required" && (
+
+ The team you are trying to join requires an invite before you can
+ create an account.
+
+ Please request an invite from your team admin and try again.
+
+ )}
>
);
}
diff --git a/app/scenes/Settings/Security.tsx b/app/scenes/Settings/Security.tsx
index 4ef5c8b59..b17e2f385 100644
--- a/app/scenes/Settings/Security.tsx
+++ b/app/scenes/Settings/Security.tsx
@@ -4,6 +4,7 @@ import { PadlockIcon } from "outline-icons";
import { useState } from "react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
+import ConfirmationDialog from "~/components/ConfirmationDialog";
import Heading from "~/components/Heading";
import InputSelect from "~/components/InputSelect";
import Scene from "~/components/Scene";
@@ -16,7 +17,7 @@ import useToasts from "~/hooks/useToasts";
import SettingRow from "./components/SettingRow";
function Security() {
- const { auth } = useStores();
+ const { auth, dialogs } = useStores();
const team = useCurrentTeam();
const { t } = useTranslation();
const { showToast } = useToasts();
@@ -26,8 +27,11 @@ function Security() {
guestSignin: team.guestSignin,
defaultUserRole: team.defaultUserRole,
memberCollectionCreate: team.memberCollectionCreate,
+ inviteRequired: team.inviteRequired,
});
+ const authenticationMethods = team.signinMethods;
+
const showSuccessMessage = React.useMemo(
() =>
debounce(() => {
@@ -38,22 +42,72 @@ function Security() {
[showToast, t]
);
- const handleChange = React.useCallback(
- async (ev: React.ChangeEvent) => {
- const newData = { ...data, [ev.target.id]: ev.target.checked };
- setData(newData);
- await auth.updateTeam(newData);
- showSuccessMessage();
+ const saveData = React.useCallback(
+ async (newData) => {
+ try {
+ setData(newData);
+ await auth.updateTeam(newData);
+ showSuccessMessage();
+ } catch (err) {
+ showToast(err.message, {
+ type: "error",
+ });
+ }
},
- [auth, data, showSuccessMessage]
+ [auth, showSuccessMessage, showToast]
);
- const handleDefaultRoleChange = async (newDefaultRole: string) => {
- const newData = { ...data, defaultUserRole: newDefaultRole };
- setData(newData);
- await auth.updateTeam(newData);
- showSuccessMessage();
- };
+ const handleChange = React.useCallback(
+ async (ev: React.ChangeEvent) => {
+ await saveData({ ...data, [ev.target.id]: ev.target.checked });
+ },
+ [data, saveData]
+ );
+
+ const handleDefaultRoleChange = React.useCallback(
+ async (newDefaultRole: string) => {
+ await saveData({ ...data, defaultUserRole: newDefaultRole });
+ },
+ [data, saveData]
+ );
+
+ const handleAllowSignupsChange = React.useCallback(
+ async (ev: React.ChangeEvent) => {
+ const inviteRequired = !ev.target.checked;
+ const newData = { ...data, inviteRequired };
+
+ if (inviteRequired) {
+ dialogs.openModal({
+ isCentered: true,
+ title: t("Are you sure you want to disable authorized signups?"),
+ content: (
+ {
+ await saveData(newData);
+ }}
+ submitText={t("I’m sure — Disable")}
+ savingText={`${t("Disabling")}…`}
+ danger
+ >
+ ,
+ }}
+ />
+
+ ),
+ });
+ return;
+ }
+
+ await saveData(newData);
+ },
+ [data, saveData, t, dialogs, authenticationMethods]
+ );
return (
}>
@@ -117,6 +171,28 @@ function Security() {
/>
+ ,
+ }}
+ />
+ }
+ >
+
+
+
{
collaborativeEditing,
defaultCollectionId,
defaultUserRole,
+ inviteRequired,
} = params;
if (subdomain !== undefined && process.env.SUBDOMAINS_ENABLED === "true") {
@@ -54,6 +55,9 @@ const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => {
if (defaultUserRole !== undefined) {
team.defaultUserRole = defaultUserRole;
}
+ if (inviteRequired !== undefined) {
+ team.inviteRequired = inviteRequired;
+ }
const changes = team.changed();
diff --git a/server/commands/userCreator.test.ts b/server/commands/userCreator.test.ts
index 2487e2ec2..979ea7efc 100644
--- a/server/commands/userCreator.test.ts
+++ b/server/commands/userCreator.test.ts
@@ -184,7 +184,7 @@ describe("userCreator", () => {
});
it("should create a user from an invited user", async () => {
- const team = await buildTeam();
+ const team = await buildTeam({ inviteRequired: true });
const invite = await buildInvite({
teamId: team.id,
email: "invite@example.com",
@@ -210,4 +210,33 @@ describe("userCreator", () => {
expect(user.email).toEqual(invite.email);
expect(isNewUser).toEqual(true);
});
+
+ it("should reject an uninvited user when invites are required", async () => {
+ const team = await buildTeam({ inviteRequired: true });
+
+ const authenticationProviders = await team.$get("authenticationProviders");
+ const authenticationProvider = authenticationProviders[0];
+ let error;
+
+ try {
+ await userCreator({
+ name: "Uninvited User",
+ email: "invite@ExamPle.com",
+ teamId: team.id,
+ ip,
+ authentication: {
+ authenticationProviderId: authenticationProvider.id,
+ providerId: "fake-service-id",
+ accessToken: "123",
+ scopes: ["read"],
+ },
+ });
+ } catch (err) {
+ error = err;
+ }
+
+ expect(error && error.toString()).toContain(
+ "You need an invite to join this team"
+ );
+ });
});
diff --git a/server/commands/userCreator.ts b/server/commands/userCreator.ts
index 4d39c9832..e55d8fad4 100644
--- a/server/commands/userCreator.ts
+++ b/server/commands/userCreator.ts
@@ -1,4 +1,5 @@
import { Op } from "sequelize";
+import { InviteRequiredError } from "@server/errors";
import { Event, Team, User, UserAuthentication } from "@server/models";
type UserCreatorResult = {
@@ -144,9 +145,16 @@ export default async function userCreator({
try {
const team = await Team.findByPk(teamId, {
- attributes: ["defaultUserRole"],
+ attributes: ["defaultUserRole", "inviteRequired"],
transaction,
});
+
+ // If the team settings are set to require invites, and the user is not already invited,
+ // throw an error and fail user creation.
+ if (team?.inviteRequired && !invite) {
+ throw InviteRequiredError();
+ }
+
const defaultUserRole = team?.defaultUserRole;
const user = await User.create(
diff --git a/server/errors.ts b/server/errors.ts
index 338efddc2..474c56465 100644
--- a/server/errors.ts
+++ b/server/errors.ts
@@ -20,6 +20,14 @@ export function AuthorizationError(
});
}
+export function InviteRequiredError(
+ message = "You need an invite to join this team"
+) {
+ return httpErrors(403, message, {
+ id: "invite_required",
+ });
+}
+
export function AdminRequiredError(
message = "An admin role is required to access this resource"
) {
diff --git a/server/migrations/20220413213537-add-inviteRequired-to-teams.js b/server/migrations/20220413213537-add-inviteRequired-to-teams.js
new file mode 100644
index 000000000..5d20ac3ba
--- /dev/null
+++ b/server/migrations/20220413213537-add-inviteRequired-to-teams.js
@@ -0,0 +1,14 @@
+'use strict';
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ await queryInterface.addColumn("teams", "inviteRequired", {
+ type: Sequelize.BOOLEAN,
+ defaultValue: false,
+ allowNull: false
+ });
+ },
+ down: async (queryInterface) => {
+ await queryInterface.removeColumn("teams", "inviteRequired");
+ },
+};
diff --git a/server/models/Team.ts b/server/models/Team.ts
index d414ce0d5..65b37ec3d 100644
--- a/server/models/Team.ts
+++ b/server/models/Team.ts
@@ -77,6 +77,10 @@ class Team extends ParanoidModel {
@Column
sharing: boolean;
+ @Default(false)
+ @Column
+ inviteRequired: boolean;
+
@Default(true)
@Column(DataType.JSONB)
signupQueryParams: { [key: string]: string } | null;
diff --git a/server/presenters/team.ts b/server/presenters/team.ts
index 60fab2cf4..d7e86c8eb 100644
--- a/server/presenters/team.ts
+++ b/server/presenters/team.ts
@@ -15,5 +15,6 @@ export default function present(team: Team) {
domain: team.domain,
url: team.url,
defaultUserRole: team.defaultUserRole,
+ inviteRequired: team.inviteRequired,
};
}
diff --git a/server/routes/api/team.ts b/server/routes/api/team.ts
index 0890ad384..a838bb339 100644
--- a/server/routes/api/team.ts
+++ b/server/routes/api/team.ts
@@ -20,6 +20,7 @@ router.post("team.update", auth(), async (ctx) => {
collaborativeEditing,
defaultCollectionId,
defaultUserRole,
+ inviteRequired,
} = ctx.body;
const { user } = ctx.state;
@@ -42,6 +43,7 @@ router.post("team.update", auth(), async (ctx) => {
collaborativeEditing,
defaultCollectionId,
defaultUserRole,
+ inviteRequired,
},
user,
team,
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index 804cd5281..0f84a115d 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -97,6 +97,9 @@
"{{ completed }} task done": "{{ completed }} task done",
"{{ completed }} task done_plural": "{{ completed }} tasks done",
"{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks",
+ "Template created, go ahead and customize it": "Template created, go ahead and customize it",
+ "Creating": "Creating",
+ "Creating a template from {{titleWithDefault}} is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from {{titleWithDefault}} is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.",
"Currently editing": "Currently editing",
"Currently viewing": "Currently viewing",
"Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago",
@@ -239,7 +242,7 @@
"Path to document": "Path to document",
"Group member options": "Group member options",
"Remove": "Remove",
- "Delete collection": "Delete collection",
+ "Delete collection": "Are you sure you want to delete this collection?",
"Sort in sidebar": "Sort in sidebar",
"Alphabetical sort": "Alphabetical sort",
"Manual sort": "Manual sort",
@@ -312,7 +315,7 @@
"Get started by creating a new one!": "Get started by creating a new one!",
"Create a document": "Create a document",
"Manage permissions": "Manage permissions",
- "Are you sure about that? Deleting the {{collectionName}} collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the {{collectionName}} collection is permanent and cannot be restored, however documents within will be moved to the trash.",
+ "Are you sure about that? Deleting the {{collectionName}} collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Deleting the {{collectionName}} collection is permanent and cannot be restored, however documents within will be moved to the trash.",
"Also, {{collectionName}} is being used as the start view – deleting it will reset the start view to the Home page.": "Also, {{collectionName}} is being used as the start view – deleting it will reset the start view to the Home page.",
"Deleting": "Deleting",
"I’m sure – Delete": "I’m sure – Delete",
@@ -331,7 +334,6 @@
"This is the default level of access, you can give individual users or groups more access once the collection is created.": "This is the default level of access, you can give individual users or groups more access once the collection is created.",
"Public document sharing": "Public document sharing",
"When enabled any documents within this collection can be shared publicly on the internet.": "When enabled any documents within this collection can be shared publicly on the internet.",
- "Creating": "Creating",
"Create": "Create",
"{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection",
"Could not add user": "Could not add user",
@@ -428,8 +430,6 @@
"Heads up – moving the document {{ title }} to the {{ newCollectionName }} collection will grant all team members {{ newPermission }}, they currently have {{ prevPermission }}.": "Heads up – moving the document {{ title }} to the {{ newCollectionName }} collection will grant all team members {{ newPermission }}, they currently have {{ prevPermission }}.",
"Moving": "Moving",
"Cancel": "Cancel",
- "Template created, go ahead and customize it": "Template created, go ahead and customize it",
- "Creating a template from {{titleWithDefault}} is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from {{titleWithDefault}} is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.",
"Search documents": "Search documents",
"No documents found for your filters.": "No documents found for your filters.",
"You’ve not got any drafts at the moment.": "You’ve not got any drafts at the moment.",
@@ -616,6 +616,10 @@
"Delete Account": "Delete Account",
"You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable",
"Delete account": "Delete account",
+ "Are you sure you want to disable authorized signups?": "Are you sure you want to disable authorized signups?",
+ "I’m sure — Disable": "I’m sure — Disable",
+ "Disabling": "Disabling",
+ "New account creation using {{ authenticationMethods }} will be disabled. New users will need to be invited.": "New account creation using {{ authenticationMethods }} will be disabled. New users will need to be invited.",
"Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.",
"Allow email authentication": "Allow email authentication",
"When enabled, users can sign-in using their email address": "When enabled, users can sign-in using their email address",
@@ -625,6 +629,8 @@
"Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents",
"Collection creation": "Collection creation",
"Allow members to create new collections within the knowledge base": "Allow members to create new collections within the knowledge base",
+ "Allow authorized signups": "Allow authorized signups",
+ "Allow authorized {{ authenticationMethods }} users to create new accounts without first receiving an invite": "Allow authorized {{ authenticationMethods }} users to create new accounts without first receiving an invite",
"Default role": "Default role",
"The default user role for new accounts. Changing this setting does not affect existing user accounts.": "The default user role for new accounts. Changing this setting does not affect existing user accounts.",
"Sharing is currently disabled.": "Sharing is currently disabled.",