feat: Add setting to allow users to send invites (#6488)

This commit is contained in:
Tom Moor
2024-02-03 17:37:39 -08:00
committed by GitHub
parent 9046892864
commit c2b7d01c7d
14 changed files with 121 additions and 64 deletions

View File

@@ -1,39 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { $Diff } from "utility-types";
import InputSelect, { Props, Option } from "~/components/InputSelect";
const InputSelectRole = (
props: $Diff<
Props,
{
options: Array<Option>;
ariaLabel: string;
}
>
) => {
const { t } = useTranslation();
return (
<InputSelect
label={t("Role")}
options={[
{
label: t("Editor"),
value: "member",
},
{
label: t("Viewer"),
value: "viewer",
},
{
label: t("Admin"),
value: "admin",
},
]}
ariaLabel={t("Role")}
{...props}
/>
);
};
export default InputSelectRole;

View File

@@ -12,7 +12,7 @@ import Button from "~/components/Button";
import CopyToClipboard from "~/components/CopyToClipboard"; import CopyToClipboard from "~/components/CopyToClipboard";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Input from "~/components/Input"; import Input from "~/components/Input";
import InputSelectRole from "~/components/InputSelectRole"; import InputSelect from "~/components/InputSelect";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer"; import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Text from "~/components/Text"; import Text from "~/components/Text";
@@ -157,6 +157,28 @@ function Invite({ onSubmit }: Props) {
</span> </span>
); );
const options = React.useMemo(() => {
const options = [
{
label: t("Editor"),
value: "member",
},
{
label: t("Viewer"),
value: "viewer",
},
];
if (user.isAdmin) {
options.push({
label: t("Admin"),
value: "admin",
});
}
return options;
}, [t, user]);
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{team.guestSignin ? ( {team.guestSignin ? (
@@ -236,7 +258,10 @@ function Invite({ onSubmit }: Props) {
required={!!invite.email} required={!!invite.email}
flex flex
/> />
<InputSelectRole <InputSelect
label={t("Role")}
ariaLabel={t("Role")}
options={options}
onChange={(role: UserRole) => handleRoleChange(role, index)} onChange={(role: UserRole) => handleRoleChange(role, index)}
value={invite.role} value={invite.role}
labelHidden={index !== 0} labelHidden={index !== 0}

View File

@@ -60,7 +60,7 @@ function Security() {
const saveData = React.useCallback( const saveData = React.useCallback(
async (newData) => { async (newData) => {
try { try {
setData(newData); setData((prev) => ({ ...prev, ...newData }));
await team.save(newData); await team.save(newData);
showSuccessMessage(); showSuccessMessage();
} catch (err) { } catch (err) {
@@ -72,16 +72,16 @@ function Security() {
const handleChange = React.useCallback( const handleChange = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => { async (ev: React.ChangeEvent<HTMLInputElement>) => {
await saveData({ ...data, [ev.target.id]: ev.target.checked }); await saveData({ [ev.target.id]: ev.target.checked });
}, },
[data, saveData] [saveData]
); );
const handleDefaultRoleChange = React.useCallback( const handleDefaultRoleChange = React.useCallback(
async (newDefaultRole: string) => { async (newDefaultRole: string) => {
await saveData({ ...data, defaultUserRole: newDefaultRole }); await saveData({ defaultUserRole: newDefaultRole });
}, },
[data, saveData] [saveData]
); );
const handlePreferenceChange = React.useCallback( const handlePreferenceChange = React.useCallback(
@@ -192,6 +192,17 @@ function Security() {
</SettingRow> </SettingRow>
<h2>{t("Access")}</h2> <h2>{t("Access")}</h2>
<SettingRow
label={t("Allow users to send invites")}
name={TeamPreference.MembersCanInvite}
description={t("Allow editors to invite other people to the workspace")}
>
<Switch
id={TeamPreference.MembersCanInvite}
checked={team.getPreference(TeamPreference.MembersCanInvite)}
onChange={handlePreferenceChange}
/>
</SettingRow>
{isCloudHosted && ( {isCloudHosted && (
<SettingRow <SettingRow
label={t("Require invites")} label={t("Require invites")}
@@ -280,7 +291,7 @@ function Security() {
label={t("Collection creation")} label={t("Collection creation")}
name="memberCollectionCreate" name="memberCollectionCreate"
description={t( description={t(
"Allow members to create new collections within the workspace" "Allow editors to create new collections within the workspace"
)} )}
> >
<Switch <Switch

View File

@@ -58,8 +58,8 @@ export default async function userInviter({
teamId: user.teamId, teamId: user.teamId,
name: invite.name, name: invite.name,
email: invite.email, email: invite.email,
isAdmin: invite.role === UserRole.Admin, isAdmin: user.isAdmin && invite.role === UserRole.Admin,
isViewer: invite.role === UserRole.Viewer, isViewer: user.isViewer || invite.role === UserRole.Viewer,
invitedById: user.id, invitedById: user.id,
flags: { flags: {
[UserFlag.InviteSent]: 1, [UserFlag.InviteSent]: 1,

View File

@@ -1,7 +1,7 @@
import { DefaultState } from "koa"; import { DefaultState } from "koa";
import randomstring from "randomstring"; import randomstring from "randomstring";
import ApiKey from "@server/models/ApiKey"; import ApiKey from "@server/models/ApiKey";
import { buildUser, buildTeam } from "@server/test/factories"; import { buildUser, buildTeam, buildAdmin } from "@server/test/factories";
import auth from "./authentication"; import auth from "./authentication";
describe("Authentication middleware", () => { describe("Authentication middleware", () => {
@@ -156,7 +156,7 @@ describe("Authentication middleware", () => {
it("should return an error for suspended users", async () => { it("should return an error for suspended users", async () => {
const state = {} as DefaultState; const state = {} as DefaultState;
const admin = await buildUser(); const admin = await buildAdmin();
const user = await buildUser({ const user = await buildUser({
suspendedAt: new Date(), suspendedAt: new Date(),
suspendedById: admin.id, suspendedById: admin.id,

View File

@@ -26,6 +26,7 @@ import {
AllowNull, AllowNull,
AfterUpdate, AfterUpdate,
BeforeUpdate, BeforeUpdate,
BeforeCreate,
} from "sequelize-typescript"; } from "sequelize-typescript";
import { TeamPreferenceDefaults } from "@shared/constants"; import { TeamPreferenceDefaults } from "@shared/constants";
import { import {
@@ -347,6 +348,14 @@ class Team extends ParanoidModel<
// hooks // hooks
@BeforeCreate
static async setPreferences(model: Team) {
// Set here rather than in TeamPreferenceDefaults as we only want to enable by default for new
// workspaces.
model.setPreference(TeamPreference.MembersCanInvite, true);
return model;
}
@BeforeUpdate @BeforeUpdate
static async checkDomain(model: Team, options: SaveOptions) { static async checkDomain(model: Team, options: SaveOptions) {
if (!model.domain) { if (!model.domain) {

View File

@@ -15,5 +15,5 @@ it("should serialize domain policies on Team", async () => {
}); });
const response = serialize(user, team); const response = serialize(user, team);
expect(response.createDocument).toEqual(true); expect(response.createDocument).toEqual(true);
expect(response.inviteUser).toEqual(false); expect(response.inviteUser).toEqual(true);
}); });

View File

@@ -1,3 +1,4 @@
import { TeamPreference } from "@shared/types";
import { User, Team } from "@server/models"; import { User, Team } from "@server/models";
import { AdminRequiredError } from "../errors"; import { AdminRequiredError } from "../errors";
import { allow } from "./cancan"; import { allow } from "./cancan";
@@ -10,10 +11,10 @@ allow(
); );
allow(User, "inviteUser", Team, (actor, team) => { allow(User, "inviteUser", Team, (actor, team) => {
if (!team || actor.teamId !== team.id) { if (!team || actor.teamId !== team.id || actor.isViewer) {
return false; return false;
} }
if (actor.isAdmin) { if (actor.isAdmin || team.getPreference(TeamPreference.MembersCanInvite)) {
return true; return true;
} }

View File

@@ -35,6 +35,8 @@ export const TeamsUpdateSchema = BaseSchema.extend({
publicBranding: z.boolean().optional(), publicBranding: z.boolean().optional(),
/** Whether viewers should see download options. */ /** Whether viewers should see download options. */
viewersCanExport: z.boolean().optional(), viewersCanExport: z.boolean().optional(),
/** Whether members can invite new people to the team. */
membersCanInvite: z.boolean().optional(),
/** Whether commenting is enabled */ /** Whether commenting is enabled */
commenting: z.boolean().optional(), commenting: z.boolean().optional(),
/** The custom theme for the team. */ /** The custom theme for the team. */

View File

@@ -47,7 +47,7 @@ const handleTeamUpdate = async (ctx: APIContext<T.TeamsUpdateSchemaReq>) => {
router.post( router.post(
"team.update", "team.update",
rateLimiter(RateLimiterStrategy.TenPerHour), rateLimiter(RateLimiterStrategy.TenPerMinute),
auth(), auth(),
validate(T.TeamsUpdateSchema), validate(T.TeamsUpdateSchema),
transaction(), transaction(),
@@ -56,7 +56,7 @@ router.post(
router.post( router.post(
"teams.update", "teams.update",
rateLimiter(RateLimiterStrategy.TenPerHour), rateLimiter(RateLimiterStrategy.TenPerMinute),
auth(), auth(),
validate(T.TeamsUpdateSchema), validate(T.TeamsUpdateSchema),
transaction(), transaction(),

View File

@@ -1,8 +1,10 @@
import { TeamPreference } from "@shared/types";
import { import {
buildTeam, buildTeam,
buildAdmin, buildAdmin,
buildUser, buildUser,
buildInvite, buildInvite,
buildViewer,
} from "@server/test/factories"; } from "@server/test/factories";
import { getTestServer } from "@server/test/support"; import { getTestServer } from "@server/test/support";
@@ -271,11 +273,51 @@ describe("#users.invite", () => {
expect(res.status).toEqual(400); expect(res.status).toEqual(400);
}); });
it("should require admin", async () => { it("should allow members to invite members", async () => {
const admin = await buildUser(); const user = await buildUser();
const res = await server.post("/api/users.invite", { const res = await server.post("/api/users.invite", {
body: { body: {
token: admin.getJwtToken(), token: user.getJwtToken(),
invites: [
{
email: "test@example.com",
name: "Test",
role: "member",
},
],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.sent.length).toEqual(1);
});
it("should now allow viewers to invite", async () => {
const user = await buildViewer();
const res = await server.post("/api/users.invite", {
body: {
token: user.getJwtToken(),
invites: [
{
email: "test@example.com",
name: "Test",
role: "member",
},
],
},
});
expect(res.status).toEqual(403);
});
it("should allow restricting invites to admin", async () => {
const team = await buildTeam();
team.setPreference(TeamPreference.MembersCanInvite, false);
await team.save();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/users.invite", {
body: {
token: user.getJwtToken(),
invites: [ invites: [
{ {
email: "test@example.com", email: "test@example.com",

View File

@@ -17,6 +17,7 @@ export const Pagination = {
export const TeamPreferenceDefaults: TeamPreferences = { export const TeamPreferenceDefaults: TeamPreferences = {
[TeamPreference.SeamlessEdit]: true, [TeamPreference.SeamlessEdit]: true,
[TeamPreference.ViewersCanExport]: true, [TeamPreference.ViewersCanExport]: true,
[TeamPreference.MembersCanInvite]: false,
[TeamPreference.PublicBranding]: false, [TeamPreference.PublicBranding]: false,
[TeamPreference.Commenting]: true, [TeamPreference.Commenting]: true,
[TeamPreference.CustomTheme]: undefined, [TeamPreference.CustomTheme]: undefined,

View File

@@ -232,10 +232,6 @@
"View only": "View only", "View only": "View only",
"No access": "No access", "No access": "No access",
"Default access": "Default access", "Default access": "Default access",
"Role": "Role",
"Editor": "Editor",
"Viewer": "Viewer",
"Admin": "Admin",
"{{appName}} is available in your language {{optionLabel}}, would you like to change?": "{{appName}} is available in your language {{optionLabel}}, would you like to change?", "{{appName}} is available in your language {{optionLabel}}, would you like to change?": "{{appName}} is available in your language {{optionLabel}}, would you like to change?",
"Change Language": "Change Language", "Change Language": "Change Language",
"Dismiss": "Dismiss", "Dismiss": "Dismiss",
@@ -256,6 +252,8 @@
"Has access through <2>parent</2>": "Has access through <2>parent</2>", "Has access through <2>parent</2>": "Has access through <2>parent</2>",
"Suspended": "Suspended", "Suspended": "Suspended",
"Invited": "Invited", "Invited": "Invited",
"Viewer": "Viewer",
"Editor": "Editor",
"Leave": "Leave", "Leave": "Leave",
"All members": "All members", "All members": "All members",
"Everyone in the workspace": "Everyone in the workspace", "Everyone in the workspace": "Everyone in the workspace",
@@ -509,6 +507,7 @@
"Search people": "Search people", "Search people": "Search people",
"No people matching your search": "No people matching your search", "No people matching your search": "No people matching your search",
"No people left to add": "No people left to add", "No people left to add": "No people left to add",
"Admin": "Admin",
"Active <1></1> ago": "Active <1></1> ago", "Active <1></1> ago": "Active <1></1> ago",
"Never signed in": "Never signed in", "Never signed in": "Never signed in",
"{{ userName }} was removed from the collection": "{{ userName }} was removed from the collection", "{{ userName }} was removed from the collection": "{{ userName }} was removed from the collection",
@@ -659,6 +658,7 @@
"As an admin you can also <2>enable email sign-in</2>.": "As an admin you can also <2>enable email sign-in</2>.", "As an admin you can also <2>enable email sign-in</2>.": "As an admin you can also <2>enable email sign-in</2>.",
"Want a link to share directly with your team?": "Want a link to share directly with your team?", "Want a link to share directly with your team?": "Want a link to share directly with your team?",
"Email": "Email", "Email": "Email",
"Role": "Role",
"Remove invite": "Remove invite", "Remove invite": "Remove invite",
"Add another": "Add another", "Add another": "Add another",
"Inviting": "Inviting", "Inviting": "Inviting",
@@ -903,6 +903,8 @@
"Allow members to sign-in using their email address": "Allow members to sign-in using their email address", "Allow members to sign-in using their email address": "Allow members to sign-in using their email address",
"The server must have SMTP configured to enable this setting": "The server must have SMTP configured to enable this setting", "The server must have SMTP configured to enable this setting": "The server must have SMTP configured to enable this setting",
"Access": "Access", "Access": "Access",
"Allow users to send invites": "Allow users to send invites",
"Allow editors to invite other people to the workspace": "Allow editors to invite other people to the workspace",
"Require invites": "Require invites", "Require invites": "Require invites",
"Require members to be invited to the workspace before they can create an account using SSO.": "Require members to be invited to the workspace before they can create an account using SSO.", "Require members to be invited to the workspace before they can create an account using SSO.": "Require members to be invited to the workspace before they can create an account using SSO.",
"Default role": "Default role", "Default role": "Default role",
@@ -913,7 +915,7 @@
"Rich service embeds": "Rich service embeds", "Rich service embeds": "Rich service embeds",
"Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents", "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", "Collection creation": "Collection creation",
"Allow members to create new collections within the workspace": "Allow members to create new collections within the workspace", "Allow editors to create new collections within the workspace": "Allow editors to create new collections within the workspace",
"Draw.io deployment": "Draw.io deployment", "Draw.io deployment": "Draw.io deployment",
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.", "Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.",
"Grist deployment": "Grist deployment", "Grist deployment": "Grist deployment",

View File

@@ -163,6 +163,8 @@ export enum TeamPreference {
PublicBranding = "publicBranding", PublicBranding = "publicBranding",
/** Whether viewers should see download options. */ /** Whether viewers should see download options. */
ViewersCanExport = "viewersCanExport", ViewersCanExport = "viewersCanExport",
/** Whether members can invite new users. */
MembersCanInvite = "membersCanInvite",
/** Whether users can comment on documents. */ /** Whether users can comment on documents. */
Commenting = "commenting", Commenting = "commenting",
/** The custom theme for the team. */ /** The custom theme for the team. */
@@ -173,6 +175,7 @@ export type TeamPreferences = {
[TeamPreference.SeamlessEdit]?: boolean; [TeamPreference.SeamlessEdit]?: boolean;
[TeamPreference.PublicBranding]?: boolean; [TeamPreference.PublicBranding]?: boolean;
[TeamPreference.ViewersCanExport]?: boolean; [TeamPreference.ViewersCanExport]?: boolean;
[TeamPreference.MembersCanInvite]?: boolean;
[TeamPreference.Commenting]?: boolean; [TeamPreference.Commenting]?: boolean;
[TeamPreference.CustomTheme]?: Partial<CustomTheme>; [TeamPreference.CustomTheme]?: Partial<CustomTheme>;
}; };