feat: add total users to people management screen (#1878)
* feat: add total users to pagination * move this.total in runInAction callback * add total counts + counts to people tabs * progress: use raw pg query * progress: add test * fix: SQL interpolation * Styling and translation of People page Co-authored-by: Tim <timothychang94@gmail.com>
This commit is contained in:
@@ -3,11 +3,15 @@ import * as React from "react";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { bounceIn } from "shared/styles/animations";
|
import { bounceIn } from "shared/styles/animations";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
count: number,
|
count: number,
|
||||||
};
|
|};
|
||||||
|
|
||||||
const Bubble = ({ count }: Props) => {
|
const Bubble = ({ count }: Props) => {
|
||||||
|
if (!count) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <Count>{count}</Count>;
|
return <Count>{count}</Count>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,11 +16,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import CollectionNew from "scenes/CollectionNew";
|
import CollectionNew from "scenes/CollectionNew";
|
||||||
import Invite from "scenes/Invite";
|
import Invite from "scenes/Invite";
|
||||||
|
import Bubble from "components/Bubble";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import Modal from "components/Modal";
|
import Modal from "components/Modal";
|
||||||
import Scrollable from "components/Scrollable";
|
import Scrollable from "components/Scrollable";
|
||||||
import Sidebar from "./Sidebar";
|
import Sidebar from "./Sidebar";
|
||||||
import Bubble from "./components/Bubble";
|
|
||||||
import Collections from "./components/Collections";
|
import Collections from "./components/Collections";
|
||||||
import HeaderBlock from "./components/HeaderBlock";
|
import HeaderBlock from "./components/HeaderBlock";
|
||||||
import Section from "./components/Section";
|
import Section from "./components/Section";
|
||||||
@@ -118,9 +118,7 @@ function MainSidebar() {
|
|||||||
label={
|
label={
|
||||||
<Drafts align="center">
|
<Drafts align="center">
|
||||||
{t("Drafts")}
|
{t("Drafts")}
|
||||||
{documents.totalDrafts > 0 && (
|
<Bubble count={documents.totalDrafts} />
|
||||||
<Bubble count={documents.totalDrafts} />
|
|
||||||
)}
|
|
||||||
</Drafts>
|
</Drafts>
|
||||||
}
|
}
|
||||||
active={
|
active={
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ type Props = {
|
|||||||
|
|
||||||
const StyledNavLink = styled(NavLink)`
|
const StyledNavLink = styled(NavLink)`
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
display: inline-block;
|
align-items: center;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: ${(props) => props.theme.textTertiary};
|
color: ${(props) => props.theme.textTertiary};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const Tabs = styled.nav`
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transition: opacity 250ms ease-out;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const Separator = styled.span`
|
export const Separator = styled.span`
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ function CollectionMenu({
|
|||||||
[history, collection.id]
|
[history, collection.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleImportDocument = React.useCallback(
|
const handleImportDocument = React.useCallback(
|
||||||
(ev: SyntheticEvent<>) => {
|
(ev: SyntheticEvent<>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -107,7 +111,7 @@ function CollectionMenu({
|
|||||||
type="file"
|
type="file"
|
||||||
ref={file}
|
ref={file}
|
||||||
onChange={handleFilePicked}
|
onChange={handleFilePicked}
|
||||||
onClick={(ev) => ev.stopPropagation()}
|
onClick={stopPropagation}
|
||||||
accept={documents.importFileTypes.join(", ")}
|
accept={documents.importFileTypes.join(", ")}
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { observable } from "mobx";
|
|||||||
import { observer, inject } from "mobx-react";
|
import { observer, inject } from "mobx-react";
|
||||||
import { PlusIcon } from "outline-icons";
|
import { PlusIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { withTranslation, type TFunction, Trans } from "react-i18next";
|
||||||
import { type Match } from "react-router-dom";
|
import { type Match } from "react-router-dom";
|
||||||
|
|
||||||
import AuthStore from "stores/AuthStore";
|
import AuthStore from "stores/AuthStore";
|
||||||
import PoliciesStore from "stores/PoliciesStore";
|
import PoliciesStore from "stores/PoliciesStore";
|
||||||
import UsersStore from "stores/UsersStore";
|
import UsersStore from "stores/UsersStore";
|
||||||
import Invite from "scenes/Invite";
|
import Invite from "scenes/Invite";
|
||||||
|
import Bubble from "components/Bubble";
|
||||||
import Button from "components/Button";
|
import Button from "components/Button";
|
||||||
import CenteredContent from "components/CenteredContent";
|
import CenteredContent from "components/CenteredContent";
|
||||||
import Empty from "components/Empty";
|
import Empty from "components/Empty";
|
||||||
@@ -27,12 +28,20 @@ type Props = {
|
|||||||
users: UsersStore,
|
users: UsersStore,
|
||||||
policies: PoliciesStore,
|
policies: PoliciesStore,
|
||||||
match: Match,
|
match: Match,
|
||||||
|
t: TFunction,
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class People extends React.Component<Props> {
|
class People extends React.Component<Props> {
|
||||||
@observable inviteModalOpen: boolean = false;
|
@observable inviteModalOpen: boolean = false;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const { team } = this.props.auth;
|
||||||
|
if (team) {
|
||||||
|
this.props.users.fetchCounts(team.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleInviteModalOpen = () => {
|
handleInviteModalOpen = () => {
|
||||||
this.inviteModalOpen = true;
|
this.inviteModalOpen = true;
|
||||||
};
|
};
|
||||||
@@ -46,7 +55,7 @@ class People extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { auth, policies, match } = this.props;
|
const { auth, policies, match, t } = this.props;
|
||||||
const { filter } = match.params;
|
const { filter } = match.params;
|
||||||
const currentUser = auth.user;
|
const currentUser = auth.user;
|
||||||
const team = auth.team;
|
const team = auth.team;
|
||||||
@@ -65,15 +74,18 @@ class People extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const can = policies.abilities(team.id);
|
const can = policies.abilities(team.id);
|
||||||
|
const { counts } = this.props.users;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
<PageTitle title="People" />
|
<PageTitle title={t("People")} />
|
||||||
<h1>People</h1>
|
<h1>{t("People")}</h1>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
Everyone that has signed into Outline appears here. It’s possible that
|
<Trans>
|
||||||
there are other users who have access through {team.signinMethods} but
|
Everyone that has signed into Outline appears here. It’s possible
|
||||||
haven’t signed in yet.
|
that there are other users who have access through{" "}
|
||||||
|
{team.signinMethods} but haven’t signed in yet.
|
||||||
|
</Trans>
|
||||||
</HelpText>
|
</HelpText>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -84,37 +96,36 @@ class People extends React.Component<Props> {
|
|||||||
icon={<PlusIcon />}
|
icon={<PlusIcon />}
|
||||||
neutral
|
neutral
|
||||||
>
|
>
|
||||||
Invite people…
|
{t("Invite people")}…
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab to="/settings/people" exact>
|
<Tab to="/settings/people" exact>
|
||||||
Active
|
{t("Active")} <Bubble count={counts.active} />
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab to="/settings/people/admins" exact>
|
<Tab to="/settings/people/admins" exact>
|
||||||
Admins
|
{t("Admins")} <Bubble count={counts.admins} />
|
||||||
</Tab>
|
</Tab>
|
||||||
{can.update && (
|
{can.update && (
|
||||||
<Tab to="/settings/people/suspended" exact>
|
<Tab to="/settings/people/suspended" exact>
|
||||||
Suspended
|
{t("Suspended")} <Bubble count={counts.suspended} />
|
||||||
</Tab>
|
</Tab>
|
||||||
)}
|
)}
|
||||||
<Tab to="/settings/people/all" exact>
|
<Tab to="/settings/people/all" exact>
|
||||||
Everyone
|
{t("Everyone")} <Bubble count={counts.all - counts.invited} />
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
||||||
{can.invite && (
|
{can.invite && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Tab to="/settings/people/invited" exact>
|
<Tab to="/settings/people/invited" exact>
|
||||||
Invited
|
{t("Invited")} <Bubble count={counts.invited} />
|
||||||
</Tab>
|
</Tab>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<PaginatedList
|
<PaginatedList
|
||||||
items={users}
|
items={users}
|
||||||
empty={<Empty>No people to see here.</Empty>}
|
empty={<Empty>{t("No people to see here.")}</Empty>}
|
||||||
fetch={this.fetchPage}
|
fetch={this.fetchPage}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<UserListItem
|
<UserListItem
|
||||||
@@ -126,7 +137,7 @@ class People extends React.Component<Props> {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="Invite people"
|
title={t("Invite people")}
|
||||||
onRequestClose={this.handleInviteModalClose}
|
onRequestClose={this.handleInviteModalClose}
|
||||||
isOpen={this.inviteModalOpen}
|
isOpen={this.inviteModalOpen}
|
||||||
>
|
>
|
||||||
@@ -137,4 +148,8 @@ class People extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default inject("auth", "users", "policies")(People);
|
export default inject(
|
||||||
|
"auth",
|
||||||
|
"users",
|
||||||
|
"policies"
|
||||||
|
)(withTranslation()<People>(People));
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import BaseModel from "../models/BaseModel";
|
|||||||
import type { PaginationParams } from "types";
|
import type { PaginationParams } from "types";
|
||||||
import { client } from "utils/ApiClient";
|
import { client } from "utils/ApiClient";
|
||||||
|
|
||||||
type Action = "list" | "info" | "create" | "update" | "delete";
|
type Action = "list" | "info" | "create" | "update" | "delete" | "count";
|
||||||
|
|
||||||
function modelNameFromClassName(string) {
|
function modelNameFromClassName(string) {
|
||||||
return string.charAt(0).toLowerCase() + string.slice(1);
|
return string.charAt(0).toLowerCase() + string.slice(1);
|
||||||
@@ -24,7 +24,7 @@ export default class BaseStore<T: BaseModel> {
|
|||||||
model: Class<T>;
|
model: Class<T>;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
rootStore: RootStore;
|
rootStore: RootStore;
|
||||||
actions: Action[] = ["list", "info", "create", "update", "delete"];
|
actions: Action[] = ["list", "info", "create", "update", "delete", "count"];
|
||||||
|
|
||||||
constructor(rootStore: RootStore, model: Class<T>) {
|
constructor(rootStore: RootStore, model: Class<T>) {
|
||||||
this.rootStore = rootStore;
|
this.rootStore = rootStore;
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import { filter, orderBy } from "lodash";
|
import { filter, orderBy } from "lodash";
|
||||||
import { computed, action, runInAction } from "mobx";
|
import { observable, computed, action, runInAction } from "mobx";
|
||||||
import User from "models/User";
|
import User from "models/User";
|
||||||
import BaseStore from "./BaseStore";
|
import BaseStore from "./BaseStore";
|
||||||
import RootStore from "./RootStore";
|
import RootStore from "./RootStore";
|
||||||
import { client } from "utils/ApiClient";
|
import { client } from "utils/ApiClient";
|
||||||
|
|
||||||
export default class UsersStore extends BaseStore<User> {
|
export default class UsersStore extends BaseStore<User> {
|
||||||
|
@observable counts: {
|
||||||
|
active: number,
|
||||||
|
admins: number,
|
||||||
|
all: number,
|
||||||
|
invited: number,
|
||||||
|
suspended: number,
|
||||||
|
} = {};
|
||||||
|
|
||||||
constructor(rootStore: RootStore) {
|
constructor(rootStore: RootStore) {
|
||||||
super(rootStore, User);
|
super(rootStore, User);
|
||||||
}
|
}
|
||||||
@@ -52,21 +60,25 @@ export default class UsersStore extends BaseStore<User> {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
promote = (user: User) => {
|
promote = (user: User) => {
|
||||||
|
this.counts.admins += 1;
|
||||||
return this.actionOnUser("promote", user);
|
return this.actionOnUser("promote", user);
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
demote = (user: User) => {
|
demote = (user: User) => {
|
||||||
|
this.counts.admins -= 1;
|
||||||
return this.actionOnUser("demote", user);
|
return this.actionOnUser("demote", user);
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
suspend = (user: User) => {
|
suspend = (user: User) => {
|
||||||
|
this.counts.suspended += 1;
|
||||||
return this.actionOnUser("suspend", user);
|
return this.actionOnUser("suspend", user);
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
activate = (user: User) => {
|
activate = (user: User) => {
|
||||||
|
this.counts.suspended -= 1;
|
||||||
return this.actionOnUser("activate", user);
|
return this.actionOnUser("activate", user);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,10 +88,39 @@ export default class UsersStore extends BaseStore<User> {
|
|||||||
invariant(res && res.data, "Data should be available");
|
invariant(res && res.data, "Data should be available");
|
||||||
runInAction(`invite`, () => {
|
runInAction(`invite`, () => {
|
||||||
res.data.users.forEach(this.add);
|
res.data.users.forEach(this.add);
|
||||||
|
this.counts.invited += res.data.sent.length;
|
||||||
|
this.counts.all += res.data.sent.length;
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
fetchCounts = async (teamId: string): Promise<*> => {
|
||||||
|
const res = await client.post(`/users.count`, { teamId });
|
||||||
|
invariant(res && res.data, "Data should be available");
|
||||||
|
|
||||||
|
this.counts = res.data.counts;
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
async delete(user: User, options: Object = {}) {
|
||||||
|
super.delete(user, options);
|
||||||
|
if (!user.isSuspended && user.lastActiveAt) {
|
||||||
|
this.counts.active -= 1;
|
||||||
|
}
|
||||||
|
if (user.isInvited) {
|
||||||
|
this.counts.invited -= 1;
|
||||||
|
}
|
||||||
|
if (user.isAdmin) {
|
||||||
|
this.counts.admins -= 1;
|
||||||
|
}
|
||||||
|
if (user.isSuspended) {
|
||||||
|
this.counts.suspended -= 1;
|
||||||
|
}
|
||||||
|
this.counts.all -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
notInCollection = (collectionId: string, query: string = "") => {
|
notInCollection = (collectionId: string, query: string = "") => {
|
||||||
const memberships = filter(
|
const memberships = filter(
|
||||||
this.rootStore.memberships.orderedData,
|
this.rootStore.memberships.orderedData,
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("users.count", auth(), async (ctx) => {
|
||||||
|
const { user } = ctx.state;
|
||||||
|
const counts = await User.getCounts(user.teamId);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: {
|
||||||
|
counts,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
router.post("users.info", auth(), async (ctx) => {
|
router.post("users.info", auth(), async (ctx) => {
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentUser(ctx.state.user),
|
data: presentUser(ctx.state.user),
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import TestServer from "fetch-test-server";
|
import TestServer from "fetch-test-server";
|
||||||
import app from "../app";
|
import app from "../app";
|
||||||
|
|
||||||
import { buildUser } from "../test/factories";
|
import { buildTeam, buildUser } from "../test/factories";
|
||||||
|
|
||||||
import { flushdb, seed } from "../test/support";
|
import { flushdb, seed } from "../test/support";
|
||||||
|
|
||||||
const server = new TestServer(app.callback());
|
const server = new TestServer(app.callback());
|
||||||
@@ -353,3 +354,75 @@ describe("#users.activate", () => {
|
|||||||
expect(body).toMatchSnapshot();
|
expect(body).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("#users.count", () => {
|
||||||
|
it("should count active users", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const user = await buildUser({ teamId: team.id });
|
||||||
|
const res = await server.post("/api/users.count", {
|
||||||
|
body: { token: user.getJwtToken() },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.counts.all).toEqual(1);
|
||||||
|
expect(body.data.counts.admins).toEqual(0);
|
||||||
|
expect(body.data.counts.invited).toEqual(0);
|
||||||
|
expect(body.data.counts.suspended).toEqual(0);
|
||||||
|
expect(body.data.counts.active).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should count admin users", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const user = await buildUser({ teamId: team.id, isAdmin: true });
|
||||||
|
const res = await server.post("/api/users.count", {
|
||||||
|
body: { token: user.getJwtToken() },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.counts.all).toEqual(1);
|
||||||
|
expect(body.data.counts.admins).toEqual(1);
|
||||||
|
expect(body.data.counts.invited).toEqual(0);
|
||||||
|
expect(body.data.counts.suspended).toEqual(0);
|
||||||
|
expect(body.data.counts.active).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should count suspended users", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const user = await buildUser({ teamId: team.id });
|
||||||
|
await buildUser({ teamId: team.id, suspendedAt: new Date() });
|
||||||
|
const res = await server.post("/api/users.count", {
|
||||||
|
body: { token: user.getJwtToken() },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.counts.all).toEqual(2);
|
||||||
|
expect(body.data.counts.admins).toEqual(0);
|
||||||
|
expect(body.data.counts.invited).toEqual(0);
|
||||||
|
expect(body.data.counts.suspended).toEqual(1);
|
||||||
|
expect(body.data.counts.active).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should count invited users", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const user = await buildUser({ teamId: team.id, lastActiveAt: null });
|
||||||
|
const res = await server.post("/api/users.count", {
|
||||||
|
body: { token: user.getJwtToken() },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.counts.all).toEqual(1);
|
||||||
|
expect(body.data.counts.admins).toEqual(0);
|
||||||
|
expect(body.data.counts.invited).toEqual(1);
|
||||||
|
expect(body.data.counts.suspended).toEqual(0);
|
||||||
|
expect(body.data.counts.active).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require authentication", async () => {
|
||||||
|
const res = await server.post("/api/users.count");
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ const User = sequelize.define(
|
|||||||
isSuspended() {
|
isSuspended() {
|
||||||
return !!this.suspendedAt;
|
return !!this.suspendedAt;
|
||||||
},
|
},
|
||||||
|
isInvited() {
|
||||||
|
return !this.lastActiveAt;
|
||||||
|
},
|
||||||
avatarUrl() {
|
avatarUrl() {
|
||||||
const original = this.getDataValue("avatarUrl");
|
const original = this.getDataValue("avatarUrl");
|
||||||
if (original) {
|
if (original) {
|
||||||
@@ -267,4 +270,33 @@ User.afterCreate(async (user, options) => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
User.getCounts = async function (teamId: string) {
|
||||||
|
const countSql = `
|
||||||
|
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 "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
|
||||||
|
FROM users
|
||||||
|
WHERE "deletedAt" IS NULL
|
||||||
|
AND "teamId" = :teamId
|
||||||
|
`;
|
||||||
|
const results = await sequelize.query(countSql, {
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
replacements: {
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const counts = results[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: parseInt(counts.activeCount),
|
||||||
|
admins: parseInt(counts.adminCount),
|
||||||
|
all: parseInt(counts.count),
|
||||||
|
invited: parseInt(counts.invitedCount),
|
||||||
|
suspended: parseInt(counts.suspendedCount),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default User;
|
export default User;
|
||||||
|
|||||||
@@ -306,6 +306,12 @@
|
|||||||
"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",
|
"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>Create a new document?": "No documents found for your search filters. <1></1>Create a new document?",
|
||||||
"Clear filters": "Clear filters",
|
"Clear filters": "Clear filters",
|
||||||
|
"Everyone that has signed into Outline appears here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.": "Everyone that has signed into Outline appears here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.",
|
||||||
|
"Active": "Active",
|
||||||
|
"Admins": "Admins",
|
||||||
|
"Suspended": "Suspended",
|
||||||
|
"Everyone": "Everyone",
|
||||||
|
"No people to see here.": "No people to see here.",
|
||||||
"Profile saved": "Profile saved",
|
"Profile saved": "Profile saved",
|
||||||
"Profile picture updated": "Profile picture updated",
|
"Profile picture updated": "Profile picture updated",
|
||||||
"Unable to upload new profile picture": "Unable to upload new profile picture",
|
"Unable to upload new profile picture": "Unable to upload new profile picture",
|
||||||
@@ -323,7 +329,6 @@
|
|||||||
"You joined": "You joined",
|
"You joined": "You joined",
|
||||||
"Joined": "Joined",
|
"Joined": "Joined",
|
||||||
"{{ time }} ago.": "{{ time }} ago.",
|
"{{ time }} ago.": "{{ time }} ago.",
|
||||||
"Suspended": "Suspended",
|
|
||||||
"Edit Profile": "Edit Profile",
|
"Edit Profile": "Edit Profile",
|
||||||
"{{ userName }} hasn’t updated any documents yet.": "{{ userName }} hasn’t updated any documents yet."
|
"{{ userName }} hasn’t updated any documents yet.": "{{ userName }} hasn’t updated any documents yet."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user