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 config from "@/../config.toml";
|
||||||
import { jwt } from '@elysiajs/jwt';
|
import { jwt } from '@elysiajs/jwt';
|
||||||
import { AuditLog } from "@/models/audit";
|
import { AuditLog } from "@/models/audit";
|
||||||
|
import { ApiKey } from "@/models/api-key";
|
||||||
import { USER_ROLES, User } from "@/models/user";
|
import { USER_ROLES, User } from "@/models/user";
|
||||||
import { createAuditLog } from "@/lib/audit";
|
import { createAuditLog } from "@/lib/audit";
|
||||||
|
import { createPlainApiKey } from "@/lib/api-key";
|
||||||
import { ulid } from "ulid";
|
import { ulid } from "ulid";
|
||||||
|
|
||||||
const hardcodedOwners = new Set<string>(
|
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" })
|
export default new Elysia({ prefix: "/auth" })
|
||||||
.use(
|
.use(
|
||||||
jwt({
|
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 }) => {
|
.get("/audit-logs", async ({ jwt, cookie: { mizuki }, query, status }) => {
|
||||||
const rawToken = mizuki.value;
|
const rawToken = mizuki.value;
|
||||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { MediaUpload } from "@/models/media";
|
||||||
import { Tag } from "@/models/tag";
|
import { Tag } from "@/models/tag";
|
||||||
import { User } from "@/models/user";
|
import { User } from "@/models/user";
|
||||||
import { createAuditLog } from "@/lib/audit";
|
import { createAuditLog } from "@/lib/audit";
|
||||||
|
import { authenticateApiKey } from "@/lib/api-key";
|
||||||
import { normalizeQueryTags, normalizeTags } from "@/lib/tag";
|
import { normalizeQueryTags, normalizeTags } from "@/lib/tag";
|
||||||
import { fetchTweetData } from "@/lib/tweet";
|
import { fetchTweetData } from "@/lib/tweet";
|
||||||
import { fetchPixivData } from "@/lib/pixiv";
|
import { fetchPixivData } from "@/lib/pixiv";
|
||||||
|
|
@ -216,16 +217,19 @@ export default new Elysia({ prefix: "/post" })
|
||||||
set.headers["Expires"] = "0";
|
set.headers["Expires"] = "0";
|
||||||
set.headers["Surrogate-Control"] = "no-store";
|
set.headers["Surrogate-Control"] = "no-store";
|
||||||
})
|
})
|
||||||
.derive(async ({ jwt, cookie: { mizuki } }) => {
|
.derive(async ({ jwt, cookie: { mizuki }, headers }) => {
|
||||||
return {
|
return {
|
||||||
getAuthenticatedUser: async () => {
|
getAuthenticatedUser: async () => {
|
||||||
const token = mizuki.value;
|
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);
|
return await authenticateApiKey(headers);
|
||||||
if (!payload || typeof payload !== "object" || !("id" in payload)) return null;
|
|
||||||
|
|
||||||
return await User.findOne({ userId: payload.id });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
|
||||||
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 Role = "admin" | "writer" | "reader";
|
||||||
|
|
||||||
export type Me = {
|
export type Me = {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserItem = {
|
export type UserItem = {
|
||||||
id: string;
|
id: string;
|
||||||
discordId: string;
|
discordId: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UploadItem = {
|
export type UploadItem = {
|
||||||
_id: string;
|
_id: string;
|
||||||
mediaUrl: string;
|
mediaUrl: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
uploadedBy?: {
|
uploadedBy?: {
|
||||||
username?: string;
|
username?: string;
|
||||||
role?: Role;
|
role?: Role;
|
||||||
};
|
};
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuditLog = {
|
export type AuditLog = {
|
||||||
_id: string;
|
_id: string;
|
||||||
action: string;
|
action: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
targetType?: string;
|
targetType?: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
actor?: {
|
actor?: {
|
||||||
username?: string;
|
username?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
};
|
};
|
||||||
createdAt?: 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"];
|
export const DEFAULT_ROLES: Role[] = ["admin", "writer", "reader"];
|
||||||
|
|
|
||||||
|
|
@ -1,350 +1,516 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { FormEvent } from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import Header from "../../components/header";
|
import Header from "../../components/header";
|
||||||
|
import ApiKeySection from "./components/api-key-section";
|
||||||
import AuditLogSection from "./components/audit-log-section";
|
import AuditLogSection from "./components/audit-log-section";
|
||||||
import DashboardHeader from "./components/dashboard-header";
|
import DashboardHeader from "./components/dashboard-header";
|
||||||
import PostManagementSection from "./components/post-management-section";
|
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 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() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [me, setMe] = useState<Me | null>(null);
|
const [me, setMe] = useState<Me | null>(null);
|
||||||
const [users, setUsers] = useState<UserItem[]>([]);
|
const [users, setUsers] = useState<UserItem[]>([]);
|
||||||
const [roles, setRoles] = useState<Role[]>([]);
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
const [pendingRole, setPendingRole] = useState<Record<string, Role>>({});
|
const [pendingRole, setPendingRole] = useState<Record<string, Role>>({});
|
||||||
|
|
||||||
const [posts, setPosts] = useState<UploadItem[]>([]);
|
const [posts, setPosts] = useState<UploadItem[]>([]);
|
||||||
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
|
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
|
||||||
const [deletingPosts, setDeletingPosts] = useState(false);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [savingUserId, setSavingUserId] = useState<string | null>(null);
|
const [savingUserId, setSavingUserId] = useState<string | null>(null);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [creatingApiKey, setCreatingApiKey] = useState(false);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
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 [newDiscordId, setNewDiscordId] = useState("");
|
||||||
const [newUsername, setNewUsername] = useState("");
|
const [newUsername, setNewUsername] = useState("");
|
||||||
const [newAvatar, setNewAvatar] = useState("");
|
const [newAvatar, setNewAvatar] = useState("");
|
||||||
const [newRole, setNewRole] = useState<Role>("reader");
|
const [newRole, setNewRole] = useState<Role>("reader");
|
||||||
|
const [newApiKeyName, setNewApiKeyName] = useState("");
|
||||||
|
|
||||||
const isAdmin = me?.role === "admin";
|
const isAdmin = me?.role === "admin";
|
||||||
|
|
||||||
const sortedUsers = useMemo(
|
const sortedUsers = useMemo(
|
||||||
() => [...users].sort((a, b) => a.username.localeCompare(b.username)),
|
() => [...users].sort((a, b) => a.username.localeCompare(b.username)),
|
||||||
[users],
|
[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}개 게시물을 삭제할까요?`,
|
||||||
);
|
);
|
||||||
|
if (!confirmed) {
|
||||||
function buildPostQuery(page: number, size: number) {
|
return;
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set("page", String(page));
|
|
||||||
params.set("size", String(size));
|
|
||||||
return params.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
setDeletingPosts(true);
|
||||||
setLoading(true);
|
setError(null);
|
||||||
setError(null);
|
setSuccess(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const meResponse = await fetch("/api/auth/me", { cache: "no-store" });
|
const response = await fetch("/api/post/bulk-delete", {
|
||||||
if (!meResponse.ok) {
|
method: "POST",
|
||||||
router.replace("/");
|
headers: {
|
||||||
return;
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
ids: selectedPostIds,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const profile = (await meResponse.json()) as Me;
|
if (!response.ok) {
|
||||||
setMe(profile);
|
const message = await response.text();
|
||||||
|
throw new Error(message || `일괄 삭제 실패: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (profile.role !== "admin") {
|
const result = (await response.json()) as { deletedCount?: number };
|
||||||
setUsers([]);
|
setPosts((current) =>
|
||||||
setRoles([]);
|
current.filter((post) => !selectedPostIds.includes(post._id)),
|
||||||
setPosts([]);
|
);
|
||||||
setAuditLogs([]);
|
setSuccess(
|
||||||
router.replace("/");
|
`${result.deletedCount ?? selectedPostIds.length}개 게시물을 삭제했습니다.`,
|
||||||
return;
|
);
|
||||||
}
|
setSelectedPostIds([]);
|
||||||
|
void refreshLogs();
|
||||||
const [usersResponse, rolesResponse, postsResponse, logsResponse] = await Promise.all([
|
} catch (deleteError) {
|
||||||
fetch("/api/auth/users", { cache: "no-store" }),
|
setError(
|
||||||
fetch("/api/auth/roles", { cache: "no-store" }),
|
deleteError instanceof Error
|
||||||
fetch(`/api/post/list?${buildPostQuery(1, 80)}`, { cache: "no-store" }),
|
? deleteError.message
|
||||||
fetch("/api/auth/audit-logs?page=1&size=80", { cache: "no-store" }),
|
: "게시물 일괄 삭제에 실패했습니다.",
|
||||||
]);
|
);
|
||||||
|
} finally {
|
||||||
if (!usersResponse.ok) {
|
setDeletingPosts(false);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
function togglePost(id: string) {
|
||||||
void loadAll();
|
setSelectedPostIds((current) => {
|
||||||
}, []);
|
if (current.includes(id)) {
|
||||||
|
return current.filter((value) => value !== id);
|
||||||
|
}
|
||||||
|
return [...current, id];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function updateRole(user: UserItem) {
|
function toggleSelectAllPosts() {
|
||||||
const nextRole = pendingRole[user.id] ?? user.role;
|
if (selectedPostIds.length === posts.length) {
|
||||||
if (nextRole === user.role) {
|
setSelectedPostIds([]);
|
||||||
return;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
setSelectedPostIds(posts.map((post) => post._id));
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteUser(user: UserItem) {
|
return (
|
||||||
if (!window.confirm(`${user.username} 유저를 삭제하시겠습니까?`)) {
|
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
|
||||||
return;
|
<Header />
|
||||||
}
|
|
||||||
|
|
||||||
setSavingUserId(user.id);
|
<main className="mx-auto w-full max-w-6xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||||
setError(null);
|
<DashboardHeader
|
||||||
setSuccess(null);
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
canAccess={Boolean(me && isAdmin)}
|
||||||
|
/>
|
||||||
|
|
||||||
try {
|
{!loading && isAdmin ? (
|
||||||
const response = await fetch(`/api/auth/user/${user.id}`, {
|
<UserManagementSection
|
||||||
method: "DELETE",
|
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) {
|
{!loading && isAdmin ? (
|
||||||
const message = await response.text();
|
<ApiKeySection
|
||||||
throw new Error(message || `유저 삭제 실패: ${response.status}`);
|
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));
|
{!loading && isAdmin ? <AuditLogSection logs={auditLogs} /> : null}
|
||||||
setSuccess(`${user.username} 유저를 삭제했습니다.`);
|
|
||||||
void refreshLogs();
|
|
||||||
} catch (deleteError) {
|
|
||||||
setError(deleteError instanceof Error ? deleteError.message : "유저 삭제에 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setSavingUserId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createUser(event: FormEvent<HTMLFormElement>) {
|
{!loading && isAdmin ? (
|
||||||
event.preventDefault();
|
<PostManagementSection
|
||||||
setCreating(true);
|
posts={posts}
|
||||||
setError(null);
|
selectedPostIds={selectedPostIds}
|
||||||
setSuccess(null);
|
deletingPosts={deletingPosts}
|
||||||
|
onToggleSelectAllPosts={toggleSelectAllPosts}
|
||||||
try {
|
onBulkDeletePosts={bulkDeletePosts}
|
||||||
const response = await fetch("/api/auth/user", {
|
onTogglePost={togglePost}
|
||||||
method: "POST",
|
/>
|
||||||
headers: {
|
) : null}
|
||||||
"Content-Type": "application/json",
|
</main>
|
||||||
},
|
</div>
|
||||||
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…
Add table
Add a link
Reference in a new issue