From 3b1c9f1efb7d02469e92537a2d1378b6cb412878 Mon Sep 17 00:00:00 2001 From: SFGrenade <25555417+SFGrenade@users.noreply.github.com> Date: Fri, 3 May 2024 23:18:27 +0200 Subject: [PATCH] feat: add admin-exclusive share-management page (#461) * testing with all_shares * share table * share table * change icon on admin page * add share size to list --------- Co-authored-by: Elias Schneider --- backend/src/share/dto/adminShare.dto.ts | 27 ++++ backend/src/share/share.controller.ts | 8 + backend/src/share/share.service.ts | 17 ++- .../admin/shares/ManageShareTable.tsx | 142 ++++++++++++++++++ frontend/src/i18n/translations/en-US.ts | 14 ++ frontend/src/pages/admin/index.tsx | 7 +- frontend/src/pages/admin/shares.tsx | 74 +++++++++ frontend/src/services/share.service.ts | 5 + 8 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 backend/src/share/dto/adminShare.dto.ts create mode 100644 frontend/src/components/admin/shares/ManageShareTable.tsx create mode 100644 frontend/src/pages/admin/shares.tsx diff --git a/backend/src/share/dto/adminShare.dto.ts b/backend/src/share/dto/adminShare.dto.ts new file mode 100644 index 0000000..4b9ecc2 --- /dev/null +++ b/backend/src/share/dto/adminShare.dto.ts @@ -0,0 +1,27 @@ +import { OmitType } from "@nestjs/swagger"; +import { Expose, plainToClass } from "class-transformer"; +import { ShareDTO } from "./share.dto"; + +export class AdminShareDTO extends OmitType(ShareDTO, [ + "files", + "from", + "fromList", +] as const) { + @Expose() + views: number; + + @Expose() + createdAt: Date; + + from(partial: Partial) { + return plainToClass(AdminShareDTO, partial, { + excludeExtraneousValues: true, + }); + } + + fromList(partial: Partial[]) { + return partial.map((part) => + plainToClass(AdminShareDTO, part, { excludeExtraneousValues: true }), + ); + } +} diff --git a/backend/src/share/share.controller.ts b/backend/src/share/share.controller.ts index 5cdc7d7..a3ae9e0 100644 --- a/backend/src/share/share.controller.ts +++ b/backend/src/share/share.controller.ts @@ -14,6 +14,7 @@ import { Throttle } from "@nestjs/throttler"; import { User } from "@prisma/client"; import { Request, Response } from "express"; import { GetUser } from "src/auth/decorator/getUser.decorator"; +import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; import { JwtGuard } from "src/auth/guard/jwt.guard"; import { CreateShareDTO } from "./dto/createShare.dto"; import { MyShareDTO } from "./dto/myShare.dto"; @@ -25,10 +26,17 @@ import { ShareOwnerGuard } from "./guard/shareOwner.guard"; import { ShareSecurityGuard } from "./guard/shareSecurity.guard"; import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard"; import { ShareService } from "./share.service"; +import { AdminShareDTO } from "./dto/adminShare.dto"; @Controller("shares") export class ShareController { constructor(private shareService: ShareService) {} + @Get("all") + @UseGuards(JwtGuard, AdministratorGuard) + async getAllShares() { + return new AdminShareDTO().fromList(await this.shareService.getShares()); + } + @Get() @UseGuards(JwtGuard) async getMyShares(@GetUser() user: User) { diff --git a/backend/src/share/share.service.ts b/backend/src/share/share.service.ts index 85821f1..3fbbeb8 100644 --- a/backend/src/share/share.service.ts +++ b/backend/src/share/share.service.ts @@ -194,6 +194,22 @@ export class ShareService { }); } + async getShares() { + const shares = await this.prisma.share.findMany({ + orderBy: { + expiration: "desc", + }, + include: { files: true, creator: true }, + }); + + return shares.map((share) => { + return { + ...share, + size: share.files.reduce((acc, file) => acc + parseInt(file.size), 0), + }; + }); + } + async getSharesByUser(userId: string) { const shares = await this.prisma.share.findMany({ where: { @@ -214,7 +230,6 @@ export class ShareService { return shares.map((share) => { return { ...share, - size: share.files.reduce((acc, file) => acc + parseInt(file.size), 0), recipients: share.recipients.map((recipients) => recipients.email), }; }); diff --git a/frontend/src/components/admin/shares/ManageShareTable.tsx b/frontend/src/components/admin/shares/ManageShareTable.tsx new file mode 100644 index 0000000..43bb434 --- /dev/null +++ b/frontend/src/components/admin/shares/ManageShareTable.tsx @@ -0,0 +1,142 @@ +import { + ActionIcon, + Box, + Group, + MediaQuery, + Skeleton, + Table, +} from "@mantine/core"; +import { useClipboard } from "@mantine/hooks"; +import { useModals } from "@mantine/modals"; +import moment from "moment"; +import { TbLink, TbTrash } from "react-icons/tb"; +import { FormattedMessage } from "react-intl"; +import useConfig from "../../../hooks/config.hook"; +import useTranslate from "../../../hooks/useTranslate.hook"; +import { MyShare } from "../../../types/share.type"; +import { byteToHumanSizeString } from "../../../utils/fileSize.util"; +import toast from "../../../utils/toast.util"; +import showShareLinkModal from "../../account/showShareLinkModal"; + +const ManageShareTable = ({ + shares, + deleteShare, + isLoading, +}: { + shares: MyShare[]; + deleteShare: (share: MyShare) => void; + isLoading: boolean; +}) => { + const modals = useModals(); + const clipboard = useClipboard(); + const config = useConfig(); + const t = useTranslate(); + + return ( + + + + + + + + + + + + + + + {isLoading + ? skeletonRows + : shares.map((share) => ( + + + + + + + + + + ))} + +
+ + + + + + + + + + + +
{share.id}{share.name}{share.creator.username}{share.views}{byteToHumanSizeString(share.size)} + {moment(share.expiration).unix() === 0 + ? "Never" + : moment(share.expiration).format("LLL")} + + + { + if (window.isSecureContext) { + clipboard.copy( + `${config.get("general.appUrl")}/s/${share.id}`, + ); + toast.success(t("common.notify.copied")); + } else { + showShareLinkModal( + modals, + share.id, + config.get("general.appUrl"), + ); + } + }} + > + + + deleteShare(share)} + > + + + +
+
+ ); +}; + +const skeletonRows = [...Array(10)].map((v, i) => ( + + + + + + + + + + + + + + + + + + + + + + +)); + +export default ManageShareTable; diff --git a/frontend/src/i18n/translations/en-US.ts b/frontend/src/i18n/translations/en-US.ts index 27686e1..edac3b6 100644 --- a/frontend/src/i18n/translations/en-US.ts +++ b/frontend/src/i18n/translations/en-US.ts @@ -224,6 +224,7 @@ export default { // /admin "admin.title": "Administration", "admin.button.users": "User management", + "admin.button.shares": "Share management", "admin.button.config": "Configuration", "admin.version": "Version", // END /admin @@ -260,6 +261,19 @@ export default { // END /admin/users + // /admin/shares + "admin.shares.title": "Share management", + "admin.shares.table.id": "Share ID", + "admin.shares.table.username": "Creator", + "admin.shares.table.visitors": "Visitors", + "admin.shares.table.expires": "Expires At", + + "admin.shares.edit.delete.title": "Delete share {id}", + "admin.shares.edit.delete.description": + "Do you really want to delete this share?", + + // END /admin/shares + // /upload "upload.title": "Upload", diff --git a/frontend/src/pages/admin/index.tsx b/frontend/src/pages/admin/index.tsx index 9b07604..b34fa62 100644 --- a/frontend/src/pages/admin/index.tsx +++ b/frontend/src/pages/admin/index.tsx @@ -10,7 +10,7 @@ import { } from "@mantine/core"; import Link from "next/link"; import { useEffect, useState } from "react"; -import { TbRefresh, TbSettings, TbUsers } from "react-icons/tb"; +import { TbLink, TbRefresh, TbSettings, TbUsers } from "react-icons/tb"; import { FormattedMessage } from "react-intl"; import Meta from "../../components/Meta"; import useTranslate from "../../hooks/useTranslate.hook"; @@ -41,6 +41,11 @@ const Admin = () => { icon: TbUsers, route: "/admin/users", }, + { + title: t("admin.button.shares"), + icon: TbLink, + route: "/admin/shares", + }, { title: t("admin.button.config"), icon: TbSettings, diff --git a/frontend/src/pages/admin/shares.tsx b/frontend/src/pages/admin/shares.tsx new file mode 100644 index 0000000..af5294b --- /dev/null +++ b/frontend/src/pages/admin/shares.tsx @@ -0,0 +1,74 @@ +import { Group, Space, Text, Title } from "@mantine/core"; +import { useModals } from "@mantine/modals"; +import { useEffect, useState } from "react"; +import { FormattedMessage } from "react-intl"; +import Meta from "../../components/Meta"; +import ManageShareTable from "../../components/admin/shares/ManageShareTable"; +import useTranslate from "../../hooks/useTranslate.hook"; +import shareService from "../../services/share.service"; +import { MyShare } from "../../types/share.type"; +import toast from "../../utils/toast.util"; + +const Shares = () => { + const [shares, setShares] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const modals = useModals(); + const t = useTranslate(); + + const getShares = () => { + setIsLoading(true); + shareService.list().then((shares) => { + setShares(shares); + setIsLoading(false); + }); + }; + + const deleteShare = (share: MyShare) => { + modals.openConfirmModal({ + title: t("admin.shares.edit.delete.title", { + id: share.id, + }), + children: ( + + + + ), + labels: { + confirm: t("common.button.delete"), + cancel: t("common.button.cancel"), + }, + confirmProps: { color: "red" }, + onConfirm: async () => { + shareService + .remove(share.id) + .then(() => setShares(shares.filter((v) => v.id != share.id))) + .catch(toast.axiosError); + }, + }); + }; + + useEffect(() => { + getShares(); + }, []); + + return ( + <> + + + + <FormattedMessage id="admin.shares.title" /> + + + + + + + ); +}; + +export default Shares; diff --git a/frontend/src/services/share.service.ts b/frontend/src/services/share.service.ts index 6ff9058..fc134c0 100644 --- a/frontend/src/services/share.service.ts +++ b/frontend/src/services/share.service.ts @@ -11,6 +11,10 @@ import { } from "../types/share.type"; import api from "./api.service"; +const list = async (): Promise => { + return (await api.get(`shares/all`)).data; +}; + const create = async (share: CreateShare) => { return (await api.post("shares", share)).data; }; @@ -131,6 +135,7 @@ const removeReverseShare = async (id: string) => { }; export default { + list, create, completeShare, revertComplete,