feat: Allow deletion of imports (#5907)

This commit is contained in:
Tom Moor
2023-10-01 21:24:50 -04:00
committed by GitHub
parent 16cd82a732
commit e7b7032284
24 changed files with 304 additions and 184 deletions

View File

@@ -126,6 +126,7 @@ const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
export const Actions = styled(Flex)<{ $selected?: boolean }>` export const Actions = styled(Flex)<{ $selected?: boolean }>`
align-self: center; align-self: center;
justify-content: center; justify-content: center;
flex-shrink: 0;
color: ${(props) => color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary}; props.$selected ? props.theme.white : props.theme.textSecondary};
`; `;

View File

@@ -2,17 +2,21 @@ import { DownloadIcon, TrashIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu"; import { useMenuState } from "reakit/Menu";
import { FileOperationState, FileOperationType } from "@shared/types";
import FileOperation from "~/models/FileOperation";
import ContextMenu from "~/components/ContextMenu"; import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton"; import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template"; import Template from "~/components/ContextMenu/Template";
import usePolicy from "~/hooks/usePolicy";
type Props = { type Props = {
id: string; fileOperation: FileOperation;
onDelete: (ev: React.SyntheticEvent) => Promise<void>; onDelete: (ev: React.SyntheticEvent) => Promise<void>;
}; };
function FileOperationMenu({ id, onDelete }: Props) { function FileOperationMenu({ fileOperation, onDelete }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const can = usePolicy(fileOperation.id);
const menu = useMenuState({ const menu = useMenuState({
modal: true, modal: true,
}); });
@@ -28,7 +32,10 @@ function FileOperationMenu({ id, onDelete }: Props) {
type: "link", type: "link",
title: t("Download"), title: t("Download"),
icon: <DownloadIcon />, icon: <DownloadIcon />,
href: "/api/fileOperations.redirect?id=" + id, visible:
fileOperation.type === FileOperationType.Export &&
fileOperation.state === FileOperationState.Complete,
href: fileOperation.downloadUrl,
}, },
{ {
type: "separator", type: "separator",
@@ -36,6 +43,7 @@ function FileOperationMenu({ id, onDelete }: Props) {
{ {
type: "button", type: "button",
title: t("Delete"), title: t("Delete"),
visible: can.delete,
icon: <TrashIcon />, icon: <TrashIcon />,
dangerous: true, dangerous: true,
onClick: onDelete, onClick: onDelete,

View File

@@ -29,6 +29,11 @@ class FileOperation extends Model {
get sizeInMB(): string { get sizeInMB(): string {
return bytesToHumanReadable(this.size); return bytesToHumanReadable(this.size);
} }
@computed
get downloadUrl(): string {
return `/api/fileOperations.redirect?id=${this.id}`;
}
} }
export default FileOperation; export default FileOperation;

View File

@@ -10,7 +10,6 @@ import Scene from "~/components/Scene";
import Text from "~/components/Text"; import Text from "~/components/Text";
import useCurrentUser from "~/hooks/useCurrentUser"; import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import ExportDialog from "../../components/ExportDialog"; import ExportDialog from "../../components/ExportDialog";
import FileOperationListItem from "./components/FileOperationListItem"; import FileOperationListItem from "./components/FileOperationListItem";
@@ -18,7 +17,6 @@ function Export() {
const { t } = useTranslation(); const { t } = useTranslation();
const user = useCurrentUser(); const user = useCurrentUser();
const { fileOperations, dialogs } = useStores(); const { fileOperations, dialogs } = useStores();
const { showToast } = useToasts();
const handleOpenDialog = React.useCallback( const handleOpenDialog = React.useCallback(
async (ev: React.SyntheticEvent) => { async (ev: React.SyntheticEvent) => {
@@ -33,20 +31,6 @@ function Export() {
[dialogs, t] [dialogs, t]
); );
const handleDelete = React.useCallback(
async (fileOperation: FileOperation) => {
try {
await fileOperations.delete(fileOperation);
showToast(t("Export deleted"));
} catch (err) {
showToast(err.message, {
type: "error",
});
}
},
[fileOperations, showToast, t]
);
return ( return (
<Scene title={t("Export")} icon={<DownloadIcon />}> <Scene title={t("Export")} icon={<DownloadIcon />}>
<Heading>{t("Export")}</Heading> <Heading>{t("Export")}</Heading>
@@ -77,11 +61,7 @@ function Export() {
</h2> </h2>
} }
renderItem={(item: FileOperation) => ( renderItem={(item: FileOperation) => (
<FileOperationListItem <FileOperationListItem key={item.id} fileOperation={item} />
key={item.id}
fileOperation={item}
handleDelete={handleDelete}
/>
)} )}
/> />
</Scene> </Scene>

View File

@@ -10,21 +10,26 @@ import {
} from "@shared/types"; } from "@shared/types";
import FileOperation from "~/models/FileOperation"; import FileOperation from "~/models/FileOperation";
import { Action } from "~/components/Actions"; import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item"; import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner"; import Spinner from "~/components/Spinner";
import Time from "~/components/Time"; import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser"; import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import FileOperationMenu from "~/menus/FileOperationMenu"; import FileOperationMenu from "~/menus/FileOperationMenu";
type Props = { type Props = {
fileOperation: FileOperation; fileOperation: FileOperation;
handleDelete?: (fileOperation: FileOperation) => Promise<void>;
}; };
const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => { const FileOperationListItem = ({ fileOperation }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const user = useCurrentUser(); const user = useCurrentUser();
const theme = useTheme(); const theme = useTheme();
const { dialogs, fileOperations } = useStores();
const { showToast } = useToasts();
const stateMapping = { const stateMapping = {
[FileOperationState.Creating]: t("Processing"), [FileOperationState.Creating]: t("Processing"),
[FileOperationState.Uploading]: t("Processing"), [FileOperationState.Uploading]: t("Processing"),
@@ -55,6 +60,46 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
? fileOperation.name ? fileOperation.name
: t("All collections"); : t("All collections");
const handleDelete = React.useCallback(async () => {
try {
await fileOperations.delete(fileOperation);
if (fileOperation.type === FileOperationType.Import) {
showToast(t("Import deleted"));
} else {
showToast(t("Export deleted"));
}
} catch (err) {
showToast(err.message, {
type: "error",
});
}
}, [fileOperation, fileOperations, showToast, t]);
const handleConfirmDelete = React.useCallback(async () => {
dialogs.openModal({
isCentered: true,
title: t("Are you sure you want to delete this import?"),
content: (
<ConfirmationDialog
onSubmit={handleDelete}
submitText={t("Im sure")}
savingText={`${t("Deleting")}`}
danger
>
{t(
"Deleting this import will also delete all collections and documents that were created from it. This cannot be undone."
)}
</ConfirmationDialog>
),
});
}, [dialogs, t, handleDelete]);
const showMenu =
(fileOperation.type === FileOperationType.Export &&
fileOperation.state === FileOperationState.Complete) ||
fileOperation.type === FileOperationType.Import;
return ( return (
<ListItem <ListItem
title={title} title={title}
@@ -76,17 +121,18 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
</> </>
} }
actions={ actions={
fileOperation.state === FileOperationState.Complete && handleDelete ? ( showMenu && (
<Action> <Action>
<FileOperationMenu <FileOperationMenu
id={fileOperation.id} fileOperation={fileOperation}
onDelete={async (ev) => { onDelete={
ev.preventDefault(); fileOperation.type === FileOperationType.Import
await handleDelete(fileOperation); ? handleConfirmDelete
}} : handleDelete
}
/> />
</Action> </Action>
) : undefined )
} }
/> />
); );

View File

@@ -0,0 +1,36 @@
import { Transaction } from "sequelize";
import { Collection, Event, User } from "@server/models";
type Props = {
/** The collection to delete */
collection: Collection;
/** The actor who is deleting the collection */
user: User;
/** The database transaction to use */
transaction: Transaction;
/** The IP address of the current request */
ip: string;
};
export default async function collectionDestroyer({
collection,
transaction,
user,
ip,
}: Props) {
await collection.destroy({ transaction });
await Event.create(
{
name: "collections.delete",
collectionId: collection.id,
teamId: collection.teamId,
actorId: user.id,
data: {
name: collection.name,
},
ip,
},
{ transaction }
);
}

View File

@@ -1,23 +0,0 @@
import { FileOperation } from "@server/models";
import { buildAdmin, buildFileOperation } from "@server/test/factories";
import fileOperationDeleter from "./fileOperationDeleter";
describe("fileOperationDeleter", () => {
const ip = "127.0.0.1";
it("should destroy file operation", async () => {
const admin = await buildAdmin();
const fileOp = await buildFileOperation({
userId: admin.id,
teamId: admin.teamId,
});
await fileOperationDeleter(fileOp, admin, ip);
expect(
await FileOperation.count({
where: {
teamId: admin.teamId,
},
})
).toEqual(0);
});
});

View File

@@ -1,17 +1,20 @@
import { Transaction } from "sequelize";
import { FileOperation, Event, User } from "@server/models"; import { FileOperation, Event, User } from "@server/models";
import { sequelize } from "@server/storage/database";
export default async function fileOperationDeleter( type Props = {
fileOperation: FileOperation, fileOperation: FileOperation;
user: User, user: User;
ip: string ip: string;
) { transaction: Transaction;
const transaction = await sequelize.transaction(); };
try { export default async function fileOperationDeleter({
await fileOperation.destroy({ fileOperation,
user,
ip,
transaction, transaction,
}); }: Props) {
await fileOperation.destroy({ transaction });
await Event.create( await Event.create(
{ {
name: "fileOperations.delete", name: "fileOperations.delete",
@@ -24,9 +27,4 @@ export default async function fileOperationDeleter(
transaction, transaction,
} }
); );
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
} }

View File

@@ -0,0 +1,14 @@
'use strict';
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.addColumn("file_operations", "deletedAt", {
type: Sequelize.DATE,
allowNull: true,
});
},
async down (queryInterface) {
await queryInterface.removeColumn("file_operations", "deletedAt");
}
};

View File

@@ -29,6 +29,7 @@ import {
Scopes, Scopes,
DataType, DataType,
Length as SimpleLength, Length as SimpleLength,
BeforeDestroy,
} from "sequelize-typescript"; } from "sequelize-typescript";
import isUUID from "validator/lib/isUUID"; import isUUID from "validator/lib/isUUID";
import type { CollectionSort } from "@shared/types"; import type { CollectionSort } from "@shared/types";
@@ -37,6 +38,7 @@ import { sortNavigationNodes } from "@shared/utils/collections";
import slugify from "@shared/utils/slugify"; import slugify from "@shared/utils/slugify";
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers"; import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
import { CollectionValidation } from "@shared/validations"; import { CollectionValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import Document from "./Document"; import Document from "./Document";
import FileOperation from "./FileOperation"; import FileOperation from "./FileOperation";
import Group from "./Group"; import Group from "./Group";
@@ -265,6 +267,18 @@ class Collection extends ParanoidModel {
} }
} }
@BeforeDestroy
static async checkLastCollection(model: Collection) {
const total = await this.count({
where: {
teamId: model.teamId,
},
});
if (total === 1) {
throw ValidationError("Cannot delete last collection");
}
}
@AfterDestroy @AfterDestroy
static async onAfterDestroy(model: Collection) { static async onAfterDestroy(model: Collection) {
await Document.destroy({ await Document.destroy({

View File

@@ -459,16 +459,18 @@ class Document extends ParanoidModel {
return null; return null;
} }
const { includeState, userId, ...rest } = options;
// allow default preloading of collection membership if `userId` is passed in find options // allow default preloading of collection membership if `userId` is passed in find options
// almost every endpoint needs the collection membership to determine policy permissions. // almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope([ const scope = this.scope([
...(options.includeState ? [] : ["withoutState"]), ...(includeState ? [] : ["withoutState"]),
"withDrafts", "withDrafts",
{ {
method: ["withCollectionPermissions", options.userId, options.paranoid], method: ["withCollectionPermissions", userId, rest.paranoid],
}, },
{ {
method: ["withViews", options.userId], method: ["withViews", userId],
}, },
]); ]);
@@ -477,7 +479,7 @@ class Document extends ParanoidModel {
where: { where: {
id, id,
}, },
...options, ...rest,
}); });
} }
@@ -487,7 +489,7 @@ class Document extends ParanoidModel {
where: { where: {
urlId: match[1], urlId: match[1],
}, },
...options, ...rest,
}); });
} }

View File

@@ -17,7 +17,7 @@ import FileStorage from "@server/storage/files";
import Collection from "./Collection"; import Collection from "./Collection";
import Team from "./Team"; import Team from "./Team";
import User from "./User"; import User from "./User";
import IdModel from "./base/IdModel"; import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix"; import Fix from "./decorators/Fix";
@DefaultScope(() => ({ @DefaultScope(() => ({
@@ -36,7 +36,7 @@ import Fix from "./decorators/Fix";
})) }))
@Table({ tableName: "file_operations", modelName: "file_operation" }) @Table({ tableName: "file_operations", modelName: "file_operation" })
@Fix @Fix
class FileOperation extends IdModel { class FileOperation extends ParanoidModel {
@Column(DataType.ENUM(...Object.values(FileOperationType))) @Column(DataType.ENUM(...Object.values(FileOperationType)))
type: FileOperationType; type: FileOperationType;
@@ -73,7 +73,7 @@ class FileOperation extends IdModel {
throw err; throw err;
} }
} }
await this.save(); return this.save();
}; };
/** /**

View File

@@ -1,3 +1,4 @@
import { FileOperationState, FileOperationType } from "@shared/types";
import { User, Team, FileOperation } from "@server/models"; import { User, Team, FileOperation } from "@server/models";
import { allow } from "./cancan"; import { allow } from "./cancan";
@@ -13,9 +14,22 @@ allow(
} }
); );
allow(User, ["read", "delete"], FileOperation, (user, fileOperation) => { allow(User, "read", FileOperation, (user, fileOperation) => {
if (!fileOperation || user.isViewer || user.teamId !== fileOperation.teamId) { if (!fileOperation || user.isViewer || user.teamId !== fileOperation.teamId) {
return false; return false;
} }
return user.isAdmin; return user.isAdmin;
}); });
allow(User, "delete", FileOperation, (user, fileOperation) => {
if (!fileOperation || user.isViewer || user.teamId !== fileOperation.teamId) {
return false;
}
if (
fileOperation.type === FileOperationType.Export &&
fileOperation.state !== FileOperationState.Complete
) {
return false;
}
return user.isAdmin;
});

View File

@@ -0,0 +1,35 @@
import teamUpdater from "@server/commands/teamUpdater";
import { Team, User } from "@server/models";
import { sequelize } from "@server/storage/database";
import { Event as TEvent, CollectionEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
export default class CollectionDeletedProcessor extends BaseProcessor {
static applicableEvents: TEvent["name"][] = ["collections.delete"];
async perform(event: CollectionEvent) {
await sequelize.transaction(async (transaction) => {
const team = await Team.findByPk(event.teamId, {
rejectOnEmpty: true,
transaction,
lock: transaction.LOCK.UPDATE,
});
if (team?.defaultCollectionId === event.collectionId) {
const user = await User.findByPk(event.actorId, {
rejectOnEmpty: true,
paranoid: false,
transaction,
});
await teamUpdater({
params: { defaultCollectionId: null },
user,
team,
transaction,
ip: event.ip,
});
}
});
}
}

View File

@@ -8,7 +8,7 @@ import { Notification } from "@server/models";
import { Event, NotificationEvent } from "@server/types"; import { Event, NotificationEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor"; import BaseProcessor from "./BaseProcessor";
export default class NotificationsProcessor extends BaseProcessor { export default class EmailsProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = ["notifications.create"]; static applicableEvents: Event["name"][] = ["notifications.create"];
async perform(event: NotificationEvent) { async perform(event: NotificationEvent) {

View File

@@ -1,4 +1,3 @@
import invariant from "invariant";
import { FileOperationFormat, FileOperationType } from "@shared/types"; import { FileOperationFormat, FileOperationType } from "@shared/types";
import { FileOperation } from "@server/models"; import { FileOperation } from "@server/models";
import { Event as TEvent, FileOperationEvent } from "@server/types"; import { Event as TEvent, FileOperationEvent } from "@server/types";
@@ -10,16 +9,13 @@ import ImportMarkdownZipTask from "../tasks/ImportMarkdownZipTask";
import ImportNotionTask from "../tasks/ImportNotionTask"; import ImportNotionTask from "../tasks/ImportNotionTask";
import BaseProcessor from "./BaseProcessor"; import BaseProcessor from "./BaseProcessor";
export default class FileOperationsProcessor extends BaseProcessor { export default class FileOperationCreatedProcessor extends BaseProcessor {
static applicableEvents: TEvent["name"][] = ["fileOperations.create"]; static applicableEvents: TEvent["name"][] = ["fileOperations.create"];
async perform(event: FileOperationEvent) { async perform(event: FileOperationEvent) {
if (event.name !== "fileOperations.create") { const fileOperation = await FileOperation.findByPk(event.modelId, {
return; rejectOnEmpty: true,
} });
const fileOperation = await FileOperation.findByPk(event.modelId);
invariant(fileOperation, "fileOperation not found");
// map file operation type and format to the appropriate task // map file operation type and format to the appropriate task
if (fileOperation.type === FileOperationType.Import) { if (fileOperation.type === FileOperationType.Import) {

View File

@@ -0,0 +1,54 @@
import { FileOperationState, FileOperationType } from "@shared/types";
import collectionDestroyer from "@server/commands/collectionDestroyer";
import Logger from "@server/logging/Logger";
import { Collection, FileOperation, User } from "@server/models";
import { sequelize } from "@server/storage/database";
import { Event as TEvent, FileOperationEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
export default class FileOperationDeletedProcessor extends BaseProcessor {
static applicableEvents: TEvent["name"][] = ["fileOperations.delete"];
async perform(event: FileOperationEvent) {
await sequelize.transaction(async (transaction) => {
const fileOperation = await FileOperation.findByPk(event.modelId, {
rejectOnEmpty: true,
paranoid: false,
transaction,
});
if (
fileOperation.type === FileOperationType.Export ||
fileOperation.state !== FileOperationState.Complete
) {
return;
}
const user = await User.findByPk(event.actorId, {
rejectOnEmpty: true,
paranoid: false,
transaction,
});
const collections = await Collection.findAll({
transaction,
lock: transaction.LOCK.UPDATE,
where: {
teamId: fileOperation.teamId,
importId: fileOperation.id,
},
});
for (const collection of collections) {
Logger.debug("processor", "Destroying collection created from import", {
collectionId: collection.id,
});
await collectionDestroyer({
collection,
transaction,
user,
ip: event.ip,
});
}
});
}
}

View File

@@ -13,7 +13,7 @@ describe("DetachDraftsFromCollectionTask", () => {
createdById: collection.createdById, createdById: collection.createdById,
teamId: collection.teamId, teamId: collection.teamId,
}); });
await collection.destroy(); await collection.destroy({ hooks: false });
const task = new DetachDraftsFromCollectionTask(); const task = new DetachDraftsFromCollectionTask();
await task.perform({ await task.perform({

View File

@@ -7,9 +7,9 @@ import {
FileOperationState, FileOperationState,
FileOperationType, FileOperationType,
} from "@shared/types"; } from "@shared/types";
import collectionDestroyer from "@server/commands/collectionDestroyer";
import collectionExporter from "@server/commands/collectionExporter"; import collectionExporter from "@server/commands/collectionExporter";
import teamUpdater from "@server/commands/teamUpdater"; import teamUpdater from "@server/commands/teamUpdater";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication"; import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter"; import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction"; import { transaction } from "@server/middlewares/transaction";
@@ -803,44 +803,15 @@ router.post(
}).findByPk(id, { }).findByPk(id, {
transaction, transaction,
}); });
const team = await Team.findByPk(user.teamId);
authorize(user, "delete", collection); authorize(user, "delete", collection);
const total = await Collection.count({ await collectionDestroyer({
where: { collection,
teamId: user.teamId,
},
});
if (total === 1) {
throw ValidationError("Cannot delete last collection");
}
await collection.destroy({ transaction });
if (team && team.defaultCollectionId === collection.id) {
await teamUpdater({
params: { defaultCollectionId: null },
ip: ctx.request.ip,
user,
team,
transaction, transaction,
}); user,
}
await Event.create(
{
name: "collections.delete",
collectionId: collection.id,
teamId: collection.teamId,
actorId: user.id,
data: {
name: collection.name,
},
ip: ctx.request.ip, ip: ctx.request.ip,
}, });
{ transaction }
);
ctx.body = { ctx.body = {
success: true, success: true,

View File

@@ -2301,7 +2301,7 @@ describe("#documents.restore", () => {
teamId: team.id, teamId: team.id,
}); });
await document.destroy(); await document.destroy();
await collection.destroy(); await collection.destroy({ hooks: false });
const res = await server.post("/api/documents.restore", { const res = await server.post("/api/documents.restore", {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),

View File

@@ -150,7 +150,7 @@ describe("#fileOperations.list", () => {
userId: admin.id, userId: admin.id,
collectionId: collection.id, collectionId: collection.id,
}); });
await collection.destroy(); await collection.destroy({ hooks: false });
const isCollectionPresent = await Collection.findByPk(collection.id); const isCollectionPresent = await Collection.findByPk(collection.id);
expect(isCollectionPresent).toBe(null); expect(isCollectionPresent).toBe(null);
const res = await server.post("/api/fileOperations.list", { const res = await server.post("/api/fileOperations.list", {

View File

@@ -3,6 +3,7 @@ import { WhereOptions } from "sequelize";
import fileOperationDeleter from "@server/commands/fileOperationDeleter"; import fileOperationDeleter from "@server/commands/fileOperationDeleter";
import { ValidationError } from "@server/errors"; import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication"; import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate"; import validate from "@server/middlewares/validate";
import { FileOperation, Team } from "@server/models"; import { FileOperation, Team } from "@server/models";
import { authorize } from "@server/policies"; import { authorize } from "@server/policies";
@@ -105,16 +106,23 @@ router.post(
"fileOperations.delete", "fileOperations.delete",
auth({ admin: true }), auth({ admin: true }),
validate(T.FileOperationsDeleteSchema), validate(T.FileOperationsDeleteSchema),
transaction(),
async (ctx: APIContext<T.FileOperationsDeleteReq>) => { async (ctx: APIContext<T.FileOperationsDeleteReq>) => {
const { id } = ctx.input.body; const { id } = ctx.input.body;
const { user } = ctx.state.auth; const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const fileOperation = await FileOperation.unscoped().findByPk(id, { const fileOperation = await FileOperation.unscoped().findByPk(id, {
rejectOnEmpty: true, rejectOnEmpty: true,
}); });
authorize(user, "delete", fileOperation); authorize(user, "delete", fileOperation);
await fileOperationDeleter(fileOperation, user, ctx.request.ip); await fileOperationDeleter({
fileOperation,
user,
ip: ctx.request.ip,
transaction,
});
ctx.body = { ctx.body = {
success: true, success: true,

View File

@@ -216,48 +216,6 @@ describe("#team.update", () => {
expect(body.data.defaultCollectionId).toEqual(collection.id); expect(body.data.defaultCollectionId).toEqual(collection.id);
}); });
it("should default to home if default collection is deleted", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
userId: admin.id,
});
await buildCollection({
teamId: team.id,
userId: admin.id,
});
const res = await server.post("/api/team.update", {
body: {
token: admin.getJwtToken(),
defaultCollectionId: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.defaultCollectionId).toEqual(collection.id);
const deleteRes = await server.post("/api/collections.delete", {
body: {
token: admin.getJwtToken(),
id: collection.id,
},
});
expect(deleteRes.status).toEqual(200);
const res3 = await server.post("/api/auth.info", {
body: {
token: admin.getJwtToken(),
},
});
const body3 = await res3.json();
expect(res3.status).toEqual(200);
expect(body3.data.team.defaultCollectionId).toEqual(null);
});
it("should update default collection to null when collection is made private", async () => { it("should update default collection to null when collection is made private", async () => {
const team = await buildTeam(); const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id }); const admin = await buildAdmin({ teamId: team.id });

View File

@@ -739,6 +739,11 @@
"Completed": "Completed", "Completed": "Completed",
"Failed": "Failed", "Failed": "Failed",
"All collections": "All collections", "All collections": "All collections",
"Import deleted": "Import deleted",
"Export deleted": "Export deleted",
"Are you sure you want to delete this import?": "Are you sure you want to delete this import?",
"Im sure": "Im sure",
"Deleting this import will also delete all collections and documents that were created from it. This cannot be undone.": "Deleting this import will also delete all collections and documents that were created from it. This cannot be undone.",
"{{userName}} requested": "{{userName}} requested", "{{userName}} requested": "{{userName}} requested",
"Upload": "Upload", "Upload": "Upload",
"Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload", "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload",
@@ -782,7 +787,6 @@
"Danger": "Danger", "Danger": "Danger",
"You can delete this entire workspace including collections, documents, and users.": "You can delete this entire workspace including collections, documents, and users.", "You can delete this entire workspace including collections, documents, and users.": "You can delete this entire workspace including collections, documents, and users.",
"Export data": "Export data", "Export data": "Export data",
"Export deleted": "Export deleted",
"A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete.", "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete.",
"Recent exports": "Recent exports", "Recent exports": "Recent exports",
"Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.": "Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.", "Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.": "Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.",
@@ -851,7 +855,6 @@
"Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.", "Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.",
"This could be your real name, or a nickname — however youd like people to refer to you.": "This could be your real name, or a nickname — however youd like people to refer to you.", "This could be your real name, or a nickname — however youd like people to refer to you.": "This could be your real name, or a nickname — however youd like people to refer to you.",
"Are you sure you want to require invites?": "Are you sure you want to require invites?", "Are you sure you want to require invites?": "Are you sure you want to require invites?",
"Im sure": "Im sure",
"New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.": "New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.", "New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.": "New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.",
"Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.", "Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.",
"Allow members to sign-in with {{ authProvider }}": "Allow members to sign-in with {{ authProvider }}", "Allow members to sign-in with {{ authProvider }}": "Allow members to sign-in with {{ authProvider }}",