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:
@ -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 />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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 {
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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 });
|
||||||
|
15
server/migrations/20211015170955-add-defaultUserRole.js
Normal file
15
server/migrations/20211015170955-add-defaultUserRole.js
Normal 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");
|
||||||
|
}
|
||||||
|
};
|
@ -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,
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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 = {};
|
||||||
|
@ -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", {
|
||||||
|
@ -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>.",
|
||||||
|
Reference in New Issue
Block a user