1
0
mirror of https://github.com/stonith404/pingvin-share.git synced 2024-10-01 00:50:10 +02:00

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 <login@eliasschneider.com>
This commit is contained in:
SFGrenade 2024-05-03 23:18:27 +02:00 committed by GitHub
parent a45184995f
commit 3b1c9f1efb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 292 additions and 2 deletions

View File

@ -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<AdminShareDTO>) {
return plainToClass(AdminShareDTO, partial, {
excludeExtraneousValues: true,
});
}
fromList(partial: Partial<AdminShareDTO>[]) {
return partial.map((part) =>
plainToClass(AdminShareDTO, part, { excludeExtraneousValues: true }),
);
}
}

View File

@ -14,6 +14,7 @@ import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { GetUser } from "src/auth/decorator/getUser.decorator"; 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 { JwtGuard } from "src/auth/guard/jwt.guard";
import { CreateShareDTO } from "./dto/createShare.dto"; import { CreateShareDTO } from "./dto/createShare.dto";
import { MyShareDTO } from "./dto/myShare.dto"; import { MyShareDTO } from "./dto/myShare.dto";
@ -25,10 +26,17 @@ import { ShareOwnerGuard } from "./guard/shareOwner.guard";
import { ShareSecurityGuard } from "./guard/shareSecurity.guard"; import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard"; import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
import { ShareService } from "./share.service"; import { ShareService } from "./share.service";
import { AdminShareDTO } from "./dto/adminShare.dto";
@Controller("shares") @Controller("shares")
export class ShareController { export class ShareController {
constructor(private shareService: ShareService) {} constructor(private shareService: ShareService) {}
@Get("all")
@UseGuards(JwtGuard, AdministratorGuard)
async getAllShares() {
return new AdminShareDTO().fromList(await this.shareService.getShares());
}
@Get() @Get()
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async getMyShares(@GetUser() user: User) { async getMyShares(@GetUser() user: User) {

View File

@ -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) { async getSharesByUser(userId: string) {
const shares = await this.prisma.share.findMany({ const shares = await this.prisma.share.findMany({
where: { where: {
@ -214,7 +230,6 @@ export class ShareService {
return shares.map((share) => { return shares.map((share) => {
return { return {
...share, ...share,
size: share.files.reduce((acc, file) => acc + parseInt(file.size), 0),
recipients: share.recipients.map((recipients) => recipients.email), recipients: share.recipients.map((recipients) => recipients.email),
}; };
}); });

View File

@ -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 (
<Box sx={{ display: "block", overflowX: "auto" }}>
<Table verticalSpacing="sm">
<thead>
<tr>
<th>
<FormattedMessage id="account.shares.table.id" />
</th>
<th>
<FormattedMessage id="account.shares.table.name" />
</th>
<th>
<FormattedMessage id="admin.shares.table.username" />
</th>
<th>
<FormattedMessage id="account.shares.table.visitors" />
</th>
<th>
<FormattedMessage id="account.shares.table.size" />
</th>
<th>
<FormattedMessage id="account.shares.table.expiresAt" />
</th>
<th></th>
</tr>
</thead>
<tbody>
{isLoading
? skeletonRows
: shares.map((share) => (
<tr key={share.id}>
<td>{share.id}</td>
<td>{share.name}</td>
<td>{share.creator.username}</td>
<td>{share.views}</td>
<td>{byteToHumanSizeString(share.size)}</td>
<td>
{moment(share.expiration).unix() === 0
? "Never"
: moment(share.expiration).format("LLL")}
</td>
<td>
<Group position="right">
<ActionIcon
color="victoria"
variant="light"
size={25}
onClick={() => {
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"),
);
}
}}
>
<TbLink />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => deleteShare(share)}
>
<TbTrash />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</Box>
);
};
const skeletonRows = [...Array(10)].map((v, i) => (
<tr key={i}>
<td>
<Skeleton key={i} height={20} />
</td>
<MediaQuery smallerThan="md" styles={{ display: "none" }}>
<td>
<Skeleton key={i} height={20} />
</td>
</MediaQuery>
<td>
<Skeleton key={i} height={20} />
</td>
<td>
<Skeleton key={i} height={20} />
</td>
<td>
<Skeleton key={i} height={20} />
</td>
<td>
<Skeleton key={i} height={20} />
</td>
</tr>
));
export default ManageShareTable;

View File

@ -224,6 +224,7 @@ export default {
// /admin // /admin
"admin.title": "Administration", "admin.title": "Administration",
"admin.button.users": "User management", "admin.button.users": "User management",
"admin.button.shares": "Share management",
"admin.button.config": "Configuration", "admin.button.config": "Configuration",
"admin.version": "Version", "admin.version": "Version",
// END /admin // END /admin
@ -260,6 +261,19 @@ export default {
// END /admin/users // 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
"upload.title": "Upload", "upload.title": "Upload",

View File

@ -10,7 +10,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; 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 { FormattedMessage } from "react-intl";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
import useTranslate from "../../hooks/useTranslate.hook"; import useTranslate from "../../hooks/useTranslate.hook";
@ -41,6 +41,11 @@ const Admin = () => {
icon: TbUsers, icon: TbUsers,
route: "/admin/users", route: "/admin/users",
}, },
{
title: t("admin.button.shares"),
icon: TbLink,
route: "/admin/shares",
},
{ {
title: t("admin.button.config"), title: t("admin.button.config"),
icon: TbSettings, icon: TbSettings,

View File

@ -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<MyShare[]>([]);
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: (
<Text size="sm">
<FormattedMessage id="admin.shares.edit.delete.description" />
</Text>
),
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 (
<>
<Meta title={t("admin.shares.title")} />
<Group position="apart" align="baseline" mb={20}>
<Title mb={30} order={3}>
<FormattedMessage id="admin.shares.title" />
</Title>
</Group>
<ManageShareTable
shares={shares}
deleteShare={deleteShare}
isLoading={isLoading}
/>
<Space h="xl" />
</>
);
};
export default Shares;

View File

@ -11,6 +11,10 @@ import {
} from "../types/share.type"; } from "../types/share.type";
import api from "./api.service"; import api from "./api.service";
const list = async (): Promise<MyShare[]> => {
return (await api.get(`shares/all`)).data;
};
const create = async (share: CreateShare) => { const create = async (share: CreateShare) => {
return (await api.post("shares", share)).data; return (await api.post("shares", share)).data;
}; };
@ -131,6 +135,7 @@ const removeReverseShare = async (id: string) => {
}; };
export default { export default {
list,
create, create,
completeShare, completeShare,
revertComplete, revertComplete,