Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
7e27045
Feat: 둜그인 νŽ˜μ΄μ§€ UI μž‘μ—…
namsh1125 May 21, 2025
87251b0
Feat: λŒ€μ‹œλ³΄λ“œ νŽ˜μ΄μ§€ κ΅¬ν˜„
newspring0203 May 28, 2025
431c9dd
Feat: κΈ°λ³Έ API 응닡 νƒ€μž… μ •μ˜
newspring0203 May 28, 2025
3035694
Feat: PG사 μ„œλ²„μ—μ„œ μ‘λ‹΅λ°›λŠ” 토큰 정보 DTO κ΅¬ν˜„
newspring0203 May 28, 2025
2074d1a
Feat: 둜그인 μš”μ²­/응닡 DTO κ΅¬ν˜„
newspring0203 May 28, 2025
b13002e
Feat: κ΄€λ¦¬μž 둜그인 μš”μ²­ route.ts μ •μ˜
newspring0203 May 28, 2025
e6deded
Feat: μ‚¬μš©μž 둜그인 μš”μ²­ route.ts μ •μ˜
newspring0203 May 28, 2025
cda223e
Feat: 둜그인 μš”μ²­ API μ—°λ™ν•˜μ—¬ 둜그인 μ‹œ ν™”λ©΄ μ΄λ™ν•˜λ„λ‘ κ΅¬ν˜„
newspring0203 May 28, 2025
b627de7
Refactor: 둜그인 μš”μ²­/응닡 DTO μΆ”μΆœν•΄ λ¦¬νŒ©ν† λ§
newspring0203 May 28, 2025
577fa3f
Feat: 둜컬 μŠ€ν† λ¦¬μ§€μ— 토큰 μ €μž₯
newspring0203 May 28, 2025
593c39d
Merge pull request #2 from WON-Q/feat/login-page
newspring0203 May 28, 2025
36b9646
Feat: νŽ˜μ΄μ§€λ„€μ΄μ…˜ μ»΄ν¬λ„ŒνŠΈ κ΅¬ν˜„
newspring0203 May 28, 2025
a306408
Chore: lucide-react μ˜μ‘΄μ„± μΆ”κ°€
newspring0203 May 28, 2025
766b5fb
Feat: κ΄€λ¦¬μž λŒ€μ‹œλ³΄λ“œ λ ˆμ΄μ•„μ›ƒ κ΅¬ν˜„
newspring0203 May 28, 2025
c763731
Feat: νŽ˜μ΄μ§€λ„€μ΄μ…˜ νƒ€μž… μ •μ˜
newspring0203 May 28, 2025
be3044e
Feat: ApiKey λͺ©λ‘ 쑰회 API의 개발 ApiKey DTO κ΅¬ν˜„
newspring0203 May 28, 2025
a4b107c
Feat: API ν‚€ λͺ©λ‘ 쑰회 API (κ΄€λ¦¬μžμš©) κ΅¬ν˜„
newspring0203 May 28, 2025
b1b6a54
Feat: 데이터λ₯Ό CSV 파일둜 λ‚΄λ³΄λ‚΄λŠ” ν•¨μˆ˜ κ΅¬ν˜„
newspring0203 May 28, 2025
544c1a6
Fix: 토큰 μΆ”μΆœ 방식 λ³€κ²½
namsh1125 May 28, 2025
f427d8b
Fix: ApiKey λͺ©λ‘ 쑰회 API의 κ°œλ³„ ApiKey DTO μˆ˜μ • (isActive -> active)
namsh1125 May 28, 2025
5246958
Update: ApiKeyResponseDto μΈν„°νŽ˜μ΄μŠ€λ₯Ό export둜 λ³€κ²½
namsh1125 May 28, 2025
49511e0
Feat: κ΄€λ¦¬μž api key 쑰회 νŽ˜μ΄μ§€ κ΅¬ν˜„
namsh1125 May 28, 2025
406db21
Merge pull request #3 from WON-Q/feat/admin-dashboard
namsh1125 May 28, 2025
fbf80a0
Chore: clsx, tailwind-merge, recharts μ˜μ‘΄μ„± μΆ”κ°€
newspring0203 May 29, 2025
f5db5a8
Feat: Tailwind 클래슀 병합 μœ ν‹Έλ¦¬ν‹° ν•¨μˆ˜ μΆ”κ°€
newspring0203 May 29, 2025
6af37b2
Feat: PayGate ν…Œλ§ˆ 색상 μ •μ˜
newspring0203 May 29, 2025
f21c04b
Feat: 곡톡 Badge μ»΄ν¬λ„ŒνŠΈ κ΅¬ν˜„
newspring0203 May 29, 2025
b66a2c2
Feat: 전체 νŠΈλžœμž­μ…˜ 둜그 쑰회 API route.ts κ΅¬ν˜„
newspring0203 May 29, 2025
1aae842
Feat: κ΄€λ¦¬μž νŠΈλžœμž­μ…˜ 쑰회 ν™”λ©΄ κ΅¬ν˜„
newspring0203 May 29, 2025
cbd7928
Merge pull request #5 from WON-Q/feat/admin-transaction-log
newspring0203 May 29, 2025
6a717d2
Remove: μ›Ήν›… 관리 메뉴 제거
newspring0203 May 29, 2025
577e98b
Merge pull request #6 from WON-Q/update/admin-dashboard-layout
newspring0203 May 29, 2025
e650b5d
Feat: μ‚¬μš©μž λŒ€μ‹œλ³΄λ“œ λ ˆμ΄μ•„μ›ƒ κ΅¬ν˜„
newspring0203 May 29, 2025
2f11777
Feat: 곡톡 Button μ»΄ν¬λ„ŒνŠΈ κ΅¬ν˜„
newspring0203 May 29, 2025
a28d434
Feat: 곡톡 Modal μ»΄ν¬λ„ŒνŠΈ κ΅¬ν˜„
newspring0203 May 29, 2025
55b4a5f
Feat: ApiKey λͺ©λ‘ 쑰회 API의 κ°œλ³„ ApiKey DTO μ •μ˜
newspring0203 May 29, 2025
65a1889
Refactor: api.ts에 μ •μ˜λœ ApiKeyResponseDto μ‚¬μš©ν•˜μ—¬ λ¦¬νŒ©ν† λ§
newspring0203 May 29, 2025
63589ee
Feat: API ν‚€ λͺ©λ‘ 쑰회 API route.ts κ΅¬ν˜„
newspring0203 May 29, 2025
f2b6230
temp
newspring0203 May 29, 2025
30b2d26
Merge pull request #7 from WON-Q/feat/user-dashboard
newspring0203 May 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
345 changes: 345 additions & 0 deletions app/(dashboard)/admin/dashboard/api-keys/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,345 @@
"use client";

import React, { useState, useEffect } from "react";
import { FileDown, Filter, Search, Trash2 } from "lucide-react";
import Pagination from "@/components/ui/Pagination";
import { exportToCsv } from "@/utils/csvExport";
import { BaseResponse, Page } from "@/types/api";
import { ApiKeyResponseDto } from "@/app/api/admin/api-keys/route"

export default function AdminApiKeysPage() {
const [apiKeys, setApiKeys] = useState<ApiKeyResponseDto[]>([]); // API ν‚€ λͺ©λ‘
const [searchTerm, setSearchTerm] = useState(""); // 검색어
const [currentPage, setCurrentPage] = useState(1); // ν˜„μž¬ νŽ˜μ΄μ§€ (UI ν‘œμ‹œμš©, 1λΆ€ν„° μ‹œμž‘)
const [itemsPerPage] = useState(10); // νŽ˜μ΄μ§€λ‹Ή ν•­λͺ© 수
const [filters, setFilters] = useState<{ isActive: boolean | null }>({
isActive: null,
}); // μƒνƒœ ν•„ν„°
const [showFilterMenu, setShowFilterMenu] = useState(false); // ν•„ν„° 메뉴 ν‘œμ‹œ μ—¬λΆ€
const [loading, setLoading] = useState(true); // λ‘œλ”© μƒνƒœ
const [totalPages, setTotalPages] = useState(0); // 총 νŽ˜μ΄μ§€ 수
const [error, setError] = useState<string | null>(null); // μ—λŸ¬ λ©”μ‹œμ§€

// APIμ—μ„œ API ν‚€ λͺ©λ‘ 쑰회
useEffect(() => {
const fetchApiKeys = async () => {
setLoading(true);
setError(null);

try {
// localStorageμ—μ„œ μ•‘μ„ΈμŠ€ 토큰 κ°€μ Έμ˜€κΈ°
const accessToken = localStorage.getItem("accessToken");
if (!accessToken) {
setError("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
setLoading(false);
return;
}

// API μš”μ²­ (νŽ˜μ΄μ§€ λ²ˆν˜ΈλŠ” λ°±μ—”λ“œμ—μ„œ 0λΆ€ν„° μ‹œμž‘)
const response = await fetch(
`/api/admin/api-keys?page=${currentPage - 1}&size=${itemsPerPage}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);

if (!response.ok) {
throw new Error(`API ν‚€ λͺ©λ‘ 쑰회 μ‹€νŒ¨: ${response.status}`);
}

const result = await response.json() as BaseResponse<Page<ApiKeyResponseDto>>;
console.log("API ν‚€ λͺ©λ‘ 쑰회 κ²°κ³Ό:", result);

if (!result.isSuccess) {
throw new Error(result.message || "API ν‚€ λͺ©λ‘ μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€");
}

// νŽ˜μ΄μ§€ 정보 μ„€μ •
if (result.data) {
setApiKeys(result.data.content);
setTotalPages(result.data.totalPages);
}

} catch (err) {
console.error("API ν‚€ λͺ©λ‘ 쑰회 쀑 였λ₯˜ λ°œμƒ:", err);
setError(
err instanceof Error ? err.message : "μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€"
);
} finally {
setLoading(false);
}
};

fetchApiKeys();
}, [currentPage, itemsPerPage]);

// API ν‚€ μ‚­μ œ ν•Έλ“€λŸ¬
const handleDelete = async (id: number) => {
try {
const accessToken = localStorage.getItem("accessToken");
if (!accessToken) {
setError("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
return;
}

const response = await fetch(`/api/admin/api-keys/${id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${accessToken}`,
},
});

if (!response.ok) {
throw new Error(`API ν‚€ μ‚­μ œ μ‹€νŒ¨: ${response.status}`);
}

// μ‚­μ œ 성곡 μ‹œ λͺ©λ‘μ—μ„œ 제거
setApiKeys(apiKeys.filter((key) => key.id !== id));

} catch (err) {
console.error("API ν‚€ μ‚­μ œ 쀑 였λ₯˜ λ°œμƒ:", err);
setError(
err instanceof Error
? err.message
: "API ν‚€ μ‚­μ œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€"
);
}
};

// 검색어 및 필터에 따라 ν•„ν„°λ§λœ API ν‚€ λͺ©λ‘
const filteredApiKeys = apiKeys.filter((key) => {
const matchesSearch =
key.merchantName.toLowerCase().includes(searchTerm.toLowerCase()) ||
key.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
key.accessKey.toLowerCase().includes(searchTerm.toLowerCase());

const matchesStatus = filters.isActive === null || filters.isActive === key.active;

return matchesSearch && matchesStatus;
});

// CSV둜 내보내기
const handleExportToCsv = () => {
exportToCsv(
filteredApiKeys,
[
{ header: "ID", accessor: "id" },
{ header: "가맹점", accessor: "merchantName" },
{ header: "API Key 이름", accessor: "name" },
{ header: "Access Key", accessor: "accessKey" },
{
header: "μƒνƒœ",
accessor: (item) => (item.isActive ? "ν™œμ„±" : "λΉ„ν™œμ„±"),
},
{
header: "λ°œκΈ‰μΌ",
accessor: (item) => formatDate(new Date(item.issuedAt)),
},
{
header: "졜근 μ‚¬μš©μΌ",
accessor: (item) =>
item.lastUsed ? formatDate(new Date(item.lastUsed)) : "-",
},
{
header: "만료일",
accessor: (item) =>
item.expiresAt ? formatDate(new Date(item.expiresAt)) : "-",
},
],
"api_keys"
);
};

// λ‚ μ§œ 포맷 ν•¨μˆ˜
const formatDate = (date: Date | null) =>
date ? new Intl.DateTimeFormat("ko-KR").format(date) : "-";

// ν•„ν„° μƒνƒœ μ„€μ • ν•¨μˆ˜
const setStatusFilter = (isActive: boolean | null) => {
setFilters({ isActive });
setShowFilterMenu(false);
};

return (
<div className="space-y-6">
{/* 제λͺ© */}
<div>
<h1 className="text-2xl font-bold">κ΄€λ¦¬μž API ν‚€ 관리</h1>
<p className="text-[#5E99D6] mt-1">
λͺ¨λ“  κ°€λ§Ήμ μ˜ API ν‚€λ₯Ό ν•œλˆˆμ— ν™•μΈν•˜κ³  κ΄€λ¦¬ν•©λ‹ˆλ‹€.
</p>
</div>

{/* μ—λŸ¬ λ©”μ‹œμ§€ */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md text-red-600">
{error}
</div>
)}

{/* 상단 ν•„ν„° 및 검색바 */}
<div className="flex flex-col lg:flex-row gap-4 justify-between">
<div className="flex flex-col sm:flex-row gap-2">
{/* 검색창 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#5E99D6]" />
<input
type="text"
placeholder="가맹점, API Key 이름 λ˜λŠ” Access Key 검색"
className="pl-10 pr-4 py-2 border border-[#CDE5FF] rounded-md w-full sm:w-92 focus:outline-none focus:ring-2 focus:ring-[#81B9F8]"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>

{/* ν•„ν„° λ²„νŠΌ */}
<div className="relative">
<button
className="inline-flex items-center gap-2 px-4 py-2 border border-[#CDE5FF] rounded-md hover:bg-[#F6FBFF]"
onClick={() => setShowFilterMenu(!showFilterMenu)}
>
<Filter className="h-4 w-4 text-[#5E99D6]" />
ν•„ν„°
{filters.isActive !== null && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#0067AC] text-[10px] text-white">
1
</span>
)}
</button>

{/* ν•„ν„° λ“œλ‘­λ‹€μš΄ */}
{showFilterMenu && (
<div className="absolute top-full mt-1 left-0 z-10 w-72 bg-white rounded-md border border-[#CDE5FF] p-4 shadow-md">
<div className="flex justify-between items-center mb-3">
<h4 className="font-medium">ν•„ν„°</h4>
<button
className="text-[#5E99D6] text-sm hover:underline"
onClick={() => setStatusFilter(null)}
>
μ΄ˆκΈ°ν™”
</button>
</div>
<div>
<h5 className="text-sm font-medium mb-2">μƒνƒœ</h5>
<div className="flex flex-wrap gap-2">
<button
className={`px-3 py-1 rounded-full text-xs ${
filters.isActive === true
? "bg-[#0067AC] text-white"
: "bg-[#F6FBFF] text-[#5E99D6] border border-[#CDE5FF]"
}`}
onClick={() => setStatusFilter(true)}
>
ν™œμ„±
</button>
<button
className={`px-3 py-1 rounded-full text-xs ${
filters.isActive === false
? "bg-[#0067AC] text-white"
: "bg-[#F6FBFF] text-[#5E99D6] border border-[#CDE5FF]"
}`}
onClick={() => setStatusFilter(false)}
>
λΉ„ν™œμ„±
</button>
</div>
</div>
</div>
)}
</div>
</div>

{/* CSV 내보내기 λ²„νŠΌ */}
<button
onClick={handleExportToCsv}
className="inline-flex items-center gap-2 rounded-md bg-white border border-[#CDE5FF] px-4 py-2 text-[#0067AC] hover:bg-[#F6FBFF] transition-colors"
disabled={loading || filteredApiKeys.length === 0}
>
<FileDown className="h-4 w-4" />
CSV둜 내보내기
</button>
</div>

{/* ν…Œμ΄λΈ” */}
<div className="bg-white rounded-lg border border-[#CDE5FF] overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-[#F6FBFF]">
<th className="px-4 py-2 text-center">ID</th>
<th className="px-4 py-2">가맹점</th>
<th className="px-4 py-2">API Key 이름</th>
<th className="px-4 py-2">Access Key</th>
<th className="px-4 py-2 text-center">μƒνƒœ</th>
<th className="px-4 py-2 text-center">λ°œκΈ‰μΌ</th>
<th className="px-4 py-2 text-center">졜근 μ‚¬μš©μΌ</th>
<th className="px-4 py-2 text-center">만료일</th>
<th className="px-4 py-2 text-center">μ‚­μ œ</th>
</tr>
</thead>
<tbody className="divide-y divide-[#CDE5FF]">
{loading ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-gray-500">
데이터λ₯Ό λΆˆλŸ¬μ˜€λŠ” μ€‘μž…λ‹ˆλ‹€...
</td>
</tr>
) : filteredApiKeys.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-gray-500">
{searchTerm || filters.isActive !== null
? "검색 쑰건에 λ§žλŠ” API ν‚€κ°€ μ—†μŠ΅λ‹ˆλ‹€."
: "λ“±λ‘λœ API ν‚€κ°€ μ—†μŠ΅λ‹ˆλ‹€."}
</td>
</tr>
) : (
filteredApiKeys.map((key) => (
<tr key={key.id}>
<td className="px-4 py-2 text-center">{key.id}</td>
<td className="px-4 py-2">{key.merchantName}</td>
<td className="px-4 py-2">{key.name}</td>
<td className="px-4 py-2">{key.accessKey}</td>
<td
className={`px-4 py-2 text-center ${
key.active ? "text-green-500" : "text-red-500"
}`}
>
{key.active ? "ν™œμ„±" : "λΉ„ν™œμ„±"}
</td>
<td className="px-4 py-2 text-center">
{formatDate(new Date(key.issuedAt))}
</td>
<td className="px-4 py-2 text-center">
{key.lastUsed ? formatDate(new Date(key.lastUsed)) : "-"}
</td>
<td className="px-4 py-2 text-center">
{key.expiresAt ? formatDate(new Date(key.expiresAt)) : "-"}
</td>
<td className="px-4 py-2 text-center">
<button
onClick={() => handleDelete(key.id)}
className="text-red-500 hover:text-red-700 disabled:opacity-50"
disabled={loading}
>
<Trash2 className="h-5 w-5" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>

{/* νŽ˜μ΄μ§€λ„€μ΄μ…˜ */}
{!loading && totalPages > 0 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
</div>
);
}
Loading
Loading