Compare commits

...

12 Commits

Author SHA1 Message Date
Salihu e26e0dd3ef minor fixes 2026-01-15 19:40:22 +01:00
Salihu a32dec4474 minor fixes 2026-01-14 21:14:21 +01:00
Salihu 07d22918fb requested changes 2026-01-13 23:44:32 +01:00
Salihu 9d98e4547a requested changes 2026-01-11 21:26:00 +01:00
Salihu e755185a8f minor fixes 2026-01-09 23:42:11 +01:00
Salihu 2c2efe57db track access requests 2026-01-09 18:53:21 +01:00
Salihu 9f96245055 select permission from notification 2026-01-07 23:38:59 +01:00
Salihu b87b33ccc9 minor fix 2025-12-14 20:39:01 +01:00
Salihu e2a1cea7d6 add tests 2025-12-08 20:20:48 +01:00
Salihu 8160d5718a minor fixes 2025-12-08 00:21:55 +01:00
Salihu 001825f4a4 fix type error 2025-12-08 00:03:16 +01:00
Salihu 30258f6c28 request document access 2025-12-07 23:41:09 +01:00
32 changed files with 1849 additions and 16 deletions
@@ -3,16 +3,22 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import styled from "styled-components";
import { s, hover, truncateMultiline } from "@shared/styles";
import { DocumentPermission, NotificationEventType } from "@shared/types";
import Notification from "~/models/Notification";
import useStores from "~/hooks/useStores";
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
import Button from "../Button";
import Flex from "../Flex";
import InputMemberPermissionSelect from "../InputMemberPermissionSelect";
import Text from "../Text";
import Time from "../Time";
import { UnreadBadge } from "../UnreadBadge";
import { Permission } from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import { client } from "~/utils/ApiClient";
const CommentEditor = lazyWithRetry(
() => import("~/scenes/Document/components/CommentEditor")
@@ -28,6 +34,31 @@ function NotificationListItem({ notification, onNavigate }: Props) {
const { collections } = useStores();
const collectionId = notification.document?.collectionId;
const collection = collectionId ? collections.get(collectionId) : undefined;
const [processing, setProcessing] = React.useState(false);
const [selectedPermission, setSelectedPermission] =
React.useState<DocumentPermission>(DocumentPermission.Read);
const isAccessRequest =
notification.event === NotificationEventType.RequestDocumentAccess &&
notification.accessRequestId;
const permissions: Permission[] = React.useMemo(
() => [
{
label: t("View only"),
value: DocumentPermission.Read,
},
{
label: t("Can edit"),
value: DocumentPermission.ReadWrite,
},
{
label: t("Manage"),
value: DocumentPermission.Admin,
},
],
[t]
);
const handleClick: React.MouseEventHandler<HTMLAnchorElement> = (event) => {
if (event.altKey) {
@@ -42,11 +73,66 @@ function NotificationListItem({ notification, onNavigate }: Props) {
onNavigate();
};
const handleApprove = React.useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!collection || !notification.actor || processing) {
return;
}
setProcessing(true);
try {
await client.post("/accessRequests.approve", {
id: notification.accessRequestId,
permission: selectedPermission,
});
toast.success(
t(`Permissions for {{ userName }} updated`, {
userName: notification.actor?.name,
})
);
void notification.markAsRead();
} catch {
toast.error(t("Failed to approve access request"));
} finally {
setProcessing(false);
}
},
[notification, processing, selectedPermission, t, collection]
);
const handleDismiss = React.useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (processing) {
return;
}
setProcessing(true);
try {
await client.post("/accessRequests.dismiss", {
id: notification.accessRequestId,
});
toast.success(t("Access request dismissed"));
void notification.markAsRead();
} catch {
toast.error(t("Failed to dismiss access request"));
} finally {
setProcessing(false);
}
},
[notification, processing, t]
);
return (
<StyledLink to={notification.path ?? ""} onClick={handleClick}>
<Container gap={8} $unread={!notification.viewedAt}>
<StyledAvatar model={notification.actor} />
<Flex column>
<Flex column gap={4} style={{ flex: 1 }}>
<Text as="div" size="small">
<Text weight="bold">
{notification.actor?.name ?? t("Unknown")}
@@ -63,6 +149,35 @@ function NotificationListItem({ notification, onNavigate }: Props) {
defaultValue={toJS(notification.comment.data)}
/>
)}
{isAccessRequest && !notification.viewedAt && (
<ActionButtons gap={8} align="center">
<PermissionSelect>
<InputMemberPermissionSelect
permissions={permissions}
value={selectedPermission}
onChange={(permission) =>
setSelectedPermission(permission as DocumentPermission)
}
disabled={processing}
/>
</PermissionSelect>
<Button
onClick={handleApprove}
disabled={processing}
size="small"
>
{t("Approve")}
</Button>
<Button
onClick={handleDismiss}
disabled={processing}
neutral
size="small"
>
{t("Dismiss")}
</Button>
</ActionButtons>
)}
</Flex>
{notification.viewedAt ? null : <UnreadBadge style={{ right: 20 }} />}
</Container>
@@ -102,4 +217,13 @@ const Container = styled(Flex)<{ $unread: boolean }>`
}
`;
const ActionButtons = styled(Flex)`
margin-top: 8px;
flex-wrap: wrap;
`;
const PermissionSelect = styled.div`
min-width: 140px;
`;
export default observer(NotificationListItem);
+10
View File
@@ -32,6 +32,13 @@ class Notification extends Model {
@observable
archivedAt: Date | null;
/**
* Request ID on notifications for access requests.
*/
@Field
@observable
accessRequestId?: string;
/**
* The user that triggered the notification.
*/
@@ -137,6 +144,8 @@ class Notification extends Model {
return t("shared");
case NotificationEventType.AddUserToCollection:
return t("invited you to");
case NotificationEventType.RequestDocumentAccess:
return t("is requesting access to");
default:
return this.event;
}
@@ -179,6 +188,7 @@ class Notification extends Model {
: undefined;
return collection ? collectionPath(collection.path) : "";
}
case NotificationEventType.RequestDocumentAccess:
case NotificationEventType.AddUserToDocument:
case NotificationEventType.GroupMentionedInDocument:
case NotificationEventType.MentionedInDocument: {
@@ -213,7 +213,7 @@ function DataLoader({ match, children }: Props) {
) : error instanceof PaymentRequiredError ? (
<Error402 />
) : error instanceof AuthorizationError ? (
<Error403 />
<Error403 documentId={documentSlug} />
) : error instanceof NotFoundError ? (
<Error404 />
) : (
+72 -8
View File
@@ -1,28 +1,92 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import Flex from "@shared/components/Flex";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import { navigateToHome } from "~/actions/definitions/navigation";
import { client } from "~/utils/ApiClient";
const Error403 = () => {
type Props = {
/** The document ID to request access to. */
documentId?: string;
};
const Error403 = ({ documentId }: Props) => {
const { t } = useTranslation();
const history = useHistory();
const [requesting, setRequesting] = React.useState(false);
const [requested, setRequested] = React.useState(false);
React.useEffect(() => {
const checkRequested = async () => {
const request = await client.post("/accessRequests.info", {
documentId,
});
if (request?.data?.status === "pending") {
setRequested(true);
} else {
setRequested(false);
}
};
checkRequested();
}, [documentId]);
const handleRequestAccess = React.useCallback(async () => {
if (!documentId || requesting || requested) {
return;
}
setRequesting(true);
try {
await client.post("/accessRequests.create", { documentId });
setRequested(true);
toast.success(t("Access request sent"));
} catch {
toast.error(t("Failed to send access request"));
} finally {
setRequesting(false);
}
}, [documentId, t, requested, requesting]);
return (
<Scene title={t("No access to this doc")}>
<Heading>{t("No access to this doc")}</Heading>
<Flex gap={20} style={{ maxWidth: 500 }} column>
<Empty size="large">
{t(
"It doesnt look like you have permission to access this document."
)}{" "}
{t("Please request access from the document owner.")}
</Empty>
{requested ? (
<Empty size="large">
{t(
"Your request to access this document has been sent. You will be notified once access is granted."
)}{" "}
</Empty>
) : (
<Empty size="large">
{t(
"It doesn't look like you have permission to access this document."
)}{" "}
{t("You can request access from a document manager.")}
</Empty>
)}
<Flex gap={8}>
<Button action={navigateToHome} hideIcon>
{documentId && (
<Button
onClick={handleRequestAccess}
disabled={requesting || requested}
neutral={requesting || requested}
>
{requested
? t("Request sent")
: requesting
? t("Requesting…")
: t("Request access")}
</Button>
)}
<Button action={navigateToHome} hideIcon neutral={!!documentId}>
{t("Home")}
</Button>
<Button onClick={history.goBack} neutral>
+8
View File
@@ -135,6 +135,14 @@ function Notifications() {
"Receive a notification when an export you requested has been completed"
),
},
{
event: NotificationEventType.RequestDocumentAccess,
icon: <CheckboxIcon checked />,
title: t("Document access requested"),
description: t(
"Receive a notification when a user requests access to a document you manage"
),
},
{
visible: isCloudHosted,
icon: <AcademicCapIcon />,
@@ -112,6 +112,7 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
case "authenticationProviders.update":
case "notifications.create":
case "notifications.update":
case "documents.request_access":
// Ignored
return;
case "users.create":
@@ -0,0 +1,93 @@
import * as React from "react";
import { Document, User } from "@server/models";
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import Header from "./components/Header";
import Heading from "./components/Heading";
type InputProps = EmailProps & {
documentId: string;
actorId: string;
teamUrl: string;
};
type BeforeSend = {
document: Document;
actor: User;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to users who can manage a document when someone requests access.
*/
export default class DocumentAccessRequestEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected get category() {
return EmailMessageCategory.Notification;
}
protected async beforeSend({ documentId, actorId }: InputProps) {
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
}
const actor = await User.findByPk(actorId);
if (!actor) {
return false;
}
return { document, actor };
}
protected subject({ actor, document }: Props) {
return `${actor.name} is requesting access to "${document.titleWithDefault}"`;
}
protected preview({ actor }: Props): string {
return `${actor.name} is requesting access to a document`;
}
protected fromName({ actor }: Props) {
return actor.name;
}
protected renderAsText({ actor, teamUrl, document }: Props): string {
return `
${actor.name} is requesting access to "${document.titleWithDefault}".
Open the document to share it with them: ${teamUrl}${document.path}
`;
}
protected render(props: Props) {
const { document, actor, teamUrl } = props;
const documentUrl = `${teamUrl}${document.path}?ref=notification-email`;
return (
<EmailTemplate
previewText={this.preview(props)}
goToAction={{ url: documentUrl, name: "View Document" }}
>
<Header />
<Body>
<Heading>{document.titleWithDefault}</Heading>
<p>
{actor.name} is requesting access to the{" "}
<a href={documentUrl}>{document.titleWithDefault}</a> document.
</p>
<p>Open the document to share it with them.</p>
<p>
<Button href={documentUrl}>View Document</Button>
</p>
</Body>
</EmailTemplate>
);
}
}
@@ -0,0 +1,83 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable("access_requests", {
id: {
type: Sequelize.UUID,
primaryKey: true,
defaultValue: Sequelize.UUIDV4,
allowNull: false,
},
status: {
type: Sequelize.STRING,
allowNull: false,
defaultValue: "pending",
},
respondedAt: {
type: Sequelize.DATE,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
documentId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "documents",
key: "id",
},
onDelete: "CASCADE",
onUpdate: "CASCADE",
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "users",
key: "id",
},
onDelete: "CASCADE",
onUpdate: "CASCADE",
},
teamId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "teams",
key: "id",
},
onDelete: "CASCADE",
onUpdate: "CASCADE",
},
responderId: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "users",
key: "id",
},
onDelete: "SET NULL",
onUpdate: "CASCADE",
},
});
await queryInterface.addIndex("access_requests", ["documentId"]);
await queryInterface.addIndex("access_requests", ["userId"]);
await queryInterface.addIndex("access_requests", ["teamId"]);
await queryInterface.addIndex("access_requests", ["status"]);
},
async down(queryInterface) {
await queryInterface.dropTable("access_requests");
},
};
@@ -0,0 +1,24 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("notifications", "accessRequestId", {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "access_requests",
key: "id",
},
onDelete: "SET NULL",
onUpdate: "CASCADE",
});
await queryInterface.addIndex("notifications", ["accessRequestId"]);
},
async down(queryInterface) {
await queryInterface.removeIndex("notifications", ["accessRequestId"]);
await queryInterface.removeColumn("notifications", "accessRequestId");
},
};
+135
View File
@@ -0,0 +1,135 @@
import type { InferAttributes, InferCreationAttributes } from "sequelize";
import {
Table,
ForeignKey,
Column,
BelongsTo,
DataType,
Default,
AllowNull,
DefaultScope,
BeforeCreate,
} from "sequelize-typescript";
import Document from "./Document";
import Team from "./Team";
import User from "./User";
import Fix from "./decorators/Fix";
import IdModel from "./base/IdModel";
import { IsIn } from "class-validator";
import { ValidationError } from "@server/errors";
export enum AccessRequestStatus {
Pending = "pending",
Approved = "approved",
Dismissed = "dismissed",
}
@DefaultScope(() => ({
include: [
{
association: "user",
required: true,
},
{
association: "responder",
required: false,
},
],
}))
@Table({
tableName: "access_requests",
modelName: "access_request",
})
@Fix
class AccessRequest extends IdModel<
InferAttributes<AccessRequest>,
Partial<InferCreationAttributes<AccessRequest>>
> {
@Default(AccessRequestStatus.Pending)
@IsIn([Object.values(AccessRequestStatus)])
@Column(DataType.STRING)
status: AccessRequestStatus;
@AllowNull
@Column
respondedAt: Date | null;
// associations
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => User, "responderId")
responder: User | null;
@AllowNull
@ForeignKey(() => User)
@Column(DataType.UUID)
responderId: string | null;
@BeforeCreate
static async validateNoDuplicatePendingRequest(instance: AccessRequest) {
const { documentId, userId } = instance;
const existingRequest = await this.pendingRequest({ documentId, userId });
if (existingRequest) {
throw ValidationError(
"A pending access request already exists for this document and user."
);
}
}
/**
* get the user's pending request.
*
* @param documentId The document ID or slug.
* @param userId The user ID.
*
* @returns the pending request or null.
*/
public static async pendingRequest({
documentId,
userId,
}: {
documentId?: string;
userId?: string;
}): Promise<AccessRequest | null> {
if (!documentId || !userId) {
return null;
}
const document = await Document.findByPk(documentId);
if (!document) {
return null;
}
const req = await this.findOne({
where: {
documentId: document.id,
userId,
status: AccessRequestStatus.Pending,
},
});
return req;
}
}
export default AccessRequest;
+21 -1
View File
@@ -551,12 +551,32 @@ class Collection extends ParanoidModel<
* either via group or direct membership.
*
* @param collectionId
* @param permission optional permission filter
*
* @returns userIds
*/
static async membershipUserIds(collectionId: string) {
static async membershipUserIds(
collectionId: string,
permission?: CollectionPermission
) {
const collection = await this.scope("withAllMemberships").findOne({
where: { id: collectionId },
include: [
{
association: "memberships",
required: false,
...(permission && { where: { permission } }),
separate: true,
},
{
association: "groupMemberships",
required: false,
...(permission && { where: { permission } }),
separate: true,
},
],
});
if (!collection) {
return [];
}
+21 -1
View File
@@ -45,6 +45,7 @@ import {
import { MaxLength } from "class-validator";
import isUUID from "validator/lib/isUUID";
import type {
DocumentPermission,
NavigationNode,
ProsemirrorData,
SourceMetadata,
@@ -679,11 +680,30 @@ class Document extends ArchivableModel<
* either via group or direct membership.
*
* @param documentId
* @param permission optional permission filter
*
* @returns userIds
*/
static async membershipUserIds(documentId: string) {
static async membershipUserIds(
documentId: string,
permission?: DocumentPermission
) {
const document = await this.scope("withAllMemberships").findOne({
where: { id: documentId },
include: [
{
association: "memberships",
required: false,
...(permission && { where: { permission } }),
separate: true,
},
{
association: "groupMemberships",
required: false,
...(permission && { where: { permission } }),
separate: true,
},
],
});
if (!document) {
return [];
+9
View File
@@ -32,6 +32,7 @@ import Team from "./Team";
import User from "./User";
import Group from "./Group";
import Fix from "./decorators/Fix";
import AccessRequest from "./AccessRequest";
let baseDomain;
@@ -195,6 +196,14 @@ class Notification extends Model<
@Column(DataType.UUID)
membershipId: string;
@BelongsTo(() => AccessRequest, "accessRequestId")
accessRequest: AccessRequest;
@AllowNull
@ForeignKey(() => AccessRequest)
@Column(DataType.UUID)
accessRequestId: string;
@AfterCreate
static async createEvent(
model: Notification,
+2
View File
@@ -69,3 +69,5 @@ export { default as WebhookDelivery } from "./WebhookDelivery";
export { default as Subscription } from "./Subscription";
export { default as Emoji } from "./Emoji";
export { default as AccessRequest } from "./AccessRequest";
+7
View File
@@ -0,0 +1,7 @@
import { AccessRequest, User } from "@server/models";
import { allow } from "./cancan";
import { isOwner, isTeamAdmin, or } from "./utils";
allow(User, "read", AccessRequest, (actor, accessRequest) =>
or(isTeamAdmin(actor, accessRequest?.user), isOwner(actor, accessRequest))
);
+1
View File
@@ -27,3 +27,4 @@ import "./group";
import "./webhookSubscription";
import "./userMembership";
import "./emoji";
import "./accessRequest";
+20
View File
@@ -0,0 +1,20 @@
import { AccessRequest } from "@server/models";
import presentUser from "./user";
export default function present(accessRequest: AccessRequest) {
return {
id: accessRequest.id,
documentId: accessRequest.documentId,
userId: accessRequest.userId,
user: accessRequest.user ? presentUser(accessRequest.user) : undefined,
teamId: accessRequest.teamId,
status: accessRequest.status,
responderId: accessRequest.responderId,
responder: accessRequest.responder
? presentUser(accessRequest.responder)
: undefined,
respondedAt: accessRequest.respondedAt,
createdAt: accessRequest.createdAt,
updatedAt: accessRequest.updatedAt,
};
}
+2
View File
@@ -29,6 +29,7 @@ import presentTeam from "./team";
import presentUser from "./user";
import presentView from "./view";
import presentEmoji from "./emoji";
import presentAccessRequest from "./accessRequests";
export {
presentApiKey,
@@ -63,4 +64,5 @@ export {
presentUser,
presentView,
presentEmoji,
presentAccessRequest,
};
+1
View File
@@ -10,6 +10,7 @@ export default async function presentNotification(
return {
id: notification.id,
viewedAt: notification.viewedAt,
accessRequestId: notification.accessRequestId,
archivedAt: notification.archivedAt,
createdAt: notification.createdAt,
event: notification.event,
@@ -5,6 +5,7 @@ import CollectionSharedEmail from "@server/emails/templates/CollectionSharedEmai
import CommentCreatedEmail from "@server/emails/templates/CommentCreatedEmail";
import CommentMentionedEmail from "@server/emails/templates/CommentMentionedEmail";
import CommentResolvedEmail from "@server/emails/templates/CommentResolvedEmail";
import DocumentAccessRequestEmail from "@server/emails/templates/DocumentAccessRequestEmail";
import DocumentMentionedEmail from "@server/emails/templates/DocumentMentionedEmail";
import DocumentPublishedOrUpdatedEmail from "@server/emails/templates/DocumentPublishedOrUpdatedEmail";
import DocumentSharedEmail from "@server/emails/templates/DocumentSharedEmail";
@@ -197,6 +198,22 @@ export default class EmailsProcessor extends BaseProcessor {
).schedule({
delay: Minute.ms,
});
return;
}
case NotificationEventType.RequestDocumentAccess: {
await new DocumentAccessRequestEmail(
{
to: notification.user.email,
documentId: notification.documentId,
actorId: notification.actorId,
teamUrl: notification.team.url,
},
{ notificationId: notification.id }
).schedule({
delay: Minute.ms,
});
return;
}
}
}
@@ -8,6 +8,7 @@ import {
DocumentUserEvent,
DocumentGroupEvent,
CommentReactionEvent,
DocumentAccessRequestEvent,
} from "@server/types";
import CollectionAddUserNotificationsTask from "../tasks/CollectionAddUserNotificationsTask";
import CollectionCreatedNotificationsTask from "../tasks/CollectionCreatedNotificationsTask";
@@ -15,6 +16,7 @@ import CommentCreatedNotificationsTask from "../tasks/CommentCreatedNotification
import CommentUpdatedNotificationsTask from "../tasks/CommentUpdatedNotificationsTask";
import ReactionCreatedNotificationsTask from "../tasks/ReactionCreatedNotificationsTask";
import ReactionRemovedNotificationsTask from "../tasks/ReactionRemovedNotificationsTask";
import DocumentAccessRequestNotificationsTask from "../tasks/DocumentAccessRequestNotificationsTask";
import DocumentAddGroupNotificationsTask from "../tasks/DocumentAddGroupNotificationsTask";
import DocumentAddUserNotificationsTask from "../tasks/DocumentAddUserNotificationsTask";
import DocumentPublishedNotificationsTask from "../tasks/DocumentPublishedNotificationsTask";
@@ -26,6 +28,7 @@ export default class NotificationsProcessor extends BaseProcessor {
"documents.publish",
"documents.add_user",
"documents.add_group",
"documents.request_access",
"revisions.create",
"collections.create",
"collections.add_user",
@@ -43,6 +46,8 @@ export default class NotificationsProcessor extends BaseProcessor {
return this.documentAddUser(event);
case "documents.add_group":
return this.documentAddGroup(event);
case "documents.request_access":
return this.documentAccessRequest(event);
case "revisions.create":
return this.revisionCreated(event);
case "collections.create":
@@ -84,6 +89,10 @@ export default class NotificationsProcessor extends BaseProcessor {
await new DocumentAddGroupNotificationsTask().schedule(event);
}
async documentAccessRequest(event: DocumentAccessRequestEvent) {
await new DocumentAccessRequestNotificationsTask().schedule(event);
}
async revisionCreated(event: RevisionEvent) {
await new RevisionCreatedNotificationsTask().schedule(event);
}
@@ -0,0 +1,248 @@
import {
NotificationEventType,
DocumentPermission,
CollectionPermission,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import { Notification, UserMembership } from "@server/models";
import DocumentAccessRequestNotificationsTask from "./DocumentAccessRequestNotificationsTask";
import {
buildCollection,
buildDocument,
buildTeam,
buildUser,
} from "@server/test/factories";
const ip = "127.0.0.1";
describe("DocumentAccessRequestNotificationsTask", () => {
let task: DocumentAccessRequestNotificationsTask;
beforeEach(() => {
task = new DocumentAccessRequestNotificationsTask();
jest.clearAllMocks();
});
describe("perform", () => {
it("should fail with the correct error if the document is not found", async () => {
const loggerSpy = jest.spyOn(Logger, "debug");
await task.perform({
name: "documents.request_access",
documentId: "doc1",
teamId: "team1",
actorId: "actor1",
ip,
});
expect(loggerSpy).toHaveBeenCalledWith(
"task",
"Document not found for access request notification",
{ documentId: "doc1" }
);
});
it("should send notifications to document managers", async () => {
const spy = jest.spyOn(Notification, "create");
const team = await buildTeam();
const manager1 = await buildUser({ teamId: team.id });
const manager2 = await buildUser({ teamId: team.id });
const user = await buildUser({ teamId: team.id, name: "actor" });
const document = await buildDocument({
teamId: team.id,
});
for (const m of [manager1, manager2]) {
await UserMembership.create({
userId: m.id,
documentId: document.id,
createdById: m.id,
permission: DocumentPermission.Admin,
});
}
const task = new DocumentAccessRequestNotificationsTask();
await task.perform({
name: "documents.request_access",
documentId: document.id,
teamId: team.id,
actorId: user.id,
ip,
});
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
event: NotificationEventType.RequestDocumentAccess,
userId: manager1.id,
actorId: user.id,
documentId: document.id,
teamId: team.id,
})
);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
event: NotificationEventType.RequestDocumentAccess,
userId: manager2.id,
actorId: user.id,
documentId: document.id,
teamId: team.id,
})
);
});
it("should send notifications to collection managers", async () => {
const spy = jest.spyOn(Notification, "create");
const team = await buildTeam();
const manager1 = await buildUser({ teamId: team.id });
const manager2 = await buildUser({ teamId: team.id });
const user = await buildUser({ teamId: team.id, name: "actor" });
const collection = await buildCollection({
teamId: team.id,
createdById: manager1.id,
});
const document = await buildDocument({
teamId: team.id,
collectionId: collection.id,
});
for (const m of [manager1, manager2]) {
await UserMembership.create({
userId: m.id,
collectionId: collection.id,
createdById: m.id,
permission: CollectionPermission.Admin,
});
}
const task = new DocumentAccessRequestNotificationsTask();
await task.perform({
name: "documents.request_access",
documentId: document.id,
teamId: team.id,
actorId: user.id,
ip,
});
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
event: NotificationEventType.RequestDocumentAccess,
userId: manager1.id,
actorId: user.id,
documentId: document.id,
teamId: team.id,
})
);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
event: NotificationEventType.RequestDocumentAccess,
userId: manager2.id,
actorId: user.id,
documentId: document.id,
teamId: team.id,
})
);
});
it("should not send notifications to the requesting user", async () => {
const spy = jest.spyOn(Notification, "create");
const team = await buildTeam();
const admin = await buildUser({ teamId: team.id });
const document = await buildDocument({
teamId: team.id,
createdById: admin.id,
});
await UserMembership.create({
userId: admin.id,
documentId: document.id,
permission: DocumentPermission.Admin,
createdById: admin.id,
});
const task = new DocumentAccessRequestNotificationsTask();
await task.perform({
name: "documents.request_access",
documentId: document.id,
teamId: team.id,
actorId: admin.id,
ip,
});
expect(spy).not.toHaveBeenCalled();
});
it("should not send notifications to suspended users", async () => {
const spy = jest.spyOn(Notification, "create");
const team = await buildTeam();
const admin = await buildUser({
teamId: team.id,
suspendedAt: new Date(),
});
const actor = await buildUser({ teamId: team.id });
const document = await buildDocument({
teamId: team.id,
createdById: admin.id,
});
await UserMembership.create({
userId: admin.id,
documentId: document.id,
permission: DocumentPermission.Admin,
createdById: admin.id,
});
const task = new DocumentAccessRequestNotificationsTask();
await task.perform({
name: "documents.request_access",
documentId: document.id,
teamId: team.id,
actorId: actor.id,
ip,
});
expect(spy).not.toHaveBeenCalled();
});
it("should not send notification if user has disabled this notification type", async () => {
const spy = jest.spyOn(Notification, "create");
const team = await buildTeam();
const admin = await buildUser({ teamId: team.id });
const actor = await buildUser({ teamId: team.id });
const document = await buildDocument({
teamId: team.id,
createdById: admin.id,
});
await UserMembership.create({
userId: admin.id,
documentId: document.id,
permission: DocumentPermission.Admin,
createdById: admin.id,
});
// disable notifications for this event type
admin.setNotificationEventType(
NotificationEventType.RequestDocumentAccess,
false
);
await admin.save();
const task = new DocumentAccessRequestNotificationsTask();
await task.perform({
name: "documents.request_access",
documentId: document.id,
teamId: team.id,
actorId: actor.id,
ip,
});
expect(spy).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,118 @@
import { Op } from "sequelize";
import {
NotificationEventType,
DocumentPermission,
CollectionPermission,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import {
Document,
Notification,
User,
Collection,
AccessRequest,
} from "@server/models";
import { DocumentAccessRequestEvent } from "@server/types";
import { BaseTask, TaskPriority } from "./base/BaseTask";
import { uniq } from "lodash";
import { AccessRequestStatus } from "@server/models/AccessRequest";
/**
* Notification task that sends notifications to users who can manage a document
* when someone requests access to it.
*/
export default class DocumentAccessRequestNotificationsTask extends BaseTask<DocumentAccessRequestEvent> {
public async perform(event: DocumentAccessRequestEvent) {
const document = await Document.findByPk(event.documentId);
if (!document) {
Logger.debug(
"task",
`Document not found for access request notification`,
{
documentId: event.documentId,
}
);
return;
}
// users can only have one pending access request per document
const accessRequest = await AccessRequest.findOne({
where: {
documentId: document.id,
userId: event.actorId,
status: AccessRequestStatus.Pending,
},
});
if (!accessRequest) {
Logger.debug("task", `Access request not found for notification`, {
documentId: event.documentId,
userId: event.actorId,
});
return;
}
const recipients = await this.findDocumentManagers(document);
for (const recipient of recipients) {
if (
recipient.id === event.actorId ||
recipient.isSuspended ||
!recipient.subscribedToEventType(
NotificationEventType.RequestDocumentAccess
)
) {
continue;
}
await Notification.create({
event: NotificationEventType.RequestDocumentAccess,
userId: recipient.id,
actorId: event.actorId,
teamId: event.teamId,
documentId: event.documentId,
accessRequestId: accessRequest.id,
});
}
}
/**
* Find all users who can manage the document (have admin/manage permissions).
*
* @param document - the document to find managers for.
* @returns list of users who can manage the document.
*/
private async findDocumentManagers(document: Document): Promise<User[]> {
const documentMemberships = await Document.membershipUserIds(
document.id,
DocumentPermission.Admin
);
let collectionMemberships: string[] = [];
if (document.collectionId) {
collectionMemberships = await Collection.membershipUserIds(
document.collectionId,
CollectionPermission.Admin
);
}
const managerIds = uniq([...documentMemberships, ...collectionMemberships]);
// Fetch the actual user objects
const users = await User.findAll({
where: {
id: {
[Op.in]: Array.from(managerIds),
},
teamId: document.teamId,
},
});
return users;
}
public get options() {
return {
priority: TaskPriority.Background,
};
}
}
@@ -0,0 +1,468 @@
import { DocumentPermission } from "@shared/types";
import { AccessRequest, Event, UserMembership } from "@server/models";
import { AccessRequestStatus } from "@server/models/AccessRequest";
import {
buildAdmin,
buildDocument,
buildTeam,
buildUser,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#accessRequests.create", () => {
it("should require id", async () => {
const user = await buildUser();
const res = await server.post("/api/accessRequests.create", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("documentId: Must be a valid UUID or slug");
});
it("should require authentication", async () => {
const document = await buildDocument();
const res = await server.post("/api/accessRequests.create", {
body: {
documentId: document.id,
},
});
expect(res.status).toEqual(401);
});
it("should return 404 for non-existent document", async () => {
const user = await buildUser();
const res = await server.post("/api/accessRequests.create", {
body: {
token: user.getJwtToken(),
documentId: "a8f22c38-f4eb-4909-8c30-b927af36c5f3",
},
});
const body = await res.json();
expect(res.status).toEqual(404);
expect(body.message).toEqual("Document could not be found");
});
it("should create event when requesting access to a document", async () => {
const team = await buildTeam();
const owner = await buildUser({ teamId: team.id });
const requester = await buildUser({ teamId: team.id });
const document = await buildDocument({
teamId: team.id,
createdById: owner.id,
});
const res = await server.post("/api/accessRequests.create", {
body: {
token: requester.getJwtToken(),
documentId: document.id,
},
});
expect(res.status).toEqual(200);
const events = await Event.findAll({
where: {
teamId: team.id,
name: "documents.request_access",
},
});
expect(events.length).toEqual(1);
expect(events[0].documentId).toEqual(document.id);
expect(events[0].actorId).toEqual(requester.id);
});
it("should work with document urlId", async () => {
const team = await buildTeam();
const owner = await buildUser({ teamId: team.id });
const requester = await buildUser({ teamId: team.id });
const document = await buildDocument({
teamId: team.id,
createdById: owner.id,
});
const res = await server.post("/api/accessRequests.create", {
body: {
token: requester.getJwtToken(),
documentId: document.urlId,
},
});
expect(res.status).toEqual(200);
});
it("should not allow new request if pending exists", async () => {
const team = await buildTeam();
const requester = await buildUser({ teamId: team.id });
const admin = await buildAdmin({ teamId: team.id });
const document = await buildDocument({
createdById: admin.id,
teamId: team.id,
});
// Create first access request
const res1 = await server.post("/api/accessRequests.create", {
body: {
token: requester.getJwtToken(),
documentId: document.id,
},
});
// Try to create another
const res2 = await server.post("/api/accessRequests.create", {
body: {
token: requester.getJwtToken(),
documentId: document.id,
},
});
expect(res1.status).toEqual(200);
expect(res2.status).toEqual(400);
// Verify only one access request exists
const count = await AccessRequest.count({
where: {
documentId: document.id,
userId: requester.id,
},
});
expect(count).toEqual(1);
});
it("should allow creating new request after previous was dismissed", async () => {
const team = await buildTeam();
const requester = await buildUser({ teamId: team.id });
const admin = await buildAdmin({ teamId: team.id });
const document = await buildDocument({
createdById: admin.id,
teamId: team.id,
});
// Create and dismiss first request
const res1 = await AccessRequest.create({
documentId: document.id,
userId: requester.id,
teamId: team.id,
status: AccessRequestStatus.Dismissed,
responderId: admin.id,
respondedAt: new Date(),
});
// Create new request
const res2 = await server.post("/api/accessRequests.create", {
body: {
token: requester.getJwtToken(),
documentId: document.id,
},
});
const body = await res2.json();
expect(res2.status).toEqual(200);
expect(body.data.id).not.toEqual(res1.id);
expect(body.data.status).toEqual(AccessRequestStatus.Pending);
});
});
describe("#accessRequests.info", () => {
it("should require authentication", async () => {
const res = await server.post("/api/accessRequests.info");
expect(res.status).toEqual(401);
});
it("should fail if both id and documentId are missing", async () => {
const user = await buildUser();
const res = await server.post("/api/accessRequests.info", {
body: {
token: user.getJwtToken(),
},
});
expect(res.status).toEqual(400);
});
it("should return access request correctly by id", async () => {
const team = await buildTeam();
const requester = await buildUser({ teamId: team.id });
const admin = await buildAdmin({ teamId: team.id });
const document = await buildDocument({
createdById: admin.id,
teamId: team.id,
});
const accessRequest = await AccessRequest.create({
documentId: document.id,
userId: requester.id,
teamId: team.id,
});
const res = await server.post("/api/accessRequests.info", {
body: {
token: requester.getJwtToken(),
id: accessRequest.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(accessRequest.id);
expect(body.data.status).toEqual(AccessRequestStatus.Pending);
});
it("should return access request correctly by documentId", async () => {
const team = await buildTeam();
const requester = await buildUser({ teamId: team.id });
const admin = await buildAdmin({ teamId: team.id });
const document = await buildDocument({
createdById: admin.id,
teamId: team.id,
});
const accessRequest = await AccessRequest.create({
documentId: document.id,
userId: requester.id,
teamId: team.id,
});
const res = await server.post("/api/accessRequests.info", {
body: {
token: requester.getJwtToken(),
documentId: document.urlId,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(accessRequest.id);
expect(body.data.status).toEqual(AccessRequestStatus.Pending);
});
it("should return 404 if access request not found", async () => {
const user = await buildUser();
const res = await server.post("/api/accessRequests.info", {
body: {
token: user.getJwtToken(),
id: "00000000-0000-0000-0000-000000000000",
},
});
expect(res.status).toEqual(404);
});
});
describe("#accessRequests.approve", () => {
it("should require authentication", async () => {
const res = await server.post("/api/accessRequests.approve");
expect(res.status).toEqual(401);
});
it("should approve an access request and grant access", async () => {
const team = await buildTeam();
const requester = await buildUser({ teamId: team.id });
const admin = await buildAdmin({ teamId: team.id });
const document = await buildDocument({
createdById: admin.id,
teamId: team.id,
});
const accessRequest = await AccessRequest.create({
documentId: document.id,
userId: requester.id,
teamId: team.id,
status: AccessRequestStatus.Pending,
});
const res = await server.post("/api/accessRequests.approve", {
body: {
token: admin.getJwtToken(),
id: accessRequest.id,
permission: DocumentPermission.ReadWrite,
},
});
const body = await res.json();
expect(body.data.status).toEqual(AccessRequestStatus.Approved);
expect(body.data.responderId).toEqual(admin.id);
// // Verify that the user now has access
const membership = await UserMembership.findOne({
where: {
userId: requester.id,
documentId: document.id,
},
});
expect(membership).toBeTruthy();
expect(membership?.permission).toEqual(DocumentPermission.ReadWrite);
});
it("should not allow non-managers to approve requests", async () => {
const team = await buildTeam();
const requester = await buildUser({ teamId: team.id });
const admin = await buildAdmin({ teamId: team.id });
const nonManager = await buildUser();
const document = await buildDocument({
createdById: admin.id,
teamId: team.id,
});
await UserMembership.create({
userId: admin.id,
documentId: document.id,
createdById: admin.id,
permission: DocumentPermission.ReadWrite,
});
const accessRequest = await AccessRequest.create({
documentId: document.id,
userId: requester.id,
teamId: team.id,
});
const res = await server.post("/api/accessRequests.approve", {
body: {
token: nonManager.getJwtToken(),
id: accessRequest.id,
permission: DocumentPermission.ReadWrite,
},
});
expect(res.status).toEqual(403);
});
it("should not allow approving requests that have been responded to", async () => {
const team = await buildTeam();
const requester = await buildUser({ teamId: team.id });
const admin = await buildAdmin({ teamId: team.id });
const document = await buildDocument({
createdById: admin.id,
teamId: team.id,
});
// Create access request that's already approved
const accessRequest = await AccessRequest.create({
documentId: document.id,
userId: requester.id,
teamId: team.id,
status: AccessRequestStatus.Approved,
responderId: admin.id,
respondedAt: new Date(),
});
const res = await server.post("/api/accessRequests.approve", {
body: {
token: admin.getJwtToken(),
id: accessRequest.id,
permission: DocumentPermission.ReadWrite,
},
});
expect(res.status).toEqual(400);
});
});
describe("#accessRequests.dismiss", () => {
it("should require authentication", async () => {
const res = await server.post("/api/accessRequests.dismiss");
expect(res.status).toEqual(401);
});
it("should dismiss an access request", async () => {
const team = await buildTeam();
const requester = await buildUser({ teamId: team.id });
const admin = await buildAdmin({ teamId: team.id });
const document = await buildDocument({
createdById: admin.id,
teamId: team.id,
});
// Create access request
const accessRequest = await AccessRequest.create({
documentId: document.id,
userId: requester.id,
teamId: team.id,
});
const res = await server.post("/api/accessRequests.dismiss", {
body: {
token: admin.getJwtToken(),
id: accessRequest.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.status).toEqual(AccessRequestStatus.Dismissed);
expect(body.data.responderId).toEqual(admin.id);
const membership = await UserMembership.findOne({
where: {
userId: requester.id,
documentId: document.id,
},
});
expect(membership).toBeNull();
});
it("should not allow non-managers to dismiss requests", async () => {
const team = await buildTeam();
const requester = await buildUser({ teamId: team.id });
const admin = await buildAdmin({ teamId: team.id });
const nonManager = await buildUser();
const document = await buildDocument({
userId: admin.id,
teamId: team.id,
});
// add non-manager to the document with editor access
await UserMembership.create({
userId: admin.id,
documentId: document.id,
createdById: admin.id,
permission: DocumentPermission.ReadWrite,
});
// Create access request
const accessRequest = await AccessRequest.create({
documentId: document.id,
userId: requester.id,
teamId: team.id,
});
const res = await server.post("/api/accessRequests.dismiss", {
body: {
token: nonManager.getJwtToken(),
id: accessRequest.id,
},
});
expect(res.status).toEqual(403);
});
it("should not allow dismissing requests that have been responded to", async () => {
const team = await buildTeam();
const requester = await buildUser({ teamId: team.id });
const admin = await buildAdmin({ teamId: team.id });
const document = await buildDocument({
createdById: admin.id,
teamId: team.id,
});
// Create access request that's already dismissed
const accessRequest = await AccessRequest.create({
documentId: document.id,
userId: requester.id,
teamId: team.id,
status: AccessRequestStatus.Dismissed,
responderId: admin.id,
respondedAt: new Date(),
});
const res = await server.post("/api/accessRequests.dismiss", {
body: {
token: admin.getJwtToken(),
id: accessRequest.id,
},
});
expect(res.status).toEqual(400);
});
});
@@ -0,0 +1,183 @@
import Router from "koa-router";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Document, AccessRequest, UserMembership, Event } from "@server/models";
import { AccessRequestStatus } from "@server/models/AccessRequest";
import { authorize } from "@server/policies";
import { presentAccessRequest, presentPolicies } from "@server/presenters";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import * as T from "./schema";
import {
DocumentPermissionPriority,
getDocumentPermission,
} from "@server/utils/permissions";
import {
AuthorizationError,
InvalidRequestError,
NotFoundError,
} from "@server/errors";
const router = new Router();
router.post(
"accessRequests.create",
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
auth(),
validate(T.AccessRequestsCreateSchema),
transaction(),
async (ctx: APIContext<T.AccessRequestsCreateReq>) => {
const { documentId } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const document = await Document.findByPk(documentId, { transaction });
if (!document) {
throw NotFoundError("Document could not be found");
}
const accessRequest = await AccessRequest.createWithCtx(ctx, {
documentId: document.id,
teamId: document.teamId,
userId: user.id,
status: AccessRequestStatus.Pending,
});
await Event.createFromContext(ctx, {
name: "documents.request_access",
documentId: document.id,
});
ctx.body = {
data: presentAccessRequest(accessRequest),
policies: presentPolicies(user, [accessRequest]),
};
}
);
router.post(
"accessRequests.info",
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
auth(),
validate(T.AccessRequestInfoSchema),
async (ctx: APIContext<T.AccessRequestInfoReq>) => {
const { user } = ctx.state.auth;
const { id, documentId } = ctx.input.body;
const accessReq = id
? await AccessRequest.findByPk(id)
: await AccessRequest.pendingRequest({ documentId, userId: user.id });
if (!accessReq) {
return ctx.throw(404, "Access request not found");
}
authorize(user, "read", accessReq);
ctx.body = {
data: presentAccessRequest(accessReq),
policies: presentPolicies(user, [accessReq]),
};
}
);
router.post(
"accessRequests.approve",
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
auth(),
validate(T.AccessRequestsApproveSchema),
transaction(),
async (ctx: APIContext<T.AccessRequestsApproveReq>) => {
const { id, permission } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const accessRequest = await AccessRequest.unscoped().findByPk(id, {
rejectOnEmpty: true,
transaction,
lock: transaction.LOCK.UPDATE,
});
if (accessRequest.status !== AccessRequestStatus.Pending) {
throw InvalidRequestError("Access request has already been responded to");
}
const document = await Document.findByPk(accessRequest.documentId, {
userId: user.id,
transaction,
});
authorize(user, "share", document);
const adminPermission = await getDocumentPermission({
userId: user.id,
documentId: document.id,
});
if (
!adminPermission ||
DocumentPermissionPriority[permission] >
DocumentPermissionPriority[adminPermission]
) {
throw AuthorizationError();
}
accessRequest.status = AccessRequestStatus.Approved;
accessRequest.responderId = user.id;
accessRequest.respondedAt = new Date();
await accessRequest.saveWithCtx(ctx, { transaction });
await UserMembership.create(
{
userId: accessRequest.userId,
documentId: accessRequest.documentId,
permission: permission,
createdById: user.id,
},
{ transaction }
);
ctx.body = {
data: presentAccessRequest(accessRequest),
policies: presentPolicies(user, [accessRequest]),
};
}
);
router.post(
"accessRequests.dismiss",
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
auth(),
validate(T.AccessRequestsDismissSchema),
transaction(),
async (ctx: APIContext<T.AccessRequestsDismissReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const accessRequest = await AccessRequest.unscoped().findByPk(id, {
rejectOnEmpty: true,
transaction,
lock: transaction.LOCK.UPDATE,
});
if (accessRequest.status !== AccessRequestStatus.Pending) {
throw InvalidRequestError("Access request has already been responded to");
}
const document = await Document.findByPk(accessRequest.documentId, {
userId: user.id,
transaction,
});
authorize(user, "share", document);
accessRequest.status = AccessRequestStatus.Dismissed;
accessRequest.responderId = user.id;
accessRequest.respondedAt = new Date();
await accessRequest.saveWithCtx(ctx, { transaction });
ctx.body = {
data: presentAccessRequest(accessRequest),
policies: presentPolicies(user, [accessRequest]),
};
}
);
export default router;
@@ -0,0 +1 @@
export { default } from "./accessRequests";
@@ -0,0 +1,52 @@
import { z } from "zod";
import { DocumentPermission } from "@shared/types";
import { BaseSchema } from "@server/routes/api/schema";
import { zodIdType } from "@server/utils/zod";
const BaseIdSchema = z.object({
id: z.string().uuid(),
});
export const AccessRequestsCreateSchema = BaseSchema.extend({
body: z.object({
documentId: zodIdType(),
}),
});
export type AccessRequestsCreateReq = z.infer<
typeof AccessRequestsCreateSchema
>;
export const AccessRequestInfoSchema = BaseSchema.extend({
body: z
.object({
id: z.string().uuid().optional(),
documentId: zodIdType().optional(),
})
.refine((data) => data.id || data.documentId, {
message: "Either 'id' or 'documentId' must be provided",
path: ["body"],
}),
});
export type AccessRequestInfoReq = z.infer<typeof AccessRequestInfoSchema>;
export const AccessRequestsApproveSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
permission: z
.nativeEnum(DocumentPermission)
.default(DocumentPermission.Read),
}),
});
export type AccessRequestsApproveReq = z.infer<
typeof AccessRequestsApproveSchema
>;
export const AccessRequestsDismissSchema = BaseSchema.extend({
body: BaseIdSchema,
});
export type AccessRequestsDismissReq = z.infer<
typeof AccessRequestsDismissSchema
>;
@@ -5856,3 +5856,92 @@ describe("#documents.documents", () => {
expect(body).toMatchSnapshot();
});
});
describe("#documents.request_access", () => {
it("should require id", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.request_access", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Must be a valid UUID or slug");
});
it("should require authentication", async () => {
const document = await buildDocument();
const res = await server.post("/api/documents.request_access", {
body: {
id: document.id,
},
});
expect(res.status).toEqual(401);
});
it("should return 404 for non-existent document", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.request_access", {
body: {
token: user.getJwtToken(),
id: "a8f22c38-f4eb-4909-8c30-b927af36c5f3",
},
});
const body = await res.json();
expect(res.status).toEqual(404);
expect(body.message).toEqual("Document could not be found");
});
it("should create event when requesting access to a document", async () => {
const team = await buildTeam();
const owner = await buildUser({ teamId: team.id });
const requester = await buildUser({ teamId: team.id });
const document = await buildDocument({
teamId: team.id,
createdById: owner.id,
});
const res = await server.post("/api/documents.request_access", {
body: {
token: requester.getJwtToken(),
id: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
const events = await Event.findAll({
where: {
teamId: team.id,
name: "documents.request_access",
},
});
expect(events.length).toEqual(1);
expect(events[0].documentId).toEqual(document.id);
expect(events[0].actorId).toEqual(requester.id);
});
it("should work with document urlId", async () => {
const team = await buildTeam();
const owner = await buildUser({ teamId: team.id });
const requester = await buildUser({ teamId: team.id });
const document = await buildDocument({
teamId: team.id,
createdById: owner.id,
});
const res = await server.post("/api/documents.request_access", {
body: {
token: requester.getJwtToken(),
id: document.urlId,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
});
});
+2
View File
@@ -46,6 +46,7 @@ import urls from "./urls";
import userMemberships from "./userMemberships";
import users from "./users";
import views from "./views";
import accessRequests from "./accessRequests";
const api = new Koa<AppState, AppContext>();
const router = new Router();
@@ -85,6 +86,7 @@ router.use("/", users.routes());
router.use("/", collections.routes());
router.use("/", comments.routes());
router.use("/", documents.routes());
router.use("/", accessRequests.routes());
router.use("/", emojis.routes());
router.use("/", pins.routes());
router.use("/", revisions.routes());
+7
View File
@@ -235,6 +235,7 @@ export type DocumentEvent = BaseEvent<Document> &
createdAt: string;
}
| DocumentMovedEvent
| DocumentAccessRequestEvent
);
export type EmptyTrashEvent = {
@@ -285,6 +286,11 @@ export type DocumentUserEvent = BaseEvent<UserMembership> & {
};
};
export type DocumentAccessRequestEvent = BaseEvent<Document> & {
name: "documents.request_access";
documentId: string;
};
export type DocumentGroupEvent = BaseEvent<GroupMembership> & {
name: "documents.add_group" | "documents.remove_group";
documentId: string;
@@ -454,6 +460,7 @@ export type Event =
| AuthenticationProviderEvent
| DocumentEvent
| DocumentUserEvent
| DocumentAccessRequestEvent
| DocumentMovedEvent
| DocumentGroupEvent
| PinEvent
+17 -4
View File
@@ -349,7 +349,13 @@
"Sorry, an error occurred.": "Sorry, an error occurred.",
"Click to retry": "Click to retry",
"Back": "Back",
"Manage": "Manage",
"Permissions for {{ userName }} updated": "Permissions for {{ userName }} updated",
"Failed to approve access request": "Failed to approve access request",
"Access request dismissed": "Access request dismissed",
"Failed to dismiss access request": "Failed to dismiss access request",
"Unknown": "Unknown",
"Approve": "Approve",
"Mark all as read": "Mark all as read",
"You're all caught up": "You're all caught up",
"Client type": "Client type",
@@ -381,7 +387,6 @@
"{{userName}} edited": "{{userName}} edited",
"Results": "Results",
"No results for {{query}}": "No results for {{query}}",
"Manage": "Manage",
"All members": "All members",
"Everyone in the workspace": "Everyone in the workspace",
"{{ count }} member": "{{ count }} member",
@@ -423,7 +428,6 @@
"Access inherited from collection": "Access inherited from collection",
"{{ userName }} was removed from the document": "{{ userName }} was removed from the document",
"Could not remove user": "Could not remove user",
"Permissions for {{ userName }} updated": "Permissions for {{ userName }} updated",
"Could not update user": "Could not update user",
"Has access through <2>parent</2>": "Has access through <2>parent</2>",
"Suspended": "Suspended",
@@ -664,6 +668,7 @@
"reacted {{ emoji }} to your comment on": "reacted {{ emoji }} to your comment on",
"shared": "shared",
"invited you to": "invited you to",
"is requesting access to": "is requesting access to",
"Choose a date": "Choose a date",
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
"Scopes": "Scopes",
@@ -796,9 +801,15 @@
"No documents found for your filters.": "No documents found for your filters.",
"Youve not got any drafts at the moment.": "Youve not got any drafts at the moment.",
"Payment Required": "Payment Required",
"Access request sent": "Access request sent",
"Failed to send access request": "Failed to send access request",
"No access to this doc": "No access to this doc",
"It doesnt look like you have permission to access this document.": "It doesnt look like you have permission to access this document.",
"Please request access from the document owner.": "Please request access from the document owner.",
"Your request to access this document has been sent. You will be notified once access is granted.": "Your request to access this document has been sent. You will be notified once access is granted.",
"It doesn't look like you have permission to access this document.": "It doesn't look like you have permission to access this document.",
"You can request access from a document manager.": "You can request access from a document manager.",
"Request sent": "Request sent",
"Requesting…": "Requesting…",
"Request access": "Request access",
"Not found": "Not found",
"The page youre looking for cannot be found. It might have been deleted or the link is incorrect.": "The page youre looking for cannot be found. It might have been deleted or the link is incorrect.",
"We were unable to load the document while offline.": "We were unable to load the document while offline.",
@@ -1164,6 +1175,8 @@
"Receive a notification when you are given access to a collection": "Receive a notification when you are given access to a collection",
"Export completed": "Export completed",
"Receive a notification when an export you requested has been completed": "Receive a notification when an export you requested has been completed",
"Document access requested": "Document access requested",
"Receive a notification when a user requests access to a document you manage": "Receive a notification when a user requests access to a document you manage",
"Getting started": "Getting started",
"Tips on getting started with features and functionality": "Tips on getting started with features and functionality",
"New features": "New features",
+2
View File
@@ -375,6 +375,7 @@ export enum NotificationEventType {
Onboarding = "emails.onboarding",
Features = "emails.features",
ExportCompleted = "emails.export_completed",
RequestDocumentAccess = "documents.request_access",
}
export enum NotificationChannelType {
@@ -414,6 +415,7 @@ export const NotificationEventDefaults: Record<NotificationEventType, boolean> =
[NotificationEventType.ExportCompleted]: true,
[NotificationEventType.AddUserToDocument]: true,
[NotificationEventType.AddUserToCollection]: true,
[NotificationEventType.RequestDocumentAccess]: true,
};
export enum UnfurlResourceType {