mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e26e0dd3ef | |||
| a32dec4474 | |||
| 07d22918fb | |||
| 9d98e4547a | |||
| e755185a8f | |||
| 2c2efe57db | |||
| 9f96245055 | |||
| b87b33ccc9 | |||
| e2a1cea7d6 | |||
| 8160d5718a | |||
| 001825f4a4 | |||
| 30258f6c28 |
@@ -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);
|
||||
|
||||
@@ -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 />
|
||||
) : (
|
||||
|
||||
@@ -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 doesn’t 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>
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
@@ -27,3 +27,4 @@ import "./group";
|
||||
import "./webhookSubscription";
|
||||
import "./userMembership";
|
||||
import "./emoji";
|
||||
import "./accessRequest";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
"You’ve not got any drafts at the moment.": "You’ve 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 doesn’t look like you have permission to access this document.": "It doesn’t 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 you’re looking for cannot be found. It might have been deleted or the link is incorrect.": "The page you’re 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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user