feat: Improved viewers popover (#2106)
* refactoring popover * feat: DocumentViews popover * i18n * fix: tab focus warnings * test: Add tests around users.info changes * snapshots
This commit is contained in:
@ -17,6 +17,7 @@ type Props = {
|
||||
isEditing: boolean,
|
||||
isCurrentUser: boolean,
|
||||
lastViewedAt: string,
|
||||
profileOnClick: boolean,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@ -65,7 +66,11 @@ class AvatarWithPresence extends React.Component<Props> {
|
||||
<AvatarWrapper isPresent={isPresent}>
|
||||
<Avatar
|
||||
src={user.avatarUrl}
|
||||
onClick={this.handleOpenProfile}
|
||||
onClick={
|
||||
this.props.profileOnClick === false
|
||||
? undefined
|
||||
: this.handleOpenProfile
|
||||
}
|
||||
size={32}
|
||||
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
|
||||
/>
|
||||
|
@ -1,80 +1,94 @@
|
||||
// @flow
|
||||
import { sortBy, keyBy } from "lodash";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { filter } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { MAX_AVATAR_DISPLAY } from "shared/constants";
|
||||
import DocumentPresenceStore from "stores/DocumentPresenceStore";
|
||||
import ViewsStore from "stores/ViewsStore";
|
||||
import Document from "models/Document";
|
||||
import { AvatarWithPresence } from "components/Avatar";
|
||||
import DocumentViews from "components/DocumentViews";
|
||||
import Facepile from "components/Facepile";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import Popover from "components/Popover";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
views: ViewsStore,
|
||||
presence: DocumentPresenceStore,
|
||||
type Props = {|
|
||||
document: Document,
|
||||
currentUserId: string,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Collaborators extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (!this.props.document.isDeleted) {
|
||||
this.props.views.fetchPage({ documentId: this.props.document.id });
|
||||
}
|
||||
}
|
||||
function Collaborators(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { users, presence } = useStores();
|
||||
const { document, currentUserId } = props;
|
||||
|
||||
render() {
|
||||
const { document, presence, views, currentUserId } = this.props;
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
|
||||
const documentViews = views.inDocument(document.id);
|
||||
|
||||
const presentIds = documentPresence.map((p) => p.userId);
|
||||
const editingIds = documentPresence
|
||||
.filter((p) => p.isEditing)
|
||||
.map((p) => p.userId);
|
||||
|
||||
// ensure currently present via websocket are always ordered first
|
||||
const mostRecentViewers = sortBy(
|
||||
documentViews.slice(0, MAX_AVATAR_DISPLAY),
|
||||
(view) => {
|
||||
return presentIds.includes(view.user.id);
|
||||
}
|
||||
const presentUsers = filter(users.orderedData, (user) =>
|
||||
presentIds.includes(user.id)
|
||||
);
|
||||
|
||||
const viewersKeyedByUserId = keyBy(mostRecentViewers, (v) => v.user.id);
|
||||
const overflow = documentViews.length - mostRecentViewers.length;
|
||||
// load any users we don't know about
|
||||
React.useEffect(() => {
|
||||
if (users.isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
presentIds.forEach((userId) => {
|
||||
if (!users.get(userId)) {
|
||||
return users.fetch(userId);
|
||||
}
|
||||
});
|
||||
}, [document, users, presentIds]);
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<NudeButton width={presentUsers.length * 32} height={32} {...props}>
|
||||
<FacepileHiddenOnMobile
|
||||
users={mostRecentViewers.map((v) => v.user)}
|
||||
overflow={overflow}
|
||||
users={presentUsers}
|
||||
renderAvatar={(user) => {
|
||||
const isPresent = presentIds.includes(user.id);
|
||||
const isEditing = editingIds.includes(user.id);
|
||||
const { lastViewedAt } = viewersKeyedByUserId[user.id];
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={user.id}
|
||||
user={user}
|
||||
lastViewedAt={lastViewedAt}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isCurrentUser={currentUserId === user.id}
|
||||
profileOnClick={false}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</NudeButton>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
|
||||
<DocumentViews document={document} isOpen={popover.visible} />
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const FacepileHiddenOnMobile = styled(Facepile)`
|
||||
${breakpoint("mobile", "tablet")`
|
||||
@ -82,4 +96,4 @@ const FacepileHiddenOnMobile = styled(Facepile)`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default inject("views", "presence")(Collaborators);
|
||||
export default observer(Collaborators);
|
||||
|
@ -1,9 +1,13 @@
|
||||
// @flow
|
||||
import { useObserver } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import DocumentViews from "components/DocumentViews";
|
||||
import Popover from "components/Popover";
|
||||
import useStores from "../hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
@ -12,22 +16,41 @@ type Props = {|
|
||||
to?: string,
|
||||
|};
|
||||
|
||||
function DocumentMetaWithViews({ to, isDraft, document }: Props) {
|
||||
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
const { views } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||
const totalViewers = documentViews.length;
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 8,
|
||||
placement: "bottom",
|
||||
modal: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Meta document={document} to={to}>
|
||||
<Meta document={document} to={to} {...rest}>
|
||||
{totalViewers && !isDraft ? (
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<>
|
||||
· Viewed by{" "}
|
||||
·
|
||||
<a {...props}>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
? "only you"
|
||||
: `${totalViewers} ${totalViewers === 1 ? "person" : "people"}`}
|
||||
? t("only you")
|
||||
: `${totalViewers} ${
|
||||
totalViewers === 1 ? t("person") : t("people")
|
||||
}`}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
) : null}
|
||||
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
|
||||
<DocumentViews document={document} isOpen={popover.visible} />
|
||||
</Popover>
|
||||
</Meta>
|
||||
);
|
||||
}
|
||||
|
82
app/components/DocumentViews.js
Normal file
82
app/components/DocumentViews.js
Normal file
@ -0,0 +1,82 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Document from "models/Document";
|
||||
import Avatar from "components/Avatar";
|
||||
import ListItem from "components/List/Item";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
isOpen?: boolean,
|
||||
|};
|
||||
|
||||
function DocumentViews({ document, isOpen }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { views, presence } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
views.fetchPage({ documentId: document.id });
|
||||
}, [views, document.id]);
|
||||
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
|
||||
const presentIds = documentPresence.map((p) => p.userId);
|
||||
const editingIds = documentPresence
|
||||
.filter((p) => p.isEditing)
|
||||
.map((p) => p.userId);
|
||||
|
||||
// ensure currently present via websocket are always ordered first
|
||||
const documentViews = views.inDocument(document.id);
|
||||
const sortedViews = sortBy(documentViews, (view) =>
|
||||
presentIds.includes(view.user.id)
|
||||
);
|
||||
|
||||
const users = React.useMemo(() => sortedViews.map((v) => v.user), [
|
||||
sortedViews,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<PaginatedList
|
||||
items={users}
|
||||
renderItem={(item) => {
|
||||
const view = documentViews.find((v) => v.user.id === item.id);
|
||||
const isPresent = presentIds.includes(item.id);
|
||||
const isEditing = editingIds.includes(item.id);
|
||||
|
||||
const subtitle = isPresent
|
||||
? isEditing
|
||||
? t("Currently editing")
|
||||
: t("Currently viewing")
|
||||
: t("Viewed {{ timeAgo }} ago", {
|
||||
timeAgo: distanceInWordsToNow(
|
||||
view ? new Date(view.lastViewedAt) : new Date()
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
title={item.name}
|
||||
subtitle={subtitle}
|
||||
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
|
||||
border={false}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentViews);
|
@ -1,29 +1,26 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import Avatar from "components/Avatar";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
users: User[],
|
||||
size?: number,
|
||||
overflow: number,
|
||||
renderAvatar: (user: User) => React.Node,
|
||||
};
|
||||
onClick?: (event: SyntheticEvent<>) => mixed,
|
||||
renderAvatar?: (user: User) => React.Node,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Facepile extends React.Component<Props> {
|
||||
render() {
|
||||
const {
|
||||
function Facepile({
|
||||
users,
|
||||
overflow,
|
||||
size = 32,
|
||||
renderAvatar = renderDefaultAvatar,
|
||||
renderAvatar = DefaultAvatar,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
}: Props) {
|
||||
return (
|
||||
<Avatars {...rest}>
|
||||
{overflow > 0 && (
|
||||
@ -37,9 +34,8 @@ class Facepile extends React.Component<Props> {
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDefaultAvatar(user: User) {
|
||||
function DefaultAvatar(user: User) {
|
||||
return <Avatar user={user} src={user.avatarUrl} size={32} />;
|
||||
}
|
||||
|
||||
@ -73,4 +69,4 @@ const Avatars = styled(Flex)`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export default inject("views", "presence")(withTheme(Facepile));
|
||||
export default observer(Facepile);
|
||||
|
@ -8,13 +8,14 @@ type Props = {
|
||||
title: React.Node,
|
||||
subtitle?: React.Node,
|
||||
actions?: React.Node,
|
||||
border?: boolean,
|
||||
};
|
||||
|
||||
const ListItem = ({ image, title, subtitle, actions }: Props) => {
|
||||
const ListItem = ({ image, title, subtitle, actions, border }: Props) => {
|
||||
const compact = !subtitle;
|
||||
|
||||
return (
|
||||
<Wrapper compact={compact}>
|
||||
<Wrapper compact={compact} $border={border}>
|
||||
{image && <Image>{image}</Image>}
|
||||
<Content align={compact ? "center" : undefined} column={!compact}>
|
||||
<Heading>{title}</Heading>
|
||||
@ -27,9 +28,11 @@ const ListItem = ({ image, title, subtitle, actions }: Props) => {
|
||||
|
||||
const Wrapper = styled.li`
|
||||
display: flex;
|
||||
padding: 10px 0;
|
||||
padding: 8px 0;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
border-bottom: 1px solid
|
||||
${(props) =>
|
||||
props.$border === false ? "transparent" : props.theme.divider};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
@ -59,7 +62,8 @@ const Content = styled(Flex)`
|
||||
const Subtitle = styled.p`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: ${(props) => props.theme.slate};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
margin-top: -2px;
|
||||
`;
|
||||
|
||||
const Actions = styled.div`
|
||||
|
@ -3,8 +3,8 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Button = styled.button`
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
width: ${(props) => props.width || props.size}px;
|
||||
height: ${(props) => props.height || props.size}px;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
|
34
app/components/Popover.js
Normal file
34
app/components/Popover.js
Normal file
@ -0,0 +1,34 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Popover as ReakitPopover } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
width?: number,
|
||||
};
|
||||
|
||||
function Popover({ children, width = 380, ...rest }: Props) {
|
||||
return (
|
||||
<ReakitPopover {...rest}>
|
||||
<Contents width={width}>{children}</Contents>
|
||||
</ReakitPopover>
|
||||
);
|
||||
}
|
||||
|
||||
const Contents = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
padding: 12px 24px;
|
||||
max-height: 50vh;
|
||||
overflow-y: scroll;
|
||||
width: ${(props) => props.width}px;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
`;
|
||||
|
||||
export default Popover;
|
@ -16,7 +16,6 @@ import Badge from "components/Badge";
|
||||
import Breadcrumb, { Slash } from "components/Breadcrumb";
|
||||
import Button from "components/Button";
|
||||
import Collaborators from "components/Collaborators";
|
||||
import Fade from "components/Fade";
|
||||
import Header from "components/Header";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import ShareButton from "./ShareButton";
|
||||
@ -148,12 +147,10 @@ function DocumentHeader({
|
||||
actions={
|
||||
<>
|
||||
{!isPublishing && isSaving && <Status>{t("Saving")}…</Status>}
|
||||
<Fade>
|
||||
<Collaborators
|
||||
document={document}
|
||||
currentUserId={auth.user ? auth.user.id : undefined}
|
||||
/>
|
||||
</Fade>
|
||||
{isEditing && !isTemplate && isNew && (
|
||||
<Action>
|
||||
<TemplatesMenu document={document} />
|
||||
|
@ -3,11 +3,10 @@ import { observer } from "mobx-react";
|
||||
import { GlobeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { usePopoverState, Popover, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import Document from "models/Document";
|
||||
import Button from "components/Button";
|
||||
import Popover from "components/Popover";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import SharePopover from "./SharePopover";
|
||||
import useStores from "hooks/useStores";
|
||||
@ -55,28 +54,14 @@ function ShareButton({ document }: Props) {
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover {...popover} aria-label={t("Share")}>
|
||||
<Contents>
|
||||
<SharePopover
|
||||
document={document}
|
||||
share={share}
|
||||
onSubmit={popover.hide}
|
||||
/>
|
||||
</Contents>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Contents = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
padding: 24px 24px 12px;
|
||||
width: 380px;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
`;
|
||||
|
||||
export default observer(ShareButton);
|
||||
|
@ -109,7 +109,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
|
||||
type="text"
|
||||
label={t("Link")}
|
||||
placeholder={`${t("Loading")}…`}
|
||||
value={share ? share.url : undefined}
|
||||
value={share ? share.url : ""}
|
||||
labelHidden
|
||||
readOnly
|
||||
/>
|
||||
@ -126,7 +126,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
|
||||
const Heading = styled.h2`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
margin-top: 12px;
|
||||
margin-left: -4px;
|
||||
`;
|
||||
|
||||
|
@ -23,6 +23,7 @@ Object {
|
||||
"demote": true,
|
||||
"promote": true,
|
||||
"read": true,
|
||||
"readDetails": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
},
|
||||
@ -74,39 +75,7 @@ Object {
|
||||
"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,
|
||||
"readDetails": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
},
|
||||
@ -140,6 +109,41 @@ Object {
|
||||
"demote": true,
|
||||
"promote": true,
|
||||
"read": true,
|
||||
"readDetails": 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,
|
||||
"readDetails": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
},
|
||||
@ -191,6 +195,7 @@ Object {
|
||||
"demote": true,
|
||||
"promote": false,
|
||||
"read": true,
|
||||
"readDetails": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
},
|
||||
@ -251,6 +256,7 @@ Object {
|
||||
"demote": false,
|
||||
"promote": false,
|
||||
"read": true,
|
||||
"readDetails": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
},
|
||||
|
@ -10,7 +10,7 @@ import { presentUser, presentPolicies } from "../presenters";
|
||||
import { Op } from "../sequelize";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const { authorize } = policy;
|
||||
const { can, authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("users.list", auth(), pagination(), async (ctx) => {
|
||||
@ -23,10 +23,10 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
|
||||
if (direction !== "ASC") direction = "DESC";
|
||||
ctx.assertSort(sort, User);
|
||||
|
||||
const user = ctx.state.user;
|
||||
const actor = ctx.state.user;
|
||||
|
||||
let where = {
|
||||
teamId: user.teamId,
|
||||
teamId: actor.teamId,
|
||||
};
|
||||
|
||||
if (!includeSuspended) {
|
||||
@ -56,10 +56,10 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: users.map((listUser) =>
|
||||
presentUser(listUser, { includeDetails: user.isAdmin })
|
||||
data: users.map((user) =>
|
||||
presentUser(user, { includeDetails: can(actor, "readDetails", user) })
|
||||
),
|
||||
policies: presentPolicies(user, users),
|
||||
policies: presentPolicies(actor, users),
|
||||
};
|
||||
});
|
||||
|
||||
@ -75,11 +75,17 @@ router.post("users.count", auth(), async (ctx) => {
|
||||
});
|
||||
|
||||
router.post("users.info", auth(), async (ctx) => {
|
||||
const { user } = ctx.state;
|
||||
const { id } = ctx.body;
|
||||
const actor = ctx.state.user;
|
||||
|
||||
const user = id ? await User.findByPk(id) : actor;
|
||||
authorize(actor, "read", user);
|
||||
|
||||
const includeDetails = can(actor, "readDetails", user);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user),
|
||||
policies: presentPolicies(user, [user]),
|
||||
data: presentUser(user, { includeDetails }),
|
||||
policies: presentPolicies(actor, [user]),
|
||||
};
|
||||
});
|
||||
|
||||
@ -128,8 +134,10 @@ router.post("users.promote", auth(), async (ctx) => {
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
const includeDetails = can(actor, "readDetails", user);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, { includeDetails: true }),
|
||||
data: presentUser(user, { includeDetails }),
|
||||
policies: presentPolicies(actor, [user]),
|
||||
};
|
||||
});
|
||||
@ -159,8 +167,10 @@ router.post("users.demote", auth(), async (ctx) => {
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
const includeDetails = can(actor, "readDetails", user);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, { includeDetails: true }),
|
||||
data: presentUser(user, { includeDetails }),
|
||||
policies: presentPolicies(actor, [user]),
|
||||
};
|
||||
});
|
||||
@ -179,8 +189,10 @@ router.post("users.suspend", auth(), async (ctx) => {
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
const includeDetails = can(actor, "readDetails", user);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, { includeDetails: true }),
|
||||
data: presentUser(user, { includeDetails }),
|
||||
policies: presentPolicies(actor, [user]),
|
||||
};
|
||||
});
|
||||
@ -205,8 +217,10 @@ router.post("users.activate", auth(), async (ctx) => {
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
const includeDetails = can(actor, "readDetails", user);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, { includeDetails: true }),
|
||||
data: presentUser(user, { includeDetails }),
|
||||
policies: presentPolicies(actor, [user]),
|
||||
};
|
||||
});
|
||||
|
@ -87,7 +87,7 @@ describe("#users.list", () => {
|
||||
});
|
||||
|
||||
describe("#users.info", () => {
|
||||
it("should return known user", async () => {
|
||||
it("should return current user with no id", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/users.info", {
|
||||
body: { token: user.getJwtToken() },
|
||||
@ -97,6 +97,33 @@ describe("#users.info", () => {
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(user.id);
|
||||
expect(body.data.name).toEqual(user.name);
|
||||
expect(body.data.email).toEqual(user.email);
|
||||
});
|
||||
|
||||
it("should return user with permission", async () => {
|
||||
const user = await buildUser();
|
||||
const another = await buildUser({ teamId: user.teamId });
|
||||
const res = await server.post("/api/users.info", {
|
||||
body: { token: user.getJwtToken(), id: another.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(another.id);
|
||||
expect(body.data.name).toEqual(another.name);
|
||||
|
||||
// no emails of other users
|
||||
expect(body.data.email).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("should now return user without permission", async () => {
|
||||
const user = await buildUser();
|
||||
const another = await buildUser();
|
||||
const res = await server.post("/api/users.info", {
|
||||
body: { token: user.getJwtToken(), id: another.id },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
|
@ -37,6 +37,12 @@ allow(User, ["activate", "suspend"], User, (actor, user) => {
|
||||
throw new AdminRequiredError();
|
||||
});
|
||||
|
||||
allow(User, "readDetails", User, (actor, user) => {
|
||||
if (!user || user.teamId !== actor.teamId) return false;
|
||||
if (user === actor) return true;
|
||||
return actor.isAdmin;
|
||||
});
|
||||
|
||||
allow(User, "promote", User, (actor, user) => {
|
||||
if (!user || user.teamId !== actor.teamId) return false;
|
||||
if (user.isAdmin || user.isSuspended) return false;
|
||||
|
@ -8,8 +8,6 @@ Object {
|
||||
"isAdmin": undefined,
|
||||
"isSuspended": undefined,
|
||||
"isViewer": undefined,
|
||||
"language": "en_US",
|
||||
"lastActiveAt": undefined,
|
||||
"name": "Test User",
|
||||
}
|
||||
`;
|
||||
@ -22,8 +20,6 @@ Object {
|
||||
"isAdmin": undefined,
|
||||
"isSuspended": undefined,
|
||||
"isViewer": undefined,
|
||||
"language": "en_US",
|
||||
"lastActiveAt": undefined,
|
||||
"name": "Test User",
|
||||
}
|
||||
`;
|
||||
|
@ -20,16 +20,17 @@ export default (user: User, options: Options = {}): ?UserPresentation => {
|
||||
const userData = {};
|
||||
userData.id = user.id;
|
||||
userData.createdAt = user.createdAt;
|
||||
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";
|
||||
|
||||
if (options.includeDetails) {
|
||||
userData.lastActiveAt = user.lastActiveAt;
|
||||
userData.email = user.email;
|
||||
userData.language =
|
||||
user.language || process.env.DEFAULT_LANGUAGE || "en_US";
|
||||
}
|
||||
|
||||
return userData;
|
||||
|
@ -8,6 +8,7 @@
|
||||
"Drafts": "Drafts",
|
||||
"Templates": "Templates",
|
||||
"Deleted Collection": "Deleted Collection",
|
||||
"Viewers": "Viewers",
|
||||
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
|
||||
"Add a description": "Add a description",
|
||||
"Collapse": "Collapse",
|
||||
@ -29,6 +30,13 @@
|
||||
"in": "in",
|
||||
"nested document": "nested document",
|
||||
"nested document_plural": "nested documents",
|
||||
"Viewed by": "Viewed by",
|
||||
"only you": "only you",
|
||||
"person": "person",
|
||||
"people": "people",
|
||||
"Currently editing": "Currently editing",
|
||||
"Currently viewing": "Currently viewing",
|
||||
"Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago",
|
||||
"Insert column after": "Insert column after",
|
||||
"Insert column before": "Insert column before",
|
||||
"Insert row after": "Insert row after",
|
||||
@ -382,7 +390,6 @@
|
||||
"Active": "Active",
|
||||
"Admins": "Admins",
|
||||
"Suspended": "Suspended",
|
||||
"Viewers": "Viewers",
|
||||
"Everyone": "Everyone",
|
||||
"No people to see here.": "No people to see here.",
|
||||
"Profile saved": "Profile saved",
|
||||
|
Reference in New Issue
Block a user