From 1e6fd9ca21308d0884a6ecbebcb6fd61666efbd0 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 17 Jul 2024 22:35:26 -0400 Subject: [PATCH 01/51] Add documents.add_group and documents.remove_group endpoints --- app/stores/GroupsStore.ts | 26 ++-- .../server/tasks/DeliverWebhookTask.ts | 3 +- server/models/Document.ts | 4 + server/presenters/documentGroupMembership.ts | 20 --- .../api/collections/collections.test.ts | 8 +- server/routes/api/collections/collections.ts | 95 +++++++------- server/routes/api/documents/documents.ts | 119 ++++++++++++++++++ server/routes/api/documents/schema.ts | 21 ++++ 8 files changed, 220 insertions(+), 76 deletions(-) delete mode 100644 server/presenters/documentGroupMembership.ts diff --git a/app/stores/GroupsStore.ts b/app/stores/GroupsStore.ts index 4861aed087c7..cf4fcc36cf5a 100644 --- a/app/stores/GroupsStore.ts +++ b/app/stores/GroupsStore.ts @@ -41,6 +41,13 @@ export default class GroupsStore extends Store { } }; + /** + * Returns groups that are in the given collection, optionally filtered by a query. + * + * @param collectionId + * @param query + * @returns A list of groups that are in the given collection. + */ inCollection = (collectionId: string, query?: string) => { const memberships = filter( this.rootStore.groupMemberships.orderedData, @@ -50,12 +57,17 @@ export default class GroupsStore extends Store { const groups = filter(this.orderedData, (group) => groupIds.includes(group.id) ); - if (!query) { - return groups; - } - return queriedGroups(groups, query); + + return query ? queriedGroups(groups, query) : groups; }; + /** + * Returns groups that are not in the given collection, optionally filtered by a query. + * + * @param collectionId + * @param query + * @returns A list of groups that are not in the given collection. + */ notInCollection = (collectionId: string, query = "") => { const memberships = filter( this.rootStore.groupMemberships.orderedData, @@ -66,10 +78,8 @@ export default class GroupsStore extends Store { this.orderedData, (group) => !groupIds.includes(group.id) ); - if (!query) { - return groups; - } - return queriedGroups(groups, query); + + return query ? queriedGroups(groups, query) : groups; }; } diff --git a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts index b15160a0aada..c1a5ad970e84 100644 --- a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts +++ b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts @@ -42,7 +42,6 @@ import { presentGroupMembership, presentComment, } from "@server/presenters"; -import presentDocumentGroupMembership from "@server/presenters/documentGroupMembership"; import BaseTask from "@server/queues/tasks/BaseTask"; import { CollectionEvent, @@ -610,7 +609,7 @@ export default class DeliverWebhookTask extends BaseTask { subscription, payload: { id: event.modelId, - model: model && presentDocumentGroupMembership(model), + model: model && presentGroupMembership(model), document, group: model && presentGroup(model.group), }, diff --git a/server/models/Document.ts b/server/models/Document.ts index 0c8215775287..12f1ee45d9cd 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -55,6 +55,7 @@ import { ValidationError } from "@server/errors"; import Backlink from "./Backlink"; import Collection from "./Collection"; import FileOperation from "./FileOperation"; +import GroupMembership from "./GroupMembership"; import Revision from "./Revision"; import Star from "./Star"; import Team from "./Team"; @@ -550,6 +551,9 @@ class Document extends ParanoidModel< @HasMany(() => UserMembership) memberships: UserMembership[]; + @HasMany(() => GroupMembership, "documentId") + groupMemberships: GroupMembership[]; + @HasMany(() => Revision) revisions: Revision[]; diff --git a/server/presenters/documentGroupMembership.ts b/server/presenters/documentGroupMembership.ts deleted file mode 100644 index f062d1309fc8..000000000000 --- a/server/presenters/documentGroupMembership.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { DocumentPermission } from "@shared/types"; -import { GroupMembership } from "@server/models"; - -type Membership = { - id: string; - groupId: string; - documentId?: string | null; - permission: DocumentPermission; -}; - -export default function presentDocumentGroupMembership( - membership: GroupMembership -): Membership { - return { - id: membership.id, - groupId: membership.groupId, - documentId: membership.documentId, - permission: membership.permission as DocumentPermission, - }; -} diff --git a/server/routes/api/collections/collections.test.ts b/server/routes/api/collections/collections.test.ts index 6babf792ebf0..c50d597dc835 100644 --- a/server/routes/api/collections/collections.test.ts +++ b/server/routes/api/collections/collections.test.ts @@ -645,8 +645,8 @@ describe("#collections.remove_group", () => { groupId: group.id, }, }); - let users = await collection.$get("groups"); - expect(users.length).toEqual(1); + let groups = await collection.$get("groups"); + expect(groups.length).toEqual(1); const res = await server.post("/api/collections.remove_group", { body: { token: user.getJwtToken(), @@ -654,9 +654,9 @@ describe("#collections.remove_group", () => { groupId: group.id, }, }); - users = await collection.$get("groups"); + groups = await collection.$get("groups"); expect(res.status).toEqual(200); - expect(users.length).toEqual(0); + expect(groups.length).toEqual(0); }); it("should require group in team", async () => { diff --git a/server/routes/api/collections/collections.ts b/server/routes/api/collections/collections.ts index 79adb204d4aa..610279adcaf4 100644 --- a/server/routes/api/collections/collections.ts +++ b/server/routes/api/collections/collections.ts @@ -230,47 +230,51 @@ router.post( "collections.add_group", auth(), validate(T.CollectionsAddGroupSchema), + transaction(), async (ctx: APIContext) => { const { id, groupId, permission } = ctx.input.body; + const { transaction } = ctx.state; const { user } = ctx.state.auth; - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(id); + const [collection, group] = await Promise.all([ + Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(id, { transaction }), + Group.findByPk(groupId, { transaction }), + ]); authorize(user, "update", collection); - - const group = await Group.findByPk(groupId); authorize(user, "read", group); - let membership = await GroupMembership.findOne({ + const [membership] = await GroupMembership.findOrCreate({ where: { collectionId: id, groupId, }, - }); - - if (!membership) { - membership = await GroupMembership.create({ - collectionId: id, - groupId, + defaults: { permission, createdById: user.id, - }); - } else { - membership.permission = permission; - await membership.save(); - } - - await Event.createFromContext(ctx, { - name: "collections.add_group", - collectionId: collection.id, - modelId: groupId, - data: { - name: group.name, - membershipId: membership.id, }, + transaction, + lock: transaction.LOCK.UPDATE, }); + membership.permission = permission; + await membership.save({ transaction }); + + await Event.createFromContext( + ctx, + { + name: "collections.add_group", + collectionId: collection.id, + modelId: groupId, + data: { + name: group.name, + membershipId: membership.id, + }, + }, + { transaction } + ); + const groupMemberships = [presentGroupMembership(membership)]; ctx.body = { @@ -293,12 +297,17 @@ router.post( const { user } = ctx.state.auth; const { transaction } = ctx.state; - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(id, { transaction }); + const [collection, group] = await Promise.all([ + Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(id, { + transaction, + }), + Group.findByPk(groupId, { + transaction, + }), + ]); authorize(user, "update", collection); - - const group = await Group.findByPk(groupId, { transaction }); authorize(user, "read", group); const [membership] = await collection.$get("groupMemberships", { @@ -310,7 +319,7 @@ router.post( ctx.throw(400, "This Group is not a part of the collection"); } - await collection.$remove("group", group); + await collection.$remove("group", group, { transaction }); await Event.createFromContext( ctx, { @@ -402,19 +411,20 @@ router.post( "collections.add_user", auth(), rateLimiter(RateLimiterStrategy.OneHundredPerHour), - transaction(), validate(T.CollectionsAddUserSchema), + transaction(), async (ctx: APIContext) => { const { auth, transaction } = ctx.state; const actor = auth.user; const { id, userId, permission } = ctx.input.body; - const collection = await Collection.scope({ - method: ["withMembership", actor.id], - }).findByPk(id, { transaction }); + const [collection, user] = await Promise.all([ + Collection.scope({ + method: ["withMembership", actor.id], + }).findByPk(id, { transaction }), + User.findByPk(userId, { transaction }), + ]); authorize(actor, "update", collection); - - const user = await User.findByPk(userId); authorize(actor, "read", user); const [membership, isNew] = await UserMembership.findOrCreate({ @@ -471,12 +481,13 @@ router.post( const actor = auth.user; const { id, userId } = ctx.input.body; - const collection = await Collection.scope({ - method: ["withMembership", actor.id], - }).findByPk(id, { transaction }); + const [collection, user] = await Promise.all([ + Collection.scope({ + method: ["withMembership", actor.id], + }).findByPk(id, { transaction }), + User.findByPk(userId, { transaction }), + ]); authorize(actor, "update", collection); - - const user = await User.findByPk(userId, { transaction }); authorize(actor, "read", user); const [membership] = await collection.$get("memberships", { diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 115837884c00..63de06741b2f 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -43,6 +43,8 @@ import { User, View, UserMembership, + Group, + GroupMembership, } from "@server/models"; import AttachmentHelper from "@server/models/helpers/AttachmentHelper"; import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; @@ -56,6 +58,7 @@ import { presentMembership, presentPublicTeam, presentUser, + presentGroupMembership, } from "@server/presenters"; import DocumentImportTask, { DocumentImportTaskResponse, @@ -1623,6 +1626,122 @@ router.post( } ); +router.post( + "documents.add_group", + auth(), + validate(T.DocumentsAddGroupSchema), + transaction(), + async (ctx: APIContext) => { + const { id, groupId, permission } = ctx.input.body; + const { transaction } = ctx.state; + const { user } = ctx.state.auth; + + const [document, group] = await Promise.all([ + Document.findByPk(id, { + userId: user.id, + rejectOnEmpty: true, + transaction, + }), + Group.findByPk(groupId, { + rejectOnEmpty: true, + transaction, + }), + ]); + authorize(user, "update", document); + authorize(user, "read", group); + + const [membership] = await GroupMembership.findOrCreate({ + where: { + documentId: id, + groupId, + }, + defaults: { + permission, + createdById: user.id, + }, + lock: transaction.LOCK.UPDATE, + transaction, + }); + + membership.permission = permission; + await membership.save({ transaction }); + + await Event.createFromContext( + ctx, + { + name: "documents.add_group", + documentId: document.id, + modelId: groupId, + data: { + name: group.name, + membershipId: membership.id, + }, + }, + { transaction } + ); + + ctx.body = { + data: { + groupMemberships: [presentGroupMembership(membership)], + }, + }; + } +); + +router.post( + "documents.remove_group", + auth(), + validate(T.DocumentsRemoveGroupSchema), + transaction(), + async (ctx: APIContext) => { + const { id, groupId } = ctx.input.body; + const { user } = ctx.state.auth; + const { transaction } = ctx.state; + + const [document, group] = await Promise.all([ + Document.findByPk(id, { + userId: user.id, + rejectOnEmpty: true, + transaction, + }), + Group.findByPk(groupId, { + rejectOnEmpty: true, + transaction, + }), + ]); + authorize(user, "update", document); + authorize(user, "read", group); + + const [membership] = await document.$get("groupMemberships", { + where: { groupId }, + transaction, + }); + + if (!membership) { + ctx.throw(400, "This Group is not a part of the document"); + } + + await document.$remove("group", group, { transaction }); + await Event.createFromContext( + ctx, + { + name: "documents.remove_group", + documentId: document.id, + modelId: groupId, + data: { + name: group.name, + membershipId: membership.id, + }, + }, + { transaction } + ); + + ctx.body = { + success: true, + }; + } +); + router.post( "documents.memberships", auth(), diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index 0524e3d1525f..492e9305e9dc 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -415,6 +415,27 @@ export const DocumentsRemoveUserSchema = BaseSchema.extend({ export type DocumentsRemoveUserReq = z.infer; +export const DocumentsAddGroupSchema = BaseSchema.extend({ + body: BaseIdSchema.extend({ + groupId: z.string().uuid(), + permission: z + .nativeEnum(DocumentPermission) + .default(DocumentPermission.ReadWrite), + }), +}); + +export type DocumentsAddGroupsReq = z.infer; + +export const DocumentsRemoveGroupSchema = BaseSchema.extend({ + body: BaseIdSchema.extend({ + groupId: z.string().uuid(), + }), +}); + +export type DocumentsRemoveGroupReq = z.infer< + typeof DocumentsRemoveGroupSchema +>; + export const DocumentsSharedWithUserSchema = BaseSchema.extend({ body: DocumentsSortParamsSchema, }); From ef76d2e050a2596e9d7e96ec4b23c6531f299ce5 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 17 Jul 2024 22:42:49 -0400 Subject: [PATCH 02/51] Add documents.group_memberships endpoint --- server/routes/api/collections/collections.ts | 4 +- server/routes/api/collections/schema.ts | 11 ---- server/routes/api/documents/documents.ts | 64 ++++++++++++++++++++ server/routes/api/documents/schema.ts | 3 +- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/server/routes/api/collections/collections.ts b/server/routes/api/collections/collections.ts index 610279adcaf4..d37606d6ba2d 100644 --- a/server/routes/api/collections/collections.ts +++ b/server/routes/api/collections/collections.ts @@ -344,8 +344,8 @@ router.post( "collections.group_memberships", auth(), pagination(), - validate(T.CollectionsGroupMembershipsSchema), - async (ctx: APIContext) => { + validate(T.CollectionsMembershipsSchema), + async (ctx: APIContext) => { const { id, query, permission } = ctx.input.body; const { user } = ctx.state.auth; diff --git a/server/routes/api/collections/schema.ts b/server/routes/api/collections/schema.ts index c69097530afd..9355dc87599a 100644 --- a/server/routes/api/collections/schema.ts +++ b/server/routes/api/collections/schema.ts @@ -101,17 +101,6 @@ export type CollectionsRemoveGroupReq = z.infer< typeof CollectionsRemoveGroupSchema >; -export const CollectionsGroupMembershipsSchema = BaseSchema.extend({ - body: BaseIdSchema.extend({ - query: z.string().optional(), - permission: z.nativeEnum(CollectionPermission).optional(), - }), -}); - -export type CollectionsGroupMembershipsReq = z.infer< - typeof CollectionsGroupMembershipsSchema ->; - export const CollectionsAddUserSchema = BaseSchema.extend({ body: BaseIdSchema.extend({ userId: z.string().uuid(), diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 63de06741b2f..34faf8933051 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -59,6 +59,7 @@ import { presentPublicTeam, presentUser, presentGroupMembership, + presentGroup, } from "@server/presenters"; import DocumentImportTask, { DocumentImportTaskResponse, @@ -1803,6 +1804,69 @@ router.post( } ); +router.post( + "documents.group_memberships", + auth(), + pagination(), + validate(T.DocumentsMembershipsSchema), + async (ctx: APIContext) => { + const { id, query, permission } = ctx.input.body; + const { user } = ctx.state.auth; + + const document = await Document.findByPk(id, { userId: user.id }); + authorize(user, "update", document); + + let where: WhereOptions = { + documentId: id, + }; + let groupWhere; + + if (query) { + groupWhere = { + name: { + [Op.iLike]: `%${query}%`, + }, + }; + } + + if (permission) { + where = { ...where, permission }; + } + + const options = { + where, + include: [ + { + model: Group, + as: "group", + where: groupWhere, + required: true, + }, + ], + }; + + const [total, memberships] = await Promise.all([ + GroupMembership.count(options), + GroupMembership.findAll({ + ...options, + order: [["createdAt", "DESC"]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }), + ]); + + const groupMemberships = memberships.map(presentGroupMembership); + + ctx.body = { + pagination: { ...ctx.state.pagination, total }, + data: { + groupMemberships, + groups: memberships.map((membership) => presentGroup(membership.group)), + }, + }; + } +); + router.post( "documents.empty_trash", auth({ role: UserRole.Admin }), diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index 492e9305e9dc..c65362713060 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -445,8 +445,7 @@ export type DocumentsSharedWithUserReq = z.infer< >; export const DocumentsMembershipsSchema = BaseSchema.extend({ - body: z.object({ - id: z.string().uuid(), + body: BaseIdSchema.extend({ query: z.string().optional(), permission: z.nativeEnum(DocumentPermission).optional(), }), From 911729dad3d614668bbedf2fa867f91df4f4e1ee Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 18 Jul 2024 18:35:57 -0400 Subject: [PATCH 03/51] document group membership --- app/models/User.ts | 6 ++ server/commands/documentCreator.ts | 10 +-- server/commands/documentMover.ts | 48 ++++++++++--- server/models/Collection.ts | 88 +++++++++++++----------- server/models/Document.ts | 82 +++++++++++++++++++--- server/models/Group.ts | 10 +-- server/models/GroupMembership.ts | 21 +++--- server/models/Share.ts | 14 ++-- server/models/UserMembership.ts | 21 +++--- server/policies/collection.ts | 6 +- server/policies/document.ts | 14 ++-- server/routes/api/documents/documents.ts | 19 +++-- 12 files changed, 220 insertions(+), 119 deletions(-) diff --git a/app/models/User.ts b/app/models/User.ts index 9cd80882b2a4..862e7c7c78ca 100644 --- a/app/models/User.ts +++ b/app/models/User.ts @@ -127,6 +127,12 @@ class User extends ParanoidModel { ); } + /** + * Returns the direct user memberships that this user has to other documents. Documents that the + * user already has access to through a collection are not included. + * + * @returns A list of user memberships + */ @computed get memberships(): UserMembership[] { return this.store.rootStore.userMemberships.orderedData diff --git a/server/commands/documentCreator.ts b/server/commands/documentCreator.ts index 6f553d4c463a..b6bddc3853fc 100644 --- a/server/commands/documentCreator.ts +++ b/server/commands/documentCreator.ts @@ -178,14 +178,8 @@ export default async function documentCreator({ // reload to get all of the data needed to present (user, collection etc) // we need to specify publishedAt to bypass default scope that only returns // published documents - return await Document.scope([ - "withDrafts", - { method: ["withMembership", user.id] }, - ]).findOne({ - where: { - id: document.id, - publishedAt: document.publishedAt, - }, + return Document.findByPk(document.id, { + userId: user.id, rejectOnEmpty: true, transaction, }); diff --git a/server/commands/documentMover.ts b/server/commands/documentMover.ts index dc9603abc78f..451c6f92df7f 100644 --- a/server/commands/documentMover.ts +++ b/server/commands/documentMover.ts @@ -9,6 +9,7 @@ import { Pin, Event, UserMembership, + GroupMembership, } from "@server/models"; import pinDestroyer from "./pinDestroyer"; @@ -231,9 +232,14 @@ async function documentMover({ await document.save({ transaction }); result.documents.push(document); - // If there are any sourced permissions for this document, we need to go to the source - // permission and recalculate - const [documentPermissions, parentDocumentPermissions] = await Promise.all([ + // If there are any sourced memberships for this document, we need to go to the source + // memberships and recalculate the membership for the user or group. + const [ + userMemberships, + parentDocumentUserMemberships, + groupMemberships, + parentDocumentGroupMemberships, + ] = await Promise.all([ UserMembership.findRootMembershipsForDocument(document.id, undefined, { transaction, }), @@ -244,10 +250,25 @@ async function documentMover({ { transaction } ) : [], + GroupMembership.findRootMembershipsForDocument(document.id, undefined, { + transaction, + }), + parentDocumentId + ? GroupMembership.findRootMembershipsForDocument( + parentDocumentId, + undefined, + { transaction } + ) + : [], ]); - await recalculatePermissions(documentPermissions, transaction); - await recalculatePermissions(parentDocumentPermissions, transaction); + await recalculateUserMemberships(userMemberships, transaction); + await recalculateUserMemberships(parentDocumentUserMemberships, transaction); + await recalculateGroupMemberships(groupMemberships, transaction); + await recalculateGroupMemberships( + parentDocumentGroupMemberships, + transaction + ); await Event.create( { @@ -272,12 +293,21 @@ async function documentMover({ return result; } -async function recalculatePermissions( - permissions: UserMembership[], +async function recalculateUserMemberships( + memberships: UserMembership[], + transaction?: Transaction +) { + for (const membership of memberships) { + await UserMembership.createSourcedMemberships(membership, { transaction }); + } +} + +async function recalculateGroupMemberships( + memberships: GroupMembership[], transaction?: Transaction ) { - for (const permission of permissions) { - await UserMembership.createSourcedMemberships(permission, { transaction }); + for (const membership of memberships) { + await GroupMembership.createSourcedMemberships(membership, { transaction }); } } diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 7aeae135005c..51c57629fd49 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -99,47 +99,52 @@ import NotContainsUrl from "./validators/NotContainsUrl"; }, ], }), - withMembership: (userId: string) => ({ - include: [ - { - model: UserMembership, - as: "memberships", - where: { - userId, + withMembership: (userId: string) => { + if (!userId) { + return {}; + } + + return { + include: [ + { + association: "memberships", + where: { + userId, + }, + required: false, }, - required: false, - }, - { - model: GroupMembership, - as: "groupMemberships", - required: false, - // use of "separate" property: sequelize breaks when there are - // nested "includes" with alternating values for "required" - // see https://github.com/sequelize/sequelize/issues/9869 - separate: true, - // include for groups that are members of this collection, - // of which userId is a member of, resulting in: - // CollectionGroup [inner join] Group [inner join] GroupUser [where] userId - include: [ - { - model: Group, - as: "group", - required: true, - include: [ - { - model: GroupUser, - as: "groupUsers", - required: true, - where: { - userId, + { + model: GroupMembership, + as: "groupMemberships", + required: false, + // use of "separate" property: sequelize breaks when there are + // nested "includes" with alternating values for "required" + // see https://github.com/sequelize/sequelize/issues/9869 + separate: true, + // include for groups that are members of this collection, + // of which userId is a member of, resulting in: + // CollectionGroup [inner join] Group [inner join] GroupUser [where] userId + include: [ + { + model: Group, + as: "group", + required: true, + include: [ + { + model: GroupUser, + as: "groupUsers", + required: true, + where: { + userId, + }, }, - }, - ], - }, - ], - }, - ], - }), + ], + }, + ], + }, + ], + }; + }, })) @Table({ tableName: "collections", modelName: "collection" }) @Fix @@ -353,7 +358,7 @@ class Collection extends ParanoidModel< /** * Returns an array of unique userIds that are members of a collection, - * either via group or direct membership + * either via group or direct membership. * * @param collectionId * @returns userIds @@ -362,13 +367,12 @@ class Collection extends ParanoidModel< const collection = await this.scope("withAllMemberships").findByPk( collectionId ); - if (!collection) { return []; } const groupMemberships = collection.groupMemberships - .map((cgm) => cgm.group.groupUsers) + .map((gm) => gm.group.groupUsers) .flat(); const membershipUserIds = [ ...groupMemberships, diff --git a/server/models/Document.ts b/server/models/Document.ts index 12f1ee45d9cd..bd677cb31780 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -55,7 +55,9 @@ import { ValidationError } from "@server/errors"; import Backlink from "./Backlink"; import Collection from "./Collection"; import FileOperation from "./FileOperation"; +import Group from "./Group"; import GroupMembership from "./GroupMembership"; +import GroupUser from "./GroupUser"; import Revision from "./Revision"; import Star from "./Star"; import Team from "./Team"; @@ -148,13 +150,11 @@ type AdditionalFindOptions = { withDrafts: { include: [ { - model: User, - as: "createdBy", + association: "createdBy", paranoid: false, }, { - model: User, - as: "updatedBy", + association: "updatedBy", paranoid: false, }, ], @@ -181,6 +181,7 @@ type AdditionalFindOptions = { if (!userId) { return {}; } + return { include: [ { @@ -190,6 +191,34 @@ type AdditionalFindOptions = { }, required: false, }, + { + association: "groupMemberships", + required: false, + // use of "separate" property: sequelize breaks when there are + // nested "includes" with alternating values for "required" + // see https://github.com/sequelize/sequelize/issues/9869 + separate: true, + // include for groups that are members of this collection, + // of which userId is a member of, resulting in: + // CollectionGroup [inner join] Group [inner join] GroupUser [where] userId + include: [ + { + model: Group, + as: "group", + required: true, + include: [ + { + model: GroupUser, + as: "groupUsers", + required: true, + where: { + userId, + }, + }, + ], + }, + ], + }, ], }; }, @@ -197,6 +226,33 @@ type AdditionalFindOptions = { include: [ { association: "memberships", + required: false, + }, + { + model: GroupMembership, + as: "groupMemberships", + required: false, + // use of "separate" property: sequelize breaks when there are + // nested "includes" with alternating values for "required" + // see https://github.com/sequelize/sequelize/issues/9869 + separate: true, + // include for groups that are members of this collection, + // of which userId is a member of, resulting in: + // CollectionGroup [inner join] Group [inner join] GroupUser [where] userId + include: [ + { + model: Group, + as: "group", + required: true, + include: [ + { + model: GroupUser, + as: "groupUsers", + required: true, + }, + ], + }, + ], }, ], }, @@ -539,7 +595,7 @@ class Document extends ParanoidModel< teamId: string; @BelongsTo(() => Collection, "collectionId") - collection: Collection | null | undefined; + collection: Collection | null; @BelongsToMany(() => User, () => UserMembership) users: User[]; @@ -567,22 +623,28 @@ class Document extends ParanoidModel< views: View[]; /** - * Returns an array of unique userIds that are members of a document via direct membership + * Returns an array of unique userIds that are members of a document + * either via group or direct membership. * * @param documentId * @returns userIds */ static async membershipUserIds(documentId: string) { const document = await this.scope("withAllMemberships").findOne({ - where: { - id: documentId, - }, + where: { id: documentId }, }); if (!document) { return []; } - return document.memberships.map((membership) => membership.userId); + const groupMemberships = document.groupMemberships + .map((gm) => gm.group.groupUsers) + .flat(); + const membershipUserIds = [ + ...groupMemberships, + ...document.memberships, + ].map((membership) => membership.userId); + return uniq(membershipUserIds); } static defaultScopeWithUser(userId: string) { diff --git a/server/models/Group.ts b/server/models/Group.ts index 125636b4e79e..e47fcf9e411f 100644 --- a/server/models/Group.ts +++ b/server/models/Group.ts @@ -29,7 +29,7 @@ import NotContainsUrl from "./validators/NotContainsUrl"; ], })) @Scopes(() => ({ - withMember: (memberId: string) => ({ + withMember: (userId: string) => ({ include: [ { association: "groupUsers", @@ -39,7 +39,7 @@ import NotContainsUrl from "./validators/NotContainsUrl"; association: "members", required: true, where: { - userId: memberId, + userId, }, }, ], @@ -97,9 +97,9 @@ class Group extends ParanoidModel< }); } - static filterByMember(memberId: string | undefined) { - return memberId - ? this.scope({ method: ["withMember", memberId] }) + static filterByMember(userId: string | undefined) { + return userId + ? this.scope({ method: ["withMember", userId] }) : this.scope("defaultScope"); } diff --git a/server/models/GroupMembership.ts b/server/models/GroupMembership.ts index 1d87e9c16e6f..2f01c8de705d 100644 --- a/server/models/GroupMembership.ts +++ b/server/models/GroupMembership.ts @@ -67,6 +67,7 @@ class GroupMembership extends ParanoidModel< InferAttributes, Partial> > { + /** The permission granted to the group. */ @Default(CollectionPermission.ReadWrite) @IsIn([Object.values(CollectionPermission)]) @Column(DataType.STRING) @@ -74,47 +75,47 @@ class GroupMembership extends ParanoidModel< // associations - /** The collection that this permission grants the group access to. */ + /** The collection that this membership grants the group access to. */ @BelongsTo(() => Collection, "collectionId") collection?: Collection | null; - /** The collection ID that this permission grants the group access to. */ + /** The collection ID that this membership grants the group access to. */ @ForeignKey(() => Collection) @Column(DataType.UUID) collectionId?: string | null; - /** The document that this permission grants the group access to. */ + /** The document that this membership grants the group access to. */ @BelongsTo(() => Document, "documentId") document?: Document | null; - /** The document ID that this permission grants the group access to. */ + /** The document ID that this membership grants the group access to. */ @ForeignKey(() => Document) @Column(DataType.UUID) documentId?: string | null; - /** If this represents the permission on a child then this points to the permission on the root */ + /** If this represents the membership on a child then this points to the membership on the root */ @BelongsTo(() => GroupMembership, "sourceId") source?: GroupMembership | null; - /** If this represents the permission on a child then this points to the permission on the root */ + /** If this represents the membership on a child then this points to the membership on the root */ @ForeignKey(() => GroupMembership) @Column(DataType.UUID) sourceId?: string | null; - /** The group that this permission is granted to. */ + /** The group that this membership is granted to. */ @BelongsTo(() => Group, "groupId") group: Group; - /** The group ID that this permission is granted to. */ + /** The group ID that this membership is granted to. */ @ForeignKey(() => Group) @Column(DataType.UUID) groupId: string; - /** The user that created this permission. */ + /** The user that created this membership. */ @BelongsTo(() => User, "createdById") createdBy: User; - /** The user ID that created this permission. */ + /** The user ID that created this membership. */ @ForeignKey(() => User) @Column(DataType.UUID) createdById: string; diff --git a/server/models/Share.ts b/server/models/Share.ts index 52f4fb1c5a87..91b217681e8f 100644 --- a/server/models/Share.ts +++ b/server/models/Share.ts @@ -48,7 +48,12 @@ import Length from "./validators/Length"; withCollectionPermissions: (userId: string) => ({ include: [ { - model: Document.scope("withDrafts"), + model: Document.scope([ + "withDrafts", + { + method: ["withMembership", userId], + }, + ]), paranoid: true, as: "document", include: [ @@ -59,13 +64,6 @@ import Length from "./validators/Length"; }), as: "collection", }, - { - association: "memberships", - where: { - userId, - }, - required: false, - }, ], }, { diff --git a/server/models/UserMembership.ts b/server/models/UserMembership.ts index d97f23b920c1..c0551a38c3d7 100644 --- a/server/models/UserMembership.ts +++ b/server/models/UserMembership.ts @@ -67,6 +67,7 @@ class UserMembership extends IdModel< InferAttributes, Partial> > { + /** The permission granted to the user. */ @Default(CollectionPermission.ReadWrite) @IsIn([Object.values(CollectionPermission)]) @Column(DataType.STRING) @@ -79,47 +80,47 @@ class UserMembership extends IdModel< // associations - /** The collection that this permission grants the user access to. */ + /** The collection that this membership grants the user access to. */ @BelongsTo(() => Collection, "collectionId") collection?: Collection | null; - /** The collection ID that this permission grants the user access to. */ + /** The collection ID that this membership grants the user access to. */ @ForeignKey(() => Collection) @Column(DataType.UUID) collectionId?: string | null; - /** The document that this permission grants the user access to. */ + /** The document that this membership grants the user access to. */ @BelongsTo(() => Document, "documentId") document?: Document | null; - /** The document ID that this permission grants the user access to. */ + /** The document ID that this membership grants the user access to. */ @ForeignKey(() => Document) @Column(DataType.UUID) documentId?: string | null; - /** If this represents the permission on a child then this points to the permission on the root */ + /** If this represents the membership on a child then this points to the membership on the root */ @BelongsTo(() => UserMembership, "sourceId") source?: UserMembership | null; - /** If this represents the permission on a child then this points to the permission on the root */ + /** If this represents the membership on a child then this points to the membership on the root */ @ForeignKey(() => UserMembership) @Column(DataType.UUID) sourceId?: string | null; - /** The user that this permission is granted to. */ + /** The user that this membership is granted to. */ @BelongsTo(() => User, "userId") user: User; - /** The user ID that this permission is granted to. */ + /** The user ID that this membership is granted to. */ @ForeignKey(() => User) @Column(DataType.UUID) userId: string; - /** The user that created this permission. */ + /** The user that created this membership. */ @BelongsTo(() => User, "createdById") createdBy: User; - /** The user ID that created this permission. */ + /** The user ID that created this membership. */ @ForeignKey(() => User) @Column(DataType.UUID) createdById: string; diff --git a/server/policies/collection.ts b/server/policies/collection.ts index f96dc0ebac14..cc6854bb8cad 100644 --- a/server/policies/collection.ts +++ b/server/policies/collection.ts @@ -1,6 +1,6 @@ import invariant from "invariant"; import some from "lodash/some"; -import { CollectionPermission, DocumentPermission } from "@shared/types"; +import { CollectionPermission } from "@shared/types"; import { Collection, User, Team } from "@server/models"; import { allow, _can as can } from "./cancan"; import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils"; @@ -150,7 +150,7 @@ allow(User, ["update", "delete"], Collection, (user, collection) => { function includesMembership( collection: Collection | null, - permissions: (CollectionPermission | DocumentPermission)[] + permissions: CollectionPermission[] ) { if (!collection) { return false; @@ -162,6 +162,6 @@ function includesMembership( ); return some( [...collection.memberships, ...collection.groupMemberships], - (m) => permissions.includes(m.permission) + (m) => permissions.includes(m.permission as CollectionPermission) ); } diff --git a/server/policies/document.ts b/server/policies/document.ts index d634b10b086e..99e34646fac4 100644 --- a/server/policies/document.ts +++ b/server/policies/document.ts @@ -1,10 +1,6 @@ import invariant from "invariant"; import some from "lodash/some"; -import { - CollectionPermission, - DocumentPermission, - TeamPreference, -} from "@shared/types"; +import { DocumentPermission, TeamPreference } from "@shared/types"; import { Document, Revision, User, Team } from "@server/models"; import { allow, _cannot as cannot, _can as can } from "./cancan"; import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils"; @@ -248,7 +244,7 @@ allow(User, "unpublish", Document, (user, document) => { function includesMembership( document: Document | null, - permissions: (DocumentPermission | CollectionPermission)[] + permissions: DocumentPermission[] ) { if (!document) { return false; @@ -256,7 +252,9 @@ function includesMembership( invariant( document.memberships, - "document memberships should be preloaded, did you forget withMembership scope?" + "Development: document memberships should be preloaded, did you forget withMembership scope?" + ); + return some([...document.memberships, ...document.groupMemberships], (m) => + permissions.includes(m.permission as DocumentPermission) ); - return some(document.memberships, (m) => permissions.includes(m.permission)); } diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 34faf8933051..d68f76043be7 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -1651,21 +1651,27 @@ router.post( authorize(user, "update", document); authorize(user, "read", group); - const [membership] = await GroupMembership.findOrCreate({ + const [membership, isNew] = await GroupMembership.findOrCreate({ where: { documentId: id, groupId, }, defaults: { - permission, + permission: permission || user.defaultDocumentPermission, createdById: user.id, }, lock: transaction.LOCK.UPDATE, transaction, }); - membership.permission = permission; - await membership.save({ transaction }); + if (permission) { + membership.permission = permission; + + // disconnect from the source if the permission is manually updated + membership.sourceId = null; + + await membership.save({ transaction }); + } await Event.createFromContext( ctx, @@ -1674,8 +1680,9 @@ router.post( documentId: document.id, modelId: groupId, data: { - name: group.name, - membershipId: membership.id, + title: document.title, + isNew, + permission: membership.permission, }, }, { transaction } From 91483c7b176bb202cfcc8d91c5bebb87e2122950 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 18 Jul 2024 18:45:32 -0400 Subject: [PATCH 04/51] tsc --- server/commands/documentDuplicator.ts | 4 ++-- server/routes/api/documents/documents.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/server/commands/documentDuplicator.ts b/server/commands/documentDuplicator.ts index 8e0b32928253..a680ca180189 100644 --- a/server/commands/documentDuplicator.ts +++ b/server/commands/documentDuplicator.ts @@ -54,7 +54,7 @@ export default async function documentDuplicator({ ...sharedProperties, }); - duplicated.collection = collection; + duplicated.collection = collection ?? null; newDocuments.push(duplicated); async function duplicateChildDocuments( @@ -86,7 +86,7 @@ export default async function documentDuplicator({ ...sharedProperties, }); - duplicatedChildDocument.collection = collection; + duplicatedChildDocument.collection = collection ?? null; newDocuments.push(duplicatedChildDocument); await duplicateChildDocuments(childDocument, duplicatedChildDocument); } diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index d68f76043be7..c97484ba02db 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -1461,7 +1461,9 @@ router.post( transaction, }); - document.collection = collection; + if (collection) { + document.collection = collection; + } ctx.body = { data: await presentDocument(ctx, document), From 17659fac3848212e44d377b19c5cea7cb8cc8397 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 19 Jul 2024 07:58:53 -0400 Subject: [PATCH 05/51] wip: Allow adding groups to docs in UI --- .../Sharing/Collection/SharePopover.tsx | 1 - .../Sharing/Document/DocumentMemberList.tsx | 35 ++++++-- .../Sharing/Document/SharePopover.tsx | 79 ++++++++++++++----- .../Sharing/components/Suggestions.tsx | 21 +++-- app/models/Collection.ts | 14 ++++ app/stores/GroupMembershipsStore.ts | 20 ++++- app/stores/GroupsStore.ts | 21 +++++ app/stores/UsersStore.ts | 14 ++++ server/models/Collection.ts | 2 +- server/models/Document.ts | 4 +- server/policies/collection.ts | 4 + server/policies/document.ts | 4 + shared/i18n/locales/en_US/translation.json | 8 +- 13 files changed, 175 insertions(+), 52 deletions(-) diff --git a/app/components/Sharing/Collection/SharePopover.tsx b/app/components/Sharing/Collection/SharePopover.tsx index f3c0ccca01a0..ccc246ffc078 100644 --- a/app/components/Sharing/Collection/SharePopover.tsx +++ b/app/components/Sharing/Collection/SharePopover.tsx @@ -362,7 +362,6 @@ function SharePopover({ collection, visible, onRequestClose }: Props) { addPendingId={handleAddPendingId} removePendingId={handleRemovePendingId} onEscape={handleEscape} - showGroups /> )} diff --git a/app/components/Sharing/Document/DocumentMemberList.tsx b/app/components/Sharing/Document/DocumentMemberList.tsx index 4fd7470584cb..19a5e0b4d79b 100644 --- a/app/components/Sharing/Document/DocumentMemberList.tsx +++ b/app/components/Sharing/Document/DocumentMemberList.tsx @@ -25,27 +25,36 @@ type Props = { }; function DocumentMembersList({ document, invitedInSession }: Props) { - const { userMemberships } = useStores(); + const { userMemberships, groupMemberships } = useStores(); const user = useCurrentUser(); const history = useHistory(); const can = usePolicy(document); const { t } = useTranslation(); + const documentId = document.id; - const { loading: loadingDocumentMembers, request: fetchDocumentMembers } = + const { loading: loadingDocumentMembers, request: fetchUserMemberships } = useRequest( React.useCallback( () => userMemberships.fetchDocumentMemberships({ - id: document.id, + id: documentId, limit: Pagination.defaultLimit, }), - [userMemberships, document.id] + [userMemberships, documentId] ) ); + const { request: fetchGroupMemberships } = useRequest( + React.useCallback( + () => groupMemberships.fetchAll({ documentId }), + [groupMemberships, documentId] + ) + ); + React.useEffect(() => { - void fetchDocumentMembers(); - }, [fetchDocumentMembers]); + void fetchUserMemberships(); + void fetchGroupMemberships(); + }, [fetchUserMemberships, fetchGroupMemberships]); const handleRemoveUser = React.useCallback( async (item) => { @@ -68,7 +77,7 @@ function DocumentMembersList({ document, invitedInSession }: Props) { toast.error(t("Could not remove user")); } }, - [history, userMemberships, user, document] + [t, history, userMemberships, user, document] ); const handleUpdateUser = React.useCallback( @@ -88,7 +97,7 @@ function DocumentMembersList({ document, invitedInSession }: Props) { toast.error(t("Could not update user")); } }, - [userMemberships, document] + [t, userMemberships, document] ); // Order newly added users first during the current editing session, on reload members are @@ -111,6 +120,16 @@ function DocumentMembersList({ document, invitedInSession }: Props) { return ( <> + {groupMemberships + .inDocument(document.id) + .sort((a, b) => + ( + (invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name + ).localeCompare(b.group.name) + ) + .map((membership) => ( +
  • {membership.group.name}
  • + ))} {members.map((item) => ( ([]); @@ -132,9 +133,9 @@ function SharePopover({ name: t("Invite"), section: UserSection, perform: async () => { - const usersInvited = await Promise.all( + const invited = await Promise.all( pendingIds.map(async (idOrEmail) => { - let user; + let user, group; // convert email to user if (isEmail(idOrEmail)) { @@ -148,38 +149,72 @@ function SharePopover({ user = response[0]; } else { user = users.get(idOrEmail); + group = groups.get(idOrEmail); } - if (!user) { - return; + if (user) { + await userMemberships.create({ + documentId: document.id, + userId: user.id, + permission, + }); + return user; } - await userMemberships.create({ - documentId: document.id, - userId: user.id, - permission, - }); + if (group) { + await groupMemberships.create({ + documentId: document.id, + groupId: group.id, + permission, + }); + return group; + } - return user; + return; }) ); - if (usersInvited.length === 1) { - const user = usersInvited[0] as User; + const invitedUsers = invited.filter( + (item) => item instanceof User + ) as User[]; + const invitedGroups = invited.filter( + (item) => item instanceof Group + ) as Group[]; + + // Special case for the common action of adding a single user. + if (invitedUsers.length === 1 && invited.length === 1) { + const user = invitedUsers[0]; toast.message( - t("{{ userName }} was invited to the document", { + t("{{ userName }} was added to the document", { userName: user.name, }), { icon: , } ); - } else { + } else if (invitedGroups.length === 1 && invited.length === 1) { + const group = invitedGroups[0]; toast.success( - t("{{ count }} people invited to the document", { - count: pendingIds.length, + t("{{ userName }} was added to the document", { + userName: group.name, }) ); + } else if (invitedGroups.length === 0) { + toast.success( + t("{{ count }} people added to the document", { + count: invitedUsers.length, + }) + ); + } else { + toast.success( + t( + "{{ count }} people and {{ count2 }} groups added to the document", + { + count: invitedUsers.length, + count2: invitedGroups.length, + } + ) + ); } setInvitedInSession((prev) => [...prev, ...pendingIds]); @@ -188,14 +223,16 @@ function SharePopover({ }, }), [ - t, - pendingIds, + document.id, + groupMemberships, + groups, hidePicker, userMemberships, - document.id, + pendingIds, permission, - users, + t, team.defaultUserRole, + users, ] ); diff --git a/app/components/Sharing/components/Suggestions.tsx b/app/components/Sharing/components/Suggestions.tsx index 0731c0ba2d69..a2e4759ed259 100644 --- a/app/components/Sharing/components/Suggestions.tsx +++ b/app/components/Sharing/components/Suggestions.tsx @@ -42,8 +42,6 @@ type Props = { addPendingId: (id: string) => void; /** Callback to remove a user from the pending list. */ removePendingId: (id: string) => void; - /** Show group suggestions. */ - showGroups?: boolean; /** Handles escape from suggestions list */ onEscape?: (ev: React.KeyboardEvent) => void; }; @@ -57,7 +55,6 @@ export const Suggestions = observer( pendingIds, addPendingId, removePendingId, - showGroups, onEscape, }: Props, ref: React.Ref @@ -76,10 +73,7 @@ export const Suggestions = observer( const fetchUsersByQuery = useThrottledCallback( (query: string) => { void users.fetchPage({ query }); - - if (showGroups) { - void groups.fetchPage({ query }); - } + void groups.fetchPage({ query }); }, 250, undefined, @@ -113,11 +107,14 @@ export const Suggestions = observer( filtered.push(getSuggestionForEmail(query)); } - if (collection?.id) { - return [...groups.notInCollection(collection.id, query), ...filtered]; - } - - return filtered; + return [ + ...(document + ? groups.notInDocument(document.id, query) + : collection + ? groups.notInCollection(collection.id, query) + : []), + ...filtered, + ]; }, [ getSuggestionForEmail, users, diff --git a/app/models/Collection.ts b/app/models/Collection.ts index f9ee80b1c176..1ae23ca09597 100644 --- a/app/models/Collection.ts +++ b/app/models/Collection.ts @@ -12,6 +12,7 @@ import type CollectionsStore from "~/stores/CollectionsStore"; import Document from "~/models/Document"; import ParanoidModel from "~/models/base/ParanoidModel"; import { client } from "~/utils/ApiClient"; +import User from "./User"; import Field from "./decorators/Field"; export default class Collection extends ParanoidModel { @@ -175,6 +176,19 @@ export default class Collection extends ParanoidModel { return this.url; } + /** + * Returns users that have been individually given access to the collection. + * + * @returns A list of users that have been given access to the collection. + */ + @computed + get members(): User[] { + return this.store.rootStore.memberships.orderedData + .filter((m) => m.collectionId === this.id) + .map((m) => m.user) + .filter(Boolean); + } + fetchDocuments = async (options?: { force: boolean }) => { if (this.isFetching) { return; diff --git a/app/stores/GroupMembershipsStore.ts b/app/stores/GroupMembershipsStore.ts index dbbd96178195..7971cb8da191 100644 --- a/app/stores/GroupMembershipsStore.ts +++ b/app/stores/GroupMembershipsStore.ts @@ -15,13 +15,25 @@ export default class GroupMembershipsStore extends Store { } @action - fetchPage = async ( - params: PaginationParams | undefined - ): Promise => { + fetchPage = async ({ + collectionId, + documentId, + ...params + }: + | (PaginationParams & { documentId?: string; collectionId?: string }) + | undefined): Promise => { this.isFetching = true; try { - const res = await client.post(`/collections.group_memberships`, params); + const res = collectionId + ? await client.post(`/collections.group_memberships`, { + id: collectionId, + ...params, + }) + : await client.post(`/documents.group_memberships`, { + id: documentId, + ...params, + }); invariant(res?.data, "Data not available"); let response: GroupMembership[] = []; diff --git a/app/stores/GroupsStore.ts b/app/stores/GroupsStore.ts index cf4fcc36cf5a..60f8cfc8caa9 100644 --- a/app/stores/GroupsStore.ts +++ b/app/stores/GroupsStore.ts @@ -61,6 +61,27 @@ export default class GroupsStore extends Store { return query ? queriedGroups(groups, query) : groups; }; + /** + * Returns groups that are not in the given document, optionally filtered by a query. + * + * @param documentId + * @param query + * @returns A list of groups that are not in the given document. + */ + notInDocument = (documentId: string, query = "") => { + const memberships = filter( + this.rootStore.groupMemberships.orderedData, + (member) => member.documentId === documentId + ); + const groupIds = memberships.map((member) => member.groupId); + const groups = filter( + this.orderedData, + (group) => !groupIds.includes(group.id) + ); + + return query ? queriedGroups(groups, query) : groups; + }; + /** * Returns groups that are not in the given collection, optionally filtered by a query. * diff --git a/app/stores/UsersStore.ts b/app/stores/UsersStore.ts index 540900947955..6dadcc1dcb08 100644 --- a/app/stores/UsersStore.ts +++ b/app/stores/UsersStore.ts @@ -138,6 +138,13 @@ export default class UsersStore extends Store { } }; + /** + * Returns users that are not in the given document, optionally filtered by a query. + * + * @param documentId + * @param query + * @returns A list of users that are not in the given document. + */ notInDocument = (documentId: string, query = "") => { const document = this.rootStore.documents.get(documentId); const teamMembers = this.activeOrInvited; @@ -150,6 +157,13 @@ export default class UsersStore extends Store { return queriedUsers(users, query); }; + /** + * Returns users that are not in the given collection, optionally filtered by a query. + * + * @param collectionId + * @param query + * @returns A list of users that are not in the given collection. + */ notInCollection = (collectionId: string, query = "") => { const groupUsers = filter( this.rootStore.memberships.orderedData, diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 51c57629fd49..6e0237e9619d 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -72,7 +72,7 @@ import NotContainsUrl from "./validators/NotContainsUrl"; separate: true, // include for groups that are members of this collection, // of which userId is a member of, resulting in: - // CollectionGroup [inner join] Group [inner join] GroupUser [where] userId + // GroupMembership [inner join] Group [inner join] GroupUser [where] userId include: [ { model: Group, diff --git a/server/models/Document.ts b/server/models/Document.ts index bd677cb31780..577cde76c6d9 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -198,9 +198,9 @@ type AdditionalFindOptions = { // nested "includes" with alternating values for "required" // see https://github.com/sequelize/sequelize/issues/9869 separate: true, - // include for groups that are members of this collection, + // include for groups that are members of this document, // of which userId is a member of, resulting in: - // CollectionGroup [inner join] Group [inner join] GroupUser [where] userId + // GroupMembership [inner join] Group [inner join] GroupUser [where] userId include: [ { model: Group, diff --git a/server/policies/collection.ts b/server/policies/collection.ts index cc6854bb8cad..dbf210e2d21d 100644 --- a/server/policies/collection.ts +++ b/server/policies/collection.ts @@ -160,6 +160,10 @@ function includesMembership( collection.memberships, "Development: collection memberships not preloaded, did you forget `withMembership` scope?" ); + invariant( + collection.groupMemberships, + "Development: collection groupMemberships not preloaded, did you forget `withMembership` scope?" + ); return some( [...collection.memberships, ...collection.groupMemberships], (m) => permissions.includes(m.permission as CollectionPermission) diff --git a/server/policies/document.ts b/server/policies/document.ts index 99e34646fac4..e48e9d212fde 100644 --- a/server/policies/document.ts +++ b/server/policies/document.ts @@ -254,6 +254,10 @@ function includesMembership( document.memberships, "Development: document memberships should be preloaded, did you forget withMembership scope?" ); + invariant( + document.groupMemberships, + "Development: document groupMemberships should be preloaded, did you forget withMembership scope?" + ); return some([...document.memberships, ...document.groupMemberships], (m) => permissions.includes(m.permission as DocumentPermission) ); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index d317c2f3b9e9..a1fe8e71a738 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -323,9 +323,11 @@ "Allow anyone with the link to access": "Allow anyone with the link to access", "Publish to internet": "Publish to internet", "Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future", - "{{ userName }} was invited to the document": "{{ userName }} was invited to the document", - "{{ count }} people invited to the document": "{{ count }} people invited to the document", - "{{ count }} people invited to the document_plural": "{{ count }} people invited to the document", + "{{ userName }} was added to the document": "{{ userName }} was added to the document", + "{{ count }} people added to the document": "{{ count }} people added to the document", + "{{ count }} people added to the document_plural": "{{ count }} people added to the document", + "{{ count }} people and {{ count2 }} groups added to the document": "{{ count }} people and {{ count2 }} groups added to the document", + "{{ count }} people and {{ count2 }} groups added to the document_plural": "{{ count }} people and {{ count2 }} groups added to the document", "Logo": "Logo", "Move document": "Move document", "New doc": "New doc", From c3cb06ef229d15d751828b2b769b8e1b1ee00f1d Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 20 Jul 2024 07:42:38 -0400 Subject: [PATCH 06/51] tsc --- app/stores/GroupMembershipsStore.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/stores/GroupMembershipsStore.ts b/app/stores/GroupMembershipsStore.ts index 7971cb8da191..3306a1027002 100644 --- a/app/stores/GroupMembershipsStore.ts +++ b/app/stores/GroupMembershipsStore.ts @@ -20,8 +20,10 @@ export default class GroupMembershipsStore extends Store { documentId, ...params }: - | (PaginationParams & { documentId?: string; collectionId?: string }) - | undefined): Promise => { + | PaginationParams & { + documentId?: string; + collectionId?: string; + }): Promise => { this.isFetching = true; try { From 2dde99844c679eb05c97c6c804933872805654a5 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 20 Jul 2024 10:33:43 -0400 Subject: [PATCH 07/51] documents.add_group, documents.remove_group websocket handling --- server/models/Group.ts | 29 ++++++-- server/presenters/group.ts | 2 +- .../queues/processors/WebsocketsProcessor.ts | 73 +++++++++++++++++-- server/routes/api/groups/groups.ts | 5 +- 4 files changed, 91 insertions(+), 18 deletions(-) diff --git a/server/models/Group.ts b/server/models/Group.ts index e47fcf9e411f..b80145856ed4 100644 --- a/server/models/Group.ts +++ b/server/models/Group.ts @@ -1,4 +1,9 @@ -import { InferAttributes, InferCreationAttributes, Op } from "sequelize"; +import { + InferAttributes, + InferCreationAttributes, + Op, + ScopeOptions, +} from "sequelize"; import { AfterDestroy, BelongsTo, @@ -29,12 +34,8 @@ import NotContainsUrl from "./validators/NotContainsUrl"; ], })) @Scopes(() => ({ - withMember: (userId: string) => ({ + withMembership: (userId: string) => ({ include: [ - { - association: "groupUsers", - required: true, - }, { association: "members", required: true, @@ -44,6 +45,14 @@ import NotContainsUrl from "./validators/NotContainsUrl"; }, ], }), + withAllMemberships: () => ({ + include: [ + { + association: "groupUsers", + required: true, + }, + ], + }), })) @Table({ tableName: "groups", @@ -97,9 +106,13 @@ class Group extends ParanoidModel< }); } - static filterByMember(userId: string | undefined) { + static filterByMember( + userId: string | undefined, + additionalScope?: ScopeOptions | string + ) { + const scope = { method: ["withMembership", userId] } as ScopeOptions; return userId - ? this.scope({ method: ["withMember", userId] }) + ? this.scope(additionalScope ? [scope, additionalScope] : scope) : this.scope("defaultScope"); } diff --git a/server/presenters/group.ts b/server/presenters/group.ts index 23ee631e7ea6..9def0eae5872 100644 --- a/server/presenters/group.ts +++ b/server/presenters/group.ts @@ -4,7 +4,7 @@ export default function presentGroup(group: Group) { return { id: group.id, name: group.name, - memberCount: group.groupUsers.length, + memberCount: group.groupUsers?.length, createdAt: group.createdAt, updatedAt: group.updatedAt, }; diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index cc9611ff8c77..f5adba6dca75 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -1,4 +1,5 @@ import { subHours } from "date-fns"; +import uniq from "lodash/uniq"; import { Op } from "sequelize"; import { Server } from "socket.io"; import { @@ -169,6 +170,46 @@ export default class WebsocketsProcessor { return; } + case "documents.add_group": { + const [document, membership] = await Promise.all([ + Document.findByPk(event.documentId), + UserMembership.findByPk(event.modelId), + ]); + if (!document || !membership) { + return; + } + + const channels = await this.getDocumentEventChannels(event, document); + socketio.to(channels).emit(event.name, presentMembership(membership)); + return; + } + + case "documents.remove_group": { + const [document, group] = await Promise.all([ + Document.findByPk(event.documentId), + Group.findByPk(event.modelId), + ]); + if (!document || !group) { + return; + } + + const channels = await this.getDocumentEventChannels(event, document); + const groupUserIds = (await group.$get("groupUsers")).map( + (groupUser) => groupUser.userId + ); + const groupUserChannels = groupUserIds.map( + (userId) => `user-${userId}` + ); + socketio + .to(uniq([...channels, ...groupUserChannels])) + .emit(event.name, { + id: event.modelId, + groupId: event.modelId, + documentId: event.documentId, + }); + return; + } + case "collections.create": { const collection = await Collection.findByPk(event.collectionId, { paranoid: false, @@ -712,16 +753,32 @@ export default class WebsocketsProcessor { channels.push(`collection-${document.collectionId}`); } - const memberships = await UserMembership.findAll({ - where: { - documentId: document.id, - }, - }); - - for (const membership of memberships) { + const [userMemberships, groupMemberships] = await Promise.all([ + UserMembership.findAll({ + where: { + documentId: document.id, + }, + }), + GroupMembership.scope("withGroup").findAll({ + where: { + documentId: document.id, + }, + }), + ]); + + for (const membership of userMemberships) { channels.push(`user-${membership.userId}`); } - return channels; + await Promise.all( + groupMemberships.map(async (groupMembership) => { + const groupUsers = await groupMembership.group.$get("groupUsers"); + for (const groupUser of groupUsers) { + channels.push(`user-${groupUser.userId}`); + } + }) + ); + + return uniq(channels); } } diff --git a/server/routes/api/groups/groups.ts b/server/routes/api/groups/groups.ts index 354f18148220..f680cdace837 100644 --- a/server/routes/api/groups/groups.ts +++ b/server/routes/api/groups/groups.ts @@ -42,7 +42,10 @@ router.post( }; } - const groups = await Group.filterByMember(userId).findAll({ + const groups = await Group.filterByMember( + userId, + "withAllMemberships" + ).findAll({ where, order: [[sort, direction]], offset: ctx.state.pagination.offset, From ff8a4dbd2cdd24134ddec33c2b3be43f3ad16de3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 20 Jul 2024 10:48:42 -0400 Subject: [PATCH 08/51] wip: Clientside event handling --- app/components/WebsocketProvider.tsx | 75 ++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 21 deletions(-) diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index 8bd90e8d3358..e388b9fb58c0 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -13,6 +13,7 @@ import Comment from "~/models/Comment"; import Document from "~/models/Document"; import FileOperation from "~/models/FileOperation"; import Group from "~/models/Group"; +import GroupMembership from "~/models/GroupMembership"; import Notification from "~/models/Notification"; import Pin from "~/models/Pin"; import Star from "~/models/Star"; @@ -85,6 +86,7 @@ class WebsocketProvider extends React.Component { documents, collections, groups, + groupMemberships, pins, stars, memberships, @@ -252,7 +254,6 @@ class WebsocketProvider extends React.Component { } ); - // received when a user is given access to a document this.socket.on( "documents.add_user", (event: PartialWithId) => { @@ -262,34 +263,66 @@ class WebsocketProvider extends React.Component { this.socket.on( "documents.remove_user", - (event: PartialWithId) => { - if (event.userId) { - const userMembership = userMemberships.get(event.id); - - // TODO: Possibly replace this with a one-to-many relation decorator. - if (userMembership) { - userMemberships - .filter({ - userId: event.userId, - sourceId: userMembership.id, - }) - .forEach((m) => { - m.documentId && documents.remove(m.documentId); - }); - } - - userMemberships.removeAll({ - userId: event.userId, - documentId: event.documentId, - }); + (event: Pick) => { + const userMembership = userMemberships.get(event.id); + + // TODO: Possibly replace this with a one-to-many relation decorator. + if (userMembership) { + userMemberships + .filter({ + userId: event.userId, + sourceId: userMembership.id, + }) + .forEach((m) => { + m.documentId && documents.remove(m.documentId); + }); } + userMemberships.removeAll({ + userId: event.userId, + documentId: event.documentId, + }); + if (event.documentId && event.userId === auth.user?.id) { + // TODO: This removes the document even if the user retains access through other means. documents.remove(event.documentId); } } ); + this.socket.on( + "documents.add_group", + (event: PartialWithId) => { + groupMemberships.add(event); + } + ); + + this.socket.on( + "documents.remove_group", + (event: Pick) => { + const groupMembership = groupMemberships.get(event.id); + + // TODO: Possibly replace this with a one-to-many relation decorator. + if (groupMembership) { + userMemberships + .filter({ + groupId: event.groupId, + sourceId: groupMembership.id, + }) + .forEach((m) => { + m.documentId && documents.remove(m.documentId); + }); + } + + userMemberships.removeAll({ + groupId: event.groupId, + documentId: event.documentId, + }); + + // TODO: Clean up document if user no longer has access. + } + ); + this.socket.on("comments.create", (event: PartialWithId) => { comments.add(event); }); From 5266476e6a26a8fc21fd4e498dc78daa0863362a Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 20 Jul 2024 15:14:33 -0400 Subject: [PATCH 09/51] Copy memberships from parent document on creation --- server/models/Document.ts | 42 +++++++++++++------------------- server/models/GroupMembership.ts | 40 +++++++++++++++++++++++++++++- server/models/UserMembership.ts | 38 +++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 26 deletions(-) diff --git a/server/models/Document.ts b/server/models/Document.ts index 577cde76c6d9..3fa60799e855 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -905,31 +905,23 @@ class Document extends ParanoidModel< } } - const parentDocumentPermissions = this.parentDocumentId - ? await UserMembership.findAll({ - where: { - documentId: this.parentDocumentId, - }, - transaction, - }) - : []; - - await Promise.all( - parentDocumentPermissions.map((permission) => - UserMembership.create( - { - documentId: this.id, - userId: permission.userId, - sourceId: permission.sourceId ?? permission.id, - permission: permission.permission, - createdById: permission.createdById, - }, - { - transaction, - } - ) - ) - ); + // Copy the group and user memberships from the parent document, if any + if (this.parentDocumentId) { + await GroupMembership.copy( + { + documentId: this.parentDocumentId, + }, + this, + { transaction } + ); + await UserMembership.copy( + { + documentId: this.parentDocumentId, + }, + this, + { transaction } + ); + } this.lastModifiedById = user.id; this.updatedBy = user; diff --git a/server/models/GroupMembership.ts b/server/models/GroupMembership.ts index 2f01c8de705d..c95316b5f8a1 100644 --- a/server/models/GroupMembership.ts +++ b/server/models/GroupMembership.ts @@ -5,6 +5,7 @@ import { type SaveOptions, type FindOptions, } from "sequelize"; +import { WhereOptions } from "sequelize"; import { BelongsTo, Column, @@ -120,6 +121,41 @@ class GroupMembership extends ParanoidModel< @Column(DataType.UUID) createdById: string; + // static methods + + /** + * Copy group memberships from one document to another. + * + * @param where The where clause to find the group memberships to copy. + * @param document The document to copy the group memberships to. + * @param options Additional options to pass to the query. + */ + public static async copy( + where: WhereOptions, + document: Document, + options: SaveOptions + ) { + const { transaction } = options; + const groupMemberships = await this.findAll({ + where, + transaction, + }); + await Promise.all( + groupMemberships.map((membership) => + this.create( + { + documentId: document.id, + groupId: membership.groupId, + sourceId: membership.sourceId ?? membership.id, + permission: membership.permission, + createdById: membership.createdById, + }, + { transaction } + ) + ) + ); + } + /** * Find the root membership for a document and (optionally) group. * @@ -128,7 +164,7 @@ class GroupMembership extends ParanoidModel< * @param options Additional options to pass to the query. * @returns A promise that resolves to the root memberships for the document and group, or null. */ - static async findRootMembershipsForDocument( + public static async findRootMembershipsForDocument( documentId: string, groupId?: string, options?: FindOptions @@ -151,6 +187,8 @@ class GroupMembership extends ParanoidModel< return rootMemberships.filter(Boolean) as GroupMembership[]; } + // hooks + @AfterUpdate static async updateSourcedMemberships( model: GroupMembership, diff --git a/server/models/UserMembership.ts b/server/models/UserMembership.ts index c0551a38c3d7..c67419d95974 100644 --- a/server/models/UserMembership.ts +++ b/server/models/UserMembership.ts @@ -5,6 +5,7 @@ import { type SaveOptions, type FindOptions, } from "sequelize"; +import { WhereOptions } from "sequelize"; import { Column, ForeignKey, @@ -125,6 +126,41 @@ class UserMembership extends IdModel< @Column(DataType.UUID) createdById: string; + // static methods + + /** + * Copy user memberships from one document to another. + * + * @param where The where clause to find the user memberships to copy. + * @param document The document to copy the user memberships to. + * @param options Additional options to pass to the query. + */ + public static async copy( + where: WhereOptions, + document: Document, + options: SaveOptions + ) { + const { transaction } = options; + const groupMemberships = await this.findAll({ + where, + transaction, + }); + await Promise.all( + groupMemberships.map((membership) => + this.create( + { + documentId: document.id, + userId: membership.userId, + sourceId: membership.sourceId ?? membership.id, + permission: membership.permission, + createdById: membership.createdById, + }, + { transaction } + ) + ) + ); + } + /** * Find the root membership for a document and (optionally) user. * @@ -156,6 +192,8 @@ class UserMembership extends IdModel< return rootMemberships.filter(Boolean) as UserMembership[]; } + // hooks + @AfterUpdate static async updateSourcedMemberships( model: UserMembership, From bffc079b8f8bb3bcd2ce782f3e12be452626bd76 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 20 Jul 2024 17:01:05 -0400 Subject: [PATCH 10/51] Handle groups.add_user, groups.remove_user --- app/components/WebsocketProvider.tsx | 13 ++++ .../queues/processors/WebsocketsProcessor.ts | 59 +++++++++++++------ 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index e388b9fb58c0..07c9e5a2c901 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -14,6 +14,7 @@ import Document from "~/models/Document"; import FileOperation from "~/models/FileOperation"; import Group from "~/models/Group"; import GroupMembership from "~/models/GroupMembership"; +import GroupUser from "~/models/GroupUser"; import Notification from "~/models/Notification"; import Pin from "~/models/Pin"; import Star from "~/models/Star"; @@ -87,6 +88,7 @@ class WebsocketProvider extends React.Component { collections, groups, groupMemberships, + groupUsers, pins, stars, memberships, @@ -347,6 +349,17 @@ class WebsocketProvider extends React.Component { groups.remove(event.modelId); }); + this.socket.on("groups.add_user", (event: PartialWithId) => { + groupUsers.add(event); + }); + + this.socket.on("groups.remove_user", (event: PartialWithId) => { + groupUsers.removeAll({ + groupId: event.groupId, + userId: event.userId, + }); + }); + this.socket.on("collections.create", (event: PartialWithId) => { collections.add(event); }); diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index f5adba6dca75..89c3ba5c0195 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -30,6 +30,7 @@ import { presentTeam, presentMembership, presentUser, + presentGroupUser, } from "@server/presenters"; import presentNotification from "@server/presenters/notification"; import { Event } from "../../types"; @@ -527,18 +528,31 @@ export default class WebsocketsProcessor { } case "groups.add_user": { - // do an add user for every collection that the group is a part of - const groupMemberships = await GroupMembership.scope( - "withCollection" - ).findAll({ - where: { - groupId: event.modelId, - }, - }); + const [groupUser, groupMemberships] = await Promise.all([ + GroupUser.scope("withGroup").findOne({ + where: { + groupId: event.modelId, + userId: event.userId, + }, + }), + GroupMembership.findAll({ + where: { + groupId: event.modelId, + }, + }), + ]); + + if (groupUser) { + socketio + .to(`team-${groupUser.group.teamId}`) + .emit(event.name, presentGroupUser(groupUser)); + } for (const groupMembership of groupMemberships) { - // the user being added isn't yet in the websocket channel for the collection - // so they need to be notified separately + if (!groupMembership.collectionId) { + continue; + } + // the user being added needs to know they were added socketio.to(`user-${event.userId}`).emit("collections.add_user", { event: event.name, userId: event.userId, @@ -552,8 +566,8 @@ export default class WebsocketsProcessor { userId: event.userId, collectionId: groupMembership.collectionId, }); - // tell any user clients to connect to the websocket channel for the collection - return socketio.to(`user-${event.userId}`).emit("join", { + // tell clients from the user to connect to the websocket channel for the collection + socketio.to(`user-${event.userId}`).emit("join", { event: event.name, collectionId: groupMembership.collectionId, }); @@ -563,13 +577,22 @@ export default class WebsocketsProcessor { } case "groups.remove_user": { - const groupMemberships = await GroupMembership.scope( - "withCollection" - ).findAll({ - where: { + const [group, groupMemberships] = await Promise.all([ + Group.findByPk(event.modelId), + GroupMembership.findAll({ + where: { + groupId: event.modelId, + }, + }), + ]); + + if (group) { + socketio.to(`team-${group.teamId}`).emit(event.name, { + event: event.name, + userId: event.userId, groupId: event.modelId, - }, - }); + }); + } for (const groupMembership of groupMemberships) { // if the user has any memberships remaining on the collection From c359629b1e3ba8df2eb99e29d20d81708c375c65 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 20 Jul 2024 17:27:58 -0400 Subject: [PATCH 11/51] GroupUserMembershipsStore -> GroupUsersStore --- ...upUserMembershipsStore.ts => GroupUsersStore.ts} | 0 app/stores/RootStore.ts | 6 +++--- server/queues/processors/WebsocketsProcessor.ts | 13 +++++++------ 3 files changed, 10 insertions(+), 9 deletions(-) rename app/stores/{GroupUserMembershipsStore.ts => GroupUsersStore.ts} (100%) diff --git a/app/stores/GroupUserMembershipsStore.ts b/app/stores/GroupUsersStore.ts similarity index 100% rename from app/stores/GroupUserMembershipsStore.ts rename to app/stores/GroupUsersStore.ts diff --git a/app/stores/RootStore.ts b/app/stores/RootStore.ts index 3ce3e218bd49..297abde26a48 100644 --- a/app/stores/RootStore.ts +++ b/app/stores/RootStore.ts @@ -12,7 +12,7 @@ import DocumentsStore from "./DocumentsStore"; import EventsStore from "./EventsStore"; import FileOperationsStore from "./FileOperationsStore"; import GroupMembershipsStore from "./GroupMembershipsStore"; -import GroupUserMembershipsStore from "./GroupUserMembershipsStore"; +import GroupUsersStore from "./GroupUsersStore"; import GroupsStore from "./GroupsStore"; import IntegrationsStore from "./IntegrationsStore"; import MembershipsStore from "./MembershipsStore"; @@ -42,7 +42,7 @@ export default class RootStore { documents: DocumentsStore; events: EventsStore; groups: GroupsStore; - groupUsers: GroupUserMembershipsStore; + groupUsers: GroupUsersStore; integrations: IntegrationsStore; memberships: MembershipsStore; notifications: NotificationsStore; @@ -71,7 +71,7 @@ export default class RootStore { this.registerStore(DocumentsStore); this.registerStore(EventsStore); this.registerStore(GroupsStore); - this.registerStore(GroupUserMembershipsStore); + this.registerStore(GroupUsersStore); this.registerStore(IntegrationsStore); this.registerStore(MembershipsStore); this.registerStore(NotificationsStore); diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index 89c3ba5c0195..b0fb51b4d52c 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -657,9 +657,7 @@ export default class WebsocketsProcessor { }, }, }); - const groupMemberships = await GroupMembership.scope( - "withCollection" - ).findAll({ + const groupMemberships = await GroupMembership.findAll({ paranoid: false, where: { groupId: event.modelId, @@ -670,9 +668,12 @@ export default class WebsocketsProcessor { }); for (const groupMembership of groupMemberships) { - const membershipUserIds = groupMembership.collectionId - ? await Collection.membershipUserIds(groupMembership.collectionId) - : []; + if (!groupMembership.collectionId) { + continue; + } + const membershipUserIds = await Collection.membershipUserIds( + groupMembership.collectionId + ); for (const groupUser of groupUsers) { if (membershipUserIds.includes(groupUser.userId)) { From 06927338b108b21edb43848bc0a58bce6ea0c9ef Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 21 Jul 2024 07:25:22 -0400 Subject: [PATCH 12/51] Group -> document sharing UI --- .../Sharing/Document/DocumentMemberList.tsx | 93 +++++++++++++++++-- .../Sharing/components/Suggestions.tsx | 2 +- 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/app/components/Sharing/Document/DocumentMemberList.tsx b/app/components/Sharing/Document/DocumentMemberList.tsx index 19a5e0b4d79b..e82d544f5073 100644 --- a/app/components/Sharing/Document/DocumentMemberList.tsx +++ b/app/components/Sharing/Document/DocumentMemberList.tsx @@ -1,18 +1,26 @@ import orderBy from "lodash/orderBy"; import { observer } from "mobx-react"; +import { GroupIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; import { toast } from "sonner"; +import { useTheme } from "styled-components"; +import Squircle from "@shared/components/Squircle"; import { Pagination } from "@shared/constants"; +import { DocumentPermission } from "@shared/types"; import Document from "~/models/Document"; import UserMembership from "~/models/UserMembership"; +import { AvatarSize } from "~/components/Avatar/Avatar"; +import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect"; import LoadingIndicator from "~/components/LoadingIndicator"; import useCurrentUser from "~/hooks/useCurrentUser"; import usePolicy from "~/hooks/usePolicy"; import useRequest from "~/hooks/useRequest"; import useStores from "~/hooks/useStores"; +import { EmptySelectValue, Permission } from "~/types"; import { homePath } from "~/utils/routeHelpers"; +import { ListItem } from "../components/ListItem"; import MemberListItem from "./DocumentMemberListItem"; type Props = { @@ -30,9 +38,10 @@ function DocumentMembersList({ document, invitedInSession }: Props) { const history = useHistory(); const can = usePolicy(document); const { t } = useTranslation(); + const theme = useTheme(); const documentId = document.id; - const { loading: loadingDocumentMembers, request: fetchUserMemberships } = + const { loading: loadingUserMemberships, request: fetchUserMemberships } = useRequest( React.useCallback( () => @@ -44,12 +53,13 @@ function DocumentMembersList({ document, invitedInSession }: Props) { ) ); - const { request: fetchGroupMemberships } = useRequest( - React.useCallback( - () => groupMemberships.fetchAll({ documentId }), - [groupMemberships, documentId] - ) - ); + const { loading: loadingGroupMemberships, request: fetchGroupMemberships } = + useRequest( + React.useCallback( + () => groupMemberships.fetchAll({ documentId }), + [groupMemberships, documentId] + ) + ); React.useEffect(() => { void fetchUserMemberships(); @@ -114,7 +124,31 @@ function DocumentMembersList({ document, invitedInSession }: Props) { [document.members, invitedInSession] ); - if (loadingDocumentMembers) { + const permissions = React.useMemo( + () => + [ + { + label: t("View only"), + value: DocumentPermission.Read, + }, + { + label: t("Can edit"), + value: DocumentPermission.ReadWrite, + }, + { + label: t("Manage"), + value: DocumentPermission.Admin, + }, + { + divider: true, + label: t("Remove"), + value: EmptySelectValue, + }, + ] as Permission[], + [t] + ); + + if (loadingUserMemberships || loadingGroupMemberships) { return ; } @@ -128,7 +162,48 @@ function DocumentMembersList({ document, invitedInSession }: Props) { ).localeCompare(b.group.name) ) .map((membership) => ( -
  • {membership.group.name}
  • + + + + } + title={membership.group.name} + subtitle={t("{{ count }} member", { + count: membership.group.memberCount, + })} + actions={ + can.manageUsers ? ( +
    + { + if (permission === EmptySelectValue) { + await groupMemberships.delete({ + documentId: document.id, + groupId: membership.groupId, + }); + } else { + await groupMemberships.create({ + documentId: document.id, + groupId: membership.groupId, + permission, + }); + } + }} + disabled={!can.update} + value={membership.permission} + labelHidden + nude + /> +
    + ) : null + } + /> ))} {members.map((item) => ( { From 3e5d26a6909bbba71f9e7bd47bc1a9897db2843a Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 21 Jul 2024 08:11:21 -0400 Subject: [PATCH 13/51] cp error --- app/components/WebsocketProvider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index 07c9e5a2c901..5890a2cb429f 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -306,7 +306,7 @@ class WebsocketProvider extends React.Component { // TODO: Possibly replace this with a one-to-many relation decorator. if (groupMembership) { - userMemberships + groupMemberships .filter({ groupId: event.groupId, sourceId: groupMembership.id, @@ -316,7 +316,7 @@ class WebsocketProvider extends React.Component { }); } - userMemberships.removeAll({ + groupMemberships.removeAll({ groupId: event.groupId, documentId: event.documentId, }); From c2ecc3226a0c9cb137d20761e24b33a065c64b72 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 9 Aug 2024 19:28:59 +0100 Subject: [PATCH 14/51] fix: Incorrect query --- app/components/Sharing/Collection/AccessControlList.tsx | 2 +- shared/i18n/locales/en_US/translation.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/Sharing/Collection/AccessControlList.tsx b/app/components/Sharing/Collection/AccessControlList.tsx index c3411c978fd1..64409d506614 100644 --- a/app/components/Sharing/Collection/AccessControlList.tsx +++ b/app/components/Sharing/Collection/AccessControlList.tsx @@ -42,7 +42,7 @@ export function AccessControlList({ collection, invitedInSession }: Props) { const { request: fetchGroupMemberships, data: groupMembershipData } = useRequest( React.useCallback( - () => groupMemberships.fetchAll({ id: collectionId }), + () => groupMemberships.fetchAll({ collectionId }), [groupMemberships, collectionId] ) ); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index f0e31001e192..9acc159e428d 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -338,7 +338,7 @@ "Go back": "Go back", "Go forward": "Go forward", "Could not load shared documents": "Could not load shared documents", - "Shared with me": "Shared with me", + "Shared": "Shared", "Show more": "Show more", "Could not load starred documents": "Could not load starred documents", "Starred": "Starred", From 61f667ebea86908329485473ff5d1761f2ec192d Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 18 Aug 2024 10:41:51 -0400 Subject: [PATCH 15/51] stash --- .../Sidebar/components/SharedWithMe.tsx | 19 +++- .../Sidebar/components/SharedWithMeLink.tsx | 3 +- app/stores/DocumentsStore.ts | 30 ------ app/stores/GroupMembershipsStore.ts | 9 +- .../groupMemberships/groupMemberships.test.ts | 70 ++++++++++++++ .../api/groupMemberships/groupMemberships.ts | 92 +++++++++++++++++++ server/routes/api/groupMemberships/index.ts | 1 + server/routes/api/groupMemberships/schema.ts | 8 ++ server/routes/api/index.ts | 2 + 9 files changed, 196 insertions(+), 38 deletions(-) create mode 100644 server/routes/api/groupMemberships/groupMemberships.test.ts create mode 100644 server/routes/api/groupMemberships/groupMemberships.ts create mode 100644 server/routes/api/groupMemberships/index.ts create mode 100644 server/routes/api/groupMemberships/schema.ts diff --git a/app/components/Sidebar/components/SharedWithMe.tsx b/app/components/Sidebar/components/SharedWithMe.tsx index 94b67c61360b..80a5a2f6dfe4 100644 --- a/app/components/Sidebar/components/SharedWithMe.tsx +++ b/app/components/Sidebar/components/SharedWithMe.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Pagination } from "@shared/constants"; +import GroupMembership from "~/models/GroupMembership"; import UserMembership from "~/models/UserMembership"; import DelayedMount from "~/components/DelayedMount"; import Flex from "~/components/Flex"; @@ -20,7 +21,7 @@ import SidebarLink from "./SidebarLink"; import { useDropToReorderUserMembership } from "./useDragAndDrop"; function SharedWithMe() { - const { userMemberships } = useStores(); + const { userMemberships, groupMemberships } = useStores(); const { t } = useTranslation(); const user = useCurrentUser(); @@ -29,6 +30,10 @@ function SharedWithMe() { limit: Pagination.sidebarLimit, }); + usePaginatedRequest(groupMemberships.fetchPage, { + limit: Pagination.sidebarLimit, + }); + // Drop to reorder document const [reorderMonitor, dropToReorderRef] = useDropToReorderUserMembership( () => fractionalIndex(null, user.memberships[0].index) @@ -40,9 +45,9 @@ function SharedWithMe() { } }, [error, t]); - if (!user.memberships.length) { - return null; - } + // if (!user.memberships.length) { + // return null; + // } return ( @@ -56,6 +61,12 @@ function SharedWithMe() { position="top" /> )} + {groupMemberships.orderedData.map((membership) => ( + + ))} {user.memberships .slice(0, page * Pagination.sidebarLimit) .map((membership) => ( diff --git a/app/components/Sidebar/components/SharedWithMeLink.tsx b/app/components/Sidebar/components/SharedWithMeLink.tsx index 4b370c45644b..a65e1d9c9c70 100644 --- a/app/components/Sidebar/components/SharedWithMeLink.tsx +++ b/app/components/Sidebar/components/SharedWithMeLink.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import styled from "styled-components"; import { IconType, NotificationEventType } from "@shared/types"; import { determineIconType } from "@shared/utils/icon"; +import GroupMembership from "~/models/GroupMembership"; import UserMembership from "~/models/UserMembership"; import Fade from "~/components/Fade"; import useBoolean from "~/hooks/useBoolean"; @@ -21,7 +22,7 @@ import { import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon"; type Props = { - userMembership: UserMembership; + userMembership: UserMembership | GroupMembership; }; function SharedWithMeLink({ userMembership }: Props) { diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts index a584bb7514a3..c8378e4339bb 100644 --- a/app/stores/DocumentsStore.ts +++ b/app/stores/DocumentsStore.ts @@ -7,7 +7,6 @@ import orderBy from "lodash/orderBy"; import { observable, action, computed, runInAction } from "mobx"; import type { DateFilter, - JSONObject, NavigationNode, PublicTeam, StatusFilter, @@ -23,7 +22,6 @@ import type { FetchOptions, PaginationParams, PartialWithId, - Properties, SearchResult, } from "~/types"; import { client } from "~/utils/ApiClient"; @@ -750,34 +748,6 @@ export default class DocumentsStore extends Store { } }; - @action - async update( - params: Properties, - options?: JSONObject - ): Promise { - this.isSaving = true; - - try { - const res = await client.post(`/${this.apiEndpoint}.update`, { - ...params, - ...options, - }); - - invariant(res?.data, "Data should be available"); - - const collection = this.getCollectionForDocument(res.data); - await collection?.fetchDocuments({ force: true }); - - return runInAction("Document#update", () => { - const document = this.add(res.data); - this.addPolicies(res.policies); - return document; - }); - } finally { - this.isSaving = false; - } - } - @action unpublish = async (document: Document) => { const res = await client.post("/documents.unpublish", { diff --git a/app/stores/GroupMembershipsStore.ts b/app/stores/GroupMembershipsStore.ts index 3306a1027002..c8eb3c387666 100644 --- a/app/stores/GroupMembershipsStore.ts +++ b/app/stores/GroupMembershipsStore.ts @@ -32,15 +32,18 @@ export default class GroupMembershipsStore extends Store { id: collectionId, ...params, }) - : await client.post(`/documents.group_memberships`, { + : documentId + ? await client.post(`/documents.group_memberships`, { id: documentId, ...params, - }); + }) + : await client.post(`/groupMemberships.list`, params); invariant(res?.data, "Data not available"); let response: GroupMembership[] = []; runInAction(`GroupMembershipsStore#fetchPage`, () => { - res.data.groups.forEach(this.rootStore.groups.add); + res.data.groups?.forEach(this.rootStore.groups.add); + res.data.documents?.forEach(this.rootStore.documents.add); response = res.data.groupMemberships.map(this.add); this.isLoaded = true; }); diff --git a/server/routes/api/groupMemberships/groupMemberships.test.ts b/server/routes/api/groupMemberships/groupMemberships.test.ts new file mode 100644 index 000000000000..571f3c6047a2 --- /dev/null +++ b/server/routes/api/groupMemberships/groupMemberships.test.ts @@ -0,0 +1,70 @@ +import { GroupUser } from "@server/models"; +import { + buildCollection, + buildDocument, + buildGroup, + buildUser, +} from "@server/test/factories"; +import { getTestServer } from "@server/test/support"; + +const server = getTestServer(); + +describe("groupMemberships.list", () => { + it("should require authentication", async () => { + const res = await server.post("/api/groupMemberships.list", { + body: {}, + }); + expect(res.status).toEqual(401); + }); + + it("should return the list of docs shared with group", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + createdById: user.id, + permission: null, + }); + const document = await buildDocument({ + collectionId: collection.id, + createdById: user.id, + teamId: user.teamId, + }); + const group = await buildGroup({ + teamId: user.teamId, + }); + const member = await buildUser({ + teamId: user.teamId, + }); + await GroupUser.create({ + groupId: group.id, + userId: member.id, + createdById: user.id, + }); + + await server.post("/api/documents.add_group", { + body: { + token: user.getJwtToken(), + id: document.id, + groupId: group.id, + }, + }); + + const res = await server.post("/api/groupMemberships.list", { + body: { + token: member.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).not.toBeFalsy(); + expect(body.data.documents).not.toBeFalsy(); + expect(body.data.documents).toHaveLength(1); + expect(body.data.memberships).not.toBeFalsy(); + expect(body.data.memberships).toHaveLength(1); + const sharedDoc = body.data.documents[0]; + expect(sharedDoc.id).toEqual(document.id); + expect(sharedDoc.id).toEqual(body.data.memberships[0].documentId); + expect(body.data.memberships[0].groupId).toEqual(group.id); + expect(body.policies).not.toBeFalsy(); + }); +}); diff --git a/server/routes/api/groupMemberships/groupMemberships.ts b/server/routes/api/groupMemberships/groupMemberships.ts new file mode 100644 index 000000000000..05c62b26173e --- /dev/null +++ b/server/routes/api/groupMemberships/groupMemberships.ts @@ -0,0 +1,92 @@ +import Router from "koa-router"; +import uniqBy from "lodash/uniqBy"; +import { Op } from "sequelize"; +import auth from "@server/middlewares/authentication"; +import validate from "@server/middlewares/validate"; +import { Document, GroupMembership } from "@server/models"; +import { + presentDocument, + presentGroup, + presentGroupMembership, + presentPolicies, +} from "@server/presenters"; +import { APIContext } from "@server/types"; +import pagination from "../middlewares/pagination"; +import * as T from "./schema"; + +const router = new Router(); + +router.post( + "groupMemberships.list", + auth(), + pagination(), + validate(T.GroupMembershipsListSchema), + async (ctx: APIContext) => { + const { user } = ctx.state.auth; + + const memberships = await GroupMembership.findAll({ + where: { + documentId: { + [Op.ne]: null, + }, + sourceId: { + [Op.eq]: null, + }, + }, + include: [ + { + association: "group", + required: true, + include: [ + { + association: "groupUsers", + required: true, + where: { + userId: user.id, + }, + }, + ], + }, + ], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + const documentIds = memberships + .map((p) => p.documentId) + .filter(Boolean) as string[]; + const documents = await Document.scope([ + "withDrafts", + { method: ["withMembership", user.id] }, + { method: ["withCollectionPermissions", user.id] }, + ]).findAll({ + where: { + id: documentIds, + }, + }); + + const groups = uniqBy( + memberships.map((membership) => membership.group), + "id" + ); + const policies = presentPolicies(user, [ + ...documents, + ...memberships, + ...groups, + ]); + + ctx.body = { + pagination: ctx.state.pagination, + data: { + groups: await Promise.all(groups.map(presentGroup)), + groupMemberships: memberships.map(presentGroupMembership), + documents: await Promise.all( + documents.map((document: Document) => presentDocument(ctx, document)) + ), + }, + policies, + }; + } +); + +export default router; diff --git a/server/routes/api/groupMemberships/index.ts b/server/routes/api/groupMemberships/index.ts new file mode 100644 index 000000000000..5fb034e79769 --- /dev/null +++ b/server/routes/api/groupMemberships/index.ts @@ -0,0 +1 @@ +export { default } from "./groupMemberships"; diff --git a/server/routes/api/groupMemberships/schema.ts b/server/routes/api/groupMemberships/schema.ts new file mode 100644 index 000000000000..2c4f349397fa --- /dev/null +++ b/server/routes/api/groupMemberships/schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; +import { BaseSchema } from "@server/routes/api/schema"; + +export const GroupMembershipsListSchema = BaseSchema; + +export type GroupMembershipsListReq = z.infer< + typeof GroupMembershipsListSchema +>; diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index da607fd5ee26..b5647e868591 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -18,6 +18,7 @@ import developer from "./developer"; import documents from "./documents"; import events from "./events"; import fileOperationsRoute from "./fileOperations"; +import groupMemberships from "./groupMemberships"; import groups from "./groups"; import integrations from "./integrations"; import apiResponse from "./middlewares/apiResponse"; @@ -85,6 +86,7 @@ router.use("/", notifications.routes()); router.use("/", attachments.routes()); router.use("/", cron.routes()); router.use("/", groups.routes()); +router.use("/", groupMemberships.routes()); router.use("/", fileOperationsRoute.routes()); router.use("/", urls.routes()); router.use("/", userMemberships.routes()); From bd5ac68a5e2f7e720a7ddec6ca8484774cbd54fc Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 18 Aug 2024 12:50:11 -0400 Subject: [PATCH 16/51] fix: Allow loading of child documents for group shared documents --- server/routes/api/documents/documents.ts | 37 +++++++++++++++++++----- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index fbe6b1ecae63..04a1b9cda91f 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -44,6 +44,7 @@ import { View, UserMembership, Group, + GroupUser, GroupMembership, } from "@server/models"; import AttachmentHelper from "@server/models/helpers/AttachmentHelper"; @@ -146,14 +147,36 @@ router.post( } if (parentDocumentId) { - const membership = await UserMembership.findOne({ - where: { - userId: user.id, - documentId: parentDocumentId, - }, - }); + const [groupMembership, membership] = await Promise.all([ + GroupMembership.findOne({ + where: { + documentId: parentDocumentId, + }, + include: [ + { + model: Group, + required: true, + include: [ + { + model: GroupUser, + required: true, + where: { + userId: user.id, + }, + }, + ], + }, + ], + }), + UserMembership.findOne({ + where: { + userId: user.id, + documentId: parentDocumentId, + }, + }), + ]); - if (membership) { + if (groupMembership || membership) { delete where.collectionId; } From 779bae56fc4ca569f8cc330d00e0a636478d686e Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 18 Aug 2024 15:43:52 -0400 Subject: [PATCH 17/51] fix: Breadcrumb for shared documents, bug in share popover for admins with collection access --- app/components/DocumentBreadcrumb.tsx | 4 +++- .../Sharing/Document/AccessControlList.tsx | 3 ++- app/models/Document.ts | 22 ++++++++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/app/components/DocumentBreadcrumb.tsx b/app/components/DocumentBreadcrumb.tsx index 96da0d700f9b..60bc7c131442 100644 --- a/app/components/DocumentBreadcrumb.tsx +++ b/app/components/DocumentBreadcrumb.tsx @@ -8,6 +8,7 @@ import Document from "~/models/Document"; import Breadcrumb from "~/components/Breadcrumb"; import Icon from "~/components/Icon"; import CollectionIcon from "~/components/Icons/CollectionIcon"; +import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { MenuInternalLink } from "~/types"; import { @@ -67,6 +68,7 @@ const DocumentBreadcrumb: React.FC = ({ const collection = document.collectionId ? collections.get(document.collectionId) : undefined; + const can = usePolicy(collection); React.useEffect(() => { void document.loadRelations(); @@ -74,7 +76,7 @@ const DocumentBreadcrumb: React.FC = ({ let collectionNode: MenuInternalLink | undefined; - if (collection) { + if (collection && can.readDocument) { collectionNode = { type: "route", title: collection.name, diff --git a/app/components/Sharing/Document/AccessControlList.tsx b/app/components/Sharing/Document/AccessControlList.tsx index b41c2a1b926b..00d6517bde56 100644 --- a/app/components/Sharing/Document/AccessControlList.tsx +++ b/app/components/Sharing/Document/AccessControlList.tsx @@ -62,6 +62,7 @@ export const AccessControlList = observer( const collectionSharingDisabled = document.collection?.sharing === false; const team = useCurrentTeam(); const can = usePolicy(document); + const canCollection = usePolicy(collection); const documentId = document.id; const containerRef = React.useRef(null); @@ -107,7 +108,7 @@ export const AccessControlList = observer( style={{ maxHeight }} > {(!userMembershipData || !groupMembershipData) && } - {collection ? ( + {collection && canCollection.readDocument ? ( <> {collection.permission ? ( Document) + parentDocument?: Document; + @observable collaboratorIds: string[]; @@ -376,9 +379,26 @@ export default class Document extends ParanoidModel { return floor((this.tasks.completed / this.tasks.total) * 100); } + /** + * Returns the path to the document, using the collection structure if available. + * otherwise if we're viewing a shared document we can iterate up the parentDocument tree. + * + * @returns path to the document + */ @computed get pathTo() { - return this.collection?.pathToDocument(this.id) ?? []; + if (this.collection?.documents) { + return this.collection.pathToDocument(this.id); + } + + // find root parent document we have access to + const path: Document[] = [this]; + + while (path[0]?.parentDocument) { + path.unshift(path[0].parentDocument); + } + + return path.map((item) => item.asNavigationNode); } @computed From a495d638d3aae695f0622ca2196c64ca4f72d5c6 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 18 Aug 2024 16:36:32 -0400 Subject: [PATCH 18/51] fix: Breadcrumb icon color --- app/models/Document.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/Document.ts b/app/models/Document.ts index 4bd3e0e72dff..59509624de20 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -602,6 +602,8 @@ export default class Document extends ParanoidModel { return { id: this.id, title: this.title, + color: this.color ?? undefined, + icon: this.icon ?? undefined, children: this.childDocuments.map((doc) => doc.asNavigationNode), url: this.url, isDraft: this.isDraft, From 15451f49cc9a246bfec81726aacafb701f0b0efb Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 18 Aug 2024 19:24:47 -0400 Subject: [PATCH 19/51] fix: Non-clickable items have cursor --- app/components/List/Item.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/components/List/Item.tsx b/app/components/List/Item.tsx index 662bdd547cb2..0de04d82a501 100644 --- a/app/components/List/Item.tsx +++ b/app/components/List/Item.tsx @@ -142,10 +142,14 @@ const ListItem = ( $hover={!!rest.onClick} {...rest} {...rovingTabIndex} - onClick={(ev) => { - rest.onClick?.(ev); - rovingTabIndex.onClick(ev); - }} + onClick={ + rest.onClick + ? (ev) => { + rest.onClick?.(ev); + rovingTabIndex.onClick(ev); + } + : undefined + } onKeyDown={(ev) => { rest.onKeyDown?.(ev); rovingTabIndex.onKeyDown(ev); From 88715ec78fc6e5d66a12c665d2fedaf1fad9596f Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 18 Aug 2024 19:25:14 -0400 Subject: [PATCH 20/51] Sidebar order --- app/components/Sidebar/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/Sidebar/App.tsx b/app/components/Sidebar/App.tsx index 693b00f65a33..3e0f02027805 100644 --- a/app/components/Sidebar/App.tsx +++ b/app/components/Sidebar/App.tsx @@ -126,10 +126,10 @@ function AppSidebar() { )}
    - +
    - +
    From 5f1b6ca27946733524939dd5f17f96add51c05b1 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 18 Aug 2024 21:19:08 -0400 Subject: [PATCH 21/51] fix: Sidebar highlight state for shared, starred, regular docs --- .../Sidebar/components/DocumentLink.tsx | 26 ++++++++++--- .../Sidebar/components/SharedContext.ts | 2 +- .../Sidebar/components/SharedWithMeLink.tsx | 39 +++++++++++++++---- .../Sidebar/components/StarredContext.ts | 2 +- .../Sidebar/components/StarredLink.tsx | 4 +- .../Sidebar/components/useDragAndDrop.tsx | 5 ++- 6 files changed, 58 insertions(+), 20 deletions(-) diff --git a/app/components/Sidebar/components/DocumentLink.tsx b/app/components/Sidebar/components/DocumentLink.tsx index f45aefc9f222..573600143183 100644 --- a/app/components/Sidebar/components/DocumentLink.tsx +++ b/app/components/Sidebar/components/DocumentLink.tsx @@ -339,6 +339,7 @@ function InnerDocumentLink( state: { title: node.title, starred: inStarredSection, + sharedWithMe: inSharedSection, }, }} icon={icon && } @@ -352,16 +353,29 @@ function InnerDocumentLink( ref={editableTitleRef} /> } - isActive={(match, location: Location<{ starred?: boolean }>) => - ((document && location.pathname.endsWith(document.urlId)) || - !!match) && - location.state?.starred === inStarredSection - } + isActive={( + match, + location: Location<{ + starred?: boolean; + sharedWithMe?: boolean; + }> + ) => { + if (inStarredSection !== location.state?.starred) { + return false; + } + if (inSharedSection !== location.state?.sharedWithMe) { + return false; + } + return ( + (document && location.pathname.endsWith(document.urlId)) || + !!match + ); + }} isActiveDrop={isOverReparent && canDropToReparent} depth={depth} exact={false} showActions={menuOpen} - scrollIntoViewIfNeeded={!inStarredSection} + scrollIntoViewIfNeeded={!inStarredSection && !inSharedSection} isDraft={isDraft} ref={ref} menu={ diff --git a/app/components/Sidebar/components/SharedContext.ts b/app/components/Sidebar/components/SharedContext.ts index 9391466f1e38..7960b720aac2 100644 --- a/app/components/Sidebar/components/SharedContext.ts +++ b/app/components/Sidebar/components/SharedContext.ts @@ -1,6 +1,6 @@ import * as React from "react"; -const SharedContext = React.createContext(undefined); +const SharedContext = React.createContext(false); export const useSharedContext = () => React.useContext(SharedContext); diff --git a/app/components/Sidebar/components/SharedWithMeLink.tsx b/app/components/Sidebar/components/SharedWithMeLink.tsx index a65e1d9c9c70..0c0190858dfc 100644 --- a/app/components/Sidebar/components/SharedWithMeLink.tsx +++ b/app/components/Sidebar/components/SharedWithMeLink.tsx @@ -1,6 +1,8 @@ import fractionalIndex from "fractional-index"; +import { Location } from "history"; import { observer } from "mobx-react"; import * as React from "react"; +import { useLocation } from "react-router-dom"; import styled from "styled-components"; import { IconType, NotificationEventType } from "@shared/types"; import { determineIconType } from "@shared/utils/icon"; @@ -16,7 +18,7 @@ import Folder from "./Folder"; import Relative from "./Relative"; import SidebarLink from "./SidebarLink"; import { - useDragUserMembership, + useDragMembership, useDropToReorderUserMembership, } from "./useDragAndDrop"; import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon"; @@ -25,21 +27,33 @@ type Props = { userMembership: UserMembership | GroupMembership; }; +function useLocationState() { + const location = useLocation<{ + sharedWithMe?: boolean; + }>(); + return location.state?.sharedWithMe; +} + function SharedWithMeLink({ userMembership }: Props) { const { ui, collections, documents } = useStores(); const { fetchChildDocuments } = documents; const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const { documentId } = userMembership; const isActiveDocument = documentId === ui.activeDocumentId; + const locationStateStarred = useLocationState(); + const [expanded, setExpanded] = React.useState( - userMembership.documentId === ui.activeDocumentId + userMembership.documentId === ui.activeDocumentId && !!locationStateStarred ); React.useEffect(() => { - if (userMembership.documentId === ui.activeDocumentId) { + if ( + userMembership.documentId === ui.activeDocumentId && + locationStateStarred + ) { setExpanded(true); } - }, [userMembership.documentId, ui.activeDocumentId]); + }, [userMembership.documentId, ui.activeDocumentId, locationStateStarred]); React.useEffect(() => { if (documentId) { @@ -63,11 +77,17 @@ function SharedWithMeLink({ userMembership }: Props) { ); const { icon } = useSidebarLabelAndIcon(userMembership); - const [{ isDragging }, draggableRef] = useDragUserMembership(userMembership); + const [{ isDragging }, draggableRef] = useDragMembership(userMembership); const getIndex = () => { - const next = userMembership?.next(); - return fractionalIndex(userMembership?.index || null, next?.index || null); + if (userMembership instanceof UserMembership) { + const next = userMembership?.next(); + return fractionalIndex( + userMembership?.index || null, + next?.index || null + ); + } + return ""; }; const [reorderMonitor, dropToReorderRef] = useDropToReorderUserMembership(getIndex); @@ -104,11 +124,14 @@ function SharedWithMeLink({ userMembership }: Props) { depth={0} to={{ pathname: document.path, - state: { starred: true }, + state: { sharedWithMe: true }, }} expanded={hasChildDocuments && !isDragging ? expanded : undefined} onDisclosureClick={handleDisclosureClick} icon={icon} + isActive={(match, location: Location<{ sharedWithMe?: boolean }>) => + !!match && location.state?.sharedWithMe === true + } label={label} exact={false} unreadBadge={ diff --git a/app/components/Sidebar/components/StarredContext.ts b/app/components/Sidebar/components/StarredContext.ts index 44d9f2e0f77b..aed577f1426a 100644 --- a/app/components/Sidebar/components/StarredContext.ts +++ b/app/components/Sidebar/components/StarredContext.ts @@ -1,6 +1,6 @@ import * as React from "react"; -const StarredContext = React.createContext(undefined); +const StarredContext = React.createContext(false); export const useStarredContext = () => React.useContext(StarredContext); diff --git a/app/components/Sidebar/components/StarredLink.tsx b/app/components/Sidebar/components/StarredLink.tsx index ee64fc63e60b..eb94bb6b9777 100644 --- a/app/components/Sidebar/components/StarredLink.tsx +++ b/app/components/Sidebar/components/StarredLink.tsx @@ -29,7 +29,7 @@ type Props = { star: Star; }; -function useLocationStateStarred() { +function useLocationState() { const location = useLocation<{ starred?: boolean; }>(); @@ -42,7 +42,7 @@ function StarredLink({ star }: Props) { const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const { documentId, collectionId } = star; const collection = collections.get(collectionId); - const locationStateStarred = useLocationStateStarred(); + const locationStateStarred = useLocationState(); const [expanded, setExpanded] = useState( star.collectionId === ui.activeCollectionId && !!locationStateStarred ); diff --git a/app/components/Sidebar/components/useDragAndDrop.tsx b/app/components/Sidebar/components/useDragAndDrop.tsx index f0f92c275e8c..310170012c81 100644 --- a/app/components/Sidebar/components/useDragAndDrop.tsx +++ b/app/components/Sidebar/components/useDragAndDrop.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { ConnectDragSource, useDrag, useDrop } from "react-dnd"; import { getEmptyImage } from "react-dnd-html5-backend"; import { useTheme } from "styled-components"; +import GroupMembership from "~/models/GroupMembership"; import Star from "~/models/Star"; import UserMembership from "~/models/UserMembership"; import useCurrentUser from "~/hooks/useCurrentUser"; @@ -90,8 +91,8 @@ export function useDropToReorderStar(getIndex?: () => string) { }); } -export function useDragUserMembership( - userMembership: UserMembership +export function useDragMembership( + userMembership: UserMembership | GroupMembership ): [{ isDragging: boolean }, ConnectDragSource] { const id = userMembership.id; const { label: title, icon } = useSidebarLabelAndIcon(userMembership); From 1e8dfb750c059101228f74f52b43f1ee88702830 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 18 Aug 2024 22:56:53 -0400 Subject: [PATCH 22/51] test --- .../routes/api/groupMemberships/groupMemberships.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/routes/api/groupMemberships/groupMemberships.test.ts b/server/routes/api/groupMemberships/groupMemberships.test.ts index 571f3c6047a2..8ccd01147e3c 100644 --- a/server/routes/api/groupMemberships/groupMemberships.test.ts +++ b/server/routes/api/groupMemberships/groupMemberships.test.ts @@ -59,12 +59,12 @@ describe("groupMemberships.list", () => { expect(body.data).not.toBeFalsy(); expect(body.data.documents).not.toBeFalsy(); expect(body.data.documents).toHaveLength(1); - expect(body.data.memberships).not.toBeFalsy(); - expect(body.data.memberships).toHaveLength(1); + expect(body.data.groupMemberships).not.toBeFalsy(); + expect(body.data.groupMemberships).toHaveLength(1); const sharedDoc = body.data.documents[0]; expect(sharedDoc.id).toEqual(document.id); - expect(sharedDoc.id).toEqual(body.data.memberships[0].documentId); - expect(body.data.memberships[0].groupId).toEqual(group.id); + expect(sharedDoc.id).toEqual(body.data.groupMemberships[0].documentId); + expect(body.data.groupMemberships[0].groupId).toEqual(group.id); expect(body.policies).not.toBeFalsy(); }); }); From 5679ca7b25be97f4af1d9a22de03f03a4e369ba3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 19 Aug 2024 22:08:02 -0400 Subject: [PATCH 23/51] AccessControlList loading state --- app/components/PlaceholderText.tsx | 15 +- .../Sharing/Collection/AccessControlList.tsx | 253 ++++++++++-------- .../Sharing/Document/AccessControlList.tsx | 170 ++++++------ .../Sharing/components/Placeholder.tsx | 47 ++++ server/routes/api/collections/collections.ts | 8 +- server/routes/api/documents/documents.ts | 8 +- 6 files changed, 300 insertions(+), 201 deletions(-) create mode 100644 app/components/Sharing/components/Placeholder.tsx diff --git a/app/components/PlaceholderText.tsx b/app/components/PlaceholderText.tsx index 4785fbb69f8a..9d961a12760b 100644 --- a/app/components/PlaceholderText.tsx +++ b/app/components/PlaceholderText.tsx @@ -5,8 +5,9 @@ import { s } from "@shared/styles"; import Flex from "~/components/Flex"; import { pulsate } from "~/styles/animations"; -export type Props = { +export type Props = React.ComponentProps & { header?: boolean; + width?: number; height?: number; minWidth?: number; maxWidth?: number; @@ -17,16 +18,22 @@ function PlaceholderText({ minWidth, maxWidth, ...restProps }: Props) { // We only want to compute the width once so we are storing it inside ref const widthRef = React.useRef(randomInteger(minWidth || 75, maxWidth || 100)); - return ; + return ( + + ); } const Mask = styled(Flex)<{ - width: number; + width: number | string; height?: number; delay?: number; header?: boolean; }>` - width: ${(props) => (props.header ? props.width / 2 : props.width)}%; + width: ${(props) => + typeof props.width === "number" ? `${props.width}px` : props.width}; height: ${(props) => props.height ? props.height : props.header ? 24 : 18}px; margin-bottom: 6px; diff --git a/app/components/Sharing/Collection/AccessControlList.tsx b/app/components/Sharing/Collection/AccessControlList.tsx index a7ddb34d335c..c1a444b88ab0 100644 --- a/app/components/Sharing/Collection/AccessControlList.tsx +++ b/app/components/Sharing/Collection/AccessControlList.tsx @@ -9,7 +9,6 @@ import Collection from "~/models/Collection"; import Avatar, { AvatarSize } from "~/components/Avatar/Avatar"; import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect"; import InputSelectPermission from "~/components/InputSelectPermission"; -import LoadingIndicator from "~/components/LoadingIndicator"; import Scrollable from "~/components/Scrollable"; import useMaxHeight from "~/hooks/useMaxHeight"; import usePolicy from "~/hooks/usePolicy"; @@ -17,6 +16,7 @@ import useRequest from "~/hooks/useRequest"; import useStores from "~/hooks/useStores"; import { EmptySelectValue, Permission } from "~/types"; import { ListItem } from "../components/ListItem"; +import { Placeholder } from "../components/Placeholder"; type Props = { /** Collection to which team members are supposed to be invited */ @@ -35,14 +35,15 @@ export const AccessControlList = observer( const theme = useTheme(); const collectionId = collection.id; - const { request: fetchMemberships, data: membershipData } = useRequest( - React.useCallback( - () => memberships.fetchAll({ id: collectionId }), - [memberships, collectionId] - ) - ); + const { request: fetchMemberships, loading: membershipLoading } = + useRequest( + React.useCallback( + () => memberships.fetchAll({ id: collectionId }), + [memberships, collectionId] + ) + ); - const { request: fetchGroupMemberships, data: groupMembershipData } = + const { request: fetchGroupMemberships, loading: groupMembershipLoading } = useRequest( React.useCallback( () => groupMemberships.fetchAll({ collectionId }), @@ -50,6 +51,15 @@ export const AccessControlList = observer( ) ); + const groupMembershipsInCollection = + groupMemberships.inCollection(collectionId); + const membershipsInCollection = memberships.inCollection(collectionId); + const hasMemberships = + groupMembershipsInCollection.length > 0 || + membershipsInCollection.length > 0; + const showLoading = + !hasMemberships && (membershipLoading || groupMembershipLoading); + React.useEffect(() => { void fetchMemberships(); void fetchGroupMemberships(); @@ -95,132 +105,141 @@ export const AccessControlList = observer( hiddenScrollbars style={{ maxHeight }} > - {(!membershipData || !groupMembershipData) && } - - - - } - title={t("All members")} - subtitle={t("Everyone in the workspace")} - actions={ -
    - { - void collection.save({ - permission: value === EmptySelectValue ? null : value, - }); - }} - disabled={!can.update} - value={collection?.permission} - labelHidden - nude - /> -
    - } - /> - {groupMemberships - .inCollection(collection.id) - .sort((a, b) => - ( - (invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name - ).localeCompare(b.group.name) - ) - .map((membership) => ( + {showLoading ? ( + + ) : ( + <> - + + } - title={membership.group.name} - subtitle={t("{{ count }} member", { - count: membership.group.memberCount, - })} + title={t("All members")} + subtitle={t("Everyone in the workspace")} actions={
    - { - if (permission === EmptySelectValue) { - await groupMemberships.delete({ - collectionId: collection.id, - groupId: membership.groupId, - }); - } else { - await groupMemberships.create({ - collectionId: collection.id, - groupId: membership.groupId, - permission, - }); - } + void collection.save({ + permission: value === EmptySelectValue ? null : value, + }); }} disabled={!can.update} - value={membership.permission} + value={collection?.permission} labelHidden nude />
    } /> - ))} - {memberships - .inCollection(collection.id) - .sort((a, b) => - ( - (invitedInSession.includes(a.user.id) ? "_" : "") + a.user.name - ).localeCompare(b.user.name) - ) - .map((membership) => ( - + ( + (invitedInSession.includes(a.group.id) ? "_" : "") + + a.group.name + ).localeCompare(b.group.name) + ) + .map((membership) => ( + + + + } + title={membership.group.name} + subtitle={t("{{ count }} member", { + count: membership.group.memberCount, + })} + actions={ +
    + { + if (permission === EmptySelectValue) { + await groupMemberships.delete({ + collectionId: collection.id, + groupId: membership.groupId, + }); + } else { + await groupMemberships.create({ + collectionId: collection.id, + groupId: membership.groupId, + permission, + }); + } + }} + disabled={!can.update} + value={membership.permission} + labelHidden + nude + /> +
    + } /> - } - title={membership.user.name} - subtitle={membership.user.email} - actions={ -
    - { - if (permission === EmptySelectValue) { - await memberships.delete({ - collectionId: collection.id, - userId: membership.userId, - }); - } else { - await memberships.create({ - collectionId: collection.id, - userId: membership.userId, - permission, - }); - } - }} - disabled={!can.update} - value={membership.permission} - labelHidden - nude - /> -
    - } - /> - ))} + ))} + {membershipsInCollection + .sort((a, b) => + ( + (invitedInSession.includes(a.user.id) ? "_" : "") + + a.user.name + ).localeCompare(b.user.name) + ) + .map((membership) => ( + + } + title={membership.user.name} + subtitle={membership.user.email} + actions={ +
    + { + if (permission === EmptySelectValue) { + await memberships.delete({ + collectionId: collection.id, + userId: membership.userId, + }); + } else { + await memberships.create({ + collectionId: collection.id, + userId: membership.userId, + permission, + }); + } + }} + disabled={!can.update} + value={membership.permission} + labelHidden + nude + /> +
    + } + /> + ))} + + )} ); } diff --git a/app/components/Sharing/Document/AccessControlList.tsx b/app/components/Sharing/Document/AccessControlList.tsx index 00d6517bde56..40fafefda1d2 100644 --- a/app/components/Sharing/Document/AccessControlList.tsx +++ b/app/components/Sharing/Document/AccessControlList.tsx @@ -11,7 +11,6 @@ import type Collection from "~/models/Collection"; import type Document from "~/models/Document"; import Share from "~/models/Share"; import Flex from "~/components/Flex"; -import LoadingIndicator from "~/components/LoadingIndicator"; import Scrollable from "~/components/Scrollable"; import Text from "~/components/Text"; import useCurrentTeam from "~/hooks/useCurrentTeam"; @@ -26,6 +25,7 @@ import CollectionIcon from "../../Icons/CollectionIcon"; import Tooltip from "../../Tooltip"; import { Separator } from "../components"; import { ListItem } from "../components/ListItem"; +import { Placeholder } from "../components/Placeholder"; import DocumentMemberList from "./DocumentMemberList"; import PublicAccess from "./PublicAccess"; @@ -72,7 +72,7 @@ export const AccessControlList = observer( margin: 24, }); - const { data: userMembershipData, request: fetchUserMemberships } = + const { loading: userMembershipLoading, request: fetchUserMemberships } = useRequest( React.useCallback( () => @@ -84,7 +84,7 @@ export const AccessControlList = observer( ) ); - const { data: groupMembershipData, request: fetchGroupMemberships } = + const { loading: groupMembershipLoading, request: fetchGroupMemberships } = useRequest( React.useCallback( () => groupMemberships.fetchAll({ documentId }), @@ -92,6 +92,12 @@ export const AccessControlList = observer( ) ); + const hasMemberships = + groupMemberships.inDocument(documentId)?.length > 0 || + document.members.length > 0; + const showLoading = + !hasMemberships && (groupMembershipLoading || userMembershipLoading); + React.useEffect(() => { void fetchUserMemberships(); void fetchGroupMemberships(); @@ -107,84 +113,92 @@ export const AccessControlList = observer( hiddenScrollbars style={{ maxHeight }} > - {(!userMembershipData || !groupMembershipData) && } - {collection && canCollection.readDocument ? ( - <> - {collection.permission ? ( - - - - } - title={t("All members")} - subtitle={t("Everyone in the workspace")} - actions={ - - {collection?.permission === CollectionPermission.ReadWrite - ? t("Can edit") - : t("Can view")} - - } - /> - ) : usersInCollection ? ( - } - title={collection.name} - subtitle={t("Everyone in the collection")} - actions={{t("Can view")}} - /> - ) : ( - } - title={user.name} - subtitle={t("You have full access")} - actions={{t("Can edit")}} - /> - )} - - - ) : document.isDraft ? ( - <> - } - title={document.createdBy?.name} - actions={ - - {t("Can edit")} - - } - /> - - + {showLoading ? ( + ) : ( <> - - - - - } - title={t("Other people")} - subtitle={t("Other workspace members may have access")} - actions={ - + {collection.permission ? ( + + + + } + title={t("All members")} + subtitle={t("Everyone in the workspace")} + actions={ + + {collection?.permission === + CollectionPermission.ReadWrite + ? t("Can edit") + : t("Can view")} + + } + /> + ) : usersInCollection ? ( + } + title={collection.name} + subtitle={t("Everyone in the collection")} + actions={{t("Can view")}} + /> + ) : ( + } + title={user.name} + subtitle={t("You have full access")} + actions={{t("Can edit")}} + /> + )} + - } - /> + + ) : document.isDraft ? ( + <> + + } + title={document.createdBy?.name} + actions={ + + {t("Can edit")} + + } + /> + + + ) : ( + <> + + + + + } + title={t("Other people")} + subtitle={t("Other workspace members may have access")} + actions={ + + } + /> + + )} )} {team.sharing && can.share && !collectionSharingDisabled && visible && ( diff --git a/app/components/Sharing/components/Placeholder.tsx b/app/components/Sharing/components/Placeholder.tsx new file mode 100644 index 000000000000..cd5b48255eb4 --- /dev/null +++ b/app/components/Sharing/components/Placeholder.tsx @@ -0,0 +1,47 @@ +import times from "lodash/times"; +import * as React from "react"; +import { AvatarSize } from "~/components/Avatar/Avatar"; +import Fade from "~/components/Fade"; +import PlaceholderText from "~/components/PlaceholderText"; +import { ListItem } from "../components/ListItem"; + +type Props = { + count?: number; +}; + +/** + * Placeholder for a list item in the share popover. + */ +export function Placeholder({ count = 1 }: Props) { + return ( + + {times(count, (index) => ( + + } + title={ + + } + subtitle={ + + } + /> + ))} + + ); +} diff --git a/server/routes/api/collections/collections.ts b/server/routes/api/collections/collections.ts index ba8a0b334bbf..4b4f9301fda9 100644 --- a/server/routes/api/collections/collections.ts +++ b/server/routes/api/collections/collections.ts @@ -306,7 +306,13 @@ router.post( ctx.throw(400, "This Group is not a part of the collection"); } - await collection.$remove("group", group, { transaction }); + await GroupMembership.destroy({ + where: { + collectionId: id, + groupId, + }, + transaction, + }); await Event.createFromContext( ctx, { diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index c08d07f7235a..3b4eca4fcef1 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -1788,7 +1788,13 @@ router.post( ctx.throw(400, "This Group is not a part of the document"); } - await document.$remove("group", group, { transaction }); + await GroupMembership.destroy({ + where: { + documentId: id, + groupId, + }, + transaction, + }); await Event.createFromContext( ctx, { From ab02e682e753c0df168633fd8937306401807538 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 28 Aug 2024 20:31:38 -0400 Subject: [PATCH 24/51] WebsocketsProcessor TODOs --- .../queues/processors/WebsocketsProcessor.ts | 112 +++++++++--------- 1 file changed, 54 insertions(+), 58 deletions(-) diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index c46db98f5359..e1d8e1a96571 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -179,21 +179,11 @@ export default class WebsocketsProcessor { } const channels = await this.getDocumentEventChannels(event, document); - - // TODO - const groupUserIds = (await group.$get("groupUsers")).map( - (groupUser) => groupUser.userId - ); - const groupUserChannels = groupUserIds.map( - (userId) => `user-${userId}` - ); - socketio - .to(uniq([...channels, ...groupUserChannels])) - .emit(event.name, { - id: event.modelId, - groupId: event.modelId, - documentId: event.documentId, - }); + socketio.to([...channels, `group-${event.modelId}`]).emit(event.name, { + id: event.modelId, + groupId: event.modelId, + documentId: event.documentId, + }); return; } @@ -549,23 +539,28 @@ export default class WebsocketsProcessor { }, async (groupMemberships) => { for (const groupMembership of groupMemberships) { - if (!groupMembership.collectionId) { - // TODO - continue; + if (groupMembership.collectionId) { + socketio + .to(`user-${event.userId}`) + .emit( + "collections.add_group", + presentGroupMembership(groupMembership) + ); + + // tell any user clients to connect to the websocket channel for the collection + socketio.to(`user-${event.userId}`).emit("join", { + event: event.name, + collectionId: groupMembership.collectionId, + }); + } + if (groupMembership.documentId) { + socketio + .to(`user-${event.userId}`) + .emit( + "documents.add_group", + presentGroupMembership(groupMembership) + ); } - - socketio - .to(`user-${event.userId}`) - .emit( - "collections.add_group", - presentGroupMembership(groupMembership) - ); - - // tell any user clients to connect to the websocket channel for the collection - socketio.to(`user-${event.userId}`).emit("join", { - event: event.name, - collectionId: groupMembership.collectionId, - }); } } ); @@ -663,27 +658,33 @@ export default class WebsocketsProcessor { }, async (groupUsers) => { for (const groupMembership of groupMemberships) { - if (!groupMembership.collectionId) { - // TODO - continue; - } const payload = presentGroupMembership(groupMembership); - for (const groupUser of groupUsers) { - socketio - .to(`user-${groupUser.userId}`) - .emit("collections.remove_group", payload); + if (groupMembership.collectionId) { + for (const groupUser of groupUsers) { + socketio + .to(`user-${groupUser.userId}`) + .emit("collections.remove_group", payload); + + const collection = await Collection.scope({ + method: ["withMembership", groupUser.userId], + }).findByPk(groupMembership.collectionId); + + if (cannot(groupUser.user, "read", collection)) { + // tell any user clients to disconnect from the websocket channel for the collection + socketio.to(`user-${groupUser.userId}`).emit("leave", { + event: event.name, + collectionId: groupMembership.collectionId, + }); + } + } + } - const collection = await Collection.scope({ - method: ["withMembership", groupUser.userId], - }).findByPk(groupMembership.collectionId); - - if (cannot(groupUser.user, "read", collection)) { - // tell any user clients to disconnect from the websocket channel for the collection - socketio.to(`user-${groupUser.userId}`).emit("leave", { - event: event.name, - collectionId: groupMembership.collectionId, - }); + if (groupMembership.documentId) { + for (const groupUser of groupUsers) { + socketio + .to(`user-${groupUser.userId}`) + .emit("documents.remove_group", payload); } } } @@ -769,7 +770,7 @@ export default class WebsocketsProcessor { documentId: document.id, }, }), - GroupMembership.scope("withGroup").findAll({ + GroupMembership.findAll({ where: { documentId: document.id, }, @@ -780,14 +781,9 @@ export default class WebsocketsProcessor { channels.push(`user-${membership.userId}`); } - await Promise.all( - groupMemberships.map(async (groupMembership) => { - const groupUsers = await groupMembership.group.$get("groupUsers"); - for (const groupUser of groupUsers) { - channels.push(`user-${groupUser.userId}`); - } - }) - ); + for (const membership of groupMemberships) { + channels.push(`group-${membership.groupId}`); + } return uniq(channels); } From 5973652e49b53e1223ca041de2c76a122f6fe7fb Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 28 Aug 2024 20:41:53 -0400 Subject: [PATCH 25/51] Improve post-share toasts --- .../Sharing/Document/SharePopover.tsx | 69 ++++++++++--------- shared/i18n/locales/en_US/translation.json | 4 +- 2 files changed, 39 insertions(+), 34 deletions(-) diff --git a/app/components/Sharing/Document/SharePopover.tsx b/app/components/Sharing/Document/SharePopover.tsx index 4d434b7b73e7..b00cfee660e7 100644 --- a/app/components/Sharing/Document/SharePopover.tsx +++ b/app/components/Sharing/Document/SharePopover.tsx @@ -1,7 +1,7 @@ import { isEmail } from "class-validator"; import { m } from "framer-motion"; import { observer } from "mobx-react"; -import { BackIcon } from "outline-icons"; +import { BackIcon, GroupIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -178,40 +178,45 @@ function SharePopover({ (item) => item instanceof Group ) as Group[]; - // Special case for the common action of adding a single user. - if (invitedUsers.length === 1 && invited.length === 1) { - const user = invitedUsers[0]; - toast.message( - t("{{ userName }} was added to the document", { - userName: user.name, - }), - { - icon: , - } - ); - } else if (invitedGroups.length === 1 && invited.length === 1) { - const group = invitedGroups[0]; - toast.success( - t("{{ userName }} was added to the document", { - userName: group.name, - }) - ); - } else if (invitedGroups.length === 0) { - toast.success( - t("{{ count }} people added to the document", { - count: invitedUsers.length, - }) - ); - } else { - toast.success( - t( - "{{ count }} people and {{ count2 }} groups added to the document", + if (invitedUsers.length > 0) { + // Special case for the common action of adding a single user. + if (invitedUsers.length === 1) { + const user = invitedUsers[0]; + toast.message( + t("{{ userName }} was added to the document", { + userName: user.name, + }), { + icon: , + } + ); + } else { + toast.message( + t("{{ count }} people added to the document", { count: invitedUsers.length, - count2: invitedGroups.length, + }) + ); + } + } + if (invitedGroups.length > 0) { + // Special case for the common action of adding a single group. + if (invitedGroups.length === 1) { + const group = invitedGroups[0]; + toast.message( + t("{{ userName }} was added to the document", { + userName: group.name, + }), + { + icon: , } - ) - ); + ); + } else { + toast.message( + t("{{ count }} groups added to the document", { + count: invitedGroups.length, + }) + ); + } } setInvitedInSession((prev) => [...prev, ...pendingIds]); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index b7019ab76567..d75fca46e588 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -325,8 +325,8 @@ "{{ userName }} was added to the document": "{{ userName }} was added to the document", "{{ count }} people added to the document": "{{ count }} people added to the document", "{{ count }} people added to the document_plural": "{{ count }} people added to the document", - "{{ count }} people and {{ count2 }} groups added to the document": "{{ count }} people and {{ count2 }} groups added to the document", - "{{ count }} people and {{ count2 }} groups added to the document_plural": "{{ count }} people and {{ count2 }} groups added to the document", + "{{ count }} groups added to the document": "{{ count }} groups added to the document", + "{{ count }} groups added to the document_plural": "{{ count }} groups added to the document", "Logo": "Logo", "Move document": "Move document", "New doc": "New doc", From abd4efcf8556e29b5f98905deeda11fe4c74b751 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 28 Aug 2024 20:55:34 -0400 Subject: [PATCH 26/51] fix: Reduce duplicate wss events --- server/queues/processors/WebsocketsProcessor.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index e1d8e1a96571..a2b6aedde651 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -268,10 +268,6 @@ export default class WebsocketsProcessor { // so they need to be notified separately socketio .to(`user-${membership.userId}`) - .emit(event.name, presentMembership(membership)); - - // let everyone with access to the collection know a user was added - socketio .to(`collection-${membership.collectionId}`) .emit(event.name, presentMembership(membership)); @@ -326,9 +322,6 @@ export default class WebsocketsProcessor { socketio .to(`group-${membership.groupId}`) - .emit(event.name, presentGroupMembership(membership)); - - socketio .to(`collection-${membership.collectionId}`) .emit(event.name, presentGroupMembership(membership)); From 565e8be8fdf55f2ea20b96eb93de8cf64b7d4961 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 28 Aug 2024 21:03:30 -0400 Subject: [PATCH 27/51] fix: Deleted documents in shared with me, ref #7478 --- app/models/User.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/models/User.ts b/app/models/User.ts index 862e7c7c78ca..e3cb121bdcd7 100644 --- a/app/models/User.ts +++ b/app/models/User.ts @@ -129,22 +129,23 @@ class User extends ParanoidModel { /** * Returns the direct user memberships that this user has to other documents. Documents that the - * user already has access to through a collection are not included. + * user already has access to through a collection and trashed documents are not included. * * @returns A list of user memberships */ @computed get memberships(): UserMembership[] { - return this.store.rootStore.userMemberships.orderedData + const { userMemberships, documents, policies } = this.store.rootStore; + return userMemberships.orderedData .filter( (m) => m.userId === this.id && m.sourceId === null && m.documentId ) .filter((m) => { - const document = this.store.rootStore.documents.get(m.documentId!); + const document = documents.get(m.documentId!); const policy = document?.collectionId - ? this.store.rootStore.policies.get(document.collectionId) + ? policies.get(document.collectionId) : undefined; - return !policy?.abilities?.readDocument; + return !policy?.abilities?.readDocument && !document?.isDeleted; }); } From 3c9d5f316ab96c30aabc898b2233a49eca8583df Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 28 Aug 2024 22:14:56 -0400 Subject: [PATCH 28/51] fix: add_group/remove_group events --- app/components/Sidebar/components/SharedWithMe.tsx | 10 +++++++--- app/models/base/Model.ts | 5 ++++- app/stores/base/Store.ts | 14 ++++++++------ server/queues/processors/WebsocketsProcessor.ts | 4 ++-- server/routes/api/documents/documents.ts | 1 + server/types.ts | 7 ++++++- 6 files changed, 28 insertions(+), 13 deletions(-) diff --git a/app/components/Sidebar/components/SharedWithMe.tsx b/app/components/Sidebar/components/SharedWithMe.tsx index 80a5a2f6dfe4..0f6e457be534 100644 --- a/app/components/Sidebar/components/SharedWithMe.tsx +++ b/app/components/Sidebar/components/SharedWithMe.tsx @@ -45,9 +45,13 @@ function SharedWithMe() { } }, [error, t]); - // if (!user.memberships.length) { - // return null; - // } + // TODO + if ( + !user.memberships.length && + !groupMemberships.orderedData.filter((m) => m.documentId).length + ) { + return null; + } return ( diff --git a/app/models/base/Model.ts b/app/models/base/Model.ts index 679bbed7fb07..13fb87b52414 100644 --- a/app/models/base/Model.ts +++ b/app/models/base/Model.ts @@ -58,7 +58,10 @@ export default abstract class Model { properties.relationClassResolver().modelName ); if ("fetch" in store) { - promises.push(store.fetch(this[properties.idKey])); + const id = this[properties.idKey]; + if (id) { + promises.push(store.fetch(id)); + } } } diff --git a/app/stores/base/Store.ts b/app/stores/base/Store.ts index 567897d62702..5e470c210634 100644 --- a/app/stores/base/Store.ts +++ b/app/stores/base/Store.ts @@ -104,6 +104,11 @@ export default abstract class Store { @action remove(id: string): void { + const model = this.data.get(id); + if (!model) { + return; + } + const inverseRelations = getInverseRelationsForModelClass(this.model); inverseRelations.forEach((relation) => { @@ -134,12 +139,9 @@ export default abstract class Store { this.rootStore.policies.remove(id); } - const model = this.data.get(id); - if (model) { - LifecycleManager.executeHooks(model.constructor, "beforeRemove", model); - this.data.delete(id); - LifecycleManager.executeHooks(model.constructor, "afterRemove", model); - } + LifecycleManager.executeHooks(model.constructor, "beforeRemove", model); + this.data.delete(id); + LifecycleManager.executeHooks(model.constructor, "afterRemove", model); } /** diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index a2b6aedde651..2bfa025f1ab5 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -156,7 +156,7 @@ export default class WebsocketsProcessor { case "documents.add_group": { const [document, membership] = await Promise.all([ Document.findByPk(event.documentId), - GroupMembership.findByPk(event.modelId), + GroupMembership.findByPk(event.data.membershipId), ]); if (!document || !membership) { return; @@ -172,7 +172,7 @@ export default class WebsocketsProcessor { case "documents.remove_group": { const [document, group] = await Promise.all([ Document.findByPk(event.documentId), - Group.findByPk(event.modelId), + Group.findByPk(event.data.membershipId), ]); if (!document || !group) { return; diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 217becbb9c10..80309a2eb6a4 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -1742,6 +1742,7 @@ router.post( title: document.title, isNew, permission: membership.permission, + membershipId: membership.id, }, }, { transaction } diff --git a/server/types.ts b/server/types.ts index 8fa2fbfee6e6..15c20b1c8e9a 100644 --- a/server/types.ts +++ b/server/types.ts @@ -275,7 +275,12 @@ export type DocumentGroupEvent = BaseEvent & { name: "documents.add_group" | "documents.remove_group"; documentId: string; modelId: string; - data: { name: string }; + data: { + title: string; + isNew?: boolean; + permission?: DocumentPermission; + membershipId: string; + }; }; export type CollectionEvent = BaseEvent & From 09f42b77d7b4964c8f9e963ba2665c9a46c548e8 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 29 Aug 2024 10:20:40 -0400 Subject: [PATCH 29/51] Send users groups in auth payload --- app/stores/AuthStore.ts | 1 + server/models/User.ts | 19 ++++++++++++------- server/routes/api/auth/auth.ts | 14 +++++++++----- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index ef93084a5a4c..6e7db71b1899 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -207,6 +207,7 @@ export default class AuthStore extends Store { this.addPolicies(res.policies); this.add(data.team); this.rootStore.users.add(data.user); + data.groups.map(this.rootStore.groups.add); this.currentUserId = data.user.id; this.currentTeamId = data.team.id; diff --git a/server/models/User.ts b/server/models/User.ts index df048994d671..8d67352bfcca 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -406,24 +406,29 @@ class User extends ParanoidModel< false; /** - * Returns the user's active group ids. + * Returns the user's active groups. * * @param options Additional options to pass to the find - * @returns An array of group ids + * @returns An array of groups */ - public groupIds = async (options: FindOptions = {}) => { - const groupStubs = await Group.scope({ + public groups = (options: FindOptions = {}) => + Group.scope({ method: ["withMembership", this.id], }).findAll({ - attributes: ["id"], where: { teamId: this.teamId, }, ...options, }); - return groupStubs.map((g) => g.id); - }; + /** + * Returns the user's active group ids. + * + * @param options Additional options to pass to the find + * @returns An array of group ids + */ + public groupIds = async (options: FindOptions = {}) => + (await this.groups(options)).map((g) => g.id); /** * Returns the user's active collection ids. This includes collections the user diff --git a/server/routes/api/auth/auth.ts b/server/routes/api/auth/auth.ts index ad579fe116b9..ebf035edf12d 100644 --- a/server/routes/api/auth/auth.ts +++ b/server/routes/api/auth/auth.ts @@ -14,6 +14,7 @@ import { presentPolicies, presentProviderConfig, presentAvailableTeam, + presentGroup, } from "@server/presenters"; import ValidateSSOAccessTask from "@server/queues/tasks/ValidateSSOAccessTask"; import { APIContext } from "@server/types"; @@ -117,10 +118,11 @@ router.post("auth.info", auth(), async (ctx: APIContext) => { const sessions = getSessionsInCookie(ctx); const signedInTeamIds = Object.keys(sessions); - const [team, signedInTeams, availableTeams] = await Promise.all([ + const [team, groups, signedInTeams, availableTeams] = await Promise.all([ Team.scope("withDomains").findByPk(user.teamId, { rejectOnEmpty: true, }), + user.groups(), Team.findAll({ where: { id: signedInTeamIds, @@ -141,16 +143,18 @@ router.post("auth.info", auth(), async (ctx: APIContext) => { includeDetails: true, }), team: presentTeam(team), + groups: await Promise.all(groups.map(presentGroup)), collaborationToken: user.getCollaborationToken(), availableTeams: uniqBy([...signedInTeams, ...availableTeams], "id").map( - (team) => + (availableTeam) => presentAvailableTeam( - team, - signedInTeamIds.includes(team.id) || team.id === user.teamId + availableTeam, + signedInTeamIds.includes(team.id) || + availableTeam.id === user.teamId ) ), }, - policies: presentPolicies(user, [team]), + policies: presentPolicies(user, [team, user, ...groups]), }; }); From e10bd8c64e5f46a6876cc88ca5eb454cf8b1773b Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 29 Aug 2024 11:17:32 -0400 Subject: [PATCH 30/51] stash --- .../Sidebar/components/SharedWithMe.tsx | 31 +++++++++------ .../Sidebar/components/SharedWithMeLink.tsx | 39 ++++++++----------- .../Sidebar/components/useDragAndDrop.tsx | 4 +- app/models/User.ts | 18 ++++++++- app/scenes/Collection/index.tsx | 5 +-- app/stores/AuthStore.ts | 1 + .../queues/processors/WebsocketsProcessor.ts | 4 +- server/routes/api/auth/auth.ts | 2 + server/routes/api/groups/groups.ts | 1 + 9 files changed, 63 insertions(+), 42 deletions(-) diff --git a/app/components/Sidebar/components/SharedWithMe.tsx b/app/components/Sidebar/components/SharedWithMe.tsx index 0f6e457be534..17063ccefd21 100644 --- a/app/components/Sidebar/components/SharedWithMe.tsx +++ b/app/components/Sidebar/components/SharedWithMe.tsx @@ -1,5 +1,6 @@ import fractionalIndex from "fractional-index"; import { observer } from "mobx-react"; +import { GroupIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -12,6 +13,7 @@ import useCurrentUser from "~/hooks/useCurrentUser"; import usePaginatedRequest from "~/hooks/usePaginatedRequest"; import useStores from "~/hooks/useStores"; import DropCursor from "./DropCursor"; +import Folder from "./Folder"; import Header from "./Header"; import PlaceholderCollections from "./PlaceholderCollections"; import Relative from "./Relative"; @@ -36,7 +38,7 @@ function SharedWithMe() { // Drop to reorder document const [reorderMonitor, dropToReorderRef] = useDropToReorderUserMembership( - () => fractionalIndex(null, user.memberships[0].index) + () => fractionalIndex(null, user.documentMemberships[0].index) ); React.useEffect(() => { @@ -47,7 +49,7 @@ function SharedWithMe() { // TODO if ( - !user.memberships.length && + !user.documentMemberships.length && !groupMemberships.orderedData.filter((m) => m.documentId).length ) { return null; @@ -65,19 +67,24 @@ function SharedWithMe() { position="top" /> )} - {groupMemberships.orderedData.map((membership) => ( - + {user.groupsWithDocumentMemberships.map((group) => ( + + } /> + + {groupMemberships.orderedData.map((membership) => ( + + ))} + + ))} - {user.memberships + {user.documentMemberships .slice(0, page * Pagination.sidebarLimit) .map((membership) => ( - + ))} {!end && ( { - if ( - userMembership.documentId === ui.activeDocumentId && - locationStateStarred - ) { + if (membership.documentId === ui.activeDocumentId && locationStateStarred) { setExpanded(true); } - }, [userMembership.documentId, ui.activeDocumentId, locationStateStarred]); + }, [membership.documentId, ui.activeDocumentId, locationStateStarred]); React.useEffect(() => { if (documentId) { @@ -62,10 +60,10 @@ function SharedWithMeLink({ userMembership }: Props) { }, [documentId, documents]); React.useEffect(() => { - if (isActiveDocument && userMembership.documentId) { - void fetchChildDocuments(userMembership.documentId); + if (isActiveDocument && membership.documentId) { + void fetchChildDocuments(membership.documentId); } - }, [fetchChildDocuments, isActiveDocument, userMembership.documentId]); + }, [fetchChildDocuments, isActiveDocument, membership.documentId]); const handleDisclosureClick = React.useCallback( (ev: React.MouseEvent) => { @@ -76,16 +74,13 @@ function SharedWithMeLink({ userMembership }: Props) { [] ); - const { icon } = useSidebarLabelAndIcon(userMembership); - const [{ isDragging }, draggableRef] = useDragMembership(userMembership); + const { icon } = useSidebarLabelAndIcon(membership); + const [{ isDragging }, draggableRef] = useDragMembership(membership); const getIndex = () => { - if (userMembership instanceof UserMembership) { - const next = userMembership?.next(); - return fractionalIndex( - userMembership?.index || null, - next?.index || null - ); + if (membership instanceof UserMembership) { + const next = membership?.next(); + return fractionalIndex(membership?.index || null, next?.index || null); } return ""; }; @@ -116,12 +111,12 @@ function SharedWithMeLink({ userMembership }: Props) { return ( <> string) { drop: async (item: DragObject) => { const userMembership = userMemberships.get(item.id); void userMembership?.save({ - index: getIndex?.() ?? fractionalIndex(null, user.memberships[0].index), + index: + getIndex?.() ?? + fractionalIndex(null, user.documentMemberships[0].index), }); }, collect: (monitor) => ({ diff --git a/app/models/User.ts b/app/models/User.ts index e3cb121bdcd7..2f6b3e9fc761 100644 --- a/app/models/User.ts +++ b/app/models/User.ts @@ -13,6 +13,7 @@ import { import type { NotificationSettings } from "@shared/types"; import { client } from "~/utils/ApiClient"; import Document from "./Document"; +import Group from "./Group"; import UserMembership from "./UserMembership"; import ParanoidModel from "./base/ParanoidModel"; import Field from "./decorators/Field"; @@ -134,7 +135,7 @@ class User extends ParanoidModel { * @returns A list of user memberships */ @computed - get memberships(): UserMembership[] { + get documentMemberships(): UserMembership[] { const { userMemberships, documents, policies } = this.store.rootStore; return userMemberships.orderedData .filter( @@ -149,6 +150,21 @@ class User extends ParanoidModel { }); } + @computed + get groupsWithDocumentMemberships() { + const { groups, groupUsers, groupMemberships } = this.store.rootStore; + + return groupUsers.orderedData + .filter((groupUser) => groupUser.userId === this.id) + .map((groupUser) => groups.get(groupUser.groupId)) + .filter(Boolean) + .filter((group) => + groupMemberships.orderedData.some( + (groupMembership) => groupMembership.groupId === group?.id + ) + ) as Group[]; + } + /** * Returns the current preference for the given notification event type taking * into account the default system value. diff --git a/app/scenes/Collection/index.tsx b/app/scenes/Collection/index.tsx index 095ebc045a63..16576102d848 100644 --- a/app/scenes/Collection/index.tsx +++ b/app/scenes/Collection/index.tsx @@ -113,10 +113,7 @@ function CollectionScene() { void fetchData(); }, [collections, isFetching, collection, error, id, can]); - useCommandBarActions( - [editCollection], - ui.activeCollectionId ? [ui.activeCollectionId] : undefined - ); + useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]); if (!collection && error) { return ; diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index 6e7db71b1899..92246708c761 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -208,6 +208,7 @@ export default class AuthStore extends Store { this.add(data.team); this.rootStore.users.add(data.user); data.groups.map(this.rootStore.groups.add); + data.groupUsers.map(this.rootStore.groupUsers.add); this.currentUserId = data.user.id; this.currentTeamId = data.team.id; diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index 2bfa025f1ab5..3acc314f51d5 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -225,8 +225,8 @@ export default class WebsocketsProcessor { return socketio .to( collection.permission - ? `collection-${event.collectionId}` - : `team-${collection.teamId}` + ? `team-${collection.teamId}` + : `collection-${event.collectionId}` ) .emit(event.name, await presentCollection(undefined, collection)); } diff --git a/server/routes/api/auth/auth.ts b/server/routes/api/auth/auth.ts index ebf035edf12d..d19a8ba71dce 100644 --- a/server/routes/api/auth/auth.ts +++ b/server/routes/api/auth/auth.ts @@ -15,6 +15,7 @@ import { presentProviderConfig, presentAvailableTeam, presentGroup, + presentGroupUser, } from "@server/presenters"; import ValidateSSOAccessTask from "@server/queues/tasks/ValidateSSOAccessTask"; import { APIContext } from "@server/types"; @@ -144,6 +145,7 @@ router.post("auth.info", auth(), async (ctx: APIContext) => { }), team: presentTeam(team), groups: await Promise.all(groups.map(presentGroup)), + groupUsers: groups.map((group) => presentGroupUser(group.groupUsers[0])), collaborationToken: user.getCollaborationToken(), availableTeams: uniqBy([...signedInTeams, ...availableTeams], "id").map( (availableTeam) => diff --git a/server/routes/api/groups/groups.ts b/server/routes/api/groups/groups.ts index ab4da6b2fc2b..01ebaf2b6bbf 100644 --- a/server/routes/api/groups/groups.ts +++ b/server/routes/api/groups/groups.ts @@ -61,6 +61,7 @@ router.post( pagination: ctx.state.pagination, data: { groups: await Promise.all(groups.map(presentGroup)), + // TODO: Deprecated, will remove in the future as language conflicts with GroupMembership groupMemberships: ( await Promise.all( groups.map((group) => From 2b057ae6531bbb810b533b63a9a6d3c6390a8333 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 29 Aug 2024 15:09:29 -0400 Subject: [PATCH 31/51] Add 'Has access through parent' --- .../Sharing/Document/DocumentMemberList.tsx | 117 +++++++++++------- app/models/GroupMembership.ts | 7 ++ server/presenters/groupMembership.ts | 1 + 3 files changed, 78 insertions(+), 47 deletions(-) diff --git a/app/components/Sharing/Document/DocumentMemberList.tsx b/app/components/Sharing/Document/DocumentMemberList.tsx index 6602bab82159..68890a28d4fe 100644 --- a/app/components/Sharing/Document/DocumentMemberList.tsx +++ b/app/components/Sharing/Document/DocumentMemberList.tsx @@ -2,11 +2,12 @@ import orderBy from "lodash/orderBy"; import { observer } from "mobx-react"; import { GroupIcon } from "outline-icons"; import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { useHistory } from "react-router-dom"; +import { useTranslation, Trans } from "react-i18next"; +import { Link, useHistory } from "react-router-dom"; import { toast } from "sonner"; -import { useTheme } from "styled-components"; +import styled, { useTheme } from "styled-components"; import Squircle from "@shared/components/Squircle"; +import { s } from "@shared/styles"; import { DocumentPermission } from "@shared/types"; import Document from "~/models/Document"; import UserMembership from "~/models/UserMembership"; @@ -129,50 +130,67 @@ function DocumentMembersList({ document, invitedInSession }: Props) { (invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name ).localeCompare(b.group.name) ) - .map((membership) => ( - - - - } - title={membership.group.name} - subtitle={t("{{ count }} member", { - count: membership.group.memberCount, - })} - actions={ - can.manageUsers ? ( -
    - { - if (permission === EmptySelectValue) { - await groupMemberships.delete({ - documentId: document.id, - groupId: membership.groupId, - }); - } else { - await groupMemberships.create({ - documentId: document.id, - groupId: membership.groupId, - permission, - }); - } - }} - disabled={!can.update} - value={membership.permission} - labelHidden - nude - /> -
    - ) : null - } - /> - ))} + .map((membership) => { + const MaybeLink = membership?.source ? StyledLink : React.Fragment; + return ( + + + + } + title={membership.group.name} + subtitle={ + membership.sourceId ? ( + + Has access through{" "} + + parent + + + ) : ( + t("{{ count }} member", { + count: membership.group.memberCount, + }) + ) + } + actions={ + can.manageUsers ? ( +
    + { + if (permission === EmptySelectValue) { + await groupMemberships.delete({ + documentId: document.id, + groupId: membership.groupId, + }); + } else { + await groupMemberships.create({ + documentId: document.id, + groupId: membership.groupId, + permission, + }); + } + }} + disabled={!can.update} + value={membership.permission} + labelHidden + nude + /> +
    + ) : null + } + /> + ); + })} {members.map((item) => ( Collection, { onDelete: "cascade" }) collection: Collection | undefined; + /** The source ID points to the root membership from which this inherits */ + sourceId?: string; + + /** The source points to the root membership from which this inherits */ + @Relation(() => GroupMembership, { onDelete: "cascade" }) + source?: GroupMembership; + /** The permission level granted to the group. */ @observable permission: CollectionPermission | DocumentPermission; diff --git a/server/presenters/groupMembership.ts b/server/presenters/groupMembership.ts index 8e6d73e53bb5..7f42f3404d44 100644 --- a/server/presenters/groupMembership.ts +++ b/server/presenters/groupMembership.ts @@ -7,5 +7,6 @@ export default function presentGroupMembership(membership: GroupMembership) { documentId: membership.documentId, collectionId: membership.collectionId, permission: membership.permission, + sourceId: membership.sourceId, }; } From 9c474aefa0f4ee2b99b063504ab8dea0a2ca6f7b Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 29 Aug 2024 15:25:15 -0400 Subject: [PATCH 32/51] Group.documentMemberships --- .../Sidebar/components/SharedWithMe.tsx | 2 +- app/models/Group.ts | 34 ++++++++++++++++++- app/models/User.ts | 2 +- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/components/Sidebar/components/SharedWithMe.tsx b/app/components/Sidebar/components/SharedWithMe.tsx index 17063ccefd21..156d40f16f0d 100644 --- a/app/components/Sidebar/components/SharedWithMe.tsx +++ b/app/components/Sidebar/components/SharedWithMe.tsx @@ -71,7 +71,7 @@ function SharedWithMe() { } /> - {groupMemberships.orderedData.map((membership) => ( + {group.documentMemberships.map((membership) => ( + groupUsers.orderedData.some( + (groupUser) => + groupUser.groupId === groupMembership.groupId && + groupUser.userId === auth.user?.id + ) + ) + .filter( + (m) => m.groupId === this.id && m.sourceId === null && m.documentId + ) + .filter((m) => { + const document = documents.get(m.documentId!); + const policy = document?.collectionId + ? policies.get(document.collectionId) + : undefined; + return !policy?.abilities?.readDocument && !document?.isDeleted; + }); + } } export default Group; diff --git a/app/models/User.ts b/app/models/User.ts index 2f6b3e9fc761..22a29f95275b 100644 --- a/app/models/User.ts +++ b/app/models/User.ts @@ -129,7 +129,7 @@ class User extends ParanoidModel { } /** - * Returns the direct user memberships that this user has to other documents. Documents that the + * Returns the direct memberships that this user has to documents. Documents that the * user already has access to through a collection and trashed documents are not included. * * @returns A list of user memberships From 3adee3df54eba49c974696c51781f2e06d727451 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 29 Aug 2024 15:40:02 -0400 Subject: [PATCH 33/51] fix: Empty 'Shared with me' section --- app/components/Sidebar/components/SharedWithMe.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/Sidebar/components/SharedWithMe.tsx b/app/components/Sidebar/components/SharedWithMe.tsx index 156d40f16f0d..3b1fab86a89a 100644 --- a/app/components/Sidebar/components/SharedWithMe.tsx +++ b/app/components/Sidebar/components/SharedWithMe.tsx @@ -50,7 +50,7 @@ function SharedWithMe() { // TODO if ( !user.documentMemberships.length && - !groupMemberships.orderedData.filter((m) => m.documentId).length + !user.groupsWithDocumentMemberships.length ) { return null; } From 8bdc225c579f59db7fcb09226bec7aa46f019332 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 29 Aug 2024 16:24:47 -0400 Subject: [PATCH 34/51] fix: documents.remove_group event --- server/queues/processors/WebsocketsProcessor.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index 1b3edf0ecd85..d205335778e6 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -172,7 +172,7 @@ export default class WebsocketsProcessor { case "documents.remove_group": { const [document, group] = await Promise.all([ Document.findByPk(event.documentId), - Group.findByPk(event.data.membershipId), + Group.findByPk(event.modelId), ]); if (!document || !group) { return; @@ -180,7 +180,7 @@ export default class WebsocketsProcessor { const channels = await this.getDocumentEventChannels(event, document); socketio.to([...channels, `group-${event.modelId}`]).emit(event.name, { - id: event.modelId, + id: event.data.membershipId, groupId: event.modelId, documentId: event.documentId, }); @@ -319,17 +319,15 @@ export default class WebsocketsProcessor { } case "collections.remove_group": { - const membership = { - groupId: event.modelId, - collectionId: event.collectionId, - id: event.data.membershipId, - }; - // let everyone with access to the collection know a group was removed // this includes those in the the group itself socketio .to(`collection-${event.collectionId}`) - .emit("collections.remove_group", membership); + .emit("collections.remove_group", { + groupId: event.modelId, + collectionId: event.collectionId, + id: event.data.membershipId, + }); await GroupUser.findAllInBatches( { From aa5aaf756495e5a19e2d6c43b11f2a20c3fcbfb9 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 30 Aug 2024 10:39:01 -0400 Subject: [PATCH 35/51] GroupLink --- .../Sidebar/components/CollectionLink.tsx | 114 ++++++++---------- .../Sidebar/components/GroupLink.tsx | 50 ++++++++ .../Sidebar/components/SharedWithMe.tsx | 16 +-- 3 files changed, 104 insertions(+), 76 deletions(-) create mode 100644 app/components/Sidebar/components/GroupLink.tsx diff --git a/app/components/Sidebar/components/CollectionLink.tsx b/app/components/Sidebar/components/CollectionLink.tsx index 9b1df54423be..5e5ca62b95b0 100644 --- a/app/components/Sidebar/components/CollectionLink.tsx +++ b/app/components/Sidebar/components/CollectionLink.tsx @@ -116,10 +116,6 @@ const CollectionLink: React.FC = ({ }), }); - const handleTitleEditing = React.useCallback((value: boolean) => { - setIsEditing(value); - }, []); - const handlePrefetch = React.useCallback(() => { void collection.fetchDocuments(); }, [collection]); @@ -130,64 +126,58 @@ const CollectionLink: React.FC = ({ }); return ( - <> - - - - } - showActions={menuOpen} - isActiveDrop={isOver && canDrop} - isActive={(match, location: Location<{ starred?: boolean }>) => - !!match && location.state?.starred === inStarredSection - } - label={ - - } - exact={false} - depth={0} - menu={ - !isEditing && - !isDraggingAnyCollection && ( - - - - - - editableTitleRef.current?.setIsEditing(true) - } - onOpen={handleMenuOpen} - onClose={handleMenuClose} - /> - - ) - } - /> - - - + + + } + showActions={menuOpen} + isActiveDrop={isOver && canDrop} + isActive={(match, location: Location<{ starred?: boolean }>) => + !!match && location.state?.starred === inStarredSection + } + label={ + + } + exact={false} + depth={0} + menu={ + !isEditing && + !isDraggingAnyCollection && ( + + + + + editableTitleRef.current?.setIsEditing(true)} + onOpen={handleMenuOpen} + onClose={handleMenuClose} + /> + + ) + } + /> + + ); }; diff --git a/app/components/Sidebar/components/GroupLink.tsx b/app/components/Sidebar/components/GroupLink.tsx new file mode 100644 index 000000000000..6079723971d3 --- /dev/null +++ b/app/components/Sidebar/components/GroupLink.tsx @@ -0,0 +1,50 @@ +import { observer } from "mobx-react"; +import { GroupIcon } from "outline-icons"; +import * as React from "react"; +import Group from "~/models/Group"; +import Folder from "./Folder"; +import Relative from "./Relative"; +import SharedWithMeLink from "./SharedWithMeLink"; +import SidebarLink from "./SidebarLink"; + +type Props = { + /** The group to render */ + group: Group; +}; + +const GroupLink: React.FC = ({ group }) => { + const [expanded, setExpanded] = React.useState(false); + + const handleDisclosureClick = React.useCallback((ev) => { + ev?.preventDefault(); + setExpanded((e) => !e); + }, []); + + const handlePrefetch = React.useCallback(() => { + // TODO: prefetch group memberships + }, [group]); + + return ( + + } + expanded={expanded} + onClickIntent={handlePrefetch} + onClick={handleDisclosureClick} + depth={0} + /> + + {group.documentMemberships.map((membership) => ( + + ))} + + + ); +}; + +export default observer(GroupLink); diff --git a/app/components/Sidebar/components/SharedWithMe.tsx b/app/components/Sidebar/components/SharedWithMe.tsx index 3b1fab86a89a..24e396c206b1 100644 --- a/app/components/Sidebar/components/SharedWithMe.tsx +++ b/app/components/Sidebar/components/SharedWithMe.tsx @@ -1,6 +1,5 @@ import fractionalIndex from "fractional-index"; import { observer } from "mobx-react"; -import { GroupIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -13,7 +12,7 @@ import useCurrentUser from "~/hooks/useCurrentUser"; import usePaginatedRequest from "~/hooks/usePaginatedRequest"; import useStores from "~/hooks/useStores"; import DropCursor from "./DropCursor"; -import Folder from "./Folder"; +import GroupLink from "./GroupLink"; import Header from "./Header"; import PlaceholderCollections from "./PlaceholderCollections"; import Relative from "./Relative"; @@ -68,18 +67,7 @@ function SharedWithMe() { /> )} {user.groupsWithDocumentMemberships.map((group) => ( - - } /> - - {group.documentMemberships.map((membership) => ( - - ))} - - + ))} {user.documentMemberships .slice(0, page * Pagination.sidebarLimit) From 8c792d8aa4ce901c9d361346d48277b708576296 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 30 Aug 2024 10:40:27 -0400 Subject: [PATCH 36/51] PrivateCollectionIcon --- app/components/Icons/CollectionIcon.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/components/Icons/CollectionIcon.tsx b/app/components/Icons/CollectionIcon.tsx index 4b00c2c380c3..14cf595d5895 100644 --- a/app/components/Icons/CollectionIcon.tsx +++ b/app/components/Icons/CollectionIcon.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react"; -import { CollectionIcon } from "outline-icons"; +import { CollectionIcon, PrivateCollectionIcon } from "outline-icons"; import { getLuminance } from "polished"; import * as React from "react"; import { colorPalette } from "@shared/utils/collections"; @@ -40,8 +40,11 @@ function ResolvedCollectionIcon({ : "currentColor" : collectionColor); + const Component = collection.isPrivate + ? PrivateCollectionIcon + : CollectionIcon; return ( - Date: Fri, 30 Aug 2024 11:09:43 -0400 Subject: [PATCH 37/51] GroupAvatar --- app/components/Avatar/AvatarWithPresence.tsx | 2 +- app/components/Avatar/GroupAvatar.tsx | 35 +++++++++++++++++++ app/components/Avatar/index.ts | 7 ++-- app/components/Collaborators.tsx | 2 +- app/components/DocumentViews.tsx | 2 +- app/components/EventListItem.tsx | 2 +- app/components/Facepile.tsx | 3 +- .../HoverPreview/HoverPreviewIssue.tsx | 2 +- .../HoverPreview/HoverPreviewMention.tsx | 3 +- .../HoverPreview/HoverPreviewPullRequest.tsx | 2 +- .../Notifications/NotificationListItem.tsx | 3 +- .../Sharing/Collection/AccessControlList.tsx | 11 +++--- .../Sharing/Collection/SharePopover.tsx | 2 +- .../Sharing/Document/AccessControlList.tsx | 3 +- .../Sharing/Document/DocumentMemberList.tsx | 11 +++--- .../Document/DocumentMemberListItem.tsx | 3 +- .../Sharing/Document/PublicAccess.tsx | 2 +- .../Sharing/Document/SharePopover.tsx | 7 ++-- .../Sharing/components/Placeholder.tsx | 2 +- .../Sharing/components/Suggestions.tsx | 15 +++----- app/components/Sidebar/Sidebar.tsx | 2 +- app/components/TeamLogo.ts | 2 +- .../TemplatizeDialog/SelectLocation.tsx | 2 +- app/editor/components/MentionMenu.tsx | 3 +- .../components/MembershipPreview.tsx | 3 +- .../Document/components/CommentForm.tsx | 2 +- .../Document/components/CommentThread.tsx | 2 +- .../Document/components/CommentThreadItem.tsx | 2 +- app/scenes/Document/components/Insights.tsx | 2 +- app/scenes/GroupMembers/AddPeopleToGroup.tsx | 3 +- .../components/GroupMemberListItem.tsx | 2 +- app/scenes/Search/components/UserFilter.tsx | 3 +- app/scenes/Settings/components/ImageInput.tsx | 2 +- .../Settings/components/PeopleTable.tsx | 2 +- .../Settings/components/SharesTable.tsx | 2 +- plugins/github/client/Settings.tsx | 2 +- 36 files changed, 87 insertions(+), 68 deletions(-) create mode 100644 app/components/Avatar/GroupAvatar.tsx diff --git a/app/components/Avatar/AvatarWithPresence.tsx b/app/components/Avatar/AvatarWithPresence.tsx index ade894f0a0a9..106c1d6ce884 100644 --- a/app/components/Avatar/AvatarWithPresence.tsx +++ b/app/components/Avatar/AvatarWithPresence.tsx @@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next"; import styled, { css } from "styled-components"; import { s } from "@shared/styles"; import User from "~/models/User"; -import Avatar from "~/components/Avatar"; import Tooltip from "~/components/Tooltip"; +import Avatar from "./Avatar"; type Props = { user: User; diff --git a/app/components/Avatar/GroupAvatar.tsx b/app/components/Avatar/GroupAvatar.tsx new file mode 100644 index 000000000000..f179aef32403 --- /dev/null +++ b/app/components/Avatar/GroupAvatar.tsx @@ -0,0 +1,35 @@ +import { GroupIcon } from "outline-icons"; +import * as React from "react"; +import { useTheme } from "styled-components"; +import Squircle from "@shared/components/Squircle"; +import Group from "~/models/Group"; +import { AvatarSize } from "../Avatar"; + +type Props = { + /** The group to show an avatar for */ + group: Group; + /** The size of the icon, 24px is default to match standard avatars */ + size?: number; + /** The color of the avatar */ + color?: string; + /** The background color of the avatar */ + backgroundColor?: string; + className?: string; +}; + +export function GroupAvatar({ + color, + backgroundColor, + size = AvatarSize.Medium, + className, +}: Props) { + const theme = useTheme(); + return ( + + + + ); +} diff --git a/app/components/Avatar/index.ts b/app/components/Avatar/index.ts index 4dc0cb18b797..be77236300e5 100644 --- a/app/components/Avatar/index.ts +++ b/app/components/Avatar/index.ts @@ -1,6 +1,7 @@ -import Avatar from "./Avatar"; +import Avatar, { IAvatar, AvatarSize } from "./Avatar"; import AvatarWithPresence from "./AvatarWithPresence"; +import { GroupAvatar } from "./GroupAvatar"; -export { AvatarWithPresence }; +export { Avatar, GroupAvatar, AvatarSize, AvatarWithPresence }; -export default Avatar; +export type { IAvatar }; diff --git a/app/components/Collaborators.tsx b/app/components/Collaborators.tsx index aca9ace1bc31..0442410a4ec5 100644 --- a/app/components/Collaborators.tsx +++ b/app/components/Collaborators.tsx @@ -7,7 +7,7 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { usePopoverState, PopoverDisclosure } from "reakit/Popover"; import Document from "~/models/Document"; -import AvatarWithPresence from "~/components/Avatar/AvatarWithPresence"; +import { AvatarWithPresence } from "~/components/Avatar"; import DocumentViews from "~/components/DocumentViews"; import Facepile from "~/components/Facepile"; import NudeButton from "~/components/NudeButton"; diff --git a/app/components/DocumentViews.tsx b/app/components/DocumentViews.tsx index 8d1e0f28dae7..e2ca6c0e09c3 100644 --- a/app/components/DocumentViews.tsx +++ b/app/components/DocumentViews.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import { dateLocale, dateToRelative } from "@shared/utils/date"; import Document from "~/models/Document"; import User from "~/models/User"; -import Avatar from "~/components/Avatar"; +import { Avatar } from "~/components/Avatar"; import ListItem from "~/components/List/Item"; import PaginatedList from "~/components/PaginatedList"; import useCurrentUser from "~/hooks/useCurrentUser"; diff --git a/app/components/EventListItem.tsx b/app/components/EventListItem.tsx index 4c441da992d8..6f7bbe76cb72 100644 --- a/app/components/EventListItem.tsx +++ b/app/components/EventListItem.tsx @@ -16,7 +16,7 @@ import EventBoundary from "@shared/components/EventBoundary"; import { s } from "@shared/styles"; import Document from "~/models/Document"; import Event from "~/models/Event"; -import Avatar from "~/components/Avatar"; +import { Avatar } from "~/components/Avatar"; import Item, { Actions, Props as ItemProps } from "~/components/List/Item"; import Time from "~/components/Time"; import useStores from "~/hooks/useStores"; diff --git a/app/components/Facepile.tsx b/app/components/Facepile.tsx index 0073d194dd8b..914fa7e9a2ca 100644 --- a/app/components/Facepile.tsx +++ b/app/components/Facepile.tsx @@ -3,9 +3,8 @@ import * as React from "react"; import styled from "styled-components"; import { s } from "@shared/styles"; import User from "~/models/User"; -import Avatar from "~/components/Avatar"; +import { Avatar, AvatarSize } from "~/components/Avatar"; import Flex from "~/components/Flex"; -import { AvatarSize } from "./Avatar/Avatar"; type Props = { users: User[]; diff --git a/app/components/HoverPreview/HoverPreviewIssue.tsx b/app/components/HoverPreview/HoverPreviewIssue.tsx index b63801d6ad6c..032328ffa0be 100644 --- a/app/components/HoverPreview/HoverPreviewIssue.tsx +++ b/app/components/HoverPreview/HoverPreviewIssue.tsx @@ -1,8 +1,8 @@ import * as React from "react"; import { Trans } from "react-i18next"; import { UnfurlResourceType, UnfurlResponse } from "@shared/types"; +import { Avatar } from "~/components/Avatar"; import Flex from "~/components/Flex"; -import Avatar from "../Avatar"; import { IssueStatusIcon } from "../Icons/IssueStatusIcon"; import Text from "../Text"; import Time from "../Time"; diff --git a/app/components/HoverPreview/HoverPreviewMention.tsx b/app/components/HoverPreview/HoverPreviewMention.tsx index 973b0df7061c..322d50cceade 100644 --- a/app/components/HoverPreview/HoverPreviewMention.tsx +++ b/app/components/HoverPreview/HoverPreviewMention.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { UnfurlResourceType, UnfurlResponse } from "@shared/types"; -import Avatar from "~/components/Avatar"; -import { AvatarSize } from "~/components/Avatar/Avatar"; +import { Avatar, AvatarSize } from "~/components/Avatar"; import Flex from "~/components/Flex"; import { Preview, Title, Info, Card, CardContent } from "./Components"; diff --git a/app/components/HoverPreview/HoverPreviewPullRequest.tsx b/app/components/HoverPreview/HoverPreviewPullRequest.tsx index 47ef95cc7b16..6393695134d8 100644 --- a/app/components/HoverPreview/HoverPreviewPullRequest.tsx +++ b/app/components/HoverPreview/HoverPreviewPullRequest.tsx @@ -1,8 +1,8 @@ import * as React from "react"; import { Trans } from "react-i18next"; import { UnfurlResourceType, UnfurlResponse } from "@shared/types"; +import { Avatar } from "~/components/Avatar"; import Flex from "~/components/Flex"; -import Avatar from "../Avatar"; import { PullRequestIcon } from "../Icons/PullRequestIcon"; import Text from "../Text"; import Time from "../Time"; diff --git a/app/components/Notifications/NotificationListItem.tsx b/app/components/Notifications/NotificationListItem.tsx index 2e3b86b14647..c5cc268c2912 100644 --- a/app/components/Notifications/NotificationListItem.tsx +++ b/app/components/Notifications/NotificationListItem.tsx @@ -9,8 +9,7 @@ import Notification from "~/models/Notification"; import CommentEditor from "~/scenes/Document/components/CommentEditor"; import useStores from "~/hooks/useStores"; import { hover, truncateMultiline } from "~/styles"; -import Avatar from "../Avatar"; -import { AvatarSize } from "../Avatar/Avatar"; +import { Avatar, AvatarSize } from "../Avatar"; import Flex from "../Flex"; import Text from "../Text"; import Time from "../Time"; diff --git a/app/components/Sharing/Collection/AccessControlList.tsx b/app/components/Sharing/Collection/AccessControlList.tsx index c1a444b88ab0..66214b542f60 100644 --- a/app/components/Sharing/Collection/AccessControlList.tsx +++ b/app/components/Sharing/Collection/AccessControlList.tsx @@ -1,12 +1,12 @@ import { observer } from "mobx-react"; -import { GroupIcon, UserIcon } from "outline-icons"; +import { UserIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import styled, { useTheme } from "styled-components"; import Squircle from "@shared/components/Squircle"; import { CollectionPermission } from "@shared/types"; import Collection from "~/models/Collection"; -import Avatar, { AvatarSize } from "~/components/Avatar/Avatar"; +import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar"; import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect"; import InputSelectPermission from "~/components/InputSelectPermission"; import Scrollable from "~/components/Scrollable"; @@ -147,9 +147,10 @@ export const AccessControlList = observer( - - + } title={membership.group.name} subtitle={t("{{ count }} member", { diff --git a/app/components/Sharing/Collection/SharePopover.tsx b/app/components/Sharing/Collection/SharePopover.tsx index 78e388716931..0d76743dcf1d 100644 --- a/app/components/Sharing/Collection/SharePopover.tsx +++ b/app/components/Sharing/Collection/SharePopover.tsx @@ -9,7 +9,7 @@ import { CollectionPermission } from "@shared/types"; import Collection from "~/models/Collection"; import Group from "~/models/Group"; import User from "~/models/User"; -import Avatar, { AvatarSize } from "~/components/Avatar/Avatar"; +import { Avatar, AvatarSize } from "~/components/Avatar"; import NudeButton from "~/components/NudeButton"; import { createAction } from "~/actions"; import { UserSection } from "~/actions/sections"; diff --git a/app/components/Sharing/Document/AccessControlList.tsx b/app/components/Sharing/Document/AccessControlList.tsx index 40fafefda1d2..2dec49efef88 100644 --- a/app/components/Sharing/Document/AccessControlList.tsx +++ b/app/components/Sharing/Document/AccessControlList.tsx @@ -19,8 +19,7 @@ import useMaxHeight from "~/hooks/useMaxHeight"; import usePolicy from "~/hooks/usePolicy"; import useRequest from "~/hooks/useRequest"; import useStores from "~/hooks/useStores"; -import Avatar from "../../Avatar"; -import { AvatarSize } from "../../Avatar/Avatar"; +import { Avatar, AvatarSize } from "../../Avatar"; import CollectionIcon from "../../Icons/CollectionIcon"; import Tooltip from "../../Tooltip"; import { Separator } from "../components"; diff --git a/app/components/Sharing/Document/DocumentMemberList.tsx b/app/components/Sharing/Document/DocumentMemberList.tsx index 68890a28d4fe..a1428a86918a 100644 --- a/app/components/Sharing/Document/DocumentMemberList.tsx +++ b/app/components/Sharing/Document/DocumentMemberList.tsx @@ -1,17 +1,15 @@ import orderBy from "lodash/orderBy"; import { observer } from "mobx-react"; -import { GroupIcon } from "outline-icons"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; import { Link, useHistory } from "react-router-dom"; import { toast } from "sonner"; import styled, { useTheme } from "styled-components"; -import Squircle from "@shared/components/Squircle"; import { s } from "@shared/styles"; import { DocumentPermission } from "@shared/types"; import Document from "~/models/Document"; import UserMembership from "~/models/UserMembership"; -import { AvatarSize } from "~/components/Avatar/Avatar"; +import { GroupAvatar } from "~/components/Avatar"; import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect"; import useCurrentUser from "~/hooks/useCurrentUser"; import usePolicy from "~/hooks/usePolicy"; @@ -136,9 +134,10 @@ function DocumentMembersList({ document, invitedInSession }: Props) { - - + } title={membership.group.name} subtitle={ diff --git a/app/components/Sharing/Document/DocumentMemberListItem.tsx b/app/components/Sharing/Document/DocumentMemberListItem.tsx index 02d81baadffc..ccfd1ba2ef90 100644 --- a/app/components/Sharing/Document/DocumentMemberListItem.tsx +++ b/app/components/Sharing/Document/DocumentMemberListItem.tsx @@ -7,8 +7,7 @@ import { s } from "@shared/styles"; import { DocumentPermission } from "@shared/types"; import User from "~/models/User"; import UserMembership from "~/models/UserMembership"; -import Avatar from "~/components/Avatar"; -import { AvatarSize } from "~/components/Avatar/Avatar"; +import { Avatar, AvatarSize } from "~/components/Avatar"; import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect"; import { EmptySelectValue, Permission } from "~/types"; import { ListItem } from "../components/ListItem"; diff --git a/app/components/Sharing/Document/PublicAccess.tsx b/app/components/Sharing/Document/PublicAccess.tsx index c9628b754714..ad4eb21b47d6 100644 --- a/app/components/Sharing/Document/PublicAccess.tsx +++ b/app/components/Sharing/Document/PublicAccess.tsx @@ -18,7 +18,7 @@ import Switch from "~/components/Switch"; import env from "~/env"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; -import { AvatarSize } from "../../Avatar/Avatar"; +import { AvatarSize } from "../../Avatar"; import CopyToClipboard from "../../CopyToClipboard"; import NudeButton from "../../NudeButton"; import { ResizingHeightContainer } from "../../ResizingHeightContainer"; diff --git a/app/components/Sharing/Document/SharePopover.tsx b/app/components/Sharing/Document/SharePopover.tsx index b00cfee660e7..6ab0f6262eb3 100644 --- a/app/components/Sharing/Document/SharePopover.tsx +++ b/app/components/Sharing/Document/SharePopover.tsx @@ -1,7 +1,7 @@ import { isEmail } from "class-validator"; import { m } from "framer-motion"; import { observer } from "mobx-react"; -import { BackIcon, GroupIcon } from "outline-icons"; +import { BackIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -10,8 +10,7 @@ import Document from "~/models/Document"; import Group from "~/models/Group"; import Share from "~/models/Share"; import User from "~/models/User"; -import Avatar from "~/components/Avatar"; -import { AvatarSize } from "~/components/Avatar/Avatar"; +import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar"; import NudeButton from "~/components/NudeButton"; import { createAction } from "~/actions"; import { UserSection } from "~/actions/sections"; @@ -207,7 +206,7 @@ function SharePopover({ userName: group.name, }), { - icon: , + icon: , } ); } else { diff --git a/app/components/Sharing/components/Placeholder.tsx b/app/components/Sharing/components/Placeholder.tsx index cd5b48255eb4..13f6685162e7 100644 --- a/app/components/Sharing/components/Placeholder.tsx +++ b/app/components/Sharing/components/Placeholder.tsx @@ -1,6 +1,6 @@ import times from "lodash/times"; import * as React from "react"; -import { AvatarSize } from "~/components/Avatar/Avatar"; +import { AvatarSize } from "~/components/Avatar"; import Fade from "~/components/Fade"; import PlaceholderText from "~/components/PlaceholderText"; import { ListItem } from "../components/ListItem"; diff --git a/app/components/Sharing/components/Suggestions.tsx b/app/components/Sharing/components/Suggestions.tsx index d0cefb419e37..69ae25e452ce 100644 --- a/app/components/Sharing/components/Suggestions.tsx +++ b/app/components/Sharing/components/Suggestions.tsx @@ -1,11 +1,10 @@ import { isEmail } from "class-validator"; import concat from "lodash/concat"; import { observer } from "mobx-react"; -import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons"; +import { CheckmarkIcon, CloseIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import styled, { useTheme } from "styled-components"; -import Squircle from "@shared/components/Squircle"; +import styled from "styled-components"; import { s } from "@shared/styles"; import { stringToColor } from "@shared/utils/color"; import Collection from "~/models/Collection"; @@ -13,8 +12,7 @@ import Document from "~/models/Document"; import Group from "~/models/Group"; import User from "~/models/User"; import ArrowKeyNavigation from "~/components/ArrowKeyNavigation"; -import Avatar from "~/components/Avatar"; -import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar"; +import { Avatar, GroupAvatar, AvatarSize, IAvatar } from "~/components/Avatar"; import Empty from "~/components/Empty"; import Placeholder from "~/components/List/Placeholder"; import Scrollable from "~/components/Scrollable"; @@ -63,7 +61,6 @@ export const Suggestions = observer( const { users, groups } = useStores(); const { t } = useTranslation(); const user = useCurrentUser(); - const theme = useTheme(); const containerRef = React.useRef(null); const { maxHeight } = useMaxHeight({ elementRef: containerRef, @@ -152,11 +149,7 @@ export const Suggestions = observer( subtitle: t("{{ count }} member", { count: suggestion.memberCount, }), - image: ( - - - - ), + image: , }; } return { diff --git a/app/components/Sidebar/Sidebar.tsx b/app/components/Sidebar/Sidebar.tsx index ae4de426e577..66becdd1f095 100644 --- a/app/components/Sidebar/Sidebar.tsx +++ b/app/components/Sidebar/Sidebar.tsx @@ -4,6 +4,7 @@ import { useLocation } from "react-router-dom"; import styled, { css, useTheme } from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { depths, s } from "@shared/styles"; +import { Avatar } from "~/components/Avatar"; import Flex from "~/components/Flex"; import useCurrentUser from "~/hooks/useCurrentUser"; import useMenuContext from "~/hooks/useMenuContext"; @@ -13,7 +14,6 @@ import AccountMenu from "~/menus/AccountMenu"; import { fadeOnDesktopBackgrounded } from "~/styles"; import { fadeIn } from "~/styles/animations"; import Desktop from "~/utils/Desktop"; -import Avatar from "../Avatar"; import NotificationIcon from "../Notifications/NotificationIcon"; import NotificationsPopover from "../Notifications/NotificationsPopover"; import ResizeBorder from "./components/ResizeBorder"; diff --git a/app/components/TeamLogo.ts b/app/components/TeamLogo.ts index 3c51c32dd975..baba4b34ac5a 100644 --- a/app/components/TeamLogo.ts +++ b/app/components/TeamLogo.ts @@ -1,6 +1,6 @@ import styled from "styled-components"; import { s } from "@shared/styles"; -import Avatar from "./Avatar"; +import { Avatar } from "./Avatar"; const TeamLogo = styled(Avatar)` border-radius: 4px; diff --git a/app/components/TemplatizeDialog/SelectLocation.tsx b/app/components/TemplatizeDialog/SelectLocation.tsx index b7efa39f1a33..25666aa751d8 100644 --- a/app/components/TemplatizeDialog/SelectLocation.tsx +++ b/app/components/TemplatizeDialog/SelectLocation.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import React from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { AvatarSize } from "~/components/Avatar/Avatar"; +import { AvatarSize } from "~/components/Avatar"; import CollectionIcon from "~/components/Icons/CollectionIcon"; import InputSelect, { Option } from "~/components/InputSelect"; import TeamLogo from "~/components/TeamLogo"; diff --git a/app/editor/components/MentionMenu.tsx b/app/editor/components/MentionMenu.tsx index dc90d77fd113..84319ad4031f 100644 --- a/app/editor/components/MentionMenu.tsx +++ b/app/editor/components/MentionMenu.tsx @@ -7,8 +7,7 @@ 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 { AvatarSize } from "~/components/Avatar/Avatar"; +import { Avatar, AvatarSize } from "~/components/Avatar"; import Flex from "~/components/Flex"; import useRequest from "~/hooks/useRequest"; import useStores from "~/hooks/useStores"; diff --git a/app/scenes/Collection/components/MembershipPreview.tsx b/app/scenes/Collection/components/MembershipPreview.tsx index af2ac490b737..ce288e6769df 100644 --- a/app/scenes/Collection/components/MembershipPreview.tsx +++ b/app/scenes/Collection/components/MembershipPreview.tsx @@ -4,8 +4,7 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { PAGINATION_SYMBOL } from "~/stores/base/Store"; import Collection from "~/models/Collection"; -import Avatar from "~/components/Avatar"; -import { AvatarSize } from "~/components/Avatar/Avatar"; +import { Avatar, AvatarSize } from "~/components/Avatar"; import Facepile from "~/components/Facepile"; import Fade from "~/components/Fade"; import NudeButton from "~/components/NudeButton"; diff --git a/app/scenes/Document/components/CommentForm.tsx b/app/scenes/Document/components/CommentForm.tsx index e1f82d6bfb88..de958150705b 100644 --- a/app/scenes/Document/components/CommentForm.tsx +++ b/app/scenes/Document/components/CommentForm.tsx @@ -12,7 +12,7 @@ import { ProsemirrorData } from "@shared/types"; import { getEventFiles } from "@shared/utils/files"; import { AttachmentValidation, CommentValidation } from "@shared/validations"; import Comment from "~/models/Comment"; -import Avatar from "~/components/Avatar"; +import { Avatar } from "~/components/Avatar"; import ButtonSmall from "~/components/ButtonSmall"; import { useDocumentContext } from "~/components/DocumentContext"; import Flex from "~/components/Flex"; diff --git a/app/scenes/Document/components/CommentThread.tsx b/app/scenes/Document/components/CommentThread.tsx index 4269e4b077e7..2f602cf60d8e 100644 --- a/app/scenes/Document/components/CommentThread.tsx +++ b/app/scenes/Document/components/CommentThread.tsx @@ -10,7 +10,7 @@ import { s } from "@shared/styles"; import { ProsemirrorData } from "@shared/types"; import Comment from "~/models/Comment"; import Document from "~/models/Document"; -import Avatar from "~/components/Avatar"; +import { Avatar } from "~/components/Avatar"; import { useDocumentContext } from "~/components/DocumentContext"; import Fade from "~/components/Fade"; import Flex from "~/components/Flex"; diff --git a/app/scenes/Document/components/CommentThreadItem.tsx b/app/scenes/Document/components/CommentThreadItem.tsx index ab332fefc9f7..5c463b680367 100644 --- a/app/scenes/Document/components/CommentThreadItem.tsx +++ b/app/scenes/Document/components/CommentThreadItem.tsx @@ -13,7 +13,7 @@ import { ProsemirrorData } from "@shared/types"; import { dateToRelative } from "@shared/utils/date"; import { Minute } from "@shared/utils/time"; import Comment from "~/models/Comment"; -import Avatar from "~/components/Avatar"; +import { Avatar } from "~/components/Avatar"; import ButtonSmall from "~/components/ButtonSmall"; import Flex from "~/components/Flex"; import Text from "~/components/Text"; diff --git a/app/scenes/Document/components/Insights.tsx b/app/scenes/Document/components/Insights.tsx index 156178030e83..c4703cfdc715 100644 --- a/app/scenes/Document/components/Insights.tsx +++ b/app/scenes/Document/components/Insights.tsx @@ -7,7 +7,7 @@ import styled from "styled-components"; import { s } from "@shared/styles"; import { stringToColor } from "@shared/utils/color"; import User from "~/models/User"; -import Avatar from "~/components/Avatar"; +import { Avatar } from "~/components/Avatar"; import { useDocumentContext } from "~/components/DocumentContext"; import DocumentViews from "~/components/DocumentViews"; import Flex from "~/components/Flex"; diff --git a/app/scenes/GroupMembers/AddPeopleToGroup.tsx b/app/scenes/GroupMembers/AddPeopleToGroup.tsx index 791a34c7c2c4..63b89454c7e5 100644 --- a/app/scenes/GroupMembers/AddPeopleToGroup.tsx +++ b/app/scenes/GroupMembers/AddPeopleToGroup.tsx @@ -6,8 +6,7 @@ import { toast } from "sonner"; import Group from "~/models/Group"; import User from "~/models/User"; import Invite from "~/scenes/Invite"; -import Avatar from "~/components/Avatar"; -import { AvatarSize } from "~/components/Avatar/Avatar"; +import { Avatar, AvatarSize } from "~/components/Avatar"; import ButtonLink from "~/components/ButtonLink"; import Empty from "~/components/Empty"; import Flex from "~/components/Flex"; diff --git a/app/scenes/GroupMembers/components/GroupMemberListItem.tsx b/app/scenes/GroupMembers/components/GroupMemberListItem.tsx index 3c7a1fad5e77..7fd27b100a2f 100644 --- a/app/scenes/GroupMembers/components/GroupMemberListItem.tsx +++ b/app/scenes/GroupMembers/components/GroupMemberListItem.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; import User from "~/models/User"; -import Avatar from "~/components/Avatar"; +import { Avatar } from "~/components/Avatar"; import Badge from "~/components/Badge"; import Button from "~/components/Button"; import Flex from "~/components/Flex"; diff --git a/app/scenes/Search/components/UserFilter.tsx b/app/scenes/Search/components/UserFilter.tsx index c816ae4de08b..de01be699a1b 100644 --- a/app/scenes/Search/components/UserFilter.tsx +++ b/app/scenes/Search/components/UserFilter.tsx @@ -3,8 +3,7 @@ import { UserIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; -import Avatar from "~/components/Avatar"; -import { AvatarSize } from "~/components/Avatar/Avatar"; +import { Avatar, AvatarSize } from "~/components/Avatar"; import FilterOptions from "~/components/FilterOptions"; import useStores from "~/hooks/useStores"; diff --git a/app/scenes/Settings/components/ImageInput.tsx b/app/scenes/Settings/components/ImageInput.tsx index 062a736eac06..9824a0176498 100644 --- a/app/scenes/Settings/components/ImageInput.tsx +++ b/app/scenes/Settings/components/ImageInput.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; import { s } from "@shared/styles"; -import Avatar, { AvatarSize, IAvatar } from "~/components/Avatar/Avatar"; +import { Avatar, AvatarSize, IAvatar } from "~/components/Avatar"; import Button from "~/components/Button"; import Flex from "~/components/Flex"; import ImageUpload, { Props as ImageUploadProps } from "./ImageUpload"; diff --git a/app/scenes/Settings/components/PeopleTable.tsx b/app/scenes/Settings/components/PeopleTable.tsx index 8eae6df7f903..476011ec225b 100644 --- a/app/scenes/Settings/components/PeopleTable.tsx +++ b/app/scenes/Settings/components/PeopleTable.tsx @@ -3,7 +3,7 @@ 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 { Avatar } from "~/components/Avatar"; import Badge from "~/components/Badge"; import Flex from "~/components/Flex"; import TableFromParams from "~/components/TableFromParams"; diff --git a/app/scenes/Settings/components/SharesTable.tsx b/app/scenes/Settings/components/SharesTable.tsx index 652bc1276636..d6efd7e3256a 100644 --- a/app/scenes/Settings/components/SharesTable.tsx +++ b/app/scenes/Settings/components/SharesTable.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { unicodeCLDRtoBCP47 } from "@shared/utils/date"; import Share from "~/models/Share"; -import Avatar from "~/components/Avatar"; +import { Avatar } from "~/components/Avatar"; import Flex from "~/components/Flex"; import TableFromParams from "~/components/TableFromParams"; import Time from "~/components/Time"; diff --git a/plugins/github/client/Settings.tsx b/plugins/github/client/Settings.tsx index 585efb5d65bf..f42c25c83712 100644 --- a/plugins/github/client/Settings.tsx +++ b/plugins/github/client/Settings.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; import { IntegrationService } from "@shared/types"; import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton"; -import { AvatarSize } from "~/components/Avatar/Avatar"; +import { AvatarSize } from "~/components/Avatar"; import Flex from "~/components/Flex"; import Heading from "~/components/Heading"; import List from "~/components/List"; From 5f0e00794aa1a579e17d325a7435403ba26b4d1e Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 30 Aug 2024 12:13:37 -0400 Subject: [PATCH 38/51] Prefetch group memberships --- app/components/Sidebar/components/GroupLink.tsx | 17 +++++++++++++++-- .../Sidebar/components/SharedWithMe.tsx | 1 - app/stores/GroupMembershipsStore.ts | 1 + .../api/groupMemberships/groupMemberships.ts | 2 ++ server/routes/api/groupMemberships/schema.ts | 6 +++++- 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/app/components/Sidebar/components/GroupLink.tsx b/app/components/Sidebar/components/GroupLink.tsx index 6079723971d3..0471ae8c6075 100644 --- a/app/components/Sidebar/components/GroupLink.tsx +++ b/app/components/Sidebar/components/GroupLink.tsx @@ -2,6 +2,7 @@ import { observer } from "mobx-react"; import { GroupIcon } from "outline-icons"; import * as React from "react"; import Group from "~/models/Group"; +import useStores from "~/hooks/useStores"; import Folder from "./Folder"; import Relative from "./Relative"; import SharedWithMeLink from "./SharedWithMeLink"; @@ -13,7 +14,9 @@ type Props = { }; const GroupLink: React.FC = ({ group }) => { + const { groupMemberships } = useStores(); const [expanded, setExpanded] = React.useState(false); + const [prefetched, setPrefetched] = React.useState(false); const handleDisclosureClick = React.useCallback((ev) => { ev?.preventDefault(); @@ -21,8 +24,18 @@ const GroupLink: React.FC = ({ group }) => { }, []); const handlePrefetch = React.useCallback(() => { - // TODO: prefetch group memberships - }, [group]); + if (prefetched) { + return; + } + void groupMemberships.fetchAll({ groupId: group.id }); + setPrefetched(true); + }, [groupMemberships, prefetched, group]); + + React.useEffect(() => { + if (expanded) { + handlePrefetch(); + } + }, [expanded, handlePrefetch]); return ( diff --git a/app/components/Sidebar/components/SharedWithMe.tsx b/app/components/Sidebar/components/SharedWithMe.tsx index 24e396c206b1..b6fff8d7169e 100644 --- a/app/components/Sidebar/components/SharedWithMe.tsx +++ b/app/components/Sidebar/components/SharedWithMe.tsx @@ -46,7 +46,6 @@ function SharedWithMe() { } }, [error, t]); - // TODO if ( !user.documentMemberships.length && !user.groupsWithDocumentMemberships.length diff --git a/app/stores/GroupMembershipsStore.ts b/app/stores/GroupMembershipsStore.ts index c8eb3c387666..7624c6943fa6 100644 --- a/app/stores/GroupMembershipsStore.ts +++ b/app/stores/GroupMembershipsStore.ts @@ -23,6 +23,7 @@ export default class GroupMembershipsStore extends Store { | PaginationParams & { documentId?: string; collectionId?: string; + groupId?: string; }): Promise => { this.isFetching = true; diff --git a/server/routes/api/groupMemberships/groupMemberships.ts b/server/routes/api/groupMemberships/groupMemberships.ts index 05c62b26173e..4fdd3f239cd9 100644 --- a/server/routes/api/groupMemberships/groupMemberships.ts +++ b/server/routes/api/groupMemberships/groupMemberships.ts @@ -22,6 +22,7 @@ router.post( pagination(), validate(T.GroupMembershipsListSchema), async (ctx: APIContext) => { + const { groupId } = ctx.input.body; const { user } = ctx.state.auth; const memberships = await GroupMembership.findAll({ @@ -37,6 +38,7 @@ router.post( { association: "group", required: true, + where: groupId ? { id: groupId } : undefined, include: [ { association: "groupUsers", diff --git a/server/routes/api/groupMemberships/schema.ts b/server/routes/api/groupMemberships/schema.ts index 2c4f349397fa..6229215dff4d 100644 --- a/server/routes/api/groupMemberships/schema.ts +++ b/server/routes/api/groupMemberships/schema.ts @@ -1,7 +1,11 @@ import { z } from "zod"; import { BaseSchema } from "@server/routes/api/schema"; -export const GroupMembershipsListSchema = BaseSchema; +export const GroupMembershipsListSchema = BaseSchema.extend({ + body: z.object({ + groupId: z.string().uuid().optional(), + }), +}); export type GroupMembershipsListReq = z.infer< typeof GroupMembershipsListSchema From 1bb4dcd099706c61f6f319f1661b585785781124 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 30 Aug 2024 13:58:19 -0400 Subject: [PATCH 39/51] fix: Empty group in 'Shared with me' if shared document is already accessible in collection tree --- app/models/User.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/models/User.ts b/app/models/User.ts index 22a29f95275b..3884ed09934d 100644 --- a/app/models/User.ts +++ b/app/models/User.ts @@ -152,16 +152,14 @@ class User extends ParanoidModel { @computed get groupsWithDocumentMemberships() { - const { groups, groupUsers, groupMemberships } = this.store.rootStore; + const { groups, groupUsers } = this.store.rootStore; return groupUsers.orderedData .filter((groupUser) => groupUser.userId === this.id) .map((groupUser) => groups.get(groupUser.groupId)) .filter(Boolean) - .filter((group) => - groupMemberships.orderedData.some( - (groupMembership) => groupMembership.groupId === group?.id - ) + .filter( + (group) => group && group.documentMemberships.length > 0 ) as Group[]; } From 09f20bb54ee32bb807ca8c49c05b1cbea727fbee Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 30 Aug 2024 15:26:30 -0400 Subject: [PATCH 40/51] fix: Show group permission when read-only in share popover --- .../Sharing/Document/DocumentMemberList.tsx | 58 +++++++++---------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/app/components/Sharing/Document/DocumentMemberList.tsx b/app/components/Sharing/Document/DocumentMemberList.tsx index a1428a86918a..ed5b667fb2f7 100644 --- a/app/components/Sharing/Document/DocumentMemberList.tsx +++ b/app/components/Sharing/Document/DocumentMemberList.tsx @@ -17,7 +17,7 @@ import useStores from "~/hooks/useStores"; import { EmptySelectValue, Permission } from "~/types"; import { homePath } from "~/utils/routeHelpers"; import { ListItem } from "../components/ListItem"; -import MemberListItem from "./DocumentMemberListItem"; +import DocumentMemberListItem from "./DocumentMemberListItem"; type Props = { /** Document to which team members are supposed to be invited */ @@ -158,40 +158,38 @@ function DocumentMembersList({ document, invitedInSession }: Props) { ) } actions={ - can.manageUsers ? ( -
    - { - if (permission === EmptySelectValue) { - await groupMemberships.delete({ - documentId: document.id, - groupId: membership.groupId, - }); - } else { - await groupMemberships.create({ - documentId: document.id, - groupId: membership.groupId, - permission, - }); - } - }} - disabled={!can.update} - value={membership.permission} - labelHidden - nude - /> -
    - ) : null +
    + { + if (permission === EmptySelectValue) { + await groupMemberships.delete({ + documentId: document.id, + groupId: membership.groupId, + }); + } else { + await groupMemberships.create({ + documentId: document.id, + groupId: membership.groupId, + permission, + }); + } + }} + disabled={!can.manageUsers} + value={membership.permission} + labelHidden + nude + /> +
    } /> ); })} {members.map((item) => ( - Date: Fri, 30 Aug 2024 16:09:35 -0400 Subject: [PATCH 41/51] Missing query lock --- server/routes/api/userMemberships/userMemberships.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/routes/api/userMemberships/userMemberships.ts b/server/routes/api/userMemberships/userMemberships.ts index 3d773e5ca785..3d67f96a14ea 100644 --- a/server/routes/api/userMemberships/userMemberships.ts +++ b/server/routes/api/userMemberships/userMemberships.ts @@ -83,6 +83,7 @@ router.post( const { user } = ctx.state.auth; const membership = await UserMembership.findByPk(id, { transaction, + lock: transaction.LOCK.UPDATE, rejectOnEmpty: true, }); authorize(user, "update", membership); From 26f8f4a64772829ae900f7275c1eb3e2e9961fa5 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 30 Aug 2024 16:11:36 -0400 Subject: [PATCH 42/51] fix: Ordering of groups in sidebar --- app/components/Sidebar/components/SharedWithMe.tsx | 6 +++--- app/models/User.ts | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/components/Sidebar/components/SharedWithMe.tsx b/app/components/Sidebar/components/SharedWithMe.tsx index b6fff8d7169e..8f54948748a5 100644 --- a/app/components/Sidebar/components/SharedWithMe.tsx +++ b/app/components/Sidebar/components/SharedWithMe.tsx @@ -57,6 +57,9 @@ function SharedWithMe() {
    + {user.groupsWithDocumentMemberships.map((group) => ( + + ))} {reorderMonitor.isDragging && ( )} - {user.groupsWithDocumentMemberships.map((group) => ( - - ))} {user.documentMemberships .slice(0, page * Pagination.sidebarLimit) .map((membership) => ( diff --git a/app/models/User.ts b/app/models/User.ts index 3884ed09934d..175486ea5dc0 100644 --- a/app/models/User.ts +++ b/app/models/User.ts @@ -158,9 +158,8 @@ class User extends ParanoidModel { .filter((groupUser) => groupUser.userId === this.id) .map((groupUser) => groups.get(groupUser.groupId)) .filter(Boolean) - .filter( - (group) => group && group.documentMemberships.length > 0 - ) as Group[]; + .filter((group) => group && group.documentMemberships.length > 0) + .sort((a, b) => a!.name.localeCompare(b!.name)) as Group[]; } /** From 6451419d86a4d0b0e4d7c106275070402c75e6e8 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 30 Aug 2024 17:19:37 -0400 Subject: [PATCH 43/51] Consistent document sharing UI --- .../Document/DocumentMemberListItem.tsx | 49 +++++++++---------- shared/i18n/locales/en_US/translation.json | 4 +- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/app/components/Sharing/Document/DocumentMemberListItem.tsx b/app/components/Sharing/Document/DocumentMemberListItem.tsx index ccfd1ba2ef90..46e6c1e1539a 100644 --- a/app/components/Sharing/Document/DocumentMemberListItem.tsx +++ b/app/components/Sharing/Document/DocumentMemberListItem.tsx @@ -9,6 +9,7 @@ import User from "~/models/User"; import UserMembership from "~/models/UserMembership"; import { Avatar, AvatarSize } from "~/components/Avatar"; import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect"; +import Time from "~/components/Time"; import { EmptySelectValue, Permission } from "~/types"; import { ListItem } from "../components/ListItem"; @@ -67,7 +68,6 @@ const DocumentMemberListItem = ({ if (!currentPermission) { return null; } - const disabled = !onUpdate && !onLeave; const MaybeLink = membership?.source ? StyledLink : React.Fragment; return ( @@ -89,36 +89,35 @@ const DocumentMemberListItem = ({ ) : user.isSuspended ? ( t("Suspended") - ) : user.email ? ( - user.email ) : user.isInvited ? ( t("Invited") - ) : user.isViewer ? ( - t("Viewer") + ) : user.lastActiveAt ? ( + + Active ) : ( - t("Editor") + t("Never signed in") ) } actions={ - disabled ? null : ( -
    - -
    - ) +
    + +
    } /> ); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index d75fca46e588..ea02651c4a80 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -312,6 +312,8 @@ "Has access through <2>parent": "Has access through <2>parent", "Suspended": "Suspended", "Invited": "Invited", + "Active <1> ago": "Active <1> ago", + "Never signed in": "Never signed in", "Leave": "Leave", "Only lowercase letters, digits and dashes allowed": "Only lowercase letters, digits and dashes allowed", "Sorry, this link has already been used": "Sorry, this link has already been used", @@ -668,8 +670,6 @@ "Search people": "Search people", "No people matching your search": "No people matching your search", "No people left to add": "No people left to add", - "Active <1> ago": "Active <1> ago", - "Never signed in": "Never signed in", "Admin": "Admin", "{{userName}} was removed from the group": "{{userName}} was removed from the group", "Add and remove members to the {{groupName}} group. Members of the group will have access to any collections this group has been added to.": "Add and remove members to the {{groupName}} group. Members of the group will have access to any collections this group has been added to.", From a962134832ebd65ec6269b86ec8b17516beca89f Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 31 Aug 2024 11:12:23 -0400 Subject: [PATCH 44/51] Fetch all root document shares --- app/components/Sidebar/components/SharedWithMe.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/components/Sidebar/components/SharedWithMe.tsx b/app/components/Sidebar/components/SharedWithMe.tsx index 8f54948748a5..baee7a077432 100644 --- a/app/components/Sidebar/components/SharedWithMe.tsx +++ b/app/components/Sidebar/components/SharedWithMe.tsx @@ -26,15 +26,13 @@ function SharedWithMe() { const { t } = useTranslation(); const user = useCurrentUser(); + usePaginatedRequest(groupMemberships.fetchAll); + const { loading, next, end, error, page } = usePaginatedRequest(userMemberships.fetchPage, { limit: Pagination.sidebarLimit, }); - usePaginatedRequest(groupMemberships.fetchPage, { - limit: Pagination.sidebarLimit, - }); - // Drop to reorder document const [reorderMonitor, dropToReorderRef] = useDropToReorderUserMembership( () => fractionalIndex(null, user.documentMemberships[0].index) From ff1e74f5ab3f9b0c2ea16c7e0669587a4ab5f55d Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 31 Aug 2024 11:26:05 -0400 Subject: [PATCH 45/51] fix: Root disclosure spacing --- app/components/Sidebar/components/Disclosure.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/Sidebar/components/Disclosure.tsx b/app/components/Sidebar/components/Disclosure.tsx index 8cccfa33de32..880fded08153 100644 --- a/app/components/Sidebar/components/Disclosure.tsx +++ b/app/components/Sidebar/components/Disclosure.tsx @@ -42,7 +42,7 @@ const Button = styled(NudeButton)<{ $root?: boolean }>` props.$root && css` opacity: 0; - left: -16px; + left: -18px; &:hover { opacity: 1; From 0200520f327923babf719efdd210d6c57c657389 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 31 Aug 2024 11:32:11 -0400 Subject: [PATCH 46/51] fix: Prevent drag of GroupMembership --- .../Sidebar/components/useDragAndDrop.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/components/Sidebar/components/useDragAndDrop.tsx b/app/components/Sidebar/components/useDragAndDrop.tsx index 21e2e5502d66..ac804018063c 100644 --- a/app/components/Sidebar/components/useDragAndDrop.tsx +++ b/app/components/Sidebar/components/useDragAndDrop.tsx @@ -91,11 +91,16 @@ export function useDropToReorderStar(getIndex?: () => string) { }); } +/** + * Hook for shared logic that allows dragging user memberships to reorder + * + * @param membership The UserMembership or GroupMembership model to drag. + */ export function useDragMembership( - userMembership: UserMembership | GroupMembership + membership: UserMembership | GroupMembership ): [{ isDragging: boolean }, ConnectDragSource] { - const id = userMembership.id; - const { label: title, icon } = useSidebarLabelAndIcon(userMembership); + const id = membership.id; + const { label: title, icon } = useSidebarLabelAndIcon(membership); const [{ isDragging }, draggableRef, preview] = useDrag({ type: "userMembership", @@ -107,7 +112,7 @@ export function useDragMembership( collect: (monitor) => ({ isDragging: !!monitor.isDragging(), }), - canDrag: () => true, + canDrag: () => membership instanceof UserMembership, }); React.useEffect(() => { From 7873167d9ede38e24d16a28c9c7d69d1fd06ec61 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 31 Aug 2024 11:36:34 -0400 Subject: [PATCH 47/51] Remove no longer used query --- .../Sidebar/components/GroupLink.tsx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/app/components/Sidebar/components/GroupLink.tsx b/app/components/Sidebar/components/GroupLink.tsx index 0471ae8c6075..a8c43def8e99 100644 --- a/app/components/Sidebar/components/GroupLink.tsx +++ b/app/components/Sidebar/components/GroupLink.tsx @@ -2,7 +2,6 @@ import { observer } from "mobx-react"; import { GroupIcon } from "outline-icons"; import * as React from "react"; import Group from "~/models/Group"; -import useStores from "~/hooks/useStores"; import Folder from "./Folder"; import Relative from "./Relative"; import SharedWithMeLink from "./SharedWithMeLink"; @@ -14,36 +13,19 @@ type Props = { }; const GroupLink: React.FC = ({ group }) => { - const { groupMemberships } = useStores(); const [expanded, setExpanded] = React.useState(false); - const [prefetched, setPrefetched] = React.useState(false); const handleDisclosureClick = React.useCallback((ev) => { ev?.preventDefault(); setExpanded((e) => !e); }, []); - const handlePrefetch = React.useCallback(() => { - if (prefetched) { - return; - } - void groupMemberships.fetchAll({ groupId: group.id }); - setPrefetched(true); - }, [groupMemberships, prefetched, group]); - - React.useEffect(() => { - if (expanded) { - handlePrefetch(); - } - }, [expanded, handlePrefetch]); - return ( } expanded={expanded} - onClickIntent={handlePrefetch} onClick={handleDisclosureClick} depth={0} /> From e43ec33a7cae3cbb626b98c158df6596a0b72ed8 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 31 Aug 2024 13:27:55 -0400 Subject: [PATCH 48/51] fix: Refactor sidebar context management (#7495) --- app/actions/definitions/documents.tsx | 12 +-- .../Sidebar/components/CollectionLink.tsx | 15 ++-- .../Sidebar/components/DocumentLink.tsx | 26 +++--- .../components/DraggableCollectionLink.tsx | 28 +++--- .../Sidebar/components/GroupLink.tsx | 21 +++-- .../Sidebar/components/SharedContext.ts | 7 -- .../Sidebar/components/SharedWithMe.tsx | 6 +- .../Sidebar/components/SharedWithMeLink.tsx | 37 ++++---- .../Sidebar/components/SidebarContext.ts | 9 ++ app/components/Sidebar/components/Starred.tsx | 6 +- .../Sidebar/components/StarredContext.ts | 7 -- .../Sidebar/components/StarredLink.tsx | 90 +++++++++++-------- .../Sidebar/hooks/useLocationState.ts | 12 +++ app/hooks/useTemplateActions.tsx | 4 +- app/types.ts | 3 +- 15 files changed, 156 insertions(+), 127 deletions(-) delete mode 100644 app/components/Sidebar/components/SharedContext.ts create mode 100644 app/components/Sidebar/components/SidebarContext.ts delete mode 100644 app/components/Sidebar/components/StarredContext.ts create mode 100644 app/components/Sidebar/hooks/useLocationState.ts diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 00f5a4a2a735..2be7b8577158 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -104,9 +104,9 @@ export const createDocument = createAction({ !!currentTeamId && stores.policies.abilities(currentTeamId).createDocument ); }, - perform: ({ activeCollectionId, inStarredSection }) => + perform: ({ activeCollectionId, sidebarContext }) => history.push(newDocumentPath(activeCollectionId), { - starred: inStarredSection, + sidebarContext, }), }); @@ -121,11 +121,11 @@ export const createDocumentFromTemplate = createAction({ !!activeDocumentId && !!stores.documents.get(activeDocumentId)?.template && stores.policies.abilities(currentTeamId).createDocument, - perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) => + perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) => history.push( newDocumentPath(activeCollectionId, { templateId: activeDocumentId }), { - starred: inStarredSection, + sidebarContext, } ), }); @@ -141,9 +141,9 @@ export const createNestedDocument = createAction({ !!activeDocumentId && stores.policies.abilities(currentTeamId).createDocument && stores.policies.abilities(activeDocumentId).createChildDocument, - perform: ({ activeDocumentId, inStarredSection }) => + perform: ({ activeDocumentId, sidebarContext }) => history.push(newNestedDocumentPath(activeDocumentId), { - starred: inStarredSection, + sidebarContext, }), }); diff --git a/app/components/Sidebar/components/CollectionLink.tsx b/app/components/Sidebar/components/CollectionLink.tsx index 5e5ca62b95b0..f5ac4f49561a 100644 --- a/app/components/Sidebar/components/CollectionLink.tsx +++ b/app/components/Sidebar/components/CollectionLink.tsx @@ -22,8 +22,8 @@ import CollectionMenu from "~/menus/CollectionMenu"; import DropToImport from "./DropToImport"; import EditableTitle, { RefHandle } from "./EditableTitle"; import Relative from "./Relative"; +import { SidebarContextType, useSidebarContext } from "./SidebarContext"; import SidebarLink, { DragObject } from "./SidebarLink"; -import { useStarredContext } from "./StarredContext"; type Props = { collection: Collection; @@ -48,7 +48,7 @@ const CollectionLink: React.FC = ({ const can = usePolicy(collection); const { t } = useTranslation(); const history = useHistory(); - const inStarredSection = useStarredContext(); + const sidebarContext = useSidebarContext(); const editableTitleRef = React.useRef(null); const handleTitleChange = React.useCallback( @@ -122,7 +122,7 @@ const CollectionLink: React.FC = ({ const context = useActionContext({ activeCollectionId: collection.id, - inStarredSection, + sidebarContext, }); return ( @@ -131,7 +131,7 @@ const CollectionLink: React.FC = ({ = ({ icon={} showActions={menuOpen} isActiveDrop={isOver && canDrop} - isActive={(match, location: Location<{ starred?: boolean }>) => - !!match && location.state?.starred === inStarredSection - } + isActive={( + match, + location: Location<{ sidebarContext?: SidebarContextType }> + ) => !!match && location.state?.sidebarContext === sidebarContext} label={ (null); - const inStarredSection = useStarredContext(); - const inSharedSection = useSharedContext(); + const sidebarContext = useSidebarContext(); React.useEffect(() => { - if (isActiveDocument && (hasChildDocuments || inSharedSection)) { + if ( + isActiveDocument && + (hasChildDocuments || sidebarContext !== "collections") + ) { void fetchChildDocuments(node.id); } }, [ fetchChildDocuments, node.id, hasChildDocuments, - inSharedSection, + sidebarContext, isActiveDocument, ]); @@ -338,8 +339,7 @@ function InnerDocumentLink( pathname: node.url, state: { title: node.title, - starred: inStarredSection, - sharedWithMe: inSharedSection, + sidebarContext, }, }} icon={icon && } @@ -356,14 +356,10 @@ function InnerDocumentLink( isActive={( match, location: Location<{ - starred?: boolean; - sharedWithMe?: boolean; + sidebarContext?: SidebarContextType; }> ) => { - if (inStarredSection !== location.state?.starred) { - return false; - } - if (inSharedSection !== location.state?.sharedWithMe) { + if (sidebarContext !== location.state?.sidebarContext) { return false; } return ( @@ -375,7 +371,7 @@ function InnerDocumentLink( depth={depth} exact={false} showActions={menuOpen} - scrollIntoViewIfNeeded={!inStarredSection && !inSharedSection} + scrollIntoViewIfNeeded={sidebarContext === "collections"} isDraft={isDraft} ref={ref} menu={ diff --git a/app/components/Sidebar/components/DraggableCollectionLink.tsx b/app/components/Sidebar/components/DraggableCollectionLink.tsx index 2116a21b9f59..a4b3900c63ed 100644 --- a/app/components/Sidebar/components/DraggableCollectionLink.tsx +++ b/app/components/Sidebar/components/DraggableCollectionLink.tsx @@ -3,17 +3,18 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useDrop, useDrag, DropTargetMonitor } from "react-dnd"; import { getEmptyImage } from "react-dnd-html5-backend"; -import { useLocation } from "react-router-dom"; import styled from "styled-components"; import Collection from "~/models/Collection"; import Document from "~/models/Document"; import CollectionIcon from "~/components/Icons/CollectionIcon"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; +import { useLocationState } from "../hooks/useLocationState"; import CollectionLink from "./CollectionLink"; import CollectionLinkChildren from "./CollectionLinkChildren"; import DropCursor from "./DropCursor"; import Relative from "./Relative"; +import { useSidebarContext } from "./SidebarContext"; import { DragObject } from "./SidebarLink"; type Props = { @@ -23,23 +24,18 @@ type Props = { belowCollection: Collection | void; }; -function useLocationStateStarred() { - const location = useLocation<{ - starred?: boolean; - }>(); - return location.state?.starred; -} - function DraggableCollectionLink({ collection, activeDocument, prefetchDocument, belowCollection, }: Props) { - const locationStateStarred = useLocationStateStarred(); + const locationSidebarContext = useLocationState(); + const sidebarContext = useSidebarContext(); const { ui, collections } = useStores(); const [expanded, setExpanded] = React.useState( - collection.id === ui.activeCollectionId && !locationStateStarred + collection.id === ui.activeCollectionId && + sidebarContext === locationSidebarContext ); const can = usePolicy(collection); const belowCollectionIndex = belowCollection ? belowCollection.index : null; @@ -86,10 +82,18 @@ function DraggableCollectionLink({ // If the current collection is active and relevant to the sidebar section we // are in then expand it automatically React.useEffect(() => { - if (collection.id === ui.activeCollectionId && !locationStateStarred) { + if ( + collection.id === ui.activeCollectionId && + sidebarContext === locationSidebarContext + ) { setExpanded(true); } - }, [collection.id, ui.activeCollectionId, locationStateStarred]); + }, [ + collection.id, + ui.activeCollectionId, + sidebarContext, + locationSidebarContext, + ]); const handleDisclosureClick = React.useCallback((ev) => { ev?.preventDefault(); diff --git a/app/components/Sidebar/components/GroupLink.tsx b/app/components/Sidebar/components/GroupLink.tsx index a8c43def8e99..63f0b98810aa 100644 --- a/app/components/Sidebar/components/GroupLink.tsx +++ b/app/components/Sidebar/components/GroupLink.tsx @@ -5,6 +5,7 @@ import Group from "~/models/Group"; import Folder from "./Folder"; import Relative from "./Relative"; import SharedWithMeLink from "./SharedWithMeLink"; +import SidebarContext from "./SidebarContext"; import SidebarLink from "./SidebarLink"; type Props = { @@ -29,15 +30,17 @@ const GroupLink: React.FC = ({ group }) => { onClick={handleDisclosureClick} depth={0} /> - - {group.documentMemberships.map((membership) => ( - - ))} - + + + {group.documentMemberships.map((membership) => ( + + ))} + + ); }; diff --git a/app/components/Sidebar/components/SharedContext.ts b/app/components/Sidebar/components/SharedContext.ts deleted file mode 100644 index 7960b720aac2..000000000000 --- a/app/components/Sidebar/components/SharedContext.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as React from "react"; - -const SharedContext = React.createContext(false); - -export const useSharedContext = () => React.useContext(SharedContext); - -export default SharedContext; diff --git a/app/components/Sidebar/components/SharedWithMe.tsx b/app/components/Sidebar/components/SharedWithMe.tsx index baee7a077432..fa21adb01552 100644 --- a/app/components/Sidebar/components/SharedWithMe.tsx +++ b/app/components/Sidebar/components/SharedWithMe.tsx @@ -16,8 +16,8 @@ import GroupLink from "./GroupLink"; import Header from "./Header"; import PlaceholderCollections from "./PlaceholderCollections"; import Relative from "./Relative"; -import SharedContext from "./SharedContext"; import SharedWithMeLink from "./SharedWithMeLink"; +import SidebarContext from "./SidebarContext"; import SidebarLink from "./SidebarLink"; import { useDropToReorderUserMembership } from "./useDragAndDrop"; @@ -52,7 +52,7 @@ function SharedWithMe() { } return ( - +
    {user.groupsWithDocumentMemberships.map((group) => ( @@ -89,7 +89,7 @@ function SharedWithMe() {
    -
    + ); } diff --git a/app/components/Sidebar/components/SharedWithMeLink.tsx b/app/components/Sidebar/components/SharedWithMeLink.tsx index 3e6958adb86f..8f3e8e058d9b 100644 --- a/app/components/Sidebar/components/SharedWithMeLink.tsx +++ b/app/components/Sidebar/components/SharedWithMeLink.tsx @@ -2,7 +2,6 @@ import fractionalIndex from "fractional-index"; import { Location } from "history"; import { observer } from "mobx-react"; import * as React from "react"; -import { useLocation } from "react-router-dom"; import styled from "styled-components"; import { IconType, NotificationEventType } from "@shared/types"; import { determineIconType } from "@shared/utils/icon"; @@ -12,10 +11,12 @@ import Fade from "~/components/Fade"; import useBoolean from "~/hooks/useBoolean"; import useStores from "~/hooks/useStores"; import DocumentMenu from "~/menus/DocumentMenu"; +import { useLocationState } from "../hooks/useLocationState"; import DocumentLink from "./DocumentLink"; import DropCursor from "./DropCursor"; import Folder from "./Folder"; import Relative from "./Relative"; +import { useSidebarContext, type SidebarContextType } from "./SidebarContext"; import SidebarLink from "./SidebarLink"; import { useDragMembership, @@ -28,30 +29,33 @@ type Props = { depth?: number; }; -function useLocationState() { - const location = useLocation<{ - sharedWithMe?: boolean; - }>(); - return location.state?.sharedWithMe; -} - function SharedWithMeLink({ membership, depth = 0 }: Props) { const { ui, collections, documents } = useStores(); const { fetchChildDocuments } = documents; const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const { documentId } = membership; const isActiveDocument = documentId === ui.activeDocumentId; - const locationStateStarred = useLocationState(); + const locationSidebarContext = useLocationState(); + const sidebarContext = useSidebarContext(); const [expanded, setExpanded] = React.useState( - membership.documentId === ui.activeDocumentId && !!locationStateStarred + membership.documentId === ui.activeDocumentId && + locationSidebarContext === sidebarContext ); React.useEffect(() => { - if (membership.documentId === ui.activeDocumentId && locationStateStarred) { + if ( + membership.documentId === ui.activeDocumentId && + locationSidebarContext === sidebarContext + ) { setExpanded(true); } - }, [membership.documentId, ui.activeDocumentId, locationStateStarred]); + }, [ + membership.documentId, + ui.activeDocumentId, + sidebarContext, + locationSidebarContext, + ]); React.useEffect(() => { if (documentId) { @@ -119,14 +123,15 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) { depth={depth} to={{ pathname: document.path, - state: { sharedWithMe: true }, + state: { sidebarContext }, }} expanded={hasChildDocuments && !isDragging ? expanded : undefined} onDisclosureClick={handleDisclosureClick} icon={icon} - isActive={(match, location: Location<{ sharedWithMe?: boolean }>) => - !!match && location.state?.sharedWithMe === true - } + isActive={( + match, + location: Location<{ sidebarContext?: SidebarContextType }> + ) => !!match && location.state?.sidebarContext === sidebarContext} label={label} exact={false} unreadBadge={ diff --git a/app/components/Sidebar/components/SidebarContext.ts b/app/components/Sidebar/components/SidebarContext.ts new file mode 100644 index 000000000000..ba8397d2e2e6 --- /dev/null +++ b/app/components/Sidebar/components/SidebarContext.ts @@ -0,0 +1,9 @@ +import * as React from "react"; + +export type SidebarContextType = "collections" | "starred" | string | undefined; + +const SidebarContext = React.createContext(undefined); + +export const useSidebarContext = () => React.useContext(SidebarContext); + +export default SidebarContext; diff --git a/app/components/Sidebar/components/Starred.tsx b/app/components/Sidebar/components/Starred.tsx index 544897027f1f..3385aa4478c4 100644 --- a/app/components/Sidebar/components/Starred.tsx +++ b/app/components/Sidebar/components/Starred.tsx @@ -11,8 +11,8 @@ import DropCursor from "./DropCursor"; import Header from "./Header"; import PlaceholderCollections from "./PlaceholderCollections"; import Relative from "./Relative"; +import SidebarContext from "./SidebarContext"; import SidebarLink from "./SidebarLink"; -import StarredContext from "./StarredContext"; import StarredLink from "./StarredLink"; import { useDropToCreateStar, useDropToReorderStar } from "./useDragAndDrop"; @@ -39,7 +39,7 @@ function Starred() { } return ( - +
    @@ -80,7 +80,7 @@ function Starred() {
    -
    + ); } diff --git a/app/components/Sidebar/components/StarredContext.ts b/app/components/Sidebar/components/StarredContext.ts deleted file mode 100644 index aed577f1426a..000000000000 --- a/app/components/Sidebar/components/StarredContext.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as React from "react"; - -const StarredContext = React.createContext(false); - -export const useStarredContext = () => React.useContext(StarredContext); - -export default StarredContext; diff --git a/app/components/Sidebar/components/StarredLink.tsx b/app/components/Sidebar/components/StarredLink.tsx index eb94bb6b9777..3b92775f83be 100644 --- a/app/components/Sidebar/components/StarredLink.tsx +++ b/app/components/Sidebar/components/StarredLink.tsx @@ -4,19 +4,23 @@ import { observer } from "mobx-react"; import { StarredIcon } from "outline-icons"; import * as React from "react"; import { useEffect, useState } from "react"; -import { useLocation } from "react-router-dom"; import styled, { useTheme } from "styled-components"; import Star from "~/models/Star"; import Fade from "~/components/Fade"; import useBoolean from "~/hooks/useBoolean"; import useStores from "~/hooks/useStores"; import DocumentMenu from "~/menus/DocumentMenu"; +import { useLocationState } from "../hooks/useLocationState"; import CollectionLink from "./CollectionLink"; import CollectionLinkChildren from "./CollectionLinkChildren"; import DocumentLink from "./DocumentLink"; import DropCursor from "./DropCursor"; import Folder from "./Folder"; import Relative from "./Relative"; +import SidebarContext, { + SidebarContextType, + useSidebarContext, +} from "./SidebarContext"; import SidebarLink from "./SidebarLink"; import { useDragStar, @@ -29,29 +33,32 @@ type Props = { star: Star; }; -function useLocationState() { - const location = useLocation<{ - starred?: boolean; - }>(); - return location.state?.starred; -} - function StarredLink({ star }: Props) { const theme = useTheme(); const { ui, collections, documents } = useStores(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const { documentId, collectionId } = star; const collection = collections.get(collectionId); - const locationStateStarred = useLocationState(); + const locationSidebarContext = useLocationState(); + const sidebarContext = useSidebarContext(); const [expanded, setExpanded] = useState( - star.collectionId === ui.activeCollectionId && !!locationStateStarred + star.collectionId === ui.activeCollectionId && + sidebarContext === locationSidebarContext ); React.useEffect(() => { - if (star.collectionId === ui.activeCollectionId && locationStateStarred) { + if ( + star.collectionId === ui.activeCollectionId && + sidebarContext === locationSidebarContext + ) { setExpanded(true); } - }, [star.collectionId, ui.activeCollectionId, locationStateStarred]); + }, [ + star.collectionId, + ui.activeCollectionId, + sidebarContext, + locationSidebarContext, + ]); useEffect(() => { if (documentId) { @@ -120,14 +127,15 @@ function StarredLink({ star }: Props) { depth={0} to={{ pathname: document.url, - state: { starred: true }, + state: { sidebarContext }, }} expanded={hasChildDocuments && !isDragging ? expanded : undefined} onDisclosureClick={handleDisclosureClick} icon={icon} - isActive={(match, location: Location<{ starred?: boolean }>) => - !!match && location.state?.starred === true - } + isActive={( + match, + location: Location<{ sidebarContext?: SidebarContextType }> + ) => !!match && location.state?.sidebarContext === sidebarContext} label={label} exact={false} showActions={menuOpen} @@ -144,22 +152,24 @@ function StarredLink({ star }: Props) { } /> - - - {childDocuments.map((node, index) => ( - - ))} - - {cursor} - + + + + {childDocuments.map((node, index) => ( + + ))} + + {cursor} + + ); } @@ -176,13 +186,15 @@ function StarredLink({ star }: Props) { isDraggingAnyCollection={reorderStarMonitor.isDragging} /> - - - {cursor} - + + + + {cursor} + + ); } diff --git a/app/components/Sidebar/hooks/useLocationState.ts b/app/components/Sidebar/hooks/useLocationState.ts new file mode 100644 index 000000000000..f79f79e34ab0 --- /dev/null +++ b/app/components/Sidebar/hooks/useLocationState.ts @@ -0,0 +1,12 @@ +import { useLocation } from "react-router-dom"; +import { SidebarContextType } from "../components/SidebarContext"; + +/** + * Hook to retrieve the sidebar context from the current location state. + */ +export function useLocationState() { + const location = useLocation<{ + sidebarContext?: SidebarContextType; + }>(); + return location.state?.sidebarContext; +} diff --git a/app/hooks/useTemplateActions.tsx b/app/hooks/useTemplateActions.tsx index 273f151998cc..2bad996d8329 100644 --- a/app/hooks/useTemplateActions.tsx +++ b/app/hooks/useTemplateActions.tsx @@ -27,13 +27,13 @@ const useTemplatesActions = () => { ), keywords: "create", - perform: ({ activeCollectionId, inStarredSection }) => + perform: ({ activeCollectionId, sidebarContext }) => history.push( newDocumentPath(item.collectionId ?? activeCollectionId, { templateId: item.id, }), { - starred: inStarredSection, + sidebarContext, } ), }) diff --git a/app/types.ts b/app/types.ts index f03b064321a6..216bdcce5bd5 100644 --- a/app/types.ts +++ b/app/types.ts @@ -7,6 +7,7 @@ import { DocumentPermission, } from "@shared/types"; import RootStore from "~/stores/RootStore"; +import { SidebarContextType } from "./components/Sidebar/components/SidebarContext"; import Document from "./models/Document"; import FileOperation from "./models/FileOperation"; import Pin from "./models/Pin"; @@ -82,7 +83,7 @@ export type ActionContext = { isContextMenu: boolean; isCommandBar: boolean; isButton: boolean; - inStarredSection?: boolean; + sidebarContext?: SidebarContextType; activeCollectionId?: string | undefined; activeDocumentId: string | undefined; currentUserId: string | undefined; From 2ac78bb11961bc7d861aa726a308a114de184e9c Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 31 Aug 2024 15:08:07 -0400 Subject: [PATCH 49/51] group.users --- app/components/GroupListItem.tsx | 7 +------ app/components/WebsocketProvider.tsx | 22 +++++++++++++--------- app/models/Group.ts | 9 +++++++++ 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/app/components/GroupListItem.tsx b/app/components/GroupListItem.tsx index a1bb92a2a598..ac18bc1b6fb8 100644 --- a/app/components/GroupListItem.tsx +++ b/app/components/GroupListItem.tsx @@ -13,7 +13,6 @@ import Flex from "~/components/Flex"; import ListItem from "~/components/List/Item"; import Modal from "~/components/Modal"; import useBoolean from "~/hooks/useBoolean"; -import useStores from "~/hooks/useStores"; import { hover } from "~/styles"; import NudeButton from "./NudeButton"; @@ -26,15 +25,11 @@ type Props = { }; function GroupListItem({ group, showFacepile, renderActions }: Props) { - const { groupUsers } = useStores(); const { t } = useTranslation(); const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] = useBoolean(); const memberCount = group.memberCount; - const membershipsInGroup = groupUsers.inGroup(group.id); - const users = membershipsInGroup - .slice(0, MAX_AVATAR_DISPLAY) - .map((gm) => gm.user); + const users = group.users.slice(0, MAX_AVATAR_DISPLAY); const overflow = memberCount - users.length; return ( diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index 1c94bc3a3c36..0f9684e7f199 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -311,16 +311,20 @@ class WebsocketProvider extends React.Component { (event: PartialWithId) => { groupMemberships.add(event); + const group = groups.get(event.groupId!); + // Any existing child policies are now invalid - // TODO: How to determine if event is for current user? - // if (event.userId === currentUserId) { - // const document = documents.get(event.documentId!); - // if (document) { - // document.childDocuments.forEach((childDocument) => { - // policies.remove(childDocument.id); - // }); - // } - // } + if ( + currentUserId && + group?.users.map((u) => u.id).includes(currentUserId) + ) { + const document = documents.get(event.documentId!); + if (document) { + document.childDocuments.forEach((childDocument) => { + policies.remove(childDocument.id); + }); + } + } } ); diff --git a/app/models/Group.ts b/app/models/Group.ts index aba06f8ebd5b..fa756fdcc7ac 100644 --- a/app/models/Group.ts +++ b/app/models/Group.ts @@ -17,6 +17,15 @@ class Group extends Model { @observable memberCount: number; + /** + * Returns the users that are members of this group. + */ + @computed + get users() { + const { users } = this.store.rootStore; + return users.inGroup(this.id); + } + /** * Returns the direct memberships that this group has to documents. Documents that the current * user already has access to through a collection and trashed documents are not included. From b0f45c475cee1632cad67c37941ac54c7038cc0f Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 31 Aug 2024 16:12:06 -0400 Subject: [PATCH 50/51] perf: Performance of sourced membership queries --- server/models/GroupMembership.ts | 33 ++++++++++++++++++++---- server/models/UserMembership.ts | 26 ++++++++++--------- server/routes/api/documents/documents.ts | 24 +++++++---------- 3 files changed, 52 insertions(+), 31 deletions(-) diff --git a/server/models/GroupMembership.ts b/server/models/GroupMembership.ts index c95316b5f8a1..de6ed02e99aa 100644 --- a/server/models/GroupMembership.ts +++ b/server/models/GroupMembership.ts @@ -4,8 +4,9 @@ import { Op, type SaveOptions, type FindOptions, + type DestroyOptions, + type WhereOptions, } from "sequelize"; -import { WhereOptions } from "sequelize"; import { BelongsTo, Column, @@ -17,6 +18,7 @@ import { Scopes, AfterCreate, AfterUpdate, + AfterDestroy, } from "sequelize-typescript"; import { CollectionPermission, DocumentPermission } from "@shared/types"; import Collection from "./Collection"; @@ -189,6 +191,18 @@ class GroupMembership extends ParanoidModel< // hooks + @AfterCreate + static async createSourcedMemberships( + model: GroupMembership, + options: SaveOptions + ) { + if (model.sourceId || !model.documentId) { + return; + } + + return this.recreateSourcedMemberships(model, options); + } + @AfterUpdate static async updateSourcedMemberships( model: GroupMembership, @@ -207,6 +221,7 @@ class GroupMembership extends ParanoidModel< }, { where: { + groupId: model.groupId, sourceId: model.id, }, transaction, @@ -215,16 +230,23 @@ class GroupMembership extends ParanoidModel< } } - @AfterCreate - static async createSourcedMemberships( + @AfterDestroy + static async destroySourcedMemberships( model: GroupMembership, - options: SaveOptions + options: DestroyOptions ) { if (model.sourceId || !model.documentId) { return; } - return this.recreateSourcedMemberships(model, options); + const { transaction } = options; + await this.destroy({ + where: { + groupId: model.groupId, + sourceId: model.id, + }, + transaction, + }); } /** @@ -241,6 +263,7 @@ class GroupMembership extends ParanoidModel< await this.destroy({ where: { + groupId: model.groupId, sourceId: model.id, }, transaction, diff --git a/server/models/UserMembership.ts b/server/models/UserMembership.ts index 028940258908..40a295763f65 100644 --- a/server/models/UserMembership.ts +++ b/server/models/UserMembership.ts @@ -197,6 +197,18 @@ class UserMembership extends IdModel< // hooks + @AfterCreate + static async createSourcedMemberships( + model: UserMembership, + options: SaveOptions + ) { + if (model.sourceId || !model.documentId) { + return; + } + + return this.recreateSourcedMemberships(model, options); + } + @AfterUpdate static async updateSourcedMemberships( model: UserMembership, @@ -215,6 +227,7 @@ class UserMembership extends IdModel< }, { where: { + userId: model.userId, sourceId: model.id, }, transaction, @@ -223,18 +236,6 @@ class UserMembership extends IdModel< } } - @AfterCreate - static async createSourcedMemberships( - model: UserMembership, - options: SaveOptions - ) { - if (model.sourceId || !model.documentId) { - return; - } - - return this.recreateSourcedMemberships(model, options); - } - /** * Recreate all sourced permissions for a given permission. */ @@ -249,6 +250,7 @@ class UserMembership extends IdModel< await this.destroy({ where: { + userId: model.userId, sourceId: model.id, }, transaction, diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 80309a2eb6a4..af850e1b4446 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -1636,8 +1636,8 @@ router.post( validate(T.DocumentsRemoveUserSchema), transaction(), async (ctx: APIContext) => { - const { auth, transaction } = ctx.state; - const actor = auth.user; + const { transaction } = ctx.state; + const { user: actor } = ctx.state.auth; const { id, userId } = ctx.input.body; const [document, user] = await Promise.all([ @@ -1762,9 +1762,9 @@ router.post( validate(T.DocumentsRemoveGroupSchema), transaction(), async (ctx: APIContext) => { - const { id, groupId } = ctx.input.body; - const { user } = ctx.state.auth; const { transaction } = ctx.state; + const { user } = ctx.state.auth; + const { id, groupId } = ctx.input.body; const [document, group] = await Promise.all([ Document.findByPk(id, { @@ -1780,22 +1780,18 @@ router.post( authorize(user, "update", document); authorize(user, "read", group); - const [membership] = await document.$get("groupMemberships", { - where: { groupId }, - transaction, - }); - - if (!membership) { - ctx.throw(400, "This Group is not a part of the document"); - } - - await GroupMembership.destroy({ + const membership = await GroupMembership.findOne({ where: { documentId: id, groupId, }, transaction, + lock: transaction.LOCK.UPDATE, + rejectOnEmpty: true, }); + + await membership.destroy({ transaction }); + await Event.createFromContext( ctx, { From 444010a92a8c43ff8f05347c7433e1319b82a243 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 31 Aug 2024 23:17:35 -0400 Subject: [PATCH 51/51] feat: Add notifications on group addition --- .../processors/NotificationsProcessor.ts | 12 +++++++++ .../DocumentAddGroupNotificationsTask.ts | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 server/queues/tasks/DocumentAddGroupNotificationsTask.ts diff --git a/server/queues/processors/NotificationsProcessor.ts b/server/queues/processors/NotificationsProcessor.ts index 3edd7fbcd91a..e2b267be51d7 100644 --- a/server/queues/processors/NotificationsProcessor.ts +++ b/server/queues/processors/NotificationsProcessor.ts @@ -6,11 +6,13 @@ import { CommentEvent, CollectionUserEvent, DocumentUserEvent, + DocumentGroupEvent, } from "@server/types"; import CollectionAddUserNotificationsTask from "../tasks/CollectionAddUserNotificationsTask"; import CollectionCreatedNotificationsTask from "../tasks/CollectionCreatedNotificationsTask"; import CommentCreatedNotificationsTask from "../tasks/CommentCreatedNotificationsTask"; import CommentUpdatedNotificationsTask from "../tasks/CommentUpdatedNotificationsTask"; +import DocumentAddGroupNotificationsTask from "../tasks/DocumentAddGroupNotificationsTask"; import DocumentAddUserNotificationsTask from "../tasks/DocumentAddUserNotificationsTask"; import DocumentPublishedNotificationsTask from "../tasks/DocumentPublishedNotificationsTask"; import RevisionCreatedNotificationsTask from "../tasks/RevisionCreatedNotificationsTask"; @@ -20,6 +22,7 @@ export default class NotificationsProcessor extends BaseProcessor { static applicableEvents: Event["name"][] = [ "documents.publish", "documents.add_user", + "documents.add_group", "revisions.create", "collections.create", "collections.add_user", @@ -33,6 +36,8 @@ export default class NotificationsProcessor extends BaseProcessor { return this.documentPublished(event); case "documents.add_user": return this.documentAddUser(event); + case "documents.add_group": + return this.documentAddGroup(event); case "revisions.create": return this.revisionCreated(event); case "collections.create": @@ -67,6 +72,13 @@ export default class NotificationsProcessor extends BaseProcessor { await DocumentAddUserNotificationsTask.schedule(event); } + async documentAddGroup(event: DocumentGroupEvent) { + if (!event.data.isNew) { + return; + } + await DocumentAddGroupNotificationsTask.schedule(event); + } + async revisionCreated(event: RevisionEvent) { await RevisionCreatedNotificationsTask.schedule(event); } diff --git a/server/queues/tasks/DocumentAddGroupNotificationsTask.ts b/server/queues/tasks/DocumentAddGroupNotificationsTask.ts new file mode 100644 index 000000000000..0191e407bd1a --- /dev/null +++ b/server/queues/tasks/DocumentAddGroupNotificationsTask.ts @@ -0,0 +1,27 @@ +import { GroupUser } from "@server/models"; +import { DocumentGroupEvent } from "@server/types"; +import BaseTask, { TaskPriority } from "./BaseTask"; +import DocumentAddUserNotificationsTask from "./DocumentAddUserNotificationsTask"; + +export default class DocumentAddGroupNotificationsTask extends BaseTask { + public async perform(event: DocumentGroupEvent) { + const groupUsers = await GroupUser.findAll({ + where: { + groupId: event.modelId, + }, + }); + + for (const groupUser of groupUsers) { + await DocumentAddUserNotificationsTask.schedule({ + ...event, + userId: groupUser.userId, + }); + } + } + + public get options() { + return { + priority: TaskPriority.Background, + }; + } +}