feat: implement API key management features
- Add API key model and schema for MongoDB. - Create utility functions for API key generation, hashing, and authentication. - Implement endpoints for creating, retrieving, and revoking API keys in the auth route. - Add API key management section in the dashboard with UI for creating and revoking keys. - Update user authentication to support API key usage. - Enhance dashboard to display API keys and their statuses.
This commit is contained in:
parent
242becd9a4
commit
7cd576911b
7 changed files with 921 additions and 355 deletions
49
apps/backend/src/lib/api-key.ts
Normal file
49
apps/backend/src/lib/api-key.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { ApiKey } from "@/models/api-key";
|
||||
import { User } from "@/models/user";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
type HeaderBag = Record<string, string | undefined>;
|
||||
|
||||
export function hashApiKey(key: string) {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
export function createPlainApiKey() {
|
||||
const keyId = ulid();
|
||||
const secret = randomBytes(32).toString("base64url");
|
||||
const key = `mizuki_${keyId}_${secret}`;
|
||||
|
||||
return {
|
||||
key,
|
||||
keyId,
|
||||
prefix: `mizuki_${keyId.slice(0, 8)}`,
|
||||
keyHash: hashApiKey(key),
|
||||
};
|
||||
}
|
||||
|
||||
export function getApiKeyFromHeaders(headers: HeaderBag) {
|
||||
const authorization = headers.authorization;
|
||||
if (authorization?.startsWith("Bearer ")) {
|
||||
return authorization.slice("Bearer ".length).trim();
|
||||
}
|
||||
|
||||
return headers["x-api-key"]?.trim() || null;
|
||||
}
|
||||
|
||||
export async function authenticateApiKey(headers: HeaderBag) {
|
||||
const rawKey = getApiKeyFromHeaders(headers);
|
||||
if (!rawKey) return null;
|
||||
|
||||
const keyHash = hashApiKey(rawKey);
|
||||
const apiKey = await ApiKey.findOne({ keyHash, revokedAt: { $exists: false } });
|
||||
if (!apiKey) return null;
|
||||
|
||||
const user = await User.findOne({ userId: apiKey.owner.userId });
|
||||
if (!user || (user.role !== "admin" && user.role !== "writer")) return null;
|
||||
|
||||
apiKey.lastUsedAt = new Date();
|
||||
await apiKey.save();
|
||||
|
||||
return user;
|
||||
}
|
||||
24
apps/backend/src/models/api-key.ts
Normal file
24
apps/backend/src/models/api-key.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as mongoose from "mongoose";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
const apiKeySchema = new mongoose.Schema({
|
||||
keyId: { type: String, required: true, unique: true, default: () => ulid() },
|
||||
name: { type: String, required: true },
|
||||
keyHash: { type: String, required: true, unique: true },
|
||||
prefix: { type: String, required: true },
|
||||
owner: {
|
||||
userId: { type: String, required: true },
|
||||
discordId: { type: String, required: true },
|
||||
username: { type: String, required: true },
|
||||
role: { type: String, enum: ["admin", "writer", "reader"], required: true },
|
||||
},
|
||||
lastUsedAt: { type: Date },
|
||||
revokedAt: { type: Date },
|
||||
}, {
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
apiKeySchema.index({ "owner.userId": 1, createdAt: -1 });
|
||||
apiKeySchema.index({ revokedAt: 1 });
|
||||
|
||||
export const ApiKey = mongoose.model("ApiKey", apiKeySchema);
|
||||
|
|
@ -2,8 +2,10 @@ import { Elysia, t } from "elysia";
|
|||
import config from "@/../config.toml";
|
||||
import { jwt } from '@elysiajs/jwt';
|
||||
import { AuditLog } from "@/models/audit";
|
||||
import { ApiKey } from "@/models/api-key";
|
||||
import { USER_ROLES, User } from "@/models/user";
|
||||
import { createAuditLog } from "@/lib/audit";
|
||||
import { createPlainApiKey } from "@/lib/api-key";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
const hardcodedOwners = new Set<string>(
|
||||
|
|
@ -12,6 +14,29 @@ const hardcodedOwners = new Set<string>(
|
|||
: [],
|
||||
);
|
||||
|
||||
async function getSessionUser(jwt: { verify: (token: string) => Promise<unknown> }, mizuki: { value?: unknown }) {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await User.findOne({ userId: payload.id });
|
||||
}
|
||||
|
||||
async function getAdminSessionUser(jwt: { verify: (token: string) => Promise<unknown> }, mizuki: { value?: unknown }) {
|
||||
const requester = await getSessionUser(jwt, mizuki);
|
||||
if (!requester || requester.role !== "admin") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return requester;
|
||||
}
|
||||
|
||||
export default new Elysia({ prefix: "/auth" })
|
||||
.use(
|
||||
jwt({
|
||||
|
|
@ -418,6 +443,115 @@ export default new Elysia({ prefix: "/auth" })
|
|||
}),
|
||||
})
|
||||
|
||||
.get("/api-keys", async ({ jwt, cookie: { mizuki }, status }) => {
|
||||
const requester = await getAdminSessionUser(jwt, mizuki);
|
||||
if (!requester) {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const apiKeys = await ApiKey.find().sort({ createdAt: -1 });
|
||||
return apiKeys.map((apiKey) => ({
|
||||
id: apiKey.keyId,
|
||||
name: apiKey.name,
|
||||
prefix: apiKey.prefix,
|
||||
owner: apiKey.owner,
|
||||
createdAt: apiKey.createdAt,
|
||||
lastUsedAt: apiKey.lastUsedAt,
|
||||
revokedAt: apiKey.revokedAt,
|
||||
}));
|
||||
})
|
||||
|
||||
.post("/api-keys", async ({ jwt, body, cookie: { mizuki }, status }) => {
|
||||
const requester = await getAdminSessionUser(jwt, mizuki);
|
||||
if (!requester) {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const plain = createPlainApiKey();
|
||||
const created = await ApiKey.create({
|
||||
keyId: plain.keyId,
|
||||
name: body.name.trim(),
|
||||
keyHash: plain.keyHash,
|
||||
prefix: plain.prefix,
|
||||
owner: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "auth.apiKey.create",
|
||||
targetType: "apiKey",
|
||||
targetId: created.keyId,
|
||||
summary: `${requester.username} created API key ${created.name}`,
|
||||
detail: {
|
||||
name: created.name,
|
||||
prefix: created.prefix,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: created.keyId,
|
||||
name: created.name,
|
||||
prefix: created.prefix,
|
||||
key: plain.key,
|
||||
owner: created.owner,
|
||||
createdAt: created.createdAt,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1, maxLength: 80 }),
|
||||
}),
|
||||
})
|
||||
|
||||
.delete("/api-keys/:id", async ({ jwt, params, cookie: { mizuki }, status }) => {
|
||||
const requester = await getAdminSessionUser(jwt, mizuki);
|
||||
if (!requester) {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const apiKey = await ApiKey.findOne({ keyId: params.id });
|
||||
if (!apiKey) {
|
||||
return status(404, "API key not found");
|
||||
}
|
||||
|
||||
if (!apiKey.revokedAt) {
|
||||
apiKey.revokedAt = new Date();
|
||||
await apiKey.save();
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "auth.apiKey.revoke",
|
||||
targetType: "apiKey",
|
||||
targetId: apiKey.keyId,
|
||||
summary: `${requester.username} revoked API key ${apiKey.name}`,
|
||||
detail: {
|
||||
name: apiKey.name,
|
||||
prefix: apiKey.prefix,
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
})
|
||||
|
||||
.get("/audit-logs", async ({ jwt, cookie: { mizuki }, query, status }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { MediaUpload } from "@/models/media";
|
|||
import { Tag } from "@/models/tag";
|
||||
import { User } from "@/models/user";
|
||||
import { createAuditLog } from "@/lib/audit";
|
||||
import { authenticateApiKey } from "@/lib/api-key";
|
||||
import { normalizeQueryTags, normalizeTags } from "@/lib/tag";
|
||||
import { fetchTweetData } from "@/lib/tweet";
|
||||
import { fetchPixivData } from "@/lib/pixiv";
|
||||
|
|
@ -216,16 +217,19 @@ export default new Elysia({ prefix: "/post" })
|
|||
set.headers["Expires"] = "0";
|
||||
set.headers["Surrogate-Control"] = "no-store";
|
||||
})
|
||||
.derive(async ({ jwt, cookie: { mizuki } }) => {
|
||||
.derive(async ({ jwt, cookie: { mizuki }, headers }) => {
|
||||
return {
|
||||
getAuthenticatedUser: async () => {
|
||||
const token = mizuki.value;
|
||||
if (typeof token !== "string" || !token) return null;
|
||||
if (typeof token === "string" && token) {
|
||||
const payload = await jwt.verify(token);
|
||||
if (payload && typeof payload === "object" && "id" in payload) {
|
||||
const user = await User.findOne({ userId: payload.id });
|
||||
if (user) return user;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(token);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload)) return null;
|
||||
|
||||
return await User.findOne({ userId: payload.id });
|
||||
return await authenticateApiKey(headers);
|
||||
}
|
||||
};
|
||||
})
|
||||
|
|
|
|||
170
apps/frontend/src/app/dashboard/components/api-key-section.tsx
Normal file
170
apps/frontend/src/app/dashboard/components/api-key-section.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import type { FormEvent } from "react";
|
||||
import type { ApiKeyItem, CreatedApiKey } from "./types";
|
||||
|
||||
type ApiKeySectionProps = {
|
||||
apiKeys: ApiKeyItem[];
|
||||
createdApiKey: CreatedApiKey | null;
|
||||
newApiKeyName: string;
|
||||
creatingApiKey: boolean;
|
||||
revokingApiKeyId: string | null;
|
||||
onNewApiKeyNameChange: (value: string) => void;
|
||||
onCreateApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
onRevokeApiKey: (apiKey: ApiKeyItem) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function formatDate(value?: string) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
export default function ApiKeySection({
|
||||
apiKeys,
|
||||
createdApiKey,
|
||||
newApiKeyName,
|
||||
creatingApiKey,
|
||||
revokingApiKeyId,
|
||||
onNewApiKeyNameChange,
|
||||
onCreateApiKey,
|
||||
onRevokeApiKey,
|
||||
}: ApiKeySectionProps) {
|
||||
function handleRevokeApiKey(apiKey: ApiKeyItem) {
|
||||
void onRevokeApiKey(apiKey);
|
||||
}
|
||||
|
||||
async function copyCreatedKey() {
|
||||
if (!createdApiKey) return;
|
||||
await navigator.clipboard.writeText(createdApiKey.key);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4 border-b border-border/70 pb-8">
|
||||
<div>
|
||||
<h2 className="text-lg tracking-wide">API Keys</h2>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-3 border border-border/70 p-4 sm:flex-row sm:items-end"
|
||||
onSubmit={onCreateApiKey}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
className="block text-xs text-foreground/70"
|
||||
htmlFor="apiKeyName"
|
||||
>
|
||||
Key name
|
||||
</label>
|
||||
<input
|
||||
id="apiKeyName"
|
||||
type="text"
|
||||
value={newApiKeyName}
|
||||
onChange={(event) => onNewApiKeyNameChange(event.target.value)}
|
||||
className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none"
|
||||
placeholder="deploy script"
|
||||
maxLength={80}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creatingApiKey}
|
||||
className="border border-border bg-foreground px-4 py-2 text-sm text-background disabled:opacity-60"
|
||||
>
|
||||
{creatingApiKey ? "발급 중..." : "API key 발급"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{createdApiKey ? (
|
||||
<div className="space-y-3 border border-emerald-300 bg-emerald-50 p-4 text-sm text-emerald-950">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p>새 키는 지금 한 번만 표시됩니다.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCreatedKey}
|
||||
className="border border-emerald-700 px-3 py-1 text-xs"
|
||||
>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<code className="block overflow-x-auto border border-emerald-200 bg-white/70 p-3 font-mono text-xs text-emerald-950">
|
||||
{createdApiKey.key}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="space-y-2 border border-border/70 p-4">
|
||||
<h3 className="text-sm tracking-wide">curl</h3>
|
||||
<pre className="overflow-x-auto text-xs leading-5 text-foreground/80">
|
||||
<code>{`curl -X POST "$BASE_URL/api/post/upload" \\
|
||||
-H "Authorization: Bearer $MIZUKI_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"url":"https://x.com/.../status/...","tag":["미분류"],"selected":[]}'`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="space-y-2 border border-border/70 p-4">
|
||||
<h3 className="text-sm tracking-wide">Headers</h3>
|
||||
<pre className="overflow-x-auto text-xs leading-5 text-foreground/80">
|
||||
<code>{`Authorization: Bearer $MIZUKI_API_KEY
|
||||
x-api-key: $MIZUKI_API_KEY`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border border-border/70">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-black/5 text-foreground/70">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Name</th>
|
||||
<th className="px-3 py-2">Prefix</th>
|
||||
<th className="px-3 py-2">Owner</th>
|
||||
<th className="px-3 py-2">Created</th>
|
||||
<th className="px-3 py-2">Last used</th>
|
||||
<th className="px-3 py-2">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apiKeys.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-3 py-4 text-foreground/60" colSpan={6}>
|
||||
발급된 API key가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{apiKeys.map((apiKey) => {
|
||||
const revoked = Boolean(apiKey.revokedAt);
|
||||
return (
|
||||
<tr key={apiKey.id} className="border-t border-border/50">
|
||||
<td className="px-3 py-2">{apiKey.name}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">
|
||||
{apiKey.prefix}
|
||||
</td>
|
||||
<td className="px-3 py-2">{apiKey.owner.username}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{formatDate(apiKey.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{formatDate(apiKey.lastUsedAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{revoked ? (
|
||||
<span className="text-xs text-foreground/50">폐기됨</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRevokeApiKey(apiKey)}
|
||||
disabled={revokingApiKeyId === apiKey.id}
|
||||
className="border border-red-300 bg-red-50 px-3 py-1 text-xs text-red-700 disabled:opacity-50"
|
||||
>
|
||||
{revokingApiKeyId === apiKey.id ? "폐기 중..." : "폐기"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,43 +1,62 @@
|
|||
export type Role = "admin" | "writer" | "reader";
|
||||
|
||||
export type Me = {
|
||||
id: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
id: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
};
|
||||
|
||||
export type UserItem = {
|
||||
id: string;
|
||||
discordId: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
role: Role;
|
||||
createdAt?: string;
|
||||
id: string;
|
||||
discordId: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
role: Role;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type UploadItem = {
|
||||
_id: string;
|
||||
mediaUrl: string;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
uploadedBy?: {
|
||||
username?: string;
|
||||
role?: Role;
|
||||
};
|
||||
createdAt?: string;
|
||||
_id: string;
|
||||
mediaUrl: string;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
uploadedBy?: {
|
||||
username?: string;
|
||||
role?: Role;
|
||||
};
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type AuditLog = {
|
||||
_id: string;
|
||||
action: string;
|
||||
summary?: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
actor?: {
|
||||
username?: string;
|
||||
role?: string;
|
||||
};
|
||||
createdAt?: string;
|
||||
_id: string;
|
||||
action: string;
|
||||
summary?: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
actor?: {
|
||||
username?: string;
|
||||
role?: string;
|
||||
};
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type ApiKeyItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
owner: {
|
||||
userId: string;
|
||||
discordId: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
};
|
||||
createdAt?: string;
|
||||
lastUsedAt?: string;
|
||||
revokedAt?: string;
|
||||
};
|
||||
|
||||
export type CreatedApiKey = ApiKeyItem & {
|
||||
key: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_ROLES: Role[] = ["admin", "writer", "reader"];
|
||||
|
|
|
|||
|
|
@ -1,350 +1,516 @@
|
|||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { FormEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Header from "../../components/header";
|
||||
import ApiKeySection from "./components/api-key-section";
|
||||
import AuditLogSection from "./components/audit-log-section";
|
||||
import DashboardHeader from "./components/dashboard-header";
|
||||
import PostManagementSection from "./components/post-management-section";
|
||||
import type {
|
||||
ApiKeyItem,
|
||||
AuditLog,
|
||||
CreatedApiKey,
|
||||
Me,
|
||||
Role,
|
||||
UploadItem,
|
||||
UserItem,
|
||||
} from "./components/types";
|
||||
import UserManagementSection from "./components/user-management-section";
|
||||
import { AuditLog, Me, Role, UploadItem, UserItem } from "./components/types";
|
||||
|
||||
function buildPostQuery(page: number, size: number) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("size", String(size));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [pendingRole, setPendingRole] = useState<Record<string, Role>>({});
|
||||
const router = useRouter();
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [pendingRole, setPendingRole] = useState<Record<string, Role>>({});
|
||||
|
||||
const [posts, setPosts] = useState<UploadItem[]>([]);
|
||||
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
|
||||
const [deletingPosts, setDeletingPosts] = useState(false);
|
||||
const [posts, setPosts] = useState<UploadItem[]>([]);
|
||||
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
|
||||
const [deletingPosts, setDeletingPosts] = useState(false);
|
||||
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [apiKeys, setApiKeys] = useState<ApiKeyItem[]>([]);
|
||||
const [createdApiKey, setCreatedApiKey] = useState<CreatedApiKey | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingUserId, setSavingUserId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingUserId, setSavingUserId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [creatingApiKey, setCreatingApiKey] = useState(false);
|
||||
const [revokingApiKeyId, setRevokingApiKeyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [newDiscordId, setNewDiscordId] = useState("");
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newAvatar, setNewAvatar] = useState("");
|
||||
const [newRole, setNewRole] = useState<Role>("reader");
|
||||
const [newDiscordId, setNewDiscordId] = useState("");
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newAvatar, setNewAvatar] = useState("");
|
||||
const [newRole, setNewRole] = useState<Role>("reader");
|
||||
const [newApiKeyName, setNewApiKeyName] = useState("");
|
||||
|
||||
const isAdmin = me?.role === "admin";
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
const sortedUsers = useMemo(
|
||||
() => [...users].sort((a, b) => a.username.localeCompare(b.username)),
|
||||
[users],
|
||||
const sortedUsers = useMemo(
|
||||
() => [...users].sort((a, b) => a.username.localeCompare(b.username)),
|
||||
[users],
|
||||
);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const meResponse = await fetch("/api/auth/me", { cache: "no-store" });
|
||||
if (!meResponse.ok) {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = (await meResponse.json()) as Me;
|
||||
setMe(profile);
|
||||
|
||||
if (profile.role !== "admin") {
|
||||
setUsers([]);
|
||||
setRoles([]);
|
||||
setPosts([]);
|
||||
setAuditLogs([]);
|
||||
setApiKeys([]);
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
|
||||
const [
|
||||
usersResponse,
|
||||
rolesResponse,
|
||||
postsResponse,
|
||||
logsResponse,
|
||||
apiKeysResponse,
|
||||
] = await Promise.all([
|
||||
fetch("/api/auth/users", { cache: "no-store" }),
|
||||
fetch("/api/auth/roles", { cache: "no-store" }),
|
||||
fetch(`/api/post/list?${buildPostQuery(1, 80)}`, { cache: "no-store" }),
|
||||
fetch("/api/auth/audit-logs?page=1&size=80", { cache: "no-store" }),
|
||||
fetch("/api/auth/api-keys", { cache: "no-store" }),
|
||||
]);
|
||||
|
||||
if (!usersResponse.ok) {
|
||||
throw new Error(
|
||||
`유저 목록을 불러오지 못했습니다: ${usersResponse.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!rolesResponse.ok) {
|
||||
throw new Error(
|
||||
`역할 목록을 불러오지 못했습니다: ${rolesResponse.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!postsResponse.ok) {
|
||||
throw new Error(
|
||||
`게시물 목록을 불러오지 못했습니다: ${postsResponse.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!logsResponse.ok) {
|
||||
throw new Error(
|
||||
`감사 로그를 불러오지 못했습니다: ${logsResponse.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKeysResponse.ok) {
|
||||
throw new Error(
|
||||
`API key 목록을 불러오지 못했습니다: ${apiKeysResponse.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const usersData = (await usersResponse.json()) as UserItem[];
|
||||
const roleData = (await rolesResponse.json()) as Role[];
|
||||
const postData = (await postsResponse.json()) as UploadItem[];
|
||||
const logData = (await logsResponse.json()) as AuditLog[];
|
||||
const apiKeyData = (await apiKeysResponse.json()) as ApiKeyItem[];
|
||||
|
||||
setUsers(usersData);
|
||||
setRoles(roleData);
|
||||
setPosts(postData);
|
||||
setAuditLogs(logData);
|
||||
setApiKeys(apiKeyData);
|
||||
setPendingRole(
|
||||
Object.fromEntries(
|
||||
usersData.map((user) => [user.id, user.role]),
|
||||
) as Record<string, Role>,
|
||||
);
|
||||
setSelectedPostIds([]);
|
||||
} catch (loadError) {
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: "대시보드를 불러오지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
async function updateRole(user: UserItem) {
|
||||
const nextRole = pendingRole[user.id] ?? user.role;
|
||||
if (nextRole === user.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingUserId(user.id);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/role", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: user.id,
|
||||
role: nextRole,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `역할 변경 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
setUsers((current) =>
|
||||
current.map((item) =>
|
||||
item.id === user.id ? { ...item, role: nextRole } : item,
|
||||
),
|
||||
);
|
||||
setSuccess(`${user.username} 권한을 ${nextRole}(으)로 변경했습니다.`);
|
||||
void refreshLogs();
|
||||
} catch (saveError) {
|
||||
setError(
|
||||
saveError instanceof Error
|
||||
? saveError.message
|
||||
: "역할 변경에 실패했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setSavingUserId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(user: UserItem) {
|
||||
if (!window.confirm(`${user.username} 유저를 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingUserId(user.id);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/auth/user/${user.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `유저 삭제 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
setUsers((current) => current.filter((item) => item.id !== user.id));
|
||||
setSuccess(`${user.username} 유저를 삭제했습니다.`);
|
||||
void refreshLogs();
|
||||
} catch (deleteError) {
|
||||
setError(
|
||||
deleteError instanceof Error
|
||||
? deleteError.message
|
||||
: "유저 삭제에 실패했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setSavingUserId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/user", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
discordId: newDiscordId.trim(),
|
||||
username: newUsername.trim(),
|
||||
avatar: newAvatar.trim() || undefined,
|
||||
role: newRole,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `유저 생성 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const created = (await response.json()) as UserItem;
|
||||
setUsers((current) => [created, ...current]);
|
||||
setPendingRole((current) => ({ ...current, [created.id]: created.role }));
|
||||
setNewDiscordId("");
|
||||
setNewUsername("");
|
||||
setNewAvatar("");
|
||||
setNewRole("reader");
|
||||
setSuccess(`${created.username} 유저를 생성했습니다.`);
|
||||
void refreshLogs();
|
||||
} catch (createError) {
|
||||
setError(
|
||||
createError instanceof Error
|
||||
? createError.message
|
||||
: "유저 생성에 실패했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/audit-logs?page=1&size=80", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as AuditLog[];
|
||||
setAuditLogs(data);
|
||||
} catch {
|
||||
// ignore refresh error
|
||||
}
|
||||
}
|
||||
|
||||
async function createApiKey(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setCreatingApiKey(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setCreatedApiKey(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/api-keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: newApiKeyName.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `API key 발급 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const created = (await response.json()) as CreatedApiKey;
|
||||
setApiKeys((current) => [created, ...current]);
|
||||
setCreatedApiKey(created);
|
||||
setNewApiKeyName("");
|
||||
setSuccess(`${created.name} API key를 발급했습니다.`);
|
||||
void refreshLogs();
|
||||
} catch (createError) {
|
||||
setError(
|
||||
createError instanceof Error
|
||||
? createError.message
|
||||
: "API key 발급에 실패했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setCreatingApiKey(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeApiKey(apiKey: ApiKeyItem) {
|
||||
if (!window.confirm(`${apiKey.name} API key를 폐기할까요?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRevokingApiKeyId(apiKey.id);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/auth/api-keys/${apiKey.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `API key 폐기 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const revokedAt = new Date().toISOString();
|
||||
setApiKeys((current) =>
|
||||
current.map((item) =>
|
||||
item.id === apiKey.id ? { ...item, revokedAt } : item,
|
||||
),
|
||||
);
|
||||
setSuccess(`${apiKey.name} API key를 폐기했습니다.`);
|
||||
void refreshLogs();
|
||||
} catch (revokeError) {
|
||||
setError(
|
||||
revokeError instanceof Error
|
||||
? revokeError.message
|
||||
: "API key 폐기에 실패했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setRevokingApiKeyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDeletePosts() {
|
||||
if (selectedPostIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`선택한 ${selectedPostIds.length}개 게시물을 삭제할까요?`,
|
||||
);
|
||||
|
||||
function buildPostQuery(page: number, size: number) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("size", String(size));
|
||||
return params.toString();
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setDeletingPosts(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const meResponse = await fetch("/api/auth/me", { cache: "no-store" });
|
||||
if (!meResponse.ok) {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch("/api/post/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ids: selectedPostIds,
|
||||
}),
|
||||
});
|
||||
|
||||
const profile = (await meResponse.json()) as Me;
|
||||
setMe(profile);
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `일괄 삭제 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
if (profile.role !== "admin") {
|
||||
setUsers([]);
|
||||
setRoles([]);
|
||||
setPosts([]);
|
||||
setAuditLogs([]);
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
|
||||
const [usersResponse, rolesResponse, postsResponse, logsResponse] = await Promise.all([
|
||||
fetch("/api/auth/users", { cache: "no-store" }),
|
||||
fetch("/api/auth/roles", { cache: "no-store" }),
|
||||
fetch(`/api/post/list?${buildPostQuery(1, 80)}`, { cache: "no-store" }),
|
||||
fetch("/api/auth/audit-logs?page=1&size=80", { cache: "no-store" }),
|
||||
]);
|
||||
|
||||
if (!usersResponse.ok) {
|
||||
throw new Error(`유저 목록을 불러오지 못했습니다: ${usersResponse.status}`);
|
||||
}
|
||||
|
||||
if (!rolesResponse.ok) {
|
||||
throw new Error(`역할 목록을 불러오지 못했습니다: ${rolesResponse.status}`);
|
||||
}
|
||||
|
||||
if (!postsResponse.ok) {
|
||||
throw new Error(`게시물 목록을 불러오지 못했습니다: ${postsResponse.status}`);
|
||||
}
|
||||
|
||||
if (!logsResponse.ok) {
|
||||
throw new Error(`감사 로그를 불러오지 못했습니다: ${logsResponse.status}`);
|
||||
}
|
||||
|
||||
const usersData = (await usersResponse.json()) as UserItem[];
|
||||
const roleData = (await rolesResponse.json()) as Role[];
|
||||
const postData = (await postsResponse.json()) as UploadItem[];
|
||||
const logData = (await logsResponse.json()) as AuditLog[];
|
||||
|
||||
setUsers(usersData);
|
||||
setRoles(roleData);
|
||||
setPosts(postData);
|
||||
setAuditLogs(logData);
|
||||
setPendingRole(
|
||||
Object.fromEntries(usersData.map((user) => [user.id, user.role])) as Record<string, Role>,
|
||||
);
|
||||
setSelectedPostIds([]);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "대시보드를 불러오지 못했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const result = (await response.json()) as { deletedCount?: number };
|
||||
setPosts((current) =>
|
||||
current.filter((post) => !selectedPostIds.includes(post._id)),
|
||||
);
|
||||
setSuccess(
|
||||
`${result.deletedCount ?? selectedPostIds.length}개 게시물을 삭제했습니다.`,
|
||||
);
|
||||
setSelectedPostIds([]);
|
||||
void refreshLogs();
|
||||
} catch (deleteError) {
|
||||
setError(
|
||||
deleteError instanceof Error
|
||||
? deleteError.message
|
||||
: "게시물 일괄 삭제에 실패했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setDeletingPosts(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
function togglePost(id: string) {
|
||||
setSelectedPostIds((current) => {
|
||||
if (current.includes(id)) {
|
||||
return current.filter((value) => value !== id);
|
||||
}
|
||||
return [...current, id];
|
||||
});
|
||||
}
|
||||
|
||||
async function updateRole(user: UserItem) {
|
||||
const nextRole = pendingRole[user.id] ?? user.role;
|
||||
if (nextRole === user.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingUserId(user.id);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/role", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: user.id,
|
||||
role: nextRole,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `역할 변경 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
setUsers((current) => current.map((item) => (item.id === user.id ? { ...item, role: nextRole } : item)));
|
||||
setSuccess(`${user.username} 권한을 ${nextRole}(으)로 변경했습니다.`);
|
||||
void refreshLogs();
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "역할 변경에 실패했습니다.");
|
||||
} finally {
|
||||
setSavingUserId(null);
|
||||
}
|
||||
function toggleSelectAllPosts() {
|
||||
if (selectedPostIds.length === posts.length) {
|
||||
setSelectedPostIds([]);
|
||||
return;
|
||||
}
|
||||
setSelectedPostIds(posts.map((post) => post._id));
|
||||
}
|
||||
|
||||
async function deleteUser(user: UserItem) {
|
||||
if (!window.confirm(`${user.username} 유저를 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
|
||||
<Header />
|
||||
|
||||
setSavingUserId(user.id);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
<main className="mx-auto w-full max-w-6xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<DashboardHeader
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
canAccess={Boolean(me && isAdmin)}
|
||||
/>
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/auth/user/${user.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
{!loading && isAdmin ? (
|
||||
<UserManagementSection
|
||||
users={sortedUsers}
|
||||
roles={roles}
|
||||
pendingRole={pendingRole}
|
||||
savingUserId={savingUserId}
|
||||
creating={creating}
|
||||
newDiscordId={newDiscordId}
|
||||
newUsername={newUsername}
|
||||
newAvatar={newAvatar}
|
||||
newRole={newRole}
|
||||
onCreateUser={createUser}
|
||||
onNewDiscordIdChange={setNewDiscordId}
|
||||
onNewUsernameChange={setNewUsername}
|
||||
onNewAvatarChange={setNewAvatar}
|
||||
onNewRoleChange={setNewRole}
|
||||
onPendingRoleChange={(userId, role) => {
|
||||
setPendingRole((current) => ({ ...current, [userId]: role }));
|
||||
}}
|
||||
onUpdateRole={updateRole}
|
||||
onDeleteUser={deleteUser}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `유저 삭제 실패: ${response.status}`);
|
||||
}
|
||||
{!loading && isAdmin ? (
|
||||
<ApiKeySection
|
||||
apiKeys={apiKeys}
|
||||
createdApiKey={createdApiKey}
|
||||
newApiKeyName={newApiKeyName}
|
||||
creatingApiKey={creatingApiKey}
|
||||
revokingApiKeyId={revokingApiKeyId}
|
||||
onNewApiKeyNameChange={setNewApiKeyName}
|
||||
onCreateApiKey={createApiKey}
|
||||
onRevokeApiKey={revokeApiKey}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
setUsers((current) => current.filter((item) => item.id !== user.id));
|
||||
setSuccess(`${user.username} 유저를 삭제했습니다.`);
|
||||
void refreshLogs();
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "유저 삭제에 실패했습니다.");
|
||||
} finally {
|
||||
setSavingUserId(null);
|
||||
}
|
||||
}
|
||||
{!loading && isAdmin ? <AuditLogSection logs={auditLogs} /> : null}
|
||||
|
||||
async function createUser(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/user", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
discordId: newDiscordId.trim(),
|
||||
username: newUsername.trim(),
|
||||
avatar: newAvatar.trim() || undefined,
|
||||
role: newRole,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `유저 생성 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const created = (await response.json()) as UserItem;
|
||||
setUsers((current) => [created, ...current]);
|
||||
setPendingRole((current) => ({ ...current, [created.id]: created.role }));
|
||||
setNewDiscordId("");
|
||||
setNewUsername("");
|
||||
setNewAvatar("");
|
||||
setNewRole("reader");
|
||||
setSuccess(`${created.username} 유저를 생성했습니다.`);
|
||||
void refreshLogs();
|
||||
} catch (createError) {
|
||||
setError(createError instanceof Error ? createError.message : "유저 생성에 실패했습니다.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/audit-logs?page=1&size=80", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as AuditLog[];
|
||||
setAuditLogs(data);
|
||||
} catch {
|
||||
// ignore refresh error
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDeletePosts() {
|
||||
if (selectedPostIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(`선택한 ${selectedPostIds.length}개 게시물을 삭제할까요?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingPosts(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/post/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ids: selectedPostIds,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `일괄 삭제 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { deletedCount?: number };
|
||||
setPosts((current) => current.filter((post) => !selectedPostIds.includes(post._id)));
|
||||
setSuccess(`${result.deletedCount ?? selectedPostIds.length}개 게시물을 삭제했습니다.`);
|
||||
setSelectedPostIds([]);
|
||||
void refreshLogs();
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "게시물 일괄 삭제에 실패했습니다.");
|
||||
} finally {
|
||||
setDeletingPosts(false);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePost(id: string) {
|
||||
setSelectedPostIds((current) => {
|
||||
if (current.includes(id)) {
|
||||
return current.filter((value) => value !== id);
|
||||
}
|
||||
return [...current, id];
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAllPosts() {
|
||||
if (selectedPostIds.length === posts.length) {
|
||||
setSelectedPostIds([]);
|
||||
return;
|
||||
}
|
||||
setSelectedPostIds(posts.map((post) => post._id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
|
||||
<Header />
|
||||
|
||||
<main className="mx-auto w-full max-w-6xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<DashboardHeader
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
canAccess={Boolean(me && isAdmin)}
|
||||
/>
|
||||
|
||||
{!loading && isAdmin ? (
|
||||
<UserManagementSection
|
||||
users={sortedUsers}
|
||||
roles={roles}
|
||||
pendingRole={pendingRole}
|
||||
savingUserId={savingUserId}
|
||||
creating={creating}
|
||||
newDiscordId={newDiscordId}
|
||||
newUsername={newUsername}
|
||||
newAvatar={newAvatar}
|
||||
newRole={newRole}
|
||||
onCreateUser={createUser}
|
||||
onNewDiscordIdChange={setNewDiscordId}
|
||||
onNewUsernameChange={setNewUsername}
|
||||
onNewAvatarChange={setNewAvatar}
|
||||
onNewRoleChange={setNewRole}
|
||||
onPendingRoleChange={(userId, role) => {
|
||||
setPendingRole((current) => ({ ...current, [userId]: role }));
|
||||
}}
|
||||
onUpdateRole={updateRole}
|
||||
onDeleteUser={deleteUser}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!loading && isAdmin ? <AuditLogSection logs={auditLogs} /> : null}
|
||||
|
||||
{!loading && isAdmin ? (
|
||||
<PostManagementSection
|
||||
posts={posts}
|
||||
selectedPostIds={selectedPostIds}
|
||||
deletingPosts={deletingPosts}
|
||||
onToggleSelectAllPosts={toggleSelectAllPosts}
|
||||
onBulkDeletePosts={bulkDeletePosts}
|
||||
onTogglePost={togglePost}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
{!loading && isAdmin ? (
|
||||
<PostManagementSection
|
||||
posts={posts}
|
||||
selectedPostIds={selectedPostIds}
|
||||
deletingPosts={deletingPosts}
|
||||
onToggleSelectAllPosts={toggleSelectAllPosts}
|
||||
onBulkDeletePosts={bulkDeletePosts}
|
||||
onTogglePost={togglePost}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue