API to fetch users who have read/write permission on a document collection (#5047)

This commit is contained in:
Apoorv Mishra
2023-03-29 06:24:32 +05:30
committed by GitHub
parent fcc89be622
commit 1b1cd1c8d4
6 changed files with 349 additions and 5 deletions

View File

@@ -1,9 +1,11 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { v4 } from "uuid";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Flex from "~/components/Flex";
@@ -33,21 +35,28 @@ type Props = Omit<
"renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "onClearSearch"
>;
function MentionMenu({ search, ...rest }: Props) {
function MentionMenu({ search, isActive, ...rest }: Props) {
const [items, setItems] = React.useState<MentionItem[]>([]);
const { t } = useTranslation();
const { users, auth } = useStores();
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
const { view } = useEditor();
const { data, request } = useRequest(
React.useCallback(
() => users.fetchPage({ query: search, filter: "active" }),
[users, search]
() =>
documentId
? users.fetchDocumentUsers({ id: documentId, query: search })
: Promise.resolve([]),
[users, documentId, search]
)
);
React.useEffect(() => {
request();
}, [request]);
if (isActive) {
request();
}
}, [request, isActive]);
React.useEffect(() => {
if (data) {
@@ -85,6 +94,7 @@ function MentionMenu({ search, ...rest }: Props) {
return (
<SuggestionsMenu
{...rest}
isActive={isActive}
filterable={false}
onClearSearch={clearSearch}
search={search}

View File

@@ -72,6 +72,7 @@ export default class MembershipsStore extends BaseStore<Membership> {
userId,
});
this.remove(`${userId}-${collectionId}`);
this.rootStore.users.remove(userId);
}
@action

View File

@@ -159,6 +159,25 @@ export default class UsersStore extends BaseStore<User> {
return res.data;
};
@action
fetchDocumentUsers = async (params: {
id: string;
query?: string;
}): Promise<User[]> => {
try {
const res = await client.post("/documents.users", params);
invariant(res?.data, "User list not available");
let response: User[] = [];
runInAction("DocumentsStore#fetchUsers", () => {
response = res.data.map(this.add);
this.addPolicies(res.policies);
});
return response;
} catch (err) {
return Promise.resolve([]);
}
};
@action
async delete(user: User, options: Record<string, any> = {}) {
super.delete(user, options);

View File

@@ -8,6 +8,8 @@ import {
CollectionUser,
SearchQuery,
Event,
User,
CollectionGroup,
} from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import {
@@ -18,6 +20,7 @@ import {
buildDraftDocument,
buildViewer,
buildTeam,
buildGroup,
} from "@server/test/factories";
import { seed, getTestServer } from "@server/test/support";
@@ -3107,3 +3110,251 @@ describe("#documents.unpublish", () => {
expect(res.status).toEqual(401);
});
});
describe("#documents.users", () => {
it("should return document users", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
createdById: user.id,
});
const document = await buildDocument({
collectionId: collection.id,
createdById: user.id,
teamId: user.teamId,
});
const [alan, bret, ken] = await Promise.all([
buildUser({
name: "Alan Kay",
teamId: user.teamId,
}),
buildUser({
name: "Bret Victor",
teamId: user.teamId,
}),
buildUser({
name: "Ken Thompson",
teamId: user.teamId,
}),
]);
// add people to collection
await Promise.all([
CollectionUser.create({
collectionId: collection.id,
userId: alan.id,
permission: CollectionPermission.Read,
createdById: user.id,
}),
CollectionUser.create({
collectionId: collection.id,
userId: bret.id,
permission: CollectionPermission.Read,
createdById: user.id,
}),
CollectionUser.create({
collectionId: collection.id,
userId: ken.id,
permission: CollectionPermission.Read,
createdById: user.id,
}),
]);
const res = await server.post("/api/documents.users", {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.length).toBe(3);
const memberIds = body.data.map((u: User) => u.id);
expect(memberIds).toContain(alan.id);
expect(memberIds).toContain(bret.id);
expect(memberIds).toContain(ken.id);
});
it("should return document users with names matching the search query", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
createdById: user.id,
});
const document = await buildDocument({
collectionId: collection.id,
createdById: user.id,
teamId: user.teamId,
});
const [alan, bret, ken, jamie] = await Promise.all([
buildUser({
name: "Alan Kay",
teamId: user.teamId,
}),
buildUser({
name: "Bret Victor",
teamId: user.teamId,
}),
buildUser({
name: "Ken Thompson",
teamId: user.teamId,
}),
buildUser({
name: "Jamie Zawinsky",
teamId: user.teamId,
}),
]);
const group = await buildGroup({
name: "Hackers",
createdById: user.id,
teamId: user.teamId,
});
// add people to group
await Promise.all([
group.$add("user", ken, {
through: {
createdById: user.id,
},
}),
group.$add("user", jamie, {
through: {
createdById: user.id,
},
}),
]);
// add people and groups to collection
await Promise.all([
CollectionUser.create({
collectionId: collection.id,
userId: alan.id,
permission: CollectionPermission.Read,
createdById: user.id,
}),
CollectionUser.create({
collectionId: collection.id,
userId: bret.id,
permission: CollectionPermission.Read,
createdById: user.id,
}),
CollectionUser.create({
collectionId: collection.id,
userId: ken.id,
permission: CollectionPermission.Read,
createdById: user.id,
}),
CollectionGroup.create({
collectionId: collection.id,
groupId: group.id,
permission: CollectionPermission.ReadWrite,
createdById: user.id,
}),
]);
const res = await server.post("/api/documents.users", {
body: {
token: user.getJwtToken(),
id: document.id,
query: "Al",
},
});
const body = await res.json();
const anotherRes = await server.post("/api/documents.users", {
body: {
token: user.getJwtToken(),
id: document.id,
query: "e",
},
});
const anotherBody = await anotherRes.json();
expect(res.status).toBe(200);
expect(body.data.length).toBe(1);
expect(body.data[0].id).toContain(alan.id);
expect(body.data[0].name).toBe(alan.name);
expect(anotherRes.status).toBe(200);
expect(anotherBody.data.length).toBe(3);
const memberIds = anotherBody.data.map((u: User) => u.id);
const memberNames = anotherBody.data.map((u: User) => u.name);
expect(memberIds).toContain(bret.id);
expect(memberIds).toContain(ken.id);
expect(memberIds).toContain(jamie.id);
expect(memberNames).toContain(bret.name);
expect(memberNames).toContain(ken.name);
expect(memberNames).toContain(jamie.name);
});
it("should not return suspended users", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
createdById: user.id,
});
const document = await buildDocument({
collectionId: collection.id,
createdById: user.id,
teamId: user.teamId,
});
const [alan, bret, ken] = await Promise.all([
buildUser({
name: "Alan Kay",
teamId: user.teamId,
}),
buildUser({
name: "Bret Victor",
teamId: user.teamId,
}),
buildUser({
name: "Ken Thompson",
teamId: user.teamId,
}),
]);
// add people to collection
await Promise.all([
CollectionUser.create({
collectionId: collection.id,
userId: alan.id,
permission: CollectionPermission.Read,
createdById: user.id,
}),
CollectionUser.create({
collectionId: collection.id,
userId: bret.id,
permission: CollectionPermission.Read,
createdById: user.id,
}),
CollectionUser.create({
collectionId: collection.id,
userId: ken.id,
permission: CollectionPermission.Read,
createdById: user.id,
}),
]);
// suspend Alan
alan.suspendedAt = new Date();
await alan.save();
const res = await server.post("/api/documents.users", {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.length).toBe(2);
const memberIds = body.data.map((u: User) => u.id);
expect(memberIds).not.toContain(alan.id);
expect(memberIds).toContain(bret.id);
expect(memberIds).toContain(ken.id);
});
});

View File

@@ -42,6 +42,7 @@ import {
presentDocument,
presentPolicies,
presentPublicTeam,
presentUser,
} from "@server/presenters";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
@@ -435,6 +436,59 @@ router.post(
}
);
router.post(
"documents.users",
auth(),
pagination(),
validate(T.DocumentsUsersSchema),
async (ctx: APIContext<T.DocumentsUsersReq>) => {
const { id, query } = ctx.input.body;
const actor = ctx.state.auth.user;
const { offset, limit } = ctx.state.pagination;
const document = await Document.findByPk(id);
authorize(actor, "read", document);
let users: User[] = [];
let total = 0;
if (document.collectionId) {
const [collection, memberIds] = await Promise.all([
Collection.findByPk(document.collectionId),
Collection.membershipUserIds(document.collectionId),
]);
authorize(actor, "update", collection);
let where: WhereOptions<User> = {
id: {
[Op.in]: memberIds,
},
suspendedAt: {
[Op.is]: null,
},
};
if (query) {
where = {
...where,
name: {
[Op.iLike]: `%${query}%`,
},
};
}
[users, total] = await Promise.all([
User.findAll({ where, offset, limit }),
User.count({ where }),
]);
}
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data: users.map((user) => presentUser(user)),
policies: presentPolicies(actor, users),
};
}
);
router.post(
"documents.export",
rateLimiter(RateLimiterStrategy.FivePerMinute),

View File

@@ -302,3 +302,12 @@ export const DocumentsCreateSchema = BaseSchema.extend({
});
export type DocumentsCreateReq = z.infer<typeof DocumentsCreateSchema>;
export const DocumentsUsersSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
/** Query term to search users by name */
query: z.string().optional(),
}),
});
export type DocumentsUsersReq = z.infer<typeof DocumentsUsersSchema>;