feat: Rebuilt member admin (#2139)

This commit is contained in:
Tom Moor 2021-05-19 21:36:10 -07:00 committed by GitHub
parent 833bd51f4c
commit b0196f0cf0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 770 additions and 291 deletions

View File

@ -48,12 +48,13 @@ const MenuItem = ({
{(props) => (
<MenuAnchor
{...props}
$toggleable={selected !== undefined}
as={onClick ? "button" : as}
onClick={handleClick}
>
{selected !== undefined && (
<>
{selected ? <CheckmarkIcon /> : <Spacer />}
{selected ? <CheckmarkIcon color="currentColor" /> : <Spacer />}
&nbsp;
</>
)}
@ -64,9 +65,10 @@ const MenuItem = ({
);
};
const Spacer = styled.div`
const Spacer = styled.svg`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
export const MenuAnchor = styled.a`
@ -118,7 +120,7 @@ export const MenuAnchor = styled.a`
`};
${breakpoint("tablet")`
padding: 6px 12px;
padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
font-size: 15px;
`};
`;

View File

@ -5,7 +5,8 @@ import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
import Button, { Inner } from "components/Button";
import ContextMenu from "components/ContextMenu";
import FilterOption from "./FilterOption";
import MenuItem from "components/ContextMenu/MenuItem";
import HelpText from "components/HelpText";
type TFilterOption = {|
key: string,
@ -35,7 +36,7 @@ const FilterOptions = ({
const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : "";
return (
<SearchFilter>
<Wrapper>
<MenuButton {...menu}>
{(props) => (
<StyledButton
@ -50,30 +51,49 @@ const FilterOptions = ({
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} {...menu}>
<List>
{options.map((option) => (
<FilterOption
key={option.key}
onSelect={() => {
onSelect(option.key);
menu.hide();
}}
active={option.key === activeKey}
{...option}
{...menu}
/>
))}
</List>
{options.map((option) => (
<MenuItem
key={option.key}
onClick={() => {
onSelect(option.key);
menu.hide();
}}
selected={option.key === activeKey}
{...menu}
>
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
))}
</ContextMenu>
</SearchFilter>
</Wrapper>
);
};
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
`;
const Note = styled(HelpText)`
margin-top: 2px;
margin-bottom: 0;
line-height: 1.2em;
font-size: 14px;
font-weight: 400;
color: ${(props) => props.theme.textTertiary};
`;
const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
border-color: transparent;
height: 28px;
&:hover {
background: transparent;
@ -84,14 +104,8 @@ const StyledButton = styled(Button)`
}
`;
const SearchFilter = styled.div`
const Wrapper = styled.div`
margin-right: 8px;
`;
const List = styled("ol")`
list-style: none;
margin: 0;
padding: 0 8px;
`;
export default FilterOptions;

View File

@ -25,6 +25,7 @@ type Props = {|
className?: string,
children?: React.Node,
role?: string,
gap?: number,
|};
const Flex = React.forwardRef<Props, HTMLDivElement>((props: Props, ref) => {
@ -44,6 +45,7 @@ const Container = styled.div`
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
gap: ${({ gap }) => `${gap}px` || "initial"};
min-height: 0;
min-width: 0;
`;

View File

@ -3,13 +3,14 @@ import { SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Input from "./Input";
import Input, { type Props as InputProps } from "./Input";
type Props = {|
...InputProps,
placeholder?: string,
value?: string,
onChange: (event: SyntheticInputEvent<>) => mixed,
onKeyDown: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
onKeyDown?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|};
export default function InputSearch(props: Props) {

View File

@ -8,6 +8,8 @@ import Flex from "components/Flex";
type Props = {|
header?: boolean,
height?: number,
minWidth?: number,
maxWidth?: number,
|};
class Mask extends React.Component<Props> {
@ -17,9 +19,9 @@ class Mask extends React.Component<Props> {
return false;
}
constructor() {
constructor(props: Props) {
super();
this.width = randomInteger(75, 100);
this.width = randomInteger(props.minWidth || 75, props.maxWidth || 100);
}
render() {

View File

@ -179,7 +179,7 @@ function MainSidebar() {
/>
{can.inviteUser && (
<SidebarLink
to="/settings/people"
to="/settings/members"
onClick={handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={`${t("Invite people")}`}

View File

@ -96,10 +96,10 @@ function SettingsSidebar() {
/>
)}
<SidebarLink
to="/settings/people"
to="/settings/members"
icon={<UserIcon color="currentColor" />}
exact={false}
label={t("People")}
label={t("Members")}
/>
<SidebarLink
to="/settings/groups"

250
app/components/Table.js Normal file
View File

@ -0,0 +1,250 @@
// @flow
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTable, useSortBy, usePagination } from "react-table";
import styled from "styled-components";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import Mask from "components/Mask";
export type Props = {|
data: any[],
offset?: number,
isLoading: boolean,
empty?: React.Node,
currentPage?: number,
page: number,
pageSize?: number,
totalPages?: number,
defaultSort?: string,
topRef?: React.Ref<any>,
onChangePage: (index: number) => void,
onChangeSort: (sort: ?string, direction: "ASC" | "DESC") => void,
columns: any,
|};
function Table({
data,
offset,
isLoading,
totalPages,
empty,
columns,
page,
pageSize = 50,
defaultSort = "name",
topRef,
onChangeSort,
onChangePage,
}: Props) {
const { t } = useTranslation();
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
canNextPage,
nextPage,
canPreviousPage,
previousPage,
state: { pageIndex, sortBy },
} = useTable(
{
columns,
data,
manualPagination: true,
manualSortBy: true,
autoResetSortBy: false,
autoResetPage: false,
pageCount: totalPages,
initialState: {
sortBy: [{ id: defaultSort, desc: false }],
pageSize,
pageIndex: page,
},
},
useSortBy,
usePagination
);
React.useEffect(() => {
onChangePage(pageIndex);
}, [pageIndex]);
React.useEffect(() => {
onChangePage(0);
onChangeSort(
sortBy.length ? sortBy[0].id : undefined,
sortBy.length && sortBy[0].desc ? "DESC" : "ASC"
);
}, [sortBy]);
const isEmpty = !isLoading && data.length === 0;
const showPlaceholder = isLoading && data.length === 0;
console.log({ canNextPage, pageIndex, totalPages, rows, data });
return (
<>
<Anchor ref={topRef} />
<InnerTable {...getTableProps()}>
<thead>
{headerGroups.map((headerGroup) => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column) => (
<Head {...column.getHeaderProps(column.getSortByToggleProps())}>
<SortWrapper align="center" gap={4}>
{column.render("Header")}
{column.isSorted &&
(column.isSortedDesc ? (
<DescSortIcon />
) : (
<AscSortIcon />
))}
</SortWrapper>
</Head>
))}
</tr>
))}
</thead>
<tbody {...getTableBodyProps()}>
{rows.map((row) => {
prepareRow(row);
return (
<Row {...row.getRowProps()}>
{row.cells.map((cell) => (
<Cell
{...cell.getCellProps([
{
className: cell.column.className,
},
])}
>
{cell.render("Cell")}
</Cell>
))}
</Row>
);
})}
</tbody>
{showPlaceholder && <Placeholder columns={columns.length} />}
</InnerTable>
{isEmpty ? (
empty || <Empty>{t("No results")}</Empty>
) : (
<Pagination
justify={canPreviousPage ? "space-between" : "flex-end"}
gap={8}
>
{/* Note: the page > 0 check shouldn't be needed here but is */}
{canPreviousPage && page > 0 && (
<Button onClick={previousPage} neutral>
{t("Previous page")}
</Button>
)}
{canNextPage && (
<Button onClick={nextPage} neutral>
{t("Next page")}
</Button>
)}
</Pagination>
)}
</>
);
}
export const Placeholder = ({
columns,
rows = 3,
}: {
columns: number,
rows?: number,
}) => {
return (
<tbody>
{new Array(rows).fill().map((_, row) => (
<Row key={row}>
{new Array(columns).fill().map((_, col) => (
<Cell key={col}>
<Mask minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
))}
</tbody>
);
};
const Anchor = styled.div`
top: -32px;
position: relative;
`;
const Pagination = styled(Flex)`
margin: 0 0 32px;
`;
const DescSortIcon = styled(CollapsedIcon)`
&:hover {
fill: ${(props) => props.theme.text};
}
`;
const AscSortIcon = styled(DescSortIcon)`
transform: rotate(180deg);
`;
const InnerTable = styled.table`
border-collapse: collapse;
margin: 16px 0;
width: 100%;
`;
const SortWrapper = styled(Flex)`
height: 24px;
`;
const Cell = styled.td`
padding: 8px 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
font-size: 14px;
&:first-child {
font-size: 15px;
font-weight: 500;
}
&.actions,
&.right-aligned {
text-align: right;
vertical-align: bottom;
}
`;
const Row = styled.tr`
&:last-child {
${Cell} {
border-bottom: 0;
}
}
`;
const Head = styled.th`
text-align: left;
position: sticky;
top: 54px;
padding: 6px 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
font-size: 14px;
color: ${(props) => props.theme.textSecondary};
font-weight: 500;
z-index: 1;
`;
export default observer(Table);

View File

@ -1,6 +1,6 @@
// @flow
import * as React from "react";
import { Switch } from "react-router-dom";
import { Switch, Redirect } from "react-router-dom";
import Details from "scenes/Settings/Details";
import Groups from "scenes/Settings/Groups";
import ImportExport from "scenes/Settings/ImportExport";
@ -20,8 +20,7 @@ export default function SettingsRoutes() {
<Route exact path="/settings" component={Profile} />
<Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/security" component={Security} />
<Route exact path="/settings/people" component={People} />
<Route exact path="/settings/people/:filter" component={People} />
<Route exact path="/settings/members" component={People} />
<Route exact path="/settings/groups" component={Groups} />
<Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} />
@ -29,6 +28,7 @@ export default function SettingsRoutes() {
<Route exact path="/settings/integrations/slack" component={Slack} />
<Route exact path="/settings/integrations/zapier" component={Zapier} />
<Route exact path="/settings/import-export" component={ImportExport} />
<Redirect from="/settings/people" to="/settings/members" />
</Switch>
);
}

View File

@ -2,7 +2,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "./FilterOptions";
import FilterOptions from "components/FilterOptions";
import useStores from "hooks/useStores";
type Props = {|

View File

@ -1,7 +1,7 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "./FilterOptions";
import FilterOptions from "components/FilterOptions";
type Props = {|
dateFilter: ?string,

View File

@ -1,85 +0,0 @@
// @flow
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
type Props = {|
label: string,
note?: string,
onSelect: () => void,
active: boolean,
|};
const FilterOption = ({ label, note, onSelect, active, ...rest }: Props) => {
return (
<MenuItem onClick={active ? undefined : onSelect} {...rest}>
{(props) => (
<ListItem>
<Button active={active} {...props}>
<Flex align="center" justify="space-between">
<span>
{label}
{note && <Description small>{note}</Description>}
</span>
{active && <Checkmark />}
</Flex>
</Button>
</ListItem>
)}
</MenuItem>
);
};
const Description = styled(HelpText)`
margin-top: 2px;
margin-bottom: 0;
line-height: 1.2em;
`;
const Checkmark = styled(CheckmarkIcon)`
flex-shrink: 0;
padding-left: 4px;
fill: ${(props) => props.theme.text};
`;
const Button = styled.button`
display: flex;
flex-direction: column;
font-size: 16px;
padding: 8px;
margin: 0;
border: 0;
background: none;
color: ${(props) => props.theme.text};
text-align: left;
font-weight: ${(props) => (props.active ? "600" : "normal")};
justify-content: center;
width: 100%;
min-height: 42px;
${HelpText} {
font-weight: normal;
user-select: none;
}
&:hover {
background: ${(props) => props.theme.listItemHoverBackground};
}
${breakpoint("tablet")`
padding: 4px 8px;
font-size: 15px;
min-height: 32px;
`};
`;
const ListItem = styled("li")`
list-style: none;
max-width: 250px;
`;
export default FilterOption;

View File

@ -1,7 +1,7 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "./FilterOptions";
import FilterOptions from "components/FilterOptions";
type Props = {|
includeArchived?: boolean,

View File

@ -2,7 +2,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "./FilterOptions";
import FilterOptions from "components/FilterOptions";
import useStores from "hooks/useStores";
type Props = {|

View File

@ -1,161 +1,245 @@
// @flow
import invariant from "invariant";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { sortBy } from "lodash";
import { observer } from "mobx-react";
import { PlusIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import { type Match } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import PoliciesStore from "stores/PoliciesStore";
import UsersStore from "stores/UsersStore";
import { Trans, useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import { Action } from "components/Actions";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
import InputSearch from "components/InputSearch";
import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
import Scene from "components/Scene";
import Tab from "components/Tab";
import Tabs, { Separator } from "components/Tabs";
import UserListItem from "./components/UserListItem";
import PeopleTable from "./components/PeopleTable";
import UserStatusFilter from "./components/UserStatusFilter";
import useCurrentTeam from "hooks/useCurrentTeam";
import useQuery from "hooks/useQuery";
import useStores from "hooks/useStores";
type Props = {
auth: AuthStore,
users: UsersStore,
policies: PoliciesStore,
match: Match,
t: TFunction,
};
function People(props) {
const topRef = React.useRef();
const location = useLocation();
const history = useHistory();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const team = useCurrentTeam();
const { users, policies } = useStores();
const { t } = useTranslation();
const params = useQuery();
const [isLoading, setIsLoading] = React.useState(false);
const [data, setData] = React.useState([]);
const [totalPages, setTotalPages] = React.useState(0);
const [userIds, setUserIds] = React.useState([]);
const can = policies.abilities(team.id);
const query = params.get("query") || "";
const filter = params.get("filter") || "";
const sort = params.get("sort") || "name";
const direction = (params.get("direction") || "asc").toUpperCase();
const page = parseInt(params.get("page") || 0, 10);
const limit = 25;
@observer
class People extends React.Component<Props> {
@observable inviteModalOpen: boolean = false;
React.useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
componentDidMount() {
const { team } = this.props.auth;
if (team) {
this.props.users.fetchCounts(team.id);
}
}
try {
const response = await users.fetchPage({
offset: page * limit,
limit,
sort,
direction,
query,
filter,
});
handleInviteModalOpen = () => {
this.inviteModalOpen = true;
};
setTotalPages(Math.ceil(response.pagination.total / limit));
setUserIds(response.data.map((u) => u.id));
} finally {
setIsLoading(false);
}
};
handleInviteModalClose = () => {
this.inviteModalOpen = false;
};
fetchData();
}, [query, sort, filter, page, direction, users]);
fetchPage = (params) => {
return this.props.users.fetchPage({ ...params, includeSuspended: true });
};
render() {
const { auth, policies, match, t } = this.props;
const { filter } = match.params;
const currentUser = auth.user;
const team = auth.team;
invariant(currentUser, "User should exist");
invariant(team, "Team should exist");
let users = this.props.users.active;
if (filter === "all") {
users = this.props.users.all;
React.useEffect(() => {
let filtered = users.orderedData;
if (!filter) {
filtered = users.active.filter((u) => userIds.includes(u.id));
} else if (filter === "all") {
filtered = users.orderedData.filter((u) => userIds.includes(u.id));
} else if (filter === "admins") {
users = this.props.users.admins;
filtered = users.admins.filter((u) => userIds.includes(u.id));
} else if (filter === "suspended") {
users = this.props.users.suspended;
filtered = users.suspended.filter((u) => userIds.includes(u.id));
} else if (filter === "invited") {
users = this.props.users.invited;
filtered = users.invited.filter((u) => userIds.includes(u.id));
} else if (filter === "viewers") {
users = this.props.users.viewers;
filtered = users.viewers.filter((u) => userIds.includes(u.id));
}
const can = policies.abilities(team.id);
const { counts } = this.props.users;
// sort the resulting data by the original order from the server
setData(sortBy(filtered, (item) => userIds.indexOf(item.id)));
}, [
filter,
users.active,
users.admins,
users.orderedData,
users.suspended,
users.invited,
users.viewers,
userIds,
]);
return (
<Scene title={t("People")} icon={<UserIcon color="currentColor" />}>
<Heading>{t("People")}</Heading>
<HelpText>
<Trans>
Everyone that has signed into Outline appears here. Its possible
that there are other users who have access through{" "}
{team.signinMethods} but havent signed in yet.
</Trans>
</HelpText>
{can.inviteUser && (
<Button
type="button"
data-on="click"
data-event-category="invite"
data-event-action="peoplePage"
onClick={this.handleInviteModalOpen}
icon={<PlusIcon />}
neutral
>
{t("Invite people")}
</Button>
)}
const handleInviteModalOpen = React.useCallback(() => {
setInviteModalOpen(true);
}, []);
<Tabs>
<Tab to="/settings/people" exact>
{t("Active")} <Bubble count={counts.active} />
</Tab>
<Tab to="/settings/people/admins" exact>
{t("Admins")} <Bubble count={counts.admins} />
</Tab>
{can.update && (
<Tab to="/settings/people/suspended" exact>
{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>
const handleInviteModalClose = React.useCallback(() => {
setInviteModalOpen(false);
}, []);
const handleFilter = React.useCallback(
(filter) => {
if (filter) {
params.set("filter", filter);
params.delete("page");
} else {
params.delete("filter");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = React.useCallback(
(event) => {
const { value } = event.target;
if (value) {
params.set("query", event.target.value);
params.delete("page");
} else {
params.delete("query");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleChangeSort = React.useCallback(
(sort, direction) => {
if (sort) {
params.set("sort", sort);
} else {
params.delete("sort");
}
if (direction === "DESC") {
params.set("direction", direction.toLowerCase());
} else {
params.delete("direction");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleChangePage = React.useCallback(
(page) => {
if (page) {
params.set("page", page.toString());
} else {
params.delete("page");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
if (topRef.current) {
scrollIntoView(topRef.current, {
scrollMode: "if-needed",
behavior: "instant",
block: "start",
});
}
},
[params, history, location.pathname]
);
return (
<Scene
title={t("Members")}
icon={<UserIcon color="currentColor" />}
actions={
<>
{can.inviteUser && (
<>
<Separator />
<Tab to="/settings/people/invited" exact>
{t("Invited")} <Bubble count={counts.invited} />
</Tab>
</>
)}
</Tabs>
<PaginatedList
items={users}
empty={<Empty>{t("No people to see here.")}</Empty>}
fetch={this.fetchPage}
renderItem={(item) => (
<UserListItem
key={item.id}
user={item}
showMenu={can.update && currentUser.id !== item.id}
/>
<Action>
<Button
type="button"
data-on="click"
data-event-category="invite"
data-event-action="peoplePage"
onClick={handleInviteModalOpen}
icon={<PlusIcon />}
>
{t("Invite people")}
</Button>
</Action>
)}
</>
}
>
<Heading>{t("Members")}</Heading>
<HelpText>
<Trans>
Everyone that has signed into Outline appears here. Its possible that
there are other users who have access through {team.signinMethods} but
havent signed in yet.
</Trans>
</HelpText>
<Flex gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
{can.inviteUser && (
<Modal
title={t("Invite people")}
onRequestClose={this.handleInviteModalClose}
isOpen={this.inviteModalOpen}
>
<Invite onSubmit={this.handleInviteModalClose} />
</Modal>
)}
</Scene>
);
}
<UserStatusFilter activeKey={filter} onSelect={handleFilter} />
</Flex>
<PeopleTable
topRef={topRef}
data={data}
canManage={can.manage}
isLoading={isLoading}
onChangeSort={handleChangeSort}
onChangePage={handleChangePage}
page={page}
totalPages={totalPages}
/>
{can.inviteUser && (
<Modal
title={t("Invite people")}
onRequestClose={handleInviteModalClose}
isOpen={inviteModalOpen}
>
<Invite onSubmit={handleInviteModalClose} />
</Modal>
)}
</Scene>
);
}
export default inject(
"auth",
"users",
"policies"
)(withTranslation()<People>(People));
export default observer(People);

View File

@ -0,0 +1,92 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import User from "models/User";
import Avatar from "components/Avatar";
import Badge from "components/Badge";
import Flex from "components/Flex";
import { type Props as TableProps } from "components/Table";
import Time from "components/Time";
import useCurrentUser from "hooks/useCurrentUser";
import UserMenu from "menus/UserMenu";
const Table = React.lazy<TableProps>(() => import("components/Table"));
type Props = {|
...$Diff<TableProps, { columns: any }>,
data: User[],
canManage: boolean,
|};
function PeopleTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const columns = React.useMemo(
() =>
[
{
id: "name",
Header: t("Name"),
accessor: "name",
Cell: observer(({ value, row }) => (
<Flex align="center" gap={8}>
<Avatar src={row.original.avatarUrl} size={32} /> {value}{" "}
{currentUser.id === row.original.id && `(${t("You")})`}
</Flex>
)),
},
canManage
? {
id: "email",
Header: t("Email"),
accessor: "email",
Cell: observer(({ value }) => value),
}
: undefined,
{
id: "lastActiveAt",
Header: t("Last active"),
accessor: "lastActiveAt",
Cell: observer(
({ value }) => value && <Time dateTime={value} addSuffix />
),
},
{
id: "isAdmin",
Header: t("Role"),
accessor: "rank",
Cell: observer(({ row }) => (
<Badges>
{!row.original.lastActiveAt && <Badge>{t("Invited")}</Badge>}
{row.original.isAdmin && <Badge primary>{t("Admin")}</Badge>}
{row.original.isViewer && <Badge>{t("Viewer")}</Badge>}
{row.original.isSuspended && <Badge>{t("Suspended")}</Badge>}
</Badges>
)),
},
canManage
? {
Header: " ",
accessor: "id",
className: "actions",
Cell: observer(
({ row, value }) =>
currentUser.id !== value && <UserMenu user={row.original} />
),
}
: undefined,
].filter((i) => i),
[t, canManage, currentUser]
);
return <Table columns={columns} {...rest} />;
}
const Badges = styled.div`
margin-left: -10px;
`;
export default PeopleTable;

View File

@ -0,0 +1,54 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "components/FilterOptions";
type Props = {|
activeKey: string,
onSelect: (key: ?string) => void,
|};
const UserStatusFilter = ({ activeKey, onSelect }: Props) => {
const { t } = useTranslation();
const options = React.useMemo(
() => [
{
key: "",
label: t("Active"),
},
{
key: "all",
label: t("Everyone"),
},
{
key: "admins",
label: t("Admins"),
},
{
key: "suspended",
label: t("Suspended"),
},
{
key: "invited",
label: t("Invited"),
},
{
key: "viewers",
label: t("Viewers"),
},
],
[t]
);
return (
<FilterOptions
options={options}
activeKey={activeKey}
onSelect={onSelect}
defaultLabel={t("Active")}
/>
);
};
export default UserStatusFilter;

View File

@ -175,7 +175,7 @@ export default class BaseStore<T: BaseModel> {
res.data.forEach(this.add);
this.isLoaded = true;
});
return res.data;
return res;
} finally {
this.isFetching = false;
}

View File

@ -162,6 +162,7 @@
"react-keydown": "^1.7.3",
"react-portal": "^4.0.0",
"react-router-dom": "^5.2.0",
"react-table": "^7.7.0",
"react-virtualized-auto-sizer": "^1.0.2",
"react-waypoint": "^9.0.2",
"react-window": "^1.8.6",

View File

@ -14,28 +14,51 @@ const { can, authorize } = policy;
const router = new Router();
router.post("users.list", auth(), pagination(), async (ctx) => {
let {
sort = "createdAt",
query,
direction,
includeSuspended = false,
} = ctx.body;
let { sort = "createdAt", query, direction, filter } = ctx.body;
if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, User);
if (filter) {
ctx.assertIn(filter, [
"invited",
"viewers",
"admins",
"active",
"all",
"suspended",
]);
}
const actor = ctx.state.user;
let where = {
teamId: actor.teamId,
};
if (!includeSuspended) {
where = {
...where,
suspendedAt: {
[Op.eq]: null,
},
};
switch (filter) {
case "invited": {
where = { ...where, lastActiveAt: null };
break;
}
case "viewers": {
where = { ...where, isViewer: true };
break;
}
case "admins": {
where = { ...where, isAdmin: true };
break;
}
case "suspended": {
where = { ...where, suspendedAt: { [Op.ne]: null } };
break;
}
case "all": {
break;
}
default: {
where = { ...where, suspendedAt: { [Op.eq]: null } };
break;
}
}
if (query) {
@ -47,15 +70,23 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
};
}
const users = await User.findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const [users, total] = await Promise.all([
await User.findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
await User.count({
where,
}),
]);
ctx.body = {
pagination: ctx.state.pagination,
pagination: {
...ctx.state.pagination,
total,
},
data: users.map((user) =>
presentUser(user, { includeDetails: can(actor, "readDetails", user) })
),

View File

@ -35,7 +35,7 @@ describe("#users.list", () => {
expect(body.data[0].id).toEqual(user.id);
});
it("should allow including suspended", async () => {
it("should allow filtering to suspended users", async () => {
const user = await buildUser({ name: "Tester" });
await buildUser({
name: "Tester",
@ -46,14 +46,35 @@ describe("#users.list", () => {
const res = await server.post("/api/users.list", {
body: {
query: "test",
includeSuspended: true,
filter: "suspended",
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(2);
expect(body.data.length).toEqual(1);
});
it("should allow filtering to invited", async () => {
const user = await buildUser({ name: "Tester" });
await buildUser({
name: "Tester",
teamId: user.teamId,
lastActiveAt: null,
});
const res = await server.post("/api/users.list", {
body: {
query: "test",
filter: "invited",
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
});
it("should return teams paginated user list", async () => {

View File

@ -125,7 +125,7 @@
"Team": "Team",
"Details": "Details",
"Security": "Security",
"People": "People",
"Members": "Members",
"Groups": "Groups",
"Share Links": "Share Links",
"Import": "Import",
@ -134,6 +134,8 @@
"Installation": "Installation",
"Unstar": "Unstar",
"Star": "Star",
"Previous page": "Previous page",
"Next page": "Next page",
"Could not import file": "Could not import file",
"Appearance": "Appearance",
"System": "System",
@ -147,7 +149,6 @@
"Show path to document": "Show path to document",
"Path to document": "Path to document",
"Group member options": "Group member options",
"Members": "Members",
"Remove": "Remove",
"Collection": "Collection",
"New document": "New document",
@ -365,10 +366,18 @@
"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",
"Email": "Email",
"Last active": "Last active",
"Role": "Role",
"Viewer": "Viewer",
"Suspended": "Suspended",
"Shared": "Shared",
"by {{ name }}": "by {{ name }}",
"Last accessed": "Last accessed",
"Add to Slack": "Add to Slack",
"Active": "Active",
"Everyone": "Everyone",
"Admins": "Admins",
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
"New group": "New group",
"All groups": "All groups",
@ -387,11 +396,7 @@
"Requesting Export": "Requesting Export",
"Export Data": "Export Data",
"Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.": "Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.",
"Active": "Active",
"Admins": "Admins",
"Suspended": "Suspended",
"Everyone": "Everyone",
"No people to see here.": "No people to see here.",
"Filter": "Filter",
"Profile saved": "Profile saved",
"Profile picture updated": "Profile picture updated",
"Unable to upload new profile picture": "Unable to upload new profile picture",

View File

@ -10792,6 +10792,11 @@ react-side-effect@^1.1.0:
dependencies:
shallowequal "^1.0.1"
react-table@^7.7.0:
version "7.7.0"
resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.7.0.tgz#e2ce14d7fe3a559f7444e9ecfe8231ea8373f912"
integrity sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA==
react-virtualized-auto-sizer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd"