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

This commit is contained in:
송대일
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.git
.gitignore
*.md
docker-compose.yml
.env*
npm-debug.log
+24
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?
+38
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"]
+20
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"]
+106
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*
+7
View File
@@ -0,0 +1,7 @@
{
"root": true,
"extends": ["../common/config/biome.base.json"],
"files": {
"includes": [".vite"]
}
}
+19
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
@@ -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가 충돌할 경우 더 좁은 범위를 적용하거나 요청을 거절해야 합니다.
+188
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()
+13
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>
File diff suppressed because it is too large Load Diff
+64
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"
}
}
+89
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
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+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="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

@@ -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}`;
}
+143
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
@@ -0,0 +1,7 @@
import { QueryClient } from "@tanstack/react-query";
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
export const queryClient = new QueryClient({
defaultOptions: queryClientDefaultOptions,
});
@@ -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);
});
});
+50
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]);
+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

@@ -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>
);
}
@@ -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;
@@ -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();
});
});
@@ -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;
@@ -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 };
@@ -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 };
@@ -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();
});
});
});
@@ -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 };
@@ -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,
};
@@ -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>
);
}
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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,
};
@@ -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 };
@@ -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>
);
}
@@ -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;
};
@@ -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;
@@ -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>;
}
@@ -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);
});
});
@@ -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 />;
}
@@ -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;
@@ -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);
});
});
@@ -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;
@@ -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;
}
@@ -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;
@@ -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
@@ -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;
@@ -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>
);
}
@@ -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");
});
});
@@ -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;
@@ -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);
});
});
@@ -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];
}
@@ -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",
]);
});
});
@@ -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;
}
@@ -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",
);
});
});
@@ -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()}`;
}
@@ -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);
});
});
@@ -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);
}
@@ -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
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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() : "";
}
@@ -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,
);
});
});
@@ -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;
}
@@ -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;
@@ -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>
);
}
+35
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%
);
}
}
+216
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");
});
});
+771
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;
}
+75
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;
+24
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),
);
@@ -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");
});
});
+33
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}`,
};
}
+139
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" },
);
});
});
+315
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;
}
+16
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],
});
+27
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 "";
}
@@ -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,
});
}
@@ -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",
);
});
});
+88
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 };
}
+8
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
+27
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>,
);
@@ -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(" · ");
}
@@ -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();
});
});
+14
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;
+39
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();
});
@@ -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");
});
});
@@ -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();
});
});
@@ -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