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:
암냥 2026-06-25 20:26:15 +09:00
commit 7cd576911b
No known key found for this signature in database
7 changed files with 921 additions and 355 deletions

View 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>
);
}

View file

@ -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"];

View file

@ -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>
);
}