feat: add admin storage manager
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage DNS Records"
|
||||
text="List and manage records"
|
||||
/>
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import UserFileList from "@/components/file";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Cloud Storage",
|
||||
description: "List and manage cloud storage.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Cloud Storage"
|
||||
text="List and manage cloud storage"
|
||||
link="/docs/cloud-storage"
|
||||
linkText="Cloud Storage"
|
||||
/>
|
||||
<UserFileList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/storage/admin"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs(["s3_config_01"]);
|
||||
|
||||
if (!configs.s3_config_01 || !configs.s3_config_01.enabled) {
|
||||
return NextResponse.json({ error: "Invalid S3 config" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
buckets: configs.s3_config_01.buckets,
|
||||
enabled: configs.s3_config_01.enabled,
|
||||
provider_name: configs.s3_config_01.provider_name,
|
||||
platform: configs.s3_config_01.platform,
|
||||
channel: configs.s3_config_01.channel,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Error listing files" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getUserFiles, softDeleteUserFiles } from "@/lib/dto/files";
|
||||
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/r2";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const page = url.searchParams.get("page");
|
||||
const size = url.searchParams.get("size");
|
||||
const bucket = url.searchParams.get("bucket") || "";
|
||||
|
||||
const configs = await getMultipleConfigs(["s3_config_01"]);
|
||||
if (!configs.s3_config_01.enabled) {
|
||||
return NextResponse.json("S3 is not enabled", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!configs.s3_config_01 ||
|
||||
!configs.s3_config_01.access_key_id ||
|
||||
!configs.s3_config_01.secret_access_key ||
|
||||
!configs.s3_config_01.endpoint
|
||||
) {
|
||||
return NextResponse.json("Invalid S3 config", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const buckets = configs.s3_config_01.buckets || [];
|
||||
if (!buckets.find((b) => b.bucket === bucket)) {
|
||||
return NextResponse.json("Bucket does not exist", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const res = await getUserFiles({
|
||||
page: Number(page) || 1,
|
||||
limit: Number(size) || 20,
|
||||
bucket,
|
||||
channel: configs.s3_config_01.channel,
|
||||
platform: configs.s3_config_01.platform,
|
||||
});
|
||||
|
||||
return NextResponse.json(res);
|
||||
} catch (error) {
|
||||
console.error("Error listing files:", error);
|
||||
return NextResponse.json({ error: "Error listing files" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const { key, bucket } = await request.json();
|
||||
if (!key || !bucket) {
|
||||
return NextResponse.json("key and bucket is required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs(["s3_config_01"]);
|
||||
if (!configs.s3_config_01.enabled) {
|
||||
return NextResponse.json("S3 is not enabled", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!configs.s3_config_01 ||
|
||||
!configs.s3_config_01.access_key_id ||
|
||||
!configs.s3_config_01.secret_access_key ||
|
||||
!configs.s3_config_01.endpoint
|
||||
) {
|
||||
return NextResponse.json("Invalid S3 config", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const buckets = configs.s3_config_01.buckets || [];
|
||||
if (!buckets.find((b) => b.bucket === bucket)) {
|
||||
return NextResponse.json("Bucket does not exist", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const signedUrl = await getSignedUrlForDownload(
|
||||
key,
|
||||
createS3Client(
|
||||
configs.s3_config_01.endpoint,
|
||||
configs.s3_config_01.access_key_id,
|
||||
configs.s3_config_01.secret_access_key,
|
||||
),
|
||||
bucket,
|
||||
);
|
||||
return NextResponse.json({ signedUrl });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Error generating download URL" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const { keys, ids, bucket } = await request.json();
|
||||
|
||||
if (!keys || !ids || !bucket) {
|
||||
return NextResponse.json("key and bucket is required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs(["s3_config_01"]);
|
||||
if (!configs.s3_config_01.enabled) {
|
||||
return NextResponse.json("S3 is not enabled", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!configs.s3_config_01 ||
|
||||
!configs.s3_config_01.access_key_id ||
|
||||
!configs.s3_config_01.secret_access_key ||
|
||||
!configs.s3_config_01.endpoint
|
||||
) {
|
||||
return NextResponse.json("Invalid S3 config", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const buckets = configs.s3_config_01.buckets || [];
|
||||
if (!buckets.find((b) => b.bucket === bucket)) {
|
||||
return NextResponse.json("Bucket does not exist", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const R2 = createS3Client(
|
||||
configs.s3_config_01.endpoint,
|
||||
configs.s3_config_01.access_key_id,
|
||||
configs.s3_config_01.secret_access_key,
|
||||
);
|
||||
|
||||
for (const key of keys) {
|
||||
await deleteFile(key, R2, bucket);
|
||||
}
|
||||
await softDeleteUserFiles(ids);
|
||||
return NextResponse.json({ message: "File deleted successfully" });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Error deleting file" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export async function GET(req: NextRequest) {
|
||||
limit: Number(size) || 20,
|
||||
bucket,
|
||||
userId: user.id,
|
||||
status: 1,
|
||||
channel: configs.s3_config_01.channel,
|
||||
platform: configs.s3_config_01.platform,
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
} from "../ui/dropdown-menu";
|
||||
import { Modal } from "../ui/modal";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { TableCell, TableRow } from "../ui/table";
|
||||
|
||||
interface Props {
|
||||
@@ -100,6 +101,8 @@ export default function UserFileList({
|
||||
const [currentSelectFile, setCurrentSelectFile] =
|
||||
useState<UserFileData | null>();
|
||||
|
||||
const isAdmin = action.includes("/admin");
|
||||
|
||||
const getFileUrl = (key: string) => {
|
||||
return `${bucketInfo.custom_domain}/${key}`;
|
||||
};
|
||||
@@ -214,7 +217,7 @@ export default function UserFileList({
|
||||
|
||||
const renderFileLinks = (file: UserFileData, index: number) => (
|
||||
<>
|
||||
{file.shortUrlId && (
|
||||
{!isAdmin && file.shortUrlId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.unLink className="size-3 flex-shrink-0 text-blue-500" />
|
||||
<Link
|
||||
@@ -266,7 +269,7 @@ export default function UserFileList({
|
||||
|
||||
const renderListView = () => (
|
||||
<div className="overflow-hidden rounded-lg border bg-primary-foreground">
|
||||
<div className="text-mute-foreground grid grid-cols-6 gap-4 bg-neutral-100 px-6 py-3 text-sm font-medium dark:bg-neutral-800 sm:grid-cols-12">
|
||||
<div className="text-mute-foreground grid grid-cols-6 gap-4 bg-neutral-100 px-6 py-3 text-sm font-medium dark:bg-neutral-800 sm:grid-cols-9">
|
||||
{showMutiCheckBox && (
|
||||
<div className="col-span-1 flex">
|
||||
<Checkbox
|
||||
@@ -276,13 +279,14 @@ export default function UserFileList({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(showMutiCheckBox ? "col-span-3" : "col-span-4")}>
|
||||
<div className={cn(showMutiCheckBox ? "col-span-2" : "col-span-3")}>
|
||||
{t("Name")}
|
||||
</div>
|
||||
<div className="col-span-1">{t("Size")}</div>
|
||||
<div className="col-span-2 hidden sm:flex">{t("Type")}</div>
|
||||
<div className="col-span-2 hidden sm:flex">{t("User")}</div>
|
||||
<div className="col-span-2 hidden sm:flex">{t("Date")}</div>
|
||||
<div className="col-span-1 hidden sm:flex">{t("Type")}</div>
|
||||
<div className="col-span-1 hidden sm:flex">{t("User")}</div>
|
||||
<div className="col-span-1 hidden sm:flex">{t("Date")}</div>
|
||||
<div className="col-span-1 hidden sm:flex">{t("Active")}</div>
|
||||
<div className="col-span-1">{t("Actions")}</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
@@ -298,7 +302,7 @@ export default function UserFileList({
|
||||
{files?.list.map((file, index) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="text-mute-foreground grid grid-cols-6 gap-4 px-6 py-4 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-600 sm:grid-cols-12"
|
||||
className="text-mute-foreground grid grid-cols-6 gap-4 px-6 py-4 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-600 sm:grid-cols-9"
|
||||
>
|
||||
{showMutiCheckBox && (
|
||||
<div
|
||||
@@ -317,7 +321,7 @@ export default function UserFileList({
|
||||
<div
|
||||
className={cn(
|
||||
"items-center space-x-3 text-sm",
|
||||
showMutiCheckBox ? "col-span-3" : "col-span-4",
|
||||
showMutiCheckBox ? "col-span-2" : "col-span-3",
|
||||
)}
|
||||
>
|
||||
<TooltipProvider>
|
||||
@@ -350,12 +354,12 @@ export default function UserFileList({
|
||||
<div className="col-span-1 flex items-center text-nowrap text-xs">
|
||||
{formatFileSize(file.size || 0)}
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center text-xs sm:flex">
|
||||
<div className="col-span-1 hidden items-center text-xs sm:flex">
|
||||
<Badge className="truncate" variant="outline">
|
||||
{file.mimeType || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center text-xs sm:flex">
|
||||
<div className="col-span-1 hidden items-center text-xs sm:flex">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger className="truncate">
|
||||
@@ -368,9 +372,12 @@ export default function UserFileList({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center text-nowrap text-xs sm:flex">
|
||||
<div className="col-span-1 hidden items-center text-nowrap text-xs sm:flex">
|
||||
<TimeAgoIntl date={file.updatedAt as Date} />
|
||||
</div>
|
||||
<div className="col-span-1 hidden items-center text-xs sm:flex">
|
||||
<Switch checked={file.status === 1} />
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -83,6 +83,8 @@ export default function UserFileManager({ user, action }: FileListProps) {
|
||||
const [selectedFiles, setSelectedFiles] = useState<UserFileData[]>([]);
|
||||
const [isDeleting, startDeleteTransition] = useTransition();
|
||||
|
||||
const isAdmin = action.includes("/admin");
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const { data: r2Configs, isLoading } = useSWR<ClientStorageCredentials>(
|
||||
@@ -230,12 +232,14 @@ export default function UserFileManager({ user, action }: FileListProps) {
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Uploader
|
||||
bucketInfo={bucketInfo}
|
||||
action={action}
|
||||
onRefresh={handleRefresh}
|
||||
plan={plan}
|
||||
/>
|
||||
{!isAdmin && (
|
||||
<Uploader
|
||||
bucketInfo={bucketInfo}
|
||||
action={action}
|
||||
onRefresh={handleRefresh}
|
||||
plan={plan}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
|
||||
@@ -131,6 +131,7 @@ export const Icons = {
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M12.116 2.57c-.973-.326-2.384-.546-3.991-.546-1.607 0-3.018.22-3.99.545-.492.164-.814.337-.992.478a.89.89 0 00-.082.072c.02.069.078.158.225.268.21.158.548.313 1.025.448.947.266 2.292.409 3.814.409 1.522 0 2.867-.143 3.814-.41.477-.134.816-.29 1.025-.447.147-.11.204-.2.225-.268a.884.884 0 00-.082-.072c-.178-.141-.5-.314-.991-.478zM4.02 4.818a5.18 5.18 0 01-.97-.369v1.757c0 .079.039.206.25.377.214.173.556.347 1.03.5.944.306 2.284.49 3.795.49 1.51 0 2.85-.184 3.794-.49.474-.153.817-.327 1.03-.5.212-.171.251-.298.251-.377V4.45a5.18 5.18 0 01-.97.369c-1.08.304-2.534.45-4.105.45-1.571 0-3.026-.146-4.105-.45zm10.23 1.388V3.05C14.25 1.917 11.508 1 8.125 1S2 1.917 2 3.049V12.95C2 14.083 4.742 15 8.125 15s6.125-.917 6.125-2.049V6.207zM13.2 7.654c-.28.157-.602.29-.95.403-1.083.35-2.543.54-4.125.54-1.582 0-3.042-.19-4.125-.54a5.258 5.258 0 01-.95-.403v1.712c0 .078.039.205.25.376.214.173.556.348 1.03.501.944.306 2.284.489 3.795.489 1.51 0 2.85-.183 3.794-.489.474-.153.817-.328 1.03-.5.212-.172.251-.299.251-.377V7.654zM4 11.215a5.253 5.253 0 01-.95-.403v2.058c.018.019.047.046.093.083.178.141.5.314.992.478.972.325 2.383.545 3.99.545 1.607 0 3.018-.22 3.99-.545.492-.164.814-.337.992-.478a.809.809 0 00.093-.083v-2.058a5.25 5.25 0 01-.95.403c-1.083.351-2.543.541-4.125.541-1.582 0-3.042-.19-4.125-.54zm9.224 1.624s0 .002-.004.006a.028.028 0 01.004-.006zm-10.198 0l.004.006a.024.024 0 01-.004-.006zm1.599-6.29c.29 0 .525-.23.525-.512a.519.519 0 00-.525-.513c-.29 0-.525.23-.525.513 0 .282.235.512.525.512zM5.15 9.28a.519.519 0 01-.525.513.519.519 0 01-.525-.513c0-.282.235-.512.525-.512.29 0 .525.23.525.512zm-.525 3.671c.29 0 .525-.23.525-.512a.519.519 0 00-.525-.512c-.29 0-.525.23-.525.512 0 .283.235.512.525.512z"
|
||||
></path>
|
||||
|
||||
@@ -76,6 +76,12 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
title: "Records",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/storage",
|
||||
icon: "storage",
|
||||
title: "Cloud Storage Manage",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/system",
|
||||
icon: "settings",
|
||||
|
||||
+2
-2
@@ -111,7 +111,7 @@ export async function getUserFiles(options: QueryUserFileOptions = {}) {
|
||||
bucket,
|
||||
userId,
|
||||
providerName,
|
||||
status = 1,
|
||||
status,
|
||||
channel,
|
||||
platform,
|
||||
shortUrlId,
|
||||
@@ -122,8 +122,8 @@ export async function getUserFiles(options: QueryUserFileOptions = {}) {
|
||||
} = options;
|
||||
|
||||
const where: Prisma.UserFileWhereInput = {
|
||||
status,
|
||||
bucket,
|
||||
...(status && { status }),
|
||||
...(userId && { userId }),
|
||||
...(providerName && { providerName }),
|
||||
...(channel && { channel }),
|
||||
|
||||
+2
-1
@@ -467,7 +467,8 @@
|
||||
"Sign in": "Sign in",
|
||||
"Log out": "Log out",
|
||||
"System Settings": "System Settings",
|
||||
"Cloud Storage": "Cloud Storage"
|
||||
"Cloud Storage": "Cloud Storage",
|
||||
"Cloud Storage Manager": "Cloud Storage"
|
||||
},
|
||||
"Email": {
|
||||
"Search emails": "Search emails",
|
||||
|
||||
+2
-1
@@ -467,7 +467,8 @@
|
||||
"Sign in": "登录",
|
||||
"Log out": "退出登录",
|
||||
"System Settings": "系统设置",
|
||||
"Cloud Storage": "云存储"
|
||||
"Cloud Storage": "云存储",
|
||||
"Cloud Storage Manage": "云存储管理"
|
||||
},
|
||||
"Email": {
|
||||
"Search emails": "搜索邮箱...",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user