diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx
index 054331cd..a390a46e 100644
--- a/adminfront/src/app/routes.tsx
+++ b/adminfront/src/app/routes.tsx
@@ -3,6 +3,10 @@ import AppLayout from "../components/layout/AppLayout";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthPage from "../features/auth/AuthPage";
import DashboardPage from "../features/dashboard/DashboardPage";
+import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
+import TenantCreatePage from "../features/tenants/TenantCreatePage";
+import TenantDetailPage from "../features/tenants/TenantDetailPage";
+import TenantListPage from "../features/tenants/TenantListPage";
export const router = createBrowserRouter(
[
@@ -10,9 +14,13 @@ export const router = createBrowserRouter(
path: "/",
element: ,
children: [
- { index: true, element: },
+ { index: true, element: },
+ { path: "dashboard", element: },
{ path: "audit-logs", element: },
{ path: "auth", element: },
+ { path: "tenants", element: },
+ { path: "tenants/new", element: },
+ { path: "tenants/:id", element: },
],
},
],
diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx
index 3df91ace..a412692a 100644
--- a/adminfront/src/components/layout/AppLayout.tsx
+++ b/adminfront/src/components/layout/AppLayout.tsx
@@ -1,5 +1,6 @@
import {
BadgeCheck,
+ Building2,
KeyRound,
LayoutDashboard,
Moon,
@@ -12,6 +13,8 @@ import { NavLink, Outlet } from "react-router-dom";
const navItems = [
{ label: "Overview", to: "/", icon: LayoutDashboard },
+ { label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
+ { label: "Tenants", to: "/tenants", icon: Building2 },
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
];
diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx
index 2faf1973..b0299695 100644
--- a/adminfront/src/features/audit/AuditLogsPage.tsx
+++ b/adminfront/src/features/audit/AuditLogsPage.tsx
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
+import type { AxiosError } from "axios";
import { Filter, ListChecks, Search, Terminal } from "lucide-react";
import { fetchAuditLogs } from "../../lib/adminApi";
@@ -22,7 +23,8 @@ function AuditLogsPage() {
if (error) {
const errMsg =
- (error as any).response?.data?.error || (error as Error).message;
+ (error as AxiosError<{ error?: string }>).response?.data?.error ??
+ (error as Error).message;
return (
Error loading logs: {errMsg}
@@ -88,10 +90,9 @@ function AuditLogsPage() {
No audit logs found.
) : (
- logs.map((row, idx) => (
+ logs.map((row) => (
{row.event_type}
diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx
new file mode 100644
index 00000000..b8a05e6e
--- /dev/null
+++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx
@@ -0,0 +1,170 @@
+import {
+ Activity,
+ ArrowUpRight,
+ Box,
+ Database,
+ ShieldCheck,
+ Users,
+} from "lucide-react";
+import { Link } from "react-router-dom";
+import { Badge } from "../../components/ui/badge";
+import { Button } from "../../components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "../../components/ui/card";
+
+const summaryCards = [
+ {
+ label: "Total Tenants",
+ value: "-",
+ hint: "Tenant-aware core",
+ icon: Users,
+ },
+ {
+ label: "OIDC Clients",
+ value: "-",
+ hint: "Hydra registry",
+ icon: ShieldCheck,
+ },
+ {
+ label: "Audit Events (24h)",
+ value: "-",
+ hint: "ClickHouse stream",
+ icon: Activity,
+ },
+ {
+ label: "Policy Gate",
+ value: "Planned",
+ hint: "Keto + Admin checks",
+ icon: Database,
+ },
+];
+
+function GlobalOverviewPage() {
+ return (
+
+
+
+
+ Global Overview
+
+
+ Tenant-independent control plane
+
+
+ 모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다.
+
+
+
+ IDP: Ory primary
+ Fallback: Descope
+
+
+
+
+ {summaryCards.map(({ label, value, hint, icon: Icon }) => (
+
+
+ {label}
+
+
+
+
+
+ {value}
+ {hint}
+
+
+ ))}
+
+
+
+
+
+ Admin playbook
+
+ 운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.
+
+
+
+
+
+
+
+
+
+ Backend-only IDP access
+
+
+ 모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos
+ admin 포트는 외부에 노출하지 않습니다.
+
+
+
+
+
+
+
+
+
+ Tenant isolation
+
+
+ Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto
+ 정책으로 확장 예정입니다.
+
+
+
+
+
+
+
+
+ 빠른 이동
+
+ 주요 운영 화면으로 바로 이동합니다.
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default GlobalOverviewPage;
diff --git a/adminfront/src/features/tenants/TenantCreatePage.tsx b/adminfront/src/features/tenants/TenantCreatePage.tsx
new file mode 100644
index 00000000..f48b67e4
--- /dev/null
+++ b/adminfront/src/features/tenants/TenantCreatePage.tsx
@@ -0,0 +1,153 @@
+import { useMutation } from "@tanstack/react-query";
+import type { AxiosError } from "axios";
+import { Building2, Sparkles } from "lucide-react";
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+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 { Label } from "../../components/ui/label";
+import { Textarea } from "../../components/ui/textarea";
+import { createTenant } from "../../lib/adminApi";
+
+function TenantCreatePage() {
+ const navigate = useNavigate();
+ const [name, setName] = useState("");
+ const [slug, setSlug] = useState("");
+ const [description, setDescription] = useState("");
+ const [status, setStatus] = useState("active");
+
+ const mutation = useMutation({
+ mutationFn: () =>
+ createTenant({
+ name,
+ slug: slug || undefined,
+ description: description || undefined,
+ status,
+ }),
+ onSuccess: () => {
+ navigate("/tenants");
+ },
+ });
+
+ const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
+ ?.data?.error;
+
+ return (
+
+
+
+ Tenants
+ /
+ Create
+
+
+
+
테넌트 추가
+
+ 글로벌 운영 기준의 신규 테넌트를 등록합니다.
+
+
+
Admin only
+
+
+
+
+
+
+
+ Tenant Profile
+
+
+ 필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.
+
+
+
+
+
+ setName(e.target.value)} />
+
+
+
+ setSlug(e.target.value)}
+ placeholder="tenant-slug"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {errorMsg && (
+
+ {errorMsg}
+
+ )}
+
+
+
+
+
+
+
+ 정책 메모
+
+
+ Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.
+
+
+
+ 생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.
+
+
+
+
+
+
+
+
+ );
+}
+
+export default TenantCreatePage;
diff --git a/adminfront/src/features/tenants/TenantDetailPage.tsx b/adminfront/src/features/tenants/TenantDetailPage.tsx
new file mode 100644
index 00000000..1e85864b
--- /dev/null
+++ b/adminfront/src/features/tenants/TenantDetailPage.tsx
@@ -0,0 +1,185 @@
+import { useMutation, useQuery } from "@tanstack/react-query";
+import type { AxiosError } from "axios";
+import { ArrowLeft, Save, Trash2 } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+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 { Label } from "../../components/ui/label";
+import { Textarea } from "../../components/ui/textarea";
+import { deleteTenant, fetchTenant, updateTenant } from "../../lib/adminApi";
+
+function TenantDetailPage() {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const tenantId = useMemo(() => id ?? "", [id]);
+
+ const tenantQuery = useQuery({
+ queryKey: ["tenant", tenantId],
+ queryFn: () => fetchTenant(tenantId),
+ enabled: tenantId !== "",
+ });
+
+ const [name, setName] = useState("");
+ const [slug, setSlug] = useState("");
+ const [description, setDescription] = useState("");
+ const [status, setStatus] = useState("active");
+
+ useEffect(() => {
+ if (!tenantQuery.data) {
+ return;
+ }
+ setName(tenantQuery.data.name);
+ setSlug(tenantQuery.data.slug);
+ setDescription(tenantQuery.data.description ?? "");
+ setStatus(tenantQuery.data.status);
+ }, [tenantQuery.data]);
+
+ const updateMutation = useMutation({
+ mutationFn: () =>
+ updateTenant(tenantId, {
+ name,
+ slug,
+ description: description || undefined,
+ status,
+ }),
+ onSuccess: () => {
+ navigate("/tenants");
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: () => deleteTenant(tenantId),
+ onSuccess: () => {
+ navigate("/tenants");
+ },
+ });
+
+ const errorMsg = (updateMutation.error as AxiosError<{ error?: string }>)
+ ?.response?.data?.error;
+ const loadError = (tenantQuery.error as AxiosError<{ error?: string }>)
+ ?.response?.data?.error;
+
+ const handleDelete = () => {
+ if (!window.confirm("이 테넌트를 삭제할까요?")) {
+ return;
+ }
+ deleteMutation.mutate();
+ };
+
+ return (
+
+
+
+
+
+ Tenant profile
+ Slug와 상태 변경은 바로 적용됩니다.
+
+
+ {loadError && (
+
+ {loadError}
+
+ )}
+
+
+ setName(e.target.value)} />
+
+
+
+ setSlug(e.target.value)} />
+
+
+
+
+
+
+
+
+
+
+
+
+ {errorMsg && (
+
+ {errorMsg}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default TenantDetailPage;
diff --git a/adminfront/src/features/tenants/TenantListPage.tsx b/adminfront/src/features/tenants/TenantListPage.tsx
new file mode 100644
index 00000000..04c3c95c
--- /dev/null
+++ b/adminfront/src/features/tenants/TenantListPage.tsx
@@ -0,0 +1,171 @@
+import { useMutation, useQuery } from "@tanstack/react-query";
+import type { AxiosError } from "axios";
+import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
+import { Link, useNavigate } from "react-router-dom";
+import { Badge } from "../../components/ui/badge";
+import { Button } from "../../components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "../../components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "../../components/ui/table";
+import { deleteTenant, fetchTenants } from "../../lib/adminApi";
+
+function TenantListPage() {
+ const navigate = useNavigate();
+ const query = useQuery({
+ queryKey: ["tenants", { limit: 50, offset: 0 }],
+ queryFn: () => fetchTenants(50, 0),
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (tenantId: string) => deleteTenant(tenantId),
+ onSuccess: () => {
+ query.refetch();
+ },
+ });
+
+ const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
+ ?.data?.error;
+ const fallbackError =
+ !errorMsg && query.isError ? "테넌트 목록 조회에 실패했습니다." : null;
+
+ const items = query.data?.items ?? [];
+
+ const handleDelete = (tenantId: string, tenantName: string) => {
+ if (!window.confirm(`테넌트 "${tenantName}"를 삭제할까요?`)) {
+ return;
+ }
+ deleteMutation.mutate(tenantId);
+ };
+
+ return (
+
+
+
+
+
+
+ Tenant registry
+
+ 총 {query.data?.total ?? 0}개 테넌트
+
+
+ Admin only
+
+
+ {(errorMsg || fallbackError) && (
+
+ {errorMsg ?? fallbackError}
+
+ )}
+
+
+
+
+ NAME
+ SLUG
+ STATUS
+ UPDATED
+ ACTIONS
+
+
+
+ {query.isLoading && (
+
+ 로딩 중...
+
+ )}
+ {!query.isLoading && items.length === 0 && (
+
+
+ 아직 등록된 테넌트가 없습니다.
+
+
+ )}
+ {items.map((tenant) => (
+
+ {tenant.name}
+ {tenant.slug}
+
+
+ {tenant.status}
+
+
+
+ {tenant.updatedAt
+ ? new Date(tenant.updatedAt).toLocaleString("ko-KR")
+ : "-"}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export default TenantListPage;
diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts
index 4f6e0663..f0cea060 100644
--- a/adminfront/src/lib/adminApi.ts
+++ b/adminfront/src/lib/adminApi.ts
@@ -17,9 +17,80 @@ export type AuditLogListResponse = {
offset: number;
};
+export type TenantSummary = {
+ id: string;
+ name: string;
+ slug: string;
+ description: string;
+ status: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type TenantCreateRequest = {
+ name: string;
+ slug?: string;
+ description?: string;
+ status?: string;
+};
+
+export type TenantListResponse = {
+ items: TenantSummary[];
+ limit: number;
+ offset: number;
+ total: number;
+};
+
+export type TenantUpdateRequest = {
+ name?: string;
+ slug?: string;
+ description?: string;
+ status?: string;
+};
+
export async function fetchAuditLogs(limit = 50, offset = 0) {
const { data } = await apiClient.get
("/v1/audit", {
params: { limit, offset },
});
return data;
}
+
+export async function fetchTenants(limit = 50, offset = 0) {
+ const { data } = await apiClient.get(
+ "/v1/admin/tenants",
+ {
+ params: { limit, offset },
+ },
+ );
+ return data;
+}
+
+export async function fetchTenant(tenantId: string) {
+ const { data } = await apiClient.get(
+ `/v1/admin/tenants/${tenantId}`,
+ );
+ return data;
+}
+
+export async function createTenant(payload: TenantCreateRequest) {
+ const { data } = await apiClient.post(
+ "/v1/admin/tenants",
+ payload,
+ );
+ return data;
+}
+
+export async function updateTenant(
+ tenantId: string,
+ payload: TenantUpdateRequest,
+) {
+ const { data } = await apiClient.put(
+ `/v1/admin/tenants/${tenantId}`,
+ payload,
+ );
+ return data;
+}
+
+export async function deleteTenant(tenantId: string) {
+ await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
+}
diff --git a/adminfront/vite.config.ts b/adminfront/vite.config.ts
index 01be851f..01b4be3a 100644
--- a/adminfront/vite.config.ts
+++ b/adminfront/vite.config.ts
@@ -8,12 +8,9 @@ export default defineConfig({
host: "0.0.0.0",
proxy: {
"/api": {
- target: process.env.API_PROXY_TARGET || "http://baron_backend:3000",
+ target: process.env.API_PROXY_TARGET || "http://localhost:3000",
changeOrigin: true,
},
},
},
- esbuild: {
- drop: process.env.APP_ENV === "production" ? ["console", "debugger"] : [],
- },
});
diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml
index 1a0bee39..82710529 100644
--- a/backend/docs/openapi.yaml
+++ b/backend/docs/openapi.yaml
@@ -19,6 +19,8 @@ tags:
description: 회원가입/검증
- name: User
description: 사용자 프로필
+ - name: Session
+ description: 세션 관리(계획)
- name: Admin
description: 관리자 기능/테넌트
- name: Dev
@@ -468,6 +470,68 @@ paths:
schema:
$ref: "#/components/schemas/MessageResponse"
+ /api/v1/sessions:
+ get:
+ tags: [Session]
+ summary: 세션 목록
+ description: 세션 관리 API는 계획 단계입니다.
+ x-status: planned
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SessionListResponse"
+
+ /api/v1/sessions/{id}:
+ get:
+ tags: [Session]
+ summary: 세션 상세
+ description: 세션 관리 API는 계획 단계입니다.
+ x-status: planned
+ parameters:
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SessionDetailResponse"
+ delete:
+ tags: [Session]
+ summary: 세션 로그아웃(폐기)
+ description: 세션 관리 API는 계획 단계입니다.
+ x-status: planned
+ parameters:
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ responses:
+ "204":
+ description: No Content
+
+ /api/v1/sessions/logout-all:
+ post:
+ tags: [Session]
+ summary: 모든 세션 로그아웃
+ description: 세션 관리 API는 계획 단계입니다.
+ x-status: planned
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MessageResponse"
+
/api/v1/admin/check:
get:
tags: [Admin]
@@ -1029,6 +1093,36 @@ components:
code:
type: string
+ SessionSummary:
+ type: object
+ properties:
+ id:
+ type: string
+ issuedAt:
+ type: string
+ expiresAt:
+ type: string
+ device:
+ type: string
+ ip:
+ type: string
+ userAgent:
+ type: string
+
+ SessionListResponse:
+ type: object
+ properties:
+ items:
+ type: array
+ items:
+ $ref: "#/components/schemas/SessionSummary"
+
+ SessionDetailResponse:
+ type: object
+ properties:
+ session:
+ $ref: "#/components/schemas/SessionSummary"
+
TenantResponse:
type: object
properties:
diff --git a/backend/internal/handler/admin_auth.go b/backend/internal/handler/admin_auth.go
deleted file mode 100644
index b4af3a39..00000000
--- a/backend/internal/handler/admin_auth.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package handler
-
-import (
- "os"
-
- "github.com/gofiber/fiber/v2"
-)
-
-func requireAdmin(c *fiber.Ctx) error {
- adminPass := os.Getenv("ADMIN_PASSWORD")
- if adminPass == "" {
- adminPass = "admin"
- }
-
- reqPass := c.Get("X-Admin-Password")
- if reqPass != adminPass {
- return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid Admin Password"})
- }
- return nil
-}
diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go
index 419889d1..f283e556 100644
--- a/backend/internal/handler/admin_handler.go
+++ b/backend/internal/handler/admin_handler.go
@@ -36,23 +36,6 @@ func NewAdminHandler() *AdminHandler {
}
}
-// checkAuth Helper
-func (h *AdminHandler) checkAuth(c *fiber.Ctx) error {
- adminPass := os.Getenv("ADMIN_PASSWORD")
- if adminPass == "" {
- adminPass = "admin" // Default fallback
- }
-
- reqPass := c.Get("X-Admin-Password")
- if reqPass != adminPass {
- return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid Admin Password"})
- }
- return nil
-}
-
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
- if err := requireAdmin(c); err != nil {
- return err
- }
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
}
diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go
index 414fc2f8..d19a5fb1 100644
--- a/backend/internal/handler/tenant_handler.go
+++ b/backend/internal/handler/tenant_handler.go
@@ -36,9 +36,6 @@ type tenantListResponse struct {
}
func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
- if err := requireAdmin(c); err != nil {
- return err
- }
if h.DB == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
}
@@ -71,9 +68,6 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
- if err := requireAdmin(c); err != nil {
- return err
- }
if h.DB == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
}
@@ -95,9 +89,6 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
}
func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
- if err := requireAdmin(c); err != nil {
- return err
- }
if h.DB == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
}
@@ -152,9 +143,6 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
}
func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
- if err := requireAdmin(c); err != nil {
- return err
- }
if h.DB == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
}
@@ -223,9 +211,6 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
}
func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
- if err := requireAdmin(c); err != nil {
- return err
- }
if h.DB == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
}
diff --git a/compose.ory.yaml b/compose.ory.yaml
index 0d132440..2724810a 100644
--- a/compose.ory.yaml
+++ b/compose.ory.yaml
@@ -161,7 +161,7 @@ services:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20
volumes:
- ./docker/ory/keto:/etc/config/keto
- command: migrate sql -e --yes
+ command: migrate up --yes
depends_on:
postgres_ory:
condition: service_healthy
diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx
index cc285173..0bbba406 100644
--- a/devfront/src/app/routes.tsx
+++ b/devfront/src/app/routes.tsx
@@ -13,6 +13,7 @@ export const router = createBrowserRouter(
children: [
{ index: true, element: },
{ path: "clients", element: },
+ { path: "clients/new", element: },
{ path: "clients/:id", element: },
{ path: "clients/:id/consents", element: },
{ path: "clients/:id/settings", element: },
diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx
index 41995b9f..77b7fae9 100644
--- a/devfront/src/features/clients/ClientConsentsPage.tsx
+++ b/devfront/src/features/clients/ClientConsentsPage.tsx
@@ -1,3 +1,4 @@
+import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowLeft,
ChevronLeft,
@@ -5,7 +6,8 @@ import {
Filter,
Search,
} from "lucide-react";
-import { Link } from "react-router-dom";
+import { useState } from "react";
+import { Link, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
@@ -24,39 +26,38 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table";
-
-const rows = [
- {
- initials: "JD",
- name: "John Doe",
- email: "john.doe@example.com",
- scopes: ["openid", "profile", "email", "offline_access"],
- lastAuth: "Oct 24, 2023 14:22",
- },
- {
- initials: "AS",
- name: "Alice Smith",
- email: "alice.smith@devmail.com",
- scopes: ["openid", "profile"],
- lastAuth: "Oct 23, 2023 09:15",
- },
- {
- initials: "RJ",
- name: "Robert Johnson",
- email: "r.johnson@corporate.org",
- scopes: ["openid", "profile", "groups"],
- lastAuth: "Oct 21, 2023 18:45",
- },
- {
- initials: "ML",
- name: "Maria Lopez",
- email: "maria.l@provider.net",
- scopes: ["openid", "email"],
- lastAuth: "Oct 20, 2023 11:30",
- },
-];
+import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
function ClientConsentsPage() {
+ const params = useParams();
+ const clientId = params.id ?? "";
+ const [subjectInput, setSubjectInput] = useState("");
+ const [subject, setSubject] = useState("");
+ const { data: clientData } = useQuery({
+ queryKey: ["client", clientId],
+ queryFn: () => fetchClient(clientId),
+ enabled: clientId.length > 0,
+ });
+ const {
+ data: consentsData,
+ isLoading,
+ error,
+ refetch,
+ } = useQuery({
+ queryKey: ["consents", clientId, subject],
+ queryFn: () => fetchConsents(subject, clientId),
+ enabled: subject.length > 0,
+ });
+ const revokeMutation = useMutation({
+ mutationFn: (payload: { subject: string }) =>
+ revokeConsent(payload.subject, clientId),
+ onSuccess: () => {
+ refetch();
+ },
+ });
+
+ const rows = consentsData?.items ?? [];
+
return (
@@ -71,7 +72,7 @@ function ClientConsentsPage() {
Clients
/
- OIDC Relying Party
+ {clientData?.client?.name || clientId}
/
User Consent Grants
@@ -79,7 +80,7 @@ function ClientConsentsPage() {
@@ -94,12 +95,18 @@ function ClientConsentsPage() {
- Active
+
+ {clientData?.client?.status === "active" ? "Active" : "Inactive"}
+
Overview
@@ -108,7 +115,7 @@ function ClientConsentsPage() {
Consent & Users
Settings
@@ -124,6 +131,8 @@ function ClientConsentsPage() {
setSubjectInput(e.target.value)}
/>
@@ -142,12 +151,28 @@ function ClientConsentsPage() {
Advanced Filters
+
+ {error && (
+
+ Error loading consents: {(error as Error).message}
+
+ )}
+ {isLoading && (
+
+ Loading consents...
+
+ )}
@@ -160,16 +185,18 @@ function ClientConsentsPage() {
{rows.map((row) => (
-
+
- {row.initials}
+ {row.subject.slice(0, 2).toUpperCase()}
- {row.name}
+
+ {row.clientName || "Subject"}
+
- {row.email}
+ {row.subject}
@@ -179,7 +206,7 @@ function ClientConsentsPage() {
- {row.scopes.map((scope) => (
+ {row.grantedScopes.map((scope) => (
- {row.lastAuth}
+ {row.authenticatedAt || "-"}
-
diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx
index 43a6b012..1e1e2e49 100644
--- a/devfront/src/features/clients/ClientDetailsPage.tsx
+++ b/devfront/src/features/clients/ClientDetailsPage.tsx
@@ -1,5 +1,7 @@
+import { useQuery } from "@tanstack/react-query";
+import type { AxiosError } from "axios";
import { AlertCircle, Copy, Eye, Link2, Shield, Workflow } from "lucide-react";
-import { Link } from "react-router-dom";
+import { Link, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent } from "../../components/ui/card";
@@ -10,25 +12,44 @@ import {
TableCell,
TableRow,
} from "../../components/ui/table";
-
-const endpoints = [
- {
- label: "Discovery Endpoint",
- value: "https://auth.acme-idp.com/.well-known/openid-configuration",
- },
- { label: "Issuer URL", value: "https://auth.acme-idp.com/" },
- {
- label: "Authorization Endpoint",
- value: "https://auth.acme-idp.com/oauth2/authorize",
- },
- { label: "Token Endpoint", value: "https://auth.acme-idp.com/oauth2/token" },
- {
- label: "UserInfo Endpoint",
- value: "https://auth.acme-idp.com/oauth2/userinfo",
- },
-];
+import { fetchClient } from "../../lib/devApi";
function ClientDetailsPage() {
+ const params = useParams();
+ const clientId = params.id ?? "";
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["client", clientId],
+ queryFn: () => fetchClient(clientId),
+ enabled: clientId.length > 0,
+ });
+
+ if (!clientId) {
+ return Client ID가 필요합니다.
;
+ }
+
+ if (isLoading) {
+ return Loading client...
;
+ }
+
+ if (error || !data) {
+ const errMsg =
+ (error as AxiosError<{ error?: string }>).response?.data?.error ??
+ (error as Error)?.message;
+ return (
+
+ Error loading client: {errMsg || "unknown error"}
+
+ );
+ }
+
+ const endpoints = [
+ { label: "Discovery Endpoint", value: data.endpoints.discovery },
+ { label: "Issuer URL", value: data.endpoints.issuer },
+ { label: "Authorization Endpoint", value: data.endpoints.authorization },
+ { label: "Token Endpoint", value: data.endpoints.token },
+ { label: "UserInfo Endpoint", value: data.endpoints.userinfo },
+ ];
+
return (
@@ -42,31 +63,34 @@ function ClientDetailsPage() {
- Developer Portal App
+ {data.client.name || data.client.id}
OIDC 자격 증명과 엔드포인트를 관리합니다.
-
- Active
+
+ {data.client.status === "active" ? "Active" : "Inactive"}
Overview
Consent & Users
Settings
@@ -82,7 +106,7 @@ function ClientDetailsPage() {
Client ID
-
721948305612-oidc-client-prod
+
{data.client.id}
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx
index e881e954..5bace9de 100644
--- a/devfront/src/features/clients/ClientGeneralPage.tsx
+++ b/devfront/src/features/clients/ClientGeneralPage.tsx
@@ -1,5 +1,8 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import type { AxiosError } from "axios";
import { Info, Search, Shield, Sparkles, Upload } from "lucide-react";
-import { Link } from "react-router-dom";
+import { useEffect, useMemo, useState } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
@@ -13,15 +16,112 @@ import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { Separator } from "../../components/ui/separator";
import { Textarea } from "../../components/ui/textarea";
+import { createClient, fetchClient, updateClient } from "../../lib/devApi";
+import type { ClientStatus, ClientType } from "../../lib/devApi";
import { cn } from "../../lib/utils";
-const meta = {
- clientId: "client_82910_ax99",
- created: "2023-10-12 10:45",
- updated: "2 hours ago",
-};
-
function ClientGeneralPage() {
+ const params = useParams();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const clientId = params.id;
+ const isCreate = !clientId;
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["client", clientId],
+ queryFn: () => fetchClient(clientId as string),
+ enabled: !isCreate,
+ });
+
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [logoUrl, setLogoUrl] = useState("");
+ const [clientType, setClientType] = useState("confidential");
+ const [status, setStatus] = useState("active");
+ const [redirectUris, setRedirectUris] = useState("");
+ const [scopes, setScopes] = useState("openid profile email");
+
+ useEffect(() => {
+ if (!data) {
+ return;
+ }
+ setName(data.client.name || data.client.id);
+ setClientType(data.client.type);
+ setStatus(data.client.status);
+ setRedirectUris(data.client.redirectUris.join(", "));
+ setScopes(data.client.scopes.join(" "));
+ const metadata = data.client.metadata ?? {};
+ if (typeof metadata.description === "string") {
+ setDescription(metadata.description);
+ }
+ if (typeof metadata.logo_url === "string") {
+ setLogoUrl(metadata.logo_url);
+ }
+ }, [data]);
+
+ const redirectUriList = useMemo(
+ () =>
+ redirectUris
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean),
+ [redirectUris],
+ );
+ const scopeList = useMemo(
+ () =>
+ scopes
+ .split(/[,\s]+/)
+ .map((value) => value.trim())
+ .filter(Boolean),
+ [scopes],
+ );
+
+ const mutation = useMutation({
+ mutationFn: async () => {
+ const payload = {
+ name,
+ type: clientType,
+ status,
+ redirectUris: redirectUriList,
+ scopes: scopeList,
+ metadata: {
+ description,
+ logo_url: logoUrl,
+ },
+ };
+ if (isCreate) {
+ return createClient(payload);
+ }
+ return updateClient(clientId as string, payload);
+ },
+ onSuccess: (result) => {
+ queryClient.invalidateQueries({ queryKey: ["clients"] });
+ if (result?.client?.id) {
+ navigate(`/clients/${result.client.id}/settings`);
+ }
+ },
+ });
+
+ if (!isCreate && isLoading) {
+ return Loading client...
;
+ }
+
+ if (!isCreate && (error || !data)) {
+ const errMsg =
+ (error as AxiosError<{ error?: string }>).response?.data?.error ??
+ (error as Error)?.message;
+ return (
+
+ Error loading client: {errMsg || "unknown error"}
+
+ );
+ }
+
+ const displayName = isCreate
+ ? "새 클라이언트"
+ : data?.client?.name || data?.client?.id;
+ const createdAt = data?.client?.createdAt;
+ const updatedAt = undefined;
+
return (
@@ -32,11 +132,11 @@ function ClientGeneralPage() {
Applications
/
- Customer Support Portal
+ {displayName}
- Client Details
+ {isCreate ? "Create Client" : "Client Details"}
RP 설정과 메타데이터를 관리합니다.
@@ -44,27 +144,34 @@ function ClientGeneralPage() {
-
- Active
+
+ {status === "active" ? "Active" : "Inactive"}
-
- Overview
-
-
- Consent & Users
-
-
- Settings
-
+ {!isCreate && (
+ <>
+
+ Overview
+
+
+ Consent & Users
+
+
+ Settings
+
+ >
+ )}
@@ -99,13 +206,14 @@ function ClientGeneralPage() {
-
+ setName(e.target.value)} />
@@ -114,7 +222,10 @@ function ClientGeneralPage() {
-
+
setLogoUrl(e.target.value)}
+ />
PNG/SVG URL을 입력하세요.
@@ -141,12 +252,20 @@ function ClientGeneralPage() {
-
@@ -214,9 +236,7 @@ function ClientsPage() {
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary"
aria-label="Copy client id"
- onClick={() =>
- navigator.clipboard.writeText(client.id)
- }
+ onClick={() => navigator.clipboard.writeText(client.id)}
>
@@ -234,24 +254,16 @@ function ClientsPage() {
-
-
+
+
+ updateStatusMutation.mutate({
+ id: client.id,
+ status: checked ? "active" : "inactive",
+ })
+ }
+ />
deleteMutation.mutate(client.id)}
>
diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts
index 72656221..2e95ae7b 100644
--- a/devfront/src/lib/devApi.ts
+++ b/devfront/src/lib/devApi.ts
@@ -34,6 +34,19 @@ export type ClientDetailResponse = {
endpoints: ClientEndpoints;
};
+export type ClientUpsertRequest = {
+ id?: string;
+ name?: string;
+ type?: ClientType;
+ status?: ClientStatus;
+ redirectUris?: string[];
+ scopes?: string[];
+ grantTypes?: string[];
+ responseTypes?: string[];
+ tokenEndpointAuthMethod?: string;
+ metadata?: Record;
+};
+
export type ConsentSummary = {
subject: string;
clientId: string;
@@ -69,6 +82,29 @@ export async function updateClientStatus(
return data;
}
+export async function createClient(payload: ClientUpsertRequest) {
+ const { data } = await apiClient.post(
+ "/clients",
+ payload,
+ );
+ return data;
+}
+
+export async function updateClient(
+ clientId: string,
+ payload: ClientUpsertRequest,
+) {
+ const { data } = await apiClient.put(
+ `/clients/${clientId}`,
+ payload,
+ );
+ return data;
+}
+
+export async function deleteClient(clientId: string) {
+ await apiClient.delete(`/clients/${clientId}`);
+}
+
export async function fetchConsents(subject: string, clientId?: string) {
const params: Record = { subject };
if (clientId) {
diff --git a/devfront/vite.config.ts b/devfront/vite.config.ts
index fdf61f82..9c864fd2 100644
--- a/devfront/vite.config.ts
+++ b/devfront/vite.config.ts
@@ -8,7 +8,7 @@ export default defineConfig({
host: "0.0.0.0", // Ensure binding to all interfaces
proxy: {
"/api": {
- target: process.env.API_PROXY_TARGET || "http://baron_backend:3000",
+ target: process.env.API_PROXY_TARGET || "http://localhost:3000",
changeOrigin: true,
},
},