diff --git a/app/components/DocumentListItem.js b/app/components/DocumentListItem.js
index 1a52d4f6..bcc1bfff 100644
--- a/app/components/DocumentListItem.js
+++ b/app/components/DocumentListItem.js
@@ -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 (
- {document.isTemplate && !document.isArchived && !document.isDeleted && (
- <>
- }
- neutral
- >
- {t("New doc")}
-
-
- >
- )}
+ {document.isTemplate &&
+ !document.isArchived &&
+ !document.isDeleted &&
+ can.createDocument && (
+ <>
+ }
+ neutral
+ >
+ {t("New doc")}
+
+
+ >
+ )}
- }
- exact={false}
- label={t("Templates")}
- active={
- documents.active ? documents.active.template : undefined
- }
- />
- }
- label={
-
- {t("Drafts")}
-
-
- }
- active={
- documents.active
- ? !documents.active.publishedAt &&
- !documents.active.isDeleted &&
- !documents.active.isTemplate
- : undefined
- }
- />
+ {can.createDocument && (
+ }
+ exact={false}
+ label={t("Templates")}
+ active={
+ documents.active ? documents.active.template : undefined
+ }
+ />
+ )}
+ {can.createDocument && (
+ }
+ label={
+
+ {t("Drafts")}
+
+
+ }
+ active={
+ documents.active
+ ? !documents.active.publishedAt &&
+ !documents.active.isDeleted &&
+ !documents.active.isTemplate
+ : undefined
+ }
+ />
+ )}
}
label={t("Notifications")}
/>
- }
- label={t("API Tokens")}
- />
+ {can.createApiKey && (
+ }
+ label={t("API Tokens")}
+ />
+ )}
diff --git a/app/components/Sidebar/components/Collections.js b/app/components/Sidebar/components/Collections.js
index bd55a1e1..97141b08 100644
--- a/app/components/Sidebar/components/Collections.js
+++ b/app/components/Sidebar/components/Collections.js
@@ -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,13 +80,15 @@ function Collections({ onCreateCollection }: Props) {
belowCollection={orderedCollections[index + 1]}
/>
))}
- }
- label={`${t("New collection")}…`}
- exact
- />
+ {can.createCollection && (
+ }
+ label={`${t("New collection")}…`}
+ exact
+ />
+ )}
>
);
diff --git a/app/menus/NewDocumentMenu.js b/app/menus/NewDocumentMenu.js
index 5bb65b0e..a4c37cc8 100644
--- a/app/menus/NewDocumentMenu.js
+++ b/app/menus/NewDocumentMenu.js
@@ -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 (
diff --git a/app/menus/NewTemplateMenu.js b/app/menus/NewTemplateMenu.js
index 610fce1f..ae298192 100644
--- a/app/menus/NewTemplateMenu.js
+++ b/app/menus/NewTemplateMenu.js
@@ -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 (
<>
diff --git a/app/menus/ShareMenu.js b/app/menus/ShareMenu.js
index e5cb11b8..d5773f49 100644
--- a/app/menus/ShareMenu.js
+++ b/app/menus/ShareMenu.js
@@ -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) {
-
-
+ {can.revoke && (
+ <>
+
+
+ >
+ )}
>
);
diff --git a/app/menus/UserMenu.js b/app/menus/UserMenu.js
index cd1d0746..c9a0786d 100644
--- a/app/menus/UserMenu.js
+++ b/app/menus/UserMenu.js
@@ -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",
diff --git a/app/models/User.js b/app/models/User.js
index d6af010b..d870969b 100644
--- a/app/models/User.js
+++ b/app/models/User.js
@@ -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;
diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js
index e497f28d..97f7b3ad 100644
--- a/app/scenes/Collection.js
+++ b/app/scenes/Collection.js
@@ -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(() => {
@@ -116,37 +119,35 @@ function CollectionScene() {
}
actions={
<>
+
+
+
{can.update && (
- <>
-
-
-
-
-
+
+ }
>
- }
- >
- {t("New doc")}
-
-
-
-
- >
+ {t("New doc")}
+
+
+
)}
+
}}
/>
- Get started by creating a new one!
+ {canUser.createDocument && (
+ Get started by creating a new one!
+ )}
-
- }>
- {t("Create a document")}
-
-
+ {canUser.createDocument && (
+
+ }>
+ {t("Create a document")}
+
+
+ )}
}
fetch={fetchTemplates}
diff --git a/app/stores/UsersStore.js b/app/stores/UsersStore.js
index ea6c88c1..24197f3d 100644
--- a/app/stores/UsersStore.js
+++ b/app/stores/UsersStore.js
@@ -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 {
all: number,
invited: number,
suspended: number,
+ viewers: number,
} = {};
constructor(rootStore: RootStore) {
@@ -48,6 +50,11 @@ export default class UsersStore extends BaseStore {
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 {
}
@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) => {
- this.counts.suspended += 1;
- return this.actionOnUser("suspend", user);
+ suspend = async (user: User) => {
+ try {
+ this.counts.suspended += 1;
+ this.counts.active -= 1;
+ await this.actionOnUser("suspend", user);
+ } catch {
+ this.counts.suspended -= 1;
+ this.counts.active += 1;
+ }
};
@action
- activate = (user: User) => {
- this.counts.suspended -= 1;
- return this.actionOnUser("activate", user);
+ activate = async (user: User) => {
+ try {
+ this.counts.suspended -= 1;
+ 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 {
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 {
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");
diff --git a/server/api/__snapshots__/users.test.js.snap b/server/api/__snapshots__/users.test.js.snap
index 4ac5ac98..adefec1e 100644
--- a/server/api/__snapshots__/users.test.js.snap
+++ b/server/api/__snapshots__/users.test.js.snap
@@ -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",
diff --git a/server/api/users.js b/server/api/users.js
index 14240dd3..7701d30b 100644
--- a/server/api/users.js
+++ b/server/api/users.js
@@ -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",
diff --git a/server/api/users.test.js b/server/api/users.test.js
index 86710de9..423191f5 100644
--- a/server/api/users.test.js
+++ b/server/api/users.test.js
@@ -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();
diff --git a/server/migrations/20210314173941-isViewer.js b/server/migrations/20210314173941-isViewer.js
new file mode 100644
index 00000000..76f76348
--- /dev/null
+++ b/server/migrations/20210314173941-isViewer.js
@@ -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");
+ }
+};
diff --git a/server/models/Team.js b/server/models/Team.js
index 892594d4..7981e693 100644
--- a/server/models/Team.js
+++ b/server/models/Team.js
@@ -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"],
diff --git a/server/models/User.js b/server/models/User.js
index 94a4ffa7..2d875e32 100644
--- a/server/models/User.js
+++ b/server/models/User.js
@@ -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;
diff --git a/server/policies/apiKey.js b/server/policies/apiKey.js
index 5da29a47..6457de08 100644
--- a/server/policies/apiKey.js
+++ b/server/policies/apiKey.js
@@ -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;
+});
diff --git a/server/policies/attachment.js b/server/policies/attachment.js
index 71637566..ef05b50c 100644
--- a/server/policies/attachment.js
+++ b/server/policies/attachment.js
@@ -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;
diff --git a/server/policies/collection.js b/server/policies/collection.js
index 6d46814f..87b1a623 100644
--- a/server/policies/collection.js
+++ b/server/policies/collection.js
@@ -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") {
diff --git a/server/policies/document.js b/server/policies/document.js
index 10d72746..26ec3375 100644
--- a/server/policies/document.js
+++ b/server/policies/document.js
@@ -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;
});
diff --git a/server/policies/group.js b/server/policies/group.js
index 69c8ad60..11b30cf1 100644
--- a/server/policies/group.js
+++ b/server/policies/group.js
@@ -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();
});
diff --git a/server/policies/integration.js b/server/policies/integration.js
index 70f21137..324511a6 100644
--- a/server/policies/integration.js
+++ b/server/policies/integration.js
@@ -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();
diff --git a/server/policies/share.js b/server/policies/share.js
index 39075707..75192b2d 100644
--- a/server/policies/share.js
+++ b/server/policies/share.js
@@ -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;
diff --git a/server/policies/team.js b/server/policies/team.js
index 2ea956be..a49078f0 100644
--- a/server/policies/team.js
+++ b/server/policies/team.js
@@ -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;
});
diff --git a/server/policies/user.js b/server/policies/user.js
index b1e5bede..c98d1f25 100644
--- a/server/policies/user.js
+++ b/server/policies/user.js
@@ -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();
});
diff --git a/server/presenters/__snapshots__/user.test.js.snap b/server/presenters/__snapshots__/user.test.js.snap
index 4383924a..2baef57d 100644
--- a/server/presenters/__snapshots__/user.test.js.snap
+++ b/server/presenters/__snapshots__/user.test.js.snap
@@ -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",
diff --git a/server/presenters/user.js b/server/presenters/user.js
index 0b7f7524..5b8e26ec 100644
--- a/server/presenters/user.js
+++ b/server/presenters/user.js
@@ -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";
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index 49925254..2d703d1e 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -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 you’re looking for.": "We were unable to find the page you’re looking for.",
"Use the {{ meta }}+K shortcut to search from anywhere in your knowledge base": "Use the {{ meta }}+K 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",
"You’ve not starred any documents yet.": "You’ve 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",
diff --git a/shared/types.js b/shared/types.js
new file mode 100644
index 00000000..44c68467
--- /dev/null
+++ b/shared/types.js
@@ -0,0 +1,2 @@
+// @flow
+export type Rank = "Admin" | "Viewer" | "Member";