Skip to content
50 changes: 50 additions & 0 deletions app/(dashboard)/user/dashboard/api-keys/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";

import React, { useState } from "react";
import ApiKeysList from "@/components/user/dashboard/api-keys/ApiKeysList";
import ErrorGuide from "@/components/user/dashboard/api-keys/ErrorGuide";

export default function ApiKeysPage() {
const [activeTab, setActiveTab] = useState("keys"); // keys, guide, errors

return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">API ν‚€ 관리</h1>
<p className="text-[#5E99D6] mt-1">
결제 μ‹œμŠ€ν…œμ— μ—°λ™ν•˜κΈ° μœ„ν•œ API ν‚€λ₯Ό κ΄€λ¦¬ν•©λ‹ˆλ‹€.
</p>
</div>

{/* νƒ­ 메뉴 */}
<div className="border-b border-[#CDE5FF]">
<div className="flex space-x-4">
<button
className={`px-4 py-2 ${
activeTab === "keys"
? "border-b-2 border-[#0067AC] text-[#0067AC] font-medium"
: "text-[#5E99D6]"
}`}
onClick={() => setActiveTab("keys")}
>
API ν‚€ λͺ©λ‘
</button>
<button
className={`px-4 py-2 ${
activeTab === "errors"
? "border-b-2 border-[#0067AC] text-[#0067AC] font-medium"
: "text-[#5E99D6]"
}`}
onClick={() => setActiveTab("errors")}
>
μ—λŸ¬ κ°€μ΄λ“œ
</button>
</div>
</div>

{/* νƒ­ 컨텐츠 */}
{activeTab === "keys" && <ApiKeysList />}
{activeTab === "errors" && <ErrorGuide />}
</div>
);
}
206 changes: 206 additions & 0 deletions app/(dashboard)/user/dashboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"use client";

import React, { type ReactNode, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, History, Home, KeyIcon, LogOut, Menu, Settings, User, X } from "lucide-react";
import { cn } from "@/lib/utils";

interface UserDashboardLayoutProps {
children: ReactNode;
}

export default function UserDashboardLayout({ children }: UserDashboardLayoutProps) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const pathname = usePathname();

// μ‚¬μš©μž 경둜 μ •μ˜
const userRoutes = [
{
name: "λŒ€μ‹œλ³΄λ“œ",
path: "/user/dashboard",
icon: <Home className="h-5 w-5" />,
},
{
name: "API ν‚€ 관리",
path: "/user/dashboard/api-keys",
icon: <KeyIcon className="h-5 w-5" />,
},
{
name: "νŠΈλžœμž­μ…˜ 둜그",
path: "/user/dashboard/transactions",
icon: <History className="h-5 w-5" />,
},
];

const routes = userRoutes;

return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-30 flex h-16 items-center gap-4 border-b bg-white px-4 md:px-6">
{isSidebarOpen ? (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setIsSidebarOpen(false)}
/>
) : null}

{/* λͺ¨λ°”일 메뉴 λ²„νŠΌ */}
<button
className="inline-flex items-center justify-center rounded-md border border-[#CDE5FF] bg-transparent hover:bg-[#F6FBFF] text-[#101010] h-10 w-10 md:hidden"
onClick={() => setIsSidebarOpen(true)}
>
<Menu className="h-5 w-5" />
<span className="sr-only">메뉴 ν† κΈ€</span>
</button>

{/* λͺ¨λ°”일 μ‚¬μ΄λ“œλ°” */}
{isSidebarOpen && (
<div className="fixed inset-y-0 left-0 z-50 w-72 bg-white shadow-xl p-0">
<div className="flex h-16 items-center border-b px-6">
<Link
href="/dashboard"
className="flex items-center gap-2 font-semibold"
onClick={() => setIsSidebarOpen(false)}
>
<span className="text-[#0067AC] font-bold text-xl">
PayGate
</span>
</Link>
<button
className="inline-flex items-center justify-center rounded-md bg-transparent hover:bg-[#F6FBFF] text-[#101010] h-10 w-10 ml-auto"
onClick={() => setIsSidebarOpen(false)}
>
<X className="h-5 w-5" />
</button>
</div>
<nav className="grid gap-2 p-4">
{routes.map((route) => (
<Link
key={route.path}
href={route.path}
onClick={() => setIsSidebarOpen(false)}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-[#F6FBFF] transition-colors",
pathname === route.path
? "bg-[#F6FBFF] text-[#0067AC]"
: "text-[#5E99D6]"
)}
>
{route.icon}
<span>{route.name}</span>
</Link>
))}
</nav>
</div>
)}

{/* λΈŒλžœλ“œ 둜고/이름 */}
<Link href="/dashboard" className="hidden md:flex items-center gap-2">
<span className="text-[#0067AC] font-bold text-xl">PayGate</span>
</Link>

{/* 헀더 였λ₯Έμͺ½ μ„Ήμ…˜ */}
<div className="ml-auto flex items-center gap-4">

{/* μ‚¬μš©μž λ“œλ‘­λ‹€μš΄ */}
<div className="relative">
<button
className="inline-flex items-center justify-center rounded-md bg-transparent hover:bg-[#F6FBFF] text-[#101010] h-8 px-3 text-sm gap-2"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#5E99D6] text-white">
<span className="text-sm font-medium">κΉ€</span>
</div>
<div className="hidden md:flex flex-col items-start text-sm">
<span>κΉ€μ‚¬μš©μž</span>
</div>
<ChevronDown className="h-4 w-4 text-[#5E99D6]" />
</button>

{isDropdownOpen && (
<div className="absolute top-full mt-1 right-0 z-50 min-w-[200px] rounded-md border border-[#CDE5FF] bg-white p-2 shadow-md">
<div className="px-2 py-1 text-sm font-medium">λ‚΄ 계정</div>
<div className="my-1 h-px bg-[#CDE5FF]"></div>
<button
className="flex w-full items-center rounded-sm px-2 py-1 text-sm hover:bg-[#F6FBFF]"
onClick={() => setIsDropdownOpen(false)}
>
<User className="mr-2 h-4 w-4" />
<span>ν”„λ‘œν•„</span>
</button>
<button
className="flex w-full items-center rounded-sm px-2 py-1 text-sm hover:bg-[#F6FBFF]"
onClick={() => setIsDropdownOpen(false)}
>
<Settings className="mr-2 h-4 w-4" />
<span>μ„€μ •</span>
</button>
<div className="my-1 h-px bg-[#CDE5FF]"></div>
<button
className="flex w-full items-center rounded-sm px-2 py-1 text-sm hover:bg-[#F6FBFF]"
onClick={() => setIsDropdownOpen(false)}
>
<LogOut className="mr-2 h-4 w-4" />
<span>λ‘œκ·Έμ•„μ›ƒ</span>
</button>
</div>
)}
</div>
</div>
</header>

<div className="flex flex-1">
{/* λ°μŠ€ν¬ν†± μ‚¬μ΄λ“œλ°” */}
<aside className="hidden w-64 shrink-0 border-r md:block sticky top-16 h-[calc(100vh-4rem)]">
<div className="flex h-full flex-col p-4 overflow-y-auto">
<nav className="grid gap-2 text-sm flex-grow-0">
{routes.map((route) => (
<Link
key={route.path}
href={route.path}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-[#F6FBFF] transition-colors",
pathname === route.path
? "bg-[#F6FBFF] text-[#0067AC]"
: "text-[#5E99D6]"
)}
>
{route.icon}
<span>{route.name}</span>
</Link>
))}
</nav>
<div className="flex-grow"></div>
<div className="pt-4">
<div className="rounded-lg border border-[#CDE5FF] bg-white p-4">
<div className="flex items-center gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#0067AC]">
<span className="text-xs font-bold text-white">?</span>
</div>
<div>
<div className="text-sm font-medium">
도움이 ν•„μš”ν•˜μ‹ κ°€μš”?
</div>
<div className="text-xs text-[#5E99D6]">
고객센터에 λ¬Έμ˜ν•˜μ„Έμš”
</div>
</div>
</div>
<button className="mt-4 w-full inline-flex items-center justify-center rounded-md border border-[#CDE5FF] bg-transparent hover:bg-[#F6FBFF] text-[#101010] h-8 px-3 text-sm">
고객센터
</button>
</div>
</div>
</div>
</aside>

{/* 메인 μ½˜ν…μΈ  */}
<main className="flex-1 overflow-y-auto p-4 bg-[#F6FBFF]">
{children}
</main>
</div>
</div>
);
}
52 changes: 1 addition & 51 deletions app/api/admin/api-keys/route.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { BaseResponse, Page } from "@/types/api";

/**
* ApiKey λͺ©λ‘ 쑰회 API의 κ°œλ³„ ApiKey DTO
*/
export interface ApiKeyResponseDto {
/**
* API ν‚€ ID
*/
id: number;

/**
* 가맹점 ID
*/
merchantId: number;

/**
* 가맹점 이름
*/
merchantName: string;

/**
* API ν‚€ 이름
*/
name: string;

/**
* μ•‘μ„ΈμŠ€ ν‚€
*/
accessKey: string;

/**
* μ•‘μ„ΈμŠ€ ν‚€ ν™œμ„±ν™” μ—¬λΆ€
*/
active: boolean;

/**
* λ°œκΈ‰ μ‹œκ°
*/
issuedAt: string;

/**
* 만료 μ‹œκ°
*/
expiresAt: string | null;

/**
* λ§ˆμ§€λ§‰ μ‚¬μš© μ‹œκ°
*/
lastUsed: string | null;

}
import { ApiKeyResponseDto } from "@/types/api";

/**
* API ν‚€ λͺ©λ‘ 쑰회 API (κ΄€λ¦¬μžμš©)
Expand Down
77 changes: 77 additions & 0 deletions app/api/user/api-keys/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from "next/server";
import { BaseResponse, Page } from "@/types/api";
import { ApiKeyResponseDto } from "@/types/api";

/**
* API ν‚€ λͺ©λ‘ 쑰회 API (κ°€λ§Ήμ μš©)
*
* @param req - API ν‚€ λͺ©λ‘ 쑰회 μš”μ²­ 정보
* @returns API ν‚€ λͺ©λ‘κ³Ό νŽ˜μ΄μ§• 정보가 ν¬ν•¨λœ 응닡
*/
export async function GET(req: NextRequest): Promise<NextResponse> {
try {
const { searchParams } = new URL(req.url);

// νŽ˜μ΄μ§€λ„€μ΄μ…˜ νŒŒλΌλ―Έν„° μΆ”μΆœ
const page = searchParams.get("page") || "0";
const size = searchParams.get("size") || "10";
const sort = searchParams.get("sort") || "id,desc";

// API ν‚€ λͺ©λ‘ 쑰회 URL
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/api-keys?page=${page}&size=${size}&sort=${sort}`;

// 인증 토큰 κ°€μ Έμ˜€κΈ°
const authToken = req.headers.get("Authorization")?.replace("Bearer ", "");

// 인증 토큰이 μ—†λŠ” 경우
if (!authToken) {
return NextResponse.json(
{
success: false,
message: "인증 토큰이 μ—†μŠ΅λ‹ˆλ‹€. 둜그인 ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.",
},
{ status: 401 }
);
}

// λ°±μ—”λ“œ API 호좜
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
});

// 응닡 μƒνƒœκ°€ 성곡이 μ•„λ‹Œ 경우 처리
if (!response.ok) {
const errorData: BaseResponse<void> = await response.json();
return NextResponse.json(
{
success: false,
message: errorData.message || "API ν‚€ λͺ©λ‘ 쑰회 μ‹€νŒ¨",
},
{ status: response.status }
);
}

// 정상적인 응닡인 경우
const data: BaseResponse<Page<ApiKeyResponseDto>> = await response.json();
return NextResponse.json(data);

} catch (error: unknown) {
console.error("API ν‚€ λͺ©λ‘ 쑰회 쀑 였λ₯˜ λ°œμƒ:", error);

let errorMessage = "μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.";
if (error instanceof Error) {
errorMessage = error.message;
}

return NextResponse.json(
{
success: false,
message: `API ν‚€ λͺ©λ‘ 쑰회 쀑 였λ₯˜: ${errorMessage}`,
},
{ status: 500 }
);
}
}
Loading