diff --git a/apps/backend/src/lib/api-key.ts b/apps/backend/src/lib/api-key.ts new file mode 100644 index 0000000..cd216cd --- /dev/null +++ b/apps/backend/src/lib/api-key.ts @@ -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; + +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; +} diff --git a/apps/backend/src/models/api-key.ts b/apps/backend/src/models/api-key.ts new file mode 100644 index 0000000..bc31ad4 --- /dev/null +++ b/apps/backend/src/models/api-key.ts @@ -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); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 946eca9..abc7f2f 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -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( @@ -12,6 +14,29 @@ const hardcodedOwners = new Set( : [], ); +async function getSessionUser(jwt: { verify: (token: string) => Promise }, 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 }, 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) { diff --git a/apps/backend/src/routes/post.ts b/apps/backend/src/routes/post.ts index 0629579..3efa269 100644 --- a/apps/backend/src/routes/post.ts +++ b/apps/backend/src/routes/post.ts @@ -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); } }; }) diff --git a/apps/frontend/src/app/dashboard/components/api-key-section.tsx b/apps/frontend/src/app/dashboard/components/api-key-section.tsx new file mode 100644 index 0000000..4ac8742 --- /dev/null +++ b/apps/frontend/src/app/dashboard/components/api-key-section.tsx @@ -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) => void | Promise; + onRevokeApiKey: (apiKey: ApiKeyItem) => void | Promise; +}; + +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 ( +
+
+

API Keys

+
+ +
+
+ + 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 + /> +
+ +
+ + {createdApiKey ? ( +
+
+

새 키는 지금 한 번만 표시됩니다.

+ +
+ + {createdApiKey.key} + +
+ ) : null} + +
+
+

curl

+
+            {`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":[]}'`}
+          
+
+
+

Headers

+
+            {`Authorization: Bearer $MIZUKI_API_KEY
+x-api-key: $MIZUKI_API_KEY`}
+          
+
+
+ +
+ + + + + + + + + + + + + {apiKeys.length === 0 ? ( + + + + ) : null} + {apiKeys.map((apiKey) => { + const revoked = Boolean(apiKey.revokedAt); + return ( + + + + + + + + + ); + })} + +
NamePrefixOwnerCreatedLast usedAction
+ 발급된 API key가 없습니다. +
{apiKey.name} + {apiKey.prefix} + {apiKey.owner.username} + {formatDate(apiKey.createdAt)} + + {formatDate(apiKey.lastUsedAt)} + + {revoked ? ( + 폐기됨 + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/frontend/src/app/dashboard/components/types.ts b/apps/frontend/src/app/dashboard/components/types.ts index 2052174..adfd73c 100644 --- a/apps/frontend/src/app/dashboard/components/types.ts +++ b/apps/frontend/src/app/dashboard/components/types.ts @@ -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"]; diff --git a/apps/frontend/src/app/dashboard/page.tsx b/apps/frontend/src/app/dashboard/page.tsx index ca83484..3017ee3 100644 --- a/apps/frontend/src/app/dashboard/page.tsx +++ b/apps/frontend/src/app/dashboard/page.tsx @@ -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(null); - const [users, setUsers] = useState([]); - const [roles, setRoles] = useState([]); - const [pendingRole, setPendingRole] = useState>({}); + const router = useRouter(); + const [me, setMe] = useState(null); + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [pendingRole, setPendingRole] = useState>({}); - const [posts, setPosts] = useState([]); - const [selectedPostIds, setSelectedPostIds] = useState([]); - const [deletingPosts, setDeletingPosts] = useState(false); + const [posts, setPosts] = useState([]); + const [selectedPostIds, setSelectedPostIds] = useState([]); + const [deletingPosts, setDeletingPosts] = useState(false); - const [auditLogs, setAuditLogs] = useState([]); + const [auditLogs, setAuditLogs] = useState([]); + const [apiKeys, setApiKeys] = useState([]); + const [createdApiKey, setCreatedApiKey] = useState( + null, + ); - const [loading, setLoading] = useState(true); - const [savingUserId, setSavingUserId] = useState(null); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); + const [loading, setLoading] = useState(true); + const [savingUserId, setSavingUserId] = useState(null); + const [creating, setCreating] = useState(false); + const [creatingApiKey, setCreatingApiKey] = useState(false); + const [revokingApiKeyId, setRevokingApiKeyId] = useState(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); - const [newDiscordId, setNewDiscordId] = useState(""); - const [newUsername, setNewUsername] = useState(""); - const [newAvatar, setNewAvatar] = useState(""); - const [newRole, setNewRole] = useState("reader"); + const [newDiscordId, setNewDiscordId] = useState(""); + const [newUsername, setNewUsername] = useState(""); + const [newAvatar, setNewAvatar] = useState(""); + const [newRole, setNewRole] = useState("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, + ); + 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) { + 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) { + 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, - ); - 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 ( +
+
- setSavingUserId(user.id); - setError(null); - setSuccess(null); +
+ - try { - const response = await fetch(`/api/auth/user/${user.id}`, { - method: "DELETE", - }); + {!loading && isAdmin ? ( + { + 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 ? ( + + ) : 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 ? : null} - async function createUser(event: FormEvent) { - 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 ( -
-
- -
- - - {!loading && isAdmin ? ( - { - setPendingRole((current) => ({ ...current, [userId]: role })); - }} - onUpdateRole={updateRole} - onDeleteUser={deleteUser} - /> - ) : null} - - {!loading && isAdmin ? : null} - - {!loading && isAdmin ? ( - - ) : null} -
-
- ); + {!loading && isAdmin ? ( + + ) : null} +
+
+ ); }