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,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;
}

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

View file

@ -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) {

View file

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

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