첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
8
baron-sso/orgfront/.dockerignore
Normal file
8
baron-sso/orgfront/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
docker-compose.yml
|
||||
.env*
|
||||
npm-debug.log
|
||||
24
baron-sso/orgfront/.gitignore
vendored
Normal file
24
baron-sso/orgfront/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
38
baron-sso/orgfront/Dockerfile
Normal file
38
baron-sso/orgfront/Dockerfile
Normal 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
baron-sso/orgfront/Dockerfile2
Normal file
20
baron-sso/orgfront/Dockerfile2
Normal 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
baron-sso/orgfront/README.md
Normal file
106
baron-sso/orgfront/README.md
Normal 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
baron-sso/orgfront/biome.json
Normal file
7
baron-sso/orgfront/biome.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["../common/config/biome.base.json"],
|
||||
"files": {
|
||||
"includes": [".vite"]
|
||||
}
|
||||
}
|
||||
19
baron-sso/orgfront/docker-compose.yml
Normal file
19
baron-sso/orgfront/docker-compose.yml
Normal 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
|
||||
125
baron-sso/orgfront/docs/orgchart-embedding-token-design.md
Normal file
125
baron-sso/orgfront/docs/orgchart-embedding-token-design.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 조직도 임베딩 토큰 설계
|
||||
|
||||
## 목적
|
||||
|
||||
조직도 임베딩 검증 화면은 외부 서비스가 Baron OrgChart의 조직 선택기 또는 조직도 데이터를 안전하게 사용할 수 있는지를 확인하는 도구입니다. 토큰 설계는 다음 두 가지 요구를 동시에 다룹니다.
|
||||
|
||||
* 최상위 테넌트 권한 범위에 맞춰 조직도 접근 토큰을 발급한다.
|
||||
* 특정 서비스가 장기간 안정적으로 임베딩할 수 있도록 서비스 전용 토큰을 생성하고 갱신한다.
|
||||
|
||||
## 방식 A: 최상위 테넌트 권한 기반 토큰
|
||||
|
||||
이 방식은 로그인한 사용자의 현재 권한을 기준으로 단기 공유 토큰을 발급합니다. 관리자가 `COMPANY_GROUP` 또는 최상위 테넌트 범위를 선택하면, 서버는 사용자가 해당 범위를 볼 수 있는지 확인한 뒤 제한된 수명의 토큰을 반환합니다.
|
||||
|
||||
### 권장 API
|
||||
|
||||
```http
|
||||
POST /v1/orgchart/embed-tokens
|
||||
Authorization: Bearer <access_token>
|
||||
X-Tenant-ID: <current-tenant-id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"scopeType": "tenant_root",
|
||||
"rootTenantId": "group-hmac",
|
||||
"allowedModes": ["single", "multiple"],
|
||||
"allowedSelectableTypes": ["tenant", "user", "both"],
|
||||
"expiresInSeconds": 3600
|
||||
}
|
||||
```
|
||||
|
||||
### 서버 검증
|
||||
|
||||
* 요청자는 `rootTenantId` 또는 그 상위 범위를 관리할 수 있어야 합니다.
|
||||
* 토큰에는 `rootTenantId`, 허용 선택 모드, 허용 선택 대상, 만료 시각을 포함합니다.
|
||||
* 토큰은 서버 저장형 opaque token을 우선 권장합니다. 즉시 폐기와 감사 로그 추적이 쉽기 때문입니다.
|
||||
* JWT를 사용할 경우 `jti`를 저장해 폐기 목록을 운영해야 합니다.
|
||||
|
||||
### 장점
|
||||
|
||||
* 현재 관리자 권한 모델과 자연스럽게 연결됩니다.
|
||||
* 단기 검증, 데모, 운영자 테스트에 적합합니다.
|
||||
* 토큰 범위가 명확해 권한 사고 범위를 줄일 수 있습니다.
|
||||
|
||||
### 한계
|
||||
|
||||
* 서비스가 장기간 임베딩하려면 사용자가 주기적으로 재발급해야 합니다.
|
||||
* 외부 서비스 단위의 소유자, 회전 주기, 폐기 정책을 별도로 관리하기 어렵습니다.
|
||||
|
||||
## 방식 B: 특정 서비스용 토큰 생성 및 갱신
|
||||
|
||||
이 방식은 Baron Admin 또는 OrgFront 관리 화면에서 외부 서비스별 임베딩 클라이언트를 등록하고, 각 서비스에 장기 토큰 또는 회전 가능한 토큰 세트를 발급합니다.
|
||||
|
||||
### 권장 모델
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "embed-client-001",
|
||||
"serviceName": "crm-dashboard",
|
||||
"ownerTenantId": "group-hmac",
|
||||
"rootTenantId": "company-baron",
|
||||
"allowedOrigins": ["https://crm.example.com"],
|
||||
"allowedModes": ["single", "multiple"],
|
||||
"allowedSelectableTypes": ["user"],
|
||||
"status": "active",
|
||||
"expiresAt": "2026-07-31T00:00:00.000Z",
|
||||
"lastRotatedAt": "2026-04-29T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 권장 API
|
||||
|
||||
```http
|
||||
POST /v1/admin/orgchart/embed-clients
|
||||
GET /v1/admin/orgchart/embed-clients
|
||||
POST /v1/admin/orgchart/embed-clients/{clientId}/rotate-token
|
||||
POST /v1/admin/orgchart/embed-clients/{clientId}/revoke
|
||||
GET /v1/public/orgchart/embed-config?token=<embed-token>
|
||||
```
|
||||
|
||||
### 서버 검증
|
||||
|
||||
* 서비스 토큰은 특정 `rootTenantId` 이하 데이터에만 접근할 수 있어야 합니다.
|
||||
* `allowedOrigins`가 설정된 경우 `Origin` 헤더를 검증합니다.
|
||||
* 토큰 회전 시 기존 토큰과 신규 토큰이 함께 유효한 grace period를 짧게 둘 수 있습니다.
|
||||
* 모든 생성, 갱신, 폐기, 사용 이벤트는 감사 로그에 남깁니다.
|
||||
* 토큰 원문은 최초 생성 또는 회전 직후에만 보여주고, 서버에는 해시만 저장합니다.
|
||||
|
||||
### 장점
|
||||
|
||||
* 외부 서비스별 접근 범위, 만료, 회전, 폐기 정책을 독립적으로 관리할 수 있습니다.
|
||||
* 운영 환경의 장기 임베딩에 적합합니다.
|
||||
* 감사 로그와 장애 대응이 명확합니다.
|
||||
|
||||
### 한계
|
||||
|
||||
* 별도 관리 화면과 백엔드 저장 모델이 필요합니다.
|
||||
* 토큰 회전 정책, Origin 검증, 만료 알림 등 운영 기능의 설계 범위가 커집니다.
|
||||
|
||||
## 권장 적용 순서
|
||||
|
||||
1. 먼저 방식 A를 구현해 임베딩 검증 화면에서 최상위 테넌트 권한 기반 단기 토큰을 발급합니다.
|
||||
2. 토큰 payload와 감사 로그 이벤트 스키마를 방식 B에서도 재사용할 수 있게 고정합니다.
|
||||
3. 외부 서비스 운영 요구가 확정되면 방식 B의 embed client 관리 API와 화면을 추가합니다.
|
||||
4. 방식 B 도입 후에도 방식 A는 관리자 테스트용 단기 토큰 발급 기능으로 유지합니다.
|
||||
|
||||
## 프론트엔드 반영 방향
|
||||
|
||||
임베딩 검증 화면은 현재 선택 모드와 선택 대상 조합을 바꿔 iframe을 갱신합니다. 토큰 기능이 추가되면 다음 값을 함께 표시해야 합니다.
|
||||
|
||||
* 발급 주체: 현재 사용자 또는 서비스 클라이언트
|
||||
* 접근 루트: `rootTenantId`
|
||||
* 허용 Origin
|
||||
* 허용 선택 모드: `single`, `multiple`
|
||||
* 허용 선택 대상: `tenant`, `user`, `both`
|
||||
* 만료 시각과 갱신 가능 여부
|
||||
|
||||
iframe URL은 다음 형태를 기준으로 확장합니다.
|
||||
|
||||
```text
|
||||
/embed/picker?token=<embed-token>&mode=multiple&select=both
|
||||
```
|
||||
|
||||
서버는 토큰의 허용 범위와 URL query가 충돌할 경우 더 좁은 범위를 적용하거나 요청을 거절해야 합니다.
|
||||
188
baron-sso/orgfront/hydra-rp-dummy.py
Normal file
188
baron-sso/orgfront/hydra-rp-dummy.py
Normal 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
baron-sso/orgfront/index.html
Normal file
13
baron-sso/orgfront/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>바론 조직도 서비스</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5636
baron-sso/orgfront/package-lock.json
generated
Normal file
5636
baron-sso/orgfront/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
64
baron-sso/orgfront/package.json
Normal file
64
baron-sso/orgfront/package.json
Normal 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
baron-sso/orgfront/playwright.config.ts
Normal file
89
baron-sso/orgfront/playwright.config.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { shouldIncludeWebKit } =
|
||||
require("../scripts/playwrightHostDeps.cjs") as {
|
||||
shouldIncludeWebKit: () => boolean;
|
||||
};
|
||||
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
const port = Number.parseInt(process.env.PORT ?? "4175", 10);
|
||||
const defaultBaseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
||||
const reuseExistingServer = !process.env.CI && !process.env.PORT;
|
||||
const testOidcAuthority = "http://localhost:5000/oidc";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: [
|
||||
"**/light-theme.spec.ts",
|
||||
"**/orgchart-*.spec.ts",
|
||||
"**/orgfront-*.spec.ts",
|
||||
],
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: [["html", { open: "never" }], ["list"]],
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
|
||||
...(shouldIncludeWebKit()
|
||||
? [
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: process.env.BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: process.env.CI
|
||||
? `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run build && VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run preview -- --host 127.0.0.1 --port ${port}`
|
||||
: `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run dev -- --host 127.0.0.1 --port ${port}`,
|
||||
url: defaultBaseUrl,
|
||||
reuseExistingServer,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
});
|
||||
3635
baron-sso/orgfront/pnpm-lock.yaml
generated
Normal file
3635
baron-sso/orgfront/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
baron-sso/orgfront/postcss.config.js
Normal file
6
baron-sso/orgfront/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
1
baron-sso/orgfront/public/vite.svg
Normal file
1
baron-sso/orgfront/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
29
baron-sso/orgfront/scripts/build-org-context-chart.mjs
Normal file
29
baron-sso/orgfront/scripts/build-org-context-chart.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const buildId = createBuildId();
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const env = {
|
||||
...process.env,
|
||||
ORG_CONTEXT_CHART_BUILD_ID: buildId,
|
||||
};
|
||||
|
||||
for (const script of [
|
||||
"build:org-context-chart:full",
|
||||
"build:org-context-chart:min",
|
||||
]) {
|
||||
const result = spawnSync(npmCommand, ["run", script], {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function createBuildId() {
|
||||
const now = new Date();
|
||||
const year = String(now.getFullYear()).slice(-2);
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const random = String(Math.floor(Math.random() * 10000)).padStart(4, "0");
|
||||
return `${year}${month}${random}`;
|
||||
}
|
||||
143
baron-sso/orgfront/scripts/runtime-mode.sh
Normal file
143
baron-sso/orgfront/scripts/runtime-mode.sh
Normal 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
|
||||
7
baron-sso/orgfront/src/app/queryClient.ts
Normal file
7
baron-sso/orgfront/src/app/queryClient.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: queryClientDefaultOptions,
|
||||
});
|
||||
13
baron-sso/orgfront/src/app/routes.test.tsx
Normal file
13
baron-sso/orgfront/src/app/routes.test.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { matchRoutes } from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ORGFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
|
||||
import { orgFrontRoutes } from "./routes";
|
||||
|
||||
describe("orgfront routes", () => {
|
||||
it("accepts the auth callback path used by the OIDC redirect URI", () => {
|
||||
const matches = matchRoutes(orgFrontRoutes, ORGFRONT_AUTH_CALLBACK_PATH);
|
||||
|
||||
expect(matches).not.toBeNull();
|
||||
expect(matches?.at(-1)?.route.path).toBe(ORGFRONT_AUTH_CALLBACK_PATH);
|
||||
});
|
||||
});
|
||||
50
baron-sso/orgfront/src/app/routes.tsx
Normal file
50
baron-sso/orgfront/src/app/routes.tsx
Normal 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
baron-sso/orgfront/src/assets/react.svg
Normal file
1
baron-sso/orgfront/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -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;
|
||||
166
baron-sso/orgfront/src/components/layout/AppLayout.test.tsx
Normal file
166
baron-sso/orgfront/src/components/layout/AppLayout.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AppLayout from "./AppLayout";
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
activeNavigator: undefined as string | undefined,
|
||||
error: null as Error | null,
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
expires_at: Math.floor(Date.now() / 1000) + 120,
|
||||
profile: {
|
||||
sub: "user-1",
|
||||
name: "Org Admin",
|
||||
email: "org@example.com",
|
||||
role: "super_admin",
|
||||
},
|
||||
},
|
||||
signinSilent: vi.fn(),
|
||||
removeUser: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("../../features/auth/authApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "user-1",
|
||||
name: "Fetched Org Admin",
|
||||
email: "fetched@example.com",
|
||||
role: "super_admin",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||
let text = fallback ?? key;
|
||||
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
authState.isAuthenticated = true;
|
||||
authState.isLoading = false;
|
||||
authState.activeNavigator = undefined;
|
||||
authState.error = null;
|
||||
authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
|
||||
authState.signinSilent.mockReset();
|
||||
authState.signinSilent.mockResolvedValue(undefined);
|
||||
authState.removeUser.mockReset();
|
||||
window.localStorage.clear();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function renderLayout(initialEntry = "/clients") {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route path="clients" element={<div>Client outlet</div>} />
|
||||
<Route path="profile" element={<div>Profile outlet</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("orgfront AppLayout", () => {
|
||||
it("renders shell navigation, profile summary, and outlet content", async () => {
|
||||
const container = await renderLayout();
|
||||
|
||||
expect(container.textContent).toContain("Developer Console");
|
||||
expect(container.textContent).toContain("Clients");
|
||||
expect(container.textContent).toContain("Client outlet");
|
||||
expect(container.textContent).toContain("Fetched Org Admin");
|
||||
expect(document.documentElement.classList.contains("light")).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
|
||||
const container = await renderLayout();
|
||||
|
||||
const themeButton = container.querySelector(
|
||||
'button[aria-label="테마 전환"]',
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
themeButton.click();
|
||||
});
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
|
||||
const profileButton = container.querySelector(
|
||||
'button[aria-label="계정 메뉴 열기"]',
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
profileButton.click();
|
||||
});
|
||||
expect(container.textContent).toContain("Account");
|
||||
|
||||
const profileMenuItem = Array.from(
|
||||
container.querySelectorAll('button[role="menuitem"]'),
|
||||
).find((button) => button.textContent?.includes("내 정보"));
|
||||
await act(async () => {
|
||||
(profileMenuItem as HTMLButtonElement).click();
|
||||
});
|
||||
expect(container.textContent).toContain("Profile outlet");
|
||||
|
||||
const logoutButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Logout"),
|
||||
);
|
||||
await act(async () => {
|
||||
(logoutButton as HTMLButtonElement).click();
|
||||
});
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(authState.removeUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attempts silent renewal after user action when the session is expiring", async () => {
|
||||
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
|
||||
await renderLayout();
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
|
||||
});
|
||||
|
||||
expect(authState.signinSilent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
571
baron-sso/orgfront/src/components/layout/AppLayout.tsx
Normal file
571
baron-sso/orgfront/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BadgeCheck,
|
||||
ChevronDown,
|
||||
LogOut,
|
||||
Moon,
|
||||
NotebookTabs,
|
||||
ShieldHalf,
|
||||
Sun,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
applyShellTheme,
|
||||
buildShellProfileSummary,
|
||||
buildShellSessionStatus,
|
||||
readShellSessionExpiryEnabled,
|
||||
readShellTheme,
|
||||
type ShellTranslator,
|
||||
shellLayoutClasses,
|
||||
writeShellSessionExpiryEnabled,
|
||||
} from "../../../../common/shell";
|
||||
import { fetchMe } from "../../features/auth/authApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import {
|
||||
shouldAttemptSlidingSessionRenew,
|
||||
shouldAttemptUnlimitedSessionRenew,
|
||||
} from "../../lib/sessionSliding";
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
import { Toaster } from "../ui/toaster";
|
||||
|
||||
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
labelKey: "ui.dev.nav.clients",
|
||||
labelFallback: "Clients",
|
||||
to: "/clients",
|
||||
icon: ShieldHalf,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.nav.audit_logs",
|
||||
labelFallback: "Audit Logs",
|
||||
to: "/audit-logs",
|
||||
icon: NotebookTabs,
|
||||
},
|
||||
];
|
||||
|
||||
type SessionStatusProps = {
|
||||
expiresAtSec?: number | null;
|
||||
t: ShellTranslator;
|
||||
};
|
||||
|
||||
function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) {
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return buildShellSessionStatus({ expiresAtSec, nowMs, t });
|
||||
}
|
||||
|
||||
function SessionStatusBadge(props: SessionStatusProps) {
|
||||
const sessionStatus = useSessionStatus(props);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
shellLayoutClasses.sessionBadge,
|
||||
sessionStatus.toneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionStatus.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionStatusText(props: SessionStatusProps) {
|
||||
const sessionStatus = useSessionStatus(props);
|
||||
|
||||
return <>{sessionStatus.text}</>;
|
||||
}
|
||||
|
||||
function AppLayout() {
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const profileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const isRenewInFlightRef = useRef(false);
|
||||
const lastRenewAttemptAtRef = useRef(0);
|
||||
const lastVisitedRouteRef = useRef<string | null>(null);
|
||||
const isDevelopmentRuntime = import.meta.env.MODE === "development";
|
||||
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||
const [, setDevelopmentRenderRevision] = useState(0);
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||
);
|
||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["userMe"],
|
||||
queryFn: fetchMe,
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) {
|
||||
auth.removeUser();
|
||||
navigate("/login");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
applyShellTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDevelopmentRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rerenderDevelopmentShell = () => {
|
||||
setDevelopmentRenderRevision((value) => value + 1);
|
||||
};
|
||||
|
||||
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
LOCALE_CHANGED_EVENT,
|
||||
rerenderDevelopmentShell,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
profileMenuRef.current &&
|
||||
!profileMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsProfileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const maybeRenewSession = async () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: auth.user?.expires_at,
|
||||
nowMs: now,
|
||||
isEnabled: isSessionExpiryEnabled,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
isRenewInFlight: isRenewInFlightRef.current,
|
||||
lastAttemptAtMs: lastRenewAttemptAtRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRenewInFlightRef.current = true;
|
||||
lastRenewAttemptAtRef.current = now;
|
||||
|
||||
try {
|
||||
await auth.signinSilent();
|
||||
} catch (error) {
|
||||
console.error("세션 자동 연장에 실패했습니다.", error);
|
||||
} finally {
|
||||
isRenewInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserAction = () => {
|
||||
void maybeRenewSession();
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", handleUserAction);
|
||||
window.addEventListener("keydown", handleUserAction);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", handleUserAction);
|
||||
window.removeEventListener("keydown", handleUserAction);
|
||||
};
|
||||
}, [
|
||||
auth,
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isSessionExpiryEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDevelopmentRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeKeepSessionAlive = async () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!shouldAttemptUnlimitedSessionRenew({
|
||||
expiresAtSec: auth.user?.expires_at,
|
||||
nowMs: now,
|
||||
isEnabled: isSessionExpiryEnabled,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
isRenewInFlight: isRenewInFlightRef.current,
|
||||
lastAttemptAtMs: lastRenewAttemptAtRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRenewInFlightRef.current = true;
|
||||
lastRenewAttemptAtRef.current = now;
|
||||
|
||||
try {
|
||||
await auth.signinSilent();
|
||||
} catch (error) {
|
||||
console.error("세션 무제한 유지 갱신에 실패했습니다.", error);
|
||||
} finally {
|
||||
isRenewInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void maybeKeepSessionAlive();
|
||||
}, 30_000);
|
||||
|
||||
void maybeKeepSessionAlive();
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [
|
||||
auth,
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isSessionExpiryEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const routeKey = `${location.pathname}${location.search}${location.hash}`;
|
||||
if (lastVisitedRouteRef.current === null) {
|
||||
lastVisitedRouteRef.current = routeKey;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastVisitedRouteRef.current === routeKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastVisitedRouteRef.current = routeKey;
|
||||
|
||||
const now = Date.now();
|
||||
if (
|
||||
!shouldAttemptSlidingSessionRenew({
|
||||
expiresAtSec: auth.user?.expires_at,
|
||||
nowMs: now,
|
||||
isEnabled: isSessionExpiryEnabled,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
isRenewInFlight: isRenewInFlightRef.current,
|
||||
lastAttemptAtMs: lastRenewAttemptAtRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRenewInFlightRef.current = true;
|
||||
lastRenewAttemptAtRef.current = now;
|
||||
|
||||
void auth
|
||||
.signinSilent()
|
||||
.catch((error) => {
|
||||
console.error("세션 자동 연장에 실패했습니다.", error);
|
||||
})
|
||||
.finally(() => {
|
||||
isRenewInFlightRef.current = false;
|
||||
});
|
||||
}, [
|
||||
auth,
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
auth.user?.expires_at,
|
||||
isSessionExpiryEnabled,
|
||||
location.hash,
|
||||
location.pathname,
|
||||
location.search,
|
||||
]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
const profileSummary = buildShellProfileSummary({
|
||||
profileName:
|
||||
profile?.name ||
|
||||
auth.user?.profile?.name?.toString() ||
|
||||
auth.user?.profile?.preferred_username?.toString() ||
|
||||
auth.user?.profile?.nickname?.toString(),
|
||||
profileEmail: profile?.email || auth.user?.profile?.email?.toString(),
|
||||
fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"),
|
||||
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
|
||||
});
|
||||
const currentRole = resolveProfileRole(
|
||||
auth.user?.profile as Record<string, unknown> | undefined,
|
||||
);
|
||||
const displayRoleKey = profile?.role || currentRole;
|
||||
|
||||
const isDevConsoleAllowed = [
|
||||
"super_admin",
|
||||
"tenant_admin",
|
||||
"rp_admin",
|
||||
].includes(currentRole);
|
||||
const handleSessionExpiryToggle = () => {
|
||||
setIsSessionExpiryEnabled((prev) => {
|
||||
const next = !prev;
|
||||
writeShellSessionExpiryEnabled(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={shellLayoutClasses.root}>
|
||||
<aside className={shellLayoutClasses.aside}>
|
||||
<div>
|
||||
<div className={shellLayoutClasses.brandSection}>
|
||||
<div className={shellLayoutClasses.brandWrap}>
|
||||
<div className={shellLayoutClasses.brandIcon}>
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t("ui.dev.brand", "Baron 로그인")}
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t("ui.dev.console_title", "Developer Console")}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className={shellLayoutClasses.scopeBadge}>
|
||||
<BadgeCheck size={14} />
|
||||
{t("ui.dev.scope_badge", "Scoped to /dev")}
|
||||
</div>
|
||||
</div>
|
||||
<nav className={shellLayoutClasses.navWrap}>
|
||||
<div className={shellLayoutClasses.navMeta}>
|
||||
<span className="rounded-full border border-border px-3 py-1">
|
||||
{t("ui.dev.env_badge", "Env: dev")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{isDevConsoleAllowed &&
|
||||
navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className={shellLayoutClasses.sidebarFooterNotice}>
|
||||
<p>{t("msg.dev.sidebar.notice", "Developer Console")}</p>
|
||||
<p>
|
||||
{t(
|
||||
"msg.dev.sidebar.notice_detail",
|
||||
"Register and manage client applications.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className={shellLayoutClasses.content}>
|
||||
<header className={shellLayoutClasses.header}>
|
||||
<div className={shellLayoutClasses.headerInner}>
|
||||
<div className={shellLayoutClasses.headerTitleWrap}>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{t("ui.dev.header.plane", "Dev Plane")}
|
||||
</p>
|
||||
<span className="text-lg font-semibold">
|
||||
{t("ui.dev.header.subtitle", "Manage your applications")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={shellLayoutClasses.headerActions}>
|
||||
<LanguageSelector />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className={shellLayoutClasses.actionButton}
|
||||
aria-label={t("ui.common.theme_toggle", "테마 전환")}
|
||||
>
|
||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
{theme === "light"
|
||||
? t("ui.common.theme_light", "Light")
|
||||
: t("ui.common.theme_dark", "Dark")}
|
||||
</button>
|
||||
{isSessionExpiryEnabled ? (
|
||||
<SessionStatusBadge
|
||||
expiresAtSec={auth.user?.expires_at}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative" ref={profileMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsProfileMenuOpen((prev) => !prev)}
|
||||
className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isProfileMenuOpen}
|
||||
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
|
||||
>
|
||||
<div className={shellLayoutClasses.profileInitial}>
|
||||
{profileSummary.initial}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-left md:block">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${isProfileMenuOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{isProfileMenuOpen ? (
|
||||
<div role="menu" className={shellLayoutClasses.profileMenu}>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("ui.dev.profile.menu_title", "Account")}
|
||||
</p>
|
||||
<div className={shellLayoutClasses.profileCard}>
|
||||
<div>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center pt-1">
|
||||
<span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
|
||||
{t(
|
||||
`ui.admin.role.${displayRoleKey}`,
|
||||
displayRoleKey.toUpperCase(),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={shellLayoutClasses.settingsCard}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSessionExpiryEnabled ? (
|
||||
<SessionStatusText
|
||||
expiresAtSec={auth.user?.expires_at}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
t("ui.dev.session.disabled", "세션 만료 비활성화")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isSessionExpiryEnabled}
|
||||
onClick={handleSessionExpiryToggle}
|
||||
className={[
|
||||
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition",
|
||||
isSessionExpiryEnabled ? "bg-primary" : "bg-muted",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"inline-block h-5 w-5 rounded-full bg-white transition",
|
||||
isSessionExpiryEnabled
|
||||
? "translate-x-5"
|
||||
: "translate-x-1",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
|
||||
onClick={() => {
|
||||
navigate("/profile");
|
||||
setIsProfileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<UserIcon size={16} className="text-muted-foreground" />
|
||||
<span>{t("ui.dev.profile.title", "내 정보")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className={shellLayoutClasses.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
47
baron-sso/orgfront/src/components/ui/avatar.tsx
Normal file
47
baron-sso/orgfront/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-semibold text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarFallback, AvatarImage };
|
||||
21
baron-sso/orgfront/src/components/ui/badge.tsx
Normal file
21
baron-sso/orgfront/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type * as React from "react";
|
||||
import {
|
||||
type CommonBadgeVariant,
|
||||
getCommonBadgeClasses,
|
||||
} from "../../../../common/ui/badge";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: CommonBadgeVariant;
|
||||
}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(getCommonBadgeClasses({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge };
|
||||
95
baron-sso/orgfront/src/components/ui/basic.test.tsx
Normal file
95
baron-sso/orgfront/src/components/ui/basic.test.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import type React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
|
||||
import { Badge } from "./badge";
|
||||
import { Input } from "./input";
|
||||
import { Label } from "./label";
|
||||
import { Separator } from "./separator";
|
||||
import { Switch } from "./switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "./table";
|
||||
import { Textarea } from "./textarea";
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
const render = async (element: React.ReactElement) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(element);
|
||||
});
|
||||
return root;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (container) {
|
||||
container.remove();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("orgfront UI wrappers", () => {
|
||||
it("renders form, badge, avatar, switch, separator, and table wrappers", async () => {
|
||||
const root = await render(
|
||||
<div>
|
||||
<Badge className="custom-badge" variant="secondary">
|
||||
Active
|
||||
</Badge>
|
||||
<Avatar className="custom-avatar">
|
||||
<AvatarImage alt="Org user" src="/avatar.png" />
|
||||
<AvatarFallback>OU</AvatarFallback>
|
||||
</Avatar>
|
||||
<Label className="custom-label" htmlFor="name">
|
||||
Name
|
||||
</Label>
|
||||
<Input id="name" className="custom-input" defaultValue="Org User" />
|
||||
<Textarea className="custom-textarea" defaultValue="Memo" />
|
||||
<Switch className="custom-switch" defaultChecked />
|
||||
<Separator className="custom-separator" />
|
||||
<Table className="custom-table">
|
||||
<TableCaption>Members</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Org User</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell>Total</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain("Active");
|
||||
expect(container?.textContent).toContain("OU");
|
||||
expect(container?.querySelector(".custom-input")).not.toBeNull();
|
||||
expect(container?.querySelector(".custom-switch")).not.toBeNull();
|
||||
expect(container?.querySelector(".custom-separator")).not.toBeNull();
|
||||
expect(container?.textContent).toContain("Members");
|
||||
expect(container?.textContent).toContain("Total");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
31
baron-sso/orgfront/src/components/ui/button.tsx
Normal file
31
baron-sso/orgfront/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type CommonButtonSize,
|
||||
type CommonButtonVariant,
|
||||
getCommonButtonClasses,
|
||||
} from "../../../../common/ui/button";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: CommonButtonVariant;
|
||||
size?: CommonButtonSize;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(getCommonButtonClasses({ variant, size }), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button };
|
||||
58
baron-sso/orgfront/src/components/ui/card.tsx
Normal file
58
baron-sso/orgfront/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type * as React from "react";
|
||||
import {
|
||||
commonCardClass,
|
||||
commonCardContentClass,
|
||||
commonCardDescriptionClass,
|
||||
commonCardFooterClass,
|
||||
commonCardHeaderClass,
|
||||
commonCardTitleClass,
|
||||
} from "../../../../common/ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn(commonCardClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn(commonCardContentClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn(commonCardFooterClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
75
baron-sso/orgfront/src/components/ui/copy-button.tsx
Normal file
75
baron-sso/orgfront/src/components/ui/copy-button.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button, type ButtonProps } from "./button";
|
||||
|
||||
interface CopyButtonProps extends ButtonProps {
|
||||
value: string;
|
||||
onCopy?: () => void;
|
||||
}
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
onCopy,
|
||||
className,
|
||||
variant = "secondary",
|
||||
size = "icon",
|
||||
...props
|
||||
}: CopyButtonProps) {
|
||||
const [hasCopied, setHasCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasCopied) {
|
||||
const timer = setTimeout(() => setHasCopied(false), 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [hasCopied]);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} else {
|
||||
// Fallback for non-secure contexts (HTTP) or missing navigator.clipboard
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = value;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-9999px";
|
||||
textArea.style.top = "0";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
const successful = document.execCommand("copy");
|
||||
if (!successful) throw new Error("execCommand copy failed");
|
||||
} catch (err) {
|
||||
console.error("Fallback: Oops, unable to copy", err);
|
||||
throw err;
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
setHasCopied(true);
|
||||
if (onCopy) onCopy();
|
||||
} catch (err) {
|
||||
console.error("Failed to copy text: ", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={size}
|
||||
variant={variant}
|
||||
className={cn("relative z-10", className)}
|
||||
onClick={copyToClipboard}
|
||||
{...props}
|
||||
>
|
||||
<span className="sr-only">Copy</span>
|
||||
{hasCopied ? (
|
||||
<Check className="h-4 w-4 text-emerald-500 transition-all scale-110" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 transition-all" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
22
baron-sso/orgfront/src/components/ui/input.tsx
Normal file
22
baron-sso/orgfront/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
import { commonInputClass } from "../../../../common/ui/input";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(commonInputClass, className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
19
baron-sso/orgfront/src/components/ui/label.tsx
Normal file
19
baron-sso/orgfront/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
HTMLLabelElement,
|
||||
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = "Label";
|
||||
|
||||
export { Label };
|
||||
44
baron-sso/orgfront/src/components/ui/scroll-area.tsx
Normal file
44
baron-sso/orgfront/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" && "h-2.5 border-t border-t-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
16
baron-sso/orgfront/src/components/ui/separator.tsx
Normal file
16
baron-sso/orgfront/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("shrink-0 bg-border", "h-px w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = "Separator";
|
||||
|
||||
export { Separator };
|
||||
26
baron-sso/orgfront/src/components/ui/switch.tsx
Normal file
26
baron-sso/orgfront/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
102
baron-sso/orgfront/src/components/ui/table.tsx
Normal file
102
baron-sso/orgfront/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
commonTableBodyClass,
|
||||
commonTableCaptionClass,
|
||||
commonTableCellClass,
|
||||
commonTableClass,
|
||||
commonTableFooterClass,
|
||||
commonTableHeadClass,
|
||||
commonTableHeaderClass,
|
||||
commonTableRowClass,
|
||||
commonTableWrapperClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={commonTableWrapperClass}>
|
||||
<table ref={ref} className={cn(commonTableClass, className)} {...props} />
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn(commonTableHeaderClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(commonTableFooterClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn(commonTableCaptionClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
};
|
||||
23
baron-sso/orgfront/src/components/ui/textarea.tsx
Normal file
23
baron-sso/orgfront/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
35
baron-sso/orgfront/src/components/ui/toaster.tsx
Normal file
35
baron-sso/orgfront/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useToastState } from "./use-toast";
|
||||
|
||||
export function Toaster() {
|
||||
const toasts = useToastState();
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-[320px]">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border p-4 shadow-lg animate-in slide-in-from-right-full duration-300",
|
||||
t.type === "success" &&
|
||||
"bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200",
|
||||
t.type === "error" &&
|
||||
"bg-rose-50 border-rose-200 text-rose-800 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-200",
|
||||
t.type === "info" &&
|
||||
"bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200",
|
||||
)}
|
||||
>
|
||||
{t.type === "success" && (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0" />
|
||||
)}
|
||||
{t.type === "error" && <AlertCircle className="h-5 w-5 shrink-0" />}
|
||||
{t.type === "info" && <Info className="h-5 w-5 shrink-0" />}
|
||||
<p className="text-sm font-medium leading-none">{t.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
baron-sso/orgfront/src/components/ui/use-toast.ts
Normal file
42
baron-sso/orgfront/src/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
|
||||
type ToastType = "success" | "error" | "info";
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
let subscribers: ((toasts: Toast[]) => void)[] = [];
|
||||
let toasts: Toast[] = [];
|
||||
|
||||
const notify = () => {
|
||||
for (const sub of subscribers) {
|
||||
sub(toasts);
|
||||
}
|
||||
};
|
||||
|
||||
export const toast = (message: string, type: ToastType = "success") => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
toasts = [...toasts, { id, message, type }];
|
||||
notify();
|
||||
|
||||
setTimeout(() => {
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
notify();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
export const useToastState = () => {
|
||||
const [state, setState] = React.useState<Toast[]>(toasts);
|
||||
|
||||
React.useEffect(() => {
|
||||
subscribers.push(setState);
|
||||
return () => {
|
||||
subscribers = subscribers.filter((sub) => sub !== setState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
};
|
||||
435
baron-sso/orgfront/src/features/audit/AuditLogsPage.tsx
Normal file
435
baron-sso/orgfront/src/features/audit/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import type { DevAuditLog } from "../../lib/devApi";
|
||||
import { fetchDevAuditLogs } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
type AuditDetails = {
|
||||
request_id?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
tenant_id?: string;
|
||||
action?: string;
|
||||
target_id?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function parseDetails(details?: string): AuditDetails {
|
||||
if (!details) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(details);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as AuditDetails;
|
||||
}
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return parsed.toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
function toCsv(logs: DevAuditLog[]) {
|
||||
const header = [
|
||||
"timestamp",
|
||||
"user_id",
|
||||
"status",
|
||||
"event_type",
|
||||
"action",
|
||||
"target_id",
|
||||
"tenant_id",
|
||||
"request_id",
|
||||
];
|
||||
const rows = logs.map((logItem) => {
|
||||
const details = parseDetails(logItem.details);
|
||||
return [
|
||||
logItem.timestamp,
|
||||
logItem.user_id || "",
|
||||
logItem.status,
|
||||
logItem.event_type,
|
||||
details.action || "",
|
||||
details.target_id || "",
|
||||
details.tenant_id || "",
|
||||
details.request_id || "",
|
||||
];
|
||||
});
|
||||
return [header, ...rows]
|
||||
.map((line) =>
|
||||
line.map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(","),
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function downloadCsv(content: string, filename: string) {
|
||||
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function AuditLogsPage() {
|
||||
const [searchClientId, setSearchClientId] = React.useState("");
|
||||
const [searchAction, setSearchAction] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["dev-audit-logs", searchClientId, searchAction, statusFilter],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchDevAuditLogs(50, pageParam, {
|
||||
client_id: searchClientId.trim() || undefined,
|
||||
action: searchAction.trim() || undefined,
|
||||
status: statusFilter !== "all" ? statusFilter : undefined,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
|
||||
const logs =
|
||||
query.data?.pages.flatMap((page) =>
|
||||
page.items.filter((item): item is DevAuditLog => Boolean(item)),
|
||||
) ?? [];
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
const csv = toCsv(logs);
|
||||
const stamp = new Date().toISOString().replaceAll(":", "-");
|
||||
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
|
||||
};
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.audit.loading", "Loading audit logs...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.error) {
|
||||
const axiosError = query.error as AxiosError<{ error?: string }>;
|
||||
if (axiosError.response?.status === 403) {
|
||||
return <ForbiddenMessage resourceToken="audit" />;
|
||||
}
|
||||
|
||||
const errMsg =
|
||||
axiosError.response?.data?.error ?? (query.error as Error).message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
{t("msg.dev.audit.load_error", "Error loading logs: {{error}}", {
|
||||
error: errMsg,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t("ui.dev.audit.registry.title", "Audit registry")}
|
||||
</p>
|
||||
<CardTitle className="text-3xl font-black tracking-tight">
|
||||
{t("ui.dev.audit.title", "Audit Logs")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.audit.subtitle",
|
||||
"Shows DevFront activity history within current tenant/app scope.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="muted">
|
||||
{t("msg.dev.audit.loaded_count", "Loaded {{count}} rows", {
|
||||
count: logs.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={handleExportCsv}
|
||||
>
|
||||
<Download size={16} />
|
||||
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
query.refetch();
|
||||
}}
|
||||
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchClientId}
|
||||
onChange={(e) => setSearchClientId(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.client_id",
|
||||
"Filter by Client ID",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={searchAction}
|
||||
onChange={(e) =>
|
||||
setSearchAction(e.target.value.toUpperCase())
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.audit.filter.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[190px]">
|
||||
{t("ui.dev.audit.table.time", "Time")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
{t("ui.dev.audit.table.actor", "Actor")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
{t("ui.dev.audit.table.action", "Action")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[260px]">
|
||||
{t("ui.dev.audit.table.target", "Target")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
{t("ui.dev.audit.table.status", "Status")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("msg.dev.audit.empty", "No audit logs found.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{logs.map((row, index) => {
|
||||
const details = parseDetails(row.details);
|
||||
const actionLabel = details.action || row.event_type;
|
||||
const targetValue = details.target_id || "-";
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const expanded = Boolean(expandedRows[rowKey]);
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDateTime(row.timestamp)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{row.user_id || "-"}</span>
|
||||
{row.user_id ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={() => handleCopy(row.user_id)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{actionLabel}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">{targetValue}</span>
|
||||
{targetValue !== "-" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={() => handleCopy(targetValue)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
row.status === "success" ? "success" : "warning"
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !expanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded ? (
|
||||
<TableRow className="bg-card/20">
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
Request ID: {formatValue(details.request_id)}
|
||||
</div>
|
||||
<div>Method: {formatValue(details.method)}</div>
|
||||
<div>Path: {formatValue(details.path)}</div>
|
||||
<div>
|
||||
Tenant: {formatValue(details.tenant_id)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 break-all">
|
||||
<div>Before: {formatValue(details.before)}</div>
|
||||
<div>After: {formatValue(details.after)}</div>
|
||||
<div>Error: {formatValue(details.error)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{query.hasNextPage ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.fetchNextPage()}
|
||||
disabled={query.isFetchingNextPage}
|
||||
>
|
||||
{query.isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.dev.audit.load_more", "Load more")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuditLogsPage;
|
||||
35
baron-sso/orgfront/src/features/auth/AuthCallbackPage.tsx
Normal file
35
baron-sso/orgfront/src/features/auth/AuthCallbackPage.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { userManager } from "../../lib/auth";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// 팝업으로 열린 경우 signinPopupCallback 처리
|
||||
if (window.opener) {
|
||||
userManager.signinPopupCallback().catch((error) => {
|
||||
console.error("Popup callback failed:", error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
const returnTo =
|
||||
typeof auth.user?.state === "object" &&
|
||||
auth.user?.state !== null &&
|
||||
"returnTo" in auth.user.state &&
|
||||
typeof auth.user.state.returnTo === "string"
|
||||
? auth.user.state.returnTo
|
||||
: "/chart";
|
||||
navigate(returnTo, { replace: true });
|
||||
} else if (auth.error) {
|
||||
console.error("Auth Error:", auth.error);
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
}, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
|
||||
|
||||
return <div>Loading Auth...</div>;
|
||||
}
|
||||
77
baron-sso/orgfront/src/features/auth/AuthGuard.test.tsx
Normal file
77
baron-sso/orgfront/src/features/auth/AuthGuard.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import AuthGuard from "./AuthGuard";
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
activeNavigator: undefined as string | undefined,
|
||||
error: undefined as Error | undefined,
|
||||
removeUser: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<div data-testid="location">
|
||||
{location.pathname}
|
||||
{location.search}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderGuard(initialEntry: string) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route element={<AuthGuard />}>
|
||||
<Route path="/embed/picker" element={<div>picker</div>} />
|
||||
</Route>
|
||||
<Route path="/login" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
function cleanupRendered(container: HTMLDivElement, root: Root) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
|
||||
describe("OrgFront AuthGuard auto login redirects", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authState.isAuthenticated = false;
|
||||
authState.isLoading = false;
|
||||
authState.activeNavigator = undefined;
|
||||
authState.error = undefined;
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("redirects protected picker entry to the auto login URL", () => {
|
||||
const rendered = renderGuard(
|
||||
"/embed/picker?mode=single&select=tenant&tenantId=hanmac-family-id",
|
||||
);
|
||||
|
||||
expect(rendered.container.textContent).toBe(
|
||||
"/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26tenantId%3Dhanmac-family-id",
|
||||
);
|
||||
cleanupRendered(rendered.container, rendered.root);
|
||||
});
|
||||
});
|
||||
72
baron-sso/orgfront/src/features/auth/AuthGuard.tsx
Normal file
72
baron-sso/orgfront/src/features/auth/AuthGuard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
export default function AuthGuard() {
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const shareToken = searchParams.get("token");
|
||||
const isPlaywrightBypass =
|
||||
typeof window !== "undefined" &&
|
||||
(window.location.hostname === "127.0.0.1" ||
|
||||
window.location.hostname === "localhost") &&
|
||||
window.localStorage.getItem("playwright_auth_bypass") === "1";
|
||||
|
||||
// 공유 토큰이 있는 경우 인증 체크를 건너뜁니다 (Public View)
|
||||
if (shareToken || isPlaywrightBypass) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
if (auth.isLoading || auth.activeNavigator) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (auth.error) {
|
||||
return <div>Auth Error: {auth.error.message}</div>;
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
const returnTo = `${location.pathname}${location.search}`;
|
||||
return (
|
||||
<Navigate
|
||||
to={`/login?auto=1&returnTo=${encodeURIComponent(returnTo)}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 조직도 앱은 일반 사용자(user)도 볼 수 있어야 하므로 접근 제한을 해제합니다.
|
||||
const isDenied = false; // normalizedRole === "guest";
|
||||
|
||||
if (isDenied) {
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center bg-background text-foreground p-6">
|
||||
<div className="max-w-lg w-full rounded-xl border border-border bg-card p-6 space-y-4">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t("msg.dev.auth.access_denied_title", "접근 권한이 없습니다.")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.auth.access_denied_description",
|
||||
"조직도를 볼 수 있는 권한이 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||
onClick={() => {
|
||||
auth.removeUser();
|
||||
navigate("/login");
|
||||
}}
|
||||
>
|
||||
{t("ui.common.back_to_login", "로그인으로 돌아가기")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
111
baron-sso/orgfront/src/features/auth/AuthPage.tsx
Normal file
111
baron-sso/orgfront/src/features/auth/AuthPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react";
|
||||
|
||||
const flows = [
|
||||
{
|
||||
title: "Admin login",
|
||||
description:
|
||||
"Enforce short TTL and step-up MFA. Keep admin session separate from app session.",
|
||||
pill: "15m TTL",
|
||||
},
|
||||
{
|
||||
title: "Tenant pick",
|
||||
description:
|
||||
"Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.",
|
||||
pill: "Header-ready",
|
||||
},
|
||||
{
|
||||
title: "Device approval",
|
||||
description:
|
||||
"If app session exists and user opts in, use push/deeplink approval as MFA replacement.",
|
||||
pill: "App session",
|
||||
},
|
||||
];
|
||||
|
||||
function AuthPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Admin auth
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Build the admin-only login flow first, keeping app login separate.
|
||||
Respect the “fallback only when user chooses” rule for SMS/email
|
||||
vs app approval.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]">
|
||||
IDP session placeholder
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
Connect auth layer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{flows.map((flow) => (
|
||||
<div
|
||||
key={flow.title}
|
||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.16em] text-[var(--color-muted)]">
|
||||
<span>{flow.pill}</span>
|
||||
<Fingerprint size={14} />
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold">{flow.title}</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{flow.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-[1fr,0.9fr]">
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<Smartphone size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.18em]">
|
||||
App-based approvals
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
App session as MFA replacement
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
If the admin keeps the mobile app signed in and opts in, use
|
||||
push/deeplink approval instead of OTP. Otherwise fall back to
|
||||
SMS/email based on user choice.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<ArrowRight size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.18em]">
|
||||
TTL discipline
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
Keep admin sessions short
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
|
||||
with step-up MFA when critical actions (rotate secret, export logs)
|
||||
happen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthPage;
|
||||
73
baron-sso/orgfront/src/features/auth/LoginPage.test.tsx
Normal file
73
baron-sso/orgfront/src/features/auth/LoginPage.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import LoginPage from "./LoginPage";
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
activeNavigator: undefined as string | undefined,
|
||||
signinRedirect: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
function renderLoginPage(initialEntry: string) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<LoginPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
function cleanupRendered(container: HTMLDivElement, root: Root) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
|
||||
describe("OrgFront LoginPage auto login", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authState.isAuthenticated = false;
|
||||
authState.isLoading = false;
|
||||
authState.activeNavigator = undefined;
|
||||
});
|
||||
|
||||
it("does not start auto login again when the orgfront session is already authenticated", () => {
|
||||
authState.isAuthenticated = true;
|
||||
|
||||
const rendered = renderLoginPage(
|
||||
"/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle",
|
||||
);
|
||||
|
||||
expect(authState.signinRedirect).not.toHaveBeenCalled();
|
||||
cleanupRendered(rendered.container, rendered.root);
|
||||
});
|
||||
|
||||
it("starts auto login once when auto mode is requested without an authenticated session", () => {
|
||||
const rendered = renderLoginPage(
|
||||
"/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle",
|
||||
);
|
||||
|
||||
expect(authState.signinRedirect).toHaveBeenCalledTimes(1);
|
||||
expect(authState.signinRedirect).toHaveBeenCalledWith({
|
||||
state: {
|
||||
returnTo: "/embed/picker?mode=single",
|
||||
},
|
||||
});
|
||||
cleanupRendered(rendered.container, rendered.root);
|
||||
});
|
||||
});
|
||||
137
baron-sso/orgfront/src/features/auth/LoginPage.tsx
Normal file
137
baron-sso/orgfront/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
|
||||
function LoginPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const autoStartedRef = useRef(false);
|
||||
const returnTo = searchParams.get("returnTo") || "/chart";
|
||||
const shouldAutoLogin = searchParams.get("auto") === "1";
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
navigate(returnTo, { replace: true });
|
||||
}
|
||||
}, [auth.isAuthenticated, navigate, returnTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoLogin) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
auth.isAuthenticated ||
|
||||
autoStartedRef.current ||
|
||||
auth.isLoading ||
|
||||
auth.activeNavigator
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoStartedRef.current = true;
|
||||
void auth.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
auth,
|
||||
auth.activeNavigator,
|
||||
auth.isAuthenticated,
|
||||
auth.isLoading,
|
||||
returnTo,
|
||||
shouldAutoLogin,
|
||||
]);
|
||||
|
||||
const handleSSOLogin = async () => {
|
||||
try {
|
||||
await auth.signinRedirect({
|
||||
state: {
|
||||
returnTo: "/chart",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Redirect login failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
|
||||
<ShieldHalf size={32} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
|
||||
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
|
||||
Developer Control Plane
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl flex items-center gap-2">
|
||||
<LogIn size={20} className="text-primary" />
|
||||
개발자 포털 로그인
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 pb-8 space-y-3">
|
||||
<Button
|
||||
onClick={handleSSOLogin}
|
||||
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
|
||||
disabled={auth.isLoading}
|
||||
>
|
||||
{auth.isLoading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
로그인 진행 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldHalf size={22} />
|
||||
SSO 계정으로 로그인
|
||||
<ExternalLink size={16} className="opacity-50" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
|
||||
개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
|
||||
<br />
|
||||
민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||
</div>
|
||||
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
인증 정보가 없거나 로그인이 되지 않는 경우
|
||||
<br />
|
||||
시스템 관리자에게 문의하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
24
baron-sso/orgfront/src/features/auth/authApi.ts
Normal file
24
baron-sso/orgfront/src/features/auth/authApi.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import apiClient from "../../lib/apiClient";
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
companyCode?: string;
|
||||
tenantId?: string;
|
||||
tenant?: Tenant;
|
||||
}
|
||||
|
||||
export async function fetchMe() {
|
||||
const { data } = await apiClient.get<UserProfile>("/user/me");
|
||||
return data;
|
||||
}
|
||||
603
baron-sso/orgfront/src/features/clients/ClientConsentsPage.tsx
Normal file
603
baron-sso/orgfront/src/features/clients/ClientConsentsPage.tsx
Normal file
@@ -0,0 +1,603 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Filter,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function ClientConsentsPage() {
|
||||
const params = useParams();
|
||||
const clientId = params.id ?? "";
|
||||
const [subjectInput, setSubjectInput] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const [scopeFilter, setScopeFilter] = useState<string[]>([]);
|
||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||
|
||||
const { data: clientData } = useQuery({
|
||||
queryKey: ["client", clientId],
|
||||
queryFn: () => fetchClient(clientId),
|
||||
enabled: clientId.length > 0,
|
||||
});
|
||||
const {
|
||||
data: consentsData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["consents", clientId, subject],
|
||||
queryFn: () => fetchConsents(subject, clientId, "all"),
|
||||
enabled: clientId.length > 0,
|
||||
});
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (payload: { subject: string }) =>
|
||||
revokeConsent(payload.subject, clientId),
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const handleRevoke = (sub: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.dev.clients.consents.revoke_confirm",
|
||||
"정말로 이 사용자의 권한을 철회하시겠습니까? 철회 시 사용자는 다음 접속 시 다시 동의해야 합니다.",
|
||||
),
|
||||
)
|
||||
) {
|
||||
revokeMutation.mutate({ subject: sub });
|
||||
}
|
||||
};
|
||||
|
||||
const rows = consentsData?.items ?? [];
|
||||
const allScopes = Array.from(new Set(rows.flatMap((r) => r.grantedScopes)));
|
||||
const filteredRows = rows.filter((row) => {
|
||||
const matchStatus =
|
||||
statusFilter.length === 0 || statusFilter.includes(row.status);
|
||||
const matchScope =
|
||||
scopeFilter.length === 0 ||
|
||||
scopeFilter.some((s) => row.grantedScopes.includes(s));
|
||||
return matchStatus && matchScope;
|
||||
});
|
||||
|
||||
const handleExportCSV = () => {
|
||||
if (filteredRows.length === 0) return;
|
||||
|
||||
const headers = [
|
||||
t("ui.dev.clients.consents.table.user", "User"),
|
||||
t("ui.dev.clients.consents.table.tenant", "Tenant"),
|
||||
t("ui.dev.clients.table.status", "Status"),
|
||||
t("ui.dev.clients.consents.table.scopes", "Granted Scopes"),
|
||||
t("ui.dev.clients.consents.table.first_granted", "First Granted"),
|
||||
t(
|
||||
"ui.dev.clients.consents.table.last_auth",
|
||||
"Last Authenticated / Revoked",
|
||||
),
|
||||
];
|
||||
|
||||
const csvContent = [
|
||||
headers.join(","),
|
||||
...filteredRows.map((row) => {
|
||||
const lastAuthRevoked =
|
||||
row.status === "revoked" && row.deletedAt
|
||||
? `${t("ui.dev.clients.consents.status_revoked", "Revoked")}: ${new Date(row.deletedAt).toLocaleString()}`
|
||||
: row.authenticatedAt
|
||||
? new Date(row.authenticatedAt).toLocaleString()
|
||||
: "-";
|
||||
|
||||
return [
|
||||
`"${row.subject} (${row.userName || ""})"`,
|
||||
`"${row.tenantName || row.tenantId || ""}"`,
|
||||
`"${row.status}"`,
|
||||
`"${row.grantedScopes.join(", ")}"`,
|
||||
`"${new Date(row.createdAt).toLocaleString()}"`,
|
||||
`"${lastAuthRevoked}"`,
|
||||
].join(",");
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([`\uFEFF${csvContent}`], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("download", `consents_${clientId}_${date}.csv`);
|
||||
link.style.visibility = "hidden";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleStatusFilterChange = (status: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setStatusFilter((prev) => [...prev, status]);
|
||||
} else {
|
||||
setStatusFilter((prev) => prev.filter((s) => s !== status));
|
||||
}
|
||||
};
|
||||
|
||||
const handleScopeFilterChange = (scope: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setScopeFilter((prev) => [...prev, scope]);
|
||||
} else {
|
||||
setScopeFilter((prev) => prev.filter((s) => s !== scope));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAllScopesChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setScopeFilter(allScopes);
|
||||
} else {
|
||||
setScopeFilter([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-4">
|
||||
<div className="flex flex-wrap justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link to="/" className="hover:text-primary">
|
||||
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link to="/clients" className="hover:text-primary">
|
||||
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>{clientData?.client?.name || clientId}</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.consents.breadcrumb.current",
|
||||
"User Consent Grants",
|
||||
)}
|
||||
</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={`/clients/${clientId}`}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<p className="text-3xl font-black leading-tight">
|
||||
{t("ui.dev.clients.consents.title", "User Consent Grants")}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.consents.subtitle",
|
||||
"OIDC Relying Party 사용자 권한을 검토·관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant={
|
||||
clientData?.client?.status === "active" ? "info" : "muted"
|
||||
}
|
||||
>
|
||||
{clientData?.client?.status === "active"
|
||||
? t("ui.common.status.active", "Active")
|
||||
: t("ui.common.status.inactive", "Inactive")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||||
<Link
|
||||
to={`/clients/${clientId}`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||
</Link>
|
||||
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
|
||||
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||||
</span>
|
||||
<Link
|
||||
to={`/clients/${clientId}/settings`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardContent className="space-y-4">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.consents.search_placeholder",
|
||||
"사용자 ID, 이름, 이메일로 검색",
|
||||
)}
|
||||
value={subjectInput}
|
||||
onChange={(e) => setSubjectInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"gap-1 text-muted-foreground",
|
||||
isAdvancedFilterOpen && "text-primary bg-primary/10",
|
||||
)}
|
||||
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{t(
|
||||
"ui.dev.clients.consents.filters.advanced",
|
||||
"Advanced Filters",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={() => setSubject(subjectInput.trim())}
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={handleExportCSV}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
advancedOpen={isAdvancedFilterOpen}
|
||||
advanced={
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={statusFilter.includes("active")}
|
||||
onChange={(e) =>
|
||||
handleStatusFilterChange("active", e.target.checked)
|
||||
}
|
||||
/>
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={statusFilter.includes("revoked")}
|
||||
onChange={(e) =>
|
||||
handleStatusFilterChange("revoked", e.target.checked)
|
||||
}
|
||||
/>
|
||||
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.dev.clients.consents.scope_label", "Scope:")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{allScopes.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer font-bold text-primary hover:opacity-80">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={
|
||||
scopeFilter.length === allScopes.length &&
|
||||
allScopes.length > 0
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleAllScopesChange(e.target.checked)
|
||||
}
|
||||
/>
|
||||
ALL
|
||||
</label>
|
||||
)}
|
||||
{allScopes.map((scope) => (
|
||||
<label
|
||||
key={scope}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={scopeFilter.includes(scope)}
|
||||
onChange={(e) =>
|
||||
handleScopeFilterChange(scope, e.target.checked)
|
||||
}
|
||||
/>
|
||||
{scope}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground p-0 h-auto"
|
||||
onClick={() => {
|
||||
setStatusFilter([]);
|
||||
setScopeFilter([]);
|
||||
}}
|
||||
>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel">
|
||||
{error && (
|
||||
<CardContent className="text-sm text-red-500">
|
||||
{t(
|
||||
"msg.dev.clients.consents.load_error",
|
||||
"Error loading consents: {{error}}",
|
||||
{
|
||||
error: (error as Error).message,
|
||||
},
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
{isLoading && (
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
{t("msg.dev.clients.consents.loading", "Loading consents...")}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.consents.table.user", "User")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.consents.table.tenant", "Tenant")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.consents.table.status", "Status")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.consents.table.scopes", "Granted Scopes")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.dev.clients.consents.table.first_granted",
|
||||
"First Granted",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.dev.clients.consents.table.last_auth",
|
||||
"Last Authenticated / Revoked",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.dev.clients.consents.table.action", "Action")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
{t("msg.dev.clients.consents.empty", "No consents found.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredRows.map((row) => (
|
||||
<TableRow
|
||||
key={`${row.subject}-${row.clientId}`}
|
||||
className={row.status === "revoked" ? "opacity-60" : ""}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
|
||||
{(row.userName || row.subject)
|
||||
.slice(0, 2)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold">
|
||||
{row.userName ||
|
||||
t("ui.dev.clients.consents.subject", "Subject")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.subject}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold">
|
||||
{row.tenantName || t("ui.common.na", "N/A")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.tenantId}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.status === "active" ? (
|
||||
<Badge variant="success">
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="warning">
|
||||
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.grantedScopes.map((scope) => (
|
||||
<Badge
|
||||
key={scope}
|
||||
variant="muted"
|
||||
className="border bg-muted/40 text-foreground"
|
||||
>
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(row.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{row.status === "revoked" && row.deletedAt ? (
|
||||
<span className="text-destructive font-medium">
|
||||
{t("ui.dev.clients.consents.revoked_at", "Revoked: ")}
|
||||
{new Date(row.deletedAt).toLocaleString()}
|
||||
</span>
|
||||
) : row.authenticatedAt ? (
|
||||
new Date(row.authenticatedAt).toLocaleString()
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.status === "active" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRevoke(row.subject)}
|
||||
disabled={revokeMutation.isPending}
|
||||
>
|
||||
{t("ui.dev.clients.consents.revoke", "Revoke")}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent className="flex items-center justify-between border-t border-border bg-muted/10 px-6 py-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
{t(
|
||||
"msg.dev.clients.consents.showing",
|
||||
"Showing {{from}} to {{to}} of {{total}} users",
|
||||
{
|
||||
from: filteredRows.length > 0 ? 1 : 0,
|
||||
to: filteredRows.length,
|
||||
total: rows.length,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" disabled>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" disabled={filteredRows.length === 0}>
|
||||
1
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" disabled>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-2">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.consents.stats.active_grants",
|
||||
"Active Grants",
|
||||
)}
|
||||
</p>
|
||||
<CardTitle className="text-2xl font-black">
|
||||
{rows.filter((r) => r.status === "active").length}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-2">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.consents.stats.total_scopes",
|
||||
"Total Scopes Issued",
|
||||
)}
|
||||
</p>
|
||||
<CardTitle className="text-2xl font-black">
|
||||
{rows.reduce((acc, row) => acc + row.grantedScopes.length, 0)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-2">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.consents.stats.avg_scopes",
|
||||
"Avg. Scopes per User",
|
||||
)}
|
||||
</p>
|
||||
<CardTitle className="text-2xl font-black">
|
||||
{rows.length > 0
|
||||
? (
|
||||
rows.reduce(
|
||||
(acc, row) => acc + row.grantedScopes.length,
|
||||
0,
|
||||
) / rows.length
|
||||
).toFixed(1)
|
||||
: "0.0"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClientConsentsPage;
|
||||
540
baron-sso/orgfront/src/features/clients/ClientDetailsPage.tsx
Normal file
540
baron-sso/orgfront/src/features/clients/ClientDetailsPage.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Link2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { CopyButton } from "../../components/ui/copy-button";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Separator } from "../../components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
fetchClient,
|
||||
rotateClientSecret,
|
||||
updateClient,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function ClientDetailsPage() {
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const clientId = params.id ?? "";
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ["client", clientId],
|
||||
queryFn: () => fetchClient(clientId),
|
||||
enabled: clientId.length > 0,
|
||||
});
|
||||
|
||||
const [redirectUris, setRedirectUris] = useState("");
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const redirectUrisHydratedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!redirectUrisHydratedRef.current &&
|
||||
data?.client?.redirectUris &&
|
||||
redirectUris === ""
|
||||
) {
|
||||
setRedirectUris(data.client.redirectUris.join(", "));
|
||||
redirectUrisHydratedRef.current = true;
|
||||
}
|
||||
}, [data, redirectUris]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const uriList = redirectUris
|
||||
.split(",")
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean);
|
||||
return updateClient(clientId, { redirectUris: uriList });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.redirect_saved",
|
||||
"Redirect URIs가 저장되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast(
|
||||
t("msg.dev.clients.details.save_error", "저장 실패: {{error}}", {
|
||||
error: (err as Error).message,
|
||||
}),
|
||||
"error",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const rotateMutation = useMutation({
|
||||
mutationFn: () => rotateClientSecret(clientId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.secret_rotated",
|
||||
"Client Secret이 재발급되었습니다.",
|
||||
),
|
||||
);
|
||||
setShowSecret(true); // 재발급 후 바로 보여줌
|
||||
},
|
||||
onError: (err) => {
|
||||
toast(
|
||||
t("msg.dev.clients.details.rotate_error", "재발급 실패: {{error}}", {
|
||||
error: (err as Error).message,
|
||||
}),
|
||||
"error",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRotateSecret = () => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.dev.clients.details.rotate_confirm",
|
||||
"경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
rotateMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
if (!clientId) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !data) {
|
||||
const errMsg =
|
||||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(error as Error)?.message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
{t(
|
||||
"msg.dev.clients.details.load_error",
|
||||
"Error loading app: {{error}}",
|
||||
{ error: errMsg || t("msg.common.unknown_error", "unknown error") },
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.clients.details.loading", "Loading app details...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const client = data?.client;
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
const endpointValues = data?.endpoints ?? {
|
||||
discovery: "-",
|
||||
issuer: "-",
|
||||
authorization: "-",
|
||||
token: "-",
|
||||
userinfo: "-",
|
||||
};
|
||||
const endpoints = [
|
||||
{
|
||||
labelKey: "ui.dev.clients.details.endpoint.discovery",
|
||||
labelFallback: "Discovery Endpoint",
|
||||
value: endpointValues.discovery,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.clients.details.endpoint.issuer",
|
||||
labelFallback: "Issuer URL",
|
||||
value: endpointValues.issuer,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.clients.details.endpoint.authorization",
|
||||
labelFallback: "Authorization Endpoint",
|
||||
value: endpointValues.authorization,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.clients.details.endpoint.token",
|
||||
labelFallback: "Token Endpoint",
|
||||
value: endpointValues.token,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.clients.details.endpoint.userinfo",
|
||||
labelFallback: "UserInfo Endpoint",
|
||||
value: endpointValues.userinfo,
|
||||
},
|
||||
];
|
||||
|
||||
// Client Secret from API
|
||||
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
|
||||
const clientSecret = client?.clientSecret || secretPlaceholder;
|
||||
const displaySecret =
|
||||
clientSecret === secretPlaceholder
|
||||
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
|
||||
: clientSecret;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-3">
|
||||
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link to="/" className="hover:text-primary">
|
||||
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link to="/clients" className="hover:text-primary">
|
||||
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>{client?.name || clientId}</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-semibold">
|
||||
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||
</span>
|
||||
</nav>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to="/clients">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-4xl font-black leading-tight tracking-tight">
|
||||
{client?.name || client?.id || clientId}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.details.subtitle",
|
||||
"Manage OIDC credentials and endpoints.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={client?.status === "active" ? "info" : "muted"}
|
||||
className="px-3 py-1 text-xs uppercase"
|
||||
>
|
||||
{client?.status === "active"
|
||||
? t("ui.common.status.active", "Active")
|
||||
: client?.status === "inactive"
|
||||
? t("ui.common.status.inactive", "Inactive")
|
||||
: t("msg.common.loading", "Loading...")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-6 border-b border-border">
|
||||
<Link
|
||||
to={`/clients/${clientId}`}
|
||||
className="border-b-2 border-primary pb-3 text-sm font-bold text-primary"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/consents`}
|
||||
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/settings`}
|
||||
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.details.credentials.title",
|
||||
"Client Credentials",
|
||||
)}
|
||||
</h2>
|
||||
<Card className="glass-panel">
|
||||
<CardContent className="flex flex-col gap-4 p-6">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.details.credentials.client_id",
|
||||
"Client ID",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-mono text-lg truncate">
|
||||
{client?.id || clientId}
|
||||
</p>
|
||||
<CopyButton
|
||||
value={client?.id || clientId}
|
||||
onCopy={() =>
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.copy_client_id",
|
||||
"Client ID가 복사되었습니다.",
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.details.credentials.client_secret",
|
||||
"Client Secret",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"font-mono text-lg",
|
||||
!showSecret && "tracking-widest",
|
||||
)}
|
||||
>
|
||||
{showSecret ? displaySecret : "••••••••••••••••"}
|
||||
</p>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => setShowSecret(!showSecret)}
|
||||
aria-label={
|
||||
showSecret
|
||||
? t(
|
||||
"ui.dev.clients.details.secret.hide",
|
||||
"비밀키 숨기기",
|
||||
)
|
||||
: t(
|
||||
"ui.dev.clients.details.secret.show",
|
||||
"비밀키 보기",
|
||||
)
|
||||
}
|
||||
>
|
||||
{showSecret ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleRotateSecret}
|
||||
disabled={rotateMutation.isPending}
|
||||
title={t(
|
||||
"ui.dev.clients.details.secret.rotate",
|
||||
"비밀키 재발급 (Rotate)",
|
||||
)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
rotateMutation.isPending && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<CopyButton
|
||||
value={clientSecret}
|
||||
disabled={
|
||||
!showSecret && clientSecret === secretPlaceholder
|
||||
}
|
||||
onCopy={() =>
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.copy_client_secret",
|
||||
"Client Secret이 복사되었습니다.",
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold">
|
||||
{t("ui.dev.clients.details.endpoints.title", "OIDC 엔드포인트")}
|
||||
</h2>
|
||||
<Badge variant="muted" className="gap-1">
|
||||
<Link2 className="h-3 w-3" />
|
||||
{t("ui.dev.clients.details.endpoints.read_only", "읽기 전용")}
|
||||
</Badge>
|
||||
</div>
|
||||
<Card className="glass-panel">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{endpoints.map((endpoint) => (
|
||||
<TableRow
|
||||
key={endpoint.labelKey}
|
||||
className="border-border/70"
|
||||
>
|
||||
<TableCell className="w-1/3">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{t(endpoint.labelKey, endpoint.labelFallback)}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="flex items-center justify-between gap-3">
|
||||
<span className="break-all font-mono text-sm">
|
||||
{endpoint.value}
|
||||
</span>
|
||||
<CopyButton
|
||||
value={endpoint.value}
|
||||
className="h-8 w-8 shrink-0"
|
||||
onCopy={() =>
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.copy_endpoint",
|
||||
"{{label}}가 복사되었습니다.",
|
||||
{
|
||||
label: t(
|
||||
endpoint.labelKey,
|
||||
endpoint.labelFallback,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">
|
||||
{t("ui.dev.clients.details.redirect.title", "리디렉션 URI 설정")}
|
||||
</h2>
|
||||
<Card className="glass-panel border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
{t("ui.dev.clients.details.redirect.label", "Redirect URIs")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.details.redirect.description",
|
||||
"인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="redirect-uris"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t(
|
||||
"ui.dev.clients.details.redirect.callback_label",
|
||||
"인증 콜백 URL",
|
||||
)}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="redirect-uris"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.details.redirect.placeholder",
|
||||
"https://your-app.com/callback, http://localhost:3000/auth/callback",
|
||||
)}
|
||||
rows={5}
|
||||
value={redirectUris}
|
||||
onChange={(e) => {
|
||||
redirectUrisHydratedRef.current = true;
|
||||
setRedirectUris(e.target.value);
|
||||
}}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{mutation.isPending
|
||||
? t("msg.common.saving", "저장 중...")
|
||||
: t(
|
||||
"ui.dev.clients.details.redirect.save",
|
||||
"Redirect URIs 저장",
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-6 opacity-80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
|
||||
<Shield className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold">
|
||||
{t("ui.dev.clients.details.security.title", "보안 메모")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.details.security.note",
|
||||
"엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 로그와 연계하세요.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.details.security.footer",
|
||||
"비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClientDetailsPage;
|
||||
1643
baron-sso/orgfront/src/features/clients/ClientGeneralPage.tsx
Normal file
1643
baron-sso/orgfront/src/features/clients/ClientGeneralPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
531
baron-sso/orgfront/src/features/clients/ClientsPage.tsx
Normal file
531
baron-sso/orgfront/src/features/clients/ClientsPage.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
BookOpenText,
|
||||
Filter,
|
||||
Plus,
|
||||
Search,
|
||||
ServerCog,
|
||||
ShieldHalf,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "../../components/ui/avatar";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Separator } from "../../components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { fetchClients, fetchDevStats } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function ClientsPage() {
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: isLoadingClients,
|
||||
error: clientError,
|
||||
} = useQuery({
|
||||
queryKey: ["clients"],
|
||||
queryFn: fetchClients,
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
|
||||
const { data: statsData, isLoading: isLoadingStats } = useQuery({
|
||||
queryKey: ["dev-stats"],
|
||||
queryFn: fetchDevStats,
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||
|
||||
const clients = data?.items || [];
|
||||
|
||||
const filteredClients = clients.filter((client) => {
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
client.id.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesType = typeFilter === "all" || client.type === typeFilter;
|
||||
const matchesStatus =
|
||||
statusFilter === "all" || client.status === statusFilter;
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
});
|
||||
|
||||
const totalClients = statsData?.total_clients ?? clients.length;
|
||||
const activeSessions = statsData?.active_sessions ?? 0;
|
||||
const authFailures = statsData?.auth_failures_24h ?? 0;
|
||||
|
||||
type StatTone = "up" | "down" | "stable";
|
||||
type StatItem = {
|
||||
labelKey: string;
|
||||
labelFallback: string;
|
||||
value: string;
|
||||
deltaKey: string;
|
||||
deltaFallback: string;
|
||||
tone: StatTone;
|
||||
};
|
||||
|
||||
const stats: StatItem[] = [
|
||||
{
|
||||
labelKey: "ui.dev.clients.stats.total",
|
||||
labelFallback: "Total Applications",
|
||||
value: totalClients.toString(),
|
||||
deltaKey: "ui.dev.clients.stats.realtime",
|
||||
deltaFallback: "Realtime",
|
||||
tone: "up" as const,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.clients.stats.active_sessions",
|
||||
labelFallback: "Active Sessions",
|
||||
value: activeSessions.toString(),
|
||||
deltaKey: "ui.dev.clients.stats.realtime",
|
||||
deltaFallback: "Realtime",
|
||||
tone: "up" as const,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.clients.stats.auth_failures",
|
||||
labelFallback: "Auth Failures (24h)",
|
||||
value: authFailures.toString(),
|
||||
deltaKey:
|
||||
authFailures > 0
|
||||
? "ui.dev.clients.stats.alert"
|
||||
: "ui.dev.clients.stats.stable",
|
||||
deltaFallback: authFailures > 0 ? "Check Logs" : "Stable",
|
||||
tone: authFailures > 0 ? ("down" as const) : ("stable" as const),
|
||||
},
|
||||
];
|
||||
|
||||
const isLoading = isLoadingClients || isLoadingStats;
|
||||
|
||||
if (auth.isLoading || !hasAccessToken || isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.clients.loading", "Loading clients...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (clientError) {
|
||||
const axiosError = clientError as AxiosError<{ error?: string }>;
|
||||
if (axiosError.response?.status === 403) {
|
||||
return <ForbiddenMessage resourceToken="clients" />;
|
||||
}
|
||||
const errMsg =
|
||||
axiosError.response?.data?.error ?? (clientError as Error).message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
|
||||
error: errMsg,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t("ui.dev.clients.registry.title", "RP registry")}
|
||||
</p>
|
||||
<CardTitle className="text-3xl font-black tracking-tight">
|
||||
{t("ui.dev.clients.registry.subtitle", "연동 앱")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.registry.description",
|
||||
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Button
|
||||
size="sm"
|
||||
className="shadow-lg shadow-primary/30"
|
||||
onClick={() => navigate("/clients/new")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SearchFilterBar
|
||||
className="mt-4"
|
||||
primary={
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.search_placeholder",
|
||||
"클라이언트 이름/ID로 검색...",
|
||||
)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"gap-1 text-muted-foreground",
|
||||
isAdvancedFilterOpen && "text-primary bg-primary/10",
|
||||
)}
|
||||
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{t(
|
||||
"ui.dev.clients.consents.filters.advanced",
|
||||
"Advanced Filters",
|
||||
)}
|
||||
</Button>
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Badge variant="muted">
|
||||
{t(
|
||||
"ui.dev.clients.badge.tenant_selected",
|
||||
"테넌트: 선택됨",
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="success">
|
||||
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
advancedOpen={isAdvancedFilterOpen}
|
||||
advanced={
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.filter.type_label", "Type:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.filter.type_all", "모든 유형")}
|
||||
</option>
|
||||
<option value="private">
|
||||
{t("ui.dev.clients.type.private", "Server side App")}
|
||||
</option>
|
||||
<option value="pkce">
|
||||
{t("ui.dev.clients.type.pkce", "PKCE")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.filter.status_all", "모든 상태")}
|
||||
</option>
|
||||
<option value="active">
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</option>
|
||||
<option value="inactive">
|
||||
{t("ui.common.status.inactive", "Inactive")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
onClick={() => {
|
||||
setTypeFilter("all");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{stats.map((item) => (
|
||||
<Card key={item.labelKey} className="border border-border/60">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>
|
||||
{t(item.labelKey, item.labelFallback)}
|
||||
</CardDescription>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{item.value}</span>
|
||||
<Badge
|
||||
variant={
|
||||
item.tone === "up"
|
||||
? "success"
|
||||
: item.tone === "down"
|
||||
? "warning"
|
||||
: "muted"
|
||||
}
|
||||
className={cn(
|
||||
"px-2",
|
||||
item.tone === "stable" && "bg-muted/40 text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(item.deltaKey, item.deltaFallback)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
{t("ui.dev.clients.list.title", "클라이언트 목록")}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<Button size="sm" onClick={() => navigate("/clients/new")}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.table.application", "애플리케이션")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.table.client_id", "Client ID")}
|
||||
</TableHead>
|
||||
<TableHead>{t("ui.dev.clients.table.type", "유형")}</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.table.status", "상태")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.table.created_at", "생성일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.dev.clients.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredClients.map((client) => (
|
||||
<TableRow key={client.id} className="bg-card/40">
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/clients/${client.id}`}
|
||||
className="flex items-center gap-3 transition-colors hover:text-primary"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{client.type === "private" ? (
|
||||
<ServerCog className="h-4 w-4" />
|
||||
) : (
|
||||
<ShieldHalf className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
{client.name ||
|
||||
t("ui.dev.clients.untitled", "Untitled")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("ui.dev.clients.tenant_scoped", "Tenant-scoped")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
|
||||
{client.id}
|
||||
</code>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={client.type === "private" ? "success" : "muted"}
|
||||
>
|
||||
{client.type === "private"
|
||||
? t("ui.dev.clients.type.private", "Server side App")
|
||||
: client.metadata?.headless_login_enabled
|
||||
? t(
|
||||
"ui.dev.clients.type.pkce_headless",
|
||||
"PKCE (Headless Login)",
|
||||
)
|
||||
: t("ui.dev.clients.type.pkce", "PKCE")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={client.status === "active" ? "info" : "muted"}
|
||||
className="px-3 py-1 text-xs uppercase"
|
||||
>
|
||||
{client.status === "active"
|
||||
? t("ui.common.status.active", "Active")
|
||||
: t("ui.common.status.inactive", "Inactive")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{client.createdAt
|
||||
? new Date(client.createdAt).toLocaleDateString()
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/clients/${client.id}`}>
|
||||
{t("ui.common.view", "View")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4 flex items-center justify-between rounded-xl border border-border/60 bg-secondary/60 px-4 py-3 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{t(
|
||||
"msg.dev.clients.showing",
|
||||
"Showing {{shown}} of {{total}} clients",
|
||||
{ shown: filteredClients.length, total: totalClients },
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
{t("ui.common.previous", "Previous")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
{t("ui.common.next", "Next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.help.title",
|
||||
"Need help with OIDC configuration?",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.help.subtitle",
|
||||
"Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
|
||||
<BookOpenText className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
{t("ui.dev.clients.help.docs_title", "Docs & Examples")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.help.docs_body",
|
||||
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary">
|
||||
{t("ui.dev.clients.help.view_guides", "View guides")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
{t("ui.dev.clients.owner.title", "Owner")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("ui.dev.clients.owner.subtitle", "Tenant admin on-call")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src="https://gitea.hmac.kr/avatars/11ed71f61227be4a9ab6c61885371d92304a4c36a5f71036890625c55daa8c41?size=512"
|
||||
alt={t("ui.dev.clients.owner.avatar_alt", "ops user")}
|
||||
/>
|
||||
<AvatarFallback>AR</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
{t("ui.dev.clients.owner.name", "AI Admin Bot")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("ui.dev.clients.owner.email", "admin@brsw.kr")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="mx-4 hidden h-10 w-px md:block" />
|
||||
<div className="hidden flex-col items-end text-sm text-muted-foreground md:flex">
|
||||
<span>
|
||||
{t("ui.dev.clients.owner.role", "Role: Tenant Admin")}
|
||||
</span>
|
||||
<span>{t("ui.dev.clients.owner.scope", "Scope: TENANT-12")}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClientsPage;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
307
baron-sso/orgfront/src/features/coverage/pageSmoke.test.tsx
Normal file
307
baron-sso/orgfront/src/features/coverage/pageSmoke.test.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||
import AuditLogsPage from "../audit/AuditLogsPage";
|
||||
import AuthPage from "../auth/AuthPage";
|
||||
import ClientConsentsPage from "../clients/ClientConsentsPage";
|
||||
import ClientDetailsPage from "../clients/ClientDetailsPage";
|
||||
import ClientGeneralPage from "../clients/ClientGeneralPage";
|
||||
import ClientsPage from "../clients/ClientsPage";
|
||||
import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
|
||||
import DashboardPage from "../dashboard/DashboardPage";
|
||||
import ProfilePage from "../profile/ProfilePage";
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
profile: {
|
||||
sub: "user-1",
|
||||
role: "super_admin",
|
||||
tenant_id: "tenant-1",
|
||||
},
|
||||
},
|
||||
signinRedirect: vi.fn(),
|
||||
removeUser: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||
let text = fallback ?? key;
|
||||
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}));
|
||||
|
||||
const clientSummary = {
|
||||
id: "client-a",
|
||||
name: "Console App",
|
||||
type: "private" as const,
|
||||
status: "active" as const,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
redirectUris: ["https://app.example/callback"],
|
||||
scopes: ["openid", "profile"],
|
||||
tokenEndpointAuthMethod: "client_secret_basic",
|
||||
metadata: {
|
||||
headless_login_enabled: true,
|
||||
headless_login_jwks_uri: "https://app.example/jwks.json",
|
||||
},
|
||||
};
|
||||
|
||||
const clientDetail = {
|
||||
client: {
|
||||
...clientSummary,
|
||||
clientSecret: "secret-value",
|
||||
jwksUri: "https://app.example/jwks.json",
|
||||
grantTypes: ["authorization_code"],
|
||||
responseTypes: ["code"],
|
||||
},
|
||||
endpoints: {
|
||||
discovery: "https://sso.example/.well-known/openid-configuration",
|
||||
issuer: "https://sso.example",
|
||||
authorization: "https://sso.example/oauth2/auth",
|
||||
token: "https://sso.example/oauth2/token",
|
||||
userinfo: "https://sso.example/userinfo",
|
||||
},
|
||||
headlessJwksCache: {
|
||||
clientId: "client-a",
|
||||
jwksUri: "https://app.example/jwks.json",
|
||||
cachedAt: "2026-05-01T00:00:00Z",
|
||||
expiresAt: "2026-05-02T00:00:00Z",
|
||||
lastRefreshStatus: "success" as const,
|
||||
cachedKids: ["kid-1"],
|
||||
parsedKeys: [{ kid: "kid-1", kty: "RSA", use: "sig", alg: "RS256" }],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../lib/devApi", () => ({
|
||||
fetchClients: vi.fn(async () => ({
|
||||
items: [clientSummary],
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
})),
|
||||
fetchDevStats: vi.fn(async () => ({
|
||||
total_clients: 1,
|
||||
active_sessions: 12,
|
||||
auth_failures_24h: 2,
|
||||
})),
|
||||
fetchClient: vi.fn(async () => clientDetail),
|
||||
updateClientStatus: vi.fn(async () => clientDetail),
|
||||
createClient: vi.fn(async () => clientDetail),
|
||||
updateClient: vi.fn(async () => clientDetail),
|
||||
rotateClientSecret: vi.fn(async () => clientDetail),
|
||||
refreshHeadlessJwksCache: vi.fn(async () => clientDetail),
|
||||
revokeHeadlessJwksCache: vi.fn(async () => undefined),
|
||||
deleteClient: vi.fn(async () => undefined),
|
||||
fetchConsents: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
subject: "user-1",
|
||||
userName: "Consent User",
|
||||
clientId: "client-a",
|
||||
clientName: "Console App",
|
||||
grantedScopes: ["openid", "profile"],
|
||||
authenticatedAt: "2026-05-01T02:00:00Z",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
status: "active",
|
||||
tenantId: "tenant-1",
|
||||
tenantName: "Hanmac",
|
||||
},
|
||||
],
|
||||
})),
|
||||
revokeConsent: vi.fn(async () => undefined),
|
||||
listIdpConfigsForClient: vi.fn(async () => [
|
||||
{
|
||||
id: "idp-1",
|
||||
client_id: "client-a",
|
||||
provider_type: "oidc",
|
||||
display_name: "Workspace OIDC",
|
||||
status: "active",
|
||||
issuer_url: "https://accounts.example",
|
||||
oidc_client_id: "oidc-client",
|
||||
scopes: "openid email profile",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
createIdpConfigForClient: vi.fn(async (payload) => ({
|
||||
id: "idp-1",
|
||||
...payload,
|
||||
status: "active",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
})),
|
||||
updateIdpConfig: vi.fn(async (_clientId, idpId, payload) => ({
|
||||
id: idpId,
|
||||
client_id: "client-a",
|
||||
provider_type: "oidc",
|
||||
display_name: "Provider",
|
||||
status: "active",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
...payload,
|
||||
})),
|
||||
deleteIdpConfig: vi.fn(async () => undefined),
|
||||
fetchDevAuditLogs: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
event_id: "event-1",
|
||||
timestamp: "2026-05-01T00:00:00Z",
|
||||
user_id: "user-1",
|
||||
event_type: "client.update",
|
||||
status: "success",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "vitest",
|
||||
details: JSON.stringify({
|
||||
action: "client.update",
|
||||
target_id: "client-a",
|
||||
tenant_id: "tenant-1",
|
||||
request_id: "req-1",
|
||||
}),
|
||||
},
|
||||
],
|
||||
limit: 50,
|
||||
})),
|
||||
fetchMyTenants: vi.fn(async () => [
|
||||
{ id: "tenant-1", name: "Hanmac", slug: "hanmac" },
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("../auth/authApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "user-1",
|
||||
name: "Org User",
|
||||
email: "org@example.com",
|
||||
phone: "010-0000-0000",
|
||||
role: "super_admin",
|
||||
department: "Platform",
|
||||
tenantId: "tenant-1",
|
||||
tenant: { id: "tenant-1", name: "Hanmac", slug: "hanmac" },
|
||||
})),
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function renderWithProviders(
|
||||
element: React.ReactElement,
|
||||
initialEntry = "/",
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialEntry]}>{element}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("orgfront page smoke coverage", () => {
|
||||
it("renders the dashboard content", async () => {
|
||||
const container = await renderWithProviders(<DashboardPage />);
|
||||
|
||||
expect(container.textContent).toContain("RP 등록 현황");
|
||||
expect(container.textContent).toContain("Stack readiness");
|
||||
});
|
||||
|
||||
it("renders static auth guidance and forbidden messages", async () => {
|
||||
const auth = await renderWithProviders(<AuthPage />);
|
||||
expect(auth.textContent).toContain("Admin auth guardrails");
|
||||
expect(auth.textContent).toContain("TTL discipline");
|
||||
|
||||
const forbidden = await renderWithProviders(
|
||||
<ForbiddenMessage resourceToken="clients" />,
|
||||
);
|
||||
expect(forbidden.textContent).toContain("연동 앱 접근 권한 없음");
|
||||
});
|
||||
|
||||
it("renders client list, detail, edit, consent, and federation pages", async () => {
|
||||
const clients = await renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/clients" element={<ClientsPage />} />
|
||||
</Routes>,
|
||||
"/clients",
|
||||
);
|
||||
expect(clients.textContent).toContain("Console App");
|
||||
|
||||
const details = await renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/clients/:id" element={<ClientDetailsPage />} />
|
||||
</Routes>,
|
||||
"/clients/client-a",
|
||||
);
|
||||
expect(details.textContent).toContain("Client Secret");
|
||||
expect(details.textContent).toContain("https://sso.example/oauth2/token");
|
||||
|
||||
const general = await renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/clients/:id/edit" element={<ClientGeneralPage />} />
|
||||
</Routes>,
|
||||
"/clients/client-a/edit",
|
||||
);
|
||||
expect(general.textContent).toContain("Console App");
|
||||
|
||||
const consents = await renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/clients/:id/consents" element={<ClientConsentsPage />} />
|
||||
</Routes>,
|
||||
"/clients/client-a/consents",
|
||||
);
|
||||
expect(consents.textContent).toContain("Consent User");
|
||||
|
||||
const federation = await renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/clients/:id/federation"
|
||||
element={<ClientFederationPage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/clients/client-a/federation",
|
||||
);
|
||||
expect(federation.textContent).toContain("Workspace OIDC");
|
||||
});
|
||||
|
||||
it("renders audit logs and profile pages", async () => {
|
||||
const auditLogs = await renderWithProviders(<AuditLogsPage />);
|
||||
expect(auditLogs.textContent).toContain("client.update");
|
||||
expect(auditLogs.textContent).toContain("Loaded 1 rows");
|
||||
|
||||
const profile = await renderWithProviders(<ProfilePage />);
|
||||
expect(profile.textContent).toContain("Org User");
|
||||
expect(profile.textContent).toContain("Hanmac");
|
||||
});
|
||||
});
|
||||
294
baron-sso/orgfront/src/features/dashboard/DashboardPage.tsx
Normal file
294
baron-sso/orgfront/src/features/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const guardHighlights = [
|
||||
{
|
||||
titleKey: "ui.dev.dashboard.guard.policy.title",
|
||||
titleFallback: "RP 정책 통제",
|
||||
bodyKey: "msg.dev.dashboard.guard.policy.body",
|
||||
bodyFallback:
|
||||
"Relying Party 상태를 활성/비활성으로 관리하고 정책 변경을 기록합니다.",
|
||||
metricKey: "ui.dev.dashboard.guard.policy.metric",
|
||||
metricFallback: "Policy",
|
||||
},
|
||||
{
|
||||
titleKey: "ui.dev.dashboard.guard.consent.title",
|
||||
titleFallback: "Consent 흐름",
|
||||
bodyKey: "msg.dev.dashboard.guard.consent.body",
|
||||
bodyFallback:
|
||||
"사용자 Consent를 조회하고 필요 시 회수해 리스크를 제어합니다.",
|
||||
metricKey: "ui.dev.dashboard.guard.consent.metric",
|
||||
metricFallback: "Consent",
|
||||
},
|
||||
{
|
||||
titleKey: "ui.dev.dashboard.guard.hydra.title",
|
||||
titleFallback: "Hydra Admin",
|
||||
bodyKey: "msg.dev.dashboard.guard.hydra.body",
|
||||
bodyFallback: "Hydra Admin API를 통해 RP 등록 현황을 동기화합니다.",
|
||||
metricKey: "ui.dev.dashboard.guard.hydra.metric",
|
||||
metricFallback: "Hydra",
|
||||
},
|
||||
];
|
||||
|
||||
const stackReadiness = [
|
||||
{
|
||||
key: "msg.dev.dashboard.stack.react",
|
||||
fallback: "React 19 + Vite 7, strict TS, Router v6 data router.",
|
||||
},
|
||||
{
|
||||
key: "msg.dev.dashboard.stack.query",
|
||||
fallback: "TanStack Query 5로 RP/Consent 데이터를 캐시합니다.",
|
||||
},
|
||||
{
|
||||
key: "msg.dev.dashboard.stack.axios",
|
||||
fallback: "Axios 클라이언트에서 Bearer + 테넌트 헤더를 주입합니다.",
|
||||
},
|
||||
{
|
||||
key: "msg.dev.dashboard.stack.tailwind",
|
||||
fallback: "Tailwind + shadcn/ui로 devfront 톤을 맞춥니다.",
|
||||
},
|
||||
{
|
||||
key: "msg.dev.dashboard.stack.proxy",
|
||||
fallback: "Hydra Admin API 연동을 위한 프록시 엔드포인트 준비.",
|
||||
},
|
||||
];
|
||||
|
||||
const nextSteps = [
|
||||
{
|
||||
key: "msg.dev.dashboard.next.rp_workflow",
|
||||
fallback: "RP 등록/수정/삭제 워크플로우 추가",
|
||||
},
|
||||
{
|
||||
key: "msg.dev.dashboard.next.consent_filters",
|
||||
fallback: "Consent 검색 필터 고도화 및 CSV 내보내기",
|
||||
},
|
||||
{
|
||||
key: "msg.dev.dashboard.next.audit_guard",
|
||||
fallback: "권한 가드 및 감사 로그 연동",
|
||||
},
|
||||
];
|
||||
|
||||
function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<section className="relative overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-7 shadow-[var(--shadow-card)]">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_24%_20%,rgba(54,211,153,0.14),transparent_32%)]" />
|
||||
<div className="relative flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
<Sparkles size={14} />
|
||||
{t("ui.dev.dashboard.ready_badge", "devfront ready")}
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold leading-tight">
|
||||
{t(
|
||||
"msg.dev.dashboard.hero.title_prefix",
|
||||
"RP 등록 현황과 Consent 상태를",
|
||||
)}
|
||||
<span className="text-[var(--color-accent)]">
|
||||
{t("msg.dev.dashboard.hero.title_emphasis", " 하나의 화면")}
|
||||
</span>
|
||||
{t("msg.dev.dashboard.hero.title_suffix", "에서 관리합니다.")}
|
||||
</h2>
|
||||
<p className="text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.dev.dashboard.hero.body",
|
||||
"Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다.",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<span className="rounded-full bg-[rgba(54,211,153,0.16)] px-3 py-2 text-[var(--color-accent)]">
|
||||
{t("ui.dev.dashboard.badge.rp_synced", "RP registry synced")}
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
|
||||
{t(
|
||||
"ui.dev.dashboard.badge.consent_guard",
|
||||
"Consent guard ready",
|
||||
)}
|
||||
</span>
|
||||
<span className="rounded-full bg-[rgba(249,168,38,0.16)] px-3 py-2 font-semibold text-[var(--color-accent-strong)]">
|
||||
{t(
|
||||
"ui.dev.dashboard.badge.policy_toggle",
|
||||
"Policy toggle enabled",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||
<ShieldCheck size={16} />
|
||||
{t(
|
||||
"msg.dev.dashboard.notice.dev_scope",
|
||||
"RP 정책은 dev scope에서만 적용",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||
<KeyRound size={16} />
|
||||
{t(
|
||||
"msg.dev.dashboard.notice.consent_audit",
|
||||
"Consent 회수는 감사 로그와 연계",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||
<Database size={16} />
|
||||
{t(
|
||||
"msg.dev.dashboard.notice.hydra_health",
|
||||
"Hydra Admin 상태 체크 준비",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{guardHighlights.map((item) => (
|
||||
<div
|
||||
key={item.titleKey}
|
||||
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5 transition hover:-translate-y-1 hover:shadow-[0_16px_48px_rgba(7,15,26,0.4)]"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_25%_25%,rgba(54,211,153,0.12),transparent_45%)]" />
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
{t(item.metricKey, item.metricFallback)}
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
|
||||
{t("ui.common.status.active", "active")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative mt-3 space-y-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t(item.titleKey, item.titleFallback)}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(item.bodyKey, item.bodyFallback)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-[1.2fr,0.8fr]">
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
{t("ui.dev.dashboard.stack.title", "Stack readiness")}
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold">
|
||||
{t("ui.dev.dashboard.stack.subtitle", "Devfront baseline")}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
{t("ui.dev.dashboard.stack.notes", "Setup notes")}
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{stackReadiness.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||
>
|
||||
<CheckCircle2
|
||||
size={16}
|
||||
className="text-[var(--color-accent)]"
|
||||
/>
|
||||
<p className="text-sm">{t(item.key, item.fallback)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
{t("ui.dev.dashboard.next.title", "Next actions")}
|
||||
</p>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
{t("ui.dev.dashboard.next.subtitle", "Ship the RP controls")}
|
||||
</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{nextSteps.map((item, idx) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||
>
|
||||
<div className="grid h-8 w-8 place-items-center rounded-full bg-[rgba(249,168,38,0.12)] text-sm font-semibold text-[var(--color-accent-strong)]">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text)]">
|
||||
{t(item.key, item.fallback)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
{t("ui.dev.dashboard.ops.title", "Ops board")}
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold">
|
||||
{t("ui.dev.dashboard.ops.subtitle", "현재 관측")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||
{t("ui.dev.dashboard.ops.tag.consent", "Consent grants")}
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||
{t("ui.dev.dashboard.ops.tag.rp_status", "RP status")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<BarChart3 size={16} />
|
||||
{t("ui.dev.dashboard.ops.card.rp_requests", "RP 요청 추이")}
|
||||
</div>
|
||||
<p className="mt-3 text-2xl font-semibold">
|
||||
{t("ui.common.status.pending", "준비 중")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<Activity size={16} />
|
||||
{t(
|
||||
"ui.dev.dashboard.ops.card.consent_revoked",
|
||||
"Consent 회수 건수",
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-2xl font-semibold">
|
||||
{t("ui.common.status.pending", "준비 중")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<Database size={16} />
|
||||
{t("ui.dev.dashboard.ops.card.hydra_status", "Hydra 상태")}
|
||||
</div>
|
||||
<p className="mt-3 text-2xl font-semibold">
|
||||
{t("ui.common.status.ok", "정상")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -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];
|
||||
}
|
||||
189
baron-sso/orgfront/src/features/orgchart/pickerTree.test.ts
Normal file
189
baron-sso/orgfront/src/features/orgchart/pickerTree.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
||||
import { buildOrgPickerTree } from "./pickerTree";
|
||||
|
||||
function tenant(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
slug: string,
|
||||
parentId?: string,
|
||||
): TenantSummary {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
slug,
|
||||
description: "",
|
||||
status: "active",
|
||||
parentId,
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
updatedAt: "2026-05-11T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildOrgPickerTree", () => {
|
||||
it("uses the hanmac-family company-group as the default picker root", () => {
|
||||
const tenants = [
|
||||
tenant("wrong-group", "COMPANY_GROUP", "Wrong Group", "wrong-group"),
|
||||
tenant(
|
||||
"wrong-company",
|
||||
"COMPANY",
|
||||
"Wrong Company",
|
||||
"wrong-company",
|
||||
"wrong-group",
|
||||
),
|
||||
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
|
||||
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
||||
];
|
||||
|
||||
const tree = buildOrgPickerTree({
|
||||
tenants,
|
||||
users: [] satisfies UserSummary[],
|
||||
});
|
||||
|
||||
expect(tree.companyGroupId).toBe("hanmac-family-id");
|
||||
expect(tree.roots).toHaveLength(1);
|
||||
expect(tree.roots[0]?.id).toBe("hanmac-family-id");
|
||||
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
|
||||
"saman-id",
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders hanmac-family children by the shared organization policy", () => {
|
||||
const tenants = [
|
||||
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
|
||||
tenant(
|
||||
"baron-group-id",
|
||||
"COMPANY_GROUP",
|
||||
"바론그룹",
|
||||
"baron-group",
|
||||
"hanmac-family-id",
|
||||
),
|
||||
tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
|
||||
tenant(
|
||||
"halla-id",
|
||||
"COMPANY",
|
||||
"한라산업개발",
|
||||
"halla",
|
||||
"hanmac-family-id",
|
||||
),
|
||||
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
||||
tenant(
|
||||
"gpdtdc-id",
|
||||
"ORGANIZATION",
|
||||
"총괄기획&기술개발센터",
|
||||
"gpdtdc",
|
||||
"hanmac-family-id",
|
||||
),
|
||||
];
|
||||
|
||||
const tree = buildOrgPickerTree({
|
||||
tenants,
|
||||
users: [] satisfies UserSummary[],
|
||||
});
|
||||
|
||||
expect(tree.roots[0]?.children.map((node) => node.name)).toEqual([
|
||||
"총괄기획&기술개발센터",
|
||||
"삼안",
|
||||
"한맥기술",
|
||||
"바론그룹",
|
||||
"한라산업개발",
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes descendant filtering by tenant slug", () => {
|
||||
const tenants = [
|
||||
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
|
||||
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
||||
tenant("planning-id", "ORGANIZATION", "기획팀", "planning", "saman-id"),
|
||||
tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
|
||||
];
|
||||
|
||||
const tree = buildOrgPickerTree({
|
||||
tenants,
|
||||
users: [] satisfies UserSummary[],
|
||||
tenantId: "saman",
|
||||
});
|
||||
|
||||
expect(tree.roots).toHaveLength(1);
|
||||
expect(tree.roots[0]?.id).toBe("saman-id");
|
||||
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
|
||||
"planning-id",
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes internal and private tenants from picker choices by default", () => {
|
||||
const tenants = [
|
||||
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
|
||||
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
||||
{
|
||||
...tenant(
|
||||
"internal-id",
|
||||
"ORGANIZATION",
|
||||
"내부 조직",
|
||||
"internal",
|
||||
"saman-id",
|
||||
),
|
||||
config: { visibility: "internal" },
|
||||
},
|
||||
{
|
||||
...tenant(
|
||||
"secret-id",
|
||||
"ORGANIZATION",
|
||||
"비공개 조직",
|
||||
"secret",
|
||||
"saman-id",
|
||||
),
|
||||
config: { visibility: "private" },
|
||||
},
|
||||
tenant(
|
||||
"secret-child-id",
|
||||
"USER_GROUP",
|
||||
"비공개 하위",
|
||||
"secret-child",
|
||||
"secret-id",
|
||||
),
|
||||
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
|
||||
];
|
||||
|
||||
const tree = buildOrgPickerTree({
|
||||
tenants,
|
||||
users: [] satisfies UserSummary[],
|
||||
tenantId: "saman",
|
||||
});
|
||||
|
||||
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual(["open-id"]);
|
||||
});
|
||||
|
||||
it("includes internal tenants when explicitly requested", () => {
|
||||
const tenants = [
|
||||
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
|
||||
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
|
||||
{
|
||||
...tenant(
|
||||
"internal-id",
|
||||
"ORGANIZATION",
|
||||
"내부 조직",
|
||||
"internal",
|
||||
"saman-id",
|
||||
),
|
||||
config: { visibility: "internal" },
|
||||
},
|
||||
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
|
||||
];
|
||||
|
||||
const tree = buildOrgPickerTree({
|
||||
includeInternal: true,
|
||||
tenants,
|
||||
users: [] satisfies UserSummary[],
|
||||
tenantId: "saman",
|
||||
});
|
||||
|
||||
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
|
||||
"internal-id",
|
||||
"open-id",
|
||||
]);
|
||||
});
|
||||
});
|
||||
188
baron-sso/orgfront/src/features/orgchart/pickerTree.ts
Normal file
188
baron-sso/orgfront/src/features/orgchart/pickerTree.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
||||
import { buildTenantFullTree, type TenantNode } from "../../lib/tenantTree";
|
||||
import { orderHanmacFamilyChildren } from "./hanmacFamilyOrder";
|
||||
import type { OrgPickerTreeNode } from "./pickerTypes";
|
||||
import { filterTenantsByVisibility } from "./tenantVisibility";
|
||||
import { getOrgChartUserDisplayName } from "./userDisplay";
|
||||
|
||||
function getUserTenantSlug(user: UserSummary) {
|
||||
return user.tenantSlug?.toLowerCase() || "";
|
||||
}
|
||||
|
||||
function isOrgFrontTenantType(tenant: TenantSummary) {
|
||||
return ["COMPANY_GROUP", "COMPANY", "ORGANIZATION", "USER_GROUP"].includes(
|
||||
tenant.type.toUpperCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function getCompanyGroupId(node: TenantNode, allTenants: TenantSummary[]) {
|
||||
let cursor: TenantSummary | undefined = node;
|
||||
const byId = new Map(allTenants.map((tenant) => [tenant.id, tenant]));
|
||||
|
||||
while (cursor?.parentId) {
|
||||
const parent = byId.get(cursor.parentId);
|
||||
if (!parent) break;
|
||||
cursor = parent;
|
||||
}
|
||||
|
||||
return cursor?.type === "COMPANY_GROUP" ? cursor.id : node.id;
|
||||
}
|
||||
|
||||
function isHanmacFamilyCompanyGroup(tenant: TenantSummary) {
|
||||
return (
|
||||
tenant.type.toUpperCase() === "COMPANY_GROUP" &&
|
||||
tenant.slug.toLowerCase() === "hanmac-family"
|
||||
);
|
||||
}
|
||||
|
||||
function findTenantByRef(tenants: TenantSummary[], ref?: string) {
|
||||
const normalizedRef = ref?.trim().toLowerCase();
|
||||
if (!normalizedRef) return undefined;
|
||||
|
||||
return (
|
||||
tenants.find((tenant) => tenant.slug.toLowerCase() === normalizedRef) ??
|
||||
tenants.find((tenant) => tenant.id === ref)
|
||||
);
|
||||
}
|
||||
|
||||
function tenantToPickerNode(
|
||||
tenant: TenantNode,
|
||||
usersBySlug: Map<string, UserSummary[]>,
|
||||
): OrgPickerTreeNode {
|
||||
const tenantChildren = orderHanmacFamilyChildren(tenant, tenant.children).map(
|
||||
(child) => tenantToPickerNode(child, usersBySlug),
|
||||
);
|
||||
const userChildren = (usersBySlug.get(tenant.slug.toLowerCase()) || []).map(
|
||||
(user) => ({
|
||||
type: "user" as const,
|
||||
id: user.id,
|
||||
name: getOrgChartUserDisplayName(user, tenant),
|
||||
parentId: tenant.id,
|
||||
user,
|
||||
children: [],
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
type: "tenant",
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
parentId: tenant.parentId ?? null,
|
||||
tenant,
|
||||
children: [...userChildren, ...tenantChildren],
|
||||
};
|
||||
}
|
||||
|
||||
function findTenantNode(
|
||||
roots: TenantNode[],
|
||||
tenantRef: string,
|
||||
): TenantNode | undefined {
|
||||
const findBySlug = (node: TenantNode): TenantNode | undefined => {
|
||||
if (node.slug.toLowerCase() === tenantRef.trim().toLowerCase()) {
|
||||
return node;
|
||||
}
|
||||
for (const child of node.children) {
|
||||
const match = findBySlug(child);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const findById = (node: TenantNode): TenantNode | undefined => {
|
||||
if (node.id === tenantRef) return node;
|
||||
for (const child of node.children) {
|
||||
const match = findById(child);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
for (const root of roots) {
|
||||
const slugMatch = findBySlug(root);
|
||||
if (slugMatch) return slugMatch;
|
||||
}
|
||||
for (const root of roots) {
|
||||
const idMatch = findById(root);
|
||||
if (idMatch) return idMatch;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildOrgPickerTree({
|
||||
includeInternal = false,
|
||||
tenants,
|
||||
users,
|
||||
rootTenantId,
|
||||
tenantId,
|
||||
}: {
|
||||
includeInternal?: boolean;
|
||||
tenants: TenantSummary[];
|
||||
users: UserSummary[];
|
||||
rootTenantId?: string;
|
||||
tenantId?: string;
|
||||
}) {
|
||||
const visibleTenants = filterTenantsByVisibility(
|
||||
tenants.filter(isOrgFrontTenantType),
|
||||
includeInternal ? "internal" : "public",
|
||||
);
|
||||
const usersBySlug = new Map<string, UserSummary[]>();
|
||||
for (const user of users) {
|
||||
if (user.status !== "active") continue;
|
||||
const slug = getUserTenantSlug(user);
|
||||
if (!slug) continue;
|
||||
const list = usersBySlug.get(slug) || [];
|
||||
list.push(user);
|
||||
usersBySlug.set(slug, list);
|
||||
}
|
||||
|
||||
const companyGroup =
|
||||
findTenantByRef(visibleTenants, rootTenantId) ??
|
||||
visibleTenants.find(isHanmacFamilyCompanyGroup) ??
|
||||
visibleTenants.find((tenant) => tenant.type === "COMPANY_GROUP") ??
|
||||
visibleTenants.find((tenant) => !tenant.parentId);
|
||||
|
||||
if (!companyGroup) return { roots: [], companies: [], companyGroupId: "" };
|
||||
|
||||
const { currentBase } = buildTenantFullTree(visibleTenants, companyGroup.id);
|
||||
const groupNode =
|
||||
currentBase ??
|
||||
buildTenantFullTree(visibleTenants).subTree.find(
|
||||
(node) => node.id === companyGroup.id,
|
||||
);
|
||||
|
||||
if (!groupNode) return { roots: [], companies: [], companyGroupId: "" };
|
||||
|
||||
const companies = orderHanmacFamilyChildren(
|
||||
groupNode,
|
||||
groupNode.children,
|
||||
).filter((node) => node.type === "COMPANY");
|
||||
const scopedRoot = tenantId
|
||||
? findTenantNode([groupNode], tenantId)
|
||||
: groupNode;
|
||||
const filteredRoots = scopedRoot ? [scopedRoot] : [];
|
||||
const roots = filteredRoots.map((node) =>
|
||||
tenantToPickerNode(node, usersBySlug),
|
||||
);
|
||||
|
||||
return {
|
||||
roots,
|
||||
companies: companies.map((company) => ({
|
||||
id: company.id,
|
||||
name: company.name,
|
||||
companyGroupTenantId: getCompanyGroupId(company, tenants),
|
||||
})),
|
||||
companyGroupId: companyGroup.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function flattenDescendants(node: OrgPickerTreeNode) {
|
||||
const descendants: OrgPickerTreeNode[] = [];
|
||||
const walk = (current: OrgPickerTreeNode) => {
|
||||
for (const child of current.children) {
|
||||
descendants.push(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
|
||||
walk(node);
|
||||
return descendants;
|
||||
}
|
||||
56
baron-sso/orgfront/src/features/orgchart/pickerTypes.test.ts
Normal file
56
baron-sso/orgfront/src/features/orgchart/pickerTypes.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildOrgPickerEmbedSrc,
|
||||
parseOrgPickerEmbedOptions,
|
||||
} from "./pickerTypes";
|
||||
|
||||
describe("org picker embed options", () => {
|
||||
it("builds slug-based tenant scope urls", () => {
|
||||
expect(
|
||||
buildOrgPickerEmbedSrc({
|
||||
includeInternal: false,
|
||||
mode: "single",
|
||||
select: "tenant",
|
||||
includeDescendants: true,
|
||||
showDescendantToggle: true,
|
||||
tenantId: "saman",
|
||||
width: 400,
|
||||
height: 600,
|
||||
}),
|
||||
).toBe(
|
||||
"/embed/picker?mode=single&select=tenant&width=400&height=600&tenantSlug=saman",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds and parses internal visibility flag only when requested", () => {
|
||||
const src = buildOrgPickerEmbedSrc({
|
||||
includeInternal: true,
|
||||
mode: "single",
|
||||
select: "tenant",
|
||||
includeDescendants: true,
|
||||
showDescendantToggle: true,
|
||||
tenantId: "",
|
||||
width: 400,
|
||||
height: 600,
|
||||
});
|
||||
|
||||
expect(src).toBe(
|
||||
"/embed/picker?mode=single&select=tenant&width=400&height=600&includeInternal=true",
|
||||
);
|
||||
expect(parseOrgPickerEmbedOptions(src).includeInternal).toBe(true);
|
||||
expect(parseOrgPickerEmbedOptions("?mode=single").includeInternal).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("parses tenantSlug first and keeps legacy tenantId compatibility", () => {
|
||||
expect(
|
||||
parseOrgPickerEmbedOptions(
|
||||
"?tenantId=legacy-id&tenantSlug=saman&companyTenantId=legacy-company",
|
||||
).tenantId,
|
||||
).toBe("saman");
|
||||
expect(parseOrgPickerEmbedOptions("?tenantId=legacy-id").tenantId).toBe(
|
||||
"legacy-id",
|
||||
);
|
||||
});
|
||||
});
|
||||
107
baron-sso/orgfront/src/features/orgchart/pickerTypes.ts
Normal file
107
baron-sso/orgfront/src/features/orgchart/pickerTypes.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
||||
|
||||
export type OrgPickerMode = "single" | "multiple";
|
||||
export type OrgPickerSelectableType = "tenant" | "user" | "both";
|
||||
export type OrgPickerObjectType = "tenant" | "user";
|
||||
|
||||
export type OrgPickerSelection = {
|
||||
type: OrgPickerObjectType;
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type OrgPickerResult = {
|
||||
mode: OrgPickerMode;
|
||||
selections: OrgPickerSelection[];
|
||||
};
|
||||
|
||||
export type OrgPickerEmbedOptions = {
|
||||
includeInternal: boolean;
|
||||
mode: OrgPickerMode;
|
||||
select: OrgPickerSelectableType;
|
||||
includeDescendants: boolean;
|
||||
showDescendantToggle: boolean;
|
||||
tenantId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type OrgPickerTreeNode = {
|
||||
type: OrgPickerObjectType;
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
tenant?: TenantSummary;
|
||||
user?: UserSummary;
|
||||
children: OrgPickerTreeNode[];
|
||||
};
|
||||
|
||||
export function nodeKey(node: Pick<OrgPickerTreeNode, "type" | "id">) {
|
||||
return `${node.type}:${node.id}`;
|
||||
}
|
||||
|
||||
export function selectionKey(selection: OrgPickerSelection) {
|
||||
return `${selection.type}:${selection.id}`;
|
||||
}
|
||||
|
||||
export function parseOrgPickerMode(value: string | null): OrgPickerMode {
|
||||
return value === "multiple" ? "multiple" : "single";
|
||||
}
|
||||
|
||||
export function parseOrgPickerSelectableType(
|
||||
value: string | null,
|
||||
): OrgPickerSelectableType {
|
||||
if (value === "tenant" || value === "user") return value;
|
||||
return "both";
|
||||
}
|
||||
|
||||
function parseEmbedDimension(value: string | null, fallback: number) {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(1600, Math.max(240, parsed));
|
||||
}
|
||||
|
||||
export function parseOrgPickerEmbedOptions(search: string) {
|
||||
const params = new URLSearchParams(search);
|
||||
return {
|
||||
mode:
|
||||
params.get("mode") === "single"
|
||||
? ("single" as const)
|
||||
: ("multiple" as const),
|
||||
select: parseOrgPickerSelectableType(params.get("select")),
|
||||
includeInternal: params.get("includeInternal") === "true",
|
||||
includeDescendants: params.get("includeDescendants") !== "false",
|
||||
showDescendantToggle: params.get("showDescendantToggle") !== "false",
|
||||
tenantId:
|
||||
params.get("tenantSlug") ??
|
||||
params.get("tenantId") ??
|
||||
params.get("companyTenantId") ??
|
||||
"",
|
||||
width: parseEmbedDimension(params.get("width"), 400),
|
||||
height: parseEmbedDimension(params.get("height"), 600),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOrgPickerEmbedSrc(options: OrgPickerEmbedOptions) {
|
||||
const params = new URLSearchParams({
|
||||
mode: options.mode,
|
||||
select: options.select,
|
||||
width: String(options.width),
|
||||
height: String(options.height),
|
||||
});
|
||||
if (options.includeInternal) {
|
||||
params.set("includeInternal", "true");
|
||||
}
|
||||
|
||||
const tenantSlug = options.tenantId.trim();
|
||||
if (tenantSlug) {
|
||||
params.set("tenantSlug", tenantSlug);
|
||||
}
|
||||
|
||||
if (options.mode === "multiple") {
|
||||
params.set("includeDescendants", String(options.includeDescendants));
|
||||
params.set("showDescendantToggle", String(options.showDescendantToggle));
|
||||
}
|
||||
|
||||
return `/embed/picker?${params.toString()}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
57
baron-sso/orgfront/src/features/orgchart/rankPriority.ts
Normal file
57
baron-sso/orgfront/src/features/orgchart/rankPriority.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export type OrgRankDefinition = {
|
||||
aliases: string[];
|
||||
label: string;
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export const ORG_RANK_DEFINITIONS: OrgRankDefinition[] = [
|
||||
{ label: "회장", weight: -10, aliases: ["회장"] },
|
||||
{ label: "사장", weight: 0, aliases: ["사장"] },
|
||||
{ label: "부회장", weight: 2, aliases: ["부회장"] },
|
||||
{ label: "고문", weight: 5, aliases: ["고문"] },
|
||||
{ label: "부사장", weight: 10, aliases: ["부사장"] },
|
||||
{ label: "전무", weight: 20, aliases: ["전무", "전무이사"] },
|
||||
{ label: "상무", weight: 30, aliases: ["상무", "상무이사"] },
|
||||
{ label: "이사", weight: 40, aliases: ["이사"] },
|
||||
{ label: "부장", weight: 50, aliases: ["부장"] },
|
||||
{ label: "수석", weight: 50, aliases: ["수석", "수석연구원"] },
|
||||
{ label: "차장", weight: 60, aliases: ["차장"] },
|
||||
{ label: "과장", weight: 70, aliases: ["과장"] },
|
||||
{ label: "책임", weight: 70, aliases: ["책임", "책임연구원"] },
|
||||
{ label: "대리", weight: 80, aliases: ["대리"] },
|
||||
{ label: "선임", weight: 80, aliases: ["선임", "선임연구원"] },
|
||||
{ label: "연구원", weight: 90, aliases: ["연구원"] },
|
||||
{ label: "사원", weight: 90, aliases: ["사원"] },
|
||||
];
|
||||
|
||||
const UNKNOWN_RANK_WEIGHT = 999;
|
||||
|
||||
function normalizeRankText(value: unknown) {
|
||||
return typeof value === "string" ? value.trim().replace(/\s+/g, "") : "";
|
||||
}
|
||||
|
||||
export function getOrgRankDefinition(rank: unknown) {
|
||||
const normalizedRank = normalizeRankText(rank);
|
||||
if (!normalizedRank) return undefined;
|
||||
|
||||
return ORG_RANK_DEFINITIONS.find((definition) =>
|
||||
definition.aliases.some(
|
||||
(alias) => normalizeRankText(alias) === normalizedRank,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getOrgRankDisplayName(rank: unknown) {
|
||||
return (
|
||||
getOrgRankDefinition(rank)?.label ??
|
||||
(typeof rank === "string" ? rank.trim() : "")
|
||||
);
|
||||
}
|
||||
|
||||
export function getOrgRankWeight(rank: unknown) {
|
||||
return getOrgRankDefinition(rank)?.weight ?? UNKNOWN_RANK_WEIGHT;
|
||||
}
|
||||
|
||||
export function compareOrgRanks(a: unknown, b: unknown) {
|
||||
return getOrgRankWeight(a) - getOrgRankWeight(b);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
2144
baron-sso/orgfront/src/features/orgchart/routes/OrgChartPage.tsx
Normal file
2144
baron-sso/orgfront/src/features/orgchart/routes/OrgChartPage.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
45
baron-sso/orgfront/src/features/orgchart/tenantVisibility.ts
Normal file
45
baron-sso/orgfront/src/features/orgchart/tenantVisibility.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { TenantSummary } from "../../lib/adminApi";
|
||||
|
||||
export function getTenantVisibility(tenant: Pick<TenantSummary, "config">) {
|
||||
const raw = String(tenant.config?.visibility ?? "public").toLowerCase();
|
||||
if (raw === "internal" || raw === "private") return raw;
|
||||
return "public";
|
||||
}
|
||||
|
||||
export function filterTenantsByVisibility(
|
||||
tenants: TenantSummary[],
|
||||
mode: "internal" | "public",
|
||||
) {
|
||||
const excludedIds = new Set<string>();
|
||||
for (const tenant of tenants) {
|
||||
const visibility = getTenantVisibility(tenant);
|
||||
if (
|
||||
visibility === "private" ||
|
||||
(mode === "public" && visibility === "internal")
|
||||
) {
|
||||
excludedIds.add(tenant.id);
|
||||
}
|
||||
}
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const tenant of tenants) {
|
||||
if (
|
||||
tenant.parentId &&
|
||||
excludedIds.has(tenant.parentId) &&
|
||||
!excludedIds.has(tenant.id)
|
||||
) {
|
||||
excludedIds.add(tenant.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tenants.filter((tenant) => !excludedIds.has(tenant.id));
|
||||
}
|
||||
|
||||
export function getOrgUnitType(config: Record<string, unknown> | undefined) {
|
||||
const value = config?.orgUnitType;
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
167
baron-sso/orgfront/src/features/orgchart/userDisplay.test.ts
Normal file
167
baron-sso/orgfront/src/features/orgchart/userDisplay.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UserSummary } from "../../lib/adminApi";
|
||||
import { getOrgChartUserDisplayName, getUserOrgProfile } from "./userDisplay";
|
||||
|
||||
function user(overrides: Partial<UserSummary>): UserSummary {
|
||||
return {
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
name: "홍길동",
|
||||
role: "user",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getOrgChartUserDisplayName", () => {
|
||||
it("renders name with grade and without job details", () => {
|
||||
expect(
|
||||
getOrgChartUserDisplayName(
|
||||
user({
|
||||
grade: "수석",
|
||||
position: "팀장",
|
||||
jobTitle: "구조",
|
||||
}),
|
||||
),
|
||||
).toBe("홍길동 수석");
|
||||
});
|
||||
|
||||
it("uses tenant appointment grade before the user grade", () => {
|
||||
expect(
|
||||
getOrgChartUserDisplayName(
|
||||
user({
|
||||
grade: "책임",
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "hanmac",
|
||||
grade: "수석",
|
||||
position: "센터장",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-1", slug: "hanmac" },
|
||||
),
|
||||
).toBe("홍길동 수석");
|
||||
});
|
||||
|
||||
it("uses short grade aliases in the display name", () => {
|
||||
expect(
|
||||
getOrgChartUserDisplayName(
|
||||
user({
|
||||
grade: "책임연구원",
|
||||
jobTitle: "구조",
|
||||
}),
|
||||
),
|
||||
).toBe("홍길동 책임");
|
||||
});
|
||||
|
||||
it("does not add leader text to the display name", () => {
|
||||
expect(
|
||||
getOrgChartUserDisplayName(
|
||||
user({
|
||||
grade: "책임",
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "hanmac",
|
||||
isOwner: true,
|
||||
grade: "수석",
|
||||
position: "센터장",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-1", slug: "hanmac" },
|
||||
),
|
||||
).toBe("홍길동 수석");
|
||||
});
|
||||
|
||||
it("does not leak an owner appointment flag into another tenant display", () => {
|
||||
expect(
|
||||
getOrgChartUserDisplayName(
|
||||
user({
|
||||
grade: "책임",
|
||||
position: "팀원",
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "hanmac",
|
||||
isOwner: true,
|
||||
grade: "수석",
|
||||
position: "센터장",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-2", slug: "baron" },
|
||||
),
|
||||
).toBe("홍길동 책임");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserOrgProfile", () => {
|
||||
it("marks owner, manager, and admin flags as highlighted profiles", () => {
|
||||
expect(
|
||||
getUserOrgProfile(
|
||||
user({
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "owner",
|
||||
isOwner: true,
|
||||
},
|
||||
{
|
||||
tenantSlug: "manager",
|
||||
isManager: true,
|
||||
},
|
||||
{
|
||||
tenantSlug: "admin",
|
||||
isAdmin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-1", slug: "owner" },
|
||||
).isHighlighted,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getUserOrgProfile(
|
||||
user({
|
||||
metadata: {
|
||||
additionalAppointments: [{ tenantSlug: "leader", isLeader: true }],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-2", slug: "leader" },
|
||||
).isHighlighted,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getUserOrgProfile(
|
||||
user({
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{ tenantSlug: "manager", isManager: true },
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-2", slug: "manager" },
|
||||
).isHighlighted,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getUserOrgProfile(
|
||||
user({
|
||||
metadata: {
|
||||
additionalAppointments: [{ tenantSlug: "admin", isAdmin: true }],
|
||||
},
|
||||
}),
|
||||
{ id: "tenant-3", slug: "admin" },
|
||||
).isHighlighted,
|
||||
).toBe(true);
|
||||
expect(getUserOrgProfile(user({ grade: "책임" })).isHighlighted).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
77
baron-sso/orgfront/src/features/orgchart/userDisplay.ts
Normal file
77
baron-sso/orgfront/src/features/orgchart/userDisplay.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
||||
import { getOrgRankDisplayName } from "./rankPriority";
|
||||
|
||||
type UserAppointment = {
|
||||
tenantId?: string;
|
||||
tenantSlug?: string;
|
||||
isAdmin?: boolean;
|
||||
isManager?: boolean;
|
||||
isOwner?: boolean;
|
||||
grade?: string;
|
||||
jobTitle?: string;
|
||||
position?: string;
|
||||
};
|
||||
|
||||
type TenantIdentity = Pick<TenantSummary, "id" | "slug">;
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function getUserAppointments(user: UserSummary): UserAppointment[] {
|
||||
const rawAppointments = user.metadata?.additionalAppointments;
|
||||
if (!Array.isArray(rawAppointments)) return [];
|
||||
|
||||
return rawAppointments
|
||||
.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
typeof item === "object" && item !== null,
|
||||
)
|
||||
.map((item) => ({
|
||||
tenantId: normalizeText(item.tenantId),
|
||||
tenantSlug: normalizeText(item.tenantSlug),
|
||||
isAdmin: item.isAdmin === true,
|
||||
isManager: item.isManager === true,
|
||||
isOwner: item.isOwner === true,
|
||||
grade: normalizeText(item.grade),
|
||||
jobTitle: normalizeText(item.jobTitle),
|
||||
position: normalizeText(item.position),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
|
||||
const appointment = getUserAppointments(user).find((item) => {
|
||||
if (tenant?.id && item.tenantId === tenant.id) return true;
|
||||
if (
|
||||
tenant?.slug &&
|
||||
item.tenantSlug &&
|
||||
item.tenantSlug.toLowerCase() === tenant.slug.toLowerCase()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return {
|
||||
grade: appointment?.grade || normalizeText(user.grade),
|
||||
isHighlighted:
|
||||
appointment?.isAdmin === true ||
|
||||
appointment?.isManager === true ||
|
||||
appointment?.isOwner === true,
|
||||
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
|
||||
position: appointment?.position || normalizeText(user.position),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOrgChartUserDisplayName(
|
||||
user: UserSummary,
|
||||
tenant?: TenantIdentity,
|
||||
) {
|
||||
const { grade } = getUserOrgProfile(user, tenant);
|
||||
const baseName = user.name.trim();
|
||||
const displayGrade = getOrgRankDisplayName(grade);
|
||||
|
||||
let displayName = baseName;
|
||||
if (displayGrade) displayName = `${baseName} ${displayGrade}`;
|
||||
return displayName;
|
||||
}
|
||||
218
baron-sso/orgfront/src/features/profile/ProfilePage.tsx
Normal file
218
baron-sso/orgfront/src/features/profile/ProfilePage.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Briefcase,
|
||||
Building2,
|
||||
Fingerprint,
|
||||
Mail,
|
||||
Shield,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { fetchMe } from "../auth/authApi";
|
||||
import ProfileTenantSwitcher from "./ProfileTenantSwitcher";
|
||||
|
||||
function ProfilePage() {
|
||||
const auth = useAuth();
|
||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||
|
||||
const {
|
||||
data: profile,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["userMe"],
|
||||
queryFn: fetchMe,
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"basic" | "role">("basic");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("ui.dev.profile.loading", "Loading profile...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !profile) {
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
{t("ui.dev.profile.error", "Failed to load profile information.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to token information if API data is incomplete
|
||||
const displayTenant =
|
||||
profile.tenant?.name ||
|
||||
profile.tenantId ||
|
||||
auth.user?.profile?.tenant_id?.toString() ||
|
||||
"-";
|
||||
const displayTenantSlug = profile.tenant?.slug || profile.tenantId || "-";
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight">
|
||||
{t("ui.dev.profile.title", "내 정보")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t(
|
||||
"ui.dev.profile.subtitle",
|
||||
"사용자 상세 정보 및 할당된 역할(Role)을 확인합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-1 border-b border-border pb-px">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("basic")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "basic"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
|
||||
}`}
|
||||
>
|
||||
{t("ui.dev.profile.tab.basic", "기본 정보")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("role")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "role"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
|
||||
}`}
|
||||
>
|
||||
{t("ui.dev.profile.tab.role", "권한 및 역할")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
{activeTab === "basic" && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card className="glass-panel">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
{t("ui.dev.profile.basic.title", "사용자 정보")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Fingerprint className="h-4 w-4" />
|
||||
{t("ui.dev.profile.basic.id", "User ID")}
|
||||
</p>
|
||||
<p className="text-sm break-all font-mono bg-muted/50 p-2 rounded-md">
|
||||
{profile.id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
{t("ui.dev.profile.basic.name", "Name")}
|
||||
</p>
|
||||
<p className="text-sm">{profile.name}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
{t("ui.dev.profile.basic.email", "Email")}
|
||||
</p>
|
||||
<p className="text-sm">{profile.email}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
{t("ui.dev.profile.basic.phone", "Phone")}
|
||||
</p>
|
||||
<p className="text-sm">{profile.phone || "-"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
{t("ui.dev.profile.org.title", "조직 정보")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("ui.dev.profile.org.tenant", "테넌트")}
|
||||
</p>
|
||||
<p className="text-sm">{displayTenant}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("ui.dev.profile.org.tenant_slug", "테넌트 Slug")}
|
||||
</p>
|
||||
<p className="text-sm">{displayTenantSlug}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ProfileTenantSwitcher />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "role" && (
|
||||
<Card className="glass-panel">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
{t("ui.dev.profile.role.title", "시스템 역할")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.dev.profile.role.description",
|
||||
"현재 계정에 부여된 권한 등급입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 bg-muted/30 p-4 rounded-lg border border-border">
|
||||
<div className="h-12 w-12 rounded-full bg-primary/20 flex items-center justify-center text-primary shrink-0">
|
||||
<Briefcase className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-sm text-muted-foreground font-medium uppercase tracking-wider">
|
||||
{t("ui.dev.profile.role.current", "Current Role")}
|
||||
</p>
|
||||
<p className="text-xl font-bold mt-1">
|
||||
{t(
|
||||
`ui.common.role.${profile.role}`,
|
||||
profile.role.toUpperCase(),
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
`ui.dev.profile.role.desc_${profile.role}`,
|
||||
"시스템 역할에 대한 설명이 제공되지 않았습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -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
baron-sso/orgfront/src/index.css
Normal file
35
baron-sso/orgfront/src/index.css
Normal 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
baron-sso/orgfront/src/lib/adminApi.test.ts
Normal file
216
baron-sso/orgfront/src/lib/adminApi.test.ts
Normal 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
baron-sso/orgfront/src/lib/adminApi.ts
Normal file
771
baron-sso/orgfront/src/lib/adminApi.ts
Normal 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
baron-sso/orgfront/src/lib/apiClient.ts
Normal file
75
baron-sso/orgfront/src/lib/apiClient.ts
Normal 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
baron-sso/orgfront/src/lib/auth.ts
Normal file
24
baron-sso/orgfront/src/lib/auth.ts
Normal 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),
|
||||
);
|
||||
29
baron-sso/orgfront/src/lib/authConfig.test.ts
Normal file
29
baron-sso/orgfront/src/lib/authConfig.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildOrgFrontAuthRedirectUris,
|
||||
ORGFRONT_AUTH_CALLBACK_PATH,
|
||||
resolveOrgFrontPublicOrigin,
|
||||
} from "./authConfig";
|
||||
|
||||
describe("orgfront auth config", () => {
|
||||
it("builds callback URLs from the public origin", () => {
|
||||
expect(buildOrgFrontAuthRedirectUris("https://sorg.hmac.kr")).toEqual({
|
||||
redirectUri: "https://sorg.hmac.kr/auth/callback",
|
||||
postLogoutRedirectUri: "https://sorg.hmac.kr",
|
||||
popupRedirectUri: "https://sorg.hmac.kr/auth/callback",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the browser origin when the configured origin is empty or invalid", () => {
|
||||
expect(resolveOrgFrontPublicOrigin("", "http://localhost:5174")).toBe(
|
||||
"http://localhost:5174",
|
||||
);
|
||||
expect(
|
||||
resolveOrgFrontPublicOrigin("not a url", "http://localhost:5174"),
|
||||
).toBe("http://localhost:5174");
|
||||
});
|
||||
|
||||
it("keeps the callback path aligned with the registered redirect path", () => {
|
||||
expect(ORGFRONT_AUTH_CALLBACK_PATH).toBe("/auth/callback");
|
||||
});
|
||||
});
|
||||
33
baron-sso/orgfront/src/lib/authConfig.ts
Normal file
33
baron-sso/orgfront/src/lib/authConfig.ts
Normal 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
baron-sso/orgfront/src/lib/devApi.test.ts
Normal file
139
baron-sso/orgfront/src/lib/devApi.test.ts
Normal 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
baron-sso/orgfront/src/lib/devApi.ts
Normal file
315
baron-sso/orgfront/src/lib/devApi.ts
Normal 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
baron-sso/orgfront/src/lib/i18n.ts
Normal file
16
baron-sso/orgfront/src/lib/i18n.ts
Normal 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
baron-sso/orgfront/src/lib/role.ts
Normal file
27
baron-sso/orgfront/src/lib/role.ts
Normal 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 "";
|
||||
}
|
||||
27
baron-sso/orgfront/src/lib/sessionSliding.ts
Normal file
27
baron-sso/orgfront/src/lib/sessionSliding.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
DEFAULT_SESSION_RENEW_THROTTLE_MS,
|
||||
type SessionRenewDecisionParams,
|
||||
shouldAttemptSlidingSessionRenew as shouldAttemptSlidingSessionRenewBase,
|
||||
shouldAttemptUnlimitedSessionRenew as shouldAttemptUnlimitedSessionRenewBase,
|
||||
} from "../../../common/core/session";
|
||||
|
||||
export const SESSION_RENEW_THROTTLE_MS = DEFAULT_SESSION_RENEW_THROTTLE_MS;
|
||||
export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
|
||||
export function shouldAttemptSlidingSessionRenew(
|
||||
params: SessionRenewDecisionParams,
|
||||
) {
|
||||
return shouldAttemptSlidingSessionRenewBase({
|
||||
...params,
|
||||
thresholdMs: params.thresholdMs ?? SESSION_RENEW_THRESHOLD_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldAttemptUnlimitedSessionRenew(
|
||||
params: SessionRenewDecisionParams,
|
||||
) {
|
||||
return shouldAttemptUnlimitedSessionRenewBase({
|
||||
...params,
|
||||
thresholdMs: params.thresholdMs ?? SESSION_RENEW_THRESHOLD_MS,
|
||||
});
|
||||
}
|
||||
46
baron-sso/orgfront/src/lib/tenantTree.test.ts
Normal file
46
baron-sso/orgfront/src/lib/tenantTree.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "./adminApi";
|
||||
import { buildTenantFullTree } from "./tenantTree";
|
||||
|
||||
function tenant(
|
||||
id: string,
|
||||
slug: string,
|
||||
parentId?: string,
|
||||
type = "USER_GROUP",
|
||||
): TenantSummary {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: slug,
|
||||
slug,
|
||||
description: "",
|
||||
status: "active",
|
||||
parentId,
|
||||
memberCount: 0,
|
||||
totalMemberCount: 0,
|
||||
createdAt: "2026-06-10T00:00:00.000Z",
|
||||
updatedAt: "2026-06-10T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildTenantFullTree", () => {
|
||||
it("treats a self-parent hanmac-family tenant as a root", () => {
|
||||
const result = buildTenantFullTree([
|
||||
tenant(
|
||||
"hanmac-family-id",
|
||||
"hanmac-family",
|
||||
"hanmac-family-id",
|
||||
"COMPANY_GROUP",
|
||||
),
|
||||
tenant("saman-id", "saman", "hanmac-family-id", "COMPANY"),
|
||||
tenant("platform-id", "platform", "saman-id"),
|
||||
]);
|
||||
|
||||
expect(result.subTree).toHaveLength(1);
|
||||
expect(result.subTree[0]?.id).toBe("hanmac-family-id");
|
||||
expect(result.subTree[0]?.children[0]?.id).toBe("saman-id");
|
||||
expect(result.subTree[0]?.children[0]?.children[0]?.id).toBe(
|
||||
"platform-id",
|
||||
);
|
||||
});
|
||||
});
|
||||
88
baron-sso/orgfront/src/lib/tenantTree.ts
Normal file
88
baron-sso/orgfront/src/lib/tenantTree.ts
Normal 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
baron-sso/orgfront/src/lib/utils.ts
Normal file
8
baron-sso/orgfront/src/lib/utils.ts
Normal 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)]);
|
||||
}
|
||||
1718
baron-sso/orgfront/src/locales/en.toml
Normal file
1718
baron-sso/orgfront/src/locales/en.toml
Normal file
File diff suppressed because it is too large
Load Diff
1716
baron-sso/orgfront/src/locales/ko.toml
Normal file
1716
baron-sso/orgfront/src/locales/ko.toml
Normal file
File diff suppressed because it is too large
Load Diff
1703
baron-sso/orgfront/src/locales/template.toml
Normal file
1703
baron-sso/orgfront/src/locales/template.toml
Normal file
File diff suppressed because it is too large
Load Diff
27
baron-sso/orgfront/src/main.tsx
Normal file
27
baron-sso/orgfront/src/main.tsx
Normal 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>,
|
||||
);
|
||||
987
baron-sso/orgfront/src/sdk/org-context-chart/index.ts
Normal file
987
baron-sso/orgfront/src/sdk/org-context-chart/index.ts
Normal file
@@ -0,0 +1,987 @@
|
||||
export type OrgContextMember = {
|
||||
id?: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
department?: string;
|
||||
grade?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
isOwner?: boolean;
|
||||
isLeader?: boolean;
|
||||
isPrimary?: boolean;
|
||||
};
|
||||
|
||||
export type OrgContextTenant = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
parentId?: string | null;
|
||||
status: string;
|
||||
description: string;
|
||||
domains: string[];
|
||||
memberCount: number;
|
||||
visibility: string;
|
||||
orgUnitType?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
members: OrgContextMember[];
|
||||
};
|
||||
|
||||
export type OrgContextTreeNode = OrgContextTenant & {
|
||||
children: OrgContextTreeNode[];
|
||||
};
|
||||
|
||||
export type OrgContextResponse = {
|
||||
schemaVersion: "baron.org-context.v1";
|
||||
issuedAt: string;
|
||||
scope: {
|
||||
tenantId: string;
|
||||
tenantSlug: string;
|
||||
};
|
||||
tree: OrgContextTreeNode;
|
||||
tenants: OrgContextTenant[];
|
||||
};
|
||||
|
||||
export type OrgChartNode = OrgContextTenant & {
|
||||
children: OrgChartNode[];
|
||||
depth: number;
|
||||
path: string[];
|
||||
};
|
||||
|
||||
export type OrgChartMember = OrgContextMember & {
|
||||
tenantIds: string[];
|
||||
};
|
||||
|
||||
export type OrgChartModel = {
|
||||
root: OrgChartNode;
|
||||
nodes: OrgChartNode[];
|
||||
tenantsById: Map<string, OrgChartNode>;
|
||||
tenantsBySlug: Map<string, OrgChartNode>;
|
||||
membersByEmail: Map<string, OrgChartMember>;
|
||||
response: OrgContextResponse;
|
||||
};
|
||||
|
||||
export type OrgContextClientOptions = {
|
||||
baseUrl: string;
|
||||
credentials?: {
|
||||
keyId: string;
|
||||
keySecret: string;
|
||||
};
|
||||
fetch?: typeof fetch;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type FetchOrgContextOptions = {
|
||||
tenantSlug?: string;
|
||||
includeUsers?: boolean;
|
||||
includeUserIds?: boolean;
|
||||
};
|
||||
|
||||
export type OrgPickerSelection = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "tenant" | "user";
|
||||
};
|
||||
|
||||
export type OrgPickerVariant = "default" | "orgfront";
|
||||
|
||||
export type OrgPickerOptions = {
|
||||
mode?: "single" | "multiple";
|
||||
selectable?: "tenant" | "user" | "both";
|
||||
includeDescendants?: boolean;
|
||||
injectStyles?: boolean;
|
||||
showDescendantToggle?: boolean;
|
||||
variant?: OrgPickerVariant;
|
||||
onCancel?: () => void;
|
||||
onChange?: (selection: OrgPickerSelection[]) => void;
|
||||
onConfirm?: (selection: OrgPickerSelection[]) => void;
|
||||
};
|
||||
|
||||
export type OrgPickerController = {
|
||||
cancel: () => void;
|
||||
confirm: () => void;
|
||||
destroy: () => void;
|
||||
getSelection: () => OrgPickerSelection[];
|
||||
};
|
||||
|
||||
const API_PATH = "/api/v1/integrations/org-context";
|
||||
const DEFAULT_STYLE_ID = "baron-org-context-chart-default-style";
|
||||
const DEFAULT_STYLE = `
|
||||
.baron-org-chart,.baron-org-picker{box-sizing:border-box;color:#0f172a;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.45}
|
||||
.baron-org-chart *,.baron-org-picker *{box-sizing:border-box}
|
||||
.baron-org-chart__tree{display:flex;min-width:100%;gap:24px;overflow:auto;padding:16px}
|
||||
.baron-org-chart__node{min-width:220px;border:1px solid #d8dee9;border-radius:8px;background:#fff;box-shadow:0 10px 30px rgba(15,23,42,.08)}
|
||||
.baron-org-chart__title{margin:0;padding:10px 12px;border-bottom:1px solid #e5e7eb;background:#f8fafc;font-size:14px;font-weight:700}
|
||||
.baron-org-chart__meta{margin:0;padding:8px 12px;color:#64748b;font-size:12px}
|
||||
.baron-org-chart__members{margin:0;padding:0 12px 12px 28px;color:#334155;font-size:12px}
|
||||
.baron-org-chart__children{display:flex;gap:16px;margin:12px;padding:12px 0 0 16px;border-left:1px solid #d8dee9}
|
||||
.baron-org-picker{width:100%;max-width:520px;border:1px solid #d8dee9;border-radius:8px;background:#fff;box-shadow:0 12px 34px rgba(15,23,42,.1);overflow:hidden}
|
||||
.baron-org-picker__toolbar{display:flex;flex-direction:column;gap:10px;padding:12px;border-bottom:1px solid #e5e7eb;background:#f8fafc}
|
||||
.baron-org-picker__search-wrap{position:relative}
|
||||
.baron-org-picker__search-icon{pointer-events:none;position:absolute;left:12px;top:50%;width:16px;height:16px;transform:translateY(-50%);color:#64748b}
|
||||
.baron-org-picker__search-icon::before{content:"";position:absolute;left:2px;top:2px;width:8px;height:8px;border:2px solid currentColor;border-radius:999px}
|
||||
.baron-org-picker__search-icon::after{content:"";position:absolute;left:10px;top:11px;width:6px;height:2px;background:currentColor;border-radius:999px;transform:rotate(45deg);transform-origin:left center}
|
||||
.baron-org-picker__search{width:100%;height:38px;border:1px solid #cbd5e1;border-radius:6px;background:#fff;padding:0 10px;color:#0f172a;font:inherit;outline:none}
|
||||
.baron-org-picker__search:focus{border-color:#24449c;box-shadow:0 0 0 3px rgba(36,68,156,.18)}
|
||||
.baron-org-picker__controls{display:flex;align-items:center;justify-content:space-between;gap:12px;color:#475569;font-size:12px}
|
||||
.baron-org-picker__descendants{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
|
||||
.baron-org-picker__summary{color:#64748b}
|
||||
.baron-org-picker__clear{border:0;background:transparent;color:#24449c;cursor:pointer;font:inherit;font-weight:700;padding:2px 0}
|
||||
.baron-org-picker__clear:hover{text-decoration:underline}
|
||||
.baron-org-picker__list,.baron-org-picker__children{list-style:none;margin:0;padding:0}
|
||||
.baron-org-picker__list{max-height:420px;overflow:auto;padding:8px}
|
||||
.baron-org-picker__item{margin:0}
|
||||
.baron-org-picker__children{margin-left:8px;border-left:1px solid #e5e7eb}
|
||||
.baron-org-picker__row{display:flex;min-height:32px;align-items:center;gap:8px;border-radius:6px;padding:4px 8px;color:#0f172a;cursor:pointer}
|
||||
.baron-org-picker__row:hover{background:#f1f5f9}
|
||||
.baron-org-picker__row--selected{background:#e8eefc;color:#18327a}
|
||||
.baron-org-picker__row--member{color:#334155;font-size:13px}
|
||||
.baron-org-picker__label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.baron-org-picker__label-primary,.baron-org-picker__label-secondary{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.baron-org-picker__label-primary{font-weight:600;line-height:20px}
|
||||
.baron-org-picker__label-secondary{color:#64748b;font-size:12px;line-height:20px}
|
||||
.baron-org-picker input[type="checkbox"],.baron-org-picker input[type="radio"]{accent-color:#24449c}
|
||||
.baron-org-picker__empty{padding:28px 12px;color:#64748b;text-align:center}
|
||||
.baron-org-picker__toggle,.baron-org-picker__toggle-placeholder{display:grid;width:24px;height:24px;flex:0 0 24px;place-items:center;border:0;border-radius:4px;background:transparent;color:#64748b;font:inherit;line-height:1}
|
||||
.baron-org-picker__toggle{cursor:pointer}
|
||||
.baron-org-picker__toggle:hover{background:#e2e8f0}
|
||||
.baron-org-picker__chevron{position:relative;display:block;width:16px;height:16px;color:currentColor}
|
||||
.baron-org-picker__chevron::before{content:"";position:absolute;width:6px;height:6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor}
|
||||
.baron-org-picker__chevron--open::before{left:4px;top:3px;transform:rotate(45deg)}
|
||||
.baron-org-picker__chevron--closed::before{left:3px;top:4px;transform:rotate(-45deg)}
|
||||
.baron-org-picker__select{min-width:0;flex:1;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;outline:none;padding:0 4px}
|
||||
.baron-org-picker__select:focus-visible{box-shadow:0 0 0 2px #3a98e5}
|
||||
.baron-org-picker__footer{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid #e5e7eb;background:#fff;padding:8px 12px}
|
||||
.baron-org-picker__actions{display:flex;align-items:center;gap:8px}
|
||||
.baron-org-picker__button{display:inline-flex;height:36px;align-items:center;justify-content:center;gap:8px;white-space:nowrap;border-radius:6px;border:1px solid #e5e7eb;background:#fff;color:#0f172a;padding:0 12px;font:inherit;font-size:14px;font-weight:600;cursor:pointer}
|
||||
.baron-org-picker__button:hover{background:#f4b840;color:#0f172a}
|
||||
.baron-org-picker__button--primary{border-color:#3a98e5;background:#3a98e5;color:#fff;box-shadow:0 1px 2px rgba(15,23,42,.12)}
|
||||
.baron-org-picker__button--primary:hover{background:#2588d8;color:#fff}
|
||||
.baron-org-picker__button:disabled{cursor:not-allowed;opacity:.5}
|
||||
.baron-org-picker--orgfront{--boc-background:hsl(var(--background,0 0% 98%));--boc-foreground:hsl(var(--foreground,223 25% 12%));--boc-primary:hsl(var(--primary,209 79% 52%));--boc-secondary:hsl(var(--secondary,220 17% 94%));--boc-muted-foreground:hsl(var(--muted-foreground,223 15% 45%));--boc-accent:hsl(var(--accent,40 96% 62%));--boc-accent-foreground:hsl(var(--accent-foreground,223 25% 12%));--boc-border:hsl(var(--border,220 17% 90%));--boc-input:hsl(var(--input,220 17% 90%));--boc-ring:hsl(var(--ring,209 79% 52%));display:flex;height:100%;min-height:320px;max-width:none;flex-direction:column;border:0;border-radius:0;background:var(--boc-background);box-shadow:none;color:var(--boc-foreground);overflow:hidden}
|
||||
.baron-org-picker--orgfront .baron-org-picker__toolbar{display:block;border-bottom:1px solid var(--boc-border);background:var(--boc-background);padding:8px}
|
||||
.baron-org-picker--orgfront .baron-org-picker__toolbar-grid{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:8px}
|
||||
.baron-org-picker--orgfront .baron-org-picker__search{height:36px;border-color:var(--boc-input);border-radius:6px;background:var(--boc-background);padding:0 12px 0 36px;font-size:14px}
|
||||
.baron-org-picker--orgfront .baron-org-picker__search:focus{border-color:var(--boc-input);box-shadow:0 0 0 2px var(--boc-ring)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__controls{height:36px;font-size:14px}
|
||||
.baron-org-picker--orgfront .baron-org-picker__descendants{height:36px;font-size:14px}
|
||||
.baron-org-picker--orgfront .baron-org-picker__list{min-height:0;flex:1;max-height:none;overflow:auto;padding:12px}
|
||||
.baron-org-picker--orgfront .baron-org-picker__children{margin-left:16px;border-left:0}
|
||||
.baron-org-picker--orgfront .baron-org-picker__row{min-height:28px;gap:6px;border-radius:4px;padding:2px 6px 2px 4px;transition:background-color .15s,color .15s,box-shadow .15s}
|
||||
.baron-org-picker--orgfront .baron-org-picker__row:hover{background:color-mix(in srgb,var(--boc-secondary) 50%,transparent)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__row--selected{background:color-mix(in srgb,var(--boc-primary) 15%,transparent);box-shadow:0 0 0 2px color-mix(in srgb,var(--boc-primary) 60%,transparent);color:var(--boc-foreground)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__row--member{font-size:14px;color:var(--boc-foreground)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__footer{border-top-color:var(--boc-border);background:var(--boc-background)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__summary{font-size:14px;color:var(--boc-muted-foreground)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__button{border-color:var(--boc-input);background:var(--boc-background);color:var(--boc-foreground)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__button:hover{background:var(--boc-accent);color:var(--boc-accent-foreground)}
|
||||
.baron-org-picker--orgfront .baron-org-picker__button--primary{border-color:var(--boc-primary);background:var(--boc-primary);color:#fff}
|
||||
.baron-org-picker--orgfront .baron-org-picker__empty{margin:12px;min-height:160px;border:1px dashed var(--boc-border);border-radius:6px;background:var(--boc-background);display:grid;place-items:center}
|
||||
`;
|
||||
|
||||
export function createOrgContextClient(options: OrgContextClientOptions) {
|
||||
const fetcher = options.fetch ?? globalThis.fetch;
|
||||
if (!fetcher) {
|
||||
throw new Error("A fetch implementation is required.");
|
||||
}
|
||||
|
||||
return {
|
||||
async fetchOrgContext(
|
||||
query: FetchOrgContextOptions = {},
|
||||
): Promise<OrgContextResponse> {
|
||||
const url = new URL(API_PATH, normalizeBaseUrl(options.baseUrl));
|
||||
appendQuery(url, "tenantSlug", query.tenantSlug);
|
||||
appendQuery(url, "includeUsers", query.includeUsers);
|
||||
appendQuery(url, "includeUserIds", query.includeUserIds);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
...options.headers,
|
||||
};
|
||||
if (options.credentials) {
|
||||
headers["X-Baron-Key-ID"] = options.credentials.keyId;
|
||||
headers["X-Baron-Key-Secret"] = options.credentials.keySecret;
|
||||
}
|
||||
|
||||
const response = await fetcher(url.toString(), { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Org context request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as OrgContextResponse;
|
||||
if (payload.schemaVersion !== "baron.org-context.v1") {
|
||||
throw new Error(
|
||||
`Unsupported org context schema: ${payload.schemaVersion}`,
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOrgChartModel(
|
||||
response: OrgContextResponse,
|
||||
): OrgChartModel {
|
||||
if (response.schemaVersion !== "baron.org-context.v1") {
|
||||
throw new Error(
|
||||
`Unsupported org context schema: ${response.schemaVersion}`,
|
||||
);
|
||||
}
|
||||
|
||||
const nodes: OrgChartNode[] = [];
|
||||
const tenantsById = new Map<string, OrgChartNode>();
|
||||
const tenantsBySlug = new Map<string, OrgChartNode>();
|
||||
const membersByEmail = new Map<string, OrgChartMember>();
|
||||
|
||||
const visit = (
|
||||
node: OrgContextTreeNode,
|
||||
depth: number,
|
||||
ancestorPath: string[],
|
||||
): OrgChartNode => {
|
||||
const path = [...ancestorPath, node.id];
|
||||
const chartNode: OrgChartNode = {
|
||||
...node,
|
||||
children: [],
|
||||
depth,
|
||||
path,
|
||||
};
|
||||
nodes.push(chartNode);
|
||||
tenantsById.set(chartNode.id, chartNode);
|
||||
tenantsBySlug.set(chartNode.slug, chartNode);
|
||||
|
||||
for (const member of chartNode.members) {
|
||||
const key = member.email.toLowerCase();
|
||||
const existing = membersByEmail.get(key);
|
||||
if (existing) {
|
||||
existing.tenantIds.push(chartNode.id);
|
||||
} else {
|
||||
membersByEmail.set(key, {
|
||||
...member,
|
||||
tenantIds: [chartNode.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
chartNode.children = node.children.map((child) =>
|
||||
visit(child, depth + 1, path),
|
||||
);
|
||||
return chartNode;
|
||||
};
|
||||
|
||||
return {
|
||||
root: visit(response.tree, 0, []),
|
||||
nodes,
|
||||
tenantsById,
|
||||
tenantsBySlug,
|
||||
membersByEmail,
|
||||
response,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderOrgChart(
|
||||
container: HTMLElement,
|
||||
model: OrgChartModel,
|
||||
): { destroy: () => void } {
|
||||
ensureDefaultStyles();
|
||||
container.replaceChildren();
|
||||
container.classList.add("baron-org-chart");
|
||||
const root = document.createElement("div");
|
||||
root.className = "baron-org-chart__tree";
|
||||
root.append(renderChartNode(model.root));
|
||||
container.append(root);
|
||||
return {
|
||||
destroy() {
|
||||
container.replaceChildren();
|
||||
container.classList.remove("baron-org-chart");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renderOrgPicker(
|
||||
container: HTMLElement,
|
||||
model: OrgChartModel,
|
||||
options: OrgPickerOptions = {},
|
||||
): OrgPickerController {
|
||||
if (options.injectStyles !== false) {
|
||||
ensureDefaultStyles();
|
||||
}
|
||||
const mode = options.mode ?? "single";
|
||||
const selectable = options.selectable ?? "tenant";
|
||||
const variant = options.variant ?? "default";
|
||||
const isOrgfront = variant === "orgfront";
|
||||
let includeDescendants =
|
||||
options.includeDescendants ?? (isOrgfront && mode === "multiple");
|
||||
let searchQuery = "";
|
||||
const selected = new Map<string, OrgPickerSelection>();
|
||||
const expanded = new Set(model.nodes.map((node) => node.id));
|
||||
const showDescendantToggle = options.showDescendantToggle ?? true;
|
||||
|
||||
const currentSelection = () => Array.from(selected.values());
|
||||
|
||||
const emitChange = () => {
|
||||
const selection = currentSelection();
|
||||
options.onChange?.(selection);
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("baron-org-picker-change", {
|
||||
bubbles: true,
|
||||
detail: { selection },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const emitConfirm = () => {
|
||||
const selection = currentSelection();
|
||||
options.onConfirm?.(selection);
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("baron-org-picker-confirm", {
|
||||
bubbles: true,
|
||||
detail: { selection },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const emitCancel = () => {
|
||||
options.onCancel?.();
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("baron-org-picker-cancel", {
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleSelection = (
|
||||
selection: OrgPickerSelection,
|
||||
checked: boolean,
|
||||
descendants: OrgPickerSelection[],
|
||||
) => {
|
||||
if (mode === "single") {
|
||||
selected.clear();
|
||||
if (checked) {
|
||||
selected.set(selectionKey(selection), selection);
|
||||
}
|
||||
emitChange();
|
||||
rerender();
|
||||
return;
|
||||
}
|
||||
|
||||
const targets =
|
||||
includeDescendants && selection.type === "tenant"
|
||||
? [selection, ...descendants]
|
||||
: [selection];
|
||||
for (const target of targets) {
|
||||
if (checked) {
|
||||
selected.set(selectionKey(target), target);
|
||||
} else {
|
||||
selected.delete(selectionKey(target));
|
||||
}
|
||||
}
|
||||
emitChange();
|
||||
rerender();
|
||||
};
|
||||
|
||||
const renderPickerNode = (node: OrgChartNode): HTMLElement => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "baron-org-picker__item";
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
const tenantSelection: OrgPickerSelection = {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
type: "tenant",
|
||||
};
|
||||
const row = document.createElement(isOrgfront ? "div" : "label");
|
||||
row.className = "baron-org-picker__row";
|
||||
if (selected.has(selectionKey(tenantSelection))) {
|
||||
row.classList.add("baron-org-picker__row--selected");
|
||||
}
|
||||
row.style.paddingLeft = `${node.depth * 16}px`;
|
||||
|
||||
if (isOrgfront) {
|
||||
row.append(createExpandToggle(node, hasChildren));
|
||||
appendOrgfrontSelectionControl(row, node, tenantSelection);
|
||||
} else {
|
||||
if (selectable === "tenant" || selectable === "both") {
|
||||
row.append(
|
||||
createPickerInput({
|
||||
mode,
|
||||
selection: tenantSelection,
|
||||
selected,
|
||||
onToggle: (checked) =>
|
||||
toggleSelection(
|
||||
tenantSelection,
|
||||
checked,
|
||||
collectDescendantSelections(node, selectable),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
row.append(createLabelText(node.name, node.type));
|
||||
}
|
||||
item.append(row);
|
||||
|
||||
if (selectable === "user" || selectable === "both") {
|
||||
for (const member of node.members) {
|
||||
item.append(
|
||||
renderMemberPickerRow(
|
||||
member,
|
||||
node,
|
||||
mode,
|
||||
selected,
|
||||
(value) =>
|
||||
toggleSelection(value, !selected.has(selectionKey(value)), []),
|
||||
isOrgfront,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChildren && (!isOrgfront || expanded.has(node.id))) {
|
||||
const children = document.createElement("ul");
|
||||
children.className = "baron-org-picker__children";
|
||||
for (const child of node.children) {
|
||||
children.append(renderPickerNode(child));
|
||||
}
|
||||
item.append(children);
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
const appendOrgfrontSelectionControl = (
|
||||
row: HTMLElement,
|
||||
node: OrgChartNode,
|
||||
tenantSelection: OrgPickerSelection,
|
||||
) => {
|
||||
const canSelect = selectable === "tenant" || selectable === "both";
|
||||
if (!canSelect) {
|
||||
row.append(createOrgfrontLabelText(node.name));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "multiple") {
|
||||
row.append(
|
||||
createPickerInput({
|
||||
mode,
|
||||
selection: tenantSelection,
|
||||
selected,
|
||||
onToggle: (checked) =>
|
||||
toggleSelection(
|
||||
tenantSelection,
|
||||
checked,
|
||||
collectDescendantSelections(node, selectable),
|
||||
),
|
||||
}),
|
||||
);
|
||||
row.append(createOrgfrontLabelText(node.name));
|
||||
return;
|
||||
}
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.className = "baron-org-picker__select";
|
||||
button.dataset.baronOrgPickerValue = selectionKey(tenantSelection);
|
||||
button.type = "button";
|
||||
button.ariaPressed = String(selected.has(selectionKey(tenantSelection)));
|
||||
button.addEventListener("click", () =>
|
||||
toggleSelection(
|
||||
tenantSelection,
|
||||
true,
|
||||
collectDescendantSelections(node, selectable),
|
||||
),
|
||||
);
|
||||
button.append(createOrgfrontLabelText(node.name));
|
||||
row.append(button);
|
||||
};
|
||||
|
||||
const createExpandToggle = (node: OrgChartNode, hasChildren: boolean) => {
|
||||
if (!hasChildren) {
|
||||
const placeholder = document.createElement("span");
|
||||
placeholder.className = "baron-org-picker__toggle-placeholder";
|
||||
placeholder.ariaHidden = "true";
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
const toggle = document.createElement("button");
|
||||
toggle.className = "baron-org-picker__toggle";
|
||||
toggle.dataset.baronOrgPickerToggle = selectionKey({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
type: "tenant",
|
||||
});
|
||||
toggle.type = "button";
|
||||
const chevron = document.createElement("span");
|
||||
chevron.className = expanded.has(node.id)
|
||||
? "baron-org-picker__chevron baron-org-picker__chevron--open"
|
||||
: "baron-org-picker__chevron baron-org-picker__chevron--closed";
|
||||
chevron.dataset.baronOrgPickerChevron = "true";
|
||||
chevron.ariaHidden = "true";
|
||||
toggle.append(chevron);
|
||||
toggle.ariaLabel = `${node.name} ${expanded.has(node.id) ? "접기" : "펼치기"}`;
|
||||
toggle.addEventListener("click", () => {
|
||||
if (expanded.has(node.id)) {
|
||||
expanded.delete(node.id);
|
||||
} else {
|
||||
expanded.add(node.id);
|
||||
}
|
||||
rerender();
|
||||
});
|
||||
return toggle;
|
||||
};
|
||||
|
||||
const rerender = () => {
|
||||
container.replaceChildren();
|
||||
container.classList.add("baron-org-picker");
|
||||
container.classList.toggle("baron-org-picker--orgfront", isOrgfront);
|
||||
container.append(renderPickerToolbar());
|
||||
|
||||
const visibleRoot = filterOrgChartNode(model.root, searchQuery, selectable);
|
||||
if (!visibleRoot) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "baron-org-picker__empty";
|
||||
empty.textContent = isOrgfront
|
||||
? "검색 결과가 없습니다."
|
||||
: "No matching organization or member.";
|
||||
container.append(empty);
|
||||
if (isOrgfront) {
|
||||
container.append(renderPickerFooter());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "baron-org-picker__list";
|
||||
list.append(renderPickerNode(visibleRoot));
|
||||
container.append(list);
|
||||
if (isOrgfront) {
|
||||
container.append(renderPickerFooter());
|
||||
}
|
||||
};
|
||||
|
||||
const renderPickerToolbar = () => {
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "baron-org-picker__toolbar";
|
||||
const toolbarContent = isOrgfront ? document.createElement("div") : toolbar;
|
||||
if (isOrgfront) {
|
||||
toolbarContent.className = "baron-org-picker__toolbar-grid";
|
||||
toolbar.append(toolbarContent);
|
||||
}
|
||||
|
||||
const searchWrap = document.createElement("div");
|
||||
searchWrap.className = "baron-org-picker__search-wrap";
|
||||
if (isOrgfront) {
|
||||
const searchIcon = document.createElement("span");
|
||||
searchIcon.className = "baron-org-picker__search-icon";
|
||||
searchIcon.dataset.baronOrgPickerSearchIcon = "true";
|
||||
searchIcon.ariaHidden = "true";
|
||||
searchWrap.append(searchIcon);
|
||||
}
|
||||
const search = document.createElement("input");
|
||||
search.className = "baron-org-picker__search";
|
||||
search.dataset.baronOrgPickerSearch = "true";
|
||||
search.placeholder = isOrgfront
|
||||
? "ID, 이름, 이메일, 메타데이터"
|
||||
: "Search organization or member";
|
||||
search.type = "search";
|
||||
search.value = searchQuery;
|
||||
search.addEventListener("input", () => {
|
||||
searchQuery = search.value;
|
||||
rerender();
|
||||
const nextSearch = container.querySelector<HTMLInputElement>(
|
||||
"[data-baron-org-picker-search]",
|
||||
);
|
||||
nextSearch?.focus();
|
||||
nextSearch?.setSelectionRange(searchQuery.length, searchQuery.length);
|
||||
});
|
||||
searchWrap.append(search);
|
||||
toolbarContent.append(searchWrap);
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "baron-org-picker__controls";
|
||||
if (mode === "multiple" && selectable !== "user" && showDescendantToggle) {
|
||||
const descendantsLabel = document.createElement("label");
|
||||
descendantsLabel.className = "baron-org-picker__descendants";
|
||||
const descendants = document.createElement("input");
|
||||
descendants.dataset.baronOrgPickerDescendants = "true";
|
||||
descendants.type = "checkbox";
|
||||
descendants.checked = includeDescendants;
|
||||
const updateDescendantSelection = () => {
|
||||
includeDescendants = descendants.checked;
|
||||
rerender();
|
||||
};
|
||||
descendants.addEventListener("change", updateDescendantSelection);
|
||||
descendants.addEventListener("click", updateDescendantSelection);
|
||||
descendantsLabel.append(
|
||||
descendants,
|
||||
isOrgfront ? "하위 선택" : "Include descendants",
|
||||
);
|
||||
controls.append(descendantsLabel);
|
||||
} else if (!isOrgfront) {
|
||||
controls.append(document.createElement("span"));
|
||||
}
|
||||
|
||||
if (!isOrgfront) {
|
||||
const summary = document.createElement("span");
|
||||
summary.className = "baron-org-picker__summary";
|
||||
summary.dataset.baronOrgPickerSummary = "true";
|
||||
summary.textContent = `${selected.size} selected`;
|
||||
controls.append(summary);
|
||||
|
||||
if (selected.size > 0) {
|
||||
const clear = document.createElement("button");
|
||||
clear.className = "baron-org-picker__clear";
|
||||
clear.type = "button";
|
||||
clear.textContent = "Clear";
|
||||
clear.addEventListener("click", () => {
|
||||
selected.clear();
|
||||
emitChange();
|
||||
rerender();
|
||||
});
|
||||
controls.append(clear);
|
||||
}
|
||||
}
|
||||
|
||||
toolbarContent.append(controls);
|
||||
return toolbar;
|
||||
};
|
||||
|
||||
const renderPickerFooter = () => {
|
||||
const footer = document.createElement("footer");
|
||||
footer.className = "baron-org-picker__footer";
|
||||
footer.dataset.baronOrgPickerFooter = "true";
|
||||
|
||||
const summary = document.createElement("div");
|
||||
summary.className = "baron-org-picker__summary";
|
||||
summary.dataset.baronOrgPickerSummary = "true";
|
||||
summary.textContent =
|
||||
selected.size > 0
|
||||
? `${selected.size}개 항목 선택됨`
|
||||
: "선택된 항목이 없습니다.";
|
||||
footer.append(summary);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "baron-org-picker__actions";
|
||||
|
||||
const cancel = document.createElement("button");
|
||||
cancel.className = "baron-org-picker__button";
|
||||
cancel.dataset.baronOrgPickerCancel = "true";
|
||||
cancel.type = "button";
|
||||
cancel.textContent = "취소";
|
||||
cancel.addEventListener("click", emitCancel);
|
||||
actions.append(cancel);
|
||||
|
||||
const confirm = document.createElement("button");
|
||||
confirm.className =
|
||||
"baron-org-picker__button baron-org-picker__button--primary";
|
||||
confirm.dataset.baronOrgPickerConfirm = "true";
|
||||
confirm.disabled = selected.size === 0;
|
||||
confirm.type = "button";
|
||||
confirm.textContent = "선택 완료";
|
||||
confirm.addEventListener("click", emitConfirm);
|
||||
actions.append(confirm);
|
||||
|
||||
footer.append(actions);
|
||||
return footer;
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
return {
|
||||
cancel: emitCancel,
|
||||
confirm: emitConfirm,
|
||||
destroy() {
|
||||
container.replaceChildren();
|
||||
container.classList.remove(
|
||||
"baron-org-picker",
|
||||
"baron-org-picker--orgfront",
|
||||
);
|
||||
selected.clear();
|
||||
},
|
||||
getSelection() {
|
||||
return currentSelection();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function appendQuery(
|
||||
url: URL,
|
||||
key: string,
|
||||
value: string | boolean | undefined,
|
||||
) {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string) {
|
||||
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
}
|
||||
|
||||
function ensureDefaultStyles() {
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.getElementById(DEFAULT_STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = DEFAULT_STYLE_ID;
|
||||
style.dataset.baronOrgContextChartStyle = "default";
|
||||
style.textContent = DEFAULT_STYLE;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
function renderChartNode(node: OrgChartNode): HTMLElement {
|
||||
const item = document.createElement("section");
|
||||
item.className = "baron-org-chart__node";
|
||||
item.dataset.baronOrgNode = node.id;
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.className = "baron-org-chart__title";
|
||||
title.textContent = node.name;
|
||||
item.append(title);
|
||||
|
||||
const meta = document.createElement("p");
|
||||
meta.className = "baron-org-chart__meta";
|
||||
meta.textContent = [node.type, node.orgUnitType, node.visibility]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
item.append(meta);
|
||||
|
||||
if (node.members.length > 0) {
|
||||
const memberList = document.createElement("ul");
|
||||
memberList.className = "baron-org-chart__members";
|
||||
for (const member of node.members) {
|
||||
const memberItem = document.createElement("li");
|
||||
memberItem.textContent = formatMember(member);
|
||||
memberList.append(memberItem);
|
||||
}
|
||||
item.append(memberList);
|
||||
}
|
||||
|
||||
if (node.children.length > 0) {
|
||||
const children = document.createElement("div");
|
||||
children.className = "baron-org-chart__children";
|
||||
for (const child of node.children) {
|
||||
children.append(renderChartNode(child));
|
||||
}
|
||||
item.append(children);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderMemberPickerRow(
|
||||
member: OrgContextMember,
|
||||
node: OrgChartNode,
|
||||
mode: "single" | "multiple",
|
||||
selected: Map<string, OrgPickerSelection>,
|
||||
onSelect: (selection: OrgPickerSelection) => void,
|
||||
isOrgfront = false,
|
||||
) {
|
||||
const selection: OrgPickerSelection = {
|
||||
id: member.id || `${node.id}:${member.email}`,
|
||||
name: member.name,
|
||||
type: "user",
|
||||
};
|
||||
const row = document.createElement(isOrgfront ? "div" : "label");
|
||||
row.className = "baron-org-picker__row baron-org-picker__row--member";
|
||||
if (selected.has(selectionKey(selection))) {
|
||||
row.classList.add("baron-org-picker__row--selected");
|
||||
}
|
||||
row.style.paddingLeft = `${node.depth * 16 + 24}px`;
|
||||
if (isOrgfront && mode === "single") {
|
||||
row.append(createOrgfrontMemberSpacer());
|
||||
const button = document.createElement("button");
|
||||
button.className = "baron-org-picker__select";
|
||||
button.dataset.baronOrgPickerValue = selectionKey(selection);
|
||||
button.type = "button";
|
||||
button.ariaPressed = String(selected.has(selectionKey(selection)));
|
||||
button.addEventListener("click", () => onSelect(selection));
|
||||
button.append(createOrgfrontLabelText(member.name, member.email));
|
||||
row.append(button);
|
||||
return row;
|
||||
}
|
||||
|
||||
if (isOrgfront) {
|
||||
row.append(createOrgfrontMemberSpacer());
|
||||
}
|
||||
row.append(
|
||||
createPickerInput({
|
||||
mode,
|
||||
selection,
|
||||
selected,
|
||||
onToggle: () => onSelect(selection),
|
||||
}),
|
||||
);
|
||||
row.append(
|
||||
isOrgfront
|
||||
? createOrgfrontLabelText(member.name, member.email)
|
||||
: createLabelText(member.name, member.email),
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
function createPickerInput({
|
||||
mode,
|
||||
selection,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
mode: "single" | "multiple";
|
||||
selection: OrgPickerSelection;
|
||||
selected: Map<string, OrgPickerSelection>;
|
||||
onToggle: (checked: boolean) => void;
|
||||
}) {
|
||||
const input = document.createElement("input");
|
||||
input.type = mode === "single" ? "radio" : "checkbox";
|
||||
input.name = "baron-org-picker";
|
||||
input.value = selectionKey(selection);
|
||||
input.checked = selected.has(selectionKey(selection));
|
||||
const handleToggle = () => {
|
||||
onToggle(mode === "single" ? true : !selected.has(selectionKey(selection)));
|
||||
};
|
||||
input.addEventListener("click", handleToggle);
|
||||
return input;
|
||||
}
|
||||
|
||||
function createLabelText(primary: string, secondary?: string) {
|
||||
const text = document.createElement("span");
|
||||
text.className = "baron-org-picker__label";
|
||||
text.textContent = secondary ? `${primary} (${secondary})` : primary;
|
||||
return text;
|
||||
}
|
||||
|
||||
function createOrgfrontMemberSpacer() {
|
||||
const spacer = document.createElement("span");
|
||||
spacer.className = "baron-org-picker__toggle-placeholder";
|
||||
spacer.ariaHidden = "true";
|
||||
return spacer;
|
||||
}
|
||||
|
||||
function createOrgfrontLabelText(primary: string, secondary?: string) {
|
||||
const text = document.createElement("span");
|
||||
text.className = "baron-org-picker__label";
|
||||
const primaryText = document.createElement("span");
|
||||
primaryText.className = "baron-org-picker__label-primary";
|
||||
primaryText.textContent = primary;
|
||||
text.append(primaryText);
|
||||
if (secondary) {
|
||||
const secondaryText = document.createElement("span");
|
||||
secondaryText.className = "baron-org-picker__label-secondary";
|
||||
secondaryText.textContent = secondary;
|
||||
text.append(secondaryText);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function filterOrgChartNode(
|
||||
node: OrgChartNode,
|
||||
rawQuery: string,
|
||||
selectable: "tenant" | "user" | "both",
|
||||
): OrgChartNode | null {
|
||||
const query = rawQuery.trim().toLowerCase();
|
||||
if (!query) return node;
|
||||
|
||||
const childMatches = node.children
|
||||
.map((child) => filterOrgChartNode(child, rawQuery, selectable))
|
||||
.filter((child): child is OrgChartNode => Boolean(child));
|
||||
const tenantMatch = orgTenantMatchesSearch(node, query, selectable);
|
||||
const matchingMembers = orgMemberMatchesSearch(node, query, selectable);
|
||||
if (
|
||||
!tenantMatch &&
|
||||
matchingMembers.length === 0 &&
|
||||
childMatches.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
members: tenantMatch ? node.members : matchingMembers,
|
||||
children: childMatches,
|
||||
};
|
||||
}
|
||||
|
||||
function orgTenantMatchesSearch(
|
||||
node: OrgChartNode,
|
||||
query: string,
|
||||
selectable: "tenant" | "user" | "both",
|
||||
) {
|
||||
const tenantValues = [
|
||||
node.id,
|
||||
node.name,
|
||||
node.slug,
|
||||
node.type,
|
||||
node.orgUnitType ?? "",
|
||||
node.visibility,
|
||||
...node.domains,
|
||||
];
|
||||
if (
|
||||
selectable !== "user" &&
|
||||
tenantValues.some((value) => value.toLowerCase().includes(query))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (selectable === "tenant") {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function orgMemberMatchesSearch(
|
||||
node: OrgChartNode,
|
||||
query: string,
|
||||
selectable: "tenant" | "user" | "both",
|
||||
) {
|
||||
if (selectable === "tenant") {
|
||||
return [];
|
||||
}
|
||||
return node.members.filter((member) =>
|
||||
[
|
||||
member.id ?? "",
|
||||
member.email,
|
||||
member.name,
|
||||
member.department ?? "",
|
||||
member.grade ?? "",
|
||||
member.position ?? "",
|
||||
member.jobTitle ?? "",
|
||||
].some((value) => value.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
function collectDescendantSelections(
|
||||
node: OrgChartNode,
|
||||
selectable: "tenant" | "user" | "both",
|
||||
): OrgPickerSelection[] {
|
||||
const selections: OrgPickerSelection[] = [];
|
||||
const visit = (child: OrgChartNode) => {
|
||||
if (selectable === "tenant" || selectable === "both") {
|
||||
selections.push({ id: child.id, name: child.name, type: "tenant" });
|
||||
}
|
||||
if (selectable === "user" || selectable === "both") {
|
||||
for (const member of child.members) {
|
||||
selections.push({
|
||||
id: member.id || `${child.id}:${member.email}`,
|
||||
name: member.name,
|
||||
type: "user",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const grandchild of child.children) {
|
||||
visit(grandchild);
|
||||
}
|
||||
};
|
||||
for (const child of node.children) {
|
||||
visit(child);
|
||||
}
|
||||
return selections;
|
||||
}
|
||||
|
||||
function selectionKey(selection: OrgPickerSelection) {
|
||||
return `${selection.type}:${selection.id}`;
|
||||
}
|
||||
|
||||
function formatMember(member: OrgContextMember) {
|
||||
return [
|
||||
member.name,
|
||||
member.position,
|
||||
member.jobTitle,
|
||||
member.isLeader || member.isOwner ? "조직장" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
@@ -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
baron-sso/orgfront/tailwind.config.ts
Normal file
14
baron-sso/orgfront/tailwind.config.ts
Normal 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
baron-sso/orgfront/tests/clients.spec.ts
Normal file
39
baron-sso/orgfront/tests/clients.spec.ts
Normal 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();
|
||||
});
|
||||
120
baron-sso/orgfront/tests/devfront-audit.spec.ts
Normal file
120
baron-sso/orgfront/tests/devfront-audit.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
type AuditLog,
|
||||
type Consent,
|
||||
installDevApiMock,
|
||||
makeClient,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
|
||||
const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i;
|
||||
|
||||
test.describe("DevFront audit logs", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("dialog", async (dialog) => {
|
||||
await dialog.accept().catch(() => {});
|
||||
});
|
||||
await seedAuth(page);
|
||||
});
|
||||
|
||||
test("filtering and cursor pagination", async ({ page }) => {
|
||||
const state = {
|
||||
clients: [makeClient("client-audit", { name: "Audit app" })],
|
||||
consents: [] as Consent[],
|
||||
auditLogsByCursor: {
|
||||
"": {
|
||||
items: [
|
||||
{
|
||||
event_id: "evt-1",
|
||||
timestamp: "2026-03-03T09:00:00.000Z",
|
||||
user_id: "actor-a",
|
||||
event_type: "CLIENT_UPDATE",
|
||||
status: "success",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "playwright",
|
||||
details: JSON.stringify({
|
||||
action: "UPDATE_CLIENT",
|
||||
target_id: "client-audit",
|
||||
tenant_id: "tenant-a",
|
||||
}),
|
||||
},
|
||||
],
|
||||
next_cursor: "cursor-2",
|
||||
},
|
||||
"cursor-2": {
|
||||
items: [
|
||||
{
|
||||
event_id: "evt-2",
|
||||
timestamp: "2026-03-03T09:01:00.000Z",
|
||||
user_id: "actor-b",
|
||||
event_type: "CLIENT_ROTATE_SECRET",
|
||||
status: "success",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "playwright",
|
||||
details: JSON.stringify({
|
||||
action: "ROTATE_SECRET",
|
||||
target_id: "client-audit",
|
||||
tenant_id: "tenant-a",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/audit-logs");
|
||||
await expect(page.getByText("UPDATE_CLIENT")).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByPlaceholder(/Client ID로 필터|Filter by Client ID/i)
|
||||
.fill("client-audit");
|
||||
await page
|
||||
.getByPlaceholder(/액션으로 필터|Filter by Action/i)
|
||||
.fill("ROTATE_SECRET");
|
||||
|
||||
await page.getByRole("button", { name: /더 보기|Load more/i }).click();
|
||||
await expect(page.getByText("ROTATE_SECRET")).toBeVisible();
|
||||
});
|
||||
|
||||
test("realtime create/update actions should be recorded", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
clients: [makeClient("client-realtime", { name: "Realtime app" })],
|
||||
consents: [] as Consent[],
|
||||
auditLogs: [] as AuditLog[],
|
||||
auditLogsByCursor: undefined,
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients/new");
|
||||
await page.getByPlaceholder(appNamePlaceholder).fill("Realtime New App");
|
||||
await page
|
||||
.getByPlaceholder(/https:\/\/app\.example\.com\/callback/i)
|
||||
.fill("https://realtime.example.com/callback");
|
||||
await page.getByRole("button", { name: /앱 생성|Create/i }).click();
|
||||
await expect.poll(() => state.auditLogs.length).toBeGreaterThanOrEqual(1);
|
||||
await expect.poll(() => state.clients.at(-1)?.id).toMatch(/^client-/);
|
||||
const createdClientId = state.clients.at(-1)?.id;
|
||||
expect(createdClientId).toBeTruthy();
|
||||
|
||||
await page.goto(`/clients/${createdClientId}/settings`);
|
||||
await page.getByPlaceholder(appNamePlaceholder).fill("Realtime Updated");
|
||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||
await expect.poll(() => state.auditLogs.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const actions = state.auditLogs
|
||||
.map((item) => {
|
||||
try {
|
||||
return JSON.parse(item.details)?.action as string | undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
expect(actions).toContain("CREATE_CLIENT");
|
||||
expect(actions).toContain("UPDATE_CLIENT");
|
||||
});
|
||||
});
|
||||
374
baron-sso/orgfront/tests/devfront-clients-lifecycle.spec.ts
Normal file
374
baron-sso/orgfront/tests/devfront-clients-lifecycle.spec.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
type ClientStatus,
|
||||
type Consent,
|
||||
installDevApiMock,
|
||||
makeClient,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
|
||||
const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i;
|
||||
const jwksUri = "https://rp.example.com/.well-known/jwks.json";
|
||||
|
||||
test.describe("DevFront clients lifecycle", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
await seedAuth(page);
|
||||
});
|
||||
|
||||
test("create, update status, and delete", async ({ page }) => {
|
||||
const state = {
|
||||
clients: [makeClient("existing-client", { name: "Existing app" })],
|
||||
consents: [] as Consent[],
|
||||
updatedStatus: "active" as ClientStatus,
|
||||
auditLogsByCursor: undefined,
|
||||
onUpdateStatus(status: ClientStatus) {
|
||||
this.updatedStatus = status;
|
||||
},
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients");
|
||||
await expect(page.getByText("Existing app")).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /연동 앱 추가|새 클라이언트|Create/i })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/clients\/new$/);
|
||||
|
||||
await page
|
||||
.getByPlaceholder(appNamePlaceholder)
|
||||
.fill("Playwright Created App");
|
||||
await page
|
||||
.getByPlaceholder(/https:\/\/app\.example\.com\/callback/i)
|
||||
.fill("https://playwright.example.com/callback");
|
||||
await page
|
||||
.getByRole("button", { name: /앱 생성|클라이언트 생성|Create/i })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/clients\/client-\d+\/settings$/);
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: /연동 앱 설정|클라이언트 설정|Client Settings/i,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /비활성|Inactive/i }).click();
|
||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||
await expect.poll(() => state.updatedStatus).toBe("inactive");
|
||||
|
||||
await page.getByRole("button", { name: /삭제|Delete/i }).click();
|
||||
await expect(page).toHaveURL(/\/clients$/);
|
||||
await expect(page.getByText("Playwright Created App")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("rotate secret shows new value", async ({ page }) => {
|
||||
let rotatedSecret = "";
|
||||
const state = {
|
||||
clients: [makeClient("client-rotate", { name: "Rotate app" })],
|
||||
consents: [] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
onRotateSecret(newSecret: string) {
|
||||
rotatedSecret = newSecret;
|
||||
},
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients/client-rotate");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Rotate app", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTitle(/비밀키 재발급|Rotate/i).click();
|
||||
await expect.poll(() => rotatedSecret).toBe("client-rotate-rotated-secret");
|
||||
await expect(page.getByText("client-rotate-rotated-secret")).toBeVisible();
|
||||
});
|
||||
|
||||
test("update name and redirect URI should be persisted", async ({ page }) => {
|
||||
const state = {
|
||||
clients: [
|
||||
makeClient("client-edit", {
|
||||
name: "Before Name",
|
||||
redirectUris: ["https://before.example.com/callback"],
|
||||
}),
|
||||
],
|
||||
consents: [] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients/client-edit/settings");
|
||||
await page.getByPlaceholder(appNamePlaceholder).fill("After Name");
|
||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||
await expect.poll(() => state.clients[0]?.name).toBe("After Name");
|
||||
|
||||
await page.goto("/clients/client-edit");
|
||||
await page
|
||||
.getByRole("textbox", { name: /인증 콜백 URL|Callback/i })
|
||||
.fill("https://after.example.com/callback");
|
||||
await page
|
||||
.getByRole("button", { name: /Redirect URIs 저장|Save/i })
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(() => state.clients[0]?.redirectUris[0])
|
||||
.toBe("https://after.example.com/callback");
|
||||
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: /인증 콜백 URL|Callback/i }),
|
||||
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
|
||||
});
|
||||
|
||||
test("pkce headless login uses jwks uri only and shows cache actions", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
clients: [
|
||||
makeClient("client-headless-login", {
|
||||
name: "Headless Login App",
|
||||
type: "pkce",
|
||||
metadata: {
|
||||
request_object_signing_alg: "RS256",
|
||||
},
|
||||
headlessJwksCache: {
|
||||
clientId: "client-headless-login",
|
||||
jwksUri,
|
||||
cachedAt: "2026-03-31T00:00:00.000Z",
|
||||
expiresAt: "2026-04-01T00:00:00.000Z",
|
||||
lastCheckedAt: "2026-03-31T12:00:00.000Z",
|
||||
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
|
||||
lastRefreshStatus: "success",
|
||||
lastError: "",
|
||||
consecutiveFailures: 0,
|
||||
cachedKids: ["kid-1"],
|
||||
etag: 'W/"cache-etag"',
|
||||
lastModified: "Tue, 31 Mar 2026 00:00:00 GMT",
|
||||
parsedKeys: [
|
||||
{
|
||||
kid: "kid-1",
|
||||
kty: "RSA",
|
||||
use: "sig",
|
||||
alg: "RS256",
|
||||
n: "voVbHlo_UHkjtT7Q_8owyjZ2omE8n8mbGlpraZziStHPfe08q_RGiEXO6Pyiz42NVi-Yo0c7qiaqRwB4h9s5phpT2wwcUxnkrQeRhe7BpigInZPzpwq1hsaB2zyhE7zTRCC3hinGtFdVpNzTVKYKGPbXfeEXaRL3P838vi-_iB4IN3WQk_pAakUQvajL2H-vcWSMSNslMGPDZxobqE9MHSWocNXemrcmtCeE7ruUND0qHZOb8k-hHUBqsNoJ63WKdapzGYF6e2qgDRveYrjgOCBigZPi8npN0xStQ0YcrH_RxeTogsdRZ8SuXmLqavryVDnrT8czPkkJ-EHb8PiTCQ",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
consents: [] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
onRefreshHeadlessJwks(clientId: string) {
|
||||
if (this.clients[0].headlessJwksCache) {
|
||||
this.clients[0].headlessJwksCache = {
|
||||
...this.clients[0].headlessJwksCache,
|
||||
lastRefreshStatus: "success",
|
||||
lastCheckedAt: "2026-04-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
expect(clientId).toBe("client-headless-login");
|
||||
},
|
||||
onRevokeHeadlessJwksCache(clientId: string) {
|
||||
expect(clientId).toBe("client-headless-login");
|
||||
},
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients/client-headless-login/settings");
|
||||
|
||||
await page
|
||||
.getByRole("switch", {
|
||||
name: /Headless Login \(자체 로그인 UI 사용\)|Headless Login \(Custom Login UI\)/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: /공개키 등록|Public Key Registration/i,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(/Request Object Signing Algorithm/i),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText(/Allowed algorithms|허용 알고리즘/i),
|
||||
).toHaveCount(0);
|
||||
await page
|
||||
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
|
||||
.fill(jwksUri);
|
||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => state.clients[0]?.tokenEndpointAuthMethod)
|
||||
.toBe("none");
|
||||
await expect
|
||||
.poll(() => state.clients[0]?.metadata?.headless_login_enabled)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
() => state.clients[0]?.metadata?.headless_token_endpoint_auth_method,
|
||||
)
|
||||
.toBe("private_key_jwt");
|
||||
await expect
|
||||
.poll(() => state.clients[0]?.metadata?.headless_jwks_uri)
|
||||
.toBe(jwksUri);
|
||||
await expect
|
||||
.poll(() => state.clients[0]?.metadata?.request_object_signing_alg)
|
||||
.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
page.getByText(/cached at|캐시됨|last refresh|마지막 갱신/i),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/Parsed Keys|파싱된 키/i)).toBeVisible();
|
||||
await expect(page.getByText(/^KID$/i)).toBeVisible();
|
||||
await expect(page.getByText("kid-1", { exact: true }).last()).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"voVbHlo_UHkjtT7Q_8owyjZ2omE8n8mbGlpraZziStHPfe08q_RGiEXO6Pyiz42NVi-Yo0c7qiaqRwB4h9s5phpT2wwcUxnkrQeRhe7BpigInZPzpwq1hsaB2zyhE7zTRCC3hinGtFdVpNzTVKYKGPbXfeEXaRL3P838vi-_iB4IN3WQk_pAakUQvajL2H-vcWSMSNslMGPDZxobqE9MHSWocNXemrcmtCeE7ruUND0qHZOb8k-hHUBqsNoJ63WKdapzGYF6e2qgDRveYrjgOCBigZPi8npN0xStQ0YcrH_RxeTogsdRZ8SuXmLqavryVDnrT8czPkkJ-EHb8PiTCQ",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /refresh|새로고침/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /refresh|새로고침/i }).click();
|
||||
await expect
|
||||
.poll(() => state.clients[0]?.headlessJwksCache?.lastCheckedAt)
|
||||
.toBe("2026-04-01T00:00:00.000Z");
|
||||
|
||||
page.removeAllListeners("dialog");
|
||||
page.once("dialog", async (dialog) => {
|
||||
expect(dialog.message()).toMatch(/revoke|삭제|cache/i);
|
||||
await dialog.accept();
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i })
|
||||
.click();
|
||||
await expect
|
||||
.poll(() => state.clients[0]?.headlessJwksCache)
|
||||
.toBeUndefined();
|
||||
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: /공개키 등록|Public Key Registration/i,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: /JWKS URI|JWKS URI/i }),
|
||||
).toHaveValue(jwksUri);
|
||||
});
|
||||
|
||||
test("pkce headless login blocks save when parsed jwks algorithm is unsupported", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
clients: [
|
||||
makeClient("client-headless-unsupported", {
|
||||
name: "Unsupported Headless Login App",
|
||||
type: "pkce",
|
||||
metadata: {
|
||||
headless_login_enabled: true,
|
||||
request_object_signing_alg: "RS256",
|
||||
},
|
||||
headlessJwksCache: {
|
||||
clientId: "client-headless-unsupported",
|
||||
jwksUri,
|
||||
cachedAt: "2026-03-31T00:00:00.000Z",
|
||||
expiresAt: "2026-04-01T00:00:00.000Z",
|
||||
lastCheckedAt: "2026-03-31T12:00:00.000Z",
|
||||
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
|
||||
lastRefreshStatus: "success",
|
||||
lastError: "",
|
||||
consecutiveFailures: 0,
|
||||
cachedKids: ["kid-unsupported"],
|
||||
parsedKeys: [
|
||||
{
|
||||
kid: "kid-unsupported",
|
||||
kty: "RSA",
|
||||
use: "sig",
|
||||
alg: "HS256",
|
||||
n: "unsupported-n-value",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
consents: [] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients/client-headless-unsupported/settings");
|
||||
|
||||
await page
|
||||
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
|
||||
.fill(jwksUri);
|
||||
|
||||
await expect(
|
||||
page.getByText("지원하지 않는 알고리즘이 감지되었습니다.", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^저장$|^Save$/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("pkce headless login blocks save when parsed jwks algorithm is missing", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
clients: [
|
||||
makeClient("client-headless-missing-alg", {
|
||||
name: "Missing Alg Headless Login App",
|
||||
type: "pkce",
|
||||
metadata: {
|
||||
headless_login_enabled: true,
|
||||
headless_jwks_uri: jwksUri,
|
||||
},
|
||||
headlessJwksCache: {
|
||||
clientId: "client-headless-missing-alg",
|
||||
jwksUri,
|
||||
cachedAt: "2026-03-31T00:00:00.000Z",
|
||||
expiresAt: "2026-04-01T00:00:00.000Z",
|
||||
lastCheckedAt: "2026-03-31T12:00:00.000Z",
|
||||
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
|
||||
lastRefreshStatus: "success",
|
||||
lastError: "",
|
||||
consecutiveFailures: 0,
|
||||
cachedKids: ["kid-missing-alg"],
|
||||
parsedKeys: [
|
||||
{
|
||||
kid: "kid-missing-alg",
|
||||
kty: "RSA",
|
||||
use: "sig",
|
||||
alg: "",
|
||||
n: "missing-alg-n-value",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
consents: [] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients/client-headless-missing-alg/settings");
|
||||
|
||||
await expect(
|
||||
page.getByText(/알고리즘이 선언되지 않았습니다|algorithm is missing/i),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^저장$|^Save$/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
45
baron-sso/orgfront/tests/devfront-consents.spec.ts
Normal file
45
baron-sso/orgfront/tests/devfront-consents.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
type Consent,
|
||||
installDevApiMock,
|
||||
makeClient,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
|
||||
test.describe("DevFront consents", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
await seedAuth(page);
|
||||
});
|
||||
|
||||
test("consent list and revoke flow", async ({ page }) => {
|
||||
const state = {
|
||||
clients: [makeClient("client-consent", { name: "Consent app" })],
|
||||
consents: [
|
||||
{
|
||||
subject: "user-1",
|
||||
userName: "Alice",
|
||||
clientId: "client-consent",
|
||||
clientName: "Consent app",
|
||||
grantedScopes: ["openid", "profile"],
|
||||
authenticatedAt: "2026-03-03T08:00:00.000Z",
|
||||
createdAt: "2026-03-02T08:00:00.000Z",
|
||||
status: "active",
|
||||
tenantId: "tenant-a",
|
||||
tenantName: "Tenant A",
|
||||
},
|
||||
] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients/client-consent/consents");
|
||||
await expect(page.getByText("Alice")).toBeVisible();
|
||||
await expect(page.getByText("Tenant A")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /권한 철회|철회|Revoke/i }).click();
|
||||
await expect(page.getByText(/Revoked|철회/i).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user