첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
node_modules
dist
.git
.gitignore
*.md
docker-compose.yml
.env*
npm-debug.log

24
baron-sso/orgfront/.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?

View File

@@ -0,0 +1,38 @@
FROM node:lts AS build
WORKDIR /workspace
ENV CI=true
ENV ORGFRONT_BUILD_OUT_DIR=/workspace/orgfront/dist
RUN corepack enable && corepack prepare pnpm@10.5.2 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY common ./common
COPY orgfront ./orgfront
ARG VITE_ORGFRONT_PUBLIC_URL
ARG VITE_OIDC_AUTHORITY
ARG VITE_OIDC_CLIENT_ID
ENV VITE_ORGFRONT_PUBLIC_URL=$VITE_ORGFRONT_PUBLIC_URL
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY
ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
RUN pnpm install --frozen-lockfile --ignore-scripts
WORKDIR /workspace/orgfront
RUN npm run build
FROM node:24-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
ENV FRONTEND_DIST_DIR=/app/dist
ENV PORT=5175
COPY scripts/serve_frontend_prod.mjs ./serve_frontend_prod.mjs
COPY --from=build /workspace/orgfront/dist ./dist
EXPOSE 5175
CMD ["node", "./serve_frontend_prod.mjs"]

View File

@@ -0,0 +1,20 @@
FROM node:lts
WORKDIR /app
# 패키지 정보 복사 및 의존성 설치
COPY package*.json ./
RUN npm ci
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
RUN npm install -g serve
# 소스 코드 복사
COPY . .
# Vite 기본 포트
EXPOSE 5173
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
RUN chmod +x ./scripts/runtime-mode.sh
CMD ["sh", "./scripts/runtime-mode.sh"]

View File

@@ -0,0 +1,106 @@
# Baron OrgChart (바론 조직도 서비스)
Baron SSO 시스템과 연동되는 독립적인 **조직도 시각화(Organization Chart) 웹 애플리케이션**입니다. 기존 관리자 시스템(Adminfront)에 종속되어 있던 기능을 별도의 서비스(RP: Relying Party)로 분리하여 구축했습니다.
## 🌟 주요 기능
* **독립된 SSO 인증 (Ory Hydra):** Baron SSO(Ory 기반)를 통한 안전한 OAuth2/OIDC 로그인 지원
* **딥링크(Deep Link) 지원:** 특정 부서의 조직도로 바로 접근 가능한 공유 링크 (`/chart/:tenantId` 또는 `/chart/:slug`) 지원
* **고급 트리뷰 디자인:** 밝은 테마 기반의 직관적인 계층형 트리뷰 제공
* **Keto ReBAC 권한 연동 (예정):** 사용자의 소속 부서 및 권한 레벨에 따라 열람 가능한 조직도가 동적으로 제어됩니다.
## 🛠️ 기술 스택
* **프레임워크:** React 19 + Vite + TypeScript
* **스타일링:** Tailwind CSS
* **인증 연동:** `react-oidc-context`, `oidc-client-ts`
* **상태 관리:** React Query (`@tanstack/react-query`)
* **아이콘:** Lucide React
## 🧩 조직도 데이터 연동 구조
조직도 화면은 Baron Admin에서 관리한 **테넌트(Tenant)**와 **사용자(User)** 정보를 Backend API로 받아와 프론트에서 트리 구조로 조립합니다. Adminfront 화면에 직접 의존하지 않고, 동일한 Admin API 데이터를 사용하는 독립 RP로 동작합니다.
### 인증 및 API 호출
* 모든 인증 화면은 `react-oidc-context`를 통해 OIDC Access Token을 얻고, API 요청 시 `Authorization: Bearer <access_token>` 헤더를 붙입니다.
* 사용자가 작업 테넌트를 선택한 경우 `localStorage.dev_tenant_id` 값을 `X-Tenant-ID` 헤더로 함께 전달합니다.
* API Base URL은 `VITE_DEV_API_BASE`, `VITE_ADMIN_API_BASE`, `/api` 순서로 결정됩니다.
### 일반 조직도 화면
`/chart``/chart/:tenantId`는 로그인된 사용자를 대상으로 다음 API를 호출합니다.
| 용도 | API | 주요 사용 필드 |
| --- | --- | --- |
| 테넌트 목록 | `GET /v1/admin/tenants?limit=10000&offset=0` | `id`, `type`, `name`, `slug`, `parentId`, `memberCount`, `status` |
| 사용자 목록 | `GET /v1/admin/users?limit=5000&offset=0` | `id`, `email`, `name`, `status`, `tenantSlug`, `companyCode`, `joinedTenants`, `grade`, `position`, `jobTitle` |
테넌트는 Baron Admin에서 입력한 `parentId` 관계를 기준으로 트리로 변환합니다. 현재 루트 후보는 `type === "COMPANY_GROUP"`인 테넌트를 우선 사용하고, 없으면 최상위 테넌트를 사용합니다. 회사 필터는 루트 하위의 `type === "COMPANY"` 테넌트로 구성됩니다.
사용자는 다음 순서로 조직도 노드에 매핑됩니다.
1. `status === "active"`인 사용자만 사용합니다.
2. `@hanmac.kr` 이메일은 현재 조직도 표시 대상에서 제외합니다.
3. 사용자의 `companyCode`가 있으면 해당 값을 테넌트 `slug`와 매칭합니다.
4. `companyCode`가 없으면 `tenantSlug`를 사용합니다.
5. `joinedTenants`가 있으면 각 joined tenant의 `slug`에도 같은 사용자를 추가합니다.
6. 같은 테넌트 노드 안에서 동일 사용자 `id`는 중복 추가하지 않습니다.
각 조직도 노드는 테넌트명(`name`)을 헤더로 사용하고, 해당 테넌트 `slug`에 매핑된 사용자를 구성원 목록으로 표시합니다. 구성원은 직책(`position`) 조직장 여부와 직급(`grade`) 기준으로 정렬되며, 사용자 표시는 `이름 직급(직책)` 형식을 우선 사용하고 직책이 없으면 직무(`jobTitle`)를 보조 표시로 사용합니다.
### 공유 조직도 화면
`/chart?token=<share-token>` 형태의 공유 링크는 인증 체크를 건너뛰고 다음 공개 API만 호출합니다.
| 용도 | API | 주요 사용 필드 |
| --- | --- | --- |
| 공유 조직도 | `GET /v1/public/orgchart?token=<share-token>` | `tenants`, `users`, `sharedWith` |
공개 API 응답의 `tenants``users`는 일반 조직도와 같은 방식으로 트리 및 구성원 목록에 매핑됩니다. 단, 공개 응답은 서버가 공유 범위에 맞게 이미 필터링한 데이터로 간주합니다.
### 조직 선택기
`/picker``/embed/picker`도 Baron Admin의 테넌트/사용자 데이터를 사용합니다.
* `GET /v1/admin/tenants`
* `GET /v1/admin/users`
선택기는 테넌트 노드와 사용자 노드를 함께 보여주며, 사용자는 `companyCode || tenantSlug`와 테넌트 `slug`를 기준으로 배치합니다. 임베딩 모드에서는 선택 결과를 `postMessage`로 부모 화면에 전달합니다.
## 🚀 로컬 환경 실행 가이드
### 1. 저장소 복제 및 의존성 설치
```bash
git clone https://gitea.hmac.kr/baron/baron-orgchart.git
cd baron-orgchart
npm install
```
### 2. 환경 변수 설정
프로젝트 루트에 `.env.local` 파일을 생성하고 아래 환경 변수를 설정합니다. (개발 환경 기준)
```env
# Baron SSO(Gateway)의 OIDC 엔드포인트
VITE_OIDC_AUTHORITY=http://localhost:5000/oidc
# Hydra에 등록된 Client ID
VITE_OIDC_CLIENT_ID=orgfront
# Backend API 주소
VITE_ADMIN_API_BASE=http://localhost:5000/api
```
### 3. 개발 서버 실행
```bash
npm run dev
```
기본적으로 `http://localhost:5175` 에서 실행됩니다.
## 🔗 관련 문서 및 이슈
* [조직도 임베딩 토큰 설계](docs/orgchart-embedding-token-design.md)
* [Issue #544: 조직도 탭 분리 및 스타일 변경](https://gitea.hmac.kr/baron/baron-sso/issues/544)
* [Issue #545: 조직도 권한 설정](https://gitea.hmac.kr/baron/baron-sso/issues/545)
---
*Powered by Baron SSO*

View File

@@ -0,0 +1,7 @@
{
"root": true,
"extends": ["../common/config/biome.base.json"],
"files": {
"includes": [".vite"]
}
}

View File

@@ -0,0 +1,19 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: baron-orgchart
ports:
- "5175:5175"
environment:
- APP_ENV=development
- VITE_OIDC_AUTHORITY=http://localhost:5000/oidc
- VITE_OIDC_CLIENT_ID=orgfront
- VITE_API_URL=http://localhost:5000/api
- API_PROXY_TARGET=http://localhost:5000
volumes:
- .:/app
- /app/node_modules
stdin_open: true
tty: true

View File

@@ -0,0 +1,125 @@
# 조직도 임베딩 토큰 설계
## 목적
조직도 임베딩 검증 화면은 외부 서비스가 Baron OrgChart의 조직 선택기 또는 조직도 데이터를 안전하게 사용할 수 있는지를 확인하는 도구입니다. 토큰 설계는 다음 두 가지 요구를 동시에 다룹니다.
* 최상위 테넌트 권한 범위에 맞춰 조직도 접근 토큰을 발급한다.
* 특정 서비스가 장기간 안정적으로 임베딩할 수 있도록 서비스 전용 토큰을 생성하고 갱신한다.
## 방식 A: 최상위 테넌트 권한 기반 토큰
이 방식은 로그인한 사용자의 현재 권한을 기준으로 단기 공유 토큰을 발급합니다. 관리자가 `COMPANY_GROUP` 또는 최상위 테넌트 범위를 선택하면, 서버는 사용자가 해당 범위를 볼 수 있는지 확인한 뒤 제한된 수명의 토큰을 반환합니다.
### 권장 API
```http
POST /v1/orgchart/embed-tokens
Authorization: Bearer <access_token>
X-Tenant-ID: <current-tenant-id>
Content-Type: application/json
```
```json
{
"scopeType": "tenant_root",
"rootTenantId": "group-hmac",
"allowedModes": ["single", "multiple"],
"allowedSelectableTypes": ["tenant", "user", "both"],
"expiresInSeconds": 3600
}
```
### 서버 검증
* 요청자는 `rootTenantId` 또는 그 상위 범위를 관리할 수 있어야 합니다.
* 토큰에는 `rootTenantId`, 허용 선택 모드, 허용 선택 대상, 만료 시각을 포함합니다.
* 토큰은 서버 저장형 opaque token을 우선 권장합니다. 즉시 폐기와 감사 로그 추적이 쉽기 때문입니다.
* JWT를 사용할 경우 `jti`를 저장해 폐기 목록을 운영해야 합니다.
### 장점
* 현재 관리자 권한 모델과 자연스럽게 연결됩니다.
* 단기 검증, 데모, 운영자 테스트에 적합합니다.
* 토큰 범위가 명확해 권한 사고 범위를 줄일 수 있습니다.
### 한계
* 서비스가 장기간 임베딩하려면 사용자가 주기적으로 재발급해야 합니다.
* 외부 서비스 단위의 소유자, 회전 주기, 폐기 정책을 별도로 관리하기 어렵습니다.
## 방식 B: 특정 서비스용 토큰 생성 및 갱신
이 방식은 Baron Admin 또는 OrgFront 관리 화면에서 외부 서비스별 임베딩 클라이언트를 등록하고, 각 서비스에 장기 토큰 또는 회전 가능한 토큰 세트를 발급합니다.
### 권장 모델
```json
{
"id": "embed-client-001",
"serviceName": "crm-dashboard",
"ownerTenantId": "group-hmac",
"rootTenantId": "company-baron",
"allowedOrigins": ["https://crm.example.com"],
"allowedModes": ["single", "multiple"],
"allowedSelectableTypes": ["user"],
"status": "active",
"expiresAt": "2026-07-31T00:00:00.000Z",
"lastRotatedAt": "2026-04-29T00:00:00.000Z"
}
```
### 권장 API
```http
POST /v1/admin/orgchart/embed-clients
GET /v1/admin/orgchart/embed-clients
POST /v1/admin/orgchart/embed-clients/{clientId}/rotate-token
POST /v1/admin/orgchart/embed-clients/{clientId}/revoke
GET /v1/public/orgchart/embed-config?token=<embed-token>
```
### 서버 검증
* 서비스 토큰은 특정 `rootTenantId` 이하 데이터에만 접근할 수 있어야 합니다.
* `allowedOrigins`가 설정된 경우 `Origin` 헤더를 검증합니다.
* 토큰 회전 시 기존 토큰과 신규 토큰이 함께 유효한 grace period를 짧게 둘 수 있습니다.
* 모든 생성, 갱신, 폐기, 사용 이벤트는 감사 로그에 남깁니다.
* 토큰 원문은 최초 생성 또는 회전 직후에만 보여주고, 서버에는 해시만 저장합니다.
### 장점
* 외부 서비스별 접근 범위, 만료, 회전, 폐기 정책을 독립적으로 관리할 수 있습니다.
* 운영 환경의 장기 임베딩에 적합합니다.
* 감사 로그와 장애 대응이 명확합니다.
### 한계
* 별도 관리 화면과 백엔드 저장 모델이 필요합니다.
* 토큰 회전 정책, Origin 검증, 만료 알림 등 운영 기능의 설계 범위가 커집니다.
## 권장 적용 순서
1. 먼저 방식 A를 구현해 임베딩 검증 화면에서 최상위 테넌트 권한 기반 단기 토큰을 발급합니다.
2. 토큰 payload와 감사 로그 이벤트 스키마를 방식 B에서도 재사용할 수 있게 고정합니다.
3. 외부 서비스 운영 요구가 확정되면 방식 B의 embed client 관리 API와 화면을 추가합니다.
4. 방식 B 도입 후에도 방식 A는 관리자 테스트용 단기 토큰 발급 기능으로 유지합니다.
## 프론트엔드 반영 방향
임베딩 검증 화면은 현재 선택 모드와 선택 대상 조합을 바꿔 iframe을 갱신합니다. 토큰 기능이 추가되면 다음 값을 함께 표시해야 합니다.
* 발급 주체: 현재 사용자 또는 서비스 클라이언트
* 접근 루트: `rootTenantId`
* 허용 Origin
* 허용 선택 모드: `single`, `multiple`
* 허용 선택 대상: `tenant`, `user`, `both`
* 만료 시각과 갱신 가능 여부
iframe URL은 다음 형태를 기준으로 확장합니다.
```text
/embed/picker?token=<embed-token>&mode=multiple&select=both
```
서버는 토큰의 허용 범위와 URL query가 충돌할 경우 더 좁은 범위를 적용하거나 요청을 거절해야 합니다.

View File

@@ -0,0 +1,188 @@
from http.server import BaseHTTPRequestHandler, HTTPServer
from http import cookiejar
import json
import os
import threading
import urllib.parse
import urllib.request
CLIENT_ID = os.environ["CLIENT_ID"]
SUBJECT = os.environ["SUBJECT"]
REDIRECT_URI = os.environ["REDIRECT_URI"]
SCOPE = os.environ["SCOPE"]
STATE = os.environ["STATE"]
NONCE = os.environ["NONCE"]
ADMIN_BASE = os.environ.get("HYDRA_ADMIN_URL", "http://127.0.0.1:4445")
PUBLIC_BASE = os.environ.get("HYDRA_PUBLIC_URL", "http://127.0.0.1:4444")
def _put_json(url: str, payload: dict) -> dict:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, method="PUT")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=5) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
def accept_login(challenge: str) -> str:
url = f"{ADMIN_BASE}/oauth2/auth/requests/login/accept?login_challenge={urllib.parse.quote(challenge)}"
payload = {"subject": SUBJECT, "remember": True, "remember_for": 3600}
data = _put_json(url, payload)
return data.get("redirect_to", "")
def accept_consent(challenge: str) -> str:
url = f"{ADMIN_BASE}/oauth2/auth/requests/consent/accept?consent_challenge={urllib.parse.quote(challenge)}"
payload = {"grant_scope": ["openid", "profile", "email"], "remember": True, "remember_for": 3600}
data = _put_json(url, payload)
return data.get("redirect_to", "")
def _location_from_response(url: str, cookie_header: str | None) -> str:
req = urllib.request.Request(url, method="GET")
if cookie_header:
req.add_header("Cookie", cookie_header)
opener = urllib.request.build_opener(NoRedirect())
try:
opener.open(req, timeout=5)
except urllib.error.HTTPError as err:
return err.headers.get("Location", "")
return ""
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(newurl, code, msg, headers, fp)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
login_challenge = (params.get("login_challenge") or [""])[0]
consent_challenge = (params.get("consent_challenge") or [""])[0]
login_verifier = (params.get("login_verifier") or [""])[0]
consent_verifier = (params.get("consent_verifier") or [""])[0]
if parsed.path == "/oauth2/auth" and consent_verifier:
query = urllib.parse.urlencode({
"consent_verifier": consent_verifier,
"client_id": (params.get("client_id") or [""])[0],
"redirect_uri": (params.get("redirect_uri") or [""])[0],
"response_type": (params.get("response_type") or [""])[0],
"scope": (params.get("scope") or [""])[0],
"state": (params.get("state") or [""])[0],
"nonce": (params.get("nonce") or [""])[0],
})
public_url = f"{PUBLIC_BASE}/oauth2/auth?{query}"
location = _location_from_response(public_url, self.headers.get("Cookie"))
print(f"consent_verifier_location={location}")
if not location:
self.send_response(400)
self.end_headers()
self.wfile.write(b"missing redirect location")
return
self.send_response(302)
self.send_header("Location", location)
self.end_headers()
return
if parsed.path == "/oauth2/auth" and login_verifier:
query = urllib.parse.urlencode({
"login_verifier": login_verifier,
"client_id": (params.get("client_id") or [""])[0],
"redirect_uri": (params.get("redirect_uri") or [""])[0],
"response_type": (params.get("response_type") or [""])[0],
"scope": (params.get("scope") or [""])[0],
"state": (params.get("state") or [""])[0],
"nonce": (params.get("nonce") or [""])[0],
})
public_url = f"{PUBLIC_BASE}/oauth2/auth?{query}"
location = _location_from_response(public_url, self.headers.get("Cookie"))
print(f"login_verifier_location={location}")
if not location:
self.send_response(400)
self.end_headers()
self.wfile.write(b"missing redirect location")
return
consent_challenge = urllib.parse.parse_qs(urllib.parse.urlparse(location).query).get(
"consent_challenge",
[""],
)[0]
if not consent_challenge:
self.send_response(400)
self.end_headers()
self.wfile.write(f"missing consent_challenge location={location}".encode("utf-8"))
return
redirect_to = accept_consent(consent_challenge)
if not redirect_to:
self.send_response(500)
self.end_headers()
self.wfile.write(b"consent accept failed")
return
self.send_response(302)
self.send_header("Location", redirect_to)
self.end_headers()
return
if login_challenge:
redirect_to = accept_login(login_challenge)
elif consent_challenge:
redirect_to = accept_consent(consent_challenge)
else:
redirect_to = ""
if not redirect_to:
self.send_response(400)
self.end_headers()
self.wfile.write(b"missing challenge")
return
self.send_response(302)
self.send_header("Location", redirect_to)
self.end_headers()
def log_message(self, format, *args):
return
class StopAtRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
if newurl.startswith(REDIRECT_URI):
raise urllib.error.HTTPError(newurl, code, msg, headers, fp)
return super().redirect_request(req, fp, code, msg, headers, newurl)
def main():
server = HTTPServer(("127.0.0.1", 3000), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
encoded_redirect = urllib.parse.quote(REDIRECT_URI, safe="")
encoded_scope = urllib.parse.quote(SCOPE, safe="")
auth_url = (
f"{PUBLIC_BASE}/oauth2/auth?response_type=code"
f"&client_id={CLIENT_ID}"
f"&redirect_uri={encoded_redirect}"
f"&scope={encoded_scope}"
f"&state={STATE}"
f"&nonce={NONCE}"
)
jar = cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar), StopAtRedirect())
try:
opener.open(auth_url, timeout=10)
except urllib.error.HTTPError as err:
body = err.read().decode("utf-8") if hasattr(err, "read") else ""
print(f"error_url={err.geturl()}")
print(f"error_code={err.code}")
if body:
print(f"error_body={body}")
finally:
server.shutdown()
if __name__ == "__main__":
main()

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>

5636
baron-sso/orgfront/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
{
"name": "orgfront",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"build:org-context-chart": "node scripts/build-org-context-chart.mjs",
"build:org-context-chart:full": "vite build --config vite.org-context-chart.config.ts",
"build:org-context-chart:min": "ORG_CONTEXT_CHART_MINIFY=true vite build --config vite.org-context-chart.config.ts",
"lint": "biome check .",
"preview": "vite preview",
"test": "playwright test",
"test:coverage": "vitest run --coverage --bail 1",
"test:unit": "vitest run --bail 1",
"test:roles": "playwright test tests/devfront-role-switch-report.spec.ts",
"test:ui": "playwright test --ui"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-query-devtools": "^5.100.10",
"@xyflow/react": "^12.10.2",
"axios": "^1.16.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"oidc-client-ts": "^3.5.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.75.0",
"react-oidc-context": "^3.3.1",
"react-router-dom": "^7.15.0",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@playwright/test": "^1.60.0",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "4.1.6",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"jsdom": "^28.1.0",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"typescript": "^6.0.3",
"vite": "^8.0.14",
"vitest": "4.1.6"
}
}

View File

@@ -0,0 +1,89 @@
import { createRequire } from "node:module";
import { defineConfig, devices } from "@playwright/test";
const require = createRequire(import.meta.url);
const { shouldIncludeWebKit } =
require("../scripts/playwrightHostDeps.cjs") as {
shouldIncludeWebKit: () => boolean;
};
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
const port = Number.parseInt(process.env.PORT ?? "4175", 10);
const defaultBaseUrl = `http://127.0.0.1:${port}`;
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
const reuseExistingServer = !process.env.CI && !process.env.PORT;
const testOidcAuthority = "http://localhost:5000/oidc";
/**
* 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",
testMatch: [
"**/light-theme.spec.ts",
"**/orgchart-*.spec.ts",
"**/orgfront-*.spec.ts",
],
/* 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: configuredWorkers ?? (process.env.CI ? 1 : undefined),
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [["html", { open: "never" }], ["list"]],
/* 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,
/* 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"] },
},
...(shouldIncludeWebKit()
? [
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
]
: []),
],
/* Run your local dev server before starting the tests */
webServer: process.env.BASE_URL
? undefined
: {
command: process.env.CI
? `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run build && VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run preview -- --host 127.0.0.1 --port ${port}`
: `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run dev -- --host 127.0.0.1 --port ${port}`,
url: defaultBaseUrl,
reuseExistingServer,
timeout: 120 * 1000,
},
});

3635
baron-sso/orgfront/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

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,29 @@
import { spawnSync } from "node:child_process";
const buildId = createBuildId();
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const env = {
...process.env,
ORG_CONTEXT_CHART_BUILD_ID: buildId,
};
for (const script of [
"build:org-context-chart:full",
"build:org-context-chart:min",
]) {
const result = spawnSync(npmCommand, ["run", script], {
env,
stdio: "inherit",
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function createBuildId() {
const now = new Date();
const year = String(now.getFullYear()).slice(-2);
const month = String(now.getMonth() + 1).padStart(2, "0");
const random = String(Math.floor(Math.random() * 10000)).padStart(4, "0");
return `${year}${month}${random}`;
}

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env sh
set -eu
app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')"
if [ -z "${VITE_ORGFRONT_PUBLIC_URL:-}" ] && [ -n "${ORGFRONT_URL:-}" ]; then
export VITE_ORGFRONT_PUBLIC_URL="$ORGFRONT_URL"
fi
if [ -z "${VITE_ORGFRONT_PUBLIC_URL:-}" ] && [ -n "${ORGFRONT_CALLBACK_URLS:-}" ]; then
first_orgfront_callback="${ORGFRONT_CALLBACK_URLS%%,*}"
case "$first_orgfront_callback" in
http://*/auth/callback | https://*/auth/callback)
export VITE_ORGFRONT_PUBLIC_URL="${first_orgfront_callback%/auth/callback}"
;;
esac
fi
case "$app_env" in
production|prod|stage|staging)
mode="production"
;;
*)
mode="development"
;;
esac
if [ "${1:-}" = "--print-public-url" ]; then
printf '%s\n' "${VITE_ORGFRONT_PUBLIC_URL:-}"
exit 0
fi
if [ "${1:-}" = "--print-mode" ]; then
printf '%s\n' "$mode"
exit 0
fi
ensure_frontend_dependencies() {
APP_PACKAGE_NAME="orgfront"
# Detect workspace root
if [ -f "/workspace/pnpm-workspace.yaml" ]; then
WORKSPACE_ROOT="/workspace"
elif [ -f "../../pnpm-workspace.yaml" ]; then
WORKSPACE_ROOT="../.."
else
WORKSPACE_ROOT=""
fi
# Manage dependencies from the real workspace tree if possible, otherwise use current dir.
if [ -n "$WORKSPACE_ROOT" ]; then
WORKSPACE_DIR="$WORKSPACE_ROOT"
LOCK_FILE="$WORKSPACE_ROOT/pnpm-lock.yaml"
COMMON_PACKAGE_FILE="$WORKSPACE_ROOT/common/package.json"
INSTALL_CMD="cd $WORKSPACE_ROOT && CI=true pnpm install --filter ${APP_PACKAGE_NAME}... --frozen-lockfile --ignore-scripts"
elif [ -f "pnpm-lock.yaml" ]; then
WORKSPACE_DIR="."
LOCK_FILE="pnpm-lock.yaml"
COMMON_PACKAGE_FILE="/workspace/common/package.json"
INSTALL_CMD="CI=true pnpm install --frozen-lockfile --ignore-scripts"
else
WORKSPACE_DIR="."
LOCK_FILE="package-lock.json"
COMMON_PACKAGE_FILE="/workspace/common/package.json"
INSTALL_CMD="npm ci"
fi
if [ ! -f "$WORKSPACE_DIR/package.json" ]; then
return 0
fi
lock_mode=""
lock_file="$WORKSPACE_DIR/.baron-deps-install.lock"
acquire_install_lock() {
if command -v flock >/dev/null 2>&1; then
lock_mode="flock"
exec 9>"$lock_file"
flock 9
trap 'release_install_lock' EXIT INT TERM
return 0
fi
lock_mode="mkdir"
while ! mkdir "$lock_file" 2>/dev/null; do
sleep 1
done
trap 'release_install_lock' EXIT INT TERM
}
release_install_lock() {
trap - EXIT INT TERM
if [ "$lock_mode" = "flock" ]; then
flock -u 9 || true
exec 9>&-
return 0
fi
if [ "$lock_mode" = "mkdir" ]; then
rmdir "$lock_file" >/dev/null 2>&1 || true
fi
}
if command -v sha256sum >/dev/null 2>&1; then
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
else
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
fi
deps_stamp="node_modules/.baron-deps-hash"
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
if [ "$installed_hash" != "$deps_hash" ]; then
echo "Installing frontend dependencies..."
acquire_install_lock
if command -v sha256sum >/dev/null 2>&1; then
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
else
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
fi
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
if [ "$installed_hash" = "$deps_hash" ]; then
release_install_lock
return 0
fi
eval "$INSTALL_CMD"
mkdir -p node_modules
printf '%s\n' "$deps_hash" > "$deps_stamp"
release_install_lock
fi
}
ensure_frontend_dependencies
if [ "$mode" = "production" ]; then
echo "Running in production mode with Vite preview..."
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0 --port 5175"
fi
echo "Running in development mode..."
exec npm run dev -- --host 0.0.0.0 --port 5175

View File

@@ -0,0 +1,7 @@
import { QueryClient } from "@tanstack/react-query";
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
export const queryClient = new QueryClient({
defaultOptions: queryClientDefaultOptions,
});

View File

@@ -0,0 +1,13 @@
import { matchRoutes } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { ORGFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
import { orgFrontRoutes } from "./routes";
describe("orgfront routes", () => {
it("accepts the auth callback path used by the OIDC redirect URI", () => {
const matches = matchRoutes(orgFrontRoutes, ORGFRONT_AUTH_CALLBACK_PATH);
expect(matches).not.toBeNull();
expect(matches?.at(-1)?.route.path).toBe(ORGFRONT_AUTH_CALLBACK_PATH);
});
});

View File

@@ -0,0 +1,50 @@
import {
createBrowserRouter,
Navigate,
type RouteObject,
} from "react-router-dom";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthGuard from "../features/auth/AuthGuard";
import LoginPage from "../features/auth/LoginPage";
import { TenantOrgChartPage } from "../features/orgchart/routes/OrgChartPage";
import { OrgFrontLayout } from "../features/orgchart/routes/OrgFrontLayout";
import { OrgPickerEmbedPreviewPage } from "../features/orgchart/routes/OrgPickerEmbedPreviewPage";
import {
OrgPickerEmbedPage,
OrgPickerPage,
} from "../features/orgchart/routes/OrgPickerPage";
import { ORGFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
export const orgFrontRoutes: RouteObject[] = [
{
path: "/login",
element: <LoginPage />,
},
{
path: ORGFRONT_AUTH_CALLBACK_PATH,
element: <AuthCallbackPage />,
},
{
path: "/",
element: <AuthGuard />,
children: [
{ index: true, element: <Navigate to="/chart" replace /> },
{
element: <OrgFrontLayout />,
children: [
{ path: "chart", element: <TenantOrgChartPage /> },
{ path: "chart/:tenantId", element: <TenantOrgChartPage /> },
{ path: "picker", element: <OrgPickerPage /> },
{ path: "embed-preview", element: <OrgPickerEmbedPreviewPage /> },
],
},
{ path: "embed/picker", element: <OrgPickerEmbedPage /> },
],
},
];
export const router = createBrowserRouter(orgFrontRoutes, {
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,51 @@
import { ShieldAlert } from "lucide-react";
import { useAuth } from "react-oidc-context";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
interface Props {
resourceToken: "audit" | "clients";
}
export function ForbiddenMessage({ resourceToken }: Props) {
const auth = useAuth();
const rawProfile = auth.user?.profile as Record<string, unknown> | undefined;
const role = resolveProfileRole(rawProfile);
let explanation = t(
"msg.dev.forbidden.default",
"해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요.",
);
if (role === "rp_admin") {
explanation = t(
"msg.dev.forbidden.rp_admin",
"RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다.",
);
} else if (role === "tenant_admin") {
explanation = t(
"msg.dev.forbidden.tenant_admin",
"테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다.",
);
} else if (role === "user" || role === "tenant_member") {
explanation = t(
"msg.dev.forbidden.user",
"일반 사용자는 관리자 화면에 접근할 수 없습니다.",
);
}
const title = t("msg.dev.forbidden.title", "{{resource}} 접근 권한 없음", {
resource:
resourceToken === "audit"
? t("ui.dev.audit.title", "Audit Logs")
: t("ui.dev.clients.registry.subtitle", "연동 앱"),
});
return (
<div className="flex flex-col items-center justify-center p-12 text-center text-red-500/90 gap-3">
<ShieldAlert className="h-10 w-10 text-red-500/80 mb-2" />
<h3 className="text-xl font-bold text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground max-w-md">{explanation}</p>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { useState } from "react";
import { t } from "../../lib/i18n";
const LOCALE_STORAGE_KEY = "locale";
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
function resolveLocale(): Locale {
if (typeof window === "undefined") {
return "ko";
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored === "ko" || stored === "en") {
return stored;
}
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale === "ko" || pathLocale === "en") {
return pathLocale;
}
const browserLang = window.navigator.language.toLowerCase();
return browserLang.startsWith("ko") ? "ko" : "en";
}
function LanguageSelector() {
const [locale, setLocale] = useState<Locale>(resolveLocale());
const handleChange = (next: Locale) => {
if (next === locale) {
return;
}
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
setLocale(next);
if (import.meta.env.MODE === "development") {
window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
return;
}
window.location.reload();
};
return (
<select
value={locale}
onChange={(event) => handleChange(event.target.value as Locale)}
className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"
aria-label={t("ui.common.language", "언어")}
>
<option value="ko">{t("ui.common.language_ko", "한국어")}</option>
<option value="en">{t("ui.common.language_en", "English")}</option>
</select>
);
}
export default LanguageSelector;

View File

@@ -0,0 +1,166 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AppLayout from "./AppLayout";
const authState = {
isAuthenticated: true,
isLoading: false,
activeNavigator: undefined as string | undefined,
error: null as Error | null,
user: {
access_token: "access-token",
expires_at: Math.floor(Date.now() / 1000) + 120,
profile: {
sub: "user-1",
name: "Org Admin",
email: "org@example.com",
role: "super_admin",
},
},
signinSilent: vi.fn(),
removeUser: vi.fn(),
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("../../features/auth/authApi", () => ({
fetchMe: vi.fn(async () => ({
id: "user-1",
name: "Fetched Org Admin",
email: "fetched@example.com",
role: "super_admin",
})),
}));
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
}));
const roots: Root[] = [];
beforeEach(() => {
authState.isAuthenticated = true;
authState.isLoading = false;
authState.activeNavigator = undefined;
authState.error = null;
authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
authState.signinSilent.mockReset();
authState.signinSilent.mockResolvedValue(undefined);
authState.removeUser.mockReset();
window.localStorage.clear();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
});
async function renderLayout(initialEntry = "/clients") {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/" element={<AppLayout />}>
<Route path="clients" element={<div>Client outlet</div>} />
<Route path="profile" element={<div>Profile outlet</div>} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("orgfront AppLayout", () => {
it("renders shell navigation, profile summary, and outlet content", async () => {
const container = await renderLayout();
expect(container.textContent).toContain("Developer Console");
expect(container.textContent).toContain("Clients");
expect(container.textContent).toContain("Client outlet");
expect(container.textContent).toContain("Fetched Org Admin");
expect(document.documentElement.classList.contains("light")).toBe(true);
});
it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
const container = await renderLayout();
const themeButton = container.querySelector(
'button[aria-label="테마 전환"]',
) as HTMLButtonElement;
await act(async () => {
themeButton.click();
});
expect(document.documentElement.classList.contains("dark")).toBe(true);
const profileButton = container.querySelector(
'button[aria-label="계정 메뉴 열기"]',
) as HTMLButtonElement;
await act(async () => {
profileButton.click();
});
expect(container.textContent).toContain("Account");
const profileMenuItem = Array.from(
container.querySelectorAll('button[role="menuitem"]'),
).find((button) => button.textContent?.includes("내 정보"));
await act(async () => {
(profileMenuItem as HTMLButtonElement).click();
});
expect(container.textContent).toContain("Profile outlet");
const logoutButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Logout"),
);
await act(async () => {
(logoutButton as HTMLButtonElement).click();
});
expect(window.confirm).toHaveBeenCalled();
expect(authState.removeUser).toHaveBeenCalled();
});
it("attempts silent renewal after user action when the session is expiring", async () => {
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
await renderLayout();
await act(async () => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
});
expect(authState.signinSilent).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,571 @@
import { useQuery } from "@tanstack/react-query";
import {
BadgeCheck,
ChevronDown,
LogOut,
Moon,
NotebookTabs,
ShieldHalf,
Sun,
User as UserIcon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellTheme,
type ShellTranslator,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
} from "../../../../common/shell";
import { fetchMe } from "../../features/auth/authApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import LanguageSelector from "../common/LanguageSelector";
import { Toaster } from "../ui/toaster";
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
const navItems = [
{
labelKey: "ui.dev.nav.clients",
labelFallback: "Clients",
to: "/clients",
icon: ShieldHalf,
},
{
labelKey: "ui.dev.nav.audit_logs",
labelFallback: "Audit Logs",
to: "/audit-logs",
icon: NotebookTabs,
},
];
type SessionStatusProps = {
expiresAtSec?: number | null;
t: ShellTranslator;
};
function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
return buildShellSessionStatus({ expiresAtSec, nowMs, t });
}
function SessionStatusBadge(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return (
<span
className={[
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionStatus.text}
</span>
);
}
function SessionStatusText(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return <>{sessionStatus.text}</>;
}
function AppLayout() {
const auth = useAuth();
const location = useLocation();
const navigate = useNavigate();
const profileMenuRef = useRef<HTMLDivElement>(null);
const isRenewInFlightRef = useRef(false);
const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(null);
const isDevelopmentRuntime = import.meta.env.MODE === "development";
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [, setDevelopmentRenderRevision] = useState(0);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
);
const hasAccessToken = Boolean(auth.user?.access_token);
const { data: profile } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const handleLogout = () => {
if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) {
auth.removeUser();
navigate("/login");
}
};
useEffect(() => {
applyShellTheme(theme);
}, [theme]);
useEffect(() => {
if (!isDevelopmentRuntime) {
return;
}
const rerenderDevelopmentShell = () => {
setDevelopmentRenderRevision((value) => value + 1);
};
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
return () => {
window.removeEventListener(
LOCALE_CHANGED_EVENT,
rerenderDevelopmentShell,
);
};
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
profileMenuRef.current &&
!profileMenuRef.current.contains(event.target as Node)
) {
setIsProfileMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
useEffect(() => {
const maybeRenewSession = async () => {
const now = Date.now();
if (
!shouldAttemptSlidingSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
try {
await auth.signinSilent();
} catch (error) {
console.error("세션 자동 연장에 실패했습니다.", error);
} finally {
isRenewInFlightRef.current = false;
}
};
const handleUserAction = () => {
void maybeRenewSession();
};
window.addEventListener("pointerdown", handleUserAction);
window.addEventListener("keydown", handleUserAction);
return () => {
window.removeEventListener("pointerdown", handleUserAction);
window.removeEventListener("keydown", handleUserAction);
};
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
]);
useEffect(() => {
if (isDevelopmentRuntime) {
return;
}
const maybeKeepSessionAlive = async () => {
const now = Date.now();
if (
!shouldAttemptUnlimitedSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
try {
await auth.signinSilent();
} catch (error) {
console.error("세션 무제한 유지 갱신에 실패했습니다.", error);
} finally {
isRenewInFlightRef.current = false;
}
};
const timer = window.setInterval(() => {
void maybeKeepSessionAlive();
}, 30_000);
void maybeKeepSessionAlive();
return () => {
window.clearInterval(timer);
};
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
]);
useEffect(() => {
const routeKey = `${location.pathname}${location.search}${location.hash}`;
if (lastVisitedRouteRef.current === null) {
lastVisitedRouteRef.current = routeKey;
return;
}
if (lastVisitedRouteRef.current === routeKey) {
return;
}
lastVisitedRouteRef.current = routeKey;
const now = Date.now();
if (
!shouldAttemptSlidingSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
void auth
.signinSilent()
.catch((error) => {
console.error("세션 자동 연장에 실패했습니다.", error);
})
.finally(() => {
isRenewInFlightRef.current = false;
});
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
location.hash,
location.pathname,
location.search,
]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
const profileSummary = buildShellProfileSummary({
profileName:
profile?.name ||
auth.user?.profile?.name?.toString() ||
auth.user?.profile?.preferred_username?.toString() ||
auth.user?.profile?.nickname?.toString(),
profileEmail: profile?.email || auth.user?.profile?.email?.toString(),
fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"),
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
});
const currentRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
const displayRoleKey = profile?.role || currentRole;
const isDevConsoleAllowed = [
"super_admin",
"tenant_admin",
"rp_admin",
].includes(currentRole);
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
writeShellSessionExpiryEnabled(next);
return next;
});
};
return (
<div className={shellLayoutClasses.root}>
<aside className={shellLayoutClasses.aside}>
<div>
<div className={shellLayoutClasses.brandSection}>
<div className={shellLayoutClasses.brandWrap}>
<div className={shellLayoutClasses.brandIcon}>
<ShieldHalf size={20} />
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t("ui.dev.brand", "Baron 로그인")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.dev.console_title", "Developer Console")}
</h1>
</div>
</div>
<div className={shellLayoutClasses.scopeBadge}>
<BadgeCheck size={14} />
{t("ui.dev.scope_badge", "Scoped to /dev")}
</div>
</div>
<nav className={shellLayoutClasses.navWrap}>
<div className={shellLayoutClasses.navMeta}>
<span className="rounded-full border border-border px-3 py-1">
{t("ui.dev.env_badge", "Env: dev")}
</span>
</div>
<div className={shellLayoutClasses.navList}>
{isDevConsoleAllowed &&
navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
shellLayoutClasses.navItemBase,
isActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</NavLink>
))}
</div>
</nav>
</div>
<div>
<div className="border-t border-border/50 px-3 pt-4">
<button
type="button"
onClick={handleLogout}
className={shellLayoutClasses.logoutButton}
>
<LogOut size={18} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
</button>
</div>
<div className={shellLayoutClasses.sidebarFooterNotice}>
<p>{t("msg.dev.sidebar.notice", "Developer Console")}</p>
<p>
{t(
"msg.dev.sidebar.notice_detail",
"Register and manage client applications.",
)}
</p>
</div>
</div>
</aside>
<div className={shellLayoutClasses.content}>
<header className={shellLayoutClasses.header}>
<div className={shellLayoutClasses.headerInner}>
<div className={shellLayoutClasses.headerTitleWrap}>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{t("ui.dev.header.plane", "Dev Plane")}
</p>
<span className="text-lg font-semibold">
{t("ui.dev.header.subtitle", "Manage your applications")}
</span>
</div>
<div className={shellLayoutClasses.headerActions}>
<LanguageSelector />
<button
type="button"
onClick={toggleTheme}
className={shellLayoutClasses.actionButton}
aria-label={t("ui.common.theme_toggle", "테마 전환")}
>
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
{theme === "light"
? t("ui.common.theme_light", "Light")
: t("ui.common.theme_dark", "Dark")}
</button>
{isSessionExpiryEnabled ? (
<SessionStatusBadge
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : null}
<div className="relative" ref={profileMenuRef}>
<button
type="button"
onClick={() => setIsProfileMenuOpen((prev) => !prev)}
className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
aria-haspopup="menu"
aria-expanded={isProfileMenuOpen}
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
>
<div className={shellLayoutClasses.profileInitial}>
{profileSummary.initial}
</div>
<div className="hidden min-w-0 text-left md:block">
<p className="truncate text-xs font-medium text-foreground">
{profileSummary.name}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{profileSummary.email}
</p>
</div>
<ChevronDown
size={14}
className={`transition-transform duration-200 ${isProfileMenuOpen ? "rotate-180" : ""}`}
/>
</button>
{isProfileMenuOpen ? (
<div role="menu" className={shellLayoutClasses.profileMenu}>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.dev.profile.menu_title", "Account")}
</p>
<div className={shellLayoutClasses.profileCard}>
<div>
<p className="truncate text-sm font-semibold text-foreground">
{profileSummary.name}
</p>
<p className="truncate text-xs text-muted-foreground">
{profileSummary.email}
</p>
</div>
<div className="flex items-center pt-1">
<span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
{t(
`ui.admin.role.${displayRoleKey}`,
displayRoleKey.toUpperCase(),
)}
</span>
</div>
</div>
<div className={shellLayoutClasses.settingsCard}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled ? (
<SessionStatusText
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : (
t("ui.dev.session.disabled", "세션 만료 비활성화")
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={isSessionExpiryEnabled}
onClick={handleSessionExpiryToggle}
className={[
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition",
isSessionExpiryEnabled ? "bg-primary" : "bg-muted",
].join(" ")}
>
<span
className={[
"inline-block h-5 w-5 rounded-full bg-white transition",
isSessionExpiryEnabled
? "translate-x-5"
: "translate-x-1",
].join(" ")}
/>
</button>
</div>
</div>
<button
type="button"
role="menuitem"
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
onClick={() => {
navigate("/profile");
setIsProfileMenuOpen(false);
}}
>
<UserIcon size={16} className="text-muted-foreground" />
<span>{t("ui.dev.profile.title", "내 정보")}</span>
</button>
<button
type="button"
role="menuitem"
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
onClick={handleLogout}
>
<LogOut size={16} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
</button>
</div>
) : null}
</div>
</div>
</div>
</header>
<main className={shellLayoutClasses.main}>
<Outlet />
</main>
</div>
<Toaster />
</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, AvatarFallback, AvatarImage };

View File

@@ -0,0 +1,21 @@
import type * as React from "react";
import {
type CommonBadgeVariant,
getCommonBadgeClasses,
} from "../../../../common/ui/badge";
import { cn } from "../../lib/utils";
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: CommonBadgeVariant;
}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div
className={cn(getCommonBadgeClasses({ variant }), className)}
{...props}
/>
);
}
export { Badge };

View File

@@ -0,0 +1,95 @@
import type React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
import { Badge } from "./badge";
import { Input } from "./input";
import { Label } from "./label";
import { Separator } from "./separator";
import { Switch } from "./switch";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "./table";
import { Textarea } from "./textarea";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement | null = null;
const render = async (element: React.ReactElement) => {
container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(element);
});
return root;
};
afterEach(() => {
if (container) {
container.remove();
container = null;
}
});
describe("orgfront UI wrappers", () => {
it("renders form, badge, avatar, switch, separator, and table wrappers", async () => {
const root = await render(
<div>
<Badge className="custom-badge" variant="secondary">
Active
</Badge>
<Avatar className="custom-avatar">
<AvatarImage alt="Org user" src="/avatar.png" />
<AvatarFallback>OU</AvatarFallback>
</Avatar>
<Label className="custom-label" htmlFor="name">
Name
</Label>
<Input id="name" className="custom-input" defaultValue="Org User" />
<Textarea className="custom-textarea" defaultValue="Memo" />
<Switch className="custom-switch" defaultChecked />
<Separator className="custom-separator" />
<Table className="custom-table">
<TableCaption>Members</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Org User</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell>Total</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>,
);
expect(container?.textContent).toContain("Active");
expect(container?.textContent).toContain("OU");
expect(container?.querySelector(".custom-input")).not.toBeNull();
expect(container?.querySelector(".custom-switch")).not.toBeNull();
expect(container?.querySelector(".custom-separator")).not.toBeNull();
expect(container?.textContent).toContain("Members");
expect(container?.textContent).toContain("Total");
await act(async () => {
root.unmount();
});
});
});

View File

@@ -0,0 +1,31 @@
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import {
type CommonButtonSize,
type CommonButtonVariant,
getCommonButtonClasses,
} from "../../../../common/ui/button";
import { cn } from "../../lib/utils";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: CommonButtonVariant;
size?: CommonButtonSize;
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(getCommonButtonClasses({ variant, size }), className)}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button };

View File

@@ -0,0 +1,58 @@
import type * as React from "react";
import {
commonCardClass,
commonCardContentClass,
commonCardDescriptionClass,
commonCardFooterClass,
commonCardHeaderClass,
commonCardTitleClass,
} from "../../../../common/ui/card";
import { cn } from "../../lib/utils";
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn(commonCardClass, className)} {...props} />;
}
function CardHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
}
function CardTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
}
function CardDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
}
function CardContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn(commonCardContentClass, className)} {...props} />;
}
function CardFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn(commonCardFooterClass, className)} {...props} />;
}
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};

View File

@@ -0,0 +1,75 @@
import { Check, Copy } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
import { Button, type ButtonProps } from "./button";
interface CopyButtonProps extends ButtonProps {
value: string;
onCopy?: () => void;
}
export function CopyButton({
value,
onCopy,
className,
variant = "secondary",
size = "icon",
...props
}: CopyButtonProps) {
const [hasCopied, setHasCopied] = React.useState(false);
React.useEffect(() => {
if (hasCopied) {
const timer = setTimeout(() => setHasCopied(false), 1500);
return () => clearTimeout(timer);
}
}, [hasCopied]);
const copyToClipboard = async () => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
// Fallback for non-secure contexts (HTTP) or missing navigator.clipboard
const textArea = document.createElement("textarea");
textArea.value = value;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
textArea.style.top = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand("copy");
if (!successful) throw new Error("execCommand copy failed");
} catch (err) {
console.error("Fallback: Oops, unable to copy", err);
throw err;
} finally {
document.body.removeChild(textArea);
}
}
setHasCopied(true);
if (onCopy) onCopy();
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
return (
<Button
size={size}
variant={variant}
className={cn("relative z-10", className)}
onClick={copyToClipboard}
{...props}
>
<span className="sr-only">Copy</span>
{hasCopied ? (
<Check className="h-4 w-4 text-emerald-500 transition-all scale-110" />
) : (
<Copy className="h-4 w-4 transition-all" />
)}
</Button>
);
}

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { commonInputClass } from "../../../../common/ui/input";
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(commonInputClass, 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,102 @@
import * as React from "react";
import {
commonTableBodyClass,
commonTableCaptionClass,
commonTableCellClass,
commonTableClass,
commonTableFooterClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableWrapperClass,
} from "../../../../common/ui/table";
import { cn } from "../../lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className={commonTableWrapperClass}>
<table ref={ref} className={cn(commonTableClass, className)} {...props} />
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn(commonTableHeaderClass, className)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(commonTableFooterClass, className)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn(commonTableCaptionClass, className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
};

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,35 @@
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
import { cn } from "../../lib/utils";
import { useToastState } from "./use-toast";
export function Toaster() {
const toasts = useToastState();
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-[320px]">
{toasts.map((t) => (
<div
key={t.id}
className={cn(
"flex items-center gap-3 rounded-lg border p-4 shadow-lg animate-in slide-in-from-right-full duration-300",
t.type === "success" &&
"bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200",
t.type === "error" &&
"bg-rose-50 border-rose-200 text-rose-800 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-200",
t.type === "info" &&
"bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200",
)}
>
{t.type === "success" && (
<CheckCircle2 className="h-5 w-5 shrink-0" />
)}
{t.type === "error" && <AlertCircle className="h-5 w-5 shrink-0" />}
{t.type === "info" && <Info className="h-5 w-5 shrink-0" />}
<p className="text-sm font-medium leading-none">{t.message}</p>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import * as React from "react";
type ToastType = "success" | "error" | "info";
interface Toast {
id: string;
message: string;
type: ToastType;
}
let subscribers: ((toasts: Toast[]) => void)[] = [];
let toasts: Toast[] = [];
const notify = () => {
for (const sub of subscribers) {
sub(toasts);
}
};
export const toast = (message: string, type: ToastType = "success") => {
const id = Math.random().toString(36).substring(2, 9);
toasts = [...toasts, { id, message, type }];
notify();
setTimeout(() => {
toasts = toasts.filter((t) => t.id !== id);
notify();
}, 3000);
};
export const useToastState = () => {
const [state, setState] = React.useState<Toast[]>(toasts);
React.useEffect(() => {
subscribers.push(setState);
return () => {
subscribers = subscribers.filter((sub) => sub !== setState);
};
}, []);
return state;
};

View File

@@ -0,0 +1,435 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ChevronDown,
ChevronUp,
Copy,
Download,
RefreshCw,
Search,
} from "lucide-react";
import * as React from "react";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import type { DevAuditLog } from "../../lib/devApi";
import { fetchDevAuditLogs } from "../../lib/devApi";
import { t } from "../../lib/i18n";
type AuditDetails = {
request_id?: string;
method?: string;
path?: string;
tenant_id?: string;
action?: string;
target_id?: string;
before?: unknown;
after?: unknown;
error?: string;
};
function parseDetails(details?: string): AuditDetails {
if (!details) {
return {};
}
try {
const parsed = JSON.parse(details);
if (parsed && typeof parsed === "object") {
return parsed as AuditDetails;
}
} catch {}
return {};
}
function formatValue(value: unknown): string {
if (value === null || value === undefined || value === "") {
return "-";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function formatDateTime(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleString("ko-KR");
}
function toCsv(logs: DevAuditLog[]) {
const header = [
"timestamp",
"user_id",
"status",
"event_type",
"action",
"target_id",
"tenant_id",
"request_id",
];
const rows = logs.map((logItem) => {
const details = parseDetails(logItem.details);
return [
logItem.timestamp,
logItem.user_id || "",
logItem.status,
logItem.event_type,
details.action || "",
details.target_id || "",
details.tenant_id || "",
details.request_id || "",
];
});
return [header, ...rows]
.map((line) =>
line.map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(","),
)
.join("\n");
}
function downloadCsv(content: string, filename: string) {
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}
function AuditLogsPage() {
const [searchClientId, setSearchClientId] = React.useState("");
const [searchAction, setSearchAction] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const [expandedRows, setExpandedRows] = React.useState<
Record<string, boolean>
>({});
const query = useInfiniteQuery({
queryKey: ["dev-audit-logs", searchClientId, searchAction, statusFilter],
queryFn: ({ pageParam }) =>
fetchDevAuditLogs(50, pageParam, {
client_id: searchClientId.trim() || undefined,
action: searchAction.trim() || undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const logs =
query.data?.pages.flatMap((page) =>
page.items.filter((item): item is DevAuditLog => Boolean(item)),
) ?? [];
const handleCopy = (value: string) => {
if (!value) {
return;
}
navigator.clipboard.writeText(value);
};
const handleExportCsv = () => {
const csv = toCsv(logs);
const stamp = new Date().toISOString().replaceAll(":", "-");
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
};
if (query.isLoading) {
return (
<div className="p-8 text-center">
{t("msg.dev.audit.loading", "Loading audit logs...")}
</div>
);
}
if (query.error) {
const axiosError = query.error as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
return <ForbiddenMessage resourceToken="audit" />;
}
const errMsg =
axiosError.response?.data?.error ?? (query.error as Error).message;
return (
<div className="p-8 text-center text-red-500">
{t("msg.dev.audit.load_error", "Error loading logs: {{error}}", {
error: errMsg,
})}
</div>
);
}
return (
<div className="space-y-6">
<Card className="glass-panel">
<CardHeader 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-muted-foreground">
{t("ui.dev.audit.registry.title", "Audit registry")}
</p>
<CardTitle className="text-3xl font-black tracking-tight">
{t("ui.dev.audit.title", "Audit Logs")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.audit.subtitle",
"Shows DevFront activity history within current tenant/app scope.",
)}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="muted">
{t("msg.dev.audit.loaded_count", "Loaded {{count}} rows", {
count: logs.length,
})}
</Badge>
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button
className="shadow-sm shadow-primary/30"
onClick={handleExportCsv}
>
<Download size={16} />
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<SearchFilterBar
primary={
<form
onSubmit={(e) => {
e.preventDefault();
query.refetch();
}}
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
>
<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"
value={searchClientId}
onChange={(e) => setSearchClientId(e.target.value)}
placeholder={t(
"ui.dev.audit.filter.client_id",
"Filter by Client ID",
)}
/>
</div>
<Input
value={searchAction}
onChange={(e) =>
setSearchAction(e.target.value.toUpperCase())
}
placeholder={t(
"ui.dev.audit.filter.action",
"Filter by Action (e.g. ROTATE_SECRET)",
)}
/>
<select
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">
{t("ui.dev.audit.filter.status_all", "All Status")}
</option>
<option value="success">
{t("ui.common.status.success", "Success")}
</option>
<option value="failure">
{t("ui.common.status.failure", "Failure")}
</option>
</select>
</form>
}
/>
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[190px]">
{t("ui.dev.audit.table.time", "Time")}
</TableHead>
<TableHead className="w-[180px]">
{t("ui.dev.audit.table.actor", "Actor")}
</TableHead>
<TableHead className="w-[180px]">
{t("ui.dev.audit.table.action", "Action")}
</TableHead>
<TableHead className="w-[260px]">
{t("ui.dev.audit.table.target", "Target")}
</TableHead>
<TableHead className="w-[120px]">
{t("ui.dev.audit.table.status", "Status")}
</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 && (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
{t("msg.dev.audit.empty", "No audit logs found.")}
</TableCell>
</TableRow>
)}
{logs.map((row, index) => {
const details = parseDetails(row.details);
const actionLabel = details.action || row.event_type;
const targetValue = details.target_id || "-";
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const expanded = Boolean(expandedRows[rowKey]);
return (
<React.Fragment key={rowKey}>
<TableRow>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(row.timestamp)}
</TableCell>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-2">
<span>{row.user_id || "-"}</span>
{row.user_id ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => handleCopy(row.user_id)}
>
<Copy className="h-3 w-3" />
</Button>
) : null}
</div>
</TableCell>
<TableCell className="text-xs">{actionLabel}</TableCell>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-2">
<span className="break-all">{targetValue}</span>
{targetValue !== "-" ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => handleCopy(targetValue)}
>
<Copy className="h-3 w-3" />
</Button>
) : null}
</div>
</TableCell>
<TableCell>
<Badge
variant={
row.status === "success" ? "success" : "warning"
}
>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}))
}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</TableCell>
</TableRow>
{expanded ? (
<TableRow className="bg-card/20">
<TableCell
colSpan={6}
className="text-xs text-muted-foreground"
>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<div>
Request ID: {formatValue(details.request_id)}
</div>
<div>Method: {formatValue(details.method)}</div>
<div>Path: {formatValue(details.path)}</div>
<div>
Tenant: {formatValue(details.tenant_id)}
</div>
</div>
<div className="space-y-1 break-all">
<div>Before: {formatValue(details.before)}</div>
<div>After: {formatValue(details.after)}</div>
<div>Error: {formatValue(details.error)}</div>
</div>
</div>
</TableCell>
</TableRow>
) : null}
</React.Fragment>
);
})}
</TableBody>
</Table>
{query.hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
onClick={() => query.fetchNextPage()}
disabled={query.isFetchingNextPage}
>
{query.isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.dev.audit.load_more", "Load more")}
</Button>
</div>
) : null}
</CardContent>
</Card>
</div>
);
}
export default AuditLogsPage;

View File

@@ -0,0 +1,35 @@
import { useEffect } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
import { userManager } from "../../lib/auth";
export default function AuthCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
useEffect(() => {
// 팝업으로 열린 경우 signinPopupCallback 처리
if (window.opener) {
userManager.signinPopupCallback().catch((error) => {
console.error("Popup callback failed:", error);
});
return;
}
if (auth.isAuthenticated) {
const returnTo =
typeof auth.user?.state === "object" &&
auth.user?.state !== null &&
"returnTo" in auth.user.state &&
typeof auth.user.state.returnTo === "string"
? auth.user.state.returnTo
: "/chart";
navigate(returnTo, { replace: true });
} else if (auth.error) {
console.error("Auth Error:", auth.error);
navigate("/login", { replace: true });
}
}, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
return <div>Loading Auth...</div>;
}

View File

@@ -0,0 +1,77 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import AuthGuard from "./AuthGuard";
const authState = {
isAuthenticated: false,
isLoading: false,
activeNavigator: undefined as string | undefined,
error: undefined as Error | undefined,
removeUser: vi.fn(),
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
function LocationProbe() {
const location = useLocation();
return (
<div data-testid="location">
{location.pathname}
{location.search}
</div>
);
}
function renderGuard(initialEntry: string) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route element={<AuthGuard />}>
<Route path="/embed/picker" element={<div>picker</div>} />
</Route>
<Route path="/login" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
);
});
return { container, root };
}
function cleanupRendered(container: HTMLDivElement, root: Root) {
act(() => {
root.unmount();
});
container.remove();
}
describe("OrgFront AuthGuard auto login redirects", () => {
afterEach(() => {
vi.clearAllMocks();
authState.isAuthenticated = false;
authState.isLoading = false;
authState.activeNavigator = undefined;
authState.error = undefined;
window.localStorage.clear();
});
it("redirects protected picker entry to the auto login URL", () => {
const rendered = renderGuard(
"/embed/picker?mode=single&select=tenant&tenantId=hanmac-family-id",
);
expect(rendered.container.textContent).toBe(
"/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26tenantId%3Dhanmac-family-id",
);
cleanupRendered(rendered.container, rendered.root);
});
});

View File

@@ -0,0 +1,72 @@
import { useAuth } from "react-oidc-context";
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
import { t } from "../../lib/i18n";
export default function AuthGuard() {
const auth = useAuth();
const location = useLocation();
const navigate = useNavigate();
const searchParams = new URLSearchParams(location.search);
const shareToken = searchParams.get("token");
const isPlaywrightBypass =
typeof window !== "undefined" &&
(window.location.hostname === "127.0.0.1" ||
window.location.hostname === "localhost") &&
window.localStorage.getItem("playwright_auth_bypass") === "1";
// 공유 토큰이 있는 경우 인증 체크를 건너뜁니다 (Public View)
if (shareToken || isPlaywrightBypass) {
return <Outlet />;
}
if (auth.isLoading || auth.activeNavigator) {
return <div>Loading...</div>;
}
if (auth.error) {
return <div>Auth Error: {auth.error.message}</div>;
}
if (!auth.isAuthenticated) {
const returnTo = `${location.pathname}${location.search}`;
return (
<Navigate
to={`/login?auto=1&returnTo=${encodeURIComponent(returnTo)}`}
replace
/>
);
}
// 조직도 앱은 일반 사용자(user)도 볼 수 있어야 하므로 접근 제한을 해제합니다.
const isDenied = false; // normalizedRole === "guest";
if (isDenied) {
return (
<div className="min-h-screen grid place-items-center bg-background text-foreground p-6">
<div className="max-w-lg w-full rounded-xl border border-border bg-card p-6 space-y-4">
<h1 className="text-xl font-semibold">
{t("msg.dev.auth.access_denied_title", "접근 권한이 없습니다.")}
</h1>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.auth.access_denied_description",
"조직도를 볼 수 있는 권한이 없습니다.",
)}
</p>
<button
type="button"
className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
onClick={() => {
auth.removeUser();
navigate("/login");
}}
>
{t("ui.common.back_to_login", "로그인으로 돌아가기")}
</button>
</div>
</div>
);
}
return <Outlet />;
}

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,73 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import LoginPage from "./LoginPage";
const authState = {
isAuthenticated: false,
isLoading: false,
activeNavigator: undefined as string | undefined,
signinRedirect: vi.fn(),
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
function renderLoginPage(initialEntry: string) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter initialEntries={[initialEntry]}>
<LoginPage />
</MemoryRouter>,
);
});
return { container, root };
}
function cleanupRendered(container: HTMLDivElement, root: Root) {
act(() => {
root.unmount();
});
container.remove();
}
describe("OrgFront LoginPage auto login", () => {
afterEach(() => {
vi.clearAllMocks();
authState.isAuthenticated = false;
authState.isLoading = false;
authState.activeNavigator = undefined;
});
it("does not start auto login again when the orgfront session is already authenticated", () => {
authState.isAuthenticated = true;
const rendered = renderLoginPage(
"/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle",
);
expect(authState.signinRedirect).not.toHaveBeenCalled();
cleanupRendered(rendered.container, rendered.root);
});
it("starts auto login once when auto mode is requested without an authenticated session", () => {
const rendered = renderLoginPage(
"/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle",
);
expect(authState.signinRedirect).toHaveBeenCalledTimes(1);
expect(authState.signinRedirect).toHaveBeenCalledWith({
state: {
returnTo: "/embed/picker?mode=single",
},
});
cleanupRendered(rendered.container, rendered.root);
});
});

View File

@@ -0,0 +1,137 @@
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useRef } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
function LoginPage() {
const auth = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false);
const returnTo = searchParams.get("returnTo") || "/chart";
const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => {
if (auth.isAuthenticated) {
navigate(returnTo, { replace: true });
}
}, [auth.isAuthenticated, navigate, returnTo]);
useEffect(() => {
if (!shouldAutoLogin) {
return;
}
if (
auth.isAuthenticated ||
autoStartedRef.current ||
auth.isLoading ||
auth.activeNavigator
) {
return;
}
autoStartedRef.current = true;
void auth.signinRedirect({
state: {
returnTo,
},
});
}, [
auth,
auth.activeNavigator,
auth.isAuthenticated,
auth.isLoading,
returnTo,
shouldAutoLogin,
]);
const handleSSOLogin = async () => {
try {
await auth.signinRedirect({
state: {
returnTo: "/chart",
},
});
} catch (error) {
console.error("Redirect login failed", error);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
<div className="w-full max-w-md space-y-8">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
<ShieldHalf size={32} />
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
Developer Control Plane
</p>
</div>
</div>
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl flex items-center gap-2">
<LogIn size={20} className="text-primary" />
</CardTitle>
<CardDescription>
Baron (SSO) .
</CardDescription>
</CardHeader>
<CardContent className="pt-4 pb-8 space-y-3">
<Button
onClick={handleSSOLogin}
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
disabled={auth.isLoading}
>
{auth.isLoading ? (
<>
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
...
</>
) : (
<>
<ShieldHalf size={22} />
SSO
<ExternalLink size={16} className="opacity-50" />
</>
)}
</Button>
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
.
<br />
.
</p>
</CardContent>
</Card>
<div className="flex justify-center gap-4">
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
<br />
.
</p>
</div>
</div>
);
}
export default LoginPage;

View File

@@ -0,0 +1,24 @@
import apiClient from "../../lib/apiClient";
export interface Tenant {
id: string;
name: string;
phone?: string;
slug: string;
}
export interface UserProfile {
id: string;
email: string;
name: string;
phone?: string;
role: string;
companyCode?: string;
tenantId?: string;
tenant?: Tenant;
}
export async function fetchMe() {
const { data } = await apiClient.get<UserProfile>("/user/me");
return data;
}

View File

@@ -0,0 +1,603 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
Download,
Filter,
Search,
} from "lucide-react";
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
function ClientConsentsPage() {
const params = useParams();
const clientId = params.id ?? "";
const [subjectInput, setSubjectInput] = useState("");
const [subject, setSubject] = useState("");
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [scopeFilter, setScopeFilter] = useState<string[]>([]);
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
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, "all"),
enabled: clientId.length > 0,
});
const revokeMutation = useMutation({
mutationFn: (payload: { subject: string }) =>
revokeConsent(payload.subject, clientId),
onSuccess: () => {
refetch();
},
});
const handleRevoke = (sub: string) => {
if (
window.confirm(
t(
"msg.dev.clients.consents.revoke_confirm",
"정말로 이 사용자의 권한을 철회하시겠습니까? 철회 시 사용자는 다음 접속 시 다시 동의해야 합니다.",
),
)
) {
revokeMutation.mutate({ subject: sub });
}
};
const rows = consentsData?.items ?? [];
const allScopes = Array.from(new Set(rows.flatMap((r) => r.grantedScopes)));
const filteredRows = rows.filter((row) => {
const matchStatus =
statusFilter.length === 0 || statusFilter.includes(row.status);
const matchScope =
scopeFilter.length === 0 ||
scopeFilter.some((s) => row.grantedScopes.includes(s));
return matchStatus && matchScope;
});
const handleExportCSV = () => {
if (filteredRows.length === 0) return;
const headers = [
t("ui.dev.clients.consents.table.user", "User"),
t("ui.dev.clients.consents.table.tenant", "Tenant"),
t("ui.dev.clients.table.status", "Status"),
t("ui.dev.clients.consents.table.scopes", "Granted Scopes"),
t("ui.dev.clients.consents.table.first_granted", "First Granted"),
t(
"ui.dev.clients.consents.table.last_auth",
"Last Authenticated / Revoked",
),
];
const csvContent = [
headers.join(","),
...filteredRows.map((row) => {
const lastAuthRevoked =
row.status === "revoked" && row.deletedAt
? `${t("ui.dev.clients.consents.status_revoked", "Revoked")}: ${new Date(row.deletedAt).toLocaleString()}`
: row.authenticatedAt
? new Date(row.authenticatedAt).toLocaleString()
: "-";
return [
`"${row.subject} (${row.userName || ""})"`,
`"${row.tenantName || row.tenantId || ""}"`,
`"${row.status}"`,
`"${row.grantedScopes.join(", ")}"`,
`"${new Date(row.createdAt).toLocaleString()}"`,
`"${lastAuthRevoked}"`,
].join(",");
}),
].join("\n");
const blob = new Blob([`\uFEFF${csvContent}`], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const date = new Date().toISOString().split("T")[0];
link.setAttribute("href", url);
link.setAttribute("download", `consents_${clientId}_${date}.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleStatusFilterChange = (status: string, checked: boolean) => {
if (checked) {
setStatusFilter((prev) => [...prev, status]);
} else {
setStatusFilter((prev) => prev.filter((s) => s !== status));
}
};
const handleScopeFilterChange = (scope: string, checked: boolean) => {
if (checked) {
setScopeFilter((prev) => [...prev, scope]);
} else {
setScopeFilter((prev) => prev.filter((s) => s !== scope));
}
};
const handleAllScopesChange = (checked: boolean) => {
if (checked) {
setScopeFilter(allScopes);
} else {
setScopeFilter([]);
}
};
return (
<div className="space-y-8">
<header className="space-y-4">
<div className="flex flex-wrap justify-between gap-4">
<div className="space-y-2">
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
</Link>
<span>/</span>
<Link to="/clients" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
</Link>
<span>/</span>
<span>{clientData?.client?.name || clientId}</span>
<span>/</span>
<span className="text-foreground font-semibold">
{t(
"ui.dev.clients.consents.breadcrumb.current",
"User Consent Grants",
)}
</span>
</nav>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link to={`/clients/${clientId}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<p className="text-3xl font-black leading-tight">
{t("ui.dev.clients.consents.title", "User Consent Grants")}
</p>
<p className="text-muted-foreground">
{t(
"msg.dev.clients.consents.subtitle",
"OIDC Relying Party 사용자 권한을 검토·관리합니다.",
)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Badge
variant={
clientData?.client?.status === "active" ? "info" : "muted"
}
>
{clientData?.client?.status === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive")}
</Badge>
</div>
</div>
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
<Link
to={`/clients/${clientId}`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
{t("ui.dev.clients.details.tab.connection", "Federation")}
</Link>
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
</span>
<Link
to={`/clients/${clientId}/settings`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
{t("ui.dev.clients.details.tab.settings", "Settings")}
</Link>
</div>
</header>
<Card className="glass-panel">
<CardContent className="space-y-4">
<SearchFilterBar
primary={
<div className="relative w-full max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-10"
placeholder={t(
"ui.dev.clients.consents.search_placeholder",
"사용자 ID, 이름, 이메일로 검색",
)}
value={subjectInput}
onChange={(e) => setSubjectInput(e.target.value)}
/>
</div>
}
actions={
<>
<Button
variant="ghost"
className={cn(
"gap-1 text-muted-foreground",
isAdvancedFilterOpen && "text-primary bg-primary/10",
)}
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
>
<Filter className="h-4 w-4" />
{t(
"ui.dev.clients.consents.filters.advanced",
"Advanced Filters",
)}
</Button>
<Button
className="shadow-sm shadow-primary/30"
onClick={() => setSubject(subjectInput.trim())}
>
{t("ui.common.search", "검색")}
</Button>
<Button
className="shadow-sm shadow-primary/30"
onClick={handleExportCSV}
disabled={filteredRows.length === 0}
>
<Download className="h-4 w-4" />
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
</Button>
</>
}
advancedOpen={isAdvancedFilterOpen}
advanced={
<>
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.dev.clients.consents.status_label", "Status:")}
</span>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
<input
type="checkbox"
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
checked={statusFilter.includes("active")}
onChange={(e) =>
handleStatusFilterChange("active", e.target.checked)
}
/>
{t("ui.common.status.active", "Active")}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
<input
type="checkbox"
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
checked={statusFilter.includes("revoked")}
onChange={(e) =>
handleStatusFilterChange("revoked", e.target.checked)
}
/>
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
</label>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.dev.clients.consents.scope_label", "Scope:")}
</span>
<div className="flex flex-wrap gap-4">
{allScopes.length > 0 && (
<label className="flex items-center gap-2 text-sm cursor-pointer font-bold text-primary hover:opacity-80">
<input
type="checkbox"
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
checked={
scopeFilter.length === allScopes.length &&
allScopes.length > 0
}
onChange={(e) =>
handleAllScopesChange(e.target.checked)
}
/>
ALL
</label>
)}
{allScopes.map((scope) => (
<label
key={scope}
className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground"
>
<input
type="checkbox"
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
checked={scopeFilter.includes(scope)}
onChange={(e) =>
handleScopeFilterChange(scope, e.target.checked)
}
/>
{scope}
</label>
))}
</div>
</div>
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground p-0 h-auto"
onClick={() => {
setStatusFilter([]);
setScopeFilter([]);
}}
>
{t("ui.common.reset", "초기화")}
</Button>
</div>
</>
}
/>
</CardContent>
</Card>
<Card className="glass-panel">
{error && (
<CardContent className="text-sm text-red-500">
{t(
"msg.dev.clients.consents.load_error",
"Error loading consents: {{error}}",
{
error: (error as Error).message,
},
)}
</CardContent>
)}
{isLoading && (
<CardContent className="text-sm text-muted-foreground">
{t("msg.dev.clients.consents.loading", "Loading consents...")}
</CardContent>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.dev.clients.consents.table.user", "User")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.tenant", "Tenant")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.status", "Status")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.scopes", "Granted Scopes")}
</TableHead>
<TableHead>
{t(
"ui.dev.clients.consents.table.first_granted",
"First Granted",
)}
</TableHead>
<TableHead>
{t(
"ui.dev.clients.consents.table.last_auth",
"Last Authenticated / Revoked",
)}
</TableHead>
<TableHead className="text-right">
{t("ui.dev.clients.consents.table.action", "Action")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
{t("msg.dev.clients.consents.empty", "No consents found.")}
</TableCell>
</TableRow>
) : (
filteredRows.map((row) => (
<TableRow
key={`${row.subject}-${row.clientId}`}
className={row.status === "revoked" ? "opacity-60" : ""}
>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{(row.userName || row.subject)
.slice(0, 2)
.toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold">
{row.userName ||
t("ui.dev.clients.consents.subject", "Subject")}
</span>
<span className="text-xs text-muted-foreground">
{row.subject}
</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-sm font-semibold">
{row.tenantName || t("ui.common.na", "N/A")}
</span>
<span className="text-xs text-muted-foreground">
{row.tenantId}
</span>
</div>
</TableCell>
<TableCell>
{row.status === "active" ? (
<Badge variant="success">
{t("ui.common.status.active", "Active")}
</Badge>
) : (
<Badge variant="warning">
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.grantedScopes.map((scope) => (
<Badge
key={scope}
variant="muted"
className="border bg-muted/40 text-foreground"
>
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(row.createdAt).toLocaleString()}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{row.status === "revoked" && row.deletedAt ? (
<span className="text-destructive font-medium">
{t("ui.dev.clients.consents.revoked_at", "Revoked: ")}
{new Date(row.deletedAt).toLocaleString()}
</span>
) : row.authenticatedAt ? (
new Date(row.authenticatedAt).toLocaleString()
) : (
"-"
)}
</TableCell>
<TableCell className="text-right">
{row.status === "active" && (
<Button
variant="ghost"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleRevoke(row.subject)}
disabled={revokeMutation.isPending}
>
{t("ui.dev.clients.consents.revoke", "Revoke")}
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<CardContent className="flex items-center justify-between border-t border-border bg-muted/10 px-6 py-4 text-sm text-muted-foreground">
<p>
{t(
"msg.dev.clients.consents.showing",
"Showing {{from}} to {{to}} of {{total}} users",
{
from: filteredRows.length > 0 ? 1 : 0,
to: filteredRows.length,
total: rows.length,
},
)}
</p>
<div className="flex gap-2">
<Button variant="outline" size="icon" disabled>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button size="sm" disabled={filteredRows.length === 0}>
1
</Button>
<Button variant="outline" size="icon" disabled>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<div className="grid gap-6 md:grid-cols-3">
<Card className="glass-panel">
<CardHeader className="pb-2">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t(
"ui.dev.clients.consents.stats.active_grants",
"Active Grants",
)}
</p>
<CardTitle className="text-2xl font-black">
{rows.filter((r) => r.status === "active").length}
</CardTitle>
</CardHeader>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-2">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t(
"ui.dev.clients.consents.stats.total_scopes",
"Total Scopes Issued",
)}
</p>
<CardTitle className="text-2xl font-black">
{rows.reduce((acc, row) => acc + row.grantedScopes.length, 0)}
</CardTitle>
</CardHeader>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-2">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t(
"ui.dev.clients.consents.stats.avg_scopes",
"Avg. Scopes per User",
)}
</p>
<CardTitle className="text-2xl font-black">
{rows.length > 0
? (
rows.reduce(
(acc, row) => acc + row.grantedScopes.length,
0,
) / rows.length
).toFixed(1)
: "0.0"}
</CardTitle>
</CardHeader>
</Card>
</div>
</div>
);
}
export default ClientConsentsPage;

View File

@@ -0,0 +1,540 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowLeft,
Eye,
EyeOff,
Link2,
RefreshCw,
Save,
Shield,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link, 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 { CopyButton } from "../../components/ui/copy-button";
import { Label } from "../../components/ui/label";
import { Separator } from "../../components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableRow,
} from "../../components/ui/table";
import { Textarea } from "../../components/ui/textarea";
import { toast } from "../../components/ui/use-toast";
import {
fetchClient,
rotateClientSecret,
updateClient,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
function ClientDetailsPage() {
const params = useParams();
const queryClient = useQueryClient();
const clientId = params.id ?? "";
const { data, error, isLoading } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId),
enabled: clientId.length > 0,
});
const [redirectUris, setRedirectUris] = useState("");
const [showSecret, setShowSecret] = useState(false);
const redirectUrisHydratedRef = useRef(false);
useEffect(() => {
if (
!redirectUrisHydratedRef.current &&
data?.client?.redirectUris &&
redirectUris === ""
) {
setRedirectUris(data.client.redirectUris.join(", "));
redirectUrisHydratedRef.current = true;
}
}, [data, redirectUris]);
const mutation = useMutation({
mutationFn: () => {
const uriList = redirectUris
.split(",")
.map((u) => u.trim())
.filter(Boolean);
return updateClient(clientId, { redirectUris: uriList });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
toast(
t(
"msg.dev.clients.details.redirect_saved",
"Redirect URIs가 저장되었습니다.",
),
);
},
onError: (err) => {
toast(
t("msg.dev.clients.details.save_error", "저장 실패: {{error}}", {
error: (err as Error).message,
}),
"error",
);
},
});
const rotateMutation = useMutation({
mutationFn: () => rotateClientSecret(clientId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
toast(
t(
"msg.dev.clients.details.secret_rotated",
"Client Secret이 재발급되었습니다.",
),
);
setShowSecret(true); // 재발급 후 바로 보여줌
},
onError: (err) => {
toast(
t("msg.dev.clients.details.rotate_error", "재발급 실패: {{error}}", {
error: (err as Error).message,
}),
"error",
);
},
});
const handleRotateSecret = () => {
if (
window.confirm(
t(
"msg.dev.clients.details.rotate_confirm",
"경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?",
),
)
) {
rotateMutation.mutate();
}
};
if (!clientId) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
</div>
);
}
if (error && !data) {
const errMsg =
(error as AxiosError<{ error?: string }>).response?.data?.error ??
(error as Error)?.message;
return (
<div className="p-8 text-center text-red-500">
{t(
"msg.dev.clients.details.load_error",
"Error loading app: {{error}}",
{ error: errMsg || t("msg.common.unknown_error", "unknown error") },
)}
</div>
);
}
if (isLoading && !data) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.loading", "Loading app details...")}
</div>
);
}
const client = data?.client;
if (!client) {
return null;
}
const endpointValues = data?.endpoints ?? {
discovery: "-",
issuer: "-",
authorization: "-",
token: "-",
userinfo: "-",
};
const endpoints = [
{
labelKey: "ui.dev.clients.details.endpoint.discovery",
labelFallback: "Discovery Endpoint",
value: endpointValues.discovery,
},
{
labelKey: "ui.dev.clients.details.endpoint.issuer",
labelFallback: "Issuer URL",
value: endpointValues.issuer,
},
{
labelKey: "ui.dev.clients.details.endpoint.authorization",
labelFallback: "Authorization Endpoint",
value: endpointValues.authorization,
},
{
labelKey: "ui.dev.clients.details.endpoint.token",
labelFallback: "Token Endpoint",
value: endpointValues.token,
},
{
labelKey: "ui.dev.clients.details.endpoint.userinfo",
labelFallback: "UserInfo Endpoint",
value: endpointValues.userinfo,
},
];
// Client Secret from API
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
const clientSecret = client?.clientSecret || secretPlaceholder;
const displaySecret =
clientSecret === secretPlaceholder
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
: clientSecret;
return (
<div className="space-y-8">
<div className="space-y-3">
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
</Link>
<span>/</span>
<Link to="/clients" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
</Link>
<span>/</span>
<span>{client?.name || clientId}</span>
<span>/</span>
<span className="text-foreground font-semibold">
{t("ui.dev.clients.details.tab.connection", "Federation")}
</span>
</nav>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link to="/clients">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-4xl font-black leading-tight tracking-tight">
{client?.name || client?.id || clientId}
</h1>
<p className="text-muted-foreground">
{t(
"msg.dev.clients.details.subtitle",
"Manage OIDC credentials and endpoints.",
)}
</p>
</div>
</div>
<Badge
variant={client?.status === "active" ? "info" : "muted"}
className="px-3 py-1 text-xs uppercase"
>
{client?.status === "active"
? t("ui.common.status.active", "Active")
: client?.status === "inactive"
? t("ui.common.status.inactive", "Inactive")
: t("msg.common.loading", "Loading...")}
</Badge>
</div>
<div className="flex gap-6 border-b border-border">
<Link
to={`/clients/${clientId}`}
className="border-b-2 border-primary pb-3 text-sm font-bold text-primary"
>
{t("ui.dev.clients.details.tab.connection", "Federation")}
</Link>
<Link
to={`/clients/${clientId}/consents`}
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
>
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
</Link>
<Link
to={`/clients/${clientId}/settings`}
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
>
{t("ui.dev.clients.details.tab.settings", "Settings")}
</Link>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
<div className="space-y-6">
<div className="space-y-4">
<h2 className="text-xl font-bold">
{t(
"ui.dev.clients.details.credentials.title",
"Client Credentials",
)}
</h2>
<Card className="glass-panel">
<CardContent className="flex flex-col gap-4 p-6">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t(
"ui.dev.clients.details.credentials.client_id",
"Client ID",
)}
</p>
<div className="flex items-center justify-between gap-2">
<p className="font-mono text-lg truncate">
{client?.id || clientId}
</p>
<CopyButton
value={client?.id || clientId}
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_client_id",
"Client ID가 복사되었습니다.",
),
)
}
/>
</div>
</div>
<Separator />
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t(
"ui.dev.clients.details.credentials.client_secret",
"Client Secret",
)}
</p>
<div className="flex items-center justify-between gap-2">
<p
className={cn(
"font-mono text-lg",
!showSecret && "tracking-widest",
)}
>
{showSecret ? displaySecret : "••••••••••••••••"}
</p>
<div className="flex gap-2 shrink-0">
<Button
variant="secondary"
size="icon"
onClick={() => setShowSecret(!showSecret)}
aria-label={
showSecret
? t(
"ui.dev.clients.details.secret.hide",
"비밀키 숨기기",
)
: t(
"ui.dev.clients.details.secret.show",
"비밀키 보기",
)
}
>
{showSecret ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
variant="secondary"
size="icon"
onClick={handleRotateSecret}
disabled={rotateMutation.isPending}
title={t(
"ui.dev.clients.details.secret.rotate",
"비밀키 재발급 (Rotate)",
)}
>
<RefreshCw
className={cn(
"h-4 w-4",
rotateMutation.isPending && "animate-spin",
)}
/>
</Button>
<CopyButton
value={clientSecret}
disabled={
!showSecret && clientSecret === secretPlaceholder
}
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_client_secret",
"Client Secret이 복사되었습니다.",
),
)
}
/>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">
{t("ui.dev.clients.details.endpoints.title", "OIDC 엔드포인트")}
</h2>
<Badge variant="muted" className="gap-1">
<Link2 className="h-3 w-3" />
{t("ui.dev.clients.details.endpoints.read_only", "읽기 전용")}
</Badge>
</div>
<Card className="glass-panel">
<Table>
<TableBody>
{endpoints.map((endpoint) => (
<TableRow
key={endpoint.labelKey}
className="border-border/70"
>
<TableCell className="w-1/3">
<p className="text-xs font-bold uppercase tracking-[0.12em] text-muted-foreground">
{t(endpoint.labelKey, endpoint.labelFallback)}
</p>
</TableCell>
<TableCell className="flex items-center justify-between gap-3">
<span className="break-all font-mono text-sm">
{endpoint.value}
</span>
<CopyButton
value={endpoint.value}
className="h-8 w-8 shrink-0"
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_endpoint",
"{{label}}가 복사되었습니다.",
{
label: t(
endpoint.labelKey,
endpoint.labelFallback,
),
},
),
)
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
</div>
<div className="space-y-6">
<div className="space-y-4">
<h2 className="text-xl font-bold">
{t("ui.dev.clients.details.redirect.title", "리디렉션 URI 설정")}
</h2>
<Card className="glass-panel border-primary/20">
<CardHeader>
<CardTitle className="text-lg">
{t("ui.dev.clients.details.redirect.label", "Redirect URIs")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.details.redirect.description",
"인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label
htmlFor="redirect-uris"
className="text-sm font-semibold"
>
{t(
"ui.dev.clients.details.redirect.callback_label",
"인증 콜백 URL",
)}
</Label>
<Textarea
id="redirect-uris"
placeholder={t(
"ui.dev.clients.details.redirect.placeholder",
"https://your-app.com/callback, http://localhost:3000/auth/callback",
)}
rows={5}
value={redirectUris}
onChange={(e) => {
redirectUrisHydratedRef.current = true;
setRedirectUris(e.target.value);
}}
className="font-mono text-sm"
/>
</div>
<Button
className="w-full gap-2"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
<Save className="h-4 w-4" />
{mutation.isPending
? t("msg.common.saving", "저장 중...")
: t(
"ui.dev.clients.details.redirect.save",
"Redirect URIs 저장",
)}
</Button>
</CardContent>
</Card>
</div>
<div className="glass-panel p-6 opacity-80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
<Shield className="h-6 w-6" />
</div>
<div>
<p className="text-lg font-semibold">
{t("ui.dev.clients.details.security.title", "보안 메모")}
</p>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.details.security.note",
"엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 로그와 연계하세요.",
)}
</p>
</div>
</div>
</div>
<Separator className="my-4" />
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.details.security.footer",
"비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.",
)}
</p>
</div>
</div>
</div>
</div>
);
}
export default ClientDetailsPage;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,531 @@
import { useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
BookOpenText,
Filter,
Plus,
Search,
ServerCog,
ShieldHalf,
} from "lucide-react";
import { useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useNavigate } from "react-router-dom";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
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, fetchDevStats } from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
function ClientsPage() {
const navigate = useNavigate();
const auth = useAuth();
const hasAccessToken = Boolean(auth.user?.access_token);
const {
data,
isLoading: isLoadingClients,
error: clientError,
} = useQuery({
queryKey: ["clients"],
queryFn: fetchClients,
enabled: hasAccessToken,
});
const { data: statsData, isLoading: isLoadingStats } = useQuery({
queryKey: ["dev-stats"],
queryFn: fetchDevStats,
enabled: hasAccessToken,
});
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const clients = data?.items || [];
const filteredClients = clients.filter((client) => {
const matchesSearch =
!searchQuery ||
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.id.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = typeFilter === "all" || client.type === typeFilter;
const matchesStatus =
statusFilter === "all" || client.status === statusFilter;
return matchesSearch && matchesType && matchesStatus;
});
const totalClients = statsData?.total_clients ?? clients.length;
const activeSessions = statsData?.active_sessions ?? 0;
const authFailures = statsData?.auth_failures_24h ?? 0;
type StatTone = "up" | "down" | "stable";
type StatItem = {
labelKey: string;
labelFallback: string;
value: string;
deltaKey: string;
deltaFallback: string;
tone: StatTone;
};
const stats: StatItem[] = [
{
labelKey: "ui.dev.clients.stats.total",
labelFallback: "Total Applications",
value: totalClients.toString(),
deltaKey: "ui.dev.clients.stats.realtime",
deltaFallback: "Realtime",
tone: "up" as const,
},
{
labelKey: "ui.dev.clients.stats.active_sessions",
labelFallback: "Active Sessions",
value: activeSessions.toString(),
deltaKey: "ui.dev.clients.stats.realtime",
deltaFallback: "Realtime",
tone: "up" as const,
},
{
labelKey: "ui.dev.clients.stats.auth_failures",
labelFallback: "Auth Failures (24h)",
value: authFailures.toString(),
deltaKey:
authFailures > 0
? "ui.dev.clients.stats.alert"
: "ui.dev.clients.stats.stable",
deltaFallback: authFailures > 0 ? "Check Logs" : "Stable",
tone: authFailures > 0 ? ("down" as const) : ("stable" as const),
},
];
const isLoading = isLoadingClients || isLoadingStats;
if (auth.isLoading || !hasAccessToken || isLoading) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.loading", "Loading clients...")}
</div>
);
}
if (clientError) {
const axiosError = clientError as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
return <ForbiddenMessage resourceToken="clients" />;
}
const errMsg =
axiosError.response?.data?.error ?? (clientError as Error).message;
return (
<div className="p-8 text-center text-red-500">
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
error: 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">
{t("ui.dev.clients.registry.title", "RP registry")}
</p>
<CardTitle className="text-3xl font-black tracking-tight">
{t("ui.dev.clients.registry.subtitle", "연동 앱")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.registry.description",
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
)}
</CardDescription>
</div>
<div className="hidden items-center gap-2 md:flex">
<Button
size="sm"
className="shadow-lg shadow-primary/30"
onClick={() => navigate("/clients/new")}
>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
</div>
</div>
<SearchFilterBar
className="mt-4"
primary={
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-10"
placeholder={t(
"ui.dev.clients.search_placeholder",
"클라이언트 이름/ID로 검색...",
)}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
}
actions={
<>
<Button
variant="ghost"
size="sm"
className={cn(
"gap-1 text-muted-foreground",
isAdvancedFilterOpen && "text-primary bg-primary/10",
)}
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
>
<Filter className="h-4 w-4" />
{t(
"ui.dev.clients.consents.filters.advanced",
"Advanced Filters",
)}
</Button>
<div className="hidden items-center gap-2 md:flex">
<Badge variant="muted">
{t(
"ui.dev.clients.badge.tenant_selected",
"테넌트: 선택됨",
)}
</Badge>
<Badge variant="success">
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
</Badge>
</div>
</>
}
advancedOpen={isAdvancedFilterOpen}
advanced={
<div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
{t("ui.dev.clients.filter.type_label", "Type:")}
</span>
<select
className="h-9 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="all">
{t("ui.dev.clients.filter.type_all", "모든 유형")}
</option>
<option value="private">
{t("ui.dev.clients.type.private", "Server side App")}
</option>
<option value="pkce">
{t("ui.dev.clients.type.pkce", "PKCE")}
</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
{t("ui.dev.clients.consents.status_label", "Status:")}
</span>
<select
className="h-9 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">
{t("ui.dev.clients.filter.status_all", "모든 상태")}
</option>
<option value="active">
{t("ui.common.status.active", "Active")}
</option>
<option value="inactive">
{t("ui.common.status.inactive", "Inactive")}
</option>
</select>
</div>
<Button
variant="ghost"
size="sm"
className="ml-auto text-xs text-muted-foreground"
onClick={() => {
setTypeFilter("all");
setStatusFilter("all");
}}
>
{t("ui.common.reset", "초기화")}
</Button>
</div>
}
/>
</CardHeader>
<CardContent className="pt-0">
<div className="grid gap-4 md:grid-cols-3">
{stats.map((item) => (
<Card key={item.labelKey} className="border border-border/60">
<CardHeader className="pb-2">
<CardDescription>
{t(item.labelKey, item.labelFallback)}
</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 === "stable" && "bg-muted/40 text-foreground",
)}
>
{t(item.deltaKey, item.deltaFallback)}
</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">
{t("ui.dev.clients.list.title", "클라이언트 목록")}
</CardTitle>
<div className="flex items-center gap-2 md:hidden">
<Button size="sm" onClick={() => navigate("/clients/new")}>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.dev.clients.table.application", "애플리케이션")}
</TableHead>
<TableHead>
{t("ui.dev.clients.table.client_id", "Client ID")}
</TableHead>
<TableHead>{t("ui.dev.clients.table.type", "유형")}</TableHead>
<TableHead>
{t("ui.dev.clients.table.status", "상태")}
</TableHead>
<TableHead>
{t("ui.dev.clients.table.created_at", "생성일")}
</TableHead>
<TableHead className="text-right">
{t("ui.dev.clients.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredClients.map((client) => (
<TableRow key={client.id} className="bg-card/40">
<TableCell>
<Link
to={`/clients/${client.id}`}
className="flex items-center gap-3 transition-colors hover:text-primary"
>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
{client.type === "private" ? (
<ServerCog className="h-4 w-4" />
) : (
<ShieldHalf className="h-4 w-4" />
)}
</div>
<div>
<p className="font-semibold">
{client.name ||
t("ui.dev.clients.untitled", "Untitled")}
</p>
<p className="text-xs text-muted-foreground">
{t("ui.dev.clients.tenant_scoped", "Tenant-scoped")}
</p>
</div>
</Link>
</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>
</div>
</TableCell>
<TableCell>
<Badge
variant={client.type === "private" ? "success" : "muted"}
>
{client.type === "private"
? t("ui.dev.clients.type.private", "Server side App")
: client.metadata?.headless_login_enabled
? t(
"ui.dev.clients.type.pkce_headless",
"PKCE (Headless Login)",
)
: t("ui.dev.clients.type.pkce", "PKCE")}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={client.status === "active" ? "info" : "muted"}
className="px-3 py-1 text-xs uppercase"
>
{client.status === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive")}
</Badge>
</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}`}>
{t("ui.common.view", "View")}
</Link>
</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>
{t(
"msg.dev.clients.showing",
"Showing {{shown}} of {{total}} clients",
{ shown: filteredClients.length, total: totalClients },
)}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled>
{t("ui.common.previous", "Previous")}
</Button>
<Button variant="outline" size="sm" disabled>
{t("ui.common.next", "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">
{t(
"ui.dev.clients.help.title",
"Need help with OIDC configuration?",
)}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.help.subtitle",
"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">
{t("ui.dev.clients.help.docs_title", "Docs & Examples")}
</p>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.help.docs_body",
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
)}
</p>
</div>
</div>
<Button variant="secondary">
{t("ui.dev.clients.help.view_guides", "View guides")}
</Button>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold">
{t("ui.dev.clients.owner.title", "Owner")}
</CardTitle>
<CardDescription>
{t("ui.dev.clients.owner.subtitle", "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={t("ui.dev.clients.owner.avatar_alt", "ops user")}
/>
<AvatarFallback>AR</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">
{t("ui.dev.clients.owner.name", "AI Admin Bot")}
</p>
<p className="text-xs text-muted-foreground">
{t("ui.dev.clients.owner.email", "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>
{t("ui.dev.clients.owner.role", "Role: Tenant Admin")}
</span>
<span>{t("ui.dev.clients.owner.scope", "Scope: TENANT-12")}</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
export default ClientsPage;

View File

@@ -0,0 +1,308 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit, Globe, Plus, Save, Trash2 } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import type { IdpConfig, IdpConfigCreateRequest } from "../../../lib/devApi";
import {
createIdpConfigForClient,
listIdpConfigsForClient,
} from "../../../lib/devApi";
import { t } from "../../../lib/i18n";
// Proper Modal Component with Form
const CreateIdpModal = ({
isOpen,
onClose,
clientId,
}: {
isOpen: boolean;
onClose: () => void;
clientId: string;
}) => {
const queryClient = useQueryClient();
const [formData, setFormData] = useState<IdpConfigCreateRequest>({
client_id: clientId,
provider_type: "oidc",
display_name: "",
status: "active",
issuer_url: "",
oidc_client_id: "",
oidc_client_secret: "",
scopes: "openid email profile",
});
const mutation = useMutation({
mutationFn: (newData: IdpConfigCreateRequest) =>
createIdpConfigForClient(newData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["idpConfigs", clientId] });
onClose();
},
onError: (error) => {
alert(`Failed to create configuration: ${error.message}`);
},
});
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 backdrop-blur-sm">
<Card className="w-full max-w-lg shadow-2xl animate-in zoom-in-95 duration-200">
<CardHeader>
<CardTitle>
{t("ui.dev.clients.federation.add_title", "Add Identity Provider")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.federation.add_subtitle",
"Connect an external OIDC provider.",
)}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-semibold">Display Name</label>
<Input
name="display_name"
value={formData.display_name}
onChange={handleChange}
placeholder="e.g. Google Workspace"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">Issuer URL</label>
<Input
type="url"
name="issuer_url"
value={formData.issuer_url}
onChange={handleChange}
placeholder="https://accounts.google.com"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-semibold">Client ID</label>
<Input
name="oidc_client_id"
value={formData.oidc_client_id}
onChange={handleChange}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">Client Secret</label>
<Input
type="password"
name="oidc_client_secret"
value={formData.oidc_client_secret}
onChange={handleChange}
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">Scopes</label>
<Input
name="scopes"
value={formData.scopes}
onChange={handleChange}
/>
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
{t("ui.common.cancel", "Cancel")}
</Button>
<Button
type="submit"
disabled={
mutation.isPending ||
formData.display_name.trim() === "" ||
(formData.issuer_url?.trim() ?? "") === ""
}
>
{mutation.isPending ? (
<div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
) : (
<Save size={16} className="mr-2" />
)}
{mutation.isPending
? t("msg.common.saving", "Saving...")
: t("ui.common.save", "Save Configuration")}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
};
export function ClientFederationPage() {
const { id: clientIdParam } = useParams<{ id: string }>();
const clientId = clientIdParam ?? "";
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["idpConfigs", clientId],
queryFn: () => listIdpConfigsForClient(clientId),
enabled: clientId.length > 0,
});
if (!clientId) {
return (
<div className="p-8 text-center text-destructive">
Client ID is missing
</div>
);
}
return (
<div className="space-y-6 p-1">
<header className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Globe className="h-6 w-6 text-primary" />
{t("ui.dev.clients.federation.title", "Identity Federation")}
</h1>
<p className="text-muted-foreground">
{t(
"msg.dev.clients.federation.subtitle",
"Manage external identity providers for this application.",
)}
</p>
</div>
<Button onClick={() => setCreateModalOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
{t("ui.dev.clients.federation.add_btn", "Add Provider")}
</Button>
</header>
<Card className="glass-panel">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Display Name</TableHead>
<TableHead>Provider Type</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
{t("msg.common.loading", "Loading...")}
</TableCell>
</TableRow>
) : error ? (
<TableRow>
<TableCell
colSpan={4}
className="h-24 text-center text-destructive"
>
{(error as Error).message}
</TableCell>
</TableRow>
) : data?.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="h-24 text-center text-muted-foreground"
>
{t(
"msg.dev.clients.federation.empty",
"No IdP configurations found.",
)}
</TableCell>
</TableRow>
) : (
data?.map((config: IdpConfig) => (
<tr
key={config.id}
className="border-b transition-colors hover:bg-muted/50"
>
<TableCell className="font-medium">
{config.display_name}
</TableCell>
<TableCell>{config.provider_type.toUpperCase()}</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
config.status === "active"
? "bg-blue-500 text-white"
: "bg-muted text-muted-foreground"
}`}
>
{config.status}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8">
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</tr>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<CreateIdpModal
isOpen={isCreateModalOpen}
onClose={() => setCreateModalOpen(false)}
clientId={clientId}
/>
</div>
);
}

View File

@@ -0,0 +1,307 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
import AuditLogsPage from "../audit/AuditLogsPage";
import AuthPage from "../auth/AuthPage";
import ClientConsentsPage from "../clients/ClientConsentsPage";
import ClientDetailsPage from "../clients/ClientDetailsPage";
import ClientGeneralPage from "../clients/ClientGeneralPage";
import ClientsPage from "../clients/ClientsPage";
import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
import DashboardPage from "../dashboard/DashboardPage";
import ProfilePage from "../profile/ProfilePage";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("react-oidc-context", () => ({
useAuth: () => ({
isAuthenticated: true,
isLoading: false,
user: {
access_token: "access-token",
profile: {
sub: "user-1",
role: "super_admin",
tenant_id: "tenant-1",
},
},
signinRedirect: vi.fn(),
removeUser: vi.fn(),
}),
}));
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
}));
const clientSummary = {
id: "client-a",
name: "Console App",
type: "private" as const,
status: "active" as const,
createdAt: "2026-05-01T00:00:00Z",
redirectUris: ["https://app.example/callback"],
scopes: ["openid", "profile"],
tokenEndpointAuthMethod: "client_secret_basic",
metadata: {
headless_login_enabled: true,
headless_login_jwks_uri: "https://app.example/jwks.json",
},
};
const clientDetail = {
client: {
...clientSummary,
clientSecret: "secret-value",
jwksUri: "https://app.example/jwks.json",
grantTypes: ["authorization_code"],
responseTypes: ["code"],
},
endpoints: {
discovery: "https://sso.example/.well-known/openid-configuration",
issuer: "https://sso.example",
authorization: "https://sso.example/oauth2/auth",
token: "https://sso.example/oauth2/token",
userinfo: "https://sso.example/userinfo",
},
headlessJwksCache: {
clientId: "client-a",
jwksUri: "https://app.example/jwks.json",
cachedAt: "2026-05-01T00:00:00Z",
expiresAt: "2026-05-02T00:00:00Z",
lastRefreshStatus: "success" as const,
cachedKids: ["kid-1"],
parsedKeys: [{ kid: "kid-1", kty: "RSA", use: "sig", alg: "RS256" }],
},
};
vi.mock("../../lib/devApi", () => ({
fetchClients: vi.fn(async () => ({
items: [clientSummary],
limit: 100,
offset: 0,
})),
fetchDevStats: vi.fn(async () => ({
total_clients: 1,
active_sessions: 12,
auth_failures_24h: 2,
})),
fetchClient: vi.fn(async () => clientDetail),
updateClientStatus: vi.fn(async () => clientDetail),
createClient: vi.fn(async () => clientDetail),
updateClient: vi.fn(async () => clientDetail),
rotateClientSecret: vi.fn(async () => clientDetail),
refreshHeadlessJwksCache: vi.fn(async () => clientDetail),
revokeHeadlessJwksCache: vi.fn(async () => undefined),
deleteClient: vi.fn(async () => undefined),
fetchConsents: vi.fn(async () => ({
items: [
{
subject: "user-1",
userName: "Consent User",
clientId: "client-a",
clientName: "Console App",
grantedScopes: ["openid", "profile"],
authenticatedAt: "2026-05-01T02:00:00Z",
createdAt: "2026-05-01T00:00:00Z",
status: "active",
tenantId: "tenant-1",
tenantName: "Hanmac",
},
],
})),
revokeConsent: vi.fn(async () => undefined),
listIdpConfigsForClient: vi.fn(async () => [
{
id: "idp-1",
client_id: "client-a",
provider_type: "oidc",
display_name: "Workspace OIDC",
status: "active",
issuer_url: "https://accounts.example",
oidc_client_id: "oidc-client",
scopes: "openid email profile",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]),
createIdpConfigForClient: vi.fn(async (payload) => ({
id: "idp-1",
...payload,
status: "active",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
})),
updateIdpConfig: vi.fn(async (_clientId, idpId, payload) => ({
id: idpId,
client_id: "client-a",
provider_type: "oidc",
display_name: "Provider",
status: "active",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
...payload,
})),
deleteIdpConfig: vi.fn(async () => undefined),
fetchDevAuditLogs: vi.fn(async () => ({
items: [
{
event_id: "event-1",
timestamp: "2026-05-01T00:00:00Z",
user_id: "user-1",
event_type: "client.update",
status: "success",
ip_address: "127.0.0.1",
user_agent: "vitest",
details: JSON.stringify({
action: "client.update",
target_id: "client-a",
tenant_id: "tenant-1",
request_id: "req-1",
}),
},
],
limit: 50,
})),
fetchMyTenants: vi.fn(async () => [
{ id: "tenant-1", name: "Hanmac", slug: "hanmac" },
]),
}));
vi.mock("../auth/authApi", () => ({
fetchMe: vi.fn(async () => ({
id: "user-1",
name: "Org User",
email: "org@example.com",
phone: "010-0000-0000",
role: "super_admin",
department: "Platform",
tenantId: "tenant-1",
tenant: { id: "tenant-1", name: "Hanmac", slug: "hanmac" },
})),
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
});
async function renderWithProviders(
element: React.ReactElement,
initialEntry = "/",
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialEntry]}>{element}</MemoryRouter>
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("orgfront page smoke coverage", () => {
it("renders the dashboard content", async () => {
const container = await renderWithProviders(<DashboardPage />);
expect(container.textContent).toContain("RP 등록 현황");
expect(container.textContent).toContain("Stack readiness");
});
it("renders static auth guidance and forbidden messages", async () => {
const auth = await renderWithProviders(<AuthPage />);
expect(auth.textContent).toContain("Admin auth guardrails");
expect(auth.textContent).toContain("TTL discipline");
const forbidden = await renderWithProviders(
<ForbiddenMessage resourceToken="clients" />,
);
expect(forbidden.textContent).toContain("연동 앱 접근 권한 없음");
});
it("renders client list, detail, edit, consent, and federation pages", async () => {
const clients = await renderWithProviders(
<Routes>
<Route path="/clients" element={<ClientsPage />} />
</Routes>,
"/clients",
);
expect(clients.textContent).toContain("Console App");
const details = await renderWithProviders(
<Routes>
<Route path="/clients/:id" element={<ClientDetailsPage />} />
</Routes>,
"/clients/client-a",
);
expect(details.textContent).toContain("Client Secret");
expect(details.textContent).toContain("https://sso.example/oauth2/token");
const general = await renderWithProviders(
<Routes>
<Route path="/clients/:id/edit" element={<ClientGeneralPage />} />
</Routes>,
"/clients/client-a/edit",
);
expect(general.textContent).toContain("Console App");
const consents = await renderWithProviders(
<Routes>
<Route path="/clients/:id/consents" element={<ClientConsentsPage />} />
</Routes>,
"/clients/client-a/consents",
);
expect(consents.textContent).toContain("Consent User");
const federation = await renderWithProviders(
<Routes>
<Route
path="/clients/:id/federation"
element={<ClientFederationPage />}
/>
</Routes>,
"/clients/client-a/federation",
);
expect(federation.textContent).toContain("Workspace OIDC");
});
it("renders audit logs and profile pages", async () => {
const auditLogs = await renderWithProviders(<AuditLogsPage />);
expect(auditLogs.textContent).toContain("client.update");
expect(auditLogs.textContent).toContain("Loaded 1 rows");
const profile = await renderWithProviders(<ProfilePage />);
expect(profile.textContent).toContain("Org User");
expect(profile.textContent).toContain("Hanmac");
});
});

View File

@@ -0,0 +1,294 @@
import {
Activity,
ArrowRight,
BarChart3,
CheckCircle2,
Database,
KeyRound,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { t } from "../../lib/i18n";
const guardHighlights = [
{
titleKey: "ui.dev.dashboard.guard.policy.title",
titleFallback: "RP 정책 통제",
bodyKey: "msg.dev.dashboard.guard.policy.body",
bodyFallback:
"Relying Party 상태를 활성/비활성으로 관리하고 정책 변경을 기록합니다.",
metricKey: "ui.dev.dashboard.guard.policy.metric",
metricFallback: "Policy",
},
{
titleKey: "ui.dev.dashboard.guard.consent.title",
titleFallback: "Consent 흐름",
bodyKey: "msg.dev.dashboard.guard.consent.body",
bodyFallback:
"사용자 Consent를 조회하고 필요 시 회수해 리스크를 제어합니다.",
metricKey: "ui.dev.dashboard.guard.consent.metric",
metricFallback: "Consent",
},
{
titleKey: "ui.dev.dashboard.guard.hydra.title",
titleFallback: "Hydra Admin",
bodyKey: "msg.dev.dashboard.guard.hydra.body",
bodyFallback: "Hydra Admin API를 통해 RP 등록 현황을 동기화합니다.",
metricKey: "ui.dev.dashboard.guard.hydra.metric",
metricFallback: "Hydra",
},
];
const stackReadiness = [
{
key: "msg.dev.dashboard.stack.react",
fallback: "React 19 + Vite 7, strict TS, Router v6 data router.",
},
{
key: "msg.dev.dashboard.stack.query",
fallback: "TanStack Query 5로 RP/Consent 데이터를 캐시합니다.",
},
{
key: "msg.dev.dashboard.stack.axios",
fallback: "Axios 클라이언트에서 Bearer + 테넌트 헤더를 주입합니다.",
},
{
key: "msg.dev.dashboard.stack.tailwind",
fallback: "Tailwind + shadcn/ui로 devfront 톤을 맞춥니다.",
},
{
key: "msg.dev.dashboard.stack.proxy",
fallback: "Hydra Admin API 연동을 위한 프록시 엔드포인트 준비.",
},
];
const nextSteps = [
{
key: "msg.dev.dashboard.next.rp_workflow",
fallback: "RP 등록/수정/삭제 워크플로우 추가",
},
{
key: "msg.dev.dashboard.next.consent_filters",
fallback: "Consent 검색 필터 고도화 및 CSV 내보내기",
},
{
key: "msg.dev.dashboard.next.audit_guard",
fallback: "권한 가드 및 감사 로그 연동",
},
];
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} />
{t("ui.dev.dashboard.ready_badge", "devfront ready")}
</div>
<h2 className="text-3xl font-semibold leading-tight">
{t(
"msg.dev.dashboard.hero.title_prefix",
"RP 등록 현황과 Consent 상태를",
)}
<span className="text-[var(--color-accent)]">
{t("msg.dev.dashboard.hero.title_emphasis", " 하나의 화면")}
</span>
{t("msg.dev.dashboard.hero.title_suffix", "에서 관리합니다.")}
</h2>
<p className="text-[var(--color-muted)]">
{t(
"msg.dev.dashboard.hero.body",
"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)]">
{t("ui.dev.dashboard.badge.rp_synced", "RP registry synced")}
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
{t(
"ui.dev.dashboard.badge.consent_guard",
"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)]">
{t(
"ui.dev.dashboard.badge.policy_toggle",
"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} />
{t(
"msg.dev.dashboard.notice.dev_scope",
"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} />
{t(
"msg.dev.dashboard.notice.consent_audit",
"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} />
{t(
"msg.dev.dashboard.notice.hydra_health",
"Hydra Admin 상태 체크 준비",
)}
</div>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
{guardHighlights.map((item) => (
<div
key={item.titleKey}
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)]">
{t(item.metricKey, item.metricFallback)}
</div>
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
{t("ui.common.status.active", "active")}
</span>
</div>
<div className="relative mt-3 space-y-2">
<h3 className="text-lg font-semibold">
{t(item.titleKey, item.titleFallback)}
</h3>
<p className="text-sm text-[var(--color-muted)]">
{t(item.bodyKey, item.bodyFallback)}
</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)]">
{t("ui.dev.dashboard.stack.title", "Stack readiness")}
</p>
<h3 className="text-xl font-semibold">
{t("ui.dev.dashboard.stack.subtitle", "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)]"
>
{t("ui.dev.dashboard.stack.notes", "Setup notes")}
<ArrowRight size={14} />
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{stackReadiness.map((item) => (
<div
key={item.key}
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">{t(item.key, item.fallback)}</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)]">
{t("ui.dev.dashboard.next.title", "Next actions")}
</p>
<h3 className="mt-2 text-xl font-semibold">
{t("ui.dev.dashboard.next.subtitle", "Ship the RP controls")}
</h3>
<div className="mt-4 space-y-3">
{nextSteps.map((item, idx) => (
<div
key={item.key}
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)]">
{t(item.key, item.fallback)}
</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)]">
{t("ui.dev.dashboard.ops.title", "Ops board")}
</p>
<h3 className="text-xl font-semibold">
{t("ui.dev.dashboard.ops.subtitle", "현재 관측")}
</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">
{t("ui.dev.dashboard.ops.tag.consent", "Consent grants")}
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
{t("ui.dev.dashboard.ops.tag.rp_status", "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} />
{t("ui.dev.dashboard.ops.card.rp_requests", "RP 요청 추이")}
</div>
<p className="mt-3 text-2xl font-semibold">
{t("ui.common.status.pending", "준비 중")}
</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} />
{t(
"ui.dev.dashboard.ops.card.consent_revoked",
"Consent 회수 건수",
)}
</div>
<p className="mt-3 text-2xl font-semibold">
{t("ui.common.status.pending", "준비 중")}
</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} />
{t("ui.dev.dashboard.ops.card.hydra_status", "Hydra 상태")}
</div>
<p className="mt-3 text-2xl font-semibold">
{t("ui.common.status.ok", "정상")}
</p>
</div>
</div>
</section>
</div>
);
}
export default DashboardPage;

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
getHanmacFamilyTenantOrderRank,
orderHanmacFamilyChildren,
orderHanmacFamilyTenants,
} from "./hanmacFamilyOrder";
function tenant(name: string, slug: string) {
return { name, slug };
}
describe("hanmac family organization order", () => {
it("orders the top hanmac-family siblings by policy", () => {
const ordered = orderHanmacFamilyTenants([
tenant("한라산업개발", "halla"),
tenant("바론그룹", "baron-group"),
tenant("한맥기술", "hanmac"),
tenant("삼안", "saman"),
tenant("총괄기획&기술개발센터", "gpdtdc"),
]);
expect(ordered.map((item) => item.name)).toEqual([
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
"한라산업개발",
]);
});
it("keeps hanmac-family as the root before ordered descendants", () => {
const family = tenant("한맥가족", "hanmac-family");
const children = orderHanmacFamilyChildren(family, [
tenant("바론그룹", "baron-group"),
tenant("총괄기획&기술개발센터", "gpdtdc"),
tenant("삼안", "saman"),
tenant("한라산업개발", "halla"),
tenant("한맥기술", "hanmac"),
]);
expect([family, ...children].map((item) => item.name)).toEqual([
"한맥가족",
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
"한라산업개발",
]);
});
it("does not rank generic technical centers as GPDTDC", () => {
expect(
getHanmacFamilyTenantOrderRank(tenant("기술개발센터", "rnd-center")),
).toBe(Number.MAX_SAFE_INTEGER);
});
it("ranks Halla as the fifth hanmac-family company", () => {
expect(
getHanmacFamilyTenantOrderRank(tenant("한라산업개발", "halla")),
).toBe(4);
});
});

View File

@@ -0,0 +1,67 @@
export type HanmacFamilyOrderTenant = {
name: string;
slug: string;
};
export const HANMAC_FAMILY_ROOT_SLUG = "hanmac-family";
export const HANMAC_FAMILY_TENANT_ORDER = [
"gpdtdc",
"saman",
"hanmac",
"baron-group",
"halla",
] as const;
function normalizedTenantText(tenant: HanmacFamilyOrderTenant) {
return `${tenant.slug} ${tenant.name}`.trim().toLowerCase();
}
export function isHanmacFamilyRootTenant(tenant: HanmacFamilyOrderTenant) {
return (
tenant.slug.toLowerCase() === HANMAC_FAMILY_ROOT_SLUG ||
tenant.name.includes("한맥가족")
);
}
export function getHanmacFamilyTenantOrderRank(
tenant: HanmacFamilyOrderTenant,
) {
const text = normalizedTenantText(tenant);
if (text.includes("gpdtdc") || text.includes("총괄기획")) return 0;
if (text.includes("saman") || text.includes("삼안")) return 1;
if (
(text.includes("hanmac") || text.includes("한맥기술")) &&
!isHanmacFamilyRootTenant(tenant)
) {
return 2;
}
if (text.includes("baron-group") || text.includes("바론그룹")) return 3;
if (text.includes("halla") || text.includes("한라산업개발")) return 4;
return Number.MAX_SAFE_INTEGER;
}
export function compareHanmacFamilyTenants<T extends HanmacFamilyOrderTenant>(
a: T,
b: T,
) {
const rankDiff =
getHanmacFamilyTenantOrderRank(a) - getHanmacFamilyTenantOrderRank(b);
if (rankDiff !== 0) return rankDiff;
return a.name.localeCompare(b.name);
}
export function orderHanmacFamilyTenants<T extends HanmacFamilyOrderTenant>(
tenants: readonly T[],
) {
return [...tenants].sort(compareHanmacFamilyTenants);
}
export function orderHanmacFamilyChildren<T extends HanmacFamilyOrderTenant>(
parent: HanmacFamilyOrderTenant,
children: readonly T[],
) {
return isHanmacFamilyRootTenant(parent)
? orderHanmacFamilyTenants(children)
: [...children];
}

View File

@@ -0,0 +1,189 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
import { buildOrgPickerTree } from "./pickerTree";
function tenant(
id: string,
type: string,
name: string,
slug: string,
parentId?: string,
): TenantSummary {
return {
id,
type,
name,
slug,
description: "",
status: "active",
parentId,
memberCount: 0,
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
describe("buildOrgPickerTree", () => {
it("uses the hanmac-family company-group as the default picker root", () => {
const tenants = [
tenant("wrong-group", "COMPANY_GROUP", "Wrong Group", "wrong-group"),
tenant(
"wrong-company",
"COMPANY",
"Wrong Company",
"wrong-company",
"wrong-group",
),
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
});
expect(tree.companyGroupId).toBe("hanmac-family-id");
expect(tree.roots).toHaveLength(1);
expect(tree.roots[0]?.id).toBe("hanmac-family-id");
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"saman-id",
]);
});
it("orders hanmac-family children by the shared organization policy", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant(
"baron-group-id",
"COMPANY_GROUP",
"바론그룹",
"baron-group",
"hanmac-family-id",
),
tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
tenant(
"halla-id",
"COMPANY",
"한라산업개발",
"halla",
"hanmac-family-id",
),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
tenant(
"gpdtdc-id",
"ORGANIZATION",
"총괄기획&기술개발센터",
"gpdtdc",
"hanmac-family-id",
),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
});
expect(tree.roots[0]?.children.map((node) => node.name)).toEqual([
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
"한라산업개발",
]);
});
it("scopes descendant filtering by tenant slug", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
tenant("planning-id", "ORGANIZATION", "기획팀", "planning", "saman-id"),
tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
tenantId: "saman",
});
expect(tree.roots).toHaveLength(1);
expect(tree.roots[0]?.id).toBe("saman-id");
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"planning-id",
]);
});
it("excludes internal and private tenants from picker choices by default", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
{
...tenant(
"internal-id",
"ORGANIZATION",
"내부 조직",
"internal",
"saman-id",
),
config: { visibility: "internal" },
},
{
...tenant(
"secret-id",
"ORGANIZATION",
"비공개 조직",
"secret",
"saman-id",
),
config: { visibility: "private" },
},
tenant(
"secret-child-id",
"USER_GROUP",
"비공개 하위",
"secret-child",
"secret-id",
),
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
tenantId: "saman",
});
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual(["open-id"]);
});
it("includes internal tenants when explicitly requested", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
{
...tenant(
"internal-id",
"ORGANIZATION",
"내부 조직",
"internal",
"saman-id",
),
config: { visibility: "internal" },
},
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
];
const tree = buildOrgPickerTree({
includeInternal: true,
tenants,
users: [] satisfies UserSummary[],
tenantId: "saman",
});
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"internal-id",
"open-id",
]);
});
});

View File

@@ -0,0 +1,188 @@
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
import { buildTenantFullTree, type TenantNode } from "../../lib/tenantTree";
import { orderHanmacFamilyChildren } from "./hanmacFamilyOrder";
import type { OrgPickerTreeNode } from "./pickerTypes";
import { filterTenantsByVisibility } from "./tenantVisibility";
import { getOrgChartUserDisplayName } from "./userDisplay";
function getUserTenantSlug(user: UserSummary) {
return user.tenantSlug?.toLowerCase() || "";
}
function isOrgFrontTenantType(tenant: TenantSummary) {
return ["COMPANY_GROUP", "COMPANY", "ORGANIZATION", "USER_GROUP"].includes(
tenant.type.toUpperCase(),
);
}
function getCompanyGroupId(node: TenantNode, allTenants: TenantSummary[]) {
let cursor: TenantSummary | undefined = node;
const byId = new Map(allTenants.map((tenant) => [tenant.id, tenant]));
while (cursor?.parentId) {
const parent = byId.get(cursor.parentId);
if (!parent) break;
cursor = parent;
}
return cursor?.type === "COMPANY_GROUP" ? cursor.id : node.id;
}
function isHanmacFamilyCompanyGroup(tenant: TenantSummary) {
return (
tenant.type.toUpperCase() === "COMPANY_GROUP" &&
tenant.slug.toLowerCase() === "hanmac-family"
);
}
function findTenantByRef(tenants: TenantSummary[], ref?: string) {
const normalizedRef = ref?.trim().toLowerCase();
if (!normalizedRef) return undefined;
return (
tenants.find((tenant) => tenant.slug.toLowerCase() === normalizedRef) ??
tenants.find((tenant) => tenant.id === ref)
);
}
function tenantToPickerNode(
tenant: TenantNode,
usersBySlug: Map<string, UserSummary[]>,
): OrgPickerTreeNode {
const tenantChildren = orderHanmacFamilyChildren(tenant, tenant.children).map(
(child) => tenantToPickerNode(child, usersBySlug),
);
const userChildren = (usersBySlug.get(tenant.slug.toLowerCase()) || []).map(
(user) => ({
type: "user" as const,
id: user.id,
name: getOrgChartUserDisplayName(user, tenant),
parentId: tenant.id,
user,
children: [],
}),
);
return {
type: "tenant",
id: tenant.id,
name: tenant.name,
parentId: tenant.parentId ?? null,
tenant,
children: [...userChildren, ...tenantChildren],
};
}
function findTenantNode(
roots: TenantNode[],
tenantRef: string,
): TenantNode | undefined {
const findBySlug = (node: TenantNode): TenantNode | undefined => {
if (node.slug.toLowerCase() === tenantRef.trim().toLowerCase()) {
return node;
}
for (const child of node.children) {
const match = findBySlug(child);
if (match) return match;
}
return undefined;
};
const findById = (node: TenantNode): TenantNode | undefined => {
if (node.id === tenantRef) return node;
for (const child of node.children) {
const match = findById(child);
if (match) return match;
}
return undefined;
};
for (const root of roots) {
const slugMatch = findBySlug(root);
if (slugMatch) return slugMatch;
}
for (const root of roots) {
const idMatch = findById(root);
if (idMatch) return idMatch;
}
return undefined;
}
export function buildOrgPickerTree({
includeInternal = false,
tenants,
users,
rootTenantId,
tenantId,
}: {
includeInternal?: boolean;
tenants: TenantSummary[];
users: UserSummary[];
rootTenantId?: string;
tenantId?: string;
}) {
const visibleTenants = filterTenantsByVisibility(
tenants.filter(isOrgFrontTenantType),
includeInternal ? "internal" : "public",
);
const usersBySlug = new Map<string, UserSummary[]>();
for (const user of users) {
if (user.status !== "active") continue;
const slug = getUserTenantSlug(user);
if (!slug) continue;
const list = usersBySlug.get(slug) || [];
list.push(user);
usersBySlug.set(slug, list);
}
const companyGroup =
findTenantByRef(visibleTenants, rootTenantId) ??
visibleTenants.find(isHanmacFamilyCompanyGroup) ??
visibleTenants.find((tenant) => tenant.type === "COMPANY_GROUP") ??
visibleTenants.find((tenant) => !tenant.parentId);
if (!companyGroup) return { roots: [], companies: [], companyGroupId: "" };
const { currentBase } = buildTenantFullTree(visibleTenants, companyGroup.id);
const groupNode =
currentBase ??
buildTenantFullTree(visibleTenants).subTree.find(
(node) => node.id === companyGroup.id,
);
if (!groupNode) return { roots: [], companies: [], companyGroupId: "" };
const companies = orderHanmacFamilyChildren(
groupNode,
groupNode.children,
).filter((node) => node.type === "COMPANY");
const scopedRoot = tenantId
? findTenantNode([groupNode], tenantId)
: groupNode;
const filteredRoots = scopedRoot ? [scopedRoot] : [];
const roots = filteredRoots.map((node) =>
tenantToPickerNode(node, usersBySlug),
);
return {
roots,
companies: companies.map((company) => ({
id: company.id,
name: company.name,
companyGroupTenantId: getCompanyGroupId(company, tenants),
})),
companyGroupId: companyGroup.id,
};
}
export function flattenDescendants(node: OrgPickerTreeNode) {
const descendants: OrgPickerTreeNode[] = [];
const walk = (current: OrgPickerTreeNode) => {
for (const child of current.children) {
descendants.push(child);
walk(child);
}
};
walk(node);
return descendants;
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
buildOrgPickerEmbedSrc,
parseOrgPickerEmbedOptions,
} from "./pickerTypes";
describe("org picker embed options", () => {
it("builds slug-based tenant scope urls", () => {
expect(
buildOrgPickerEmbedSrc({
includeInternal: false,
mode: "single",
select: "tenant",
includeDescendants: true,
showDescendantToggle: true,
tenantId: "saman",
width: 400,
height: 600,
}),
).toBe(
"/embed/picker?mode=single&select=tenant&width=400&height=600&tenantSlug=saman",
);
});
it("builds and parses internal visibility flag only when requested", () => {
const src = buildOrgPickerEmbedSrc({
includeInternal: true,
mode: "single",
select: "tenant",
includeDescendants: true,
showDescendantToggle: true,
tenantId: "",
width: 400,
height: 600,
});
expect(src).toBe(
"/embed/picker?mode=single&select=tenant&width=400&height=600&includeInternal=true",
);
expect(parseOrgPickerEmbedOptions(src).includeInternal).toBe(true);
expect(parseOrgPickerEmbedOptions("?mode=single").includeInternal).toBe(
false,
);
});
it("parses tenantSlug first and keeps legacy tenantId compatibility", () => {
expect(
parseOrgPickerEmbedOptions(
"?tenantId=legacy-id&tenantSlug=saman&companyTenantId=legacy-company",
).tenantId,
).toBe("saman");
expect(parseOrgPickerEmbedOptions("?tenantId=legacy-id").tenantId).toBe(
"legacy-id",
);
});
});

View File

@@ -0,0 +1,107 @@
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
export type OrgPickerMode = "single" | "multiple";
export type OrgPickerSelectableType = "tenant" | "user" | "both";
export type OrgPickerObjectType = "tenant" | "user";
export type OrgPickerSelection = {
type: OrgPickerObjectType;
id: string;
name: string;
};
export type OrgPickerResult = {
mode: OrgPickerMode;
selections: OrgPickerSelection[];
};
export type OrgPickerEmbedOptions = {
includeInternal: boolean;
mode: OrgPickerMode;
select: OrgPickerSelectableType;
includeDescendants: boolean;
showDescendantToggle: boolean;
tenantId: string;
width: number;
height: number;
};
export type OrgPickerTreeNode = {
type: OrgPickerObjectType;
id: string;
name: string;
parentId: string | null;
tenant?: TenantSummary;
user?: UserSummary;
children: OrgPickerTreeNode[];
};
export function nodeKey(node: Pick<OrgPickerTreeNode, "type" | "id">) {
return `${node.type}:${node.id}`;
}
export function selectionKey(selection: OrgPickerSelection) {
return `${selection.type}:${selection.id}`;
}
export function parseOrgPickerMode(value: string | null): OrgPickerMode {
return value === "multiple" ? "multiple" : "single";
}
export function parseOrgPickerSelectableType(
value: string | null,
): OrgPickerSelectableType {
if (value === "tenant" || value === "user") return value;
return "both";
}
function parseEmbedDimension(value: string | null, fallback: number) {
const parsed = Number.parseInt(value ?? "", 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(1600, Math.max(240, parsed));
}
export function parseOrgPickerEmbedOptions(search: string) {
const params = new URLSearchParams(search);
return {
mode:
params.get("mode") === "single"
? ("single" as const)
: ("multiple" as const),
select: parseOrgPickerSelectableType(params.get("select")),
includeInternal: params.get("includeInternal") === "true",
includeDescendants: params.get("includeDescendants") !== "false",
showDescendantToggle: params.get("showDescendantToggle") !== "false",
tenantId:
params.get("tenantSlug") ??
params.get("tenantId") ??
params.get("companyTenantId") ??
"",
width: parseEmbedDimension(params.get("width"), 400),
height: parseEmbedDimension(params.get("height"), 600),
};
}
export function buildOrgPickerEmbedSrc(options: OrgPickerEmbedOptions) {
const params = new URLSearchParams({
mode: options.mode,
select: options.select,
width: String(options.width),
height: String(options.height),
});
if (options.includeInternal) {
params.set("includeInternal", "true");
}
const tenantSlug = options.tenantId.trim();
if (tenantSlug) {
params.set("tenantSlug", tenantSlug);
}
if (options.mode === "multiple") {
params.set("includeDescendants", String(options.includeDescendants));
params.set("showDescendantToggle", String(options.showDescendantToggle));
}
return `/embed/picker?${params.toString()}`;
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
compareOrgRanks,
getOrgRankDisplayName,
getOrgRankWeight,
} from "./rankPriority";
describe("org chart rank priority", () => {
it("normalizes long rank aliases to short display labels", () => {
expect(getOrgRankDisplayName("전무이사")).toBe("전무");
expect(getOrgRankDisplayName("상무이사")).toBe("상무");
expect(getOrgRankDisplayName("수석연구원")).toBe("수석");
expect(getOrgRankDisplayName("책임연구원")).toBe("책임");
expect(getOrgRankDisplayName("선임연구원")).toBe("선임");
});
it("orders executive and research ranks with shared priority weights", () => {
expect(getOrgRankWeight("사장")).toBeLessThan(getOrgRankWeight("부사장"));
expect(getOrgRankWeight("전무이사")).toBeLessThan(getOrgRankWeight("상무"));
expect(getOrgRankWeight("수석연구원")).toBeLessThan(
getOrgRankWeight("책임"),
);
expect(getOrgRankWeight("책임연구원")).toBeLessThan(
getOrgRankWeight("선임"),
);
expect(compareOrgRanks("부장", "차장")).toBeLessThan(0);
});
it("orders top executive ranks before the existing vice president and lower ranks", () => {
const ranks = ["부사장", "고문", "부회장", "사장", "회장"];
expect(ranks.toSorted(compareOrgRanks)).toEqual([
"회장",
"사장",
"부회장",
"고문",
"부사장",
]);
expect(getOrgRankWeight("부사장")).toBe(10);
expect(getOrgRankWeight("전무")).toBe(20);
});
});

View File

@@ -0,0 +1,57 @@
export type OrgRankDefinition = {
aliases: string[];
label: string;
weight: number;
};
export const ORG_RANK_DEFINITIONS: OrgRankDefinition[] = [
{ label: "회장", weight: -10, aliases: ["회장"] },
{ label: "사장", weight: 0, aliases: ["사장"] },
{ label: "부회장", weight: 2, aliases: ["부회장"] },
{ label: "고문", weight: 5, aliases: ["고문"] },
{ label: "부사장", weight: 10, aliases: ["부사장"] },
{ label: "전무", weight: 20, aliases: ["전무", "전무이사"] },
{ label: "상무", weight: 30, aliases: ["상무", "상무이사"] },
{ label: "이사", weight: 40, aliases: ["이사"] },
{ label: "부장", weight: 50, aliases: ["부장"] },
{ label: "수석", weight: 50, aliases: ["수석", "수석연구원"] },
{ label: "차장", weight: 60, aliases: ["차장"] },
{ label: "과장", weight: 70, aliases: ["과장"] },
{ label: "책임", weight: 70, aliases: ["책임", "책임연구원"] },
{ label: "대리", weight: 80, aliases: ["대리"] },
{ label: "선임", weight: 80, aliases: ["선임", "선임연구원"] },
{ label: "연구원", weight: 90, aliases: ["연구원"] },
{ label: "사원", weight: 90, aliases: ["사원"] },
];
const UNKNOWN_RANK_WEIGHT = 999;
function normalizeRankText(value: unknown) {
return typeof value === "string" ? value.trim().replace(/\s+/g, "") : "";
}
export function getOrgRankDefinition(rank: unknown) {
const normalizedRank = normalizeRankText(rank);
if (!normalizedRank) return undefined;
return ORG_RANK_DEFINITIONS.find((definition) =>
definition.aliases.some(
(alias) => normalizeRankText(alias) === normalizedRank,
),
);
}
export function getOrgRankDisplayName(rank: unknown) {
return (
getOrgRankDefinition(rank)?.label ??
(typeof rank === "string" ? rank.trim() : "")
);
}
export function getOrgRankWeight(rank: unknown) {
return getOrgRankDefinition(rank)?.weight ?? UNKNOWN_RANK_WEIGHT;
}
export function compareOrgRanks(a: unknown, b: unknown) {
return getOrgRankWeight(a) - getOrgRankWeight(b);
}

View File

@@ -0,0 +1,660 @@
import { describe, expect, it } from "vitest";
import {
buildOrgSelectionOptions,
buildUsersMap,
clampScale,
filterSystemGlobalTenants,
getMemberGridMetrics,
getOrgNodeHeaderFill,
getSemanticZoomMode,
layoutForest,
resolveOrgChartFamilyRoot,
type OrgNode,
} from "./OrgChartPage";
function orgNode(id: string, children: OrgNode[] = [], level = 0): OrgNode {
return {
id,
name: id,
level,
members: [],
children,
totalCount: 0,
totalMemberIds: new Set<string>(),
companyCode: id,
type: level === 0 ? "COMPANY" : "USER_GROUP",
};
}
function member(id: string) {
return {
id,
email: `${id}@example.com`,
name: id,
role: "user",
status: "active",
companyCode: "root",
grade: "사원",
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
function tenantNode(
id: string,
type: string,
name: string,
slug: string,
children = [],
) {
return {
id,
type,
name,
slug,
children,
description: "",
status: "active",
memberCount: 0,
recursiveMemberCount: 0,
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
function getNodeBoundsAspectRatio(
nodes: ReturnType<typeof layoutForest>["nodes"],
) {
const minX = Math.min(...nodes.map((node) => node.x));
const maxX = Math.max(...nodes.map((node) => node.x + node.width));
const minY = Math.min(...nodes.map((node) => node.y));
const maxY = Math.max(...nodes.map((node) => node.y + node.height));
return (maxX - minX) / (maxY - minY);
}
describe("org chart layout", () => {
it("keeps small sibling groups horizontal in automatic mode", () => {
const children = Array.from({ length: 4 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set());
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
expect(new Set(childNodes.map((node) => node.y)).size).toBe(1);
});
it("uses member columns in node bounds when the rendered node aspect ratio needs them", () => {
const compactMembers = Array.from({ length: 10 }, (_, index) =>
member(`member-${index + 1}`),
);
const node = {
...orgNode("root"),
members: compactMembers,
totalCount: compactMembers.length,
totalMemberIds: new Set(compactMembers.map((item) => item.id)),
};
const layout = layoutForest([node], new Set());
const rootNode = layout.nodes.find((item) => item.node.id === "root");
expect(rootNode).toBeDefined();
expect(rootNode?.width).toBeGreaterThan(240);
expect(rootNode?.height).toBeLessThan(42 + 24 + 10 * 24);
expect(layout.width).toBeGreaterThan((rootNode?.width ?? 0) + 72 * 2 - 1);
});
it("sizes member cards from an eight-character baseline and expands for long display names", () => {
const shortMembers = Array.from({ length: 6 }, (_, index) => ({
...member(`short-${index + 1}`),
name: `홍길${index + 1}`,
grade: "책임",
}));
const longMembers = shortMembers.map((item, index) => ({
...item,
id: `long-${index + 1}`,
name: `매우긴사용자이름${index + 1}`,
}));
const shortLayout = layoutForest(
[
{
...orgNode("short"),
members: shortMembers,
totalCount: shortMembers.length,
totalMemberIds: new Set(shortMembers.map((item) => item.id)),
},
],
new Set(),
);
const longLayout = layoutForest(
[
{
...orgNode("long"),
members: longMembers,
totalCount: longMembers.length,
totalMemberIds: new Set(longMembers.map((item) => item.id)),
},
],
new Set(),
);
const shortNode = shortLayout.nodes.find(
(item) => item.node.id === "short",
);
const longNode = longLayout.nodes.find((item) => item.node.id === "long");
expect(shortNode?.width).toBeLessThan(320);
expect(longNode?.width).toBeGreaterThan(shortNode?.width ?? 0);
});
it("uses compact member columns when another column improves the rendered ratio", () => {
const tenMembers = Array.from({ length: 10 }, (_, index) =>
member(`member-${index + 1}`),
);
const sixMembers = tenMembers.slice(0, 6);
const sixLayout = layoutForest(
[
{
...orgNode("six"),
members: sixMembers,
totalCount: sixMembers.length,
totalMemberIds: new Set(sixMembers.map((item) => item.id)),
},
],
new Set(),
);
const tenLayout = layoutForest(
[
{
...orgNode("ten"),
members: tenMembers,
totalCount: tenMembers.length,
totalMemberIds: new Set(tenMembers.map((item) => item.id)),
},
],
new Set(),
);
const sixNode = sixLayout.nodes.find((item) => item.node.id === "six");
const tenNode = tenLayout.nodes.find((item) => item.node.id === "ten");
expect(sixNode?.width).toBeGreaterThan(240);
expect(tenNode?.width).toBe(sixNode?.width);
expect(sixNode?.height).toBeLessThan(42 + 24 + 6 * 24);
expect(tenNode?.height).toBeLessThan(42 + 24 + 10 * 24);
});
it("chooses member columns from the rendered node aspect ratio instead of fixed five-member buckets", () => {
expect(getMemberGridMetrics(6)).toEqual({ columnCount: 2, rowCount: 3 });
expect(getMemberGridMetrics(10)).toEqual({ columnCount: 2, rowCount: 5 });
expect(getMemberGridMetrics(25)).toEqual({ columnCount: 4, rowCount: 7 });
});
it("sorts members by normalized rank inside the same organization", () => {
const members = [
{ ...member("staff"), name: "사원", grade: "사원" },
{ ...member("principal"), name: "수석", grade: "수석연구원" },
{ ...member("director"), name: "전무", grade: "전무이사" },
{ ...member("lead"), name: "책임", grade: "책임" },
];
const layout = layoutForest(
[
{
...orgNode("root"),
members,
totalCount: members.length,
totalMemberIds: new Set(members.map((item) => item.id)),
},
],
new Set(),
);
const rootNode = layout.nodes.find((item) => item.node.id === "root");
expect(rootNode?.members.map((item) => item.id)).toEqual([
"director",
"principal",
"lead",
"staff",
]);
});
it("uses multi-column layout by default when sibling width crosses the threshold", () => {
const children = Array.from({ length: 13 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set());
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
const childSpan =
Math.max(...childNodes.map((node) => node.x + node.width)) -
Math.min(...childNodes.map((node) => node.x));
const aspectRatio = getNodeBoundsAspectRatio(layout.nodes);
expect(childNodes).toHaveLength(13);
expect(uniqueChildRows.size).toBeGreaterThan(1);
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
expect(aspectRatio).toBeLessThanOrEqual(1.61);
expect(childSpan).toBeLessThan(13 * 240 + 12 * 80);
expect(
layout.edges.filter((edge) => edge.key.startsWith("root->")),
).toHaveLength(13);
expect(
layout.edges.filter(
(edge) => edge.key.startsWith("root->") && edge.visibleByDefault,
),
).toHaveLength(new Set(childNodes.map((node) => node.x)).size);
});
it("tunes column and row gaps after column selection to keep auto layout near the target aspect ratio", () => {
const children = Array.from({ length: 5 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set());
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
const aspectRatio = getNodeBoundsAspectRatio(layout.nodes);
expect(new Set(childNodes.map((node) => node.x)).size).toBe(4);
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
expect(aspectRatio).toBeLessThanOrEqual(1.61);
});
it("keeps direct siblings on one level in top-down mode", () => {
const children = Array.from({ length: 13 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "topDown",
});
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
expect(childNodes).toHaveLength(13);
expect(uniqueChildRows.size).toBe(1);
});
it("places children in three fixed columns with centered parent edges", () => {
const children = Array.from({ length: 10 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "threeColumn",
});
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
const uniqueChildColumns = new Set(childNodes.map((node) => node.x));
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
const rootEdges = layout.edges.filter((edge) =>
edge.key.startsWith("root->"),
);
expect(uniqueChildColumns.size).toBe(3);
expect(uniqueChildRows.size).toBe(4);
expect(rootEdges).toHaveLength(10);
expect(rootEdges.filter((edge) => edge.visibleByDefault)).toHaveLength(3);
});
it("places the deepest child subtree in the first multi-column section", () => {
const children = [
orgNode("shallow-1", [], 1),
orgNode("shallow-2", [], 1),
orgNode("shallow-3", [], 1),
orgNode(
"deep",
[
orgNode(
"deep-branch",
[orgNode("deep-leaf", [orgNode("deep-tail", [], 4)], 3)],
2,
),
],
1,
),
orgNode("shallow-4", [], 1),
orgNode("shallow-5", [], 1),
];
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "threeColumn",
});
const rootEdges = layout.edges.filter((edge) =>
edge.key.startsWith("root->"),
);
expect(rootEdges.map((edge) => edge.key)).toContain("root->deep");
});
it("centers a parent over the full child span in multi-column mode", () => {
const children = [
orgNode(
"deep",
[
orgNode(
"deep-branch",
[orgNode("deep-leaf", [orgNode("deep-tail", [], 4)], 3)],
2,
),
],
1,
),
...Array.from({ length: 9 }, (_, index) =>
orgNode(`shallow-${index + 1}`, [], 1),
),
];
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "threeColumn",
});
const rootNode = layout.nodes.find((node) => node.node.id === "root");
const directChildren = layout.nodes.filter((node) => node.node.level === 1);
const childSpanCenter =
(Math.min(...directChildren.map((node) => node.x + node.width / 2)) +
Math.max(...directChildren.map((node) => node.x + node.width / 2))) /
2;
const rootCenter = rootNode ? rootNode.x + rootNode.width / 2 : 0;
expect(rootNode).toBeDefined();
expect(rootCenter).toBeCloseTo(childSpanCenter, 5);
});
it("centers parents above the tidy child span", () => {
const children = [
orgNode("left", [orgNode("left-a", [], 2), orgNode("left-b", [], 2)], 1),
orgNode("middle", [], 1),
orgNode(
"right",
[orgNode("right-a", [], 2), orgNode("right-b", [], 2)],
1,
),
];
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "topDown",
});
const rootNode = layout.nodes.find((node) => node.node.id === "root");
const directChildren = layout.nodes.filter((node) =>
["left", "middle", "right"].includes(node.node.id),
);
const childSpanCenter =
(Math.min(...directChildren.map((node) => node.x + node.width / 2)) +
Math.max(...directChildren.map((node) => node.x + node.width / 2))) /
2;
const rootCenter = rootNode ? rootNode.x + rootNode.width / 2 : 0;
expect(rootNode).toBeDefined();
expect(rootCenter).toBeCloseTo(childSpanCenter, 5);
});
it("keeps compressed subtrees from overlapping on shared vertical bands", () => {
const layout = layoutForest(
[
orgNode("root", [
orgNode(
"left",
[orgNode("left-a", [], 2), orgNode("left-b", [], 2)],
1,
),
orgNode(
"right",
[orgNode("right-a", [], 2), orgNode("right-b", [], 2)],
1,
),
]),
],
new Set(),
);
for (const node of layout.nodes) {
for (const other of layout.nodes) {
if (node.node.id >= other.node.id) continue;
const verticalOverlap =
node.y < other.y + other.height && other.y < node.y + node.height;
const horizontalOverlap =
node.x < other.x + other.width && other.x < node.x + node.width;
expect(
verticalOverlap && horizontalOverlap,
`${node.node.id} overlaps ${other.node.id}`,
).toBe(false);
}
}
});
it("keeps zoom limits wide enough for large SVG organization charts", () => {
expect(clampScale(0.08)).toBe(0.08);
expect(clampScale(32)).toBe(32);
expect(clampScale(64)).toBe(32);
});
it("switches semantic zoom modes from overview to detail", () => {
expect(getSemanticZoomMode(0.12)).toBe("overview");
expect(getSemanticZoomMode(0.4)).toBe("compact");
expect(getSemanticZoomMode(0.8)).toBe("detail");
});
it("uses distinct header fills by organization depth", () => {
expect(getOrgNodeHeaderFill(0, "family")).toBe("#000000");
expect(getOrgNodeHeaderFill(0, "saman")).toBe("#f58220");
expect(getOrgNodeHeaderFill(0, "hanmac")).toBe("#1e489d");
expect(getOrgNodeHeaderFill(0, "gpdtdc")).toBe("#4b746d");
expect(getOrgNodeHeaderFill(0, "baron")).toBe("#004cbf");
expect(getOrgNodeHeaderFill(1, "saman")).not.toBe(
getOrgNodeHeaderFill(0, "saman"),
);
expect(getOrgNodeHeaderFill(2, "saman")).not.toBe(
getOrgNodeHeaderFill(1, "saman"),
);
});
it("orders top organization choices by the hanmac family policy", () => {
const familyRoot = tenantNode(
"family",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
[
tenantNode("saman", "COMPANY", "삼안", "saman"),
tenantNode("baron", "COMPANY_GROUP", "바론그룹", "baron-group"),
tenantNode("hanmac", "COMPANY", "한맥기술", "hanmac"),
tenantNode("gpdtdc", "ORGANIZATION", "총괄기획&기술개발센터", "gpdtdc"),
],
);
expect(
buildOrgSelectionOptions(familyRoot).map((option) => option.label),
).toEqual(["총괄기획&기술개발센터", "삼안", "한맥기술", "바론그룹"]);
});
it("selects hanmac family as the default root even when public sector group is listed first", () => {
const publicSector = tenantNode(
"public-sector",
"COMPANY_GROUP",
"공공기관",
"public-sector",
);
const familyRoot = tenantNode(
"family",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
[tenantNode("saman", "COMPANY", "삼안", "saman")],
);
expect(resolveOrgChartFamilyRoot([publicSector, familyRoot])?.id).toBe(
"family",
);
});
it("hides internal organizations by default and includes them for internal mode", () => {
const visibleParent = tenantNode(
"visible-parent",
"COMPANY",
"공개 회사",
"visible-parent",
);
const internalOrg = {
...tenantNode(
"internal-org",
"ORGANIZATION",
"내부 조직",
"internal-org",
),
parentId: "visible-parent",
config: { visibility: "internal" },
};
const internalChild = {
...tenantNode(
"internal-child",
"ORGANIZATION",
"내부 하위",
"internal-child",
),
parentId: "internal-org",
};
const privateOrg = {
...tenantNode(
"private-org",
"ORGANIZATION",
"비공개 조직",
"private-org",
),
parentId: "visible-parent",
config: { visibility: "private" },
};
const publicOrg = {
...tenantNode("public-org", "ORGANIZATION", "공개 조직", "public-org"),
parentId: "visible-parent",
};
const tenants = [
visibleParent,
internalOrg,
internalChild,
privateOrg,
publicOrg,
];
expect(
filterSystemGlobalTenants(tenants, "public").map((tenant) => tenant.id),
).toEqual(["visible-parent", "public-org"]);
expect(
filterSystemGlobalTenants(tenants, "internal").map((tenant) => tenant.id),
).toEqual([
"visible-parent",
"internal-org",
"internal-child",
"public-org",
]);
});
it("maps legacy companyCode users to matching tenant slugs", () => {
const usersMap = buildUsersMap(
[
{
...member("engineering-user"),
companyCode: "engineering",
tenantSlug: undefined,
tenant: undefined,
joinedTenants: undefined,
},
],
[tenantNode("engineering", "ORGANIZATION", "Engineering", "engineering")],
{ activeOnly: true },
);
expect(usersMap.get("engineering")?.map((user) => user.id)).toEqual([
"engineering-user",
]);
});
it("maps GPDTDC representative users to their visible leaf appointment", () => {
const gpdtdc = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc");
const tdc = {
...tenantNode("tdc", "ORGANIZATION", "기술개발센터", "tdc"),
parentId: "gpdtdc",
};
const leaf = {
...tenantNode("tdc-leaf", "USER_GROUP", "기술개발센터 1팀", "tdc-leaf"),
parentId: "tdc",
};
const rootNode = {
...gpdtdc,
children: [{ ...tdc, children: [leaf] }],
};
const usersMap = buildUsersMap(
[
{
...member("gpdtdc-user"),
companyCode: undefined,
tenantSlug: "gpdtdc",
metadata: {
additionalAppointments: [
{
tenantSlug: "internal-planning",
isPrimary: true,
},
{
tenantSlug: "tdc-leaf",
isPrimary: false,
grade: "책임",
position: "팀장",
},
],
},
joinedTenants: undefined,
},
],
[rootNode],
{ activeOnly: true },
);
expect(usersMap.get("gpdtdc")).toBeUndefined();
expect(usersMap.get("internal-planning")).toBeUndefined();
expect(usersMap.get("tdc-leaf")?.map((user) => user.id)).toEqual([
"gpdtdc-user",
]);
});
it("does not fall back to a visible parent for hidden leaf memberships", () => {
const gpdtdc = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc");
const internalLeaf = {
...tenantNode(
"internal-leaf",
"USER_GROUP",
"내부 구성 조직",
"internal-leaf",
),
parentId: "gpdtdc",
};
const usersMap = buildUsersMap(
[
{
...member("hidden-only-user"),
companyCode: undefined,
tenantSlug: "gpdtdc",
metadata: {
additionalAppointments: [
{
tenantSlug: "internal-leaf",
isPrimary: true,
},
],
},
joinedTenants: undefined,
},
],
[gpdtdc],
{
activeOnly: true,
membershipRootNodes: [{ ...gpdtdc, children: [internalLeaf] }],
},
);
expect(usersMap.get("gpdtdc")).toBeUndefined();
expect(usersMap.get("internal-leaf")).toBeUndefined();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
import { GitBranch, Network, PanelTop } from "lucide-react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
const navItems = [
{ to: "/chart", label: "조직도", icon: Network },
{ to: "/picker", label: "조직 선택기", icon: GitBranch },
{ to: "/embed-preview", label: "임베딩 검증", icon: PanelTop },
];
export function OrgFrontLayout() {
const location = useLocation();
const isChartRoute =
location.pathname === "/chart" || location.pathname.startsWith("/chart/");
return (
<div
className={
isChartRoute
? "flex h-screen flex-col overflow-hidden bg-background text-foreground"
: "min-h-screen bg-background text-foreground"
}
>
<header
className="sticky top-0 z-30 shrink-0 border-b border-border bg-background/95 backdrop-blur"
data-testid="orgfront-topbar"
>
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Baron Orgfront
</p>
<h1 className="text-xl font-semibold"> </h1>
</div>
<nav className="flex flex-wrap gap-2" aria-label="주요 메뉴">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"inline-flex h-10 items-center gap-2 rounded-md border px-3 text-sm font-semibold transition",
isActive
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:text-foreground",
].join(" ")
}
>
<Icon size={16} />
{label}
</NavLink>
))}
</nav>
</div>
</header>
<main
className={
isChartRoute
? "min-h-0 flex-1 overflow-hidden"
: "mx-auto max-w-7xl px-4 py-5"
}
data-testid="orgfront-main"
>
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,236 @@
import * as React from "react";
import { useLocation } from "react-router-dom";
import {
buildOrgPickerEmbedSrc,
type OrgPickerEmbedOptions,
type OrgPickerMode,
type OrgPickerSelectableType,
parseOrgPickerEmbedOptions,
} from "../pickerTypes";
type PickerMessage = {
type: string;
payload?: unknown;
error?: string;
};
function PickerScenarioControls({
options,
onChange,
}: {
options: OrgPickerEmbedOptions;
onChange: (options: OrgPickerEmbedOptions) => void;
}) {
return (
<div className="grid gap-3 rounded-md border border-border bg-card p-4 md:grid-cols-2 lg:grid-cols-[1fr,1fr,1fr,auto,auto,auto,auto,auto] lg:items-end">
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<select
className="h-10 w-full rounded-md border border-input bg-background px-3"
value={options.mode}
onChange={(event) =>
onChange({
...options,
mode: event.target.value as OrgPickerMode,
})
}
>
<option value="multiple"> </option>
<option value="single"> </option>
</select>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<select
className="h-10 w-full rounded-md border border-input bg-background px-3"
value={options.select}
onChange={(event) =>
onChange({
...options,
select: event.target.value as OrgPickerSelectableType,
})
}
>
<option value="both">&</option>
<option value="tenant"></option>
<option value="user"></option>
</select>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground">tenant slug</span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
onChange={(event) =>
onChange({
...options,
tenantId: event.target.value,
})
}
placeholder="saman"
type="text"
value={options.tenantId}
/>
</label>
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
<input
checked={options.includeDescendants}
disabled={options.mode === "single"}
onChange={(event) =>
onChange({
...options,
includeDescendants: event.target.checked,
})
}
type="checkbox"
/>
<span> </span>
</label>
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
<input
checked={options.showDescendantToggle}
disabled={options.mode === "single"}
onChange={(event) =>
onChange({
...options,
showDescendantToggle: event.target.checked,
})
}
type="checkbox"
/>
<span> </span>
</label>
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
<input
checked={options.includeInternal}
onChange={(event) =>
onChange({
...options,
includeInternal: event.target.checked,
})
}
type="checkbox"
/>
<span>internal </span>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
min={240}
max={1600}
onChange={(event) =>
onChange({
...options,
width: Number.parseInt(event.target.value || "400", 10),
})
}
type="number"
value={options.width}
/>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
min={240}
max={1600}
onChange={(event) =>
onChange({
...options,
height: Number.parseInt(event.target.value || "600", 10),
})
}
type="number"
value={options.height}
/>
</label>
</div>
);
}
export function OrgPickerEmbedPreviewPage() {
const location = useLocation();
const shareToken = new URLSearchParams(location.search).get("token");
const [options, setOptions] = React.useState<OrgPickerEmbedOptions>(() =>
parseOrgPickerEmbedOptions(location.search),
);
const [lastMessage, setLastMessage] = React.useState<PickerMessage | null>(
null,
);
const pickerSrcBase = buildOrgPickerEmbedSrc(options);
const pickerSrc = shareToken
? `${pickerSrcBase}&token=${encodeURIComponent(shareToken)}`
: pickerSrcBase;
React.useEffect(() => {
const handleMessage = (event: MessageEvent<PickerMessage>) => {
if (!event.data?.type?.startsWith("orgfront:picker:")) return;
setLastMessage(event.data);
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
return (
<div className="space-y-5">
<header className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Embed Preview
</p>
<h1 className="text-2xl font-semibold"> </h1>
</div>
<div
className="min-h-16 w-full overflow-x-auto rounded-md border border-border bg-card px-4 py-3 font-mono text-sm leading-6 text-foreground"
data-testid="embed-preview-src"
>
{pickerSrc}
</div>
</header>
<PickerScenarioControls options={options} onChange={setOptions} />
<section className="grid gap-4 lg:grid-cols-[1fr,360px]">
<div
className="max-w-full resize overflow-auto rounded-md border border-border bg-card"
data-testid="embed-preview-frame-shell"
style={{
width: options.width,
height: options.height,
}}
>
<iframe
className="h-full w-full bg-background"
src={pickerSrc}
title="조직 선택기 임베딩 검증"
/>
</div>
<aside className="space-y-3 rounded-md border border-border bg-card p-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
postMessage
</p>
<h2 className="text-lg font-semibold"> </h2>
</div>
<pre
className="min-h-[280px] overflow-auto rounded-md border border-border bg-background p-3 text-xs"
data-testid="embed-preview-output"
>
{lastMessage
? JSON.stringify(lastMessage, null, 2)
: "아직 수신된 메시지가 없습니다."}
</pre>
</aside>
</section>
</div>
);
}

View File

@@ -0,0 +1,737 @@
import { useQuery } from "@tanstack/react-query";
import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
import * as React from "react";
import { useLocation } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import { fetchAllTenants, fetchUsers } from "../../../lib/adminApi";
import { buildOrgPickerTree, flattenDescendants } from "../pickerTree";
import {
buildOrgPickerEmbedSrc,
nodeKey,
type OrgPickerEmbedOptions,
type OrgPickerMode,
type OrgPickerResult,
type OrgPickerSelectableType,
type OrgPickerSelection,
type OrgPickerTreeNode,
parseOrgPickerEmbedOptions,
parseOrgPickerMode,
parseOrgPickerSelectableType,
} from "../pickerTypes";
function canSelectNode(
node: OrgPickerTreeNode,
select: OrgPickerSelectableType,
) {
return select === "both" || select === node.type;
}
function toSelection(node: OrgPickerTreeNode): OrgPickerSelection {
return {
type: node.type,
id: node.id,
name: node.name,
};
}
function collectSelectedNodes({
roots,
selectedKeys,
includeDescendants,
select,
}: {
roots: OrgPickerTreeNode[];
selectedKeys: Set<string>;
includeDescendants: boolean;
select: OrgPickerSelectableType;
}) {
const selected = new Map<string, OrgPickerTreeNode>();
const visit = (node: OrgPickerTreeNode) => {
const key = nodeKey(node);
if (selectedKeys.has(key) && canSelectNode(node, select)) {
selected.set(key, node);
if (includeDescendants && node.type === "tenant") {
for (const descendant of flattenDescendants(node)) {
if (canSelectNode(descendant, select)) {
selected.set(nodeKey(descendant), descendant);
}
}
}
}
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return Array.from(selected.values()).map(toSelection);
}
function collectCheckedKeys({
roots,
selectedKeys,
includeDescendants,
select,
}: {
roots: OrgPickerTreeNode[];
selectedKeys: Set<string>;
includeDescendants: boolean;
select: OrgPickerSelectableType;
}) {
const checkedKeys = new Set(selectedKeys);
if (!includeDescendants) return checkedKeys;
const visit = (node: OrgPickerTreeNode) => {
const key = nodeKey(node);
if (selectedKeys.has(key) && node.type === "tenant") {
for (const descendant of flattenDescendants(node)) {
if (canSelectNode(descendant, select)) {
checkedKeys.add(nodeKey(descendant));
}
}
}
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return checkedKeys;
}
function postPickerMessage(message: unknown) {
window.parent.postMessage(message, "*");
}
function collectSearchValues(value: unknown, depth = 0): string[] {
if (value == null || depth > 4) return [];
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return [String(value)];
}
if (Array.isArray(value)) {
return value.flatMap((item) => collectSearchValues(item, depth + 1));
}
if (typeof value === "object") {
return Object.entries(value as Record<string, unknown>).flatMap(
([key, item]) => [key, ...collectSearchValues(item, depth + 1)],
);
}
return [];
}
function getNodeSearchValues(node: OrgPickerTreeNode) {
const tenantSearchValues = node.tenant
? collectSearchValues({
id: node.tenant.id,
type: node.tenant.type,
name: node.tenant.name,
slug: node.tenant.slug,
description: node.tenant.description,
status: node.tenant.status,
domains: node.tenant.domains,
parentId: node.tenant.parentId,
config: node.tenant.config,
memberCount: node.tenant.memberCount,
createdAt: node.tenant.createdAt,
updatedAt: node.tenant.updatedAt,
})
: [];
return [
node.type,
node.id,
node.name,
node.parentId ?? "",
...tenantSearchValues,
...collectSearchValues(node.user),
].map((value) => value.toLowerCase());
}
function nodeMatchesSearch(node: OrgPickerTreeNode, query: string) {
return getNodeSearchValues(node).some((value) => value.includes(query));
}
function filterPickerTree(
roots: OrgPickerTreeNode[],
rawQuery: string,
): OrgPickerTreeNode[] {
const query = rawQuery.trim().toLowerCase();
if (!query) return roots;
const filterNode = (node: OrgPickerTreeNode): OrgPickerTreeNode | null => {
const filteredChildren = node.children
.map(filterNode)
.filter((child): child is OrgPickerTreeNode => Boolean(child));
if (nodeMatchesSearch(node, query) || filteredChildren.length > 0) {
return {
...node,
children: filteredChildren,
};
}
return null;
};
return roots
.map(filterNode)
.filter((node): node is OrgPickerTreeNode => Boolean(node));
}
function OrgPickerTree({
roots,
mode,
select,
selectedKeys,
onSingleSelect,
onToggle,
}: {
roots: OrgPickerTreeNode[];
mode: OrgPickerMode;
select: OrgPickerSelectableType;
selectedKeys: Set<string>;
onSingleSelect: (node: OrgPickerTreeNode) => void;
onToggle: (node: OrgPickerTreeNode, checked: boolean) => void;
}) {
return (
<div className="space-y-1" data-testid="org-picker-tree">
{roots.map((node) => (
<OrgPickerTreeItem
key={nodeKey(node)}
mode={mode}
node={node}
onSingleSelect={onSingleSelect}
onToggle={onToggle}
select={select}
selectedKeys={selectedKeys}
/>
))}
</div>
);
}
function OrgPickerTreeItem({
node,
mode,
select,
selectedKeys,
onSingleSelect,
onToggle,
depth = 0,
}: {
node: OrgPickerTreeNode;
mode: OrgPickerMode;
select: OrgPickerSelectableType;
selectedKeys: Set<string>;
onSingleSelect: (node: OrgPickerTreeNode) => void;
onToggle: (node: OrgPickerTreeNode, checked: boolean) => void;
depth?: number;
}) {
const [isOpen, setIsOpen] = React.useState(true);
const selectable = canSelectNode(node, select);
const hasChildren = node.children.length > 0;
const key = nodeKey(node);
const checked = selectedKeys.has(key);
const label = `${node.name} 선택`;
const email = node.type === "user" ? node.user?.email : undefined;
const nameTestId =
node.type === "tenant"
? "org-picker-node-name-tenant"
: "org-picker-node-name-user";
const content = (
<span className="flex min-w-0 flex-col">
<span
className={`truncate font-semibold leading-5 ${
node.type === "tenant" ? "text-[#0a2114]" : ""
}`}
data-testid={nameTestId}
>
{node.name}
</span>
{email ? (
<span className="truncate text-xs leading-5 text-muted-foreground">
{email}
</span>
) : null}
</span>
);
return (
<div className="relative">
<div
className={`group flex min-h-7 items-center gap-1.5 rounded-sm py-0.5 pr-1.5 transition ${
mode === "single" && checked
? "bg-primary/15 text-foreground ring-2 ring-primary/60 shadow-sm"
: "hover:bg-secondary/50"
} ${depth > 0 ? "pl-4" : "pl-1"}`}
data-selected={mode === "single" && checked ? "true" : undefined}
>
{hasChildren ? (
<button
type="button"
className="grid h-6 w-6 shrink-0 place-items-center rounded-sm text-muted-foreground transition hover:bg-secondary"
onClick={() => setIsOpen((current) => !current)}
aria-label={`${node.name} ${isOpen ? "접기" : "펼치기"}`}
>
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
) : (
<span className="h-6 w-6 shrink-0" aria-hidden="true" />
)}
{mode === "multiple" && selectable ? (
<input
aria-label={label}
checked={checked}
className="h-3.5 w-3.5 rounded border-border"
onChange={(event) => onToggle(node, event.target.checked)}
type="checkbox"
/>
) : null}
{mode === "single" && selectable ? (
<button
type="button"
aria-pressed={checked}
className={`min-w-0 flex-1 rounded-sm px-1 text-left outline-none transition focus-visible:ring-2 focus-visible:ring-ring ${
checked ? "text-primary" : ""
}`}
data-selected={checked ? "true" : undefined}
onClick={() => onSingleSelect(node)}
>
{content}
</button>
) : (
<div className="min-w-0 flex-1">{content}</div>
)}
</div>
{isOpen && hasChildren ? (
<div className="ml-4">
{node.children.map((child) => (
<OrgPickerTreeItem
depth={depth + 1}
key={nodeKey(child)}
mode={mode}
node={child}
onSingleSelect={onSingleSelect}
onToggle={onToggle}
select={select}
selectedKeys={selectedKeys}
/>
))}
</div>
) : null}
</div>
);
}
export function OrgPickerEmbedPage() {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const mode = parseOrgPickerMode(searchParams.get("mode"));
const select = parseOrgPickerSelectableType(searchParams.get("select"));
const rootTenantId = searchParams.get("rootTenantId") || undefined;
const includeInternal = searchParams.get("includeInternal") === "true";
const tenantId =
searchParams.get("tenantSlug") ||
searchParams.get("tenantId") ||
searchParams.get("companyTenantId") ||
undefined;
const [includeDescendants, setIncludeDescendants] = React.useState(
searchParams.get("includeDescendants") !== "false",
);
const showDescendantToggle =
searchParams.get("showDescendantToggle") !== "false";
const [searchQuery, setSearchQuery] = React.useState("");
const [selectedKeys, setSelectedKeys] = React.useState<Set<string>>(
() => new Set(),
);
const tenantsQuery = useQuery({
queryKey: ["org-picker-tenants"],
queryFn: () => fetchAllTenants(),
});
const usersQuery = useQuery({
queryKey: ["org-picker-users"],
queryFn: () => fetchUsers(5000, 0),
});
React.useEffect(() => {
postPickerMessage({ type: "orgfront:picker:ready" });
}, []);
const tree = React.useMemo(() => {
return buildOrgPickerTree({
includeInternal,
tenants: tenantsQuery.data?.items ?? [],
users: select === "tenant" ? [] : (usersQuery.data?.items ?? []),
rootTenantId,
tenantId,
});
}, [
includeInternal,
rootTenantId,
select,
tenantId,
tenantsQuery.data,
usersQuery.data,
]);
const selectedItems = React.useMemo(
() =>
collectSelectedNodes({
roots: tree.roots,
selectedKeys,
includeDescendants: mode === "multiple" && includeDescendants,
select,
}),
[includeDescendants, mode, select, selectedKeys, tree.roots],
);
const checkedKeys = React.useMemo(
() =>
collectCheckedKeys({
roots: tree.roots,
selectedKeys,
includeDescendants: mode === "multiple" && includeDescendants,
select,
}),
[includeDescendants, mode, select, selectedKeys, tree.roots],
);
const filteredRoots = React.useMemo(
() => filterPickerTree(tree.roots, searchQuery),
[searchQuery, tree.roots],
);
const handleSingleSelect = (node: OrgPickerTreeNode) => {
setSelectedKeys(new Set([nodeKey(node)]));
};
const handleToggle = (node: OrgPickerTreeNode, checked: boolean) => {
setSelectedKeys((current) => {
const next = new Set(current);
const key = nodeKey(node);
if (checked) next.add(key);
else next.delete(key);
return next;
});
};
const confirmSelection = () => {
const payload: OrgPickerResult = {
mode,
selections: selectedItems,
};
postPickerMessage({ type: "orgfront:picker:confirm", payload });
};
const cancelSelection = () => {
postPickerMessage({ type: "orgfront:picker:cancel" });
};
const isLoading = tenantsQuery.isLoading || usersQuery.isLoading;
const isError = tenantsQuery.isError || usersQuery.isError;
React.useEffect(() => {
const htmlOverflow = document.documentElement.style.overflow;
const bodyOverflow = document.body.style.overflow;
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
return () => {
document.documentElement.style.overflow = htmlOverflow;
document.body.style.overflow = bodyOverflow;
};
}, []);
React.useEffect(() => {
if (!isError) return;
postPickerMessage({
type: "orgfront:picker:error",
error: "org_picker_load_failed",
});
}, [isError]);
if (isLoading) {
return (
<div className="grid min-h-screen place-items-center bg-background p-6 text-muted-foreground">
...
</div>
);
}
if (isError) {
return (
<div className="grid min-h-screen place-items-center bg-background p-6 text-destructive">
.
</div>
);
}
return (
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
<main className="flex min-h-0 flex-1 flex-col">
<div
className="shrink-0 border-b border-border bg-background p-2"
data-testid="org-picker-search-section"
>
<div className="grid grid-cols-[minmax(0,1fr),auto] items-end gap-2">
<div>
<label className="sr-only" htmlFor="org-picker-search">
/
</label>
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
size={16}
/>
<input
id="org-picker-search"
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="ID, 이름, 이메일, 메타데이터"
type="search"
value={searchQuery}
/>
</div>
</div>
{mode === "multiple" && showDescendantToggle ? (
<label
className="inline-flex h-9 items-center gap-2 whitespace-nowrap text-sm"
data-testid="org-picker-descendant-toggle"
>
<input
checked={includeDescendants}
className="h-3.5 w-3.5"
onChange={(event) =>
setIncludeDescendants(event.target.checked)
}
type="checkbox"
/>
<span> </span>
</label>
) : null}
</div>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto p-3"
data-testid="org-picker-tree-scroll"
>
{filteredRoots.length > 0 ? (
<OrgPickerTree
mode={mode}
onSingleSelect={handleSingleSelect}
onToggle={handleToggle}
roots={filteredRoots}
select={select}
selectedKeys={checkedKeys}
/>
) : (
<div className="grid min-h-40 place-items-center rounded-md border border-dashed border-border bg-background p-6 text-center text-sm text-muted-foreground">
.
</div>
)}
</div>
</main>
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-border bg-background px-3 py-2">
<div className="min-w-0 text-sm text-muted-foreground">
{selectedItems.length > 0
? `${selectedItems.length}개 항목 선택됨`
: "선택된 항목이 없습니다."}
</div>
<div className="flex items-center gap-2">
<Button onClick={cancelSelection} type="button" variant="outline">
<X size={16} />
</Button>
<Button
disabled={selectedItems.length === 0}
onClick={confirmSelection}
type="button"
>
<Check size={16} />
</Button>
</div>
</footer>
</div>
);
}
export function OrgPickerPage() {
const location = useLocation();
const shareToken = new URLSearchParams(location.search).get("token");
const [options, setOptions] = React.useState<OrgPickerEmbedOptions>(() =>
parseOrgPickerEmbedOptions(location.search),
);
const pickerSrcBase = buildOrgPickerEmbedSrc(options);
const pickerSrc = shareToken
? `${pickerSrcBase}&token=${encodeURIComponent(shareToken)}`
: pickerSrcBase;
return (
<div className="space-y-4">
<header className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Picker Workbench
</p>
<h1 className="text-2xl font-semibold"> </h1>
</div>
<div className="rounded-md border border-border bg-card px-3 py-2 text-sm text-muted-foreground">
{pickerSrc}
</div>
</header>
<section className="grid gap-3 rounded-md border border-border bg-card p-4 md:grid-cols-2 lg:grid-cols-[1fr,1fr,1fr,auto,auto,auto,auto,auto] lg:items-end">
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<select
className="h-10 w-full rounded-md border border-input bg-background px-3"
value={options.mode}
onChange={(event) =>
setOptions((current) => ({
...current,
mode: event.target.value as OrgPickerMode,
}))
}
>
<option value="multiple"> </option>
<option value="single"> </option>
</select>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<select
className="h-10 w-full rounded-md border border-input bg-background px-3"
value={options.select}
onChange={(event) =>
setOptions((current) => ({
...current,
select: event.target.value as OrgPickerSelectableType,
}))
}
>
<option value="both">&</option>
<option value="tenant"></option>
<option value="user"></option>
</select>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground">tenant slug</span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
onChange={(event) =>
setOptions((current) => ({
...current,
tenantId: event.target.value,
}))
}
placeholder="saman"
type="text"
value={options.tenantId}
/>
</label>
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
<input
checked={options.includeDescendants}
disabled={options.mode === "single"}
onChange={(event) =>
setOptions((current) => ({
...current,
includeDescendants: event.target.checked,
}))
}
type="checkbox"
/>
<span> </span>
</label>
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
<input
checked={options.showDescendantToggle}
disabled={options.mode === "single"}
onChange={(event) =>
setOptions((current) => ({
...current,
showDescendantToggle: event.target.checked,
}))
}
type="checkbox"
/>
<span> </span>
</label>
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
<input
checked={options.includeInternal}
onChange={(event) =>
setOptions((current) => ({
...current,
includeInternal: event.target.checked,
}))
}
type="checkbox"
/>
<span>internal </span>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
max={1600}
min={240}
onChange={(event) =>
setOptions((current) => ({
...current,
width: Number.parseInt(event.target.value || "400", 10),
}))
}
type="number"
value={options.width}
/>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
max={1600}
min={240}
onChange={(event) =>
setOptions((current) => ({
...current,
height: Number.parseInt(event.target.value || "600", 10),
}))
}
type="number"
value={options.height}
/>
</label>
</section>
<div
className="max-w-full resize overflow-auto rounded-md border border-border bg-card"
style={{
width: options.width,
height: options.height,
}}
>
<iframe
className="h-full w-full bg-background"
src={pickerSrc}
title="조직 선택기 테스트"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import type { TenantSummary } from "../../lib/adminApi";
export function getTenantVisibility(tenant: Pick<TenantSummary, "config">) {
const raw = String(tenant.config?.visibility ?? "public").toLowerCase();
if (raw === "internal" || raw === "private") return raw;
return "public";
}
export function filterTenantsByVisibility(
tenants: TenantSummary[],
mode: "internal" | "public",
) {
const excludedIds = new Set<string>();
for (const tenant of tenants) {
const visibility = getTenantVisibility(tenant);
if (
visibility === "private" ||
(mode === "public" && visibility === "internal")
) {
excludedIds.add(tenant.id);
}
}
let changed = true;
while (changed) {
changed = false;
for (const tenant of tenants) {
if (
tenant.parentId &&
excludedIds.has(tenant.parentId) &&
!excludedIds.has(tenant.id)
) {
excludedIds.add(tenant.id);
changed = true;
}
}
}
return tenants.filter((tenant) => !excludedIds.has(tenant.id));
}
export function getOrgUnitType(config: Record<string, unknown> | undefined) {
const value = config?.orgUnitType;
return typeof value === "string" ? value.trim() : "";
}

View File

@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import type { UserSummary } from "../../lib/adminApi";
import { getOrgChartUserDisplayName, getUserOrgProfile } from "./userDisplay";
function user(overrides: Partial<UserSummary>): UserSummary {
return {
id: "user-1",
email: "user@example.com",
name: "홍길동",
role: "user",
status: "active",
createdAt: "",
updatedAt: "",
...overrides,
};
}
describe("getOrgChartUserDisplayName", () => {
it("renders name with grade and without job details", () => {
expect(
getOrgChartUserDisplayName(
user({
grade: "수석",
position: "팀장",
jobTitle: "구조",
}),
),
).toBe("홍길동 수석");
});
it("uses tenant appointment grade before the user grade", () => {
expect(
getOrgChartUserDisplayName(
user({
grade: "책임",
metadata: {
additionalAppointments: [
{
tenantSlug: "hanmac",
grade: "수석",
position: "센터장",
},
],
},
}),
{ id: "tenant-1", slug: "hanmac" },
),
).toBe("홍길동 수석");
});
it("uses short grade aliases in the display name", () => {
expect(
getOrgChartUserDisplayName(
user({
grade: "책임연구원",
jobTitle: "구조",
}),
),
).toBe("홍길동 책임");
});
it("does not add leader text to the display name", () => {
expect(
getOrgChartUserDisplayName(
user({
grade: "책임",
metadata: {
additionalAppointments: [
{
tenantSlug: "hanmac",
isOwner: true,
grade: "수석",
position: "센터장",
},
],
},
}),
{ id: "tenant-1", slug: "hanmac" },
),
).toBe("홍길동 수석");
});
it("does not leak an owner appointment flag into another tenant display", () => {
expect(
getOrgChartUserDisplayName(
user({
grade: "책임",
position: "팀원",
metadata: {
additionalAppointments: [
{
tenantSlug: "hanmac",
isOwner: true,
grade: "수석",
position: "센터장",
},
],
},
}),
{ id: "tenant-2", slug: "baron" },
),
).toBe("홍길동 책임");
});
});
describe("getUserOrgProfile", () => {
it("marks owner, manager, and admin flags as highlighted profiles", () => {
expect(
getUserOrgProfile(
user({
metadata: {
additionalAppointments: [
{
tenantSlug: "owner",
isOwner: true,
},
{
tenantSlug: "manager",
isManager: true,
},
{
tenantSlug: "admin",
isAdmin: true,
},
],
},
}),
{ id: "tenant-1", slug: "owner" },
).isHighlighted,
).toBe(true);
expect(
getUserOrgProfile(
user({
metadata: {
additionalAppointments: [{ tenantSlug: "leader", isLeader: true }],
},
}),
{ id: "tenant-2", slug: "leader" },
).isHighlighted,
).toBe(false);
expect(
getUserOrgProfile(
user({
metadata: {
additionalAppointments: [
{ tenantSlug: "manager", isManager: true },
],
},
}),
{ id: "tenant-2", slug: "manager" },
).isHighlighted,
).toBe(true);
expect(
getUserOrgProfile(
user({
metadata: {
additionalAppointments: [{ tenantSlug: "admin", isAdmin: true }],
},
}),
{ id: "tenant-3", slug: "admin" },
).isHighlighted,
).toBe(true);
expect(getUserOrgProfile(user({ grade: "책임" })).isHighlighted).toBe(
false,
);
});
});

View File

@@ -0,0 +1,77 @@
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
import { getOrgRankDisplayName } from "./rankPriority";
type UserAppointment = {
tenantId?: string;
tenantSlug?: string;
isAdmin?: boolean;
isManager?: boolean;
isOwner?: boolean;
grade?: string;
jobTitle?: string;
position?: string;
};
type TenantIdentity = Pick<TenantSummary, "id" | "slug">;
function normalizeText(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function getUserAppointments(user: UserSummary): UserAppointment[] {
const rawAppointments = user.metadata?.additionalAppointments;
if (!Array.isArray(rawAppointments)) return [];
return rawAppointments
.filter(
(item): item is Record<string, unknown> =>
typeof item === "object" && item !== null,
)
.map((item) => ({
tenantId: normalizeText(item.tenantId),
tenantSlug: normalizeText(item.tenantSlug),
isAdmin: item.isAdmin === true,
isManager: item.isManager === true,
isOwner: item.isOwner === true,
grade: normalizeText(item.grade),
jobTitle: normalizeText(item.jobTitle),
position: normalizeText(item.position),
}));
}
export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
const appointment = getUserAppointments(user).find((item) => {
if (tenant?.id && item.tenantId === tenant.id) return true;
if (
tenant?.slug &&
item.tenantSlug &&
item.tenantSlug.toLowerCase() === tenant.slug.toLowerCase()
) {
return true;
}
return false;
});
return {
grade: appointment?.grade || normalizeText(user.grade),
isHighlighted:
appointment?.isAdmin === true ||
appointment?.isManager === true ||
appointment?.isOwner === true,
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
position: appointment?.position || normalizeText(user.position),
};
}
export function getOrgChartUserDisplayName(
user: UserSummary,
tenant?: TenantIdentity,
) {
const { grade } = getUserOrgProfile(user, tenant);
const baseName = user.name.trim();
const displayGrade = getOrgRankDisplayName(grade);
let displayName = baseName;
if (displayGrade) displayName = `${baseName} ${displayGrade}`;
return displayName;
}

View File

@@ -0,0 +1,218 @@
import { useQuery } from "@tanstack/react-query";
import {
Briefcase,
Building2,
Fingerprint,
Mail,
Shield,
User,
} from "lucide-react";
import { useState } from "react";
import { useAuth } from "react-oidc-context";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { t } from "../../lib/i18n";
import { fetchMe } from "../auth/authApi";
import ProfileTenantSwitcher from "./ProfileTenantSwitcher";
function ProfilePage() {
const auth = useAuth();
const hasAccessToken = Boolean(auth.user?.access_token);
const {
data: profile,
isLoading,
error,
} = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const [activeTab, setActiveTab] = useState<"basic" | "role">("basic");
if (isLoading) {
return (
<div className="p-8 text-center">
{t("ui.dev.profile.loading", "Loading profile...")}
</div>
);
}
if (error || !profile) {
return (
<div className="p-8 text-center text-red-500">
{t("ui.dev.profile.error", "Failed to load profile information.")}
</div>
);
}
// Fallback to token information if API data is incomplete
const displayTenant =
profile.tenant?.name ||
profile.tenantId ||
auth.user?.profile?.tenant_id?.toString() ||
"-";
const displayTenantSlug = profile.tenant?.slug || profile.tenantId || "-";
return (
<div className="space-y-6 max-w-4xl mx-auto">
<div>
<h1 className="text-3xl font-black tracking-tight">
{t("ui.dev.profile.title", "내 정보")}
</h1>
<p className="text-muted-foreground mt-2">
{t(
"ui.dev.profile.subtitle",
"사용자 상세 정보 및 할당된 역할(Role)을 확인합니다.",
)}
</p>
</div>
<div className="flex space-x-1 border-b border-border pb-px">
<button
type="button"
onClick={() => setActiveTab("basic")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "basic"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
{t("ui.dev.profile.tab.basic", "기본 정보")}
</button>
<button
type="button"
onClick={() => setActiveTab("role")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "role"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
{t("ui.dev.profile.tab.role", "권한 및 역할")}
</button>
</div>
<div className="pt-4">
{activeTab === "basic" && (
<div className="grid gap-6 md:grid-cols-2">
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
{t("ui.dev.profile.basic.title", "사용자 정보")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Fingerprint className="h-4 w-4" />
{t("ui.dev.profile.basic.id", "User ID")}
</p>
<p className="text-sm break-all font-mono bg-muted/50 p-2 rounded-md">
{profile.id}
</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<User className="h-4 w-4" />
{t("ui.dev.profile.basic.name", "Name")}
</p>
<p className="text-sm">{profile.name}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Mail className="h-4 w-4" />
{t("ui.dev.profile.basic.email", "Email")}
</p>
<p className="text-sm">{profile.email}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Briefcase className="h-4 w-4" />
{t("ui.dev.profile.basic.phone", "Phone")}
</p>
<p className="text-sm">{profile.phone || "-"}</p>
</div>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Building2 className="h-5 w-5 text-primary" />
{t("ui.dev.profile.org.title", "조직 정보")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">
{t("ui.dev.profile.org.tenant", "테넌트")}
</p>
<p className="text-sm">{displayTenant}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">
{t("ui.dev.profile.org.tenant_slug", "테넌트 Slug")}
</p>
<p className="text-sm">{displayTenantSlug}</p>
</div>
</CardContent>
</Card>
<ProfileTenantSwitcher />
</div>
)}
{activeTab === "role" && (
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
{t("ui.dev.profile.role.title", "시스템 역할")}
</CardTitle>
<CardDescription>
{t(
"ui.dev.profile.role.description",
"현재 계정에 부여된 권한 등급입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 bg-muted/30 p-4 rounded-lg border border-border">
<div className="h-12 w-12 rounded-full bg-primary/20 flex items-center justify-center text-primary shrink-0">
<Briefcase className="h-6 w-6" />
</div>
<div className="flex flex-col gap-1 w-full">
<p className="text-sm text-muted-foreground font-medium uppercase tracking-wider">
{t("ui.dev.profile.role.current", "Current Role")}
</p>
<p className="text-xl font-bold mt-1">
{t(
`ui.common.role.${profile.role}`,
profile.role.toUpperCase(),
)}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t(
`ui.dev.profile.role.desc_${profile.role}`,
"시스템 역할에 대한 설명이 제공되지 않았습니다.",
)}
</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}
export default ProfilePage;

View File

@@ -0,0 +1,92 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Building2, Save } from "lucide-react";
import { useState } from "react";
import { Button } from "../../components/ui/button";
import { toast } from "../../components/ui/use-toast";
import { fetchMyTenants } from "../../lib/devApi";
import { t } from "../../lib/i18n";
export default function ProfileTenantSwitcher() {
const queryClient = useQueryClient();
const { data: tenants, isLoading } = useQuery({
queryKey: ["myTenants"],
queryFn: fetchMyTenants,
});
const [selectedTenantId, setSelectedTenantId] = useState<string>(() => {
return window.localStorage.getItem("dev_tenant_id") || "";
});
const handleSave = () => {
window.localStorage.setItem("dev_tenant_id", selectedTenantId);
// Invalidate queries to refresh data with new tenant context
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] !== "userMe" && query.queryKey[0] !== "myTenants",
});
toast(t("ui.dev.tenant.switch_success", "테넌트 전환 완료"), "success");
};
if (isLoading || !tenants || tenants.length === 0) {
return null;
}
// If there's only one tenant, the user doesn't need to switch.
// Still show it as read-only or hidden. Let's just show it as disabled.
const isSingleTenant = tenants.length <= 1;
return (
<div className="flex flex-col gap-4 mt-6 p-4 rounded-lg border border-border bg-card">
<div className="flex items-center gap-2 mb-2">
<Building2 className="h-5 w-5 text-primary" />
<h3 className="font-semibold">
{t("ui.dev.tenant.workspace", "작업 테넌트 (컨텍스트)")}
</h3>
</div>
<p className="text-sm text-muted-foreground -mt-2 mb-2">
{t(
"ui.dev.tenant.workspace_desc",
"현재 작업 중인 테넌트를 선택하고 저장하여 API 요청 컨텍스트를 변경합니다.",
)}
</p>
<div className="flex items-center gap-3">
<select
aria-label={t("ui.dev.tenant.workspace", "작업 테넌트 (컨텍스트)")}
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
disabled={isSingleTenant}
className="flex h-10 w-full rounded-md 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"
>
{tenants.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name}
</option>
))}
</select>
<Button
type="button"
onClick={handleSave}
disabled={isSingleTenant}
className="gap-2"
>
<Save size={16} />
{t("ui.common.save", "저장")}
</Button>
</div>
{isSingleTenant && (
<p className="text-xs text-muted-foreground mt-1">
{t(
"ui.dev.tenant.single_notice",
"단일 테넌트에 소속되어 전환할 필요가 없습니다.",
)}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
@import "../../common/theme/base.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--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%;
--radius: 0.75rem;
--app-background-image: linear-gradient(
180deg,
hsl(var(--background)) 0%,
hsl(var(--secondary) / 0.35) 100%
);
}
}

View File

@@ -0,0 +1,216 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiClient = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
};
const fetchAllCursorPages = vi.fn(async () => ({
items: [{ id: "tenant-1", name: "Tenant", slug: "tenant" }],
total: 1,
}));
vi.mock("./apiClient", () => ({
default: apiClient,
}));
vi.mock("./auth", () => ({
userManager: {
getUser: vi.fn(async () => ({ access_token: "access-token" })),
},
}));
vi.mock("../../../common/core/pagination", () => ({
fetchAllCursorPages,
}));
describe("orgfront adminApi user tenant payloads", () => {
beforeEach(() => {
apiClient.get.mockReset();
apiClient.post.mockReset();
apiClient.put.mockReset();
apiClient.delete.mockReset();
apiClient.get.mockResolvedValue({ data: { ok: true } });
apiClient.post.mockResolvedValue({ data: { ok: true } });
apiClient.put.mockResolvedValue({ data: { ok: true } });
apiClient.delete.mockResolvedValue({ data: { ok: true } });
fetchAllCursorPages.mockClear();
window.localStorage.clear();
});
it("routes read APIs to their documented orgfront admin endpoints", async () => {
const adminApi = await import("./adminApi");
await adminApi.fetchAuditLogs(10, "cursor-a");
await adminApi.fetchTenants(25, 50, "parent-1", "cursor-b");
await adminApi.fetchAllTenants({ pageSize: 200, parentId: "parent-1" });
await adminApi.fetchTenant("tenant-1");
await adminApi.fetchTenantAdmins("tenant-1");
await adminApi.fetchTenantOwners("tenant-1");
await adminApi.fetchGroups("tenant-1");
await adminApi.fetchGroup("tenant-1", "group-1");
await adminApi.fetchImportProgress("tenant-1", "progress-1");
await adminApi.fetchGroupRoles("tenant-1", "group-1");
await adminApi.fetchApiKeys(20, 40);
await adminApi.fetchUsers(30, 60, "admin", "tenant");
await adminApi.fetchUser("user-1");
await adminApi.fetchPasswordPolicy();
await adminApi.fetchUserRpHistory("user-1");
await adminApi.fetchMe();
await adminApi.fetchRelyingParties("tenant-1");
await adminApi.fetchAllRelyingParties();
await adminApi.fetchRelyingParty("client-1");
await adminApi.fetchRPOwners("client-1");
await adminApi.fetchPublicOrgChart("public-token");
await adminApi.fetchOrgChartSnapshot();
expect(apiClient.get).toHaveBeenCalledWith("/v1/audit", {
params: { limit: 10, cursor: "cursor-a" },
});
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/tenants", {
params: {
limit: 25,
offset: 50,
parentId: "parent-1",
cursor: "cursor-b",
},
});
expect(fetchAllCursorPages).toHaveBeenCalledWith(
expect.objectContaining({
path: "/v1/admin/tenants",
pageSize: 200,
params: { parentId: "parent-1" },
}),
);
expect(apiClient.get).toHaveBeenCalledWith(
"/v1/admin/tenants/tenant-1/organization/group-1/roles",
);
expect(apiClient.get).toHaveBeenCalledWith("/v1/public/orgchart", {
params: { token: "public-token" },
});
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/orgchart/snapshot", {
params: { cache: "redis" },
});
});
it("routes mutation APIs to their documented orgfront admin endpoints", async () => {
const adminApi = await import("./adminApi");
await adminApi.createTenant({ name: "Tenant", slug: "tenant" });
await adminApi.updateTenant("tenant-1", { status: "inactive" });
await adminApi.deleteTenant("tenant-1");
await adminApi.deleteTenantsBulk(["tenant-1"]);
await adminApi.approveTenant("tenant-1");
await adminApi.addTenantAdmin("tenant-1", "user-1");
await adminApi.removeTenantAdmin("tenant-1", "user-1");
await adminApi.addTenantOwner("tenant-1", "user-1");
await adminApi.removeTenantOwner("tenant-1", "user-1");
await adminApi.createGroup("tenant-1", { name: "Group" });
await adminApi.deleteGroup("tenant-1", "group-1");
await adminApi.addGroupMember("tenant-1", "group-1", "user-1");
await adminApi.removeGroupMember("tenant-1", "group-1", "user-1");
await adminApi.importOrgChart(
"tenant-1",
new File(["name"], "org.csv"),
"progress-1",
);
await adminApi.assignGroupRole("tenant-1", "group-1", "tenant-2", "owner");
await adminApi.removeGroupRole("tenant-1", "group-1", "tenant-2", "owner");
await adminApi.createApiKey({ name: "key", scopes: ["read"] });
await adminApi.deleteApiKey("key-1");
await adminApi.createUser({ email: "user@example.com", name: "User" });
await adminApi.bulkCreateUsers([
{ email: "user@example.com", name: "User", metadata: {} },
]);
await adminApi.bulkUpdateUsers({ userIds: ["user-1"], status: "inactive" });
await adminApi.bulkDeleteUsers(["user-1"]);
await adminApi.updateUser("user-1", { status: "active" });
await adminApi.deleteUser("user-1");
await adminApi.createRelyingParty("tenant-1", {
client_name: "RP",
redirect_uris: ["https://rp.example/callback"],
});
await adminApi.updateRelyingParty("client-1", {
client_name: "RP",
redirect_uris: ["https://rp.example/callback"],
});
await adminApi.deleteRelyingParty("client-1");
await adminApi.addRPOwner("client-1", "User:user-1");
await adminApi.removeRPOwner("client-1", "User:user-1");
expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/tenants/tenant-1/organization/group-1/members",
{ userId: "user-1" },
);
expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/tenants/tenant-1/organization/import?progressId=progress-1",
expect.any(FormData),
{ headers: { "Content-Type": "multipart/form-data" } },
);
expect(apiClient.put).toHaveBeenCalledWith("/v1/admin/users/user-1", {
status: "active",
});
expect(apiClient.delete).toHaveBeenCalledWith(
"/v1/admin/relying-parties/client-1/owners/User:user-1",
);
});
it("sends tenantSlug without remapping it to companyCode when creating a user", async () => {
const { createUser } = await import("./adminApi");
await createUser({
email: "user@test.com",
name: "Test User",
tenantSlug: "test-tenant",
});
expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/users",
expect.objectContaining({ tenantSlug: "test-tenant" }),
);
expect(apiClient.post.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
it("sends tenantSlug without remapping it to companyCode when updating a user", async () => {
const { updateUser } = await import("./adminApi");
await updateUser("user-id", { tenantSlug: "new-tenant" });
expect(apiClient.put).toHaveBeenCalledWith(
"/v1/admin/users/user-id",
expect.objectContaining({ tenantSlug: "new-tenant" }),
);
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
it("keeps tenantSlug payloads unchanged for bulk user APIs", async () => {
const { bulkCreateUsers, bulkUpdateUsers } = await import("./adminApi");
await bulkCreateUsers([
{
email: "user@test.com",
name: "Test User",
tenantSlug: "test-tenant",
metadata: {},
},
]);
await bulkUpdateUsers({
userIds: ["user-id"],
tenantSlug: "new-tenant",
});
expect(apiClient.post.mock.calls[0][1].users[0]).toMatchObject({
tenantSlug: "test-tenant",
});
expect(apiClient.post.mock.calls[0][1].users[0]).not.toHaveProperty(
"companyCode",
);
expect(apiClient.put.mock.calls[0][1]).toMatchObject({
tenantSlug: "new-tenant",
});
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
});

View File

@@ -0,0 +1,771 @@
import { fetchAllCursorPages } from "../../../common/core/pagination";
import apiClient from "./apiClient";
import { userManager } from "./auth";
export type AuditLog = {
event_id: string;
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;
cursor?: string;
next_cursor?: string;
};
export type TenantSummary = {
id: string;
type: string; // 허용 타입: PERSONAL, COMPANY, COMPANY_GROUP, ORGANIZATION, USER_GROUP
name: string;
slug: string;
description: string;
status: string;
domains?: string[];
parentId?: string;
config?: Record<string, unknown>;
memberCount: number; // Added member count
totalMemberCount?: number;
createdAt: string;
updatedAt: string;
};
export type TenantCreateRequest = {
name: string;
type?: string;
slug?: string;
parentId?: string;
description?: string;
status?: string;
domains?: string[];
config?: Record<string, unknown>;
};
export type TenantListResponse = {
items: TenantSummary[];
limit: number;
offset: number;
total: number;
cursor?: string;
nextCursor?: string;
next_cursor?: string;
};
export type TenantUpdateRequest = {
name?: string;
type?: string;
slug?: string;
parentId?: string;
description?: string;
status?: string;
domains?: string[];
config?: Record<string, unknown>;
};
export type ApiKeySummary = {
id: string;
name: string;
client_id: string;
scopes: string[];
status: string;
lastUsedAt?: string;
createdAt: string;
};
export type ApiKeyListResponse = {
items: ApiKeySummary[];
total: number;
};
export type RoleSummary = {
id: string;
name: string;
description: string;
permissions: string[];
createdAt: string;
updatedAt: string;
};
export type RoleListResponse = {
items: RoleSummary[];
total: number;
};
export async function fetchAuditLogs(limit = 50, cursor?: string) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor },
});
return data;
}
export async function fetchTenants(
limit = 50,
offset = 0,
parentId?: string,
cursor?: string,
) {
const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants",
{
params: { limit, offset, parentId, cursor },
},
);
return data;
}
function getOrgApiBaseUrl() {
return (
import.meta.env.VITE_DEV_API_BASE ??
import.meta.env.VITE_ADMIN_API_BASE ??
"/api"
);
}
async function buildOrgRequestHeaders() {
const headers: Record<string, string> = {};
const user = await userManager.getUser();
if (user?.access_token) {
headers.Authorization = `Bearer ${user.access_token}`;
}
const tenantId = window.localStorage.getItem("dev_tenant_id");
if (tenantId) {
headers["X-Tenant-ID"] = tenantId;
}
return headers;
}
export async function fetchAllTenants({
pageSize = 100,
parentId,
}: {
pageSize?: number;
parentId?: string;
} = {}) {
return fetchAllCursorPages<TenantSummary>({
baseUrl: getOrgApiBaseUrl(),
path: "/v1/admin/tenants",
pageSize,
params: { parentId },
headers: await buildOrgRequestHeaders(),
}) as Promise<TenantListResponse>;
}
export type OrgChartSnapshotResponse = {
tenants: TenantSummary[];
users: UserSummary[];
cache?: {
source: "redis" | "database";
hit: boolean;
ttlSeconds?: number;
};
};
export async function fetchOrgChartSnapshot() {
const { data } = await apiClient.get<OrgChartSnapshotResponse>(
"/v1/admin/orgchart/snapshot",
{
params: { cache: "redis" },
},
);
return data;
}
export async function fetchTenant(tenantId: string) {
const { data } = await apiClient.get<TenantSummary>(
`/v1/admin/tenants/${tenantId}`,
);
return data;
}
export async function createTenant(payload: TenantCreateRequest) {
const { data } = await apiClient.post<TenantSummary>(
"/v1/admin/tenants",
payload,
);
return data;
}
export async function updateTenant(
tenantId: string,
payload: TenantUpdateRequest,
) {
const { data } = await apiClient.put<TenantSummary>(
`/v1/admin/tenants/${tenantId}`,
payload,
);
return data;
}
export async function deleteTenant(tenantId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
}
export async function deleteTenantsBulk(ids: string[]) {
await apiClient.delete("/v1/admin/tenants/bulk", {
data: { ids },
});
}
export async function approveTenant(tenantId: string) {
const { data } = await apiClient.post<TenantSummary>(
`/v1/admin/tenants/${tenantId}/approve`,
);
return data;
}
export type TenantAdmin = {
id: string;
name: string;
email: string;
};
export async function fetchTenantAdmins(tenantId: string) {
const { data } = await apiClient.get<TenantAdmin[]>(
`/v1/admin/tenants/${tenantId}/admins`,
);
return data;
}
export async function addTenantAdmin(tenantId: string, userId: string) {
await apiClient.post(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
}
export async function removeTenantAdmin(tenantId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
}
export async function fetchTenantOwners(tenantId: string) {
const { data } = await apiClient.get<TenantAdmin[]>(
`/v1/admin/tenants/${tenantId}/owners`,
);
return data;
}
export async function addTenantOwner(tenantId: string, userId: string) {
await apiClient.post(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
}
export async function removeTenantOwner(tenantId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
}
// Group Management
export type GroupMember = {
id: string;
name: string;
email: string;
};
export type GroupSummary = {
id: string;
tenantId: string;
parentId?: string;
name: string;
description?: string;
unitType?: string;
members?: GroupMember[];
createdAt?: string;
updatedAt?: string;
};
export type GroupCreateRequest = {
name: string;
parentId?: string;
description?: string;
unitType?: string;
};
export async function fetchGroups(tenantId: string) {
const { data } = await apiClient.get<GroupSummary[]>(
`/v1/admin/tenants/${tenantId}/organization`,
);
return data;
}
export async function fetchGroup(tenantId: string, groupId: string) {
const { data } = await apiClient.get<GroupSummary>(
`/v1/admin/tenants/${tenantId}/organization/${groupId}`,
);
return data;
}
export async function createGroup(
tenantId: string,
payload: GroupCreateRequest,
) {
const { data } = await apiClient.post<GroupSummary>(
`/v1/admin/tenants/${tenantId}/organization`,
payload,
);
return data;
}
export async function deleteGroup(tenantId: string, groupId: string) {
await apiClient.delete(
`/v1/admin/tenants/${tenantId}/organization/${groupId}`,
);
}
export async function addGroupMember(
tenantId: string,
groupId: string,
userId: string,
) {
await apiClient.post(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/members`,
{ userId },
);
}
export async function removeGroupMember(
tenantId: string,
groupId: string,
userId: string,
) {
await apiClient.delete(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/members/${userId}`,
);
}
export interface ImportResult {
totalRows: number;
processed: number;
userCreated: number;
userUpdated: number;
tenantCreated: number;
errors: string[];
}
export async function fetchImportProgress(
tenantId: string,
progressId: string,
) {
const { data } = await apiClient.get<{ current: number; total: number }>(
`/v1/admin/tenants/${tenantId}/organization/import/progress/${progressId}`,
);
return data;
}
export async function importOrgChart(
tenantId: string,
file: File,
progressId?: string,
) {
const formData = new FormData();
formData.append("file", file);
const url = progressId
? `/v1/admin/tenants/${tenantId}/organization/import?progressId=${progressId}`
: `/v1/admin/tenants/${tenantId}/organization/import`;
const { data } = await apiClient.post<{ data: ImportResult }>(url, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return data.data;
}
export type GroupRole = {
tenantId: string;
tenantName: string;
relation: string;
};
export async function fetchGroupRoles(tenantId: string, groupId: string) {
const { data } = await apiClient.get<GroupRole[]>(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles`,
);
return data;
}
export async function assignGroupRole(
tenantId: string,
groupId: string,
targetTenantId: string,
relation: string,
) {
await apiClient.post(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles`,
{ tenantId: targetTenantId, relation },
);
}
export async function removeGroupRole(
tenantId: string,
groupId: string,
targetTenantId: string,
relation: string,
) {
await apiClient.delete(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles/${targetTenantId}/${relation}`,
);
}
// API Key Management (M2M)
export type ApiKeyCreateRequest = {
name: string;
scopes: string[];
};
export type ApiKeyCreateResponse = {
apiKey: ApiKeySummary;
clientSecret: string;
};
export async function fetchApiKeys(limit = 50, offset = 0) {
const { data } = await apiClient.get<ApiKeyListResponse>(
"/v1/admin/api-keys",
{
params: { limit, offset },
},
);
return data;
}
export async function createApiKey(payload: ApiKeyCreateRequest) {
const { data } = await apiClient.post<ApiKeyCreateResponse>(
"/v1/admin/api-keys",
payload,
);
return data;
}
export async function deleteApiKey(apiKeyId: string) {
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
}
// User Management
export type UserSummary = {
id: string;
email: string;
loginId?: string;
name: string;
phone?: string;
role: string;
status: string;
tenantSlug?: string;
companyCode?: string;
tenant?: TenantSummary;
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
metadata?: Record<string, unknown>;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
createdAt: string;
updatedAt: string;
};
export type UserListResponse = {
items: UserSummary[];
limit: number;
offset: number;
total: number;
};
export type UserCreateRequest = {
email: string;
loginId?: string;
password?: string;
name: string;
phone?: string;
role?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
};
export type UserCreateResponse = UserSummary & {
initialPassword?: string;
};
export type UserUpdateRequest = {
loginId?: string;
password?: string;
name?: string;
phone?: string;
role?: string;
status?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
};
export type BulkUserItem = {
email: string;
loginId?: string;
name: string;
phone?: string;
role?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
metadata: Record<string, string>;
};
export type BulkUserResult = {
email: string;
success: boolean;
message?: string;
userId?: string;
};
export type BulkUserResponse = {
results: BulkUserResult[];
};
export async function fetchUsers(
limit = 50,
offset = 0,
search?: string,
tenantSlug?: string,
) {
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
params: { limit, offset, search, tenantSlug },
});
return data;
}
export async function fetchUser(userId: string) {
const { data } = await apiClient.get<UserSummary>(
`/v1/admin/users/${userId}`,
);
return data;
}
export async function createUser(payload: UserCreateRequest) {
const { data } = await apiClient.post<UserCreateResponse>(
"/v1/admin/users",
payload,
);
return data;
}
export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
const params = new URLSearchParams();
if (search) params.append("search", search);
if (tenantSlug) params.append("tenantSlug", tenantSlug);
// Get mock role from storage if exists for dev environment
const isMockRoleEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRole = window.localStorage.getItem("X-Mock-Role");
if (isMockRoleEnabled && mockRole) params.append("x-test-role", mockRole);
const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1";
return `${baseUrl}/admin/users/export?${params.toString()}`;
}
export async function bulkCreateUsers(users: BulkUserItem[]) {
const { data } = await apiClient.post<BulkUserResponse>(
"/v1/admin/users/bulk",
{ users },
);
return data;
}
export async function bulkUpdateUsers(payload: {
userIds: string[];
status?: string;
role?: string;
tenantSlug?: string;
department?: string;
}) {
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
return data;
}
export async function bulkDeleteUsers(userIds: string[]) {
const { data } = await apiClient.delete("/v1/admin/users/bulk", {
data: { userIds },
});
return data;
}
export async function updateUser(userId: string, payload: UserUpdateRequest) {
const { data } = await apiClient.put<UserSummary>(
`/v1/admin/users/${userId}`,
payload,
);
return data;
}
export type PasswordPolicyResponse = {
minLength?: number;
lowercase?: boolean;
uppercase?: boolean;
number?: boolean;
nonAlphanumeric?: boolean;
minCharacterTypes?: number;
};
export async function fetchPasswordPolicy() {
const { data } = await apiClient.get<PasswordPolicyResponse>(
"/v1/auth/password/policy",
);
return data;
}
export async function deleteUser(userId: string) {
await apiClient.delete(`/v1/admin/users/${userId}`);
}
export type UserRpHistoryItem = {
client_id: string;
client_name: string;
lastLoginAt: string;
status: string;
};
export async function fetchUserRpHistory(userId: string) {
const { data } = await apiClient.get<UserRpHistoryItem[]>(
`/v1/admin/users/${userId}/rp-history`,
);
return data;
}
export type UserProfileResponse = {
id: string;
email: string;
name: string;
phone: string;
role: string;
department: string;
affiliationType: string;
tenantSlug?: string;
tenantId?: string;
metadata?: Record<string, unknown>;
tenant?: TenantSummary;
manageableTenants?: TenantSummary[];
};
export async function fetchMe() {
const { data } = await apiClient.get<UserProfileResponse>("/v1/user/me");
return data;
}
// Relying Party Management
export type RelyingParty = {
clientId: string;
tenantId: string;
name: string;
description: string;
createdAt: string;
updatedAt: string;
};
export type HydraClientReq = {
client_id?: string;
client_name: string;
client_secret?: string;
redirect_uris: string[];
scope?: string;
token_endpoint_auth_method?: string;
grant_types?: string[];
response_types?: string[];
metadata?: Record<string, unknown>;
};
export async function fetchRelyingParties(tenantId: string) {
const { data } = await apiClient.get<RelyingParty[]>(
`/v1/admin/tenants/${tenantId}/relying-parties`,
);
return data;
}
export async function fetchAllRelyingParties() {
const { data } = await apiClient.get<RelyingParty[]>(
"/v1/admin/relying-parties",
);
return data;
}
export async function createRelyingParty(
tenantId: string,
payload: HydraClientReq,
) {
const { data } = await apiClient.post<RelyingParty>(
`/v1/admin/tenants/${tenantId}/relying-parties`,
payload,
);
return data;
}
export async function fetchRelyingParty(id: string) {
const { data } = await apiClient.get<{
relyingParty: RelyingParty;
oauth2Config: HydraClientReq;
}>(`/v1/admin/relying-parties/${id}`);
return data;
}
export async function updateRelyingParty(id: string, payload: HydraClientReq) {
const { data } = await apiClient.put<RelyingParty>(
`/v1/admin/relying-parties/${id}`,
payload,
);
return data;
}
export async function deleteRelyingParty(id: string) {
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
}
export type RPOwner = {
subject: string;
name?: string;
email?: string;
type: string;
};
export async function fetchRPOwners(clientId: string) {
const { data } = await apiClient.get<RPOwner[]>(
`/v1/admin/relying-parties/${clientId}/owners`,
);
return data;
}
export async function addRPOwner(clientId: string, subject: string) {
await apiClient.post(
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
);
}
export async function removeRPOwner(clientId: string, subject: string) {
await apiClient.delete(
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
);
}
export async function fetchPublicOrgChart(token: string) {
const { data } = await apiClient.get<{
tenants: TenantSummary[];
users: UserSummary[];
sharedWith: string;
}>("/v1/public/orgchart", {
params: { token },
});
return data;
}

View File

@@ -0,0 +1,75 @@
import axios from "axios";
import { shouldStartLoginRedirect } from "../../../common/core/auth";
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
import { userManager } from "./auth";
let isRedirectingToLogin = false;
const apiClient = axios.create({
baseURL:
import.meta.env.VITE_DEV_API_BASE ??
import.meta.env.VITE_ADMIN_API_BASE ??
"/api",
});
apiClient.interceptors.request.use(async (config) => {
// OIDC Access Token 주입
const user = await userManager.getUser();
if (user?.access_token) {
config.headers.Authorization = `Bearer ${user.access_token}`;
}
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
const tenantId = window.localStorage.getItem("dev_tenant_id"); // 키 이름을 좀 더 명확하게 변경 고려
if (tenantId) {
config.headers["X-Tenant-ID"] = tenantId;
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const status = error.response?.status;
const message =
error.response?.data?.error?.toString().toLowerCase() ??
error.response?.data?.message?.toString().toLowerCase() ??
"";
const shouldRedirectToLogin =
status === 401 ||
(status === 403 &&
(message.includes("authentication required") ||
message.includes("invalid session") ||
message.includes("token is not active")));
if (!shouldRedirectToLogin) {
return Promise.reject(error);
}
if (
shouldSuppressDevelopmentSessionRedirect({
appMode: import.meta.env.MODE,
})
) {
console.warn(
"[apiClient] Auth failure detected, but development session redirects are disabled.",
);
return Promise.reject(error);
}
if (
shouldStartLoginRedirect({
pathname: window.location.pathname,
isRedirecting: isRedirectingToLogin,
})
) {
isRedirectingToLogin = true;
await userManager.removeUser();
window.location.href = "/login";
}
return Promise.reject(error);
},
);
export default apiClient;

View File

@@ -0,0 +1,24 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context";
import {
buildCommonOidcRuntimeConfig,
buildCommonUserManagerSettings,
} from "../../../common/core/auth";
import { resolveOrgFrontPublicOrigin } from "./authConfig";
const orgFrontPublicOrigin = resolveOrgFrontPublicOrigin(
import.meta.env.VITE_ORGFRONT_PUBLIC_URL,
window.location.origin,
);
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
authority:
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc",
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront",
origin: orgFrontPublicOrigin,
userStore: new WebStorageStateStore({ store: window.localStorage }),
});
export const userManager = new UserManager(
buildCommonUserManagerSettings(oidcConfig),
);

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
buildOrgFrontAuthRedirectUris,
ORGFRONT_AUTH_CALLBACK_PATH,
resolveOrgFrontPublicOrigin,
} from "./authConfig";
describe("orgfront auth config", () => {
it("builds callback URLs from the public origin", () => {
expect(buildOrgFrontAuthRedirectUris("https://sorg.hmac.kr")).toEqual({
redirectUri: "https://sorg.hmac.kr/auth/callback",
postLogoutRedirectUri: "https://sorg.hmac.kr",
popupRedirectUri: "https://sorg.hmac.kr/auth/callback",
});
});
it("uses the browser origin when the configured origin is empty or invalid", () => {
expect(resolveOrgFrontPublicOrigin("", "http://localhost:5174")).toBe(
"http://localhost:5174",
);
expect(
resolveOrgFrontPublicOrigin("not a url", "http://localhost:5174"),
).toBe("http://localhost:5174");
});
it("keeps the callback path aligned with the registered redirect path", () => {
expect(ORGFRONT_AUTH_CALLBACK_PATH).toBe("/auth/callback");
});
});

View File

@@ -0,0 +1,33 @@
export interface OrgFrontAuthRedirectUris {
redirectUri: string;
postLogoutRedirectUri: string;
popupRedirectUri: string;
}
export const ORGFRONT_AUTH_CALLBACK_PATH = "/auth/callback";
export function resolveOrgFrontPublicOrigin(
configuredOrigin: string | undefined,
browserOrigin: string,
) {
const trimmed = configuredOrigin?.trim();
if (!trimmed) {
return browserOrigin;
}
try {
return new URL(trimmed).origin;
} catch {
return browserOrigin;
}
}
export function buildOrgFrontAuthRedirectUris(
publicOrigin: string,
): OrgFrontAuthRedirectUris {
return {
redirectUri: `${publicOrigin}${ORGFRONT_AUTH_CALLBACK_PATH}`,
postLogoutRedirectUri: publicOrigin,
popupRedirectUri: `${publicOrigin}${ORGFRONT_AUTH_CALLBACK_PATH}`,
};
}

View File

@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiClient = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
};
vi.mock("./apiClient", () => ({
default: apiClient,
}));
describe("orgfront devApi", () => {
beforeEach(() => {
apiClient.get.mockReset();
apiClient.post.mockReset();
apiClient.put.mockReset();
apiClient.patch.mockReset();
apiClient.delete.mockReset();
apiClient.get.mockResolvedValue({ data: { ok: true } });
apiClient.post.mockResolvedValue({ data: { ok: true } });
apiClient.put.mockResolvedValue({ data: { ok: true } });
apiClient.patch.mockResolvedValue({ data: { ok: true } });
apiClient.delete.mockResolvedValue({ data: { ok: true } });
});
it("fetches dev resources with expected query parameters", async () => {
const {
fetchClients,
fetchDevStats,
fetchClient,
fetchConsents,
fetchDevAuditLogs,
fetchMyTenants,
listIdpConfigsForClient,
} = await import("./devApi");
await fetchClients();
await fetchDevStats();
await fetchClient("client-a");
await fetchConsents("user-a", "client-a", "active");
await fetchDevAuditLogs(10, "cursor-a", {
action: "client.update",
client_id: "client-a",
status: "success",
tenant_id: "tenant-a",
});
await fetchMyTenants();
await listIdpConfigsForClient("client-a");
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients");
expect(apiClient.get).toHaveBeenCalledWith("/dev/stats");
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a");
expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
params: { subject: "user-a", client_id: "client-a", status: "active" },
});
expect(apiClient.get).toHaveBeenCalledWith("/dev/audit-logs", {
params: {
limit: 10,
cursor: "cursor-a",
action: "client.update",
client_id: "client-a",
status: "success",
tenant_id: "tenant-a",
},
});
expect(apiClient.get).toHaveBeenCalledWith("/dev/my-tenants");
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a/idps");
});
it("omits optional consent filters when they are empty or all", async () => {
const { fetchConsents, revokeConsent } = await import("./devApi");
await fetchConsents("user-a", undefined, "all");
await revokeConsent("user-a");
expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
params: { subject: "user-a" },
});
expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
params: { subject: "user-a" },
});
});
it("sends mutation requests to the documented dev endpoints", async () => {
const {
updateClientStatus,
createClient,
updateClient,
rotateClientSecret,
refreshHeadlessJwksCache,
revokeHeadlessJwksCache,
deleteClient,
revokeConsent,
createIdpConfigForClient,
updateIdpConfig,
deleteIdpConfig,
} = await import("./devApi");
await updateClientStatus("client-a", "inactive");
await createClient({ id: "client-a", name: "Console App" });
await updateClient("client-a", { name: "Console App Updated" });
await rotateClientSecret("client-a");
await refreshHeadlessJwksCache("client-a");
await revokeHeadlessJwksCache("client-a");
await deleteClient("client-a");
await revokeConsent("user-a", "client-a");
await createIdpConfigForClient({
client_id: "client-a",
provider_type: "oidc",
display_name: "OIDC Provider",
status: "active",
});
await updateIdpConfig("client-a", "idp-a", { status: "inactive" });
await deleteIdpConfig("client-a", "idp-a");
expect(apiClient.patch).toHaveBeenCalledWith(
"/dev/clients/client-a/status",
{ status: "inactive" },
);
expect(apiClient.post).toHaveBeenCalledWith("/dev/clients", {
id: "client-a",
name: "Console App",
});
expect(apiClient.put).toHaveBeenCalledWith("/dev/clients/client-a", {
name: "Console App Updated",
});
expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
params: { subject: "user-a", client_id: "client-a" },
});
expect(apiClient.put).toHaveBeenCalledWith(
"/dev/clients/client-a/idps/idp-a",
{ status: "inactive" },
);
});
});

View File

@@ -0,0 +1,315 @@
import apiClient from "./apiClient";
export type ClientStatus = "active" | "inactive";
export type ClientType = "private" | "pkce";
export type ClientSummary = {
id: string;
name: string;
type: ClientType;
status: ClientStatus;
createdAt?: string;
clientSecret?: string;
tokenEndpointAuthMethod?: string;
jwksUri?: string;
redirectUris: string[];
scopes: string[];
metadata?: Record<string, unknown>;
};
export type ClientListResponse = {
items: ClientSummary[];
limit: number;
offset: number;
};
export type DevStats = {
total_clients: number;
active_sessions: number;
auth_failures_24h: number;
};
export type DevAuditLog = {
event_id: string;
timestamp: string;
user_id: string;
event_type: string;
status: string;
ip_address: string;
user_agent: string;
device_id?: string;
details?: string;
};
export type DevAuditLogListResponse = {
items: DevAuditLog[];
limit: number;
cursor?: string;
next_cursor?: string;
};
export type ClientEndpoints = {
discovery: string;
issuer: string;
authorization: string;
token: string;
userinfo: string;
};
export type ClientDetailResponse = {
client: ClientSummary & {
clientSecret?: string;
metadata?: Record<string, unknown>;
};
endpoints: ClientEndpoints;
headlessJwksCache?: {
clientId: string;
jwksUri: string;
cachedAt: string;
expiresAt: string;
lastCheckedAt?: string;
lastSuccessfulVerificationAt?: string;
lastRefreshStatus?: "success" | "failure" | "pending";
lastError?: string;
consecutiveFailures?: number;
cachedKids?: string[];
etag?: string;
lastModified?: string;
parsedKeys?: Array<{
kid?: string;
kty?: string;
use?: string;
alg?: string;
n?: string;
}>;
};
};
export type ClientUpsertRequest = {
id?: string;
name?: string;
type?: ClientType;
status?: ClientStatus;
redirectUris?: string[];
scopes?: string[];
grantTypes?: string[];
responseTypes?: string[];
tokenEndpointAuthMethod?: string;
jwksUri?: string;
metadata?: Record<string, unknown>;
};
export type ConsentSummary = {
subject: string;
userName?: string;
clientId: string;
clientName?: string;
grantedScopes: string[];
authenticatedAt?: string;
createdAt: string;
deletedAt?: string;
status: "active" | "revoked";
tenantId?: string;
tenantName?: string;
};
export type ConsentListResponse = {
items: ConsentSummary[];
};
// --- Federation / IdP Config Types ---
export type ProviderType = "oidc" | "saml";
export type IdpConfig = {
id: string;
client_id: string; // Changed from tenant_id
provider_type: ProviderType;
display_name: string;
status: "active" | "inactive";
issuer_url?: string;
// OIDC specific fields
oidc_client_id?: string;
oidc_client_secret?: string;
scopes?: string;
// SAML specific fields
metadata_url?: string;
metadata_xml?: string;
entity_id?: string;
acs_url?: string;
createdAt: string;
updatedAt: string;
};
export type IdpConfigCreateRequest = Omit<
IdpConfig,
"id" | "createdAt" | "updatedAt"
>;
export type IdpConfigUpdateRequest = Partial<IdpConfigCreateRequest>;
// --- End Federation Types ---
export async function fetchClients() {
const { data } = await apiClient.get<ClientListResponse>("/dev/clients");
return data;
}
export async function fetchDevStats() {
const { data } = await apiClient.get<DevStats>("/dev/stats");
return data;
}
export async function fetchClient(clientId: string) {
const { data } = await apiClient.get<ClientDetailResponse>(
`/dev/clients/${clientId}`,
);
return data;
}
export async function updateClientStatus(
clientId: string,
status: ClientStatus,
) {
const { data } = await apiClient.patch<ClientDetailResponse>(
`/dev/clients/${clientId}/status`,
{ status },
);
return data;
}
export async function createClient(payload: ClientUpsertRequest) {
const { data } = await apiClient.post<ClientDetailResponse>(
"/dev/clients",
payload,
);
return data;
}
export async function updateClient(
clientId: string,
payload: ClientUpsertRequest,
) {
const { data } = await apiClient.put<ClientDetailResponse>(
`/dev/clients/${clientId}`,
payload,
);
return data;
}
export async function rotateClientSecret(clientId: string) {
const { data } = await apiClient.post<ClientDetailResponse>(
`/dev/clients/${clientId}/secret/rotate`,
);
return data;
}
export async function refreshHeadlessJwksCache(clientId: string) {
const { data } = await apiClient.post<ClientDetailResponse>(
`/dev/clients/${clientId}/headless-jwks/refresh`,
);
return data;
}
export async function revokeHeadlessJwksCache(clientId: string) {
await apiClient.delete(`/dev/clients/${clientId}/headless-jwks/cache`);
}
export async function deleteClient(clientId: string) {
await apiClient.delete(`/dev/clients/${clientId}`);
}
export async function fetchConsents(
subject: string,
clientId?: string,
status?: string,
) {
const params: Record<string, string> = { subject };
if (clientId) {
params.client_id = clientId;
}
if (status && status !== "all") {
params.status = status;
}
const { data } = await apiClient.get<ConsentListResponse>("/dev/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("/dev/consents", { params });
}
// --- Federation / IdP Config API Calls ---
export async function listIdpConfigsForClient(clientId: string) {
const { data } = await apiClient.get<IdpConfig[]>(
`/dev/clients/${clientId}/idps`,
);
return data;
}
export async function createIdpConfigForClient(
payload: IdpConfigCreateRequest,
) {
const { data } = await apiClient.post<IdpConfig>(
`/dev/clients/${payload.client_id}/idps`,
payload,
);
return data;
}
export async function updateIdpConfig(
clientId: string,
idpId: string,
payload: IdpConfigUpdateRequest,
) {
const { data } = await apiClient.put<IdpConfig>(
`/dev/clients/${clientId}/idps/${idpId}`,
payload,
);
return data;
}
export async function deleteIdpConfig(clientId: string, idpId: string) {
await apiClient.delete(`/dev/clients/${clientId}/idps/${idpId}`);
}
export async function fetchDevAuditLogs(
limit = 50,
cursor?: string,
filters?: {
action?: string;
client_id?: string;
status?: string;
tenant_id?: string;
},
) {
const { data } = await apiClient.get<DevAuditLogListResponse>(
"/dev/audit-logs",
{
params: {
limit,
cursor,
action: filters?.action,
client_id: filters?.client_id,
status: filters?.status,
tenant_id: filters?.tenant_id,
},
},
);
return data;
}
export type TenantSummary = {
id: string;
name: string;
slug: string;
};
export async function fetchMyTenants() {
const { data } = await apiClient.get<TenantSummary[]>("/dev/my-tenants");
return data;
}

View File

@@ -0,0 +1,16 @@
import { createTomlTranslator } from "../../../common/core/i18n";
// eslint-disable-next-line import/no-unresolved
import commonEnRaw from "../../../common/locales/en.toml?raw";
// eslint-disable-next-line import/no-unresolved
import commonKoRaw from "../../../common/locales/ko.toml?raw";
// eslint-disable-next-line import/no-unresolved
import enRaw from "../locales/en.toml?raw";
// Vite ?raw import는 런타임 상수로 번들됩니다.
// eslint-disable-next-line import/no-unresolved
import koRaw from "../locales/ko.toml?raw";
export const t = createTomlTranslator({
ko: [commonKoRaw, koRaw],
en: [commonEnRaw, enRaw],
});

View File

@@ -0,0 +1,27 @@
export function normalizeRole(rawRole: unknown): string {
if (typeof rawRole !== "string") return "";
const role = rawRole.trim().toLowerCase();
if (role === "tenant_member") return "user";
if (role === "admin") return "tenant_admin";
if (role === "superadmin") return "super_admin";
if (role === "tenantadmin") return "tenant_admin";
if (role === "rpadmin") return "rp_admin";
return role;
}
export function resolveProfileRole(
profile: Record<string, unknown> | undefined,
) {
if (!profile) return "";
const candidates = [
profile.role,
profile.grade,
profile["custom:role"],
profile["custom:grade"],
];
for (const candidate of candidates) {
const normalized = normalizeRole(candidate);
if (normalized) return normalized;
}
return "";
}

View File

@@ -0,0 +1,27 @@
import {
DEFAULT_SESSION_RENEW_THROTTLE_MS,
type SessionRenewDecisionParams,
shouldAttemptSlidingSessionRenew as shouldAttemptSlidingSessionRenewBase,
shouldAttemptUnlimitedSessionRenew as shouldAttemptUnlimitedSessionRenewBase,
} from "../../../common/core/session";
export const SESSION_RENEW_THROTTLE_MS = DEFAULT_SESSION_RENEW_THROTTLE_MS;
export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000;
export function shouldAttemptSlidingSessionRenew(
params: SessionRenewDecisionParams,
) {
return shouldAttemptSlidingSessionRenewBase({
...params,
thresholdMs: params.thresholdMs ?? SESSION_RENEW_THRESHOLD_MS,
});
}
export function shouldAttemptUnlimitedSessionRenew(
params: SessionRenewDecisionParams,
) {
return shouldAttemptUnlimitedSessionRenewBase({
...params,
thresholdMs: params.thresholdMs ?? SESSION_RENEW_THRESHOLD_MS,
});
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "./adminApi";
import { buildTenantFullTree } from "./tenantTree";
function tenant(
id: string,
slug: string,
parentId?: string,
type = "USER_GROUP",
): TenantSummary {
return {
id,
type,
name: slug,
slug,
description: "",
status: "active",
parentId,
memberCount: 0,
totalMemberCount: 0,
createdAt: "2026-06-10T00:00:00.000Z",
updatedAt: "2026-06-10T00:00:00.000Z",
};
}
describe("buildTenantFullTree", () => {
it("treats a self-parent hanmac-family tenant as a root", () => {
const result = buildTenantFullTree([
tenant(
"hanmac-family-id",
"hanmac-family",
"hanmac-family-id",
"COMPANY_GROUP",
),
tenant("saman-id", "saman", "hanmac-family-id", "COMPANY"),
tenant("platform-id", "platform", "saman-id"),
]);
expect(result.subTree).toHaveLength(1);
expect(result.subTree[0]?.id).toBe("hanmac-family-id");
expect(result.subTree[0]?.children[0]?.id).toBe("saman-id");
expect(result.subTree[0]?.children[0]?.children[0]?.id).toBe(
"platform-id",
);
});
});

View File

@@ -0,0 +1,88 @@
import type { TenantSummary } from "./adminApi";
export type TenantNode = TenantSummary & {
children: TenantNode[];
recursiveMemberCount: number;
};
/**
* Builds a hierarchical tree from a flat list of tenants and calculates
* direct and recursive member counts for each node.
*/
export function buildTenantFullTree(
allTenants: TenantSummary[],
rootId?: string,
): { currentBase: TenantNode | null; subTree: TenantNode[] } {
if (allTenants.length === 0) return { currentBase: null, subTree: [] };
const tenantMap = new Map<string, TenantNode>();
for (const t of allTenants) {
tenantMap.set(t.id, {
...t,
children: [],
recursiveMemberCount: t.memberCount || 0,
});
}
// Build initial children relations and prevent simple cycles
for (const t of allTenants) {
if (t.parentId && t.parentId !== t.id) {
const parent = tenantMap.get(t.parentId);
const child = tenantMap.get(t.id);
if (parent && child) {
// Simple cycle prevention during build: don't add if it creates an immediate loop
parent.children.push(child);
}
}
}
const visitedForCalc = new Set<string>();
// Function to calculate recursive counts with cycle protection
const calculateRecursive = (node: TenantNode): number => {
if (visitedForCalc.has(node.id)) {
console.warn(
`Circular dependency detected in tenant tree for ID: ${node.id}`,
);
return 0; // Prevent infinite loop
}
visitedForCalc.add(node.id);
let total = node.memberCount || 0;
for (const child of node.children) {
total += calculateRecursive(child);
}
node.recursiveMemberCount = total;
// We don't remove from visitedForCalc here because a tree shouldn't have
// multiple paths to the same node anyway (it's a tree, not a graph).
// If it were a DAG, we'd need different logic, but for a tree with parentIds,
// a node should only be visited once.
return total;
};
// Calculate for all top-level nodes (those without parent or with self-parent)
for (const node of tenantMap.values()) {
if (!node.parentId || node.parentId === node.id) {
visitedForCalc.clear();
calculateRecursive(node);
}
}
// If a specific rootId is provided, find and return its subtree
if (rootId) {
const base = tenantMap.get(rootId);
if (base) {
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
visitedForCalc.clear();
calculateRecursive(base);
return { currentBase: base, subTree: base.children };
}
return { currentBase: null, subTree: [] };
}
// If no rootId, return all top-level roots as subTree
const roots = Array.from(tenantMap.values()).filter(
(n) => !n.parentId || n.parentId === n.id,
);
return { currentBase: null, subTree: roots };
}

View File

@@ -0,0 +1,8 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { mergeClassNames } from "../../../common/core/utils";
export function cn(...inputs: ClassValue[]) {
return mergeClassNames(twMerge, [clsx(inputs)]);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { AuthProvider } from "react-oidc-context";
import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient";
import { router } from "./app/routes";
import { Toaster } from "./components/ui/toaster";
import "./index.css";
import { oidcConfig, userManager } from "./lib/auth";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
}
createRoot(rootElement).render(
<StrictMode>
<AuthProvider {...oidcConfig} userManager={userManager}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster />
</QueryClientProvider>
</AuthProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,987 @@
export type OrgContextMember = {
id?: string;
email: string;
name: string;
phone?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
isOwner?: boolean;
isLeader?: boolean;
isPrimary?: boolean;
};
export type OrgContextTenant = {
id: string;
type: string;
name: string;
slug: string;
parentId?: string | null;
status: string;
description: string;
domains: string[];
memberCount: number;
visibility: string;
orgUnitType?: string;
createdAt: string;
updatedAt: string;
members: OrgContextMember[];
};
export type OrgContextTreeNode = OrgContextTenant & {
children: OrgContextTreeNode[];
};
export type OrgContextResponse = {
schemaVersion: "baron.org-context.v1";
issuedAt: string;
scope: {
tenantId: string;
tenantSlug: string;
};
tree: OrgContextTreeNode;
tenants: OrgContextTenant[];
};
export type OrgChartNode = OrgContextTenant & {
children: OrgChartNode[];
depth: number;
path: string[];
};
export type OrgChartMember = OrgContextMember & {
tenantIds: string[];
};
export type OrgChartModel = {
root: OrgChartNode;
nodes: OrgChartNode[];
tenantsById: Map<string, OrgChartNode>;
tenantsBySlug: Map<string, OrgChartNode>;
membersByEmail: Map<string, OrgChartMember>;
response: OrgContextResponse;
};
export type OrgContextClientOptions = {
baseUrl: string;
credentials?: {
keyId: string;
keySecret: string;
};
fetch?: typeof fetch;
headers?: Record<string, string>;
};
export type FetchOrgContextOptions = {
tenantSlug?: string;
includeUsers?: boolean;
includeUserIds?: boolean;
};
export type OrgPickerSelection = {
id: string;
name: string;
type: "tenant" | "user";
};
export type OrgPickerVariant = "default" | "orgfront";
export type OrgPickerOptions = {
mode?: "single" | "multiple";
selectable?: "tenant" | "user" | "both";
includeDescendants?: boolean;
injectStyles?: boolean;
showDescendantToggle?: boolean;
variant?: OrgPickerVariant;
onCancel?: () => void;
onChange?: (selection: OrgPickerSelection[]) => void;
onConfirm?: (selection: OrgPickerSelection[]) => void;
};
export type OrgPickerController = {
cancel: () => void;
confirm: () => void;
destroy: () => void;
getSelection: () => OrgPickerSelection[];
};
const API_PATH = "/api/v1/integrations/org-context";
const DEFAULT_STYLE_ID = "baron-org-context-chart-default-style";
const DEFAULT_STYLE = `
.baron-org-chart,.baron-org-picker{box-sizing:border-box;color:#0f172a;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.45}
.baron-org-chart *,.baron-org-picker *{box-sizing:border-box}
.baron-org-chart__tree{display:flex;min-width:100%;gap:24px;overflow:auto;padding:16px}
.baron-org-chart__node{min-width:220px;border:1px solid #d8dee9;border-radius:8px;background:#fff;box-shadow:0 10px 30px rgba(15,23,42,.08)}
.baron-org-chart__title{margin:0;padding:10px 12px;border-bottom:1px solid #e5e7eb;background:#f8fafc;font-size:14px;font-weight:700}
.baron-org-chart__meta{margin:0;padding:8px 12px;color:#64748b;font-size:12px}
.baron-org-chart__members{margin:0;padding:0 12px 12px 28px;color:#334155;font-size:12px}
.baron-org-chart__children{display:flex;gap:16px;margin:12px;padding:12px 0 0 16px;border-left:1px solid #d8dee9}
.baron-org-picker{width:100%;max-width:520px;border:1px solid #d8dee9;border-radius:8px;background:#fff;box-shadow:0 12px 34px rgba(15,23,42,.1);overflow:hidden}
.baron-org-picker__toolbar{display:flex;flex-direction:column;gap:10px;padding:12px;border-bottom:1px solid #e5e7eb;background:#f8fafc}
.baron-org-picker__search-wrap{position:relative}
.baron-org-picker__search-icon{pointer-events:none;position:absolute;left:12px;top:50%;width:16px;height:16px;transform:translateY(-50%);color:#64748b}
.baron-org-picker__search-icon::before{content:"";position:absolute;left:2px;top:2px;width:8px;height:8px;border:2px solid currentColor;border-radius:999px}
.baron-org-picker__search-icon::after{content:"";position:absolute;left:10px;top:11px;width:6px;height:2px;background:currentColor;border-radius:999px;transform:rotate(45deg);transform-origin:left center}
.baron-org-picker__search{width:100%;height:38px;border:1px solid #cbd5e1;border-radius:6px;background:#fff;padding:0 10px;color:#0f172a;font:inherit;outline:none}
.baron-org-picker__search:focus{border-color:#24449c;box-shadow:0 0 0 3px rgba(36,68,156,.18)}
.baron-org-picker__controls{display:flex;align-items:center;justify-content:space-between;gap:12px;color:#475569;font-size:12px}
.baron-org-picker__descendants{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.baron-org-picker__summary{color:#64748b}
.baron-org-picker__clear{border:0;background:transparent;color:#24449c;cursor:pointer;font:inherit;font-weight:700;padding:2px 0}
.baron-org-picker__clear:hover{text-decoration:underline}
.baron-org-picker__list,.baron-org-picker__children{list-style:none;margin:0;padding:0}
.baron-org-picker__list{max-height:420px;overflow:auto;padding:8px}
.baron-org-picker__item{margin:0}
.baron-org-picker__children{margin-left:8px;border-left:1px solid #e5e7eb}
.baron-org-picker__row{display:flex;min-height:32px;align-items:center;gap:8px;border-radius:6px;padding:4px 8px;color:#0f172a;cursor:pointer}
.baron-org-picker__row:hover{background:#f1f5f9}
.baron-org-picker__row--selected{background:#e8eefc;color:#18327a}
.baron-org-picker__row--member{color:#334155;font-size:13px}
.baron-org-picker__label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.baron-org-picker__label-primary,.baron-org-picker__label-secondary{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.baron-org-picker__label-primary{font-weight:600;line-height:20px}
.baron-org-picker__label-secondary{color:#64748b;font-size:12px;line-height:20px}
.baron-org-picker input[type="checkbox"],.baron-org-picker input[type="radio"]{accent-color:#24449c}
.baron-org-picker__empty{padding:28px 12px;color:#64748b;text-align:center}
.baron-org-picker__toggle,.baron-org-picker__toggle-placeholder{display:grid;width:24px;height:24px;flex:0 0 24px;place-items:center;border:0;border-radius:4px;background:transparent;color:#64748b;font:inherit;line-height:1}
.baron-org-picker__toggle{cursor:pointer}
.baron-org-picker__toggle:hover{background:#e2e8f0}
.baron-org-picker__chevron{position:relative;display:block;width:16px;height:16px;color:currentColor}
.baron-org-picker__chevron::before{content:"";position:absolute;width:6px;height:6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor}
.baron-org-picker__chevron--open::before{left:4px;top:3px;transform:rotate(45deg)}
.baron-org-picker__chevron--closed::before{left:3px;top:4px;transform:rotate(-45deg)}
.baron-org-picker__select{min-width:0;flex:1;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;outline:none;padding:0 4px}
.baron-org-picker__select:focus-visible{box-shadow:0 0 0 2px #3a98e5}
.baron-org-picker__footer{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid #e5e7eb;background:#fff;padding:8px 12px}
.baron-org-picker__actions{display:flex;align-items:center;gap:8px}
.baron-org-picker__button{display:inline-flex;height:36px;align-items:center;justify-content:center;gap:8px;white-space:nowrap;border-radius:6px;border:1px solid #e5e7eb;background:#fff;color:#0f172a;padding:0 12px;font:inherit;font-size:14px;font-weight:600;cursor:pointer}
.baron-org-picker__button:hover{background:#f4b840;color:#0f172a}
.baron-org-picker__button--primary{border-color:#3a98e5;background:#3a98e5;color:#fff;box-shadow:0 1px 2px rgba(15,23,42,.12)}
.baron-org-picker__button--primary:hover{background:#2588d8;color:#fff}
.baron-org-picker__button:disabled{cursor:not-allowed;opacity:.5}
.baron-org-picker--orgfront{--boc-background:hsl(var(--background,0 0% 98%));--boc-foreground:hsl(var(--foreground,223 25% 12%));--boc-primary:hsl(var(--primary,209 79% 52%));--boc-secondary:hsl(var(--secondary,220 17% 94%));--boc-muted-foreground:hsl(var(--muted-foreground,223 15% 45%));--boc-accent:hsl(var(--accent,40 96% 62%));--boc-accent-foreground:hsl(var(--accent-foreground,223 25% 12%));--boc-border:hsl(var(--border,220 17% 90%));--boc-input:hsl(var(--input,220 17% 90%));--boc-ring:hsl(var(--ring,209 79% 52%));display:flex;height:100%;min-height:320px;max-width:none;flex-direction:column;border:0;border-radius:0;background:var(--boc-background);box-shadow:none;color:var(--boc-foreground);overflow:hidden}
.baron-org-picker--orgfront .baron-org-picker__toolbar{display:block;border-bottom:1px solid var(--boc-border);background:var(--boc-background);padding:8px}
.baron-org-picker--orgfront .baron-org-picker__toolbar-grid{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:8px}
.baron-org-picker--orgfront .baron-org-picker__search{height:36px;border-color:var(--boc-input);border-radius:6px;background:var(--boc-background);padding:0 12px 0 36px;font-size:14px}
.baron-org-picker--orgfront .baron-org-picker__search:focus{border-color:var(--boc-input);box-shadow:0 0 0 2px var(--boc-ring)}
.baron-org-picker--orgfront .baron-org-picker__controls{height:36px;font-size:14px}
.baron-org-picker--orgfront .baron-org-picker__descendants{height:36px;font-size:14px}
.baron-org-picker--orgfront .baron-org-picker__list{min-height:0;flex:1;max-height:none;overflow:auto;padding:12px}
.baron-org-picker--orgfront .baron-org-picker__children{margin-left:16px;border-left:0}
.baron-org-picker--orgfront .baron-org-picker__row{min-height:28px;gap:6px;border-radius:4px;padding:2px 6px 2px 4px;transition:background-color .15s,color .15s,box-shadow .15s}
.baron-org-picker--orgfront .baron-org-picker__row:hover{background:color-mix(in srgb,var(--boc-secondary) 50%,transparent)}
.baron-org-picker--orgfront .baron-org-picker__row--selected{background:color-mix(in srgb,var(--boc-primary) 15%,transparent);box-shadow:0 0 0 2px color-mix(in srgb,var(--boc-primary) 60%,transparent);color:var(--boc-foreground)}
.baron-org-picker--orgfront .baron-org-picker__row--member{font-size:14px;color:var(--boc-foreground)}
.baron-org-picker--orgfront .baron-org-picker__footer{border-top-color:var(--boc-border);background:var(--boc-background)}
.baron-org-picker--orgfront .baron-org-picker__summary{font-size:14px;color:var(--boc-muted-foreground)}
.baron-org-picker--orgfront .baron-org-picker__button{border-color:var(--boc-input);background:var(--boc-background);color:var(--boc-foreground)}
.baron-org-picker--orgfront .baron-org-picker__button:hover{background:var(--boc-accent);color:var(--boc-accent-foreground)}
.baron-org-picker--orgfront .baron-org-picker__button--primary{border-color:var(--boc-primary);background:var(--boc-primary);color:#fff}
.baron-org-picker--orgfront .baron-org-picker__empty{margin:12px;min-height:160px;border:1px dashed var(--boc-border);border-radius:6px;background:var(--boc-background);display:grid;place-items:center}
`;
export function createOrgContextClient(options: OrgContextClientOptions) {
const fetcher = options.fetch ?? globalThis.fetch;
if (!fetcher) {
throw new Error("A fetch implementation is required.");
}
return {
async fetchOrgContext(
query: FetchOrgContextOptions = {},
): Promise<OrgContextResponse> {
const url = new URL(API_PATH, normalizeBaseUrl(options.baseUrl));
appendQuery(url, "tenantSlug", query.tenantSlug);
appendQuery(url, "includeUsers", query.includeUsers);
appendQuery(url, "includeUserIds", query.includeUserIds);
const headers: Record<string, string> = {
Accept: "application/json",
...options.headers,
};
if (options.credentials) {
headers["X-Baron-Key-ID"] = options.credentials.keyId;
headers["X-Baron-Key-Secret"] = options.credentials.keySecret;
}
const response = await fetcher(url.toString(), { headers });
if (!response.ok) {
throw new Error(`Org context request failed with ${response.status}`);
}
const payload = (await response.json()) as OrgContextResponse;
if (payload.schemaVersion !== "baron.org-context.v1") {
throw new Error(
`Unsupported org context schema: ${payload.schemaVersion}`,
);
}
return payload;
},
};
}
export function buildOrgChartModel(
response: OrgContextResponse,
): OrgChartModel {
if (response.schemaVersion !== "baron.org-context.v1") {
throw new Error(
`Unsupported org context schema: ${response.schemaVersion}`,
);
}
const nodes: OrgChartNode[] = [];
const tenantsById = new Map<string, OrgChartNode>();
const tenantsBySlug = new Map<string, OrgChartNode>();
const membersByEmail = new Map<string, OrgChartMember>();
const visit = (
node: OrgContextTreeNode,
depth: number,
ancestorPath: string[],
): OrgChartNode => {
const path = [...ancestorPath, node.id];
const chartNode: OrgChartNode = {
...node,
children: [],
depth,
path,
};
nodes.push(chartNode);
tenantsById.set(chartNode.id, chartNode);
tenantsBySlug.set(chartNode.slug, chartNode);
for (const member of chartNode.members) {
const key = member.email.toLowerCase();
const existing = membersByEmail.get(key);
if (existing) {
existing.tenantIds.push(chartNode.id);
} else {
membersByEmail.set(key, {
...member,
tenantIds: [chartNode.id],
});
}
}
chartNode.children = node.children.map((child) =>
visit(child, depth + 1, path),
);
return chartNode;
};
return {
root: visit(response.tree, 0, []),
nodes,
tenantsById,
tenantsBySlug,
membersByEmail,
response,
};
}
export function renderOrgChart(
container: HTMLElement,
model: OrgChartModel,
): { destroy: () => void } {
ensureDefaultStyles();
container.replaceChildren();
container.classList.add("baron-org-chart");
const root = document.createElement("div");
root.className = "baron-org-chart__tree";
root.append(renderChartNode(model.root));
container.append(root);
return {
destroy() {
container.replaceChildren();
container.classList.remove("baron-org-chart");
},
};
}
export function renderOrgPicker(
container: HTMLElement,
model: OrgChartModel,
options: OrgPickerOptions = {},
): OrgPickerController {
if (options.injectStyles !== false) {
ensureDefaultStyles();
}
const mode = options.mode ?? "single";
const selectable = options.selectable ?? "tenant";
const variant = options.variant ?? "default";
const isOrgfront = variant === "orgfront";
let includeDescendants =
options.includeDescendants ?? (isOrgfront && mode === "multiple");
let searchQuery = "";
const selected = new Map<string, OrgPickerSelection>();
const expanded = new Set(model.nodes.map((node) => node.id));
const showDescendantToggle = options.showDescendantToggle ?? true;
const currentSelection = () => Array.from(selected.values());
const emitChange = () => {
const selection = currentSelection();
options.onChange?.(selection);
container.dispatchEvent(
new CustomEvent("baron-org-picker-change", {
bubbles: true,
detail: { selection },
}),
);
};
const emitConfirm = () => {
const selection = currentSelection();
options.onConfirm?.(selection);
container.dispatchEvent(
new CustomEvent("baron-org-picker-confirm", {
bubbles: true,
detail: { selection },
}),
);
};
const emitCancel = () => {
options.onCancel?.();
container.dispatchEvent(
new CustomEvent("baron-org-picker-cancel", {
bubbles: true,
}),
);
};
const toggleSelection = (
selection: OrgPickerSelection,
checked: boolean,
descendants: OrgPickerSelection[],
) => {
if (mode === "single") {
selected.clear();
if (checked) {
selected.set(selectionKey(selection), selection);
}
emitChange();
rerender();
return;
}
const targets =
includeDescendants && selection.type === "tenant"
? [selection, ...descendants]
: [selection];
for (const target of targets) {
if (checked) {
selected.set(selectionKey(target), target);
} else {
selected.delete(selectionKey(target));
}
}
emitChange();
rerender();
};
const renderPickerNode = (node: OrgChartNode): HTMLElement => {
const item = document.createElement("li");
item.className = "baron-org-picker__item";
const hasChildren = node.children.length > 0;
const tenantSelection: OrgPickerSelection = {
id: node.id,
name: node.name,
type: "tenant",
};
const row = document.createElement(isOrgfront ? "div" : "label");
row.className = "baron-org-picker__row";
if (selected.has(selectionKey(tenantSelection))) {
row.classList.add("baron-org-picker__row--selected");
}
row.style.paddingLeft = `${node.depth * 16}px`;
if (isOrgfront) {
row.append(createExpandToggle(node, hasChildren));
appendOrgfrontSelectionControl(row, node, tenantSelection);
} else {
if (selectable === "tenant" || selectable === "both") {
row.append(
createPickerInput({
mode,
selection: tenantSelection,
selected,
onToggle: (checked) =>
toggleSelection(
tenantSelection,
checked,
collectDescendantSelections(node, selectable),
),
}),
);
}
row.append(createLabelText(node.name, node.type));
}
item.append(row);
if (selectable === "user" || selectable === "both") {
for (const member of node.members) {
item.append(
renderMemberPickerRow(
member,
node,
mode,
selected,
(value) =>
toggleSelection(value, !selected.has(selectionKey(value)), []),
isOrgfront,
),
);
}
}
if (hasChildren && (!isOrgfront || expanded.has(node.id))) {
const children = document.createElement("ul");
children.className = "baron-org-picker__children";
for (const child of node.children) {
children.append(renderPickerNode(child));
}
item.append(children);
}
return item;
};
const appendOrgfrontSelectionControl = (
row: HTMLElement,
node: OrgChartNode,
tenantSelection: OrgPickerSelection,
) => {
const canSelect = selectable === "tenant" || selectable === "both";
if (!canSelect) {
row.append(createOrgfrontLabelText(node.name));
return;
}
if (mode === "multiple") {
row.append(
createPickerInput({
mode,
selection: tenantSelection,
selected,
onToggle: (checked) =>
toggleSelection(
tenantSelection,
checked,
collectDescendantSelections(node, selectable),
),
}),
);
row.append(createOrgfrontLabelText(node.name));
return;
}
const button = document.createElement("button");
button.className = "baron-org-picker__select";
button.dataset.baronOrgPickerValue = selectionKey(tenantSelection);
button.type = "button";
button.ariaPressed = String(selected.has(selectionKey(tenantSelection)));
button.addEventListener("click", () =>
toggleSelection(
tenantSelection,
true,
collectDescendantSelections(node, selectable),
),
);
button.append(createOrgfrontLabelText(node.name));
row.append(button);
};
const createExpandToggle = (node: OrgChartNode, hasChildren: boolean) => {
if (!hasChildren) {
const placeholder = document.createElement("span");
placeholder.className = "baron-org-picker__toggle-placeholder";
placeholder.ariaHidden = "true";
return placeholder;
}
const toggle = document.createElement("button");
toggle.className = "baron-org-picker__toggle";
toggle.dataset.baronOrgPickerToggle = selectionKey({
id: node.id,
name: node.name,
type: "tenant",
});
toggle.type = "button";
const chevron = document.createElement("span");
chevron.className = expanded.has(node.id)
? "baron-org-picker__chevron baron-org-picker__chevron--open"
: "baron-org-picker__chevron baron-org-picker__chevron--closed";
chevron.dataset.baronOrgPickerChevron = "true";
chevron.ariaHidden = "true";
toggle.append(chevron);
toggle.ariaLabel = `${node.name} ${expanded.has(node.id) ? "접기" : "펼치기"}`;
toggle.addEventListener("click", () => {
if (expanded.has(node.id)) {
expanded.delete(node.id);
} else {
expanded.add(node.id);
}
rerender();
});
return toggle;
};
const rerender = () => {
container.replaceChildren();
container.classList.add("baron-org-picker");
container.classList.toggle("baron-org-picker--orgfront", isOrgfront);
container.append(renderPickerToolbar());
const visibleRoot = filterOrgChartNode(model.root, searchQuery, selectable);
if (!visibleRoot) {
const empty = document.createElement("div");
empty.className = "baron-org-picker__empty";
empty.textContent = isOrgfront
? "검색 결과가 없습니다."
: "No matching organization or member.";
container.append(empty);
if (isOrgfront) {
container.append(renderPickerFooter());
}
return;
}
const list = document.createElement("ul");
list.className = "baron-org-picker__list";
list.append(renderPickerNode(visibleRoot));
container.append(list);
if (isOrgfront) {
container.append(renderPickerFooter());
}
};
const renderPickerToolbar = () => {
const toolbar = document.createElement("div");
toolbar.className = "baron-org-picker__toolbar";
const toolbarContent = isOrgfront ? document.createElement("div") : toolbar;
if (isOrgfront) {
toolbarContent.className = "baron-org-picker__toolbar-grid";
toolbar.append(toolbarContent);
}
const searchWrap = document.createElement("div");
searchWrap.className = "baron-org-picker__search-wrap";
if (isOrgfront) {
const searchIcon = document.createElement("span");
searchIcon.className = "baron-org-picker__search-icon";
searchIcon.dataset.baronOrgPickerSearchIcon = "true";
searchIcon.ariaHidden = "true";
searchWrap.append(searchIcon);
}
const search = document.createElement("input");
search.className = "baron-org-picker__search";
search.dataset.baronOrgPickerSearch = "true";
search.placeholder = isOrgfront
? "ID, 이름, 이메일, 메타데이터"
: "Search organization or member";
search.type = "search";
search.value = searchQuery;
search.addEventListener("input", () => {
searchQuery = search.value;
rerender();
const nextSearch = container.querySelector<HTMLInputElement>(
"[data-baron-org-picker-search]",
);
nextSearch?.focus();
nextSearch?.setSelectionRange(searchQuery.length, searchQuery.length);
});
searchWrap.append(search);
toolbarContent.append(searchWrap);
const controls = document.createElement("div");
controls.className = "baron-org-picker__controls";
if (mode === "multiple" && selectable !== "user" && showDescendantToggle) {
const descendantsLabel = document.createElement("label");
descendantsLabel.className = "baron-org-picker__descendants";
const descendants = document.createElement("input");
descendants.dataset.baronOrgPickerDescendants = "true";
descendants.type = "checkbox";
descendants.checked = includeDescendants;
const updateDescendantSelection = () => {
includeDescendants = descendants.checked;
rerender();
};
descendants.addEventListener("change", updateDescendantSelection);
descendants.addEventListener("click", updateDescendantSelection);
descendantsLabel.append(
descendants,
isOrgfront ? "하위 선택" : "Include descendants",
);
controls.append(descendantsLabel);
} else if (!isOrgfront) {
controls.append(document.createElement("span"));
}
if (!isOrgfront) {
const summary = document.createElement("span");
summary.className = "baron-org-picker__summary";
summary.dataset.baronOrgPickerSummary = "true";
summary.textContent = `${selected.size} selected`;
controls.append(summary);
if (selected.size > 0) {
const clear = document.createElement("button");
clear.className = "baron-org-picker__clear";
clear.type = "button";
clear.textContent = "Clear";
clear.addEventListener("click", () => {
selected.clear();
emitChange();
rerender();
});
controls.append(clear);
}
}
toolbarContent.append(controls);
return toolbar;
};
const renderPickerFooter = () => {
const footer = document.createElement("footer");
footer.className = "baron-org-picker__footer";
footer.dataset.baronOrgPickerFooter = "true";
const summary = document.createElement("div");
summary.className = "baron-org-picker__summary";
summary.dataset.baronOrgPickerSummary = "true";
summary.textContent =
selected.size > 0
? `${selected.size}개 항목 선택됨`
: "선택된 항목이 없습니다.";
footer.append(summary);
const actions = document.createElement("div");
actions.className = "baron-org-picker__actions";
const cancel = document.createElement("button");
cancel.className = "baron-org-picker__button";
cancel.dataset.baronOrgPickerCancel = "true";
cancel.type = "button";
cancel.textContent = "취소";
cancel.addEventListener("click", emitCancel);
actions.append(cancel);
const confirm = document.createElement("button");
confirm.className =
"baron-org-picker__button baron-org-picker__button--primary";
confirm.dataset.baronOrgPickerConfirm = "true";
confirm.disabled = selected.size === 0;
confirm.type = "button";
confirm.textContent = "선택 완료";
confirm.addEventListener("click", emitConfirm);
actions.append(confirm);
footer.append(actions);
return footer;
};
rerender();
return {
cancel: emitCancel,
confirm: emitConfirm,
destroy() {
container.replaceChildren();
container.classList.remove(
"baron-org-picker",
"baron-org-picker--orgfront",
);
selected.clear();
},
getSelection() {
return currentSelection();
},
};
}
function appendQuery(
url: URL,
key: string,
value: string | boolean | undefined,
) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
function normalizeBaseUrl(baseUrl: string) {
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
}
function ensureDefaultStyles() {
if (typeof document === "undefined") return;
if (document.getElementById(DEFAULT_STYLE_ID)) return;
const style = document.createElement("style");
style.id = DEFAULT_STYLE_ID;
style.dataset.baronOrgContextChartStyle = "default";
style.textContent = DEFAULT_STYLE;
document.head.append(style);
}
function renderChartNode(node: OrgChartNode): HTMLElement {
const item = document.createElement("section");
item.className = "baron-org-chart__node";
item.dataset.baronOrgNode = node.id;
const title = document.createElement("h3");
title.className = "baron-org-chart__title";
title.textContent = node.name;
item.append(title);
const meta = document.createElement("p");
meta.className = "baron-org-chart__meta";
meta.textContent = [node.type, node.orgUnitType, node.visibility]
.filter(Boolean)
.join(" · ");
item.append(meta);
if (node.members.length > 0) {
const memberList = document.createElement("ul");
memberList.className = "baron-org-chart__members";
for (const member of node.members) {
const memberItem = document.createElement("li");
memberItem.textContent = formatMember(member);
memberList.append(memberItem);
}
item.append(memberList);
}
if (node.children.length > 0) {
const children = document.createElement("div");
children.className = "baron-org-chart__children";
for (const child of node.children) {
children.append(renderChartNode(child));
}
item.append(children);
}
return item;
}
function renderMemberPickerRow(
member: OrgContextMember,
node: OrgChartNode,
mode: "single" | "multiple",
selected: Map<string, OrgPickerSelection>,
onSelect: (selection: OrgPickerSelection) => void,
isOrgfront = false,
) {
const selection: OrgPickerSelection = {
id: member.id || `${node.id}:${member.email}`,
name: member.name,
type: "user",
};
const row = document.createElement(isOrgfront ? "div" : "label");
row.className = "baron-org-picker__row baron-org-picker__row--member";
if (selected.has(selectionKey(selection))) {
row.classList.add("baron-org-picker__row--selected");
}
row.style.paddingLeft = `${node.depth * 16 + 24}px`;
if (isOrgfront && mode === "single") {
row.append(createOrgfrontMemberSpacer());
const button = document.createElement("button");
button.className = "baron-org-picker__select";
button.dataset.baronOrgPickerValue = selectionKey(selection);
button.type = "button";
button.ariaPressed = String(selected.has(selectionKey(selection)));
button.addEventListener("click", () => onSelect(selection));
button.append(createOrgfrontLabelText(member.name, member.email));
row.append(button);
return row;
}
if (isOrgfront) {
row.append(createOrgfrontMemberSpacer());
}
row.append(
createPickerInput({
mode,
selection,
selected,
onToggle: () => onSelect(selection),
}),
);
row.append(
isOrgfront
? createOrgfrontLabelText(member.name, member.email)
: createLabelText(member.name, member.email),
);
return row;
}
function createPickerInput({
mode,
selection,
selected,
onToggle,
}: {
mode: "single" | "multiple";
selection: OrgPickerSelection;
selected: Map<string, OrgPickerSelection>;
onToggle: (checked: boolean) => void;
}) {
const input = document.createElement("input");
input.type = mode === "single" ? "radio" : "checkbox";
input.name = "baron-org-picker";
input.value = selectionKey(selection);
input.checked = selected.has(selectionKey(selection));
const handleToggle = () => {
onToggle(mode === "single" ? true : !selected.has(selectionKey(selection)));
};
input.addEventListener("click", handleToggle);
return input;
}
function createLabelText(primary: string, secondary?: string) {
const text = document.createElement("span");
text.className = "baron-org-picker__label";
text.textContent = secondary ? `${primary} (${secondary})` : primary;
return text;
}
function createOrgfrontMemberSpacer() {
const spacer = document.createElement("span");
spacer.className = "baron-org-picker__toggle-placeholder";
spacer.ariaHidden = "true";
return spacer;
}
function createOrgfrontLabelText(primary: string, secondary?: string) {
const text = document.createElement("span");
text.className = "baron-org-picker__label";
const primaryText = document.createElement("span");
primaryText.className = "baron-org-picker__label-primary";
primaryText.textContent = primary;
text.append(primaryText);
if (secondary) {
const secondaryText = document.createElement("span");
secondaryText.className = "baron-org-picker__label-secondary";
secondaryText.textContent = secondary;
text.append(secondaryText);
}
return text;
}
function filterOrgChartNode(
node: OrgChartNode,
rawQuery: string,
selectable: "tenant" | "user" | "both",
): OrgChartNode | null {
const query = rawQuery.trim().toLowerCase();
if (!query) return node;
const childMatches = node.children
.map((child) => filterOrgChartNode(child, rawQuery, selectable))
.filter((child): child is OrgChartNode => Boolean(child));
const tenantMatch = orgTenantMatchesSearch(node, query, selectable);
const matchingMembers = orgMemberMatchesSearch(node, query, selectable);
if (
!tenantMatch &&
matchingMembers.length === 0 &&
childMatches.length === 0
) {
return null;
}
return {
...node,
members: tenantMatch ? node.members : matchingMembers,
children: childMatches,
};
}
function orgTenantMatchesSearch(
node: OrgChartNode,
query: string,
selectable: "tenant" | "user" | "both",
) {
const tenantValues = [
node.id,
node.name,
node.slug,
node.type,
node.orgUnitType ?? "",
node.visibility,
...node.domains,
];
if (
selectable !== "user" &&
tenantValues.some((value) => value.toLowerCase().includes(query))
) {
return true;
}
if (selectable === "tenant") {
return false;
}
return false;
}
function orgMemberMatchesSearch(
node: OrgChartNode,
query: string,
selectable: "tenant" | "user" | "both",
) {
if (selectable === "tenant") {
return [];
}
return node.members.filter((member) =>
[
member.id ?? "",
member.email,
member.name,
member.department ?? "",
member.grade ?? "",
member.position ?? "",
member.jobTitle ?? "",
].some((value) => value.toLowerCase().includes(query)),
);
}
function collectDescendantSelections(
node: OrgChartNode,
selectable: "tenant" | "user" | "both",
): OrgPickerSelection[] {
const selections: OrgPickerSelection[] = [];
const visit = (child: OrgChartNode) => {
if (selectable === "tenant" || selectable === "both") {
selections.push({ id: child.id, name: child.name, type: "tenant" });
}
if (selectable === "user" || selectable === "both") {
for (const member of child.members) {
selections.push({
id: member.id || `${child.id}:${member.email}`,
name: member.name,
type: "user",
});
}
}
for (const grandchild of child.children) {
visit(grandchild);
}
};
for (const child of node.children) {
visit(child);
}
return selections;
}
function selectionKey(selection: OrgPickerSelection) {
return `${selection.type}:${selection.id}`;
}
function formatMember(member: OrgContextMember) {
return [
member.name,
member.position,
member.jobTitle,
member.isLeader || member.isOwner ? "조직장" : "",
]
.filter(Boolean)
.join(" · ");
}

View File

@@ -0,0 +1,347 @@
import { describe, expect, it, vi } from "vitest";
import {
buildOrgChartModel,
createOrgContextClient,
type OrgContextResponse,
renderOrgChart,
renderOrgPicker,
} from "./index";
const sampleOrgContext: OrgContextResponse = {
schemaVersion: "baron.org-context.v1",
issuedAt: "2026-05-15T00:00:00Z",
scope: {
tenantId: "root",
tenantSlug: "hanmac-family",
},
tree: {
id: "root",
type: "COMPANY_GROUP",
name: "한맥가족",
slug: "hanmac-family",
status: "active",
description: "",
domains: [],
memberCount: 0,
visibility: "public",
createdAt: "2026-05-15T00:00:00Z",
updatedAt: "2026-05-15T00:00:00Z",
members: [],
children: [
{
id: "company-baron",
type: "COMPANY",
name: "Baron",
slug: "baron",
parentId: "root",
status: "active",
description: "",
domains: ["baron.example"],
memberCount: 1,
visibility: "public",
createdAt: "2026-05-15T00:00:00Z",
updatedAt: "2026-05-15T00:00:00Z",
members: [
{
email: "leader@example.com",
name: "Leader",
grade: "책임",
position: "팀장",
isLeader: true,
isOwner: true,
isPrimary: true,
},
],
children: [
{
id: "team-platform",
type: "USER_GROUP",
name: "Platform",
slug: "platform",
parentId: "company-baron",
status: "active",
description: "",
domains: [],
memberCount: 1,
visibility: "internal",
orgUnitType: "팀",
createdAt: "2026-05-15T00:00:00Z",
updatedAt: "2026-05-15T00:00:00Z",
members: [
{
email: "engineer@example.com",
name: "Engineer",
jobTitle: "Frontend Engineer",
isLeader: false,
isOwner: false,
isPrimary: false,
},
],
children: [],
},
],
},
],
},
tenants: [],
};
describe("org-context chart SDK", () => {
it("builds chart and lookup models from org-context v1", () => {
const model = buildOrgChartModel(sampleOrgContext);
expect(model.root.name).toBe("한맥가족");
expect(model.nodes).toHaveLength(3);
expect(model.tenantsBySlug.get("platform")?.orgUnitType).toBe("팀");
expect(model.membersByEmail.get("engineer@example.com")?.tenantIds).toEqual(
["team-platform"],
);
});
it("fetches org-context through authenticated API headers", async () => {
const fetcher = vi.fn(async () => {
return new Response(JSON.stringify(sampleOrgContext), {
headers: { "content-type": "application/json" },
status: 200,
});
});
const client = createOrgContextClient({
baseUrl: "https://sso.example.com",
credentials: {
keyId: "client-id",
keySecret: "client-secret",
},
fetch: fetcher,
});
await client.fetchOrgContext({
tenantSlug: "baron",
includeUsers: true,
includeUserIds: false,
});
const [url, init] = fetcher.mock.calls[0];
expect(String(url)).toBe(
"https://sso.example.com/api/v1/integrations/org-context?tenantSlug=baron&includeUsers=true&includeUserIds=false",
);
expect(init.headers).toMatchObject({
"X-Baron-Key-ID": "client-id",
"X-Baron-Key-Secret": "client-secret",
});
});
it("renders chart and picker DOM with selection events", () => {
const model = buildOrgChartModel(sampleOrgContext);
const chartContainer = document.createElement("div");
const pickerContainer = document.createElement("div");
const onChange = vi.fn();
renderOrgChart(chartContainer, model);
const picker = renderOrgPicker(pickerContainer, model, {
mode: "multiple",
selectable: "both",
onChange,
});
expect(
chartContainer.querySelectorAll("[data-baron-org-node]"),
).toHaveLength(3);
expect(chartContainer.textContent).toContain("Leader · 팀장 · 조직장");
const platformCheckbox = pickerContainer.querySelector<HTMLInputElement>(
'input[value="tenant:team-platform"]',
);
expect(platformCheckbox).not.toBeNull();
platformCheckbox?.click();
expect(picker.getSelection()).toEqual([
{
id: "team-platform",
name: "Platform",
type: "tenant",
},
]);
expect(onChange).toHaveBeenCalledWith([
{
id: "team-platform",
name: "Platform",
type: "tenant",
},
]);
});
it("packages default picker UX and styles with search and descendant selection", () => {
const model = buildOrgChartModel(sampleOrgContext);
const pickerContainer = document.createElement("div");
const onChange = vi.fn();
const picker = renderOrgPicker(pickerContainer, model, {
mode: "multiple",
selectable: "both",
onChange,
});
expect(
document.head.querySelector(
'style[data-baron-org-context-chart-style="default"]',
),
).not.toBeNull();
expect(
pickerContainer.querySelector<HTMLInputElement>(
'input[type="search"][data-baron-org-picker-search]',
),
).not.toBeNull();
expect(
pickerContainer.querySelector<HTMLInputElement>(
'input[type="checkbox"][data-baron-org-picker-descendants]',
),
).not.toBeNull();
expect(
pickerContainer.querySelector("[data-baron-org-picker-summary]")
?.textContent,
).toContain("0 selected");
const search = pickerContainer.querySelector<HTMLInputElement>(
'input[type="search"][data-baron-org-picker-search]',
);
expect(search).not.toBeNull();
if (!search) return;
search.value = "platform";
search.dispatchEvent(new Event("input", { bubbles: true }));
expect(pickerContainer.textContent).toContain("Platform");
expect(pickerContainer.textContent).not.toContain(
"Leader (leader@example.com)",
);
search.value = "";
search.dispatchEvent(new Event("input", { bubbles: true }));
const descendantToggle = pickerContainer.querySelector<HTMLInputElement>(
'input[type="checkbox"][data-baron-org-picker-descendants]',
);
expect(descendantToggle).not.toBeNull();
descendantToggle?.click();
const companyBaron = pickerContainer.querySelector<HTMLInputElement>(
'input[value="tenant:company-baron"]',
);
expect(companyBaron).not.toBeNull();
companyBaron?.click();
expect(picker.getSelection()).toEqual([
{ id: "company-baron", name: "Baron", type: "tenant" },
{ id: "team-platform", name: "Platform", type: "tenant" },
{
id: "team-platform:engineer@example.com",
name: "Engineer",
type: "user",
},
]);
expect(
pickerContainer.querySelector("[data-baron-org-picker-summary]")
?.textContent,
).toContain("3 selected");
expect(onChange).toHaveBeenLastCalledWith([
{ id: "company-baron", name: "Baron", type: "tenant" },
{ id: "team-platform", name: "Platform", type: "tenant" },
{
id: "team-platform:engineer@example.com",
name: "Engineer",
type: "user",
},
]);
});
it("renders the orgfront-compatible picker UX", () => {
const model = buildOrgChartModel(sampleOrgContext);
const pickerContainer = document.createElement("div");
const onChange = vi.fn();
const onConfirm = vi.fn();
const onCancel = vi.fn();
const picker = renderOrgPicker(pickerContainer, model, {
mode: "single",
selectable: "tenant",
variant: "orgfront",
showDescendantToggle: false,
onCancel,
onChange,
onConfirm,
});
expect(
pickerContainer.classList.contains("baron-org-picker--orgfront"),
).toBe(true);
expect(
pickerContainer.querySelector<HTMLInputElement>(
'input[type="radio"][value="tenant:company-baron"]',
),
).toBeNull();
expect(
pickerContainer.querySelector("[data-baron-org-picker-search-icon]"),
).not.toBeNull();
expect(
pickerContainer.querySelector<HTMLInputElement>(
"[data-baron-org-picker-search]",
)?.placeholder,
).toBe("ID, 이름, 이메일, 메타데이터");
expect(
pickerContainer.querySelector("[data-baron-org-picker-descendants]"),
).toBeNull();
expect(
pickerContainer.querySelector("[data-baron-org-picker-footer]"),
).not.toBeNull();
const companyButton = pickerContainer.querySelector<HTMLButtonElement>(
'button[data-baron-org-picker-value="tenant:company-baron"]',
);
expect(companyButton).not.toBeNull();
companyButton?.click();
expect(onChange).toHaveBeenCalledWith([
{ id: "company-baron", name: "Baron", type: "tenant" },
]);
expect(picker.getSelection()).toEqual([
{ id: "company-baron", name: "Baron", type: "tenant" },
]);
const collapse = pickerContainer.querySelector<HTMLButtonElement>(
'button[data-baron-org-picker-toggle="tenant:company-baron"]',
);
expect(collapse).not.toBeNull();
expect(collapse?.textContent).toBe("");
expect(
collapse?.querySelector("[data-baron-org-picker-chevron]"),
).not.toBeNull();
collapse?.click();
const collapsed = pickerContainer.querySelector<HTMLButtonElement>(
'button[data-baron-org-picker-toggle="tenant:company-baron"]',
);
expect(
collapsed
?.querySelector("[data-baron-org-picker-chevron]")
?.classList.contains("baron-org-picker__chevron--open"),
).toBe(false);
expect(
pickerContainer.querySelector(
'button[data-baron-org-picker-value="tenant:team-platform"]',
),
).toBeNull();
const confirm = pickerContainer.querySelector<HTMLButtonElement>(
"[data-baron-org-picker-confirm]",
);
expect(confirm?.disabled).toBe(false);
confirm?.click();
expect(onConfirm).toHaveBeenCalledWith([
{ id: "company-baron", name: "Baron", type: "tenant" },
]);
pickerContainer
.querySelector<HTMLButtonElement>("[data-baron-org-picker-cancel]")
?.click();
expect(onCancel).toHaveBeenCalled();
picker.destroy();
});
});

View File

@@ -0,0 +1,14 @@
import type { Config } from "tailwindcss";
import commonPreset from "../common/theme/tailwind.preset";
const config: Config = {
presets: [commonPreset],
content: [
"./index.html",
"./src/**/*.{ts,tsx}",
"../common/core/**/*.{ts,tsx}",
"../common/shell/**/*.{ts,tsx}",
],
};
export default config;

View File

@@ -0,0 +1,39 @@
import { expect, test } from "@playwright/test";
import {
type Consent,
installDevApiMock,
makeClient,
seedAuth,
} from "./helpers/devfront-fixtures";
test("clients page loads correctly", async ({ page }) => {
await seedAuth(page);
await installDevApiMock(page, {
clients: [
makeClient("client-playwright", {
name: "Playwright Client",
createdAt: new Date().toISOString(),
redirectUris: ["http://localhost:5174/callback"],
}),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
});
await page.goto("/clients");
await expect(page).toHaveURL(/\/clients$/);
// 타이틀 확인
await expect(page).toHaveTitle(/바론 개발자 서비스/);
// 페이지 내 주요 텍스트 확인
await expect(page.getByText("연동 앱 목록")).toBeVisible();
// 테이블 헤더 확인
await expect(
page.locator("th").filter({ hasText: "애플리케이션" }),
).toBeVisible();
await expect(
page.locator("th").filter({ hasText: /클라이언트 ID|Client ID/i }),
).toBeVisible();
});

View File

@@ -0,0 +1,120 @@
import { expect, test } from "@playwright/test";
import {
type AuditLog,
type Consent,
installDevApiMock,
makeClient,
seedAuth,
} from "./helpers/devfront-fixtures";
const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i;
test.describe("DevFront audit logs", () => {
test.beforeEach(async ({ page }) => {
page.on("dialog", async (dialog) => {
await dialog.accept().catch(() => {});
});
await seedAuth(page);
});
test("filtering and cursor pagination", async ({ page }) => {
const state = {
clients: [makeClient("client-audit", { name: "Audit app" })],
consents: [] as Consent[],
auditLogsByCursor: {
"": {
items: [
{
event_id: "evt-1",
timestamp: "2026-03-03T09:00:00.000Z",
user_id: "actor-a",
event_type: "CLIENT_UPDATE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "playwright",
details: JSON.stringify({
action: "UPDATE_CLIENT",
target_id: "client-audit",
tenant_id: "tenant-a",
}),
},
],
next_cursor: "cursor-2",
},
"cursor-2": {
items: [
{
event_id: "evt-2",
timestamp: "2026-03-03T09:01:00.000Z",
user_id: "actor-b",
event_type: "CLIENT_ROTATE_SECRET",
status: "success",
ip_address: "127.0.0.1",
user_agent: "playwright",
details: JSON.stringify({
action: "ROTATE_SECRET",
target_id: "client-audit",
tenant_id: "tenant-a",
}),
},
],
},
},
};
await installDevApiMock(page, state);
await page.goto("/audit-logs");
await expect(page.getByText("UPDATE_CLIENT")).toBeVisible();
await page
.getByPlaceholder(/Client ID로 필터|Filter by Client ID/i)
.fill("client-audit");
await page
.getByPlaceholder(/액션으로 필터|Filter by Action/i)
.fill("ROTATE_SECRET");
await page.getByRole("button", { name: /더 보기|Load more/i }).click();
await expect(page.getByText("ROTATE_SECRET")).toBeVisible();
});
test("realtime create/update actions should be recorded", async ({
page,
}) => {
const state = {
clients: [makeClient("client-realtime", { name: "Realtime app" })],
consents: [] as Consent[],
auditLogs: [] as AuditLog[],
auditLogsByCursor: undefined,
};
await installDevApiMock(page, state);
await page.goto("/clients/new");
await page.getByPlaceholder(appNamePlaceholder).fill("Realtime New App");
await page
.getByPlaceholder(/https:\/\/app\.example\.com\/callback/i)
.fill("https://realtime.example.com/callback");
await page.getByRole("button", { name: /앱 생성|Create/i }).click();
await expect.poll(() => state.auditLogs.length).toBeGreaterThanOrEqual(1);
await expect.poll(() => state.clients.at(-1)?.id).toMatch(/^client-/);
const createdClientId = state.clients.at(-1)?.id;
expect(createdClientId).toBeTruthy();
await page.goto(`/clients/${createdClientId}/settings`);
await page.getByPlaceholder(appNamePlaceholder).fill("Realtime Updated");
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect.poll(() => state.auditLogs.length).toBeGreaterThanOrEqual(2);
const actions = state.auditLogs
.map((item) => {
try {
return JSON.parse(item.details)?.action as string | undefined;
} catch {
return undefined;
}
})
.filter((value): value is string => Boolean(value));
expect(actions).toContain("CREATE_CLIENT");
expect(actions).toContain("UPDATE_CLIENT");
});
});

View File

@@ -0,0 +1,374 @@
import { expect, test } from "@playwright/test";
import {
type ClientStatus,
type Consent,
installDevApiMock,
makeClient,
seedAuth,
} from "./helpers/devfront-fixtures";
const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i;
const jwksUri = "https://rp.example.com/.well-known/jwks.json";
test.describe("DevFront clients lifecycle", () => {
test.beforeEach(async ({ page }) => {
page.on("dialog", async (dialog) => {
await dialog.accept();
});
await seedAuth(page);
});
test("create, update status, and delete", async ({ page }) => {
const state = {
clients: [makeClient("existing-client", { name: "Existing app" })],
consents: [] as Consent[],
updatedStatus: "active" as ClientStatus,
auditLogsByCursor: undefined,
onUpdateStatus(status: ClientStatus) {
this.updatedStatus = status;
},
};
await installDevApiMock(page, state);
await page.goto("/clients");
await expect(page.getByText("Existing app")).toBeVisible();
await page
.getByRole("button", { name: /연동 앱 추가|새 클라이언트|Create/i })
.click();
await expect(page).toHaveURL(/\/clients\/new$/);
await page
.getByPlaceholder(appNamePlaceholder)
.fill("Playwright Created App");
await page
.getByPlaceholder(/https:\/\/app\.example\.com\/callback/i)
.fill("https://playwright.example.com/callback");
await page
.getByRole("button", { name: /앱 생성|클라이언트 생성|Create/i })
.click();
await expect(page).toHaveURL(/\/clients\/client-\d+\/settings$/);
await expect(
page.getByRole("heading", {
name: /연동 앱 설정|클라이언트 설정|Client Settings/i,
}),
).toBeVisible();
await page.getByRole("button", { name: /비활성|Inactive/i }).click();
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect.poll(() => state.updatedStatus).toBe("inactive");
await page.getByRole("button", { name: /삭제|Delete/i }).click();
await expect(page).toHaveURL(/\/clients$/);
await expect(page.getByText("Playwright Created App")).not.toBeVisible();
});
test("rotate secret shows new value", async ({ page }) => {
let rotatedSecret = "";
const state = {
clients: [makeClient("client-rotate", { name: "Rotate app" })],
consents: [] as Consent[],
auditLogsByCursor: undefined,
onRotateSecret(newSecret: string) {
rotatedSecret = newSecret;
},
};
await installDevApiMock(page, state);
await page.goto("/clients/client-rotate");
await expect(
page.getByRole("heading", { name: "Rotate app", exact: true }),
).toBeVisible();
await page.getByTitle(/비밀키 재발급|Rotate/i).click();
await expect.poll(() => rotatedSecret).toBe("client-rotate-rotated-secret");
await expect(page.getByText("client-rotate-rotated-secret")).toBeVisible();
});
test("update name and redirect URI should be persisted", async ({ page }) => {
const state = {
clients: [
makeClient("client-edit", {
name: "Before Name",
redirectUris: ["https://before.example.com/callback"],
}),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
};
await installDevApiMock(page, state);
await page.goto("/clients/client-edit/settings");
await page.getByPlaceholder(appNamePlaceholder).fill("After Name");
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect.poll(() => state.clients[0]?.name).toBe("After Name");
await page.goto("/clients/client-edit");
await page
.getByRole("textbox", { name: /인증 콜백 URL|Callback/i })
.fill("https://after.example.com/callback");
await page
.getByRole("button", { name: /Redirect URIs 저장|Save/i })
.click();
await expect
.poll(() => state.clients[0]?.redirectUris[0])
.toBe("https://after.example.com/callback");
await page.reload();
await expect(
page.getByRole("textbox", { name: /인증 콜백 URL|Callback/i }),
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
});
test("pkce headless login uses jwks uri only and shows cache actions", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-headless-login", {
name: "Headless Login App",
type: "pkce",
metadata: {
request_object_signing_alg: "RS256",
},
headlessJwksCache: {
clientId: "client-headless-login",
jwksUri,
cachedAt: "2026-03-31T00:00:00.000Z",
expiresAt: "2026-04-01T00:00:00.000Z",
lastCheckedAt: "2026-03-31T12:00:00.000Z",
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
lastRefreshStatus: "success",
lastError: "",
consecutiveFailures: 0,
cachedKids: ["kid-1"],
etag: 'W/"cache-etag"',
lastModified: "Tue, 31 Mar 2026 00:00:00 GMT",
parsedKeys: [
{
kid: "kid-1",
kty: "RSA",
use: "sig",
alg: "RS256",
n: "voVbHlo_UHkjtT7Q_8owyjZ2omE8n8mbGlpraZziStHPfe08q_RGiEXO6Pyiz42NVi-Yo0c7qiaqRwB4h9s5phpT2wwcUxnkrQeRhe7BpigInZPzpwq1hsaB2zyhE7zTRCC3hinGtFdVpNzTVKYKGPbXfeEXaRL3P838vi-_iB4IN3WQk_pAakUQvajL2H-vcWSMSNslMGPDZxobqE9MHSWocNXemrcmtCeE7ruUND0qHZOb8k-hHUBqsNoJ63WKdapzGYF6e2qgDRveYrjgOCBigZPi8npN0xStQ0YcrH_RxeTogsdRZ8SuXmLqavryVDnrT8czPkkJ-EHb8PiTCQ",
},
],
},
}),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
onRefreshHeadlessJwks(clientId: string) {
if (this.clients[0].headlessJwksCache) {
this.clients[0].headlessJwksCache = {
...this.clients[0].headlessJwksCache,
lastRefreshStatus: "success",
lastCheckedAt: "2026-04-01T00:00:00.000Z",
};
}
expect(clientId).toBe("client-headless-login");
},
onRevokeHeadlessJwksCache(clientId: string) {
expect(clientId).toBe("client-headless-login");
},
};
await installDevApiMock(page, state);
await page.goto("/clients/client-headless-login/settings");
await page
.getByRole("switch", {
name: /Headless Login \(자체 로그인 UI 사용\)|Headless Login \(Custom Login UI\)/i,
})
.click();
await expect(
page.getByRole("heading", {
name: /공개키 등록|Public Key Registration/i,
}),
).toBeVisible();
await expect(
page.getByText(/Request Object Signing Algorithm/i),
).toHaveCount(0);
await expect(
page.getByText(/Allowed algorithms|허용 알고리즘/i),
).toHaveCount(0);
await page
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
.fill(jwksUri);
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect
.poll(() => state.clients[0]?.tokenEndpointAuthMethod)
.toBe("none");
await expect
.poll(() => state.clients[0]?.metadata?.headless_login_enabled)
.toBe(true);
await expect
.poll(
() => state.clients[0]?.metadata?.headless_token_endpoint_auth_method,
)
.toBe("private_key_jwt");
await expect
.poll(() => state.clients[0]?.metadata?.headless_jwks_uri)
.toBe(jwksUri);
await expect
.poll(() => state.clients[0]?.metadata?.request_object_signing_alg)
.toBeUndefined();
await expect(
page.getByText(/cached at|캐시됨|last refresh|마지막 갱신/i),
).toBeVisible();
await expect(page.getByText(/Parsed Keys|파싱된 키/i)).toBeVisible();
await expect(page.getByText(/^KID$/i)).toBeVisible();
await expect(page.getByText("kid-1", { exact: true }).last()).toBeVisible();
await expect(
page.getByText(
"voVbHlo_UHkjtT7Q_8owyjZ2omE8n8mbGlpraZziStHPfe08q_RGiEXO6Pyiz42NVi-Yo0c7qiaqRwB4h9s5phpT2wwcUxnkrQeRhe7BpigInZPzpwq1hsaB2zyhE7zTRCC3hinGtFdVpNzTVKYKGPbXfeEXaRL3P838vi-_iB4IN3WQk_pAakUQvajL2H-vcWSMSNslMGPDZxobqE9MHSWocNXemrcmtCeE7ruUND0qHZOb8k-hHUBqsNoJ63WKdapzGYF6e2qgDRveYrjgOCBigZPi8npN0xStQ0YcrH_RxeTogsdRZ8SuXmLqavryVDnrT8czPkkJ-EHb8PiTCQ",
{ exact: true },
),
).toBeVisible();
await expect(
page.getByRole("button", { name: /refresh|새로고침/i }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i }),
).toBeVisible();
await page.getByRole("button", { name: /refresh|새로고침/i }).click();
await expect
.poll(() => state.clients[0]?.headlessJwksCache?.lastCheckedAt)
.toBe("2026-04-01T00:00:00.000Z");
page.removeAllListeners("dialog");
page.once("dialog", async (dialog) => {
expect(dialog.message()).toMatch(/revoke|삭제|cache/i);
await dialog.accept();
});
await page
.getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i })
.click();
await expect
.poll(() => state.clients[0]?.headlessJwksCache)
.toBeUndefined();
await page.reload();
await expect(
page.getByRole("heading", {
name: /공개키 등록|Public Key Registration/i,
}),
).toBeVisible();
await expect(
page.getByRole("textbox", { name: /JWKS URI|JWKS URI/i }),
).toHaveValue(jwksUri);
});
test("pkce headless login blocks save when parsed jwks algorithm is unsupported", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-headless-unsupported", {
name: "Unsupported Headless Login App",
type: "pkce",
metadata: {
headless_login_enabled: true,
request_object_signing_alg: "RS256",
},
headlessJwksCache: {
clientId: "client-headless-unsupported",
jwksUri,
cachedAt: "2026-03-31T00:00:00.000Z",
expiresAt: "2026-04-01T00:00:00.000Z",
lastCheckedAt: "2026-03-31T12:00:00.000Z",
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
lastRefreshStatus: "success",
lastError: "",
consecutiveFailures: 0,
cachedKids: ["kid-unsupported"],
parsedKeys: [
{
kid: "kid-unsupported",
kty: "RSA",
use: "sig",
alg: "HS256",
n: "unsupported-n-value",
},
],
},
}),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
};
await installDevApiMock(page, state);
await page.goto("/clients/client-headless-unsupported/settings");
await page
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
.fill(jwksUri);
await expect(
page.getByText("지원하지 않는 알고리즘이 감지되었습니다.", {
exact: true,
}),
).toBeVisible();
await expect(
page.getByRole("button", { name: /^저장$|^Save$/i }),
).toBeDisabled();
});
test("pkce headless login blocks save when parsed jwks algorithm is missing", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-headless-missing-alg", {
name: "Missing Alg Headless Login App",
type: "pkce",
metadata: {
headless_login_enabled: true,
headless_jwks_uri: jwksUri,
},
headlessJwksCache: {
clientId: "client-headless-missing-alg",
jwksUri,
cachedAt: "2026-03-31T00:00:00.000Z",
expiresAt: "2026-04-01T00:00:00.000Z",
lastCheckedAt: "2026-03-31T12:00:00.000Z",
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
lastRefreshStatus: "success",
lastError: "",
consecutiveFailures: 0,
cachedKids: ["kid-missing-alg"],
parsedKeys: [
{
kid: "kid-missing-alg",
kty: "RSA",
use: "sig",
alg: "",
n: "missing-alg-n-value",
},
],
},
}),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
};
await installDevApiMock(page, state);
await page.goto("/clients/client-headless-missing-alg/settings");
await expect(
page.getByText(/알고리즘이 선언되지 않았습니다|algorithm is missing/i),
).toBeVisible();
await expect(
page.getByRole("button", { name: /^저장$|^Save$/i }),
).toBeDisabled();
});
});

View File

@@ -0,0 +1,45 @@
import { expect, test } from "@playwright/test";
import {
type Consent,
installDevApiMock,
makeClient,
seedAuth,
} from "./helpers/devfront-fixtures";
test.describe("DevFront consents", () => {
test.beforeEach(async ({ page }) => {
page.on("dialog", async (dialog) => {
await dialog.accept();
});
await seedAuth(page);
});
test("consent list and revoke flow", async ({ page }) => {
const state = {
clients: [makeClient("client-consent", { name: "Consent app" })],
consents: [
{
subject: "user-1",
userName: "Alice",
clientId: "client-consent",
clientName: "Consent app",
grantedScopes: ["openid", "profile"],
authenticatedAt: "2026-03-03T08:00:00.000Z",
createdAt: "2026-03-02T08:00:00.000Z",
status: "active",
tenantId: "tenant-a",
tenantName: "Tenant A",
},
] as Consent[],
auditLogsByCursor: undefined,
};
await installDevApiMock(page, state);
await page.goto("/clients/client-consent/consents");
await expect(page.getByText("Alice")).toBeVisible();
await expect(page.getByText("Tenant A")).toBeVisible();
await page.getByRole("button", { name: /권한 철회|철회|Revoke/i }).click();
await expect(page.getByText(/Revoked|철회/i).first()).toBeVisible();
});
});

Some files were not shown because too many files have changed in this diff Show More