1
0
forked from baron/baron-sso

ory용 MCP 제작, devfront/adminfront 백엔드 연결

This commit is contained in:
Lectom C Han
2026-01-28 10:57:22 +09:00
parent 1aaa772907
commit 93cab064fc
75 changed files with 7327 additions and 454 deletions

View File

@@ -11,6 +11,8 @@ DB_PORT=5432
CLICKHOUSE_PORT_HTTP=8123
CLICKHOUSE_PORT_NATIVE=9000
BACKEND_PORT=3000
ADMINFRONT_PORT=5173
DEVFRONT_PORT=5174
USERFRONT_PORT=5000
# --- Database Credentials (PostgreSQL) ---
@@ -68,7 +70,7 @@ KETO_DB=ory_keto
# Ory Kratos Configuration
KRATOS_VERSION=v25.4.0-distroless
KRATOS_PUBLIC_PORT=4433
KRATOS_ADMIN_PORT=4434
KRATOS_ADMINFRONT_PORT=4434
KRATOS_UI_NODE_VERSION=v25.4.0
KRATOS_UI_PORT=4455
@@ -76,7 +78,7 @@ KRATOS_UI_PORT=4455
# Ory Hydra Configuration
HYDRA_VERSION=v25.4.0-distroless
HYDRA_PUBLIC_PORT=4441
HYDRA_ADMIN_PORT=4445
HYDRA_ADMINFRONT_PORT=4445
# Ory Keto Configuration
KETO_VERSION=v25.4.0-distroless
@@ -88,6 +90,7 @@ ORY_SDK_URL=http://kratos:4433
KRATOS_PUBLIC_URL=http://kratos:4433
KRATOS_ADMIN_URL=http://kratos:4434
HYDRA_ADMIN_URL=http://hydra:4445
HYDRA_PUBLIC_URL=http://hydra:4444
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
# Kratos Selfservice UI required secrets (local only)

View File

@@ -91,6 +91,7 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. Magic
IDP_PROVIDER=ory,descope
KRATOS_ADMIN_URL=http://kratos:4434
HYDRA_ADMIN_URL=http://hydra:4445
HYDRA_PUBLIC_URL=http://hydra:4444
```
### 전체 스택 실행 (Running the Stack)
@@ -124,6 +125,35 @@ docker compose -f docker-compose.yaml up -d
- **Hydra Public**: http://localhost:4444
- **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)
Docker 없이 코드를 수정하며 개발하려면:
@@ -141,6 +171,20 @@ flutter pub get
flutter run -d chrome
```
**adminfront:**
```bash
cd adminfront
npm install
npm run dev
```
**devfront:**
```bash
cd devfront
npm install
npm run dev
```
---
## 📂 프로젝트 구조 (Project Structure)

25
adminfront/Dockerfile Normal file
View 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"

View File

@@ -1,6 +1,6 @@
# Admin Front (React 19 + Vite)
관리자 포털을 위한 React/Vite 기반 SPA입니다. 이슈 #60 스펙을 바탕으로 라우팅, 서버 상태, 스타일 토큰을 세팅했고 특정 벤더에 종속되지 않는 IDP 연동 훅 포인트를 남겨두었습니다.
관리자 포털을 위한 React/Vite 기반 입니다. 이슈 #60 스펙을 바탕으로 라우팅, 서버 상태, 스타일 토큰을 세팅했고 특정 벤더에 종속되지 않는 IDP 연동 훅 포인트를 남겨두었습니다.
## 주요 스택
- React 19, Vite 7, TypeScript(strict)

View File

@@ -4,7 +4,7 @@
<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>adminfront</title>
<title>바론 어드민 서비스</title>
</head>
<body>
<div id="root"></div>

View File

@@ -27,6 +27,7 @@
},
"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",
@@ -657,6 +658,22 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -2804,6 +2821,53 @@
"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": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",

View File

@@ -4,10 +4,12 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"lint": "biome check .",
"preview": "vite preview"
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.4",
@@ -29,6 +31,7 @@
},
"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",

View 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,
},
});

View File

@@ -2,10 +2,6 @@ import { createBrowserRouter } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout";
import AuditLogsPage from "../features/audit/AuditLogsPage";
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";
export const router = createBrowserRouter(
@@ -15,10 +11,6 @@ export const router = createBrowserRouter(
element: <AppLayout />,
children: [
{ 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: "auth", element: <AuthPage /> },
],

View File

@@ -12,7 +12,6 @@ import { NavLink, Outlet } from "react-router-dom";
const navItems = [
{ label: "Overview", to: "/", icon: LayoutDashboard },
{ label: "Clients", to: "/clients", icon: ShieldHalf },
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
];

View File

@@ -1,4 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { Filter, ListChecks, Search, Terminal } from "lucide-react";
import { fetchAuditLogs } from "../../lib/adminApi";
const auditFilters = [
"Actor role = admin",
@@ -6,31 +8,28 @@ const auditFilters = [
"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() {
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 (
<div className="space-y-8">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@@ -84,28 +83,42 @@ function AuditLogsPage() {
))}
</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>
{logs.length === 0 ? (
<div className="py-8 text-center text-sm text-[var(--color-muted)]">
No audit logs found.
</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>

View File

@@ -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 &amp; 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;

View 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;
}

View File

@@ -1,7 +1,7 @@
import axios from "axios";
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) => {

View 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(/바론 어드민 서비스/);
});

View File

@@ -4,4 +4,16 @@ import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
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"] : [],
},
});

View File

@@ -8,6 +8,7 @@ import (
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/validator"
"errors"
"fmt"
"log"
"log/slog"
@@ -160,11 +161,45 @@ func main() {
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider)
adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler()
// 3. Initialize Fiber
appEnv := getEnv("APP_ENV", "dev")
app := fiber.New(fiber.Config{
AppName: "Baron SSO Backend",
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
@@ -281,6 +316,7 @@ func main() {
// API Group
api := app.Group("/api/v1")
api.Post("/audit", auditHandler.CreateLog)
api.Get("/audit", auditHandler.ListLogs)
// Auth Proxy Routes
auth := api.Group("/auth")
@@ -320,6 +356,14 @@ func main() {
admin := api.Group("/admin")
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
auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay)

View File

@@ -1,6 +1,7 @@
package domain
import (
"context"
"time"
)
@@ -19,5 +20,6 @@ type AuditLog struct {
// AuditRepository defines interface for storing logs
type AuditRepository interface {
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
}

View File

@@ -46,3 +46,22 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error {
"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,
})
}

View 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,
}
}

View File

@@ -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 {
if r.conn == nil {
return fmt.Errorf("clickhouse connection is nil")

View 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
}

View File

@@ -36,7 +36,7 @@ services:
container_name: ory_kratos
ports:
- "${KRATOS_PUBLIC_PORT:-4433}:4433"
- "${KRATOS_ADMIN_PORT:-4434}:4434"
- "${KRATOS_ADMINFRONT_PORT:-4434}:4434"
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB}?sslmode=disable&max_conns=20
- COOKIE_SECRET=${COOKIE_SECRET:-localcookie123}
@@ -50,6 +50,22 @@ services:
- ory-net
- 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:
image: oryd/kratos-selfservice-ui-node:${KRATOS_UI_NODE_VERSION:-v25.4.0}
container_name: ory_kratos_ui
@@ -83,7 +99,7 @@ services:
container_name: ory_hydra
ports:
- "${HYDRA_PUBLIC_PORT:-4441}:4444"
- "${HYDRA_ADMIN_PORT:-4445}:4445"
- "${HYDRA_ADMINFRONT_PORT:-4445}:4445"
environment:
- 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}
@@ -100,6 +116,23 @@ services:
- ory-net
- 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-migrate:
image: oryd/keto:${KETO_VERSION:-v25.4.0}
@@ -171,14 +204,24 @@ services:
environment:
- HYDRA_ADMIN_URL=http://hydra:4445
command: >
clients create
/bin/sh -c "
hydra clients create
--endpoint http://hydra:4445
--id adminfront
--secret admin-secret
--grant-types authorization_code,refresh_token
--response-types code
--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:
ory_stack_check:
condition: service_completed_successfully

24
devfront/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

49
devfront/package.json Normal file
View 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"
}
}

View 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,
},
});

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

1
devfront/public/vite.svg Normal file
View 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

View 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,
},
},
});

View 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],
);

View 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

View 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;

View 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 };

View 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 };

View 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 };

View 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,
};

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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,
};

View 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 };

View 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;

View 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;

View 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 &amp; 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;

View 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
View 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;
}
}

View 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;

View 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 });
}

View 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
View 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>,
);

View 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;

View 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();
});

View 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(/바론 개발자 서비스/);
});

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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"] : [],
},
});

View File

@@ -21,6 +21,7 @@ services:
- IDP_PROVIDER=${IDP_PROVIDER:-ory,descope}
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
- HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL:-http://hydra:4444}
- DB_HOST=postgres
- CLICKHOUSE_HOST=clickhouse
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
@@ -44,6 +45,42 @@ services:
retries: 3
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:
build:
context: ./userfront

View File

@@ -3,6 +3,36 @@ dsn: memory
serve:
cookies:
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:
cors:
enabled: true
@@ -14,11 +44,25 @@ serve:
- 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
allow_credentials: true
urls:
@@ -27,11 +71,18 @@ urls:
consent: http://127.0.0.1:3000/consent
login: http://127.0.0.1:3000/login
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:
system:
- youReallyNeedToChangeThis
webfinger:
oidc_discovery:
client_registration_url: http://127.0.0.1:4444/oauth2/register
oidc:
subject_identifiers:
supported_types:
@@ -39,3 +90,5 @@ oidc:
- public
pairwise:
salt: youReallyNeedToChangeThis
dynamic_client_registration:
enabled: true

119
docs/API_DESIGN_POLICY.md Normal file
View 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
View 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"]

View 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
View 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
View 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");
}

View 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"]