feat: Read-only users (#1955)

* Introduce isViewer field

* Update policies

* Make users read-only feature

* Remove not demoting current user validation

* Update tests

* Catch the unhandled promise rejection

* Hide unnecessary ui elements for read-only user

* Update app/scenes/Settings/People.js

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

* Remove redundant logic for admin only policies

* Use can logic

* Update snapshot

* Remove lint error

* Update snapshot

* Minor fix

* Update app/menus/UserMenu.js

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

* Update server/api/users.js

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

* Update app/components/DocumentListItem.js

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

* Update app/stores/UsersStore.js

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

* Use useCurrentTeam hook in functional component

* Update translation

* Update ternary

* Remove punctuation

* Move the functions to User model

* Update share policy and shareMenu

* Rename makeAdmin to promote

* Create updateCounts function and Rank enum

* Update tests

* Remove enum

* Use async await, remove enum and create computed accessor

* Remove unused variable

* Fix lint issues

* Hide templates

* Create shared/types and use rank type from it

* Delete shared/utils/rank type file

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Saumya Pandey
2021-04-12 08:09:17 +05:30
committed by GitHub
parent cdc7f61fa1
commit bc4fe05147
34 changed files with 508 additions and 189 deletions

View File

@ -15,7 +15,9 @@ import Flex from "components/Flex";
import Highlight from "components/Highlight";
import StarButton, { AnimatedStar } from "components/Star";
import Tooltip from "components/Tooltip";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
@ -41,7 +43,9 @@ function replaceResultMarks(tag: string) {
function DocumentListItem(props: Props) {
const { t } = useTranslation();
const { policies } = useStores();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, setMenuOpen] = React.useState(false);
const {
document,
@ -60,6 +64,7 @@ function DocumentListItem(props: Props) {
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = policies.abilities(currentTeam.id);
return (
<DocumentLink
@ -111,7 +116,10 @@ function DocumentListItem(props: Props) {
/>
</Content>
<Actions>
{document.isTemplate && !document.isArchived && !document.isDeleted && (
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted &&
can.createDocument && (
<>
<Button
as={Link}

View File

@ -114,6 +114,7 @@ function MainSidebar() {
exact={false}
label={t("Starred")}
/>
{can.createDocument && (
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
@ -123,6 +124,8 @@ function MainSidebar() {
documents.active ? documents.active.template : undefined
}
/>
)}
{can.createDocument && (
<SidebarLink
to="/drafts"
icon={<EditIcon color="currentColor" />}
@ -140,6 +143,7 @@ function MainSidebar() {
: undefined
}
/>
)}
</Section>
<Section auto>
<Collections

View File

@ -71,11 +71,13 @@ function SettingsSidebar() {
icon={<EmailIcon color="currentColor" />}
label={t("Notifications")}
/>
{can.createApiKey && (
<SidebarLink
to="/settings/tokens"
icon={<CodeIcon color="currentColor" />}
label={t("API Tokens")}
/>
)}
</Section>
<Section>
<Header>{t("Team")}</Header>

View File

@ -13,6 +13,7 @@ import CollectionsLoading from "./CollectionsLoading";
import DropCursor from "./DropCursor";
import Header from "./Header";
import SidebarLink from "./SidebarLink";
import useCurrentTeam from "hooks/useCurrentTeam";
type Props = {
onCreateCollection: () => void,
};
@ -22,7 +23,9 @@ function Collections({ onCreateCollection }: Props) {
const { ui, policies, documents, collections } = useStores();
const isPreloaded: boolean = !!collections.orderedData.length;
const { t } = useTranslation();
const team = useCurrentTeam();
const orderedCollections = collections.orderedData;
const can = policies.abilities(team.id);
const [isDraggingAnyCollection, setIsDraggingAnyCollection] = React.useState(
false
);
@ -77,6 +80,7 @@ function Collections({ onCreateCollection }: Props) {
belowCollection={orderedCollections[index + 1]}
/>
))}
{can.createCollection && (
<SidebarLink
to="/collections"
onClick={onCreateCollection}
@ -84,6 +88,7 @@ function Collections({ onCreateCollection }: Props) {
label={`${t("New collection")}`}
exact
/>
)}
</>
);

View File

@ -12,14 +12,21 @@ import ContextMenu from "components/ContextMenu";
import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
function NewDocumentMenu() {
const menu = useMenuState();
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const singleCollection = collections.orderedData.length === 1;
const can = policies.abilities(team.id);
if (!can.createDocument) {
return;
}
if (singleCollection) {
return (

View File

@ -11,13 +11,20 @@ import ContextMenu from "components/ContextMenu";
import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
function NewTemplateMenu() {
const menu = useMenuState();
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = policies.abilities(team.id);
if (!can.createDocument) {
return;
}
return (
<>

View File

@ -17,9 +17,10 @@ type Props = {
function ShareMenu({ share }: Props) {
const menu = useMenuState({ modal: true });
const { ui, shares } = useStores();
const { ui, shares, policies } = useStores();
const { t } = useTranslation();
const history = useHistory();
const can = policies.abilities(share.id);
const handleGoToDocument = React.useCallback(
(ev: SyntheticEvent<>) => {
@ -57,10 +58,14 @@ function ShareMenu({ share }: Props) {
<MenuItem {...menu} onClick={handleGoToDocument}>
{t("Go to document")}
</MenuItem>
{can.revoke && (
<>
<hr />
<MenuItem {...menu} onClick={handleRevoke}>
{t("Revoke link")}
</MenuItem>
</>
)}
</ContextMenu>
</>
);

View File

@ -37,7 +37,7 @@ function UserMenu({ user }: Props) {
[users, user, t]
);
const handleDemote = React.useCallback(
const handleMember = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
if (
@ -49,7 +49,27 @@ function UserMenu({ user }: Props) {
) {
return;
}
users.demote(user);
users.demote(user, "Member");
},
[users, user, t]
);
const handleViewer = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
if (
!window.confirm(
t(
"Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content",
{
userName: user.name,
}
)
)
) {
return;
}
users.demote(user, "Viewer");
},
[users, user, t]
);
@ -95,18 +115,25 @@ function UserMenu({ user }: Props) {
{...menu}
items={[
{
title: t("Make {{ userName }} a member", {
title: t("Make {{ userName }} a member", {
userName: user.name,
}),
onClick: handleDemote,
visible: can.demote,
onClick: handleMember,
visible: can.demote && user.rank !== "Member",
},
{
title: t("Make {{ userName }} a viewer", {
userName: user.name,
}),
onClick: handleViewer,
visible: can.demote && user.rank !== "Viewer",
},
{
title: t("Make {{ userName }} an admin…", {
userName: user.name,
}),
onClick: handlePromote,
visible: can.promote,
visible: can.promote && user.rank !== "Admin",
},
{
type: "separator",

View File

@ -1,5 +1,6 @@
// @flow
import { computed } from "mobx";
import type { Rank } from "shared/types";
import BaseModel from "./BaseModel";
class User extends BaseModel {
@ -8,6 +9,7 @@ class User extends BaseModel {
name: string;
email: string;
isAdmin: boolean;
isViewer: boolean;
lastActiveAt: string;
isSuspended: boolean;
createdAt: string;
@ -17,6 +19,17 @@ class User extends BaseModel {
get isInvited(): boolean {
return !this.lastActiveAt;
}
@computed
get rank(): Rank {
if (this.isAdmin) {
return "Admin";
} else if (this.isViewer) {
return "Viewer";
} else {
return "Member";
}
}
}
export default User;

View File

@ -29,6 +29,7 @@ import Subheading from "components/Subheading";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import useCurrentTeam from "hooks/useCurrentTeam";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
import useUnmount from "hooks/useUnmount";
@ -39,6 +40,7 @@ function CollectionScene() {
const params = useParams();
const { t } = useTranslation();
const { documents, policies, collections, ui } = useStores();
const team = useCurrentTeam();
const [isFetching, setFetching] = React.useState();
const [error, setError] = React.useState();
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
@ -46,6 +48,7 @@ function CollectionScene() {
const collectionId = params.id || "";
const collection = collections.get(collectionId);
const can = policies.abilities(collectionId || "");
const canUser = policies.abilities(team.id);
const { handleFiles, isImporting } = useImportDocument(collectionId);
React.useEffect(() => {
@ -115,8 +118,6 @@ function CollectionScene() {
</>
}
actions={
<>
{can.update && (
<>
<Action>
<InputSearch
@ -127,6 +128,7 @@ function CollectionScene() {
collectionId={collectionId}
/>
</Action>
{can.update && (
<Action>
<Tooltip
tooltip={t("New document")}
@ -144,9 +146,8 @@ function CollectionScene() {
</Button>
</Tooltip>
</Action>
<Separator />
</>
)}
<Separator />
<Action>
<CollectionMenu
collection={collection}
@ -200,14 +201,18 @@ function CollectionScene() {
components={{ em: <strong /> }}
/>
<br />
{canUser.createDocument && (
<Trans>Get started by creating a new one!</Trans>
)}
</HelpText>
<Empty>
{canUser.createDocument && (
<Link to={newDocumentUrl(collection.id)}>
<Button icon={<NewDocumentIcon color="currentColor" />}>
{t("Create a document")}
</Button>
</Link>
)}
&nbsp;&nbsp;
<Button onClick={handlePermissionsModalOpen} neutral>
{t("Manage permissions")}

View File

@ -15,8 +15,10 @@ import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import AuthStore from "stores/AuthStore";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UsersStore from "stores/UsersStore";
import Button from "components/Button";
@ -44,7 +46,9 @@ type Props = {
match: Match,
location: LocationWithState,
documents: DocumentsStore,
auth: AuthStore,
users: UsersStore,
policies: PoliciesStore,
notFound: ?boolean,
t: TFunction,
};
@ -255,11 +259,12 @@ class Search extends React.Component<Props> {
};
render() {
const { documents, notFound, location, t } = this.props;
const { documents, notFound, location, t, auth, policies } = this.props;
const results = documents.searchResults(this.query);
const showEmpty = !this.isLoading && this.query && results.length === 0;
const showShortcutTip =
!this.pinToTop && location.state && location.state.fromMenu;
const can = policies.abilities(auth.team?.id ? auth.team.id : "");
return (
<Container auto>
@ -323,11 +328,11 @@ class Search extends React.Component<Props> {
<HelpText>
<Trans>
No documents found for your search filters. <br />
Create a new document?
</Trans>
{can.createDocument && <Trans>Create a new document?</Trans>}
</HelpText>
<Wrapper>
{this.collectionId ? (
{this.collectionId && can.createDocument ? (
<Button
onClick={this.handleNewDoc}
icon={<PlusIcon />}
@ -435,5 +440,5 @@ const Filters = styled(Flex)`
`;
export default withTranslation()<Search>(
withRouter(inject("documents")(Search))
withRouter(inject("documents", "auth", "policies")(Search))
);

View File

@ -71,6 +71,8 @@ class People extends React.Component<Props> {
users = this.props.users.suspended;
} else if (filter === "invited") {
users = this.props.users.invited;
} else if (filter === "viewers") {
users = this.props.users.viewers;
}
const can = policies.abilities(team.id);
@ -113,6 +115,9 @@ class People extends React.Component<Props> {
{t("Suspended")} <Bubble count={counts.suspended} />
</Tab>
)}
<Tab to="/settings/people/viewers" exact>
{t("Viewers")} <Bubble count={counts.viewers} />
</Tab>
<Tab to="/settings/people/all" exact>
{t("Everyone")} <Bubble count={counts.all - counts.invited} />
</Tab>

View File

@ -3,6 +3,7 @@ import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import ApiKeysStore from "stores/ApiKeysStore";
import UiStore from "stores/UiStore";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
@ -14,6 +15,7 @@ import TokenListItem from "./components/TokenListItem";
type Props = {
apiKeys: ApiKeysStore,
ui: UiStore,
};
@observer
@ -29,9 +31,13 @@ class Tokens extends React.Component<Props> {
};
handleSubmit = async (ev: SyntheticEvent<>) => {
try {
ev.preventDefault();
await this.props.apiKeys.create({ name: this.name });
this.name = "";
} catch (error) {
this.props.ui.showToast(error.message, { type: "error" });
}
};
render() {
@ -82,4 +88,4 @@ class Tokens extends React.Component<Props> {
}
}
export default inject("apiKeys")(Tokens);
export default inject("apiKeys", "ui")(Tokens);

View File

@ -11,6 +11,7 @@ import PaginatedDocumentList from "components/PaginatedDocumentList";
import Scene from "components/Scene";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import NewTemplateMenu from "menus/NewTemplateMenu";
@ -19,10 +20,12 @@ type Props = {
};
function Templates(props: Props) {
const { documents } = useStores();
const { documents, policies } = useStores();
const { t } = useTranslation();
const team = useCurrentTeam();
const { fetchTemplates, templates, templatesAlphabetical } = documents;
const { sort } = props.match.params;
const can = policies.abilities(team.id);
return (
<Scene
@ -48,8 +51,10 @@ function Templates(props: Props) {
}
empty={
<Empty>
{t(
"There are no templates just yet. You can create templates to help your team create consistent and accurate documentation."
{t("There are no templates just yet.")}
{can.createDocument &&
t(
"You can create templates to help your team create consistent and accurate documentation."
)}
</Empty>
}

View File

@ -2,6 +2,7 @@
import invariant from "invariant";
import { filter, orderBy } from "lodash";
import { observable, computed, action, runInAction } from "mobx";
import type { Rank } from "shared/types";
import User from "models/User";
import BaseStore from "./BaseStore";
import RootStore from "./RootStore";
@ -14,6 +15,7 @@ export default class UsersStore extends BaseStore<User> {
all: number,
invited: number,
suspended: number,
viewers: number,
} = {};
constructor(rootStore: RootStore) {
@ -48,6 +50,11 @@ export default class UsersStore extends BaseStore<User> {
return filter(this.orderedData, (user) => user.isAdmin);
}
@computed
get viewers(): User[] {
return filter(this.orderedData, (user) => user.isViewer);
}
@computed
get all(): User[] {
return filter(this.orderedData, (user) => user.lastActiveAt);
@ -59,27 +66,47 @@ export default class UsersStore extends BaseStore<User> {
}
@action
promote = (user: User) => {
this.counts.admins += 1;
return this.actionOnUser("promote", user);
promote = async (user: User) => {
try {
this.updateCounts("Admin", user.rank);
await this.actionOnUser("promote", user);
} catch {
this.updateCounts(user.rank, "Admin");
}
};
@action
demote = (user: User) => {
this.counts.admins -= 1;
return this.actionOnUser("demote", user);
demote = async (user: User, to: Rank) => {
try {
this.updateCounts(to, user.rank);
await this.actionOnUser("demote", user, to);
} catch {
this.updateCounts(user.rank, to);
}
};
@action
suspend = (user: User) => {
suspend = async (user: User) => {
try {
this.counts.suspended += 1;
return this.actionOnUser("suspend", user);
this.counts.active -= 1;
await this.actionOnUser("suspend", user);
} catch {
this.counts.suspended -= 1;
this.counts.active += 1;
}
};
@action
activate = (user: User) => {
activate = async (user: User) => {
try {
this.counts.suspended -= 1;
return this.actionOnUser("activate", user);
this.counts.active += 1;
await this.actionOnUser("activate", user);
} catch {
this.counts.suspended += 1;
this.counts.active -= 1;
}
};
@action
@ -118,9 +145,36 @@ export default class UsersStore extends BaseStore<User> {
if (user.isSuspended) {
this.counts.suspended -= 1;
}
if (user.isViewer) {
this.counts.viewers -= 1;
}
this.counts.all -= 1;
}
@action
updateCounts = (to: Rank, from: Rank) => {
if (to === "Admin") {
this.counts.admins += 1;
if (from === "Viewer") {
this.counts.viewers -= 1;
}
}
if (to === "Viewer") {
this.counts.viewers += 1;
if (from === "Admin") {
this.counts.admins -= 1;
}
}
if (to === "Member") {
if (from === "Viewer") {
this.counts.viewers -= 1;
}
if (from === "Admin") {
this.counts.admins -= 1;
}
}
};
notInCollection = (collectionId: string, query: string = "") => {
const memberships = filter(
this.rootStore.memberships.orderedData,
@ -179,9 +233,10 @@ export default class UsersStore extends BaseStore<User> {
return queriedUsers(users, query);
};
actionOnUser = async (action: string, user: User) => {
actionOnUser = async (action: string, user: User, to?: Rank) => {
const res = await client.post(`/users.${action}`, {
id: user.id,
to,
});
invariant(res && res.data, "Data should be available");

View File

@ -9,6 +9,7 @@ Object {
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"isViewer": false,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
@ -19,7 +20,7 @@ Object {
"abilities": Object {
"activate": true,
"delete": true,
"demote": false,
"demote": true,
"promote": true,
"read": true,
"suspend": true,
@ -59,6 +60,7 @@ Object {
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"isViewer": false,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
@ -69,7 +71,73 @@ Object {
"abilities": Object {
"activate": true,
"delete": true,
"demote": false,
"demote": true,
"promote": true,
"read": true,
"suspend": true,
"update": false,
},
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
},
],
"status": 200,
}
`;
exports[`#users.demote should demote an admin to viewer 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"isViewer": true,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
"ok": true,
"policies": Array [
Object {
"abilities": Object {
"activate": true,
"delete": true,
"demote": true,
"promote": true,
"read": true,
"suspend": true,
"update": false,
},
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
},
],
"status": 200,
}
`;
exports[`#users.demote should demote an admin to member 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"isViewer": false,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
"ok": true,
"policies": Array [
Object {
"abilities": Object {
"activate": true,
"delete": true,
"demote": true,
"promote": true,
"read": true,
"suspend": true,
@ -109,6 +177,7 @@ Object {
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": true,
"isSuspended": false,
"isViewer": false,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
@ -168,6 +237,7 @@ Object {
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": true,
"isViewer": false,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",

View File

@ -116,8 +116,7 @@ router.post("users.promote", auth(), async (ctx) => {
const user = await User.findByPk(userId);
authorize(actor, "promote", user);
const team = await Team.findByPk(teamId);
await team.addAdmin(user);
await user.promote();
await Event.create({
name: "users.promote",
@ -137,14 +136,18 @@ router.post("users.promote", auth(), async (ctx) => {
router.post("users.demote", auth(), async (ctx) => {
const userId = ctx.body.id;
const teamId = ctx.state.user.teamId;
let { to } = ctx.body;
const actor = ctx.state.user;
ctx.assertPresent(userId, "id is required");
to = to === "Viewer" ? "Viewer" : "Member";
const user = await User.findByPk(userId);
authorize(actor, "demote", user);
const team = await Team.findByPk(teamId);
await team.removeAdmin(user);
await user.demote(teamId, to);
await Event.create({
name: "users.demote",
@ -190,8 +193,7 @@ router.post("users.activate", auth(), async (ctx) => {
const user = await User.findByPk(userId);
authorize(actor, "activate", user);
const team = await Team.findByPk(teamId);
await team.activateUser(user, actor);
await user.activate();
await Event.create({
name: "users.activate",

View File

@ -264,6 +264,40 @@ describe("#users.demote", () => {
expect(body).toMatchSnapshot();
});
it("should demote an admin to viewer", async () => {
const { admin, user } = await seed();
await user.update({ isAdmin: true }); // Make another admin
const res = await server.post("/api/users.demote", {
body: {
token: admin.getJwtToken(),
id: user.id,
to: "Viewer",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it("should demote an admin to member", async () => {
const { admin, user } = await seed();
await user.update({ isAdmin: true }); // Make another admin
const res = await server.post("/api/users.demote", {
body: {
token: admin.getJwtToken(),
id: user.id,
to: "Member",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it("should not demote admins if only one available", async () => {
const admin = await buildAdmin();

View File

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

View File

@ -8,14 +8,12 @@ import {
stripSubdomain,
RESERVED_SUBDOMAINS,
} from "../../shared/utils/domains";
import { ValidationError } from "../errors";
import { DataTypes, sequelize, Op } from "../sequelize";
import { generateAvatarUrl } from "../utils/avatars";
import { publicS3Endpoint, uploadToS3FromUrl } from "../utils/s3";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
const readFile = util.promisify(fs.readFile);
@ -194,35 +192,6 @@ Team.prototype.provisionFirstCollection = async function (userId) {
}
};
Team.prototype.addAdmin = async function (user: User) {
return user.update({ isAdmin: true });
};
Team.prototype.removeAdmin = async function (user: User) {
const res = await User.findAndCountAll({
where: {
teamId: this.id,
isAdmin: true,
id: {
[Op.ne]: user.id,
},
},
limit: 1,
});
if (res.count >= 1) {
return user.update({ isAdmin: false });
} else {
throw new ValidationError("At least one admin is required");
}
};
Team.prototype.activateUser = async function (user: User, admin: User) {
return user.update({
suspendedById: null,
suspendedAt: null,
});
};
Team.prototype.collectionIds = async function (paranoid: boolean = true) {
let models = await Collection.findAll({
attributes: ["id"],

View File

@ -7,7 +7,7 @@ import uuid from "uuid";
import { languages } from "../../shared/i18n";
import { ValidationError } from "../errors";
import { sendEmail } from "../mailer";
import { DataTypes, sequelize, encryptedFields } from "../sequelize";
import { DataTypes, sequelize, encryptedFields, Op } from "../sequelize";
import { DEFAULT_AVATAR_HOST } from "../utils/avatars";
import { publicS3Endpoint, uploadToS3FromUrl } from "../utils/s3";
import { Star, Team, Collection, NotificationSetting, ApiKey } from ".";
@ -25,6 +25,11 @@ const User = sequelize.define(
name: DataTypes.STRING,
avatarUrl: { type: DataTypes.STRING, allowNull: true },
isAdmin: DataTypes.BOOLEAN,
isViewer: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
},
service: { type: DataTypes.STRING, allowNull: true },
serviceId: { type: DataTypes.STRING, allowNull: true, unique: true },
jwtSecret: encryptedFields().vault("jwtSecret"),
@ -277,6 +282,7 @@ User.getCounts = async function (teamId: string) {
SELECT
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
COUNT(CASE WHEN "isViewer" = true THEN 1 END) as "viewerCount",
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
COUNT(*) as count
@ -295,10 +301,48 @@ User.getCounts = async function (teamId: string) {
return {
active: parseInt(counts.activeCount),
admins: parseInt(counts.adminCount),
viewers: parseInt(counts.viewerCount),
all: parseInt(counts.count),
invited: parseInt(counts.invitedCount),
suspended: parseInt(counts.suspendedCount),
};
};
User.prototype.demote = async function (
teamId: string,
to: "Member" | "Viewer"
) {
const res = await User.findAndCountAll({
where: {
teamId,
isAdmin: true,
id: {
[Op.ne]: this.id,
},
},
limit: 1,
});
if (res.count >= 1) {
if (to === "Member") {
return this.update({ isAdmin: false, isViewer: false });
} else if (to === "Viewer") {
return this.update({ isAdmin: false, isViewer: true });
}
} else {
throw new ValidationError("At least one admin is required");
}
};
User.prototype.promote = async function () {
return this.update({ isAdmin: true, isViewer: false });
};
User.prototype.activate = async function () {
return this.update({
suspendedById: null,
suspendedAt: null,
});
};
export default User;

View File

@ -5,13 +5,11 @@ import policy from "./policy";
const { allow } = policy;
allow(User, "createApiKey", Team, (user, team) => {
if (!team || user.teamId !== team.id) return false;
if (!team || user.isViewer || user.teamId !== team.id) return false;
return true;
});
allow(
User,
["read", "update", "delete"],
ApiKey,
(user, apiKey) => user && user.id === apiKey.userId
);
allow(User, ["read", "update", "delete"], ApiKey, (user, apiKey) => {
if (user.isViewer) return false;
return user && user.id === apiKey.userId;
});

View File

@ -5,11 +5,19 @@ import policy from "./policy";
const { allow } = policy;
allow(User, "createAttachment", Team, (user, team) => {
if (!team || user.teamId !== team.id) return false;
if (!team || user.isViewer || user.teamId !== team.id) return false;
return true;
});
allow(User, ["read", "delete"], Attachment, (actor, attachment) => {
allow(User, "read", Attachment, (actor, attachment) => {
if (!attachment || attachment.teamId !== actor.teamId) return false;
if (actor.isAdmin) return true;
if (actor.id === attachment.userId) return true;
return false;
});
allow(User, "delete", Attachment, (actor, attachment) => {
if (actor.isViewer) return false;
if (!attachment || attachment.teamId !== actor.teamId) return false;
if (actor.isAdmin) return true;
if (actor.id === attachment.userId) return true;

View File

@ -8,7 +8,7 @@ import policy from "./policy";
const { allow } = policy;
allow(User, "createCollection", Team, (user, team) => {
if (!team || user.teamId !== team.id) return false;
if (!team || user.isViewer || user.teamId !== team.id) return false;
return true;
});
@ -48,6 +48,7 @@ allow(User, ["read", "export"], Collection, (user, collection) => {
});
allow(User, "share", Collection, (user, collection) => {
if (user.isViewer) return false;
if (!collection || user.teamId !== collection.teamId) return false;
if (!collection.sharing) return false;
@ -71,6 +72,7 @@ allow(User, "share", Collection, (user, collection) => {
});
allow(User, ["publish", "update"], Collection, (user, collection) => {
if (user.isViewer) return false;
if (!collection || user.teamId !== collection.teamId) return false;
if (collection.permission !== "read_write") {
@ -93,6 +95,7 @@ allow(User, ["publish", "update"], Collection, (user, collection) => {
});
allow(User, "delete", Collection, (user, collection) => {
if (user.isViewer) return false;
if (!collection || user.teamId !== collection.teamId) return false;
if (collection.permission !== "read_write") {

View File

@ -6,7 +6,7 @@ import policy from "./policy";
const { allow, cannot } = policy;
allow(User, "createDocument", Team, (user, team) => {
if (!team || user.teamId !== team.id) return false;
if (!team || user.isViewer || user.teamId !== team.id) return false;
return true;
});
@ -102,6 +102,7 @@ allow(User, ["pin", "unpin"], Document, (user, document) => {
allow(User, "delete", Document, (user, document) => {
// unpublished drafts can always be deleted
if (user.isViewer) return false;
if (
!document.deletedAt &&
!document.publishedAt &&
@ -121,6 +122,7 @@ allow(User, "delete", Document, (user, document) => {
});
allow(User, "restore", Document, (user, document) => {
if (user.isViewer) return false;
if (!document.deletedAt) return false;
return user.teamId === document.teamId;
});

View File

@ -6,7 +6,7 @@ import policy from "./policy";
const { allow } = policy;
allow(User, "createGroup", Team, (actor, team) => {
if (!team || actor.teamId !== team.id) return false;
if (!team || actor.isViewer || actor.teamId !== team.id) return false;
if (actor.isAdmin) return true;
throw new AdminRequiredError();
});
@ -21,7 +21,7 @@ allow(User, "read", Group, (actor, group) => {
});
allow(User, ["update", "delete"], Group, (actor, group) => {
if (!group || actor.teamId !== group.teamId) return false;
if (!group || actor.isViewer || actor.teamId !== group.teamId) return false;
if (actor.isAdmin) return true;
throw new AdminRequiredError();
});

View File

@ -6,7 +6,7 @@ import policy from "./policy";
const { allow } = policy;
allow(User, "createIntegration", Team, (actor, team) => {
if (!team || actor.teamId !== team.id) return false;
if (!team || actor.isViewer || actor.teamId !== team.id) return false;
if (actor.isAdmin) return true;
throw new AdminRequiredError();
});
@ -19,6 +19,7 @@ allow(
);
allow(User, ["update", "delete"], Integration, (user, integration) => {
if (user.isViewer) return false;
if (!integration || user.teamId !== integration.teamId) return false;
if (user.isAdmin) return true;
throw new AdminRequiredError();

View File

@ -5,14 +5,17 @@ import policy from "./policy";
const { allow } = policy;
allow(
User,
["read", "update"],
Share,
(user, share) => user.teamId === share.teamId
);
allow(User, "read", Share, (user, share) => {
return user.teamId === share.teamId;
});
allow(User, "update", Share, (user, share) => {
if (user.isViewer) return false;
return user.teamId === share.teamId;
});
allow(User, "revoke", Share, (user, share) => {
if (user.isViewer) return false;
if (!share || user.teamId !== share.teamId) return false;
if (user.id === share.userId) return true;
if (user.isAdmin) return true;

View File

@ -7,11 +7,11 @@ const { allow } = policy;
allow(User, "read", Team, (user, team) => team && user.teamId === team.id);
allow(User, "share", Team, (user, team) => {
if (!team || user.teamId !== team.id) return false;
if (!team || user.isViewer || user.teamId !== team.id) return false;
return team.sharing;
});
allow(User, ["update", "export", "manage"], Team, (user, team) => {
if (!team || user.teamId !== team.id) return false;
if (!team || user.isViewer || user.teamId !== team.id) return false;
return user.isAdmin;
});

View File

@ -46,7 +46,7 @@ allow(User, "promote", User, (actor, user) => {
allow(User, "demote", User, (actor, user) => {
if (!user || user.teamId !== actor.teamId) return false;
if (!user.isAdmin || user.isSuspended) return false;
if (user.isSuspended) return false;
if (actor.isAdmin) return true;
throw new AdminRequiredError();
});

View File

@ -7,6 +7,7 @@ Object {
"id": "123",
"isAdmin": undefined,
"isSuspended": undefined,
"isViewer": undefined,
"language": "en_US",
"lastActiveAt": undefined,
"name": "Test User",
@ -20,6 +21,7 @@ Object {
"id": "123",
"isAdmin": undefined,
"isSuspended": undefined,
"isViewer": undefined,
"language": "en_US",
"lastActiveAt": undefined,
"name": "Test User",

View File

@ -12,6 +12,7 @@ type UserPresentation = {
email?: string,
isAdmin: boolean,
isSuspended: boolean,
isViewer: boolean,
language: string,
};
@ -22,6 +23,7 @@ export default (user: User, options: Options = {}): ?UserPresentation => {
userData.lastActiveAt = user.lastActiveAt;
userData.name = user.name;
userData.isAdmin = user.isAdmin;
userData.isViewer = user.isViewer;
userData.isSuspended = user.isSuspended;
userData.avatarUrl = user.avatarUrl;
userData.language = user.language || process.env.DEFAULT_LANGUAGE || "en_US";

View File

@ -193,9 +193,11 @@
"By {{ author }}": "By {{ author }}",
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
"Are you sure you want to make {{ userName }} a member?": "Are you sure you want to make {{ userName }} a member?",
"Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content": "Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content",
"Are you sure you want to suspend this account? Suspended users will be prevented from logging in.": "Are you sure you want to suspend this account? Suspended users will be prevented from logging in.",
"User options": "User options",
"Make {{ userName }} a member": "Make {{ userName }} a member",
"Make {{ userName }} a member": "Make {{ userName }} a member",
"Make {{ userName }} a viewer": "Make {{ userName }} a viewer",
"Make {{ userName }} an admin…": "Make {{ userName }} an admin…",
"Revoke invite": "Revoke invite",
"Activate account": "Activate account",
@ -340,7 +342,8 @@
"Not Found": "Not Found",
"We were unable to find the page youre looking for.": "We were unable to find the page youre looking for.",
"Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base": "Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base",
"No documents found for your search filters. <1></1>Create a new document?": "No documents found for your search filters. <1></1>Create a new document?",
"No documents found for your search filters. <1></1>": "No documents found for your search filters. <1></1>",
"Create a new document?": "Create a new document?",
"Clear filters": "Clear filters",
"Import started": "Import started",
"Export in progress…": "Export in progress…",
@ -359,6 +362,7 @@
"Active": "Active",
"Admins": "Admins",
"Suspended": "Suspended",
"Viewers": "Viewers",
"Everyone": "Everyone",
"No people to see here.": "No people to see here.",
"Profile saved": "Profile saved",
@ -373,7 +377,8 @@
"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",
"Youve not starred any documents yet.": "Youve not starred any documents yet.",
"There are no templates just yet. You can create templates to help your team create consistent and accurate documentation.": "There are no templates just yet. You can create templates to help your team create consistent and accurate documentation.",
"There are no templates just yet.": "There are no templates just yet.",
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",
"Trash is empty at the moment.": "Trash is empty at the moment.",
"You joined": "You joined",
"Joined": "Joined",

2
shared/types.js Normal file
View File

@ -0,0 +1,2 @@
// @flow
export type Rank = "Admin" | "Viewer" | "Member";