feat: add admin storage manager

This commit is contained in:
oiov
2025-07-08 21:22:10 +08:00
parent 6e6bc22177
commit 709a56609e
13 changed files with 308 additions and 22 deletions
+14
View File
@@ -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" />
</>
);
}
+39
View File
@@ -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"
/>
</>
);
}
+34
View File
@@ -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 });
}
}
+178
View File
@@ -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 });
}
}
+1
View File
@@ -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,
});
+18 -11
View File
@@ -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>
+10 -6
View File
@@ -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
+1
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -467,7 +467,8 @@
"Sign in": "登录",
"Log out": "退出登录",
"System Settings": "系统设置",
"Cloud Storage": "云存储"
"Cloud Storage": "云存储",
"Cloud Storage Manage": "云存储管理"
},
"Email": {
"Search emails": "搜索邮箱...",
+1 -1
View File
File diff suppressed because one or more lines are too long