forked from baron/baron-sso
ory용 MCP 제작, devfront/adminfront 백엔드 연결
This commit is contained in:
@@ -11,6 +11,8 @@ DB_PORT=5432
|
|||||||
CLICKHOUSE_PORT_HTTP=8123
|
CLICKHOUSE_PORT_HTTP=8123
|
||||||
CLICKHOUSE_PORT_NATIVE=9000
|
CLICKHOUSE_PORT_NATIVE=9000
|
||||||
BACKEND_PORT=3000
|
BACKEND_PORT=3000
|
||||||
|
ADMINFRONT_PORT=5173
|
||||||
|
DEVFRONT_PORT=5174
|
||||||
USERFRONT_PORT=5000
|
USERFRONT_PORT=5000
|
||||||
|
|
||||||
# --- Database Credentials (PostgreSQL) ---
|
# --- Database Credentials (PostgreSQL) ---
|
||||||
@@ -68,7 +70,7 @@ KETO_DB=ory_keto
|
|||||||
# Ory Kratos Configuration
|
# Ory Kratos Configuration
|
||||||
KRATOS_VERSION=v25.4.0-distroless
|
KRATOS_VERSION=v25.4.0-distroless
|
||||||
KRATOS_PUBLIC_PORT=4433
|
KRATOS_PUBLIC_PORT=4433
|
||||||
KRATOS_ADMIN_PORT=4434
|
KRATOS_ADMINFRONT_PORT=4434
|
||||||
|
|
||||||
KRATOS_UI_NODE_VERSION=v25.4.0
|
KRATOS_UI_NODE_VERSION=v25.4.0
|
||||||
KRATOS_UI_PORT=4455
|
KRATOS_UI_PORT=4455
|
||||||
@@ -76,7 +78,7 @@ KRATOS_UI_PORT=4455
|
|||||||
# Ory Hydra Configuration
|
# Ory Hydra Configuration
|
||||||
HYDRA_VERSION=v25.4.0-distroless
|
HYDRA_VERSION=v25.4.0-distroless
|
||||||
HYDRA_PUBLIC_PORT=4441
|
HYDRA_PUBLIC_PORT=4441
|
||||||
HYDRA_ADMIN_PORT=4445
|
HYDRA_ADMINFRONT_PORT=4445
|
||||||
|
|
||||||
# Ory Keto Configuration
|
# Ory Keto Configuration
|
||||||
KETO_VERSION=v25.4.0-distroless
|
KETO_VERSION=v25.4.0-distroless
|
||||||
@@ -88,6 +90,7 @@ ORY_SDK_URL=http://kratos:4433
|
|||||||
KRATOS_PUBLIC_URL=http://kratos:4433
|
KRATOS_PUBLIC_URL=http://kratos:4433
|
||||||
KRATOS_ADMIN_URL=http://kratos:4434
|
KRATOS_ADMIN_URL=http://kratos:4434
|
||||||
HYDRA_ADMIN_URL=http://hydra:4445
|
HYDRA_ADMIN_URL=http://hydra:4445
|
||||||
|
HYDRA_PUBLIC_URL=http://hydra:4444
|
||||||
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
|
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
|
||||||
|
|
||||||
# Kratos Selfservice UI required secrets (local only)
|
# Kratos Selfservice UI required secrets (local only)
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -91,6 +91,7 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. Magic
|
|||||||
IDP_PROVIDER=ory,descope
|
IDP_PROVIDER=ory,descope
|
||||||
KRATOS_ADMIN_URL=http://kratos:4434
|
KRATOS_ADMIN_URL=http://kratos:4434
|
||||||
HYDRA_ADMIN_URL=http://hydra:4445
|
HYDRA_ADMIN_URL=http://hydra:4445
|
||||||
|
HYDRA_PUBLIC_URL=http://hydra:4444
|
||||||
```
|
```
|
||||||
|
|
||||||
### 전체 스택 실행 (Running the Stack)
|
### 전체 스택 실행 (Running the Stack)
|
||||||
@@ -124,6 +125,35 @@ docker compose -f docker-compose.yaml up -d
|
|||||||
- **Hydra Public**: http://localhost:4444
|
- **Hydra Public**: http://localhost:4444
|
||||||
- **Kratos UI**: http://localhost:4455
|
- **Kratos UI**: http://localhost:4455
|
||||||
|
|
||||||
|
### MCP 서버 (Hydra/Kratos)
|
||||||
|
MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다.
|
||||||
|
프로덕션에서는 실행하지 않도록 `mcp` 프로파일을 로컬에서만 켜세요.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.ory.yaml --profile mcp up -d hydra-mcp-server kratos-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
- MCP 서버는 stdio 기반이라 외부 포트를 열지 않습니다.
|
||||||
|
- MCP 클라이언트에서 `npx`로 실행하는 설정 예시입니다.
|
||||||
|
- `hydra-mcp`는 첫 실행 시 캐시 디렉터리에 의존성을 자동 설치합니다(수동 `npm install` 불필요).
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mcp_servers.kratos-mcp]
|
||||||
|
command = "npx"
|
||||||
|
args = ["-y", "mcp-ory-kratos"]
|
||||||
|
|
||||||
|
[mcp_servers.kratos-mcp.env]
|
||||||
|
KRATOS_ADMIN_URL = "http://localhost:4434"
|
||||||
|
|
||||||
|
[mcp_servers.hydra-mcp]
|
||||||
|
command = "npx"
|
||||||
|
args = ["-y", "/home/lectom/repos/baron-sso/mcp/hydra-mcp"]
|
||||||
|
|
||||||
|
[mcp_servers.hydra-mcp.env]
|
||||||
|
HYDRA_PUBLIC_URL = "http://localhost:4441"
|
||||||
|
HYDRA_ADMIN_URL = "http://localhost:4445"
|
||||||
|
```
|
||||||
|
|
||||||
### 로컬 개발 (Manual)
|
### 로컬 개발 (Manual)
|
||||||
Docker 없이 코드를 수정하며 개발하려면:
|
Docker 없이 코드를 수정하며 개발하려면:
|
||||||
|
|
||||||
@@ -141,6 +171,20 @@ flutter pub get
|
|||||||
flutter run -d chrome
|
flutter run -d chrome
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**adminfront:**
|
||||||
|
```bash
|
||||||
|
cd adminfront
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**devfront:**
|
||||||
|
```bash
|
||||||
|
cd devfront
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📂 프로젝트 구조 (Project Structure)
|
## 📂 프로젝트 구조 (Project Structure)
|
||||||
|
|||||||
25
adminfront/Dockerfile
Normal file
25
adminfront/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM node:lts
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 패키지 정보 복사 및 의존성 설치
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
||||||
|
RUN npm install -g serve
|
||||||
|
|
||||||
|
# 소스 코드 복사
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Vite 기본 포트
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
||||||
|
CMD sh -c "if [ \"$APP_ENV\" = 'production' ]; then \
|
||||||
|
echo 'Running in production mode...'; \
|
||||||
|
npm run build && serve -s dist -l 5173; \
|
||||||
|
else \
|
||||||
|
echo 'Running in development mode...'; \
|
||||||
|
npm run dev -- --host 0.0.0.0; \
|
||||||
|
fi"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Admin Front (React 19 + Vite)
|
# Admin Front (React 19 + Vite)
|
||||||
|
|
||||||
관리자 포털을 위한 React/Vite 기반 SPA입니다. 이슈 #60 스펙을 바탕으로 라우팅, 서버 상태, 스타일 토큰을 세팅했고 특정 벤더에 종속되지 않는 IDP 연동 훅 포인트를 남겨두었습니다.
|
관리자 포털을 위한 React/Vite 기반 웹입니다. 이슈 #60 스펙을 바탕으로 라우팅, 서버 상태, 스타일 토큰을 세팅했고 특정 벤더에 종속되지 않는 IDP 연동 훅 포인트를 남겨두었습니다.
|
||||||
|
|
||||||
## 주요 스택
|
## 주요 스택
|
||||||
- React 19, Vite 7, TypeScript(strict)
|
- React 19, Vite 7, TypeScript(strict)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>adminfront</title>
|
<title>바론 어드민 서비스</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
64
adminfront/package-lock.json
generated
64
adminfront/package-lock.json
generated
@@ -27,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
"@playwright/test": "^1.58.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -657,6 +658,22 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.58.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
|
||||||
|
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.58.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/number": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
@@ -2804,6 +2821,53 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
|
||||||
|
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
|
||||||
|
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-avatar": "^1.1.4",
|
"@radix-ui/react-avatar": "^1.1.4",
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
"@playwright/test": "^1.58.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
59
adminfront/playwright.config.ts
Normal file
59
adminfront/playwright.config.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// import dotenv from 'dotenv';
|
||||||
|
// import path from 'path';
|
||||||
|
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: "html",
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: "http://localhost:5173",
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "firefox",
|
||||||
|
use: { ...devices["Desktop Firefox"] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
use: { ...devices["Desktop Safari"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
command: "npm run dev",
|
||||||
|
url: "http://localhost:5173",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -2,10 +2,6 @@ import { createBrowserRouter } from "react-router-dom";
|
|||||||
import AppLayout from "../components/layout/AppLayout";
|
import AppLayout from "../components/layout/AppLayout";
|
||||||
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||||
import AuthPage from "../features/auth/AuthPage";
|
import AuthPage from "../features/auth/AuthPage";
|
||||||
import ClientConsentsPage from "../features/clients/ClientConsentsPage";
|
|
||||||
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
|
||||||
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
|
||||||
import ClientsPage from "../features/clients/ClientsPage";
|
|
||||||
import DashboardPage from "../features/dashboard/DashboardPage";
|
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||||
|
|
||||||
export const router = createBrowserRouter(
|
export const router = createBrowserRouter(
|
||||||
@@ -15,10 +11,6 @@ export const router = createBrowserRouter(
|
|||||||
element: <AppLayout />,
|
element: <AppLayout />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <DashboardPage /> },
|
{ index: true, element: <DashboardPage /> },
|
||||||
{ path: "clients", element: <ClientsPage /> },
|
|
||||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
|
||||||
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
|
||||||
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
|
||||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||||
{ path: "auth", element: <AuthPage /> },
|
{ path: "auth", element: <AuthPage /> },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { NavLink, Outlet } from "react-router-dom";
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Overview", to: "/", icon: LayoutDashboard },
|
{ label: "Overview", to: "/", icon: LayoutDashboard },
|
||||||
{ label: "Clients", to: "/clients", icon: ShieldHalf },
|
|
||||||
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
||||||
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
|
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Filter, ListChecks, Search, Terminal } from "lucide-react";
|
import { Filter, ListChecks, Search, Terminal } from "lucide-react";
|
||||||
|
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||||
|
|
||||||
const auditFilters = [
|
const auditFilters = [
|
||||||
"Actor role = admin",
|
"Actor role = admin",
|
||||||
@@ -6,31 +8,28 @@ const auditFilters = [
|
|||||||
"Tenant = selected header",
|
"Tenant = selected header",
|
||||||
];
|
];
|
||||||
|
|
||||||
const auditRows = [
|
|
||||||
{
|
|
||||||
action: "client.create",
|
|
||||||
tenant: "TENANT-12",
|
|
||||||
actor: "ops.jane@baron",
|
|
||||||
result: "ok",
|
|
||||||
ts: "2026-01-26 15:21 KST",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: "client.rotate_secret",
|
|
||||||
tenant: "TENANT-12",
|
|
||||||
actor: "ops.jane@baron",
|
|
||||||
result: "ok",
|
|
||||||
ts: "2026-01-26 15:22 KST",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: "audit.export",
|
|
||||||
tenant: "TENANT-07",
|
|
||||||
actor: "auditor.lee@baron",
|
|
||||||
result: "rate_limited",
|
|
||||||
ts: "2026-01-26 15:30 KST",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function AuditLogsPage() {
|
function AuditLogsPage() {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["audit-logs"],
|
||||||
|
queryFn: () => fetchAuditLogs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs = data?.items || [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-8 text-center">Loading audit logs...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errMsg =
|
||||||
|
(error as any).response?.data?.error || (error as Error).message;
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-red-500">
|
||||||
|
Error loading logs: {errMsg}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
@@ -84,28 +83,42 @@ function AuditLogsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 divide-y divide-[var(--color-border)]">
|
<div className="mt-5 divide-y divide-[var(--color-border)]">
|
||||||
{auditRows.map((row) => (
|
{logs.length === 0 ? (
|
||||||
<div
|
<div className="py-8 text-center text-sm text-[var(--color-muted)]">
|
||||||
key={`${row.action}-${row.ts}`}
|
No audit logs found.
|
||||||
className="grid grid-cols-[1.2fr,1fr,1fr,1fr] items-center gap-2 py-3 text-sm"
|
|
||||||
>
|
|
||||||
<div className="font-semibold">{row.action}</div>
|
|
||||||
<div className="text-[var(--color-muted)]">{row.tenant}</div>
|
|
||||||
<div className="text-[var(--color-muted)]">{row.actor}</div>
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-1 text-xs ${
|
|
||||||
row.result === "ok"
|
|
||||||
? "bg-[rgba(54,211,153,0.16)] text-[var(--color-accent)]"
|
|
||||||
: "bg-[rgba(249,168,38,0.16)] text-[var(--color-accent-strong)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{row.result}
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--color-muted)]">{row.ts}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : (
|
||||||
|
logs.map((row, idx) => (
|
||||||
|
<div
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: simple list
|
||||||
|
key={`${row.event_type}-${idx}`}
|
||||||
|
className="grid grid-cols-[1.2fr,1fr,1fr,1fr] items-center gap-2 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="font-semibold">{row.event_type}</div>
|
||||||
|
<div className="text-[var(--color-muted)]">
|
||||||
|
{/* Tenant info not yet in basic schema, show generic or details snippet */}
|
||||||
|
Tenant-?
|
||||||
|
</div>
|
||||||
|
<div className="text-[var(--color-muted)] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
{row.user_id}
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-1 text-xs ${
|
||||||
|
row.status === "success" || row.status === "ok"
|
||||||
|
? "bg-[rgba(54,211,153,0.16)] text-[var(--color-accent)]"
|
||||||
|
: "bg-[rgba(249,168,38,0.16)] text-[var(--color-accent-strong)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--color-muted)] text-xs">
|
||||||
|
{new Date(row.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,388 +0,0 @@
|
|||||||
import {
|
|
||||||
Activity,
|
|
||||||
BookOpenText,
|
|
||||||
Copy,
|
|
||||||
Laptop,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
ServerCog,
|
|
||||||
ShieldHalf,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from "../../components/ui/avatar";
|
|
||||||
import { Badge } from "../../components/ui/badge";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "../../components/ui/card";
|
|
||||||
import { Input } from "../../components/ui/input";
|
|
||||||
import { Separator } from "../../components/ui/separator";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "../../components/ui/table";
|
|
||||||
import { cn } from "../../lib/utils";
|
|
||||||
|
|
||||||
const clients = [
|
|
||||||
{
|
|
||||||
name: "Customer Portal",
|
|
||||||
type: "Confidential",
|
|
||||||
clientId: "cli_481...8k2",
|
|
||||||
status: "Active",
|
|
||||||
created: "2023-10-12",
|
|
||||||
icon: <Laptop className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Mobile App (iOS)",
|
|
||||||
type: "Public",
|
|
||||||
clientId: "cli_922...4m1",
|
|
||||||
status: "Inactive",
|
|
||||||
created: "2023-11-04",
|
|
||||||
icon: <ShieldHalf className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Internal Analytics",
|
|
||||||
type: "Confidential",
|
|
||||||
clientId: "cli_773...5z9",
|
|
||||||
status: "Active",
|
|
||||||
created: "2024-01-12",
|
|
||||||
icon: <ServerCog className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const stats = [
|
|
||||||
{ label: "총 클라이언트", value: "24", delta: "+2%", tone: "up" as const },
|
|
||||||
{
|
|
||||||
label: "활성 세션",
|
|
||||||
value: "1,204",
|
|
||||||
delta: "-5%",
|
|
||||||
tone: "down" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "인증 실패 (24h)",
|
|
||||||
value: "12",
|
|
||||||
delta: "Stable",
|
|
||||||
tone: "stable" as const,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function ClientsPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<Card className="glass-panel">
|
|
||||||
<CardHeader className="pb-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
|
||||||
RP registry
|
|
||||||
</p>
|
|
||||||
<CardTitle className="text-3xl font-black tracking-tight">
|
|
||||||
Relying Parties
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
OIDC 클라이언트, 인증 방식, 리다이렉트 URI,
|
|
||||||
비밀키 재발행을 감사 로그와 함께 관리합니다.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="hidden items-center gap-2 md:flex">
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
비밀키 재발행
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="shadow-lg shadow-primary/30"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />새 클라이언트
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-3 md:grid-cols-[1.5fr,1fr]">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
className="pl-10"
|
|
||||||
placeholder="클라이언트 이름/ID로 검색..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-2 md:justify-start">
|
|
||||||
<Badge variant="muted">테넌트: 선택됨</Badge>
|
|
||||||
<Badge variant="success">관리자 세션</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
{stats.map((item) => (
|
|
||||||
<Card
|
|
||||||
key={item.label}
|
|
||||||
className="border border-border/60"
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardDescription>
|
|
||||||
{item.label}
|
|
||||||
</CardDescription>
|
|
||||||
<div className="mt-1 flex items-baseline gap-2">
|
|
||||||
<span className="text-3xl font-bold">
|
|
||||||
{item.value}
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
item.tone === "up"
|
|
||||||
? "success"
|
|
||||||
: item.tone === "down"
|
|
||||||
? "warning"
|
|
||||||
: "muted"
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"px-2",
|
|
||||||
item.tone === "down" &&
|
|
||||||
"bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-200",
|
|
||||||
item.tone === "stable" &&
|
|
||||||
"bg-muted/40 text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.delta}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="glass-panel">
|
|
||||||
<CardHeader className="pb-0">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-xl font-semibold">
|
|
||||||
클라이언트 목록
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-center gap-2 md:hidden">
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
비밀키 재발행
|
|
||||||
</Button>
|
|
||||||
<Button size="sm">
|
|
||||||
<Plus className="h-4 w-4" />새 클라이언트
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>애플리케이션</TableHead>
|
|
||||||
<TableHead>Client ID</TableHead>
|
|
||||||
<TableHead>유형</TableHead>
|
|
||||||
<TableHead>상태</TableHead>
|
|
||||||
<TableHead>생성일</TableHead>
|
|
||||||
<TableHead className="text-right">
|
|
||||||
액션
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{clients.map((client) => (
|
|
||||||
<TableRow
|
|
||||||
key={client.clientId}
|
|
||||||
className="bg-card/40"
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
||||||
{client.icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">
|
|
||||||
{client.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Tenant-scoped
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
|
|
||||||
{client.clientId}
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
|
||||||
aria-label="Copy client id"
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
client.type === "Confidential"
|
|
||||||
? "success"
|
|
||||||
: "muted"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{client.type === "Confidential"
|
|
||||||
? "기밀(Confidential)"
|
|
||||||
: "Public"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-5 w-10 items-center rounded-full p-1",
|
|
||||||
client.status === "Active"
|
|
||||||
? "bg-primary/40"
|
|
||||||
: "bg-muted/50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-3 w-3 rounded-full bg-background transition",
|
|
||||||
client.status ===
|
|
||||||
"Active"
|
|
||||||
? "translate-x-5"
|
|
||||||
: "translate-x-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium",
|
|
||||||
client.status === "Active"
|
|
||||||
? "text-emerald-400"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{client.status === "Active"
|
|
||||||
? "활성"
|
|
||||||
: "비활성"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{client.created}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to={`/clients/${client.clientId}`}
|
|
||||||
>
|
|
||||||
관리
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
||||||
aria-label="Delete client"
|
|
||||||
>
|
|
||||||
<Activity className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<div className="mt-4 flex items-center justify-between rounded-xl border border-border/60 bg-secondary/60 px-4 py-3 text-sm text-muted-foreground">
|
|
||||||
<span>Showing 1 to 3 of 24 clients</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
|
|
||||||
<Card className="glass-panel">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-lg font-bold">
|
|
||||||
Need help with OIDC configuration?
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Developer guides for Confidential/Public clients,
|
|
||||||
redirect URIs, and auth methods.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
|
|
||||||
<BookOpenText className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">
|
|
||||||
Docs & Examples
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Includes PKCE, client_secret_basic, redirect
|
|
||||||
URI validation tips.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="secondary">View guides</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="glass-panel">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-lg font-semibold">
|
|
||||||
Owner
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Tenant admin on-call</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Avatar>
|
|
||||||
<AvatarImage
|
|
||||||
src="https://gitea.hmac.kr/avatars/11ed71f61227be4a9ab6c61885371d92304a4c36a5f71036890625c55daa8c41?size=512"
|
|
||||||
alt="ops user"
|
|
||||||
/>
|
|
||||||
<AvatarFallback>AR</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">AI Admin Bot</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
admin@brsw.kr
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator className="mx-4 hidden h-10 w-px md:block" />
|
|
||||||
<div className="hidden flex-col items-end text-sm text-muted-foreground md:flex">
|
|
||||||
<span>Role: Tenant Admin</span>
|
|
||||||
<span>Scope: TENANT-12</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ClientsPage;
|
|
||||||
25
adminfront/src/lib/adminApi.ts
Normal file
25
adminfront/src/lib/adminApi.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import apiClient from "./apiClient";
|
||||||
|
|
||||||
|
export type AuditLog = {
|
||||||
|
timestamp: string;
|
||||||
|
user_id: string;
|
||||||
|
event_type: string;
|
||||||
|
status: string;
|
||||||
|
ip_address: string;
|
||||||
|
user_agent: string;
|
||||||
|
device_id?: string;
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditLogListResponse = {
|
||||||
|
items: AuditLog[];
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchAuditLogs(limit = 50, offset = 0) {
|
||||||
|
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||||
|
params: { limit, offset },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: import.meta.env.VITE_ADMIN_API_BASE ?? "/api/admin",
|
baseURL: import.meta.env.VITE_ADMIN_API_BASE ?? "/api",
|
||||||
});
|
});
|
||||||
|
|
||||||
apiClient.interceptors.request.use((config) => {
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
|||||||
8
adminfront/tests/example.spec.ts
Normal file
8
adminfront/tests/example.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("has title", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Expect a title "to contain" a substring.
|
||||||
|
await expect(page).toHaveTitle(/바론 어드민 서비스/);
|
||||||
|
});
|
||||||
@@ -4,4 +4,16 @@ import { defineConfig } from "vite";
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: process.env.API_PROXY_TARGET || "http://baron_backend:3000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
esbuild: {
|
||||||
|
drop: process.env.APP_ENV === "production" ? ["console", "debugger"] : [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/validator"
|
"baron-sso-backend/internal/validator"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -160,11 +161,45 @@ func main() {
|
|||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider)
|
||||||
adminHandler := handler.NewAdminHandler()
|
adminHandler := handler.NewAdminHandler()
|
||||||
|
devHandler := handler.NewDevHandler()
|
||||||
|
|
||||||
// 3. Initialize Fiber
|
// 3. Initialize Fiber
|
||||||
|
appEnv := getEnv("APP_ENV", "dev")
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
AppName: "Baron SSO Backend",
|
AppName: "Baron SSO Backend",
|
||||||
DisableStartupMessage: true, // Clean logs
|
DisableStartupMessage: true, // Clean logs
|
||||||
|
// Global Error Handler for Production Masking
|
||||||
|
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
||||||
|
// Default status code
|
||||||
|
code := fiber.StatusInternalServerError
|
||||||
|
|
||||||
|
// Check if it's a known fiber.Error
|
||||||
|
var e *fiber.Error
|
||||||
|
if errors.As(err, &e) {
|
||||||
|
code = e.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production or stage, mask detailed 500+ errors
|
||||||
|
if appEnv == "production" || appEnv == "stage" {
|
||||||
|
if code >= 500 {
|
||||||
|
// Log the actual error for developers
|
||||||
|
slog.Error("Internal Server Error",
|
||||||
|
"error", err.Error(),
|
||||||
|
"path", c.Path(),
|
||||||
|
"method", c.Method(),
|
||||||
|
)
|
||||||
|
// Return masked message
|
||||||
|
return c.Status(code).JSON(fiber.Map{
|
||||||
|
"error": "Internal Server Error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For development or non-500 errors, return the actual error message
|
||||||
|
return c.Status(code).JSON(fiber.Map{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
@@ -281,6 +316,7 @@ func main() {
|
|||||||
// API Group
|
// API Group
|
||||||
api := app.Group("/api/v1")
|
api := app.Group("/api/v1")
|
||||||
api.Post("/audit", auditHandler.CreateLog)
|
api.Post("/audit", auditHandler.CreateLog)
|
||||||
|
api.Get("/audit", auditHandler.ListLogs)
|
||||||
|
|
||||||
// Auth Proxy Routes
|
// Auth Proxy Routes
|
||||||
auth := api.Group("/auth")
|
auth := api.Group("/auth")
|
||||||
@@ -320,6 +356,14 @@ func main() {
|
|||||||
admin := api.Group("/admin")
|
admin := api.Group("/admin")
|
||||||
admin.Get("/check", adminHandler.CheckAuth)
|
admin.Get("/check", adminHandler.CheckAuth)
|
||||||
|
|
||||||
|
// 개발자 포털 라우트 (RP/Consent 관리)
|
||||||
|
dev := api.Group("/dev")
|
||||||
|
dev.Get("/clients", devHandler.ListClients)
|
||||||
|
dev.Get("/clients/:id", devHandler.GetClient)
|
||||||
|
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus)
|
||||||
|
dev.Get("/consents", devHandler.ListConsents)
|
||||||
|
dev.Delete("/consents", devHandler.RevokeConsents)
|
||||||
|
|
||||||
// Webhook for Descope Generic SMS Gateway
|
// Webhook for Descope Generic SMS Gateway
|
||||||
auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay)
|
auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,5 +20,6 @@ type AuditLog struct {
|
|||||||
// AuditRepository defines interface for storing logs
|
// AuditRepository defines interface for storing logs
|
||||||
type AuditRepository interface {
|
type AuditRepository interface {
|
||||||
Create(log *AuditLog) error
|
Create(log *AuditLog) error
|
||||||
// FindAll(filter Filter) ([]*AuditLog, error) // Future scope
|
FindAll(ctx context.Context, limit, offset int) ([]AuditLog, error)
|
||||||
|
Ping(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,3 +46,22 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error {
|
|||||||
"message": "Audit log saved",
|
"message": "Audit log saved",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListLogs handles GET /api/v1/audit
|
||||||
|
func (h *AuditHandler) ListLogs(c *fiber.Ctx) error {
|
||||||
|
limit := c.QueryInt("limit", 50)
|
||||||
|
offset := c.QueryInt("offset", 0)
|
||||||
|
|
||||||
|
logs, err := h.repo.FindAll(c.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"error": "Failed to retrieve audit logs",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"items": logs,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
241
backend/internal/handler/dev_handler.go
Normal file
241
backend/internal/handler/dev_handler.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DevHandler struct {
|
||||||
|
Hydra *service.HydraAdminService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDevHandler() *DevHandler {
|
||||||
|
return &DevHandler{
|
||||||
|
Hydra: service.NewHydraAdminService(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||||
|
RedirectURIs []string `json:"redirectUris"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientListResponse struct {
|
||||||
|
Items []clientSummary `json:"items"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientDetailResponse struct {
|
||||||
|
Client clientSummary `json:"client"`
|
||||||
|
Endpoints clientEndpoints `json:"endpoints"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientEndpoints struct {
|
||||||
|
Discovery string `json:"discovery"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
Authorization string `json:"authorization"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
UserInfo string `json:"userinfo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type consentSummary struct {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
ClientID string `json:"clientId"`
|
||||||
|
ClientName string `json:"clientName,omitempty"`
|
||||||
|
GrantedScopes []string `json:"grantedScopes"`
|
||||||
|
AuthenticatedAt string `json:"authenticatedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type consentListResponse struct {
|
||||||
|
Items []consentSummary `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||||
|
limit := c.QueryInt("limit", 50)
|
||||||
|
offset := c.QueryInt("offset", 0)
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrHydraNotFound) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "clients not found"})
|
||||||
|
}
|
||||||
|
errMsg := err.Error()
|
||||||
|
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||||
|
"error": "Hydra service is unavailable. Please check if Ory Hydra is running.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errMsg})
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]clientSummary, 0, len(clients))
|
||||||
|
for _, client := range clients {
|
||||||
|
items = append(items, mapClientSummary(client))
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(clientListResponse{
|
||||||
|
Items: items,
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||||
|
clientID := c.Params("id")
|
||||||
|
if clientID == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrHydraNotFound) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := mapClientSummary(*client)
|
||||||
|
return c.JSON(clientDetailResponse{
|
||||||
|
Client: summary,
|
||||||
|
Endpoints: clientEndpoints{
|
||||||
|
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
||||||
|
Issuer: h.Hydra.PublicURL,
|
||||||
|
Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth",
|
||||||
|
Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token",
|
||||||
|
UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||||
|
clientID := c.Params("id")
|
||||||
|
if clientID == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
status := strings.ToLower(strings.TrimSpace(req.Status))
|
||||||
|
if status != "active" && status != "inactive" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"})
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrHydraNotFound) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := mapClientSummary(*updated)
|
||||||
|
return c.JSON(clientDetailResponse{
|
||||||
|
Client: summary,
|
||||||
|
Endpoints: clientEndpoints{
|
||||||
|
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
||||||
|
Issuer: h.Hydra.PublicURL,
|
||||||
|
Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth",
|
||||||
|
Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token",
|
||||||
|
UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||||
|
subject := strings.TrimSpace(c.Query("subject"))
|
||||||
|
if subject == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"})
|
||||||
|
}
|
||||||
|
clientID := strings.TrimSpace(c.Query("client_id"))
|
||||||
|
|
||||||
|
sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, clientID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]consentSummary, 0, len(sessions))
|
||||||
|
for _, session := range sessions {
|
||||||
|
authAt := ""
|
||||||
|
if session.AuthenticatedAt != nil {
|
||||||
|
authAt = session.AuthenticatedAt.Format(time.RFC3339)
|
||||||
|
} else if session.RequestedAt != nil {
|
||||||
|
authAt = session.RequestedAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
items = append(items, consentSummary{
|
||||||
|
Subject: session.Subject,
|
||||||
|
ClientID: session.Client.ClientID,
|
||||||
|
ClientName: session.Client.ClientName,
|
||||||
|
GrantedScopes: session.GrantedScope,
|
||||||
|
AuthenticatedAt: authAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(consentListResponse{Items: items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
||||||
|
subject := strings.TrimSpace(c.Query("subject"))
|
||||||
|
if subject == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"})
|
||||||
|
}
|
||||||
|
clientID := strings.TrimSpace(c.Query("client_id"))
|
||||||
|
|
||||||
|
if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapClientSummary(client service.HydraClient) clientSummary {
|
||||||
|
status := "active"
|
||||||
|
if client.Metadata != nil {
|
||||||
|
if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" {
|
||||||
|
status = "inactive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientType := "confidential"
|
||||||
|
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
|
||||||
|
clientType = "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(client.ClientName)
|
||||||
|
if name == "" {
|
||||||
|
name = client.ClientID
|
||||||
|
}
|
||||||
|
|
||||||
|
scopes := strings.Fields(client.Scope)
|
||||||
|
|
||||||
|
return clientSummary{
|
||||||
|
ID: client.ClientID,
|
||||||
|
Name: name,
|
||||||
|
Type: clientType,
|
||||||
|
Status: status,
|
||||||
|
RedirectURIs: client.RedirectURIs,
|
||||||
|
Scopes: scopes,
|
||||||
|
Metadata: client.Metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,46 @@ func (r *ClickHouseRepository) Create(log *domain.AuditLog) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ClickHouseRepository) FindAll(ctx context.Context, limit, offset int) ([]domain.AuditLog, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT timestamp, user_id, event_type, status, ip_address, user_agent, device_id, details
|
||||||
|
FROM audit_logs
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`
|
||||||
|
rows, err := r.conn.Query(ctx, query, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query audit logs: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var logs []domain.AuditLog
|
||||||
|
for rows.Next() {
|
||||||
|
var log domain.AuditLog
|
||||||
|
if err := rows.Scan(
|
||||||
|
&log.Timestamp,
|
||||||
|
&log.UserID,
|
||||||
|
&log.EventType,
|
||||||
|
&log.Status,
|
||||||
|
&log.IPAddress,
|
||||||
|
&log.UserAgent,
|
||||||
|
&log.DeviceID,
|
||||||
|
&log.Details,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan audit log: %w", err)
|
||||||
|
}
|
||||||
|
logs = append(logs, log)
|
||||||
|
}
|
||||||
|
return logs, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ClickHouseRepository) Ping(ctx context.Context) error {
|
func (r *ClickHouseRepository) Ping(ctx context.Context) error {
|
||||||
if r.conn == nil {
|
if r.conn == nil {
|
||||||
return fmt.Errorf("clickhouse connection is nil")
|
return fmt.Errorf("clickhouse connection is nil")
|
||||||
|
|||||||
265
backend/internal/service/hydra_admin_service.go
Normal file
265
backend/internal/service/hydra_admin_service.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrHydraNotFound = errors.New("hydra admin: resource not found")
|
||||||
|
|
||||||
|
// HydraAdminService는 Hydra Admin API 호출을 래핑합니다.
|
||||||
|
type HydraAdminService struct {
|
||||||
|
AdminURL string
|
||||||
|
PublicURL string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type HydraClient struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||||
|
GrantTypes []string `json:"grant_types,omitempty"`
|
||||||
|
ResponseTypes []string `json:"response_types,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HydraConsentSession struct {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
GrantedScope []string `json:"granted_scope"`
|
||||||
|
GrantedAudience []string `json:"granted_audience,omitempty"`
|
||||||
|
Remember bool `json:"remember"`
|
||||||
|
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
|
||||||
|
RequestedAt *time.Time `json:"requested_at,omitempty"`
|
||||||
|
Client HydraClient `json:"client"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHydraAdminService() *HydraAdminService {
|
||||||
|
return &HydraAdminService{
|
||||||
|
AdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"),
|
||||||
|
PublicURL: getenv("HYDRA_PUBLIC_URL", "http://hydra:4444"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HydraAdminService) ListClients(ctx context.Context, limit, offset int) ([]HydraClient, error) {
|
||||||
|
endpoint, err := s.buildURL("/clients", map[string]int{
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, ErrHydraNotFound
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return nil, fmt.Errorf("hydra admin: list clients failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var clients []HydraClient
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&clients); err != nil {
|
||||||
|
return nil, fmt.Errorf("hydra admin: decode clients failed: %w", err)
|
||||||
|
}
|
||||||
|
return clients, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*HydraClient, error) {
|
||||||
|
endpoint := fmt.Sprintf("%s/clients/%s", strings.TrimRight(s.AdminURL, "/"), url.PathEscape(clientID))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, ErrHydraNotFound
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return nil, fmt.Errorf("hydra admin: get client failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var client HydraClient
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&client); err != nil {
|
||||||
|
return nil, fmt.Errorf("hydra admin: decode client failed: %w", err)
|
||||||
|
}
|
||||||
|
return &client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, status string) (*HydraClient, error) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
endpoint := fmt.Sprintf("%s/clients/%s", strings.TrimRight(s.AdminURL, "/"), url.PathEscape(clientID))
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/merge-patch+json")
|
||||||
|
|
||||||
|
resp, err := s.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, ErrHydraNotFound
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return nil, fmt.Errorf("hydra admin: patch client failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated HydraClient
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil {
|
||||||
|
return nil, fmt.Errorf("hydra admin: decode patched client failed: %w", err)
|
||||||
|
}
|
||||||
|
return &updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, clientID string) ([]HydraConsentSession, error) {
|
||||||
|
params := map[string]string{
|
||||||
|
"subject": subject,
|
||||||
|
}
|
||||||
|
if clientID != "" {
|
||||||
|
params["client"] = clientID
|
||||||
|
}
|
||||||
|
endpoint, err := s.buildURLWithParams("/oauth2/auth/sessions/consent", params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessions []HydraConsentSession
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil {
|
||||||
|
return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w", err)
|
||||||
|
}
|
||||||
|
return sessions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject, clientID string) error {
|
||||||
|
params := map[string]string{
|
||||||
|
"subject": subject,
|
||||||
|
}
|
||||||
|
if clientID != "" {
|
||||||
|
params["client"] = clientID
|
||||||
|
}
|
||||||
|
endpoint, err := s.buildURLWithParams("/oauth2/auth/sessions/consent", params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return fmt.Errorf("hydra admin: revoke consent failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HydraAdminService) httpClient() *http.Client {
|
||||||
|
if s.HTTPClient != nil {
|
||||||
|
return s.HTTPClient
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
TLSHandshakeTimeout: 5 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HydraAdminService) buildURL(path string, ints map[string]int) (string, error) {
|
||||||
|
base := strings.TrimRight(s.AdminURL, "/")
|
||||||
|
u, err := url.Parse(base + path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
for key, value := range ints {
|
||||||
|
if value > 0 {
|
||||||
|
q.Set(key, strconv.Itoa(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HydraAdminService) buildURLWithParams(path string, params map[string]string) (string, error) {
|
||||||
|
base := strings.TrimRight(s.AdminURL, "/")
|
||||||
|
u, err := url.Parse(base + path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
for key, value := range params {
|
||||||
|
if value != "" {
|
||||||
|
q.Set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
container_name: ory_kratos
|
container_name: ory_kratos
|
||||||
ports:
|
ports:
|
||||||
- "${KRATOS_PUBLIC_PORT:-4433}:4433"
|
- "${KRATOS_PUBLIC_PORT:-4433}:4433"
|
||||||
- "${KRATOS_ADMIN_PORT:-4434}:4434"
|
- "${KRATOS_ADMINFRONT_PORT:-4434}:4434"
|
||||||
environment:
|
environment:
|
||||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB}?sslmode=disable&max_conns=20
|
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB}?sslmode=disable&max_conns=20
|
||||||
- COOKIE_SECRET=${COOKIE_SECRET:-localcookie123}
|
- COOKIE_SECRET=${COOKIE_SECRET:-localcookie123}
|
||||||
@@ -50,6 +50,22 @@ services:
|
|||||||
- ory-net
|
- ory-net
|
||||||
- kratosnet
|
- kratosnet
|
||||||
|
|
||||||
|
kratos-mcp-server:
|
||||||
|
build:
|
||||||
|
context: ./mcp/kratos-mcp
|
||||||
|
container_name: mcp_ory_kratos
|
||||||
|
profiles:
|
||||||
|
- mcp
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
init: true
|
||||||
|
environment:
|
||||||
|
- KRATOS_ADMIN_URL=http://kratos:4434
|
||||||
|
depends_on:
|
||||||
|
- kratos
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
kratos-ui:
|
kratos-ui:
|
||||||
image: oryd/kratos-selfservice-ui-node:${KRATOS_UI_NODE_VERSION:-v25.4.0}
|
image: oryd/kratos-selfservice-ui-node:${KRATOS_UI_NODE_VERSION:-v25.4.0}
|
||||||
container_name: ory_kratos_ui
|
container_name: ory_kratos_ui
|
||||||
@@ -83,7 +99,7 @@ services:
|
|||||||
container_name: ory_hydra
|
container_name: ory_hydra
|
||||||
ports:
|
ports:
|
||||||
- "${HYDRA_PUBLIC_PORT:-4441}:4444"
|
- "${HYDRA_PUBLIC_PORT:-4441}:4444"
|
||||||
- "${HYDRA_ADMIN_PORT:-4445}:4445"
|
- "${HYDRA_ADMINFRONT_PORT:-4445}:4445"
|
||||||
environment:
|
environment:
|
||||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20
|
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20
|
||||||
- URLS_SELF_ISSUER=${BACKEND_URL:-http://127.0.0.1:3000}
|
- URLS_SELF_ISSUER=${BACKEND_URL:-http://127.0.0.1:3000}
|
||||||
@@ -100,6 +116,23 @@ services:
|
|||||||
- ory-net
|
- ory-net
|
||||||
- hydranet
|
- hydranet
|
||||||
|
|
||||||
|
hydra-mcp-server:
|
||||||
|
build:
|
||||||
|
context: ./mcp/hydra-mcp
|
||||||
|
container_name: mcp_ory_hydra
|
||||||
|
profiles:
|
||||||
|
- mcp
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
init: true
|
||||||
|
environment:
|
||||||
|
- HYDRA_PUBLIC_URL=http://hydra:4444
|
||||||
|
- HYDRA_ADMIN_URL=http://hydra:4445
|
||||||
|
depends_on:
|
||||||
|
- hydra
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
# --- Keto ---
|
# --- Keto ---
|
||||||
keto-migrate:
|
keto-migrate:
|
||||||
image: oryd/keto:${KETO_VERSION:-v25.4.0}
|
image: oryd/keto:${KETO_VERSION:-v25.4.0}
|
||||||
@@ -171,14 +204,24 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- HYDRA_ADMIN_URL=http://hydra:4445
|
- HYDRA_ADMIN_URL=http://hydra:4445
|
||||||
command: >
|
command: >
|
||||||
clients create
|
/bin/sh -c "
|
||||||
|
hydra clients create
|
||||||
--endpoint http://hydra:4445
|
--endpoint http://hydra:4445
|
||||||
--id adminfront
|
--id adminfront
|
||||||
--secret admin-secret
|
--secret admin-secret
|
||||||
--grant-types authorization_code,refresh_token
|
--grant-types authorization_code,refresh_token
|
||||||
--response-types code
|
--response-types code
|
||||||
--scope openid,offline_access,profile,email
|
--scope openid,offline_access,profile,email
|
||||||
--callbacks http://localhost:5000/callback
|
--callbacks http://localhost:5000/callback;
|
||||||
|
hydra clients create
|
||||||
|
--endpoint http://hydra:4445
|
||||||
|
--id devfront
|
||||||
|
--grant-types authorization_code,refresh_token
|
||||||
|
--response-types code
|
||||||
|
--scope openid,offline_access,profile,email
|
||||||
|
--token-endpoint-auth-method none
|
||||||
|
--callbacks http://localhost:5174/callback;
|
||||||
|
"
|
||||||
depends_on:
|
depends_on:
|
||||||
ory_stack_check:
|
ory_stack_check:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|||||||
24
devfront/.gitignore
vendored
Normal file
24
devfront/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
25
devfront/Dockerfile
Normal file
25
devfront/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM node:lts
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 패키지 정보 복사 및 의존성 설치
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
||||||
|
RUN npm install -g serve
|
||||||
|
|
||||||
|
# 소스 코드 복사
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Vite 기본 포트
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
||||||
|
CMD sh -c "if [ \"$APP_ENV\" = 'production' ]; then \
|
||||||
|
echo 'Running in production mode...'; \
|
||||||
|
npm run build && serve -s dist -l 5173; \
|
||||||
|
else \
|
||||||
|
echo 'Running in development mode...'; \
|
||||||
|
npm run dev -- --host 0.0.0.0; \
|
||||||
|
fi"
|
||||||
29
devfront/README.md
Normal file
29
devfront/README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Dev Front (React 19 + Vite)
|
||||||
|
|
||||||
|
RP 등록 현황과 Consent 관리를 담당하는 개발자 포털용 React/Vite 기반 SPA입니다. adminfront와 동일한 스택으로 구성하고, Ory Hydra Admin API 연동을 위한 훅 포인트를 남겨두었습니다.
|
||||||
|
|
||||||
|
## 주요 스택
|
||||||
|
- React 19, Vite 7, TypeScript(strict)
|
||||||
|
- React Router v6 (data router)
|
||||||
|
- TanStack Query v5
|
||||||
|
- Tailwind CSS v3 + shadcn/ui 컴포넌트(seed: Button/Card/Badge/Input/Table/Avatar)
|
||||||
|
- Axios 클라이언트 스텁: Bearer + `X-Tenant-ID` 헤더 주입 준비
|
||||||
|
- React Hook Form + Zod (추가 예정)
|
||||||
|
- Biome (formatter/linter)
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
- `src/app`: 라우터, QueryClient 등 전역 설정
|
||||||
|
- `src/components/layout`: App 레이아웃/네비게이션
|
||||||
|
- `src/features`: dashboard, clients, audit, auth 등 화면 스캐폴딩
|
||||||
|
- `src/lib/apiClient.ts`: Axios 인스턴스(토큰/테넌트 헤더 주입 스텁)
|
||||||
|
|
||||||
|
## 다음 작업 가이드
|
||||||
|
- Devfront 전용 인증/권한 가드 추가 (RP 관리 권한 검증)
|
||||||
|
- 테넌트 선택 UI 추가 → `X-Tenant-ID` 헤더에 반영
|
||||||
|
- Hydra Admin API 기반 RP/Consent 실데이터 연동
|
||||||
23
devfront/biome.json
Normal file
23
devfront/biome.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"formatter": {
|
||||||
|
"indentStyle": "space"
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"useEnumInitializers": "off"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"noLabelWithoutControl": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignore": ["dist", "node_modules", "tsconfig*.json"]
|
||||||
|
}
|
||||||
|
}
|
||||||
13
devfront/index.html
Normal file
13
devfront/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>바론 개발자 서비스</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3622
devfront/package-lock.json
generated
Normal file
3622
devfront/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
devfront/package.json
Normal file
49
devfront/package.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "devfront",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:ui": "playwright test --ui"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-avatar": "^1.1.4",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.1.2",
|
||||||
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
|
"@tanstack/react-query": "^5.66.8",
|
||||||
|
"@tanstack/react-query-devtools": "^5.66.8",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.71.1",
|
||||||
|
"react-router-dom": "^6.28.2",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
"@playwright/test": "^1.58.0",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
59
devfront/playwright.config.ts
Normal file
59
devfront/playwright.config.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// import dotenv from 'dotenv';
|
||||||
|
// import path from 'path';
|
||||||
|
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: "html",
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: "http://localhost:5174",
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "firefox",
|
||||||
|
use: { ...devices["Desktop Firefox"] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
use: { ...devices["Desktop Safari"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
command: "npm run dev -- --port 5174",
|
||||||
|
url: "http://localhost:5174",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
6
devfront/postcss.config.js
Normal file
6
devfront/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
1
devfront/public/vite.svg
Normal file
1
devfront/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
11
devfront/src/app/queryClient.ts
Normal file
11
devfront/src/app/queryClient.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
28
devfront/src/app/routes.tsx
Normal file
28
devfront/src/app/routes.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Navigate, createBrowserRouter } from "react-router-dom";
|
||||||
|
import AppLayout from "../components/layout/AppLayout";
|
||||||
|
import ClientConsentsPage from "../features/clients/ClientConsentsPage";
|
||||||
|
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
||||||
|
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
||||||
|
import ClientsPage from "../features/clients/ClientsPage";
|
||||||
|
|
||||||
|
export const router = createBrowserRouter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <AppLayout />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Navigate to="/clients" replace /> },
|
||||||
|
{ path: "clients", element: <ClientsPage /> },
|
||||||
|
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||||
|
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
||||||
|
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
|
||||||
|
{
|
||||||
|
future: {
|
||||||
|
v7_startTransition: true,
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof createBrowserRouter>[1],
|
||||||
|
);
|
||||||
1
devfront/src/assets/react.svg
Normal file
1
devfront/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
112
devfront/src/components/layout/AppLayout.tsx
Normal file
112
devfront/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { BadgeCheck, Moon, ShieldHalf, Sun } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
const navItems = [{ label: "Clients", to: "/clients", icon: ShieldHalf }];
|
||||||
|
|
||||||
|
function AppLayout() {
|
||||||
|
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||||
|
const stored = window.localStorage.getItem("admin_theme");
|
||||||
|
return stored === "dark" ? "dark" : "light";
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.remove("light", "dark");
|
||||||
|
if (theme === "light") {
|
||||||
|
root.classList.add("light");
|
||||||
|
} else {
|
||||||
|
root.classList.add("dark");
|
||||||
|
}
|
||||||
|
window.localStorage.setItem("admin_theme", theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||||
|
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||||
|
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||||
|
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||||
|
<ShieldHalf size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Baron 통합로그인
|
||||||
|
</p>
|
||||||
|
<h1 className="text-lg font-semibold">Developer Console</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
|
||||||
|
<BadgeCheck size={14} />
|
||||||
|
Scoped to /dev
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||||
|
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
||||||
|
<span className="rounded-full border border-border px-3 py-1">
|
||||||
|
Env: dev
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{navItems.map(({ label, to, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
[
|
||||||
|
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||||
|
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||||
|
].join(" ")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
|
||||||
|
<p>개발자 전용 콘솔입니다.</p>
|
||||||
|
<p>클라이언트 애플리케이션 등록 및 관리를 수행할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
|
Dev Plane
|
||||||
|
</p>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
Manage your applications
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||||
|
aria-label="테마 전환"
|
||||||
|
>
|
||||||
|
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||||
|
{theme === "light" ? "Light" : "Dark"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
47
devfront/src/components/ui/avatar.tsx
Normal file
47
devfront/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-semibold text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
38
devfront/src/components/ui/badge.tsx
Normal file
38
devfront/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { type VariantProps, cva } from "class-variance-authority";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
muted: "border-border bg-secondary/60 text-muted-foreground",
|
||||||
|
success:
|
||||||
|
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||||
|
warning:
|
||||||
|
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
56
devfront/src/components/ui/button.tsx
Normal file
56
devfront/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { type VariantProps, cva } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
muted: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-6 text-base",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
72
devfront/src/components/ui/card.tsx
Normal file
72
devfront/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
className={cn("text-lg font-semibold leading-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return (
|
||||||
|
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
};
|
||||||
24
devfront/src/components/ui/input.tsx
Normal file
24
devfront/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
19
devfront/src/components/ui/label.tsx
Normal file
19
devfront/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
HTMLLabelElement,
|
||||||
|
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = "Label";
|
||||||
|
|
||||||
|
export { Label };
|
||||||
44
devfront/src/components/ui/scroll-area.tsx
Normal file
44
devfront/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" && "h-2.5 border-t border-t-transparent",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
));
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
16
devfront/src/components/ui/separator.tsx
Normal file
16
devfront/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("shrink-0 bg-border", "h-px w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Separator.displayName = "Separator";
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
26
devfront/src/components/ui/switch.tsx
Normal file
26
devfront/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
113
devfront/src/components/ui/table.tsx
Normal file
113
devfront/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn("bg-muted/50 font-medium text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableFooter.displayName = "TableFooter";
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-muted-foreground align-middle",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-6 align-middle text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCaption.displayName = "TableCaption";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
23
devfront/src/components/ui/textarea.tsx
Normal file
23
devfront/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
143
devfront/src/features/audit/AuditLogsPage.tsx
Normal file
143
devfront/src/features/audit/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { Filter, ListChecks, Search, Terminal } from "lucide-react";
|
||||||
|
|
||||||
|
const auditFilters = [
|
||||||
|
"Actor role = admin",
|
||||||
|
"Action = client.rotate_secret",
|
||||||
|
"Tenant = selected header",
|
||||||
|
];
|
||||||
|
|
||||||
|
const auditRows = [
|
||||||
|
{
|
||||||
|
action: "client.create",
|
||||||
|
tenant: "TENANT-12",
|
||||||
|
actor: "ops.jane@baron",
|
||||||
|
result: "ok",
|
||||||
|
ts: "2026-01-26 15:21 KST",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "client.rotate_secret",
|
||||||
|
tenant: "TENANT-12",
|
||||||
|
actor: "ops.jane@baron",
|
||||||
|
result: "ok",
|
||||||
|
ts: "2026-01-26 15:22 KST",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "audit.export",
|
||||||
|
tenant: "TENANT-07",
|
||||||
|
actor: "auditor.lee@baron",
|
||||||
|
result: "rate_limited",
|
||||||
|
ts: "2026-01-26 15:30 KST",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function AuditLogsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
Audit stream
|
||||||
|
</p>
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Observe admin actions per tenant
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
ClickHouse-backed feed. Filter by tenant, actor, action, and
|
||||||
|
rate-limit status. Enforce admin-only access under /admin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]"
|
||||||
|
>
|
||||||
|
<Filter size={14} />
|
||||||
|
Saved filters
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
|
||||||
|
>
|
||||||
|
<ListChecks size={14} />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-[1.1fr,0.9fr]">
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-[var(--color-muted)]">
|
||||||
|
<Search size={14} />
|
||||||
|
<span className="text-sm">
|
||||||
|
Try: tenant:TENANT-12 action:client.*
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{auditFilters.map((filter) => (
|
||||||
|
<span
|
||||||
|
key={filter}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||||
|
>
|
||||||
|
<Terminal size={12} />
|
||||||
|
{filter}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 divide-y divide-[var(--color-border)]">
|
||||||
|
{auditRows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={`${row.action}-${row.ts}`}
|
||||||
|
className="grid grid-cols-[1.2fr,1fr,1fr,1fr] items-center gap-2 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="font-semibold">{row.action}</div>
|
||||||
|
<div className="text-[var(--color-muted)]">{row.tenant}</div>
|
||||||
|
<div className="text-[var(--color-muted)]">{row.actor}</div>
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-1 text-xs ${
|
||||||
|
row.result === "ok"
|
||||||
|
? "bg-[rgba(54,211,153,0.16)] text-[var(--color-accent)]"
|
||||||
|
: "bg-[rgba(249,168,38,0.16)] text-[var(--color-accent-strong)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.result}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--color-muted)]">{row.ts}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||||
|
Guard rails
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-1 text-lg font-semibold">Tenant admin only</h3>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
Enforce Tenant Admin middleware and admin session TTL before
|
||||||
|
surfacing any audit feed. Super Admin role can bypass tenant
|
||||||
|
filter when needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||||
|
Export rules
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-1 text-lg font-semibold">
|
||||||
|
Rate-limit sensitive exports
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
Keep export endpoints behind admin-only routes with ClickHouse
|
||||||
|
query limits. Log download attempts with IP, role, and tenant
|
||||||
|
scope.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuditLogsPage;
|
||||||
111
devfront/src/features/auth/AuthPage.tsx
Normal file
111
devfront/src/features/auth/AuthPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
const flows = [
|
||||||
|
{
|
||||||
|
title: "Admin login",
|
||||||
|
description:
|
||||||
|
"Enforce short TTL and step-up MFA. Keep admin session separate from app session.",
|
||||||
|
pill: "15m TTL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Tenant pick",
|
||||||
|
description:
|
||||||
|
"Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.",
|
||||||
|
pill: "Header-ready",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Device approval",
|
||||||
|
description:
|
||||||
|
"If app session exists and user opts in, use push/deeplink approval as MFA replacement.",
|
||||||
|
pill: "App session",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function AuthPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
Admin auth
|
||||||
|
</p>
|
||||||
|
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
Build the admin-only login flow first, keeping app login separate.
|
||||||
|
Respect the “fallback only when user chooses” rule for SMS/email
|
||||||
|
vs app approval.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]">
|
||||||
|
IDP session placeholder
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
|
||||||
|
>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
Connect auth layer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
|
{flows.map((flow) => (
|
||||||
|
<div
|
||||||
|
key={flow.title}
|
||||||
|
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between text-xs uppercase tracking-[0.16em] text-[var(--color-muted)]">
|
||||||
|
<span>{flow.pill}</span>
|
||||||
|
<Fingerprint size={14} />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-3 text-lg font-semibold">{flow.title}</h3>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
{flow.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 md:grid-cols-[1fr,0.9fr]">
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
|
<Smartphone size={16} />
|
||||||
|
<span className="text-xs uppercase tracking-[0.18em]">
|
||||||
|
App-based approvals
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-2 text-xl font-semibold">
|
||||||
|
App session as MFA replacement
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
If the admin keeps the mobile app signed in and opts in, use
|
||||||
|
push/deeplink approval instead of OTP. Otherwise fall back to
|
||||||
|
SMS/email based on user choice.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
<span className="text-xs uppercase tracking-[0.18em]">
|
||||||
|
TTL discipline
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-2 text-xl font-semibold">
|
||||||
|
Keep admin sessions short
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
|
||||||
|
with step-up MFA when critical actions (rotate secret, export logs)
|
||||||
|
happen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthPage;
|
||||||
366
devfront/src/features/clients/ClientsPage.tsx
Normal file
366
devfront/src/features/clients/ClientsPage.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
BookOpenText,
|
||||||
|
Copy,
|
||||||
|
Laptop,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
ServerCog,
|
||||||
|
ShieldHalf,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "../../components/ui/avatar";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Separator } from "../../components/ui/separator";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
import { fetchClients } from "../../lib/devApi";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
function ClientsPage() {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["clients"],
|
||||||
|
queryFn: fetchClients,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clients = data?.items || [];
|
||||||
|
const totalClients = clients.length;
|
||||||
|
// TODO: Add real stats for active sessions and auth failures
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: "총 클라이언트",
|
||||||
|
value: totalClients.toString(),
|
||||||
|
delta: "Realtime",
|
||||||
|
tone: "up" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "활성 세션",
|
||||||
|
value: "-",
|
||||||
|
delta: "Not impl",
|
||||||
|
tone: "stable" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "인증 실패 (24h)",
|
||||||
|
value: "0",
|
||||||
|
delta: "Stable",
|
||||||
|
tone: "stable" as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-8 text-center">Loading clients...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errMsg =
|
||||||
|
(error as any).response?.data?.error || (error as Error).message;
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-red-500">
|
||||||
|
Error loading clients: {errMsg}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
RP registry
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-3xl font-black tracking-tight">
|
||||||
|
Relying Parties
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사
|
||||||
|
로그와 함께 관리합니다.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="hidden items-center gap-2 md:flex">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
비밀키 재발행
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className="shadow-lg shadow-primary/30">
|
||||||
|
<Plus className="h-4 w-4" />새 클라이언트
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-[1.5fr,1fr]">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-10"
|
||||||
|
placeholder="클라이언트 이름/ID로 검색..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2 md:justify-start">
|
||||||
|
<Badge variant="muted">테넌트: 선택됨</Badge>
|
||||||
|
<Badge variant="success">관리자 세션</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{stats.map((item) => (
|
||||||
|
<Card key={item.label} className="border border-border/60">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>{item.label}</CardDescription>
|
||||||
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
|
<span className="text-3xl font-bold">{item.value}</span>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
item.tone === "up"
|
||||||
|
? "success"
|
||||||
|
: item.tone === "down"
|
||||||
|
? "warning"
|
||||||
|
: "muted"
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"px-2",
|
||||||
|
item.tone === "down" &&
|
||||||
|
"bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-200",
|
||||||
|
item.tone === "stable" && "bg-muted/40 text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.delta}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-xl font-semibold">
|
||||||
|
클라이언트 목록
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
비밀키 재발행
|
||||||
|
</Button>
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="h-4 w-4" />새 클라이언트
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>애플리케이션</TableHead>
|
||||||
|
<TableHead>Client ID</TableHead>
|
||||||
|
<TableHead>유형</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead>생성일</TableHead>
|
||||||
|
<TableHead className="text-right">액션</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{clients.map((client) => (
|
||||||
|
<TableRow key={client.id} className="bg-card/40">
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
{client.type === "confidential" ? (
|
||||||
|
<ServerCog className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ShieldHalf className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{client.name || "Untitled"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Tenant-scoped
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
|
||||||
|
{client.id}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label="Copy client id"
|
||||||
|
onClick={() =>
|
||||||
|
navigator.clipboard.writeText(client.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
client.type === "confidential" ? "success" : "muted"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{client.type === "confidential"
|
||||||
|
? "기밀(Confidential)"
|
||||||
|
: "Public"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-5 w-10 items-center rounded-full p-1",
|
||||||
|
client.status === "active"
|
||||||
|
? "bg-primary/40"
|
||||||
|
: "bg-muted/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-3 w-3 rounded-full bg-background transition",
|
||||||
|
client.status === "active"
|
||||||
|
? "translate-x-5"
|
||||||
|
: "translate-x-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
client.status === "active"
|
||||||
|
? "text-emerald-400"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{client.status === "active" ? "활성" : "비활성"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{client.createdAt
|
||||||
|
? new Date(client.createdAt).toLocaleDateString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to={`/clients/${client.id}`}>관리</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
aria-label="Delete client"
|
||||||
|
>
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<div className="mt-4 flex items-center justify-between rounded-xl border border-border/60 bg-secondary/60 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Showing {clients.length} of {totalClients} clients
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-lg font-bold">
|
||||||
|
Need help with OIDC configuration?
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Developer guides for Confidential/Public clients, redirect URIs,
|
||||||
|
and auth methods.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
|
||||||
|
<BookOpenText className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Docs & Examples</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Includes PKCE, client_secret_basic, redirect URI validation
|
||||||
|
tips.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary">View guides</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-lg font-semibold">Owner</CardTitle>
|
||||||
|
<CardDescription>Tenant admin on-call</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage
|
||||||
|
src="https://gitea.hmac.kr/avatars/11ed71f61227be4a9ab6c61885371d92304a4c36a5f71036890625c55daa8c41?size=512"
|
||||||
|
alt="ops user"
|
||||||
|
/>
|
||||||
|
<AvatarFallback>AR</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">AI Admin Bot</p>
|
||||||
|
<p className="text-xs text-muted-foreground">admin@brsw.kr</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="mx-4 hidden h-10 w-px md:block" />
|
||||||
|
<div className="hidden flex-col items-end text-sm text-muted-foreground md:flex">
|
||||||
|
<span>Role: Tenant Admin</span>
|
||||||
|
<span>Scope: TENANT-12</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClientsPage;
|
||||||
215
devfront/src/features/dashboard/DashboardPage.tsx
Normal file
215
devfront/src/features/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowRight,
|
||||||
|
BarChart3,
|
||||||
|
CheckCircle2,
|
||||||
|
Database,
|
||||||
|
KeyRound,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const guardHighlights = [
|
||||||
|
{
|
||||||
|
title: "RP 정책 통제",
|
||||||
|
body: "Relying Party 상태를 활성/비활성으로 관리하고 정책 변경을 기록합니다.",
|
||||||
|
metric: "Policy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Consent 흐름",
|
||||||
|
body: "사용자 Consent를 조회하고 필요 시 회수해 리스크를 제어합니다.",
|
||||||
|
metric: "Consent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Hydra Admin",
|
||||||
|
body: "Hydra Admin API를 통해 RP 등록 현황을 동기화합니다.",
|
||||||
|
metric: "Hydra",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const stackReadiness = [
|
||||||
|
"React 19 + Vite 7, strict TS, Router v6 data router.",
|
||||||
|
"TanStack Query 5로 RP/Consent 데이터를 캐시합니다.",
|
||||||
|
"Axios 클라이언트에서 Bearer + 테넌트 헤더를 주입합니다.",
|
||||||
|
"Tailwind + shadcn/ui로 devfront 톤을 맞춥니다.",
|
||||||
|
"Hydra Admin API 연동을 위한 프록시 엔드포인트 준비.",
|
||||||
|
];
|
||||||
|
|
||||||
|
const nextSteps = [
|
||||||
|
"RP 등록/수정/삭제 워크플로우 추가",
|
||||||
|
"Consent 검색 필터 고도화 및 CSV 내보내기",
|
||||||
|
"권한 가드 및 감사 로그 연동",
|
||||||
|
];
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<section className="relative overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-7 shadow-[var(--shadow-card)]">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_24%_20%,rgba(54,211,153,0.14),transparent_32%)]" />
|
||||||
|
<div className="relative flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-3 max-w-2xl">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
<Sparkles size={14} />
|
||||||
|
devfront ready
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold leading-tight">
|
||||||
|
RP 등록 현황과 Consent 상태를
|
||||||
|
<span className="text-[var(--color-accent)]"> 하나의 화면</span>
|
||||||
|
에서 관리합니다.
|
||||||
|
</h2>
|
||||||
|
<p className="text-[var(--color-muted)]">
|
||||||
|
Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지
|
||||||
|
devfront에서 처리하도록 준비합니다.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3 text-sm">
|
||||||
|
<span className="rounded-full bg-[rgba(54,211,153,0.16)] px-3 py-2 text-[var(--color-accent)]">
|
||||||
|
RP registry synced
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
|
||||||
|
Consent guard ready
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-[rgba(249,168,38,0.16)] px-3 py-2 font-semibold text-[var(--color-accent-strong)]">
|
||||||
|
Policy toggle enabled
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
RP 정책은 dev scope에서만 적용
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||||
|
<KeyRound size={16} />
|
||||||
|
Consent 회수는 감사 로그와 연계
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||||
|
<Database size={16} />
|
||||||
|
Hydra Admin 상태 체크 준비
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
|
{guardHighlights.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.title}
|
||||||
|
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5 transition hover:-translate-y-1 hover:shadow-[0_16px_48px_rgba(7,15,26,0.4)]"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_25%_25%,rgba(54,211,153,0.12),transparent_45%)]" />
|
||||||
|
<div className="relative flex items-center justify-between gap-2">
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
{item.metric}
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
|
||||||
|
active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-3 space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">{item.title}</h3>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">{item.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 md:grid-cols-[1.2fr,0.8fr]">
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
Stack readiness
|
||||||
|
</p>
|
||||||
|
<h3 className="text-xl font-semibold">Devfront baseline</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
||||||
|
>
|
||||||
|
Setup notes
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
|
{stackReadiness.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<CheckCircle2
|
||||||
|
size={16}
|
||||||
|
className="text-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
<p className="text-sm">{item}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
Next actions
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 text-xl font-semibold">Ship the RP controls</h3>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{nextSteps.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="flex gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="grid h-8 w-8 place-items-center rounded-full bg-[rgba(249,168,38,0.12)] text-sm font-semibold text-[var(--color-accent-strong)]">
|
||||||
|
{idx + 1}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--color-text)]">{item}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
Ops board
|
||||||
|
</p>
|
||||||
|
<h3 className="text-xl font-semibold">현재 관측</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||||
|
Consent grants
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||||
|
RP status
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
|
<BarChart3 size={16} />
|
||||||
|
RP 요청 추이
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-2xl font-semibold">준비 중</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
|
<Activity size={16} />
|
||||||
|
Consent 회수 건수
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-2xl font-semibold">준비 중</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
|
<Database size={16} />
|
||||||
|
Hydra 상태
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-2xl font-semibold">정상</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
83
devfront/src/index.css
Normal file
83
devfront/src/index.css
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 210 25% 6%;
|
||||||
|
--foreground: 210 35% 96%;
|
||||||
|
--card: 215 32% 9%;
|
||||||
|
--card-foreground: 210 35% 96%;
|
||||||
|
--popover: 215 32% 9%;
|
||||||
|
--popover-foreground: 210 35% 96%;
|
||||||
|
--primary: 209 79% 52%;
|
||||||
|
--primary-foreground: 210 35% 96%;
|
||||||
|
--secondary: 215 25% 16%;
|
||||||
|
--secondary-foreground: 210 35% 96%;
|
||||||
|
--muted: 215 15% 65%;
|
||||||
|
--muted-foreground: 215 15% 65%;
|
||||||
|
--accent: 42 95% 57%;
|
||||||
|
--accent-foreground: 215 25% 10%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 210 35% 96%;
|
||||||
|
--border: 215 25% 24%;
|
||||||
|
--input: 215 25% 24%;
|
||||||
|
--ring: 209 79% 52%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
--background: 0 0% 98%;
|
||||||
|
--foreground: 223 25% 12%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 223 25% 12%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 223 25% 12%;
|
||||||
|
--primary: 209 79% 52%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 220 17% 94%;
|
||||||
|
--secondary-foreground: 223 25% 20%;
|
||||||
|
--muted: 223 15% 45%;
|
||||||
|
--muted-foreground: 223 15% 45%;
|
||||||
|
--accent: 40 96% 62%;
|
||||||
|
--accent-foreground: 223 25% 12%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 220 17% 90%;
|
||||||
|
--input: 220 17% 90%;
|
||||||
|
--ring: 209 79% 52%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||||
|
background-image: radial-gradient(
|
||||||
|
circle at 10% 18%,
|
||||||
|
rgba(54, 211, 153, 0.16),
|
||||||
|
transparent 28%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
circle at 78% 4%,
|
||||||
|
rgba(249, 168, 38, 0.14),
|
||||||
|
transparent 24%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
circle at 50% 90%,
|
||||||
|
rgba(54, 211, 153, 0.08),
|
||||||
|
transparent 30%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
@apply text-inherit no-underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.glass-panel {
|
||||||
|
@apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
devfront/src/lib/apiClient.ts
Normal file
31
devfront/src/lib/apiClient.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_ADMIN_API_BASE ?? "/api/admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
// TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다.
|
||||||
|
const sessionToken = window.localStorage.getItem("admin_session");
|
||||||
|
if (sessionToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${sessionToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
|
||||||
|
const tenantId = window.localStorage.getItem("admin_tenant");
|
||||||
|
if (tenantId) {
|
||||||
|
config.headers["X-Tenant-ID"] = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
// TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다.
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
89
devfront/src/lib/devApi.ts
Normal file
89
devfront/src/lib/devApi.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import apiClient from "./apiClient";
|
||||||
|
|
||||||
|
export type ClientStatus = "active" | "inactive";
|
||||||
|
export type ClientType = "confidential" | "public";
|
||||||
|
|
||||||
|
export type ClientSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: ClientType;
|
||||||
|
status: ClientStatus;
|
||||||
|
createdAt?: string;
|
||||||
|
redirectUris: string[];
|
||||||
|
scopes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientListResponse = {
|
||||||
|
items: ClientSummary[];
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientEndpoints = {
|
||||||
|
discovery: string;
|
||||||
|
issuer: string;
|
||||||
|
authorization: string;
|
||||||
|
token: string;
|
||||||
|
userinfo: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientDetailResponse = {
|
||||||
|
client: ClientSummary & {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
endpoints: ClientEndpoints;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConsentSummary = {
|
||||||
|
subject: string;
|
||||||
|
clientId: string;
|
||||||
|
clientName?: string;
|
||||||
|
grantedScopes: string[];
|
||||||
|
authenticatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConsentListResponse = {
|
||||||
|
items: ConsentSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchClients() {
|
||||||
|
const { data } = await apiClient.get<ClientListResponse>("/clients");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchClient(clientId: string) {
|
||||||
|
const { data } = await apiClient.get<ClientDetailResponse>(
|
||||||
|
`/clients/${clientId}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateClientStatus(
|
||||||
|
clientId: string,
|
||||||
|
status: ClientStatus,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.patch<ClientDetailResponse>(
|
||||||
|
`/clients/${clientId}/status`,
|
||||||
|
{ status },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConsents(subject: string, clientId?: string) {
|
||||||
|
const params: Record<string, string> = { subject };
|
||||||
|
if (clientId) {
|
||||||
|
params.client_id = clientId;
|
||||||
|
}
|
||||||
|
const { data } = await apiClient.get<ConsentListResponse>("/consents", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeConsent(subject: string, clientId?: string) {
|
||||||
|
const params: Record<string, string> = { subject };
|
||||||
|
if (clientId) {
|
||||||
|
params.client_id = clientId;
|
||||||
|
}
|
||||||
|
await apiClient.delete("/consents", { params });
|
||||||
|
}
|
||||||
6
devfront/src/lib/utils.ts
Normal file
6
devfront/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
21
devfront/src/main.tsx
Normal file
21
devfront/src/main.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { RouterProvider } from "react-router-dom";
|
||||||
|
import { queryClient } from "./app/queryClient";
|
||||||
|
import { router } from "./app/routes";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Root element not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
67
devfront/tailwind.config.ts
Normal file
67
devfront/tailwind.config.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "1.5rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Space Grotesk", "Pretendard Variable", ...fontFamily.sans],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: "0 12px 40px rgba(7, 15, 26, 0.25)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
19
devfront/tests/clients.spec.ts
Normal file
19
devfront/tests/clients.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("clients page loads correctly", async ({ page }) => {
|
||||||
|
await page.goto("/clients");
|
||||||
|
|
||||||
|
// 타이틀 확인
|
||||||
|
await expect(page).toHaveTitle(/바론 개발자 서비스/);
|
||||||
|
|
||||||
|
// 페이지 내 주요 텍스트 확인
|
||||||
|
await expect(page.getByText("클라이언트 목록")).toBeVisible();
|
||||||
|
|
||||||
|
// 테이블 헤더 확인
|
||||||
|
await expect(
|
||||||
|
page.getByRole("columnheader", { name: "애플리케이션" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("columnheader", { name: "Client ID" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
8
devfront/tests/example.spec.ts
Normal file
8
devfront/tests/example.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("has title", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Expect a title "to contain" a substring.
|
||||||
|
await expect(page).toHaveTitle(/바론 개발자 서비스/);
|
||||||
|
});
|
||||||
28
devfront/tsconfig.app.json
Normal file
28
devfront/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
devfront/tsconfig.json
Normal file
7
devfront/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
devfront/tsconfig.node.json
Normal file
26
devfront/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
19
devfront/vite.config.ts
Normal file
19
devfront/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0", // Ensure binding to all interfaces
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: process.env.API_PROXY_TARGET || "http://baron_backend:3000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
esbuild: {
|
||||||
|
drop: process.env.APP_ENV === "production" ? ["console", "debugger"] : [],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ services:
|
|||||||
- IDP_PROVIDER=${IDP_PROVIDER:-ory,descope}
|
- IDP_PROVIDER=${IDP_PROVIDER:-ory,descope}
|
||||||
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
||||||
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
|
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
|
||||||
|
- HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL:-http://hydra:4444}
|
||||||
- DB_HOST=postgres
|
- DB_HOST=postgres
|
||||||
- CLICKHOUSE_HOST=clickhouse
|
- CLICKHOUSE_HOST=clickhouse
|
||||||
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
||||||
@@ -44,6 +45,42 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
adminfront:
|
||||||
|
build:
|
||||||
|
context: ./adminfront
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: baron_adminfront
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=${APP_ENV:-development}
|
||||||
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
|
ports:
|
||||||
|
- "${ADMIN_PORT:-5173}:5173"
|
||||||
|
volumes:
|
||||||
|
- ./adminfront:/app
|
||||||
|
- /app/node_modules
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
|
devfront:
|
||||||
|
build:
|
||||||
|
context: ./devfront
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: baron_devfront
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=${APP_ENV:-development}
|
||||||
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
|
ports:
|
||||||
|
- "${DEVFRONT_PORT:-5174}:5173"
|
||||||
|
volumes:
|
||||||
|
- ./devfront:/app
|
||||||
|
- /app/node_modules
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
userfront:
|
userfront:
|
||||||
build:
|
build:
|
||||||
context: ./userfront
|
context: ./userfront
|
||||||
|
|||||||
@@ -3,6 +3,36 @@ dsn: memory
|
|||||||
serve:
|
serve:
|
||||||
cookies:
|
cookies:
|
||||||
same_site_mode: Lax
|
same_site_mode: Lax
|
||||||
|
admin:
|
||||||
|
cors:
|
||||||
|
enabled: true
|
||||||
|
allowed_origins:
|
||||||
|
- "*"
|
||||||
|
allowed_methods:
|
||||||
|
- POST
|
||||||
|
- GET
|
||||||
|
- PUT
|
||||||
|
- PATCH
|
||||||
|
- DELETE
|
||||||
|
- CONNECT
|
||||||
|
- HEAD
|
||||||
|
- OPTIONS
|
||||||
|
- TRACE
|
||||||
|
allowed_headers:
|
||||||
|
- Authorization
|
||||||
|
- Accept
|
||||||
|
- Content-Type
|
||||||
|
- Content-Length
|
||||||
|
- Accept-Language
|
||||||
|
- Content-Language
|
||||||
|
exposed_headers:
|
||||||
|
- Content-Type
|
||||||
|
- Cache-Control
|
||||||
|
- Expires
|
||||||
|
- Last-Modified
|
||||||
|
- Pragma
|
||||||
|
- Content-Length
|
||||||
|
- Content-Language
|
||||||
public:
|
public:
|
||||||
cors:
|
cors:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -14,11 +44,25 @@ serve:
|
|||||||
- PUT
|
- PUT
|
||||||
- PATCH
|
- PATCH
|
||||||
- DELETE
|
- DELETE
|
||||||
|
- CONNECT
|
||||||
|
- HEAD
|
||||||
|
- OPTIONS
|
||||||
|
- TRACE
|
||||||
allowed_headers:
|
allowed_headers:
|
||||||
- Authorization
|
- Authorization
|
||||||
|
- Accept
|
||||||
- Content-Type
|
- Content-Type
|
||||||
|
- Content-Length
|
||||||
|
- Accept-Language
|
||||||
|
- Content-Language
|
||||||
exposed_headers:
|
exposed_headers:
|
||||||
- Content-Type
|
- Content-Type
|
||||||
|
- Cache-Control
|
||||||
|
- Expires
|
||||||
|
- Last-Modified
|
||||||
|
- Pragma
|
||||||
|
- Content-Length
|
||||||
|
- Content-Language
|
||||||
allow_credentials: true
|
allow_credentials: true
|
||||||
|
|
||||||
urls:
|
urls:
|
||||||
@@ -27,11 +71,18 @@ urls:
|
|||||||
consent: http://127.0.0.1:3000/consent
|
consent: http://127.0.0.1:3000/consent
|
||||||
login: http://127.0.0.1:3000/login
|
login: http://127.0.0.1:3000/login
|
||||||
logout: http://127.0.0.1:3000/logout
|
logout: http://127.0.0.1:3000/logout
|
||||||
|
device:
|
||||||
|
verification: http://127.0.0.1:3000/device/verify
|
||||||
|
success: http://127.0.0.1:3000/device/success
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
system:
|
system:
|
||||||
- youReallyNeedToChangeThis
|
- youReallyNeedToChangeThis
|
||||||
|
|
||||||
|
webfinger:
|
||||||
|
oidc_discovery:
|
||||||
|
client_registration_url: http://127.0.0.1:4444/oauth2/register
|
||||||
|
|
||||||
oidc:
|
oidc:
|
||||||
subject_identifiers:
|
subject_identifiers:
|
||||||
supported_types:
|
supported_types:
|
||||||
@@ -39,3 +90,5 @@ oidc:
|
|||||||
- public
|
- public
|
||||||
pairwise:
|
pairwise:
|
||||||
salt: youReallyNeedToChangeThis
|
salt: youReallyNeedToChangeThis
|
||||||
|
dynamic_client_registration:
|
||||||
|
enabled: true
|
||||||
|
|||||||
119
docs/API_DESIGN_POLICY.md
Normal file
119
docs/API_DESIGN_POLICY.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Baron SSO API Design Policy
|
||||||
|
|
||||||
|
## 1. 개요 (Overview)
|
||||||
|
본 문서는 Baron SSO 시스템의 백엔드 API 설계 원칙과 규약을 정의합니다. 모든 API 개발은 이 문서를 따름으로써 시스템의 일관성, 가독성, 유지보수성을 확보해야 합니다.
|
||||||
|
|
||||||
|
## 2. API 버전 관리 및 URL 구조 (Versioning & URI)
|
||||||
|
|
||||||
|
### 2.1 URL 구조
|
||||||
|
모든 API는 아래의 기본 구조를 따릅니다.
|
||||||
|
|
||||||
|
`[GET|POST|...] /api/{version}/{namespace}/{resource}[/{id}][/{action}]`
|
||||||
|
|
||||||
|
* **Version**: `v1`, `v2` 등 메이저 버전 단위로 관리합니다.
|
||||||
|
* **Namespace**: API의 사용 목적과 권한 범위를 구분합니다.
|
||||||
|
* `/auth`: 인증, 로그인, 비밀번호 찾기 등 (Public/User context)
|
||||||
|
* `/user`: 사용자 마이페이지, 프로필 수정 (Self-service, User context)
|
||||||
|
* `/admin`: 시스템 관리자 기능 (Admin context, Tenant aware)
|
||||||
|
* `/dev`: 개발자 포털 기능 (Developer context, RP management)
|
||||||
|
* **Resource**: 리소스명은 **복수형(Plural)** 명사를 사용합니다. (예: `clients`, `audit-logs`)
|
||||||
|
|
||||||
|
### 2.2 명명 규칙 (Naming Conventions)
|
||||||
|
* **URL Path**: **kebab-case** (소문자, 하이픈 사용)
|
||||||
|
* `GET /api/v1/audit-logs` (O)
|
||||||
|
* `GET /api/v1/auditLogs` (X)
|
||||||
|
* **Query Parameters**: **snake_case** 또는 **camelCase**를 허용하되, **camelCase**를 권장합니다.
|
||||||
|
* `GET /clients?clientId=...`
|
||||||
|
* **JSON Fields**: **camelCase**를 엄격히 준수합니다. (프론트엔드 JS/TS/Dart 표준 준수)
|
||||||
|
* `{ "clientId": "...", "createdAt": "..." }` (O)
|
||||||
|
* `{ "client_id": "...", "created_at": "..." }` (X)
|
||||||
|
* *예외:* DB 모델을 직접 반환해야 하는 불가피한 레거시(ClickHouse 로그 등)는 예외를 두되, 가급적 DTO 변환을 권장합니다.
|
||||||
|
|
||||||
|
## 3. HTTP 메서드 (HTTP Methods)
|
||||||
|
|
||||||
|
리소스에 대한 행위는 HTTP 메서드로 표현합니다.
|
||||||
|
|
||||||
|
| 메서드 | 용도 | 멱등성(Idempotent) | 예시 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **GET** | 리소스 조회 | O | `GET /clients` (목록), `GET /clients/:id` (상세) |
|
||||||
|
| **POST** | 리소스 생성, 또는 복잡한 액션 | X | `POST /clients` (생성), `POST /auth/login` (액션) |
|
||||||
|
| **PUT** | 리소스 전체 수정 (대체) | O | `PUT /users/:id` |
|
||||||
|
| **PATCH** | 리소스 일부 수정 | O (권장) | `PATCH /clients/:id/status` |
|
||||||
|
| **DELETE**| 리소스 삭제 | O | `DELETE /clients/:id` |
|
||||||
|
|
||||||
|
## 4. 요청 및 응답 형식 (Request & Response)
|
||||||
|
|
||||||
|
### 4.1 목록 조회 (List Response)
|
||||||
|
목록 조회 시 반드시 페이지네이션 메타데이터를 포함해야 합니다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{ "id": "1", "name": "Resource A" },
|
||||||
|
{ "id": "2", "name": "Resource B" }
|
||||||
|
],
|
||||||
|
"limit": 50,
|
||||||
|
"offset": 0,
|
||||||
|
"total": 120 // 선택적 (성능 이슈 시 제외 가능)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 단건 조회/생성/수정 (Single Resource Response)
|
||||||
|
데이터를 바로 반환하거나, 필요 시 래핑할 수 있습니다. 일관성을 위해 루트 객체로 반환하는 것을 권장합니다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Resource A",
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 에러 응답 (Error Response)
|
||||||
|
모든 에러는 일관된 포맷을 유지해야 합니다. 프로덕션 환경에서는 내부 스택 트레이스를 노출하지 않아야 합니다.
|
||||||
|
|
||||||
|
**HTTP Status Code 활용:**
|
||||||
|
* `400 Bad Request`: 입력값 검증 실패
|
||||||
|
* `401 Unauthorized`: 인증 토큰 없음/만료
|
||||||
|
* `403 Forbidden`: 권한 부족 (토큰은 있으나 접근 불가)
|
||||||
|
* `404 Not Found`: 리소스 없음
|
||||||
|
* `409 Conflict`: 데이터 충돌 (중복 생성 등)
|
||||||
|
* `429 Too Many Requests`: 레이트 리밋 초과
|
||||||
|
* `500 Internal Server Error`: 서버 내부 오류 (상세 내용 마스킹)
|
||||||
|
* `503 Service Unavailable`: 외부 의존성(Hydra, DB 등) 연결 실패
|
||||||
|
|
||||||
|
**JSON Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "사람이 읽을 수 있는 에러 메시지",
|
||||||
|
"code": "MACHINE_READABLE_CODE", // 선택적 (예: USER_NOT_FOUND, HYDRA_CONN_ERR)
|
||||||
|
"details": { ... } // 선택적 (Validation error 필드별 상세 등)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 헤더 및 보안 (Headers & Security)
|
||||||
|
|
||||||
|
### 5.1 인증 (Authentication)
|
||||||
|
* **Authorization**: `Bearer {token}` 형식을 사용합니다.
|
||||||
|
* Backend는 Gateway 또는 Middleware에서 토큰을 검증하고 `User Context`를 생성해야 합니다.
|
||||||
|
|
||||||
|
### 5.2 테넌트 격리 (Multi-tenancy)
|
||||||
|
* **X-Tenant-ID**: 관리자 API(`admin`) 호출 시, 대상 테넌트를 식별하기 위해 필수적으로 사용합니다.
|
||||||
|
* 슈퍼 어드민이 아닐 경우, 요청자의 권한과 헤더의 테넌트가 일치하는지 검증해야 합니다.
|
||||||
|
|
||||||
|
### 5.3 요청 추적 (Tracing)
|
||||||
|
* **X-Request-ID**: 모든 요청/응답에 고유 ID를 포함하여 로그 추적성을 확보합니다. 클라이언트가 보내지 않으면 서버가 생성합니다.
|
||||||
|
|
||||||
|
## 6. 개발 가이드라인 (Implementation Guidelines)
|
||||||
|
|
||||||
|
### 6.1 DTO 사용
|
||||||
|
* DB 모델(Gorm, ClickHouse struct)을 그대로 API 응답으로 내보내지 마십시오.
|
||||||
|
* 반드시 **Response DTO** 구조체를 별도로 정의하여 `json` 태그를 통해 명명 규칙(camelCase)을 적용하고, 민감한 정보(비밀번호 해시, 내부 ID 등)를 필터링해야 합니다.
|
||||||
|
|
||||||
|
### 6.2 핸들러 구조
|
||||||
|
* 핸들러는 `Service` 레이어를 호출하고, HTTP 요청/응답 처리(파싱, 상태 코드 매핑)에만 집중해야 합니다.
|
||||||
|
* 비즈니스 로직은 `Handler`가 아닌 `Service` 또는 `Domain` 레이어에 위치해야 합니다.
|
||||||
|
|
||||||
|
### 6.3 로깅 정책
|
||||||
|
* 요청/응답 로그는 미들웨어 레벨에서 처리합니다.
|
||||||
|
* 에러 발생 시 `slog.Error`를 통해 스택 트레이스와 컨텍스트를 남기고, 클라이언트에게는 정제된 메시지만 전달합니다.
|
||||||
12
mcp/hydra-mcp/Dockerfile
Normal file
12
mcp/hydra-mcp/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
CMD ["node", "./src/index.js"]
|
||||||
16
mcp/hydra-mcp/package.json
Normal file
16
mcp/hydra-mcp/package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "mcp-ory-hydra",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "MCP server for Ory Hydra Admin/Public APIs",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"mcp-ory-hydra": "./src/runner.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node ./src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.25.0",
|
||||||
|
"zod": "^3.25.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
324
mcp/hydra-mcp/src/index.js
Executable file
324
mcp/hydra-mcp/src/index.js
Executable file
@@ -0,0 +1,324 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const modulesBase = process.env.MCP_MODULES_DIR;
|
||||||
|
const requireFromModules = createRequire(
|
||||||
|
modulesBase ? path.join(modulesBase, "package.json") : import.meta.url,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mcpModule = await import(resolveModule("@modelcontextprotocol/sdk/server/mcp.js"));
|
||||||
|
const stdioModule = await import(resolveModule("@modelcontextprotocol/sdk/server/stdio.js"));
|
||||||
|
const zodModule = await import(resolveModule("zod"));
|
||||||
|
|
||||||
|
const { McpServer } = mcpModule;
|
||||||
|
const { StdioServerTransport } = stdioModule;
|
||||||
|
const { z } = zodModule;
|
||||||
|
|
||||||
|
const hydraPublicUrl = process.env.HYDRA_PUBLIC_URL ?? "http://127.0.0.1:4444";
|
||||||
|
const hydraAdminUrl = process.env.HYDRA_ADMIN_URL ?? "http://127.0.0.1:4445";
|
||||||
|
const adminApiToken = process.env.HYDRA_ADMIN_API_TOKEN;
|
||||||
|
const publicApiToken = process.env.HYDRA_PUBLIC_API_TOKEN;
|
||||||
|
const timeoutMs = Number.parseInt(process.env.HYDRA_HTTP_TIMEOUT_MS ?? "15000", 10);
|
||||||
|
|
||||||
|
class HttpError extends Error {
|
||||||
|
constructor(message, status, body, url) {
|
||||||
|
super(message);
|
||||||
|
this.name = "HttpError";
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModule(specifier) {
|
||||||
|
const resolvedPath = requireFromModules.resolve(specifier);
|
||||||
|
return pathToFileURL(resolvedPath).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(base, path, query) {
|
||||||
|
const url = new URL(path, base);
|
||||||
|
if (query) {
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(url, { method = "GET", headers, body } = {}, token) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = Number.isFinite(timeoutMs)
|
||||||
|
? setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const requestHeaders = {
|
||||||
|
accept: "application/json",
|
||||||
|
...headers,
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
requestHeaders.authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: requestHeaders,
|
||||||
|
body,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text
|
||||||
|
? contentType.includes("application/json")
|
||||||
|
? safeJsonParse(text)
|
||||||
|
: text
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new HttpError(`HTTP ${response.status} ${response.statusText}`, response.status, data, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonParse(text) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToolResult(payload) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(payload, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatErrorResult(error) {
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
return formatToolResult({
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
status: error.status,
|
||||||
|
url: error.url,
|
||||||
|
body: error.body,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatToolResult({
|
||||||
|
error: {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const HealthInputSchema = z.object({
|
||||||
|
service: z.enum(["public", "admin"]).optional(),
|
||||||
|
probe: z.enum(["alive", "ready"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ListClientsInputSchema = z.object({
|
||||||
|
limit: z.number().int().positive().max(500).optional(),
|
||||||
|
offset: z.number().int().min(0).optional(),
|
||||||
|
page_size: z.number().int().positive().max(500).optional(),
|
||||||
|
page_token: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ClientIdInputSchema = z.object({
|
||||||
|
client_id: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ClientPayloadInputSchema = z.object({
|
||||||
|
client_id: z.string().min(1),
|
||||||
|
payload: z.record(z.unknown()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RegisterClientInputSchema = z.object({
|
||||||
|
payload: z.record(z.unknown()),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "mcp-ory-hydra",
|
||||||
|
version: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"hydra_health",
|
||||||
|
"Check Hydra health using /health/alive or /health/ready on public/admin ports.",
|
||||||
|
HealthInputSchema.shape,
|
||||||
|
async (input) => {
|
||||||
|
const service = input.service ?? "admin";
|
||||||
|
const probe = input.probe ?? "ready";
|
||||||
|
const base = service === "public" ? hydraPublicUrl : hydraAdminUrl;
|
||||||
|
const url = buildUrl(base, `/health/${probe}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await requestJson(url, {}, service === "public" ? publicApiToken : adminApiToken);
|
||||||
|
return formatToolResult({
|
||||||
|
service,
|
||||||
|
probe,
|
||||||
|
status: result.status,
|
||||||
|
data: result.data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return formatErrorResult(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"hydra_list_clients",
|
||||||
|
"List OAuth2 clients from Hydra Admin API.",
|
||||||
|
ListClientsInputSchema.shape,
|
||||||
|
async (input) => {
|
||||||
|
const url = buildUrl(hydraAdminUrl, "/clients", {
|
||||||
|
limit: input.limit,
|
||||||
|
offset: input.offset,
|
||||||
|
page_size: input.page_size,
|
||||||
|
page_token: input.page_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await requestJson(url, {}, adminApiToken);
|
||||||
|
return formatToolResult({
|
||||||
|
status: result.status,
|
||||||
|
data: result.data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return formatErrorResult(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"hydra_get_client",
|
||||||
|
"Get an OAuth2 client by client_id from Hydra Admin API.",
|
||||||
|
ClientIdInputSchema.shape,
|
||||||
|
async (input) => {
|
||||||
|
const url = buildUrl(hydraAdminUrl, `/clients/${encodeURIComponent(input.client_id)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await requestJson(url, {}, adminApiToken);
|
||||||
|
return formatToolResult({
|
||||||
|
status: result.status,
|
||||||
|
data: result.data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return formatErrorResult(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"hydra_register_client",
|
||||||
|
"Register an OAuth2 client via Hydra public dynamic client registration endpoint.",
|
||||||
|
RegisterClientInputSchema.shape,
|
||||||
|
async (input) => {
|
||||||
|
const url = buildUrl(hydraPublicUrl, "/oauth2/register");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await requestJson(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input.payload ?? {}),
|
||||||
|
},
|
||||||
|
publicApiToken,
|
||||||
|
);
|
||||||
|
return formatToolResult({
|
||||||
|
status: result.status,
|
||||||
|
data: result.data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return formatErrorResult(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"hydra_update_client",
|
||||||
|
"Update an OAuth2 client via Hydra Admin API.",
|
||||||
|
ClientPayloadInputSchema.shape,
|
||||||
|
async (input) => {
|
||||||
|
const url = buildUrl(hydraAdminUrl, `/clients/${encodeURIComponent(input.client_id)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await requestJson(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input.payload ?? {}),
|
||||||
|
},
|
||||||
|
adminApiToken,
|
||||||
|
);
|
||||||
|
return formatToolResult({
|
||||||
|
status: result.status,
|
||||||
|
data: result.data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return formatErrorResult(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"hydra_delete_client",
|
||||||
|
"Delete an OAuth2 client via Hydra Admin API.",
|
||||||
|
ClientIdInputSchema.shape,
|
||||||
|
async (input) => {
|
||||||
|
const url = buildUrl(hydraAdminUrl, `/clients/${encodeURIComponent(input.client_id)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await requestJson(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
adminApiToken,
|
||||||
|
);
|
||||||
|
return formatToolResult({
|
||||||
|
status: result.status,
|
||||||
|
data: result.data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return formatErrorResult(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
70
mcp/hydra-mcp/src/runner.js
Executable file
70
mcp/hydra-mcp/src/runner.js
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import { existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const sdkVersion = "^1.25.0";
|
||||||
|
const zodVersion = "^3.25.0";
|
||||||
|
const cacheRoot = resolveCacheRoot();
|
||||||
|
const sdkMarker = path.join(cacheRoot, "node_modules", "@modelcontextprotocol", "sdk", "package.json");
|
||||||
|
|
||||||
|
if (!existsSync(sdkMarker)) {
|
||||||
|
mkdirSync(cacheRoot, { recursive: true });
|
||||||
|
|
||||||
|
const installResult = spawnSync(
|
||||||
|
"npm",
|
||||||
|
[
|
||||||
|
"install",
|
||||||
|
"--no-audit",
|
||||||
|
"--no-fund",
|
||||||
|
"--prefix",
|
||||||
|
cacheRoot,
|
||||||
|
`@modelcontextprotocol/sdk@${sdkVersion}`,
|
||||||
|
`zod@${zodVersion}`,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
encoding: "utf-8",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
npm_config_loglevel: "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (installResult.stdout) {
|
||||||
|
process.stderr.write(installResult.stdout);
|
||||||
|
}
|
||||||
|
if (installResult.stderr) {
|
||||||
|
process.stderr.write(installResult.stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (installResult.status !== 0) {
|
||||||
|
process.exit(installResult.status ?? 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
MCP_MODULES_DIR: cacheRoot,
|
||||||
|
};
|
||||||
|
|
||||||
|
const entryPath = fileURLToPath(new URL("./index.js", import.meta.url));
|
||||||
|
const child = spawn(process.execPath, [entryPath], {
|
||||||
|
stdio: "inherit",
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
process.exit(code ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolveCacheRoot() {
|
||||||
|
if (process.env.MCP_ORY_HYDRA_CACHE_DIR) {
|
||||||
|
return process.env.MCP_ORY_HYDRA_CACHE_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseCache = process.env.XDG_CACHE_HOME ?? path.join(homedir(), ".cache");
|
||||||
|
return path.join(baseCache, "mcp-ory-hydra");
|
||||||
|
}
|
||||||
9
mcp/kratos-mcp/Dockerfile
Normal file
9
mcp/kratos-mcp/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN npm install -g mcp-ory-kratos
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
CMD ["mcp-ory-kratos"]
|
||||||
Reference in New Issue
Block a user