feat: allow admins to require invites before user accounts can be created (#3381)

* allow admins to require invites before user accounts can be created
* use new dialog component for general confirmation dialogs
This commit is contained in:
Nan Yu
2022-04-19 12:27:23 -07:00
committed by GitHub
parent 1b913054e8
commit 233f3af667
19 changed files with 313 additions and 136 deletions

View File

@@ -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: (
<DocumentTemplatize
documentId={activeDocumentId}
onSubmit={stores.dialogs.closeAllModals}
/>
),
isCentered: true,
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
});
},
});

View File

@@ -111,7 +111,7 @@ const RealButton = styled.button<{
}
&:disabled {
background: none;
background: ${lighten(0.05, props.theme.danger)};
}
&.focus-visible {

View File

@@ -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 (
<Flex column>
<form onSubmit={handleSubmit}>
<Text type="secondary">{children}</Text>
<Button type="submit" disabled={isSaving} danger={danger} autoFocus>
{isSaving ? savingText : submitText}
</Button>
</form>
</Flex>
);
}
export default observer(ConfirmationDialog);

View File

@@ -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 (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Create template")}
savingText={`${t("Creating")}`}
>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> 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."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export default observer(DocumentTemplatizeDialog);

View File

@@ -82,9 +82,11 @@ const Modal: React.FC<Props> = ({
{title}
</Text>
)}
<NudeButton onClick={onRequestClose}>
<CloseIcon color="currentColor" />
</NudeButton>
<Text as="span" size="large">
<NudeButton onClick={onRequestClose}>
<CloseIcon color="currentColor" />
</NudeButton>
</Text>
</Header>
<SmallContent shadow>{children}</SmallContent>
</Centered>
@@ -261,6 +263,11 @@ const Small = styled.div`
&[aria-expanded="true"] {
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
vertical-align: middle;
}
${Header} {
align-items: start;
}
`;

View File

@@ -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<HTMLInputElement>(null);
const handleOpen = React.useCallback(() => {
@@ -330,6 +327,7 @@ function DocumentMenu({
visible: !document.isStarred && !!can.star,
icon: <StarredIcon />,
},
// Pin document
actionToMenuItem(pinDocument, context),
{
type: "separator",
@@ -357,14 +355,8 @@ function DocumentMenu({
onClick: handleImportDocument,
icon: <ImportIcon />,
},
{
type: "button",
title: `${t("Create template")}`,
onClick: () => setShowTemplateModal(true),
visible:
!!can.update && !document.isTemplate && !document.isDraft,
icon: <ShapesIcon />,
},
// Templatize document
actionToMenuItem(createTemplate, context),
{
type: "button",
title: t("Duplicate"),
@@ -518,19 +510,6 @@ function DocumentMenu({
/>
</Modal>
)}
{can.update && (
<Modal
title={t("Create template")}
onRequestClose={() => setShowTemplateModal(false)}
isOpen={showTemplateModal}
isCentered
>
<DocumentTemplatize
documentId={document.id}
onSubmit={() => setShowTemplateModal(false)}
/>
</Modal>
)}
</>
)}
</>

View File

@@ -19,6 +19,10 @@ class Team extends BaseModel {
@observable
sharing: boolean;
@Field
@observable
inviteRequired: boolean;
@Field
@observable
collaborativeEditing: boolean;

View File

@@ -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<boolean>();
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 (
<Flex column>
<form onSubmit={handleSubmit}>
<Text type="secondary">
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> 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."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</Text>
<Button type="submit">
{isSaving ? `${t("Creating")}` : t("Create template")}
</Button>
</form>
</Flex>
);
}
export default observer(DocumentTemplatize);

View File

@@ -71,6 +71,14 @@ export default function Notices() {
admin.
</NoticeAlert>
)}
{notice === "invite-required" && (
<NoticeAlert>
The team you are trying to join requires an invite before you can
create an account.
<hr />
Please request an invite from your team admin and try again.
</NoticeAlert>
)}
</>
);
}

View File

@@ -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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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: (
<ConfirmationDialog
onSubmit={async () => {
await saveData(newData);
}}
submitText={t("Im sure — Disable")}
savingText={`${t("Disabling")}`}
danger
>
<Trans
defaults="New account creation using <em>{{ authenticationMethods }}</em> will be disabled. New users will need to be invited."
values={{
authenticationMethods,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
),
});
return;
}
await saveData(newData);
},
[data, saveData, t, dialogs, authenticationMethods]
);
return (
<Scene title={t("Security")} icon={<PadlockIcon color="currentColor" />}>
@@ -117,6 +171,28 @@ function Security() {
/>
</SettingRow>
<SettingRow
label={t("Allow authorized signups")}
name="allowSignups"
description={
<Trans
defaults="Allow authorized <em>{{ authenticationMethods }}</em> users to create new accounts without first receiving an invite"
values={{
authenticationMethods,
}}
components={{
em: <strong />,
}}
/>
}
>
<Switch
id="allowSignups"
checked={!data.inviteRequired}
onChange={handleAllowSignupsChange}
/>
</SettingRow>
<SettingRow
label={t("Default role")}
name="defaultUserRole"

View File

@@ -21,6 +21,7 @@ const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => {
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();

View File

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

View File

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

View File

@@ -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"
) {

View File

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

View File

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

View File

@@ -15,5 +15,6 @@ export default function present(team: Team) {
domain: team.domain,
url: team.url,
defaultUserRole: team.defaultUserRole,
inviteRequired: team.inviteRequired,
};
}

View File

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

View File

@@ -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 <em>{{titleWithDefault}}</em> 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 <em>{{titleWithDefault}}</em> 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 <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.",
"Deleting": "Deleting",
"Im sure Delete": "Im 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 <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all team members <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}.": "Heads up moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all team members <em>{{ newPermission }}</em>, 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 <em>{{titleWithDefault}}</em> 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 <em>{{titleWithDefault}}</em> 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.",
"Youve not got any drafts at the moment.": "Youve 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?",
"Im sure — Disable": "Im sure — Disable",
"Disabling": "Disabling",
"New account creation using <em>{{ authenticationMethods }}</em> will be disabled. New users will need to be invited.": "New account creation using <em>{{ authenticationMethods }}</em> 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 <em>{{ authenticationMethods }}</em> users to create new accounts without first receiving an invite": "Allow authorized <em>{{ authenticationMethods }}</em> 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.",