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);
|
const payload = await jwt.verify(token);
|
||||||
if (!payload || typeof payload !== "object" || !("id" in payload)) return null;
|
if (payload && typeof payload === "object" && "id" in payload) {
|
||||||
|
const user = await User.findOne({ userId: payload.id });
|
||||||
|
if (user) return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return await User.findOne({ userId: payload.id });
|
return await authenticateApiKey(headers);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
|
||||||
170
apps/frontend/src/app/dashboard/components/api-key-section.tsx
Normal file
170
apps/frontend/src/app/dashboard/components/api-key-section.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import type { FormEvent } from "react";
|
||||||
|
import type { ApiKeyItem, CreatedApiKey } from "./types";
|
||||||
|
|
||||||
|
type ApiKeySectionProps = {
|
||||||
|
apiKeys: ApiKeyItem[];
|
||||||
|
createdApiKey: CreatedApiKey | null;
|
||||||
|
newApiKeyName: string;
|
||||||
|
creatingApiKey: boolean;
|
||||||
|
revokingApiKeyId: string | null;
|
||||||
|
onNewApiKeyNameChange: (value: string) => void;
|
||||||
|
onCreateApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||||
|
onRevokeApiKey: (apiKey: ApiKeyItem) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(value?: string) {
|
||||||
|
if (!value) return "-";
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApiKeySection({
|
||||||
|
apiKeys,
|
||||||
|
createdApiKey,
|
||||||
|
newApiKeyName,
|
||||||
|
creatingApiKey,
|
||||||
|
revokingApiKeyId,
|
||||||
|
onNewApiKeyNameChange,
|
||||||
|
onCreateApiKey,
|
||||||
|
onRevokeApiKey,
|
||||||
|
}: ApiKeySectionProps) {
|
||||||
|
function handleRevokeApiKey(apiKey: ApiKeyItem) {
|
||||||
|
void onRevokeApiKey(apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCreatedKey() {
|
||||||
|
if (!createdApiKey) return;
|
||||||
|
await navigator.clipboard.writeText(createdApiKey.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4 border-b border-border/70 pb-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg tracking-wide">API Keys</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-3 border border-border/70 p-4 sm:flex-row sm:items-end"
|
||||||
|
onSubmit={onCreateApiKey}
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label
|
||||||
|
className="block text-xs text-foreground/70"
|
||||||
|
htmlFor="apiKeyName"
|
||||||
|
>
|
||||||
|
Key name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="apiKeyName"
|
||||||
|
type="text"
|
||||||
|
value={newApiKeyName}
|
||||||
|
onChange={(event) => onNewApiKeyNameChange(event.target.value)}
|
||||||
|
className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none"
|
||||||
|
placeholder="deploy script"
|
||||||
|
maxLength={80}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={creatingApiKey}
|
||||||
|
className="border border-border bg-foreground px-4 py-2 text-sm text-background disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{creatingApiKey ? "발급 중..." : "API key 발급"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{createdApiKey ? (
|
||||||
|
<div className="space-y-3 border border-emerald-300 bg-emerald-50 p-4 text-sm text-emerald-950">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p>새 키는 지금 한 번만 표시됩니다.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyCreatedKey}
|
||||||
|
className="border border-emerald-700 px-3 py-1 text-xs"
|
||||||
|
>
|
||||||
|
복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<code className="block overflow-x-auto border border-emerald-200 bg-white/70 p-3 font-mono text-xs text-emerald-950">
|
||||||
|
{createdApiKey.key}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
<div className="space-y-2 border border-border/70 p-4">
|
||||||
|
<h3 className="text-sm tracking-wide">curl</h3>
|
||||||
|
<pre className="overflow-x-auto text-xs leading-5 text-foreground/80">
|
||||||
|
<code>{`curl -X POST "$BASE_URL/api/post/upload" \\
|
||||||
|
-H "Authorization: Bearer $MIZUKI_API_KEY" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"url":"https://x.com/.../status/...","tag":["미분류"],"selected":[]}'`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 border border-border/70 p-4">
|
||||||
|
<h3 className="text-sm tracking-wide">Headers</h3>
|
||||||
|
<pre className="overflow-x-auto text-xs leading-5 text-foreground/80">
|
||||||
|
<code>{`Authorization: Bearer $MIZUKI_API_KEY
|
||||||
|
x-api-key: $MIZUKI_API_KEY`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto border border-border/70">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="bg-black/5 text-foreground/70">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">Name</th>
|
||||||
|
<th className="px-3 py-2">Prefix</th>
|
||||||
|
<th className="px-3 py-2">Owner</th>
|
||||||
|
<th className="px-3 py-2">Created</th>
|
||||||
|
<th className="px-3 py-2">Last used</th>
|
||||||
|
<th className="px-3 py-2">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{apiKeys.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-4 text-foreground/60" colSpan={6}>
|
||||||
|
발급된 API key가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{apiKeys.map((apiKey) => {
|
||||||
|
const revoked = Boolean(apiKey.revokedAt);
|
||||||
|
return (
|
||||||
|
<tr key={apiKey.id} className="border-t border-border/50">
|
||||||
|
<td className="px-3 py-2">{apiKey.name}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">
|
||||||
|
{apiKey.prefix}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">{apiKey.owner.username}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
{formatDate(apiKey.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
{formatDate(apiKey.lastUsedAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{revoked ? (
|
||||||
|
<span className="text-xs text-foreground/50">폐기됨</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRevokeApiKey(apiKey)}
|
||||||
|
disabled={revokingApiKeyId === apiKey.id}
|
||||||
|
className="border border-red-300 bg-red-50 px-3 py-1 text-xs text-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{revokingApiKeyId === apiKey.id ? "폐기 중..." : "폐기"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -40,4 +40,23 @@ export type AuditLog = {
|
||||||
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,13 +1,30 @@
|
||||||
"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();
|
||||||
|
|
@ -21,10 +38,16 @@ export default function DashboardPage() {
|
||||||
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 [creatingApiKey, setCreatingApiKey] = useState(false);
|
||||||
|
const [revokingApiKeyId, setRevokingApiKeyId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -32,6 +55,7 @@ export default function DashboardPage() {
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -40,14 +64,7 @@ export default function DashboardPage() {
|
||||||
[users],
|
[users],
|
||||||
);
|
);
|
||||||
|
|
||||||
function buildPostQuery(page: number, size: number) {
|
const loadAll = useCallback(async () => {
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set("page", String(page));
|
|
||||||
params.set("size", String(size));
|
|
||||||
return params.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAll() {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
|
@ -66,56 +83,86 @@ export default function DashboardPage() {
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
setPosts([]);
|
setPosts([]);
|
||||||
setAuditLogs([]);
|
setAuditLogs([]);
|
||||||
|
setApiKeys([]);
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [usersResponse, rolesResponse, postsResponse, logsResponse] = await Promise.all([
|
const [
|
||||||
|
usersResponse,
|
||||||
|
rolesResponse,
|
||||||
|
postsResponse,
|
||||||
|
logsResponse,
|
||||||
|
apiKeysResponse,
|
||||||
|
] = await Promise.all([
|
||||||
fetch("/api/auth/users", { cache: "no-store" }),
|
fetch("/api/auth/users", { cache: "no-store" }),
|
||||||
fetch("/api/auth/roles", { cache: "no-store" }),
|
fetch("/api/auth/roles", { cache: "no-store" }),
|
||||||
fetch(`/api/post/list?${buildPostQuery(1, 80)}`, { 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/audit-logs?page=1&size=80", { cache: "no-store" }),
|
||||||
|
fetch("/api/auth/api-keys", { cache: "no-store" }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!usersResponse.ok) {
|
if (!usersResponse.ok) {
|
||||||
throw new Error(`유저 목록을 불러오지 못했습니다: ${usersResponse.status}`);
|
throw new Error(
|
||||||
|
`유저 목록을 불러오지 못했습니다: ${usersResponse.status}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rolesResponse.ok) {
|
if (!rolesResponse.ok) {
|
||||||
throw new Error(`역할 목록을 불러오지 못했습니다: ${rolesResponse.status}`);
|
throw new Error(
|
||||||
|
`역할 목록을 불러오지 못했습니다: ${rolesResponse.status}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!postsResponse.ok) {
|
if (!postsResponse.ok) {
|
||||||
throw new Error(`게시물 목록을 불러오지 못했습니다: ${postsResponse.status}`);
|
throw new Error(
|
||||||
|
`게시물 목록을 불러오지 못했습니다: ${postsResponse.status}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!logsResponse.ok) {
|
if (!logsResponse.ok) {
|
||||||
throw new Error(`감사 로그를 불러오지 못했습니다: ${logsResponse.status}`);
|
throw new Error(
|
||||||
|
`감사 로그를 불러오지 못했습니다: ${logsResponse.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKeysResponse.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`API key 목록을 불러오지 못했습니다: ${apiKeysResponse.status}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const usersData = (await usersResponse.json()) as UserItem[];
|
const usersData = (await usersResponse.json()) as UserItem[];
|
||||||
const roleData = (await rolesResponse.json()) as Role[];
|
const roleData = (await rolesResponse.json()) as Role[];
|
||||||
const postData = (await postsResponse.json()) as UploadItem[];
|
const postData = (await postsResponse.json()) as UploadItem[];
|
||||||
const logData = (await logsResponse.json()) as AuditLog[];
|
const logData = (await logsResponse.json()) as AuditLog[];
|
||||||
|
const apiKeyData = (await apiKeysResponse.json()) as ApiKeyItem[];
|
||||||
|
|
||||||
setUsers(usersData);
|
setUsers(usersData);
|
||||||
setRoles(roleData);
|
setRoles(roleData);
|
||||||
setPosts(postData);
|
setPosts(postData);
|
||||||
setAuditLogs(logData);
|
setAuditLogs(logData);
|
||||||
|
setApiKeys(apiKeyData);
|
||||||
setPendingRole(
|
setPendingRole(
|
||||||
Object.fromEntries(usersData.map((user) => [user.id, user.role])) as Record<string, Role>,
|
Object.fromEntries(
|
||||||
|
usersData.map((user) => [user.id, user.role]),
|
||||||
|
) as Record<string, Role>,
|
||||||
);
|
);
|
||||||
setSelectedPostIds([]);
|
setSelectedPostIds([]);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(loadError instanceof Error ? loadError.message : "대시보드를 불러오지 못했습니다.");
|
setError(
|
||||||
|
loadError instanceof Error
|
||||||
|
? loadError.message
|
||||||
|
: "대시보드를 불러오지 못했습니다.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}, [router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadAll();
|
void loadAll();
|
||||||
}, []);
|
}, [loadAll]);
|
||||||
|
|
||||||
async function updateRole(user: UserItem) {
|
async function updateRole(user: UserItem) {
|
||||||
const nextRole = pendingRole[user.id] ?? user.role;
|
const nextRole = pendingRole[user.id] ?? user.role;
|
||||||
|
|
@ -144,11 +191,19 @@ export default function DashboardPage() {
|
||||||
throw new Error(message || `역할 변경 실패: ${response.status}`);
|
throw new Error(message || `역할 변경 실패: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setUsers((current) => current.map((item) => (item.id === user.id ? { ...item, role: nextRole } : item)));
|
setUsers((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === user.id ? { ...item, role: nextRole } : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
setSuccess(`${user.username} 권한을 ${nextRole}(으)로 변경했습니다.`);
|
setSuccess(`${user.username} 권한을 ${nextRole}(으)로 변경했습니다.`);
|
||||||
void refreshLogs();
|
void refreshLogs();
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
setError(saveError instanceof Error ? saveError.message : "역할 변경에 실패했습니다.");
|
setError(
|
||||||
|
saveError instanceof Error
|
||||||
|
? saveError.message
|
||||||
|
: "역할 변경에 실패했습니다.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingUserId(null);
|
setSavingUserId(null);
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +232,11 @@ export default function DashboardPage() {
|
||||||
setSuccess(`${user.username} 유저를 삭제했습니다.`);
|
setSuccess(`${user.username} 유저를 삭제했습니다.`);
|
||||||
void refreshLogs();
|
void refreshLogs();
|
||||||
} catch (deleteError) {
|
} catch (deleteError) {
|
||||||
setError(deleteError instanceof Error ? deleteError.message : "유저 삭제에 실패했습니다.");
|
setError(
|
||||||
|
deleteError instanceof Error
|
||||||
|
? deleteError.message
|
||||||
|
: "유저 삭제에 실패했습니다.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingUserId(null);
|
setSavingUserId(null);
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +277,11 @@ export default function DashboardPage() {
|
||||||
setSuccess(`${created.username} 유저를 생성했습니다.`);
|
setSuccess(`${created.username} 유저를 생성했습니다.`);
|
||||||
void refreshLogs();
|
void refreshLogs();
|
||||||
} catch (createError) {
|
} catch (createError) {
|
||||||
setError(createError instanceof Error ? createError.message : "유저 생성에 실패했습니다.");
|
setError(
|
||||||
|
createError instanceof Error
|
||||||
|
? createError.message
|
||||||
|
: "유저 생성에 실패했습니다.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
|
|
@ -226,7 +289,9 @@ export default function DashboardPage() {
|
||||||
|
|
||||||
async function refreshLogs() {
|
async function refreshLogs() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/auth/audit-logs?page=1&size=80", { cache: "no-store" });
|
const response = await fetch("/api/auth/audit-logs?page=1&size=80", {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -237,12 +302,92 @@ export default function DashboardPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function bulkDeletePosts() {
|
||||||
if (selectedPostIds.length === 0) {
|
if (selectedPostIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmed = window.confirm(`선택한 ${selectedPostIds.length}개 게시물을 삭제할까요?`);
|
const confirmed = window.confirm(
|
||||||
|
`선택한 ${selectedPostIds.length}개 게시물을 삭제할까요?`,
|
||||||
|
);
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -268,12 +413,20 @@ export default function DashboardPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = (await response.json()) as { deletedCount?: number };
|
const result = (await response.json()) as { deletedCount?: number };
|
||||||
setPosts((current) => current.filter((post) => !selectedPostIds.includes(post._id)));
|
setPosts((current) =>
|
||||||
setSuccess(`${result.deletedCount ?? selectedPostIds.length}개 게시물을 삭제했습니다.`);
|
current.filter((post) => !selectedPostIds.includes(post._id)),
|
||||||
|
);
|
||||||
|
setSuccess(
|
||||||
|
`${result.deletedCount ?? selectedPostIds.length}개 게시물을 삭제했습니다.`,
|
||||||
|
);
|
||||||
setSelectedPostIds([]);
|
setSelectedPostIds([]);
|
||||||
void refreshLogs();
|
void refreshLogs();
|
||||||
} catch (deleteError) {
|
} catch (deleteError) {
|
||||||
setError(deleteError instanceof Error ? deleteError.message : "게시물 일괄 삭제에 실패했습니다.");
|
setError(
|
||||||
|
deleteError instanceof Error
|
||||||
|
? deleteError.message
|
||||||
|
: "게시물 일괄 삭제에 실패했습니다.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingPosts(false);
|
setDeletingPosts(false);
|
||||||
}
|
}
|
||||||
|
|
@ -332,6 +485,19 @@ export default function DashboardPage() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!loading && isAdmin ? (
|
||||||
|
<ApiKeySection
|
||||||
|
apiKeys={apiKeys}
|
||||||
|
createdApiKey={createdApiKey}
|
||||||
|
newApiKeyName={newApiKeyName}
|
||||||
|
creatingApiKey={creatingApiKey}
|
||||||
|
revokingApiKeyId={revokingApiKeyId}
|
||||||
|
onNewApiKeyNameChange={setNewApiKeyName}
|
||||||
|
onCreateApiKey={createApiKey}
|
||||||
|
onRevokeApiKey={revokeApiKey}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{!loading && isAdmin ? <AuditLogSection logs={auditLogs} /> : null}
|
{!loading && isAdmin ? <AuditLogSection logs={auditLogs} /> : null}
|
||||||
|
|
||||||
{!loading && isAdmin ? (
|
{!loading && isAdmin ? (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue