mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
b4cbb39f17
* feat: Request document access Allow users without permission to a document to request access. Notifies document managers via in-app notification and email; managers can grant or dismiss the request. - Adds AccessRequest model, migration, policy, presenter - Adds accessRequests.create/info/approve/dismiss endpoints - Adds DocumentAccessRequestNotificationsTask + email - Adds Error403 request flow with loading state and pending indicator - Auto-opens notifications popover via ?notifications=true (used in email) - Adds SplitButton primitive for permission selection in notifications - Refactors useConsumeQueryParam hook * refactor * fix: Make approve/dismiss idempotent on access requests Return success when the access request has already been dismissed, or when the user already has document membership at approve time, instead of throwing 400. Avoids racy double-clicks on notification actions producing user-visible errors. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Minor fixes --------- Co-authored-by: Tom Moor <tom@getoutline.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
296 lines
7.8 KiB
TypeScript
296 lines
7.8 KiB
TypeScript
import type { TFunction } from "i18next";
|
|
import { action, computed, observable } from "mobx";
|
|
import type { NotificationData } from "@shared/types";
|
|
import { NotificationEventType } from "@shared/types";
|
|
import {
|
|
collectionPath,
|
|
commentPath,
|
|
documentPath,
|
|
settingsPath,
|
|
} from "~/utils/routeHelpers";
|
|
import Collection from "./Collection";
|
|
import Comment from "./Comment";
|
|
import Document from "./Document";
|
|
import User from "./User";
|
|
import Model from "./base/Model";
|
|
import Field from "./decorators/Field";
|
|
import Relation from "./decorators/Relation";
|
|
|
|
export type NotificationFilter =
|
|
| "all"
|
|
| "mentions"
|
|
| "comments"
|
|
| "reactions"
|
|
| "documents"
|
|
| "collections"
|
|
| "system";
|
|
|
|
class Notification extends Model {
|
|
static modelName = "Notification";
|
|
|
|
static filterCategories: Record<NotificationFilter, NotificationEventType[]> =
|
|
{
|
|
all: [],
|
|
mentions: [
|
|
NotificationEventType.MentionedInDocument,
|
|
NotificationEventType.MentionedInComment,
|
|
NotificationEventType.GroupMentionedInDocument,
|
|
NotificationEventType.GroupMentionedInComment,
|
|
],
|
|
comments: [
|
|
NotificationEventType.CreateComment,
|
|
NotificationEventType.ResolveComment,
|
|
NotificationEventType.ReactionsCreate,
|
|
],
|
|
reactions: [NotificationEventType.ReactionsCreate],
|
|
documents: [
|
|
NotificationEventType.PublishDocument,
|
|
NotificationEventType.UpdateDocument,
|
|
NotificationEventType.CreateRevision,
|
|
NotificationEventType.AddUserToDocument,
|
|
NotificationEventType.RequestDocumentAccess,
|
|
],
|
|
collections: [
|
|
NotificationEventType.CreateCollection,
|
|
NotificationEventType.AddUserToCollection,
|
|
],
|
|
system: [
|
|
NotificationEventType.InviteAccepted,
|
|
NotificationEventType.Onboarding,
|
|
NotificationEventType.Features,
|
|
NotificationEventType.ExportCompleted,
|
|
],
|
|
};
|
|
|
|
/**
|
|
* The date the notification was marked as read.
|
|
*/
|
|
@Field
|
|
@observable
|
|
viewedAt: Date | null;
|
|
|
|
/**
|
|
* The date the notification was archived.
|
|
*/
|
|
@Field
|
|
@observable
|
|
archivedAt: Date | null;
|
|
|
|
/**
|
|
* Request ID on notifications for access requests.
|
|
*/
|
|
@Field
|
|
@observable
|
|
accessRequestId?: string;
|
|
|
|
/**
|
|
* Status of the associated access request.
|
|
*/
|
|
@Field
|
|
@observable
|
|
accessRequestStatus?: string;
|
|
|
|
/**
|
|
* The user that triggered the notification.
|
|
*/
|
|
@Relation(() => User)
|
|
actor?: User;
|
|
|
|
/**
|
|
* The document ID that the notification is associated with.
|
|
*/
|
|
documentId?: string;
|
|
|
|
/**
|
|
* The document that the notification is associated with.
|
|
*/
|
|
@Relation(() => Document, { onDelete: "cascade" })
|
|
document?: Document;
|
|
|
|
/**
|
|
* The collection ID that the notification is associated with.
|
|
*/
|
|
collectionId?: string;
|
|
|
|
/**
|
|
* The collection that the notification is associated with.
|
|
*/
|
|
@Relation(() => Collection, { onDelete: "cascade" })
|
|
collection?: Collection;
|
|
|
|
commentId?: string;
|
|
|
|
/**
|
|
* The comment that the notification is associated with.
|
|
*/
|
|
@Relation(() => Comment, { onDelete: "cascade" })
|
|
comment?: Comment;
|
|
|
|
/**
|
|
* The type of notification.
|
|
*/
|
|
event: NotificationEventType;
|
|
|
|
/**
|
|
* Additional data associated with the notification.
|
|
*/
|
|
data: NotificationData;
|
|
|
|
/**
|
|
* Mark the notification as read or unread
|
|
*
|
|
* @returns A promise that resolves when the notification has been saved.
|
|
*/
|
|
@action
|
|
toggleRead() {
|
|
this.viewedAt = this.viewedAt ? null : new Date();
|
|
return this.save();
|
|
}
|
|
|
|
/**
|
|
* Mark the notification as read
|
|
*
|
|
* @returns A promise that resolves when the notification has been saved.
|
|
*/
|
|
@action
|
|
markAsRead() {
|
|
if (this.viewedAt) {
|
|
return;
|
|
}
|
|
|
|
this.viewedAt = new Date();
|
|
return this.save();
|
|
}
|
|
|
|
/**
|
|
* Archive the notification
|
|
*
|
|
* @returns A promise that resolves when the notification has been archived.
|
|
*/
|
|
@action
|
|
archive() {
|
|
if (this.archivedAt) {
|
|
return;
|
|
}
|
|
|
|
this.archivedAt = new Date();
|
|
return this.save();
|
|
}
|
|
|
|
/**
|
|
* Returns translated text that describes the notification
|
|
*
|
|
* @param t - The translation function
|
|
* @returns The event text
|
|
*/
|
|
eventText(t: TFunction): string {
|
|
switch (this.event) {
|
|
case NotificationEventType.PublishDocument:
|
|
return t("published");
|
|
case NotificationEventType.UpdateDocument:
|
|
case NotificationEventType.CreateRevision:
|
|
return t("edited");
|
|
case NotificationEventType.CreateCollection:
|
|
return t("created the collection");
|
|
case NotificationEventType.MentionedInDocument:
|
|
case NotificationEventType.MentionedInComment:
|
|
return t("mentioned you in");
|
|
case NotificationEventType.GroupMentionedInComment:
|
|
case NotificationEventType.GroupMentionedInDocument:
|
|
return t("mentioned your group in");
|
|
case NotificationEventType.CreateComment:
|
|
return t("left a comment on");
|
|
case NotificationEventType.ResolveComment:
|
|
return t("resolved a comment on");
|
|
case NotificationEventType.ReactionsCreate:
|
|
return t("reacted {{ emoji }} to your comment on", {
|
|
emoji: this.data.emoji,
|
|
});
|
|
case NotificationEventType.AddUserToDocument:
|
|
return t("shared");
|
|
case NotificationEventType.AddUserToCollection:
|
|
return t("invited you to");
|
|
case NotificationEventType.RequestDocumentAccess:
|
|
if (this.accessRequestStatus === "approved") {
|
|
return t("was granted access to");
|
|
}
|
|
if (this.accessRequestStatus === "dismissed") {
|
|
return t("requested access to");
|
|
}
|
|
return t("is requesting access to");
|
|
default:
|
|
return this.event;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the subject of the notification. This is the title of the associated
|
|
* document.
|
|
*
|
|
* @returns The subject
|
|
*/
|
|
get subject() {
|
|
if (this.documentId) {
|
|
return this.document?.title ?? "a document";
|
|
}
|
|
if (this.collectionId) {
|
|
return this.collection?.name ?? "a collection";
|
|
}
|
|
return "Unknown";
|
|
}
|
|
|
|
/**
|
|
* Returns the path to the model associated with the notification that can be
|
|
* used with the router.
|
|
*
|
|
* @returns The router path.
|
|
*/
|
|
@computed
|
|
get path() {
|
|
switch (this.event) {
|
|
case NotificationEventType.PublishDocument:
|
|
case NotificationEventType.UpdateDocument:
|
|
case NotificationEventType.CreateRevision: {
|
|
return this.document ? documentPath(this.document) : "";
|
|
}
|
|
case NotificationEventType.AddUserToCollection:
|
|
case NotificationEventType.CreateCollection: {
|
|
const collection = this.collectionId
|
|
? this.store.rootStore.collections.get(this.collectionId)
|
|
: undefined;
|
|
return collection ? collectionPath(collection) : "";
|
|
}
|
|
case NotificationEventType.RequestDocumentAccess:
|
|
case NotificationEventType.AddUserToDocument:
|
|
case NotificationEventType.GroupMentionedInDocument:
|
|
case NotificationEventType.MentionedInDocument: {
|
|
return this.document?.path;
|
|
}
|
|
case NotificationEventType.GroupMentionedInComment:
|
|
case NotificationEventType.MentionedInComment:
|
|
case NotificationEventType.ResolveComment:
|
|
case NotificationEventType.CreateComment:
|
|
case NotificationEventType.ReactionsCreate: {
|
|
return this.document && this.comment
|
|
? commentPath(this.document, this.comment)
|
|
: this.document?.path;
|
|
}
|
|
case NotificationEventType.InviteAccepted: {
|
|
return settingsPath("members");
|
|
}
|
|
case NotificationEventType.Onboarding:
|
|
case NotificationEventType.Features: {
|
|
return "";
|
|
}
|
|
case NotificationEventType.ExportCompleted: {
|
|
return settingsPath("export");
|
|
}
|
|
default:
|
|
this.event satisfies never;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
export default Notification;
|