fix: Add default role option for new users (#2665)

* Add defaultUserRole on server

* Handle defaultUserRole on frontend

* Handle tests

* Handle user role in userCreator

* Minor improvments

* Fix prettier issue

* Undefined when isNewTeam is false

* Update app/scenes/Settings/Security.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/Settings/Security.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/Settings/Security.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Remove duplicate validation

* Update Team.js

* fix: Move note out of restricted width wrapper

* Move language setting to use 'note' prop

* Remove admin option

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Saumya Pandey
2021-10-20 09:26:11 +05:30
committed by GitHub
parent 90fdf5106a
commit 3610a7f4a2
13 changed files with 189 additions and 23 deletions

View File

@ -12,6 +12,7 @@ import { VisuallyHidden } from "reakit/VisuallyHidden";
import scrollIntoView from "smooth-scroll-into-view-if-needed"; import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import Button, { Inner } from "components/Button"; import Button, { Inner } from "components/Button";
import HelpText from "components/HelpText";
import { Position, Background, Backdrop } from "./ContextMenu"; import { Position, Background, Backdrop } from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem"; import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import { LabelText } from "./Input"; import { LabelText } from "./Input";
@ -30,6 +31,7 @@ export type Props = {
labelHidden?: boolean, labelHidden?: boolean,
icon?: React.Node, icon?: React.Node,
options: Option[], options: Option[],
note?: React.Node,
onChange: (string) => Promise<void> | void, onChange: (string) => Promise<void> | void,
}; };
@ -49,6 +51,7 @@ const InputSelect = (props: Props) => {
onChange, onChange,
disabled, disabled,
nude, nude,
note,
icon, icon,
} = props; } = props;
@ -124,6 +127,7 @@ const InputSelect = (props: Props) => {
) : ( ) : (
wrappedLabel wrappedLabel
))} ))}
<Select <Select
{...select} {...select}
disabled={disabled} disabled={disabled}
@ -201,6 +205,8 @@ const InputSelect = (props: Props) => {
}} }}
</SelectPopover> </SelectPopover>
</Wrapper> </Wrapper>
{note && <HelpText small>{note}</HelpText>}
{(select.visible || select.animating) && <Backdrop />} {(select.visible || select.animating) && <Backdrop />}
</> </>
); );

View File

@ -13,6 +13,7 @@ class Team extends BaseModel {
subdomain: ?string; subdomain: ?string;
domain: ?string; domain: ?string;
url: string; url: string;
defaultUserRole: string;
@computed @computed
get signinMethods(): string { get signinMethods(): string {

View File

@ -107,23 +107,22 @@ const Profile = () => {
value={language} value={language}
onChange={handleLanguageChange} onChange={handleLanguageChange}
ariaLabel={t("Language")} ariaLabel={t("Language")}
note={
<Trans>
Please note that translations are currently in early access.
<br />
Community contributions are accepted though our{" "}
<a
href="https://translate.getoutline.com"
target="_blank"
rel="noreferrer"
>
translation portal
</a>
</Trans>
}
short short
/> />
<HelpText small>
<Trans>
Please note that translations are currently in early access.
<br />
Community contributions are accepted though our{" "}
<a
href="https://translate.getoutline.com"
target="_blank"
rel="noreferrer"
>
translation portal
</a>
</Trans>
.
</HelpText>
<Button type="submit" disabled={isSaving || !isValid}> <Button type="submit" disabled={isSaving || !isValid}>
{isSaving ? `${t("Saving")}` : t("Save")} {isSaving ? `${t("Saving")}` : t("Save")}
</Button> </Button>

View File

@ -8,6 +8,7 @@ import { useTranslation, Trans } from "react-i18next";
import Checkbox from "components/Checkbox"; import Checkbox from "components/Checkbox";
import Heading from "components/Heading"; import Heading from "components/Heading";
import HelpText from "components/HelpText"; import HelpText from "components/HelpText";
import InputSelect from "components/InputSelect";
import Scene from "components/Scene"; import Scene from "components/Scene";
import env from "env"; import env from "env";
import useCurrentTeam from "hooks/useCurrentTeam"; import useCurrentTeam from "hooks/useCurrentTeam";
@ -23,13 +24,20 @@ function Security() {
sharing: team.sharing, sharing: team.sharing,
documentEmbeds: team.documentEmbeds, documentEmbeds: team.documentEmbeds,
guestSignin: team.guestSignin, guestSignin: team.guestSignin,
defaultUserRole: team.defaultUserRole,
}); });
const showSuccessMessage = React.useCallback( const notes = {
debounce(() => { member: t("New user accounts will be given member permissions by default"),
showToast(t("Settings saved"), { type: "success" }); viewer: t("New user accounts will be given viewer permissions by default"),
}, 250), };
[t, showToast]
const showSuccessMessage = React.useMemo(
() =>
debounce(() => {
showToast(t("Settings saved"), { type: "success" });
}, 250),
[showToast, t]
); );
const handleChange = React.useCallback( const handleChange = React.useCallback(
@ -44,6 +52,15 @@ function Security() {
[auth, data, showSuccessMessage] [auth, data, showSuccessMessage]
); );
const handleDefaultRoleChange = async (newDefaultRole: string) => {
const newData = { ...data, defaultUserRole: newDefaultRole };
setData(newData);
await auth.updateTeam(newData);
showSuccessMessage();
};
return ( return (
<Scene title={t("Security")} icon={<PadlockIcon color="currentColor" />}> <Scene title={t("Security")} icon={<PadlockIcon color="currentColor" />}>
<Heading> <Heading>
@ -86,6 +103,18 @@ function Security() {
"Links to supported services are shown as rich embeds within your documents" "Links to supported services are shown as rich embeds within your documents"
)} )}
/> />
<InputSelect
value={data.defaultUserRole}
label="Default role"
options={[
{ label: t("Member"), value: "member" },
{ label: t("Viewer"), value: "viewer" },
]}
onChange={handleDefaultRoleChange}
ariaLabel={t("Default role")}
note={notes[data.defaultUserRole]}
short
/>
</Scene> </Scene>
); );
} }

View File

@ -76,7 +76,7 @@ export default async function accountProvisioner({
name: userParams.name, name: userParams.name,
email: userParams.email, email: userParams.email,
username: userParams.username, username: userParams.username,
isAdmin: isNewTeam, isAdmin: isNewTeam || undefined,
avatarUrl: userParams.avatarUrl, avatarUrl: userParams.avatarUrl,
teamId: team.id, teamId: team.id,
ip, ip,

View File

@ -1,6 +1,6 @@
// @flow // @flow
import Sequelize from "sequelize"; import Sequelize from "sequelize";
import { Event, User, UserAuthentication } from "../models"; import { Event, Team, User, UserAuthentication } from "../models";
import { sequelize } from "../sequelize"; import { sequelize } from "../sequelize";
const Op = Sequelize.Op; const Op = Sequelize.Op;
@ -126,12 +126,18 @@ export default async function userCreator({
let transaction = await sequelize.transaction(); let transaction = await sequelize.transaction();
try { try {
const { defaultUserRole } = await Team.findByPk(teamId, {
attributes: ["defaultUserRole"],
transaction,
});
const user = await User.create( const user = await User.create(
{ {
name, name,
email, email,
username, username,
isAdmin, isAdmin: typeof isAdmin === "boolean" && isAdmin,
isViewer: isAdmin === true ? false : defaultUserRole === "viewer",
teamId, teamId,
avatarUrl, avatarUrl,
service: null, service: null,

View File

@ -122,9 +122,83 @@ describe("userCreator", () => {
expect(authentication.scopes[0]).toEqual("read"); expect(authentication.scopes[0]).toEqual("read");
expect(user.email).toEqual("test@example.com"); expect(user.email).toEqual("test@example.com");
expect(user.username).toEqual("tname"); expect(user.username).toEqual("tname");
expect(user.isAdmin).toEqual(false);
expect(user.isViewer).toEqual(false);
expect(isNewUser).toEqual(true); expect(isNewUser).toEqual(true);
}); });
it("should prefer isAdmin argument over defaultUserRole", async () => {
const team = await buildTeam({ defaultUserRole: "viewer" });
const authenticationProviders = await team.getAuthenticationProviders();
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
name: "Test Name",
email: "test@example.com",
username: "tname",
teamId: team.id,
isAdmin: true,
ip,
authentication: {
authenticationProviderId: authenticationProvider.id,
providerId: "fake-service-id",
accessToken: "123",
scopes: ["read"],
},
});
const { user } = result;
expect(user.isAdmin).toEqual(true);
});
it("should prefer defaultUserRole when isAdmin is undefined or false", async () => {
const team = await buildTeam({ defaultUserRole: "viewer" });
const authenticationProviders = await team.getAuthenticationProviders();
const authenticationProvider = authenticationProviders[0];
const result = await userCreator({
name: "Test Name",
email: "test@example.com",
username: "tname",
teamId: team.id,
ip,
authentication: {
authenticationProviderId: authenticationProvider.id,
providerId: "fake-service-id",
accessToken: "123",
scopes: ["read"],
},
});
const { user: tname } = result;
expect(tname.username).toEqual("tname");
expect(tname.isAdmin).toEqual(false);
expect(tname.isViewer).toEqual(true);
const tname2Result = await userCreator({
name: "Test2 Name",
email: "tes2@example.com",
username: "tname2",
teamId: team.id,
isAdmin: false,
ip,
authentication: {
authenticationProviderId: authenticationProvider.id,
providerId: "fake-service-id",
accessToken: "123",
scopes: ["read"],
},
});
const { user: tname2 } = tname2Result;
expect(tname2.username).toEqual("tname2");
expect(tname2.isAdmin).toEqual(false);
expect(tname2.isViewer).toEqual(true);
});
it("should create a user from an invited user", async () => { it("should create a user from an invited user", async () => {
const team = await buildTeam(); const team = await buildTeam();
const invite = await buildInvite({ teamId: team.id }); const invite = await buildInvite({ teamId: team.id });

View File

@ -0,0 +1,15 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("teams", "defaultUserRole", {
type: Sequelize.STRING,
defaultValue: "member",
allowNull: false,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("teams", "defaultUserRole");
}
};

View File

@ -75,6 +75,17 @@ const Team = sequelize.define(
allowNull: false, allowNull: false,
defaultValue: false, defaultValue: false,
}, },
defaultUserRole: {
type: DataTypes.STRING,
defaultValue: "member",
allowNull: false,
validate: {
isIn: {
args: [["viewer", "member"]],
msg: "Must be 'viewer' or 'member'",
},
},
},
}, },
{ {
paranoid: true, paranoid: true,

View File

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

View File

@ -18,6 +18,7 @@ router.post("team.update", auth(), async (ctx) => {
guestSignin, guestSignin,
documentEmbeds, documentEmbeds,
collaborativeEditing, collaborativeEditing,
defaultUserRole,
} = ctx.body; } = ctx.body;
const user = ctx.state.user; const user = ctx.state.user;
const team = await Team.findByPk(user.teamId); const team = await Team.findByPk(user.teamId);
@ -35,6 +36,9 @@ router.post("team.update", auth(), async (ctx) => {
if (collaborativeEditing !== undefined) { if (collaborativeEditing !== undefined) {
team.collaborativeEditing = collaborativeEditing; team.collaborativeEditing = collaborativeEditing;
} }
if (defaultUserRole !== undefined) {
team.defaultUserRole = defaultUserRole;
}
const changes = team.changed(); const changes = team.changed();
const data = {}; const data = {};

View File

@ -21,6 +21,23 @@ describe("#team.update", () => {
expect(body.data.name).toEqual("New name"); expect(body.data.name).toEqual("New name");
}); });
it("should only allow member,viewer or admin as default role", async () => {
const { admin } = await seed();
const res = await server.post("/api/team.update", {
body: { token: admin.getJwtToken(), defaultUserRole: "New name" },
});
expect(res.status).toEqual(400);
const successRes = await server.post("/api/team.update", {
body: { token: admin.getJwtToken(), defaultUserRole: "viewer" },
});
const body = await successRes.json();
expect(successRes.status).toEqual(200);
expect(body.data.defaultUserRole).toBe("viewer");
});
it("should allow identical team details", async () => { it("should allow identical team details", async () => {
const { admin, team } = await seed(); const { admin, team } = await seed();
const res = await server.post("/api/team.update", { const res = await server.post("/api/team.update", {

View File

@ -561,6 +561,8 @@
"Delete Account": "Delete Account", "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", "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", "Delete account": "Delete account",
"New user accounts will be given member permissions by default": "New user accounts will be given member permissions by default",
"New user accounts will be given viewer permissions by default": "New user accounts will be given viewer permissions by default",
"Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.", "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", "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", "When enabled, users can sign-in using their email address": "When enabled, users can sign-in using their email address",
@ -568,6 +570,7 @@
"When enabled, documents can be shared publicly on the internet by any team member": "When enabled, documents can be shared publicly on the internet by any team member", "When enabled, documents can be shared publicly on the internet by any team member": "When enabled, documents can be shared publicly on the internet by any team member",
"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",
"Default role": "Default role",
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.", "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.",
"Sharing is currently disabled.": "Sharing is currently disabled.", "Sharing is currently disabled.": "Sharing is currently disabled.",
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.", "You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",