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:
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
@ -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 (
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
)}
|
||||
|
||||
<Button onClick={handlePermissionsModalOpen} neutral>
|
||||
{t("Manage permissions")}…
|
||||
|
@ -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))
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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");
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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();
|
||||
|
||||
|
15
server/migrations/20210314173941-isViewer.js
Normal file
15
server/migrations/20210314173941-isViewer.js
Normal 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");
|
||||
}
|
||||
};
|
@ -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"],
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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") {
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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",
|
||||
|
@ -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";
|
||||
|
@ -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 <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",
|
||||
"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",
|
||||
|
2
shared/types.js
Normal file
2
shared/types.js
Normal file
@ -0,0 +1,2 @@
|
||||
// @flow
|
||||
export type Rank = "Admin" | "Viewer" | "Member";
|
Reference in New Issue
Block a user