316 lines
21 KiB
Markdown
316 lines
21 KiB
Markdown
# 외부 서비스 조직선택기 통합 가이드
|
|
|
|
외부 서비스에서 조직·사용자를 선택하는 두 가지 연동 방법입니다. 이 문서에서 RP(Relying Party)는 Baron과 연동하는 외부 서비스를 뜻합니다. 현재 저장소 구현을 기준으로 합니다.
|
|
|
|
| 필요한 방식 | 데이터 조회 | 선택 UI | 읽을 절 |
|
|
| --- | --- | --- | --- |
|
|
| Integration API로 받은 데이터를 자체 화면에서 선택 | RP Backend가 Integration Credential로 조회 | 독립 SDK의 `renderOrgPicker()` | 0절 |
|
|
| OrgFront 선택기 화면을 그대로 임베딩 | iframe 안의 OrgFront가 조회 | `/embed/picker`와 `postMessage` | 1~5절 |
|
|
|
|
## 0. Integration API 데이터 + Picker SDK 방식
|
|
|
|
이 방식은 **RP Backend가 조직 데이터를 조회하고, RP 브라우저는 선택 UI만 렌더링**합니다. OrgFront iframe이나 OrgFront 사용자 로그인 세션을 사용하지 않습니다. RP 자체 로그인과 권한 검사는 계속 필요합니다.
|
|
|
|
```text
|
|
RP 브라우저 → RP Backend → GET /api/v1/integrations/org-context
|
|
│ Integration Credential
|
|
└─ 승인된 v2 응답 → buildOrgChartModel → renderOrgPicker
|
|
```
|
|
|
|
### Credential과 조회 API
|
|
|
|
1. [Directory API 가이드](directory-api.md)의 절차로 AdminFront에서 Integration Credential을 발급합니다. OperationID는 `baron.org-context.read`, 범위는 필요한 Tenant subtree, 사용자 선택에 필요한 exposure는 `members`로 승인받습니다.
|
|
2. key ID와 secret을 **RP Backend에 보관**합니다. 브라우저 JavaScript, iframe URL, `VITE_*` 환경변수에 넣지 않습니다.
|
|
3. RP Backend가 사용자의 로그인과 요청한 조직에 대한 접근 권한을 확인한 뒤 아래 API를 호출합니다. `tenantId` 또는 `tenantSlug` 중 하나만 전달합니다.
|
|
|
|
```http
|
|
GET /api/v1/integrations/org-context?tenantId=<tenant-uuid>&exposure=members
|
|
X-Baron-Key-ID: <integration-key-id>
|
|
X-Baron-Key-Secret: <integration-key-secret>
|
|
Accept: application/json
|
|
```
|
|
|
|
응답은 `schemaVersion: "baron.org-context.v2"`이며 `scope`, `tree`, `tenants`를 포함합니다. 원본 계약을 유지한 채 브라우저가 사용할 수 있는 응답으로 전달합니다. 이 예제처럼 사용자 이름을 표시하려면 `members`를 사용합니다. `structure`는 기본 exposure로 조직 구조만 조회하며 사용자 이름을 표시하는 다중선택 예제에는 적합하지 않습니다. 정확한 필드와 오류·cache 처리는 [Org Context API 가이드](org-context-api.md)를 따릅니다.
|
|
|
|
Credential은 서비스에 부여된 범위입니다. RP Backend는 이 범위 전체를 모든 RP 사용자에게 공개하지 말고, 해당 사용자가 조회해도 되는 subtree인지 별도로 확인해야 합니다. `401`/`403`/조회 실패를 빈 조직도 응답으로 바꾸지 않습니다.
|
|
|
|
SDK의 `createOrgContextClient({ baseUrl, credentials: { keyId, keySecret } })`와 `fetchOrgContext({ tenantId, exposure: "members" })`를 Backend 조회에 사용할 수도 있습니다. 이때도 secret을 사용하는 코드는 Backend에서만 실행합니다.
|
|
|
|
### SDK 파일 준비
|
|
|
|
SDK는 저장소에서 ES module과 UMD 파일로 빌드합니다.
|
|
|
|
```sh
|
|
cd orgfront
|
|
pnpm build:org-context-chart
|
|
```
|
|
|
|
`orgfront/dist/org-context-chart/`에 `boc-<build-id>.js`, `boc-<build-id>.min.js`, UMD 파일 등이 생성됩니다. 필요한 ES module 파일 한 개를 RP의 정적 파일로 배포합니다. 아래 예제는 그 파일을 `/vendor/baron-org-context-chart.js`라는 이름으로 배포했다고 가정합니다. 이 주소는 RP가 정하는 경로이며, Baron의 고정 CDN 주소나 npm 패키지명이 아닙니다.
|
|
|
|
### 이미 받은 데이터로 사용자 선택기만 렌더링
|
|
|
|
아래 `/api/org-picker-context`는 **RP가 구현하는 예시 endpoint**입니다. 앞의 인증·권한 검사를 수행한 뒤 Baron v2 응답을 반환하도록 구현합니다. 브라우저는 Integration Credential을 알 필요가 없습니다.
|
|
|
|
```html
|
|
<div id="api-org-picker"></div>
|
|
<pre id="api-org-picker-result" aria-live="polite"></pre>
|
|
```
|
|
|
|
```js
|
|
import {
|
|
buildOrgChartModel,
|
|
renderOrgPicker,
|
|
} from "/vendor/baron-org-context-chart.js";
|
|
|
|
// RP 사용자 세션으로 자신의 Backend를 호출합니다.
|
|
const response = await fetch("/api/org-picker-context", {
|
|
credentials: "same-origin",
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
if (!response.ok) throw new Error(`조직 조회 실패: ${response.status}`);
|
|
const payload = await response.json();
|
|
if (payload.schemaVersion !== "baron.org-context.v2") {
|
|
throw new Error("지원하지 않는 조직 데이터 버전입니다.");
|
|
}
|
|
|
|
// 이미 API 응답을 보유하고 있다면 위 fetch 없이 payload부터 사용합니다.
|
|
const model = buildOrgChartModel(payload);
|
|
const host = document.getElementById("api-org-picker");
|
|
const result = document.getElementById("api-org-picker-result");
|
|
let picker;
|
|
picker = renderOrgPicker(host, model, {
|
|
variant: "orgfront",
|
|
mode: "multiple",
|
|
selectable: "user",
|
|
includeDescendants: true,
|
|
showDescendantToggle: true,
|
|
onChange(selection) {
|
|
result.textContent = `${selection.length}명 선택 중`;
|
|
},
|
|
onConfirm(selection) {
|
|
const userIds = [...new Set(selection
|
|
.filter(item => item.type === "user")
|
|
.map(item => item.id))];
|
|
// 이 UUID들을 RP Backend에 제출합니다. 저장 권한은 Backend가 재검사합니다.
|
|
result.textContent = JSON.stringify(userIds, null, 2);
|
|
picker.destroy();
|
|
},
|
|
onCancel() {
|
|
result.textContent = "선택을 취소했습니다.";
|
|
picker.destroy();
|
|
},
|
|
});
|
|
// 부모 모달을 닫거나 컴포넌트를 해제할 때도 picker.destroy() 호출
|
|
```
|
|
|
|
예제는 ES module로 실행합니다. HTML에 직접 넣을 때는 JavaScript 코드를 `<script type="module">` 안에 넣습니다. `renderOrgChart()`는 호출하지 않아도 됩니다. `renderOrgPicker()`는 넘겨받은 model로 검색·선택 UI를 만들며 직접 API를 조회하지 않습니다. 기본 스타일은 자동 삽입됩니다. 스타일을 직접 제공할 때만 `injectStyles: false`를 사용합니다.
|
|
|
|
SDK 옵션은 `selectable: "tenant" | "user" | "both"`입니다. iframe query의 `select`와 이름이 다릅니다. `variant: "orgfront"`는 OrgFront 형태의 UI preset이며, `/embed/picker` 페이지 자체를 실행하는 옵션은 아닙니다.
|
|
|
|
콜백의 `selection`은 `{ type, id, name }[]`이고 `user.id`는 canonical identity UUID입니다. 같은 정보를 DOM 이벤트 `baron-org-picker-change`/`baron-org-picker-confirm`의 `event.detail.selection`으로도 받을 수 있습니다. SDK 방식에는 iframe의 `orgfront:picker:*`, `context`, nonce 검증을 적용하지 않습니다.
|
|
|
|
반환된 controller는 `getSelection()`, `confirm()`, `cancel()`, `destroy()`를 제공합니다. `confirm()`/`cancel()`은 이벤트를 발생시키고, 화면 제거는 `destroy()`로 처리합니다. 데이터가 변경되면 기존 picker를 `destroy()`한 뒤 새 응답으로 model과 picker를 다시 만듭니다.
|
|
|
|
## 1. iframe 방식 연동 준비
|
|
|
|
운영 담당자에게 OrgFront 공개 URL과 사용자의 조직 조회 권한을 확인합니다. 아래 예제의 `https://org.example.com`은 실제 주소로 바꿉니다. iframe을 넣을 서비스도 HTTPS로 제공합니다. 로컬 검증에는 HTTP localhost를 사용할 수 있습니다.
|
|
|
|
| 경로 | 용도 |
|
|
| --- | --- |
|
|
| `/embed/picker` | 외부 화면에 넣는 선택기 |
|
|
| `/login?auto=1&returnTo=...` | OrgFront 인증을 거쳐 선택기로 진입 |
|
|
| `/picker` | 선택 모드와 대상을 바꿔 보는 화면 |
|
|
| `/embed-preview` | iframe URL과 선택 메시지를 확인하는 화면 |
|
|
|
|
부모 서비스의 로그인 상태만으로 OrgFront 로그인이 완료되지는 않습니다. OrgFront 세션이 있으면 선택기로 이동하고, 없으면 `auto=1` 진입이 OIDC 자동 로그인을 시도합니다. 자동 인증이 실패하면 대화형 로그인 재시도 또는 로그인 화면으로 이어질 수 있습니다. iframe 안에서 추가 로그인 없이 항상 열린다고 가정하지 마세요.
|
|
|
|
실제 외부 도메인에서 로그인 진입을 확인합니다. iframe이 차단되면 부모의 `frame-src`, OrgFront·로그인 화면의 `frame-ancestors`/`X-Frame-Options`, 브라우저의 쿠키 제한을 확인합니다. 로그인 화면을 임베딩할 수 없는 배포에서는 먼저 OrgFront를 최상위 창에서 열어 로그인한 뒤 선택기를 다시 엽니다. 이 절차만으로 브라우저의 쿠키 제한이 해제되는 것은 아닙니다.
|
|
|
|
현재 인증·세션 운영 정책은 [Authentication and Login Flow](https://gitea.hmac.kr/baron/baron-sso/wiki/Authentication-and-Login-Flow)를 따릅니다.
|
|
|
|
## 2. 선택기 URL 만들기
|
|
|
|
사용자 다중선택의 기본 조합은 다음과 같습니다. URL은 문자열을 직접 이어 붙이지 말고 `URLSearchParams`로 만듭니다.
|
|
|
|
```js
|
|
const orgOrigin = new URL("https://org.example.com").origin;
|
|
const rootTenantId = "all";
|
|
const nonce = crypto.randomUUID(); // 열 때마다 새로 생성
|
|
const params = new URLSearchParams({
|
|
mode: "multiple",
|
|
select: "user",
|
|
rootTenantId,
|
|
parentOrigin: window.location.origin,
|
|
requestNonce: nonce,
|
|
includeDescendants: "true",
|
|
showDescendantToggle: "true",
|
|
});
|
|
const pickerPath = `/embed/picker?${params}`;
|
|
const loginUrl = new URL("/login", orgOrigin);
|
|
loginUrl.search = new URLSearchParams({
|
|
auto: "1",
|
|
returnTo: pickerPath,
|
|
}).toString();
|
|
```
|
|
|
|
`returnTo`는 OrgFront 내부 상대 경로입니다. RP callback 주소를 넣지 않습니다. `parentOrigin`은 선택기를 담는 부모 페이지의 origin이며 경로나 마지막 `/`를 붙이지 않습니다. 예: `https://app.example.com`.
|
|
|
|
### URL 옵션
|
|
|
|
아래 기본값은 `/embed/picker`에 직접 접근했을 때 기준입니다. `/picker`와 `/embed-preview`의 URL 생성기는 모드를 명시하므로 외부에서도 `mode`와 `select`를 명시하세요.
|
|
|
|
| 옵션 | 값과 현재 동작 |
|
|
| --- | --- |
|
|
| `mode` | `single` 또는 `multiple`. 직접 접근 시 생략하면 `single` |
|
|
| `select` | `tenant`, `user`, `both`. 생략하면 `both` |
|
|
| `rootTenantId` | 루트 Tenant ID 또는 slug. `all`은 조회된 트리의 루트들을 표시하며 독립 `USER_GROUP` 루트는 제외. 특정 범위는 아래 allowlist 설명 참고 |
|
|
| `parentOrigin` | 부모의 정확한 origin. 생략하거나 잘못된 값이면 현재 구현은 `*`로 전송하므로 반드시 명시 |
|
|
| `requestNonce` | 이번 열기 요청을 식별하는 임의 문자열. `rootTenantId`와 함께 있어야 응답 `context`가 생성됨 |
|
|
| `tenantSlug` | 선택한 루트 안에서 표시할 하위 Tenant. `tenantId`, `companyTenantId`도 호환 별칭으로 수용하며 이 순서로 우선 적용 |
|
|
| `allowedRootTenantIds` | 표시할 관리 루트 ID를 쉼표로 구분. 지정하면 `rootTenantId`/하위 Tenant 선택보다 우선. 빈 값 또는 `__none__`은 빈 트리 |
|
|
| `allowedTenantIds` | 선택 가능한 Tenant ID의 allowlist. 빈 값 또는 `__none__`은 빈 목록. 표시 트리도 필터링하므로 사용자 표시 범위에 영향을 줄 수 있음 |
|
|
| `includeInternal` | `true`이면 내부 Tenant 포함. 기본 `false` |
|
|
| `includePrivate` | `true`이면 비공개 Tenant 포함. 기본 `false` |
|
|
| `includeDescendants` | 다중선택에서 하위 항목도 선택. 기본 `true` |
|
|
| `showDescendantToggle` | 하위 선택 체크박스 표시. 기본 `true` |
|
|
| `token` | 이미 발급된 공유 조직도 토큰으로 공개 조회 경로 사용. 아래 공유 모드 참고 |
|
|
|
|
`rootTenantId`를 생략하거나 찾지 못하면 현재 구현은 `hanmac-family` 회사그룹, 첫 회사그룹, 부모 없는 Tenant 순으로 기본 루트를 찾습니다. 특정 범위를 반드시 지정해야 하면 Backend가 승인한 `allowedRootTenantIds`를 전달하고, 결과 저장 시 Backend에서 다시 권한을 검사합니다. `rootTenantId=all`도 서버 조회 권한을 늘리지는 않습니다.
|
|
|
|
`tenantSlug`에 의한 하위 범위 지정은 `rootTenantId=all` 또는 `allowedRootTenantIds`로 루트를 구성하는 경우에는 적용되지 않습니다. 한 번에 한 가지 범위 지정 방식을 사용하세요.
|
|
|
|
`width`/`height`는 검증 화면의 URL 생성 옵션에도 보이지만, 실제 iframe 크기는 부모의 CSS로 지정합니다. 선택기 내부는 iframe의 화면 높이를 사용합니다.
|
|
|
|
## 3. 선택 결과 수신
|
|
|
|
선택기는 부모에게 `window.parent.postMessage`를 보냅니다. 부모는 **origin, iframe 창, nonce, rootTenantId, payload 형식**을 검사한 뒤 사용합니다. `origin`만 확인하면 같은 OrgFront의 다른 iframe에서 보낸 메시지도 받아들일 수 있습니다.
|
|
|
|
| 메시지 `type` | 의미 |
|
|
| --- | --- |
|
|
| `orgfront:picker:ready` | 선택기 컴포넌트가 마운트됨. 조직 데이터 조회 완료나 인증 성공 보증이 아님 |
|
|
| `orgfront:picker:confirm` | 사용자가 **선택 완료**를 누름. `payload.mode`, `payload.selections` 포함 |
|
|
| `orgfront:picker:cancel` | 사용자가 선택기 내부 **취소**를 누름. 부모가 iframe/모달을 닫아야 함 |
|
|
| `orgfront:picker:error` | 조직 데이터 조회 실패. `error: "org_picker_load_failed"` |
|
|
|
|
`requestNonce`와 `rootTenantId`를 함께 보낸 경우 위 메시지에 다음 `context`가 포함됩니다.
|
|
|
|
```json
|
|
{
|
|
"type": "orgfront:picker:confirm",
|
|
"context": { "nonce": "이번 요청의 nonce", "rootTenantId": "all" },
|
|
"payload": {
|
|
"mode": "multiple",
|
|
"selections": [
|
|
{
|
|
"type": "user",
|
|
"id": "11111111-1111-4111-8111-111111111111",
|
|
"name": "김하나 책임",
|
|
"email": "one@example.com",
|
|
"rootTenantName": "개발본부",
|
|
"leafTenantName": "개발팀"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
항목의 필수 필드는 `type`, `id`, `name`입니다. `type`은 `tenant` 또는 `user`이며, 사용자 항목에는 이메일과 조직 표시명이 추가될 수 있습니다. 이름에는 직급 등 표시용 텍스트가 포함될 수 있으므로 이름이나 이메일로 사용자를 식별하지 않습니다. **사용자 연결·저장에는 `id`의 Baron canonical identity UUID를 사용합니다.**
|
|
|
|
선택 완료는 조회 결과 전달입니다. RP 사용자 등록이나 whitelist 저장 API를 자동 호출하지 않습니다. 부모가 ID를 자신의 Backend에 제출하고, Backend는 대상의 활성 상태·소속·처리 권한을 다시 검사해야 합니다. URL allowlist나 nonce는 권한 증명서가 아닙니다.
|
|
|
|
## 4. 부모 페이지 예제
|
|
|
|
2절 코드를 먼저 실행한 뒤 아래 예제를 이어 붙입니다. `org-picker-host`와 `org-picker-result` 요소가 있는 부모 페이지에서 동작합니다. 사용자 다중선택 전용이며, 조직 선택으로 바꿀 때는 `select`, 응답 `type`, ID 검증도 함께 바꿉니다.
|
|
|
|
```html
|
|
<div id="org-picker-host"></div>
|
|
<pre id="org-picker-result" aria-live="polite"></pre>
|
|
```
|
|
|
|
```js
|
|
const host = document.getElementById("org-picker-host");
|
|
const result = document.getElementById("org-picker-result");
|
|
const frame = document.createElement("iframe");
|
|
frame.title = "사용자 다중선택";
|
|
frame.style.cssText = "width:100%;height:480px;border:0";
|
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
let closed = false;
|
|
|
|
function closePicker() {
|
|
closed = true;
|
|
clearTimeout(readyTimer);
|
|
window.removeEventListener("message", receive);
|
|
frame.remove();
|
|
}
|
|
|
|
function receive(event) {
|
|
if (closed || event.origin !== orgOrigin || event.source !== frame.contentWindow) return;
|
|
const data = event.data;
|
|
if (!data || typeof data !== "object" ||
|
|
data.context?.nonce !== nonce || data.context?.rootTenantId !== rootTenantId) return;
|
|
|
|
switch (data.type) {
|
|
case "orgfront:picker:ready":
|
|
clearTimeout(readyTimer);
|
|
result.textContent = "선택기가 열렸습니다. 데이터 로딩 상태는 선택기에서 확인하세요.";
|
|
break;
|
|
case "orgfront:picker:cancel":
|
|
result.textContent = "선택을 취소했습니다.";
|
|
closePicker();
|
|
break;
|
|
case "orgfront:picker:error":
|
|
result.textContent = "조직을 불러오지 못했습니다. 조회 권한과 연결 상태를 확인하세요.";
|
|
closePicker();
|
|
break;
|
|
case "orgfront:picker:confirm": {
|
|
const items = data.payload?.selections;
|
|
if (data.payload?.mode !== "multiple" || !Array.isArray(items) || !items.length) return;
|
|
if (!items.every(item => item && item.type === "user" &&
|
|
typeof item.id === "string" && uuid.test(item.id) && typeof item.name === "string")) return;
|
|
const selected = [...new Map(items.map(item => [item.id, { id: item.id, name: item.name }])).values()];
|
|
// 이 ID 목록을 자체 Backend에 제출하고 별도로 저장 결과를 처리합니다.
|
|
result.textContent = JSON.stringify(selected, null, 2);
|
|
closePicker();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
window.addEventListener("message", receive); // iframe을 열기 전에 등록
|
|
const readyTimer = setTimeout(() => {
|
|
result.textContent = "선택기 응답이 없습니다. OrgFront 로그인과 iframe 허용 설정을 확인하세요.";
|
|
closePicker();
|
|
}, 20000); // 예제의 대기 시간이며 서비스 응답 조건에 맞게 조정
|
|
frame.src = loginUrl.toString();
|
|
host.append(frame);
|
|
// 부모 모달을 닫거나 페이지를 떠날 때도 closePicker() 호출
|
|
```
|
|
|
|
예제의 20초 대기 시간에는 OrgFront 로그인 시간도 포함됩니다. 대화형 로그인 중에도 시간이 지나면 iframe이 닫히므로, 실제 서비스에서는 로그인 절차를 먼저 완료하거나 로그인에 필요한 대기 시간으로 조정합니다.
|
|
|
|
선택기 내부에는 검색과 선택 완료 UI가 있습니다. 부모에서 별도 저장 확인이 필요하면 선택 결과를 보관해 확인 화면을 보여준 뒤 저장합니다. 반복해서 여는 모달에 적용할 때는 2·4절 JavaScript를 하나의 열기 함수 안에서 실행하고, 해당 함수에서 `closePicker`를 반환해 부모의 닫기 동작에 연결합니다. 다시 열 때는 이전 함수가 반환한 닫기 함수를 호출하고 새 nonce로 URL을 생성합니다. 현재 선택기에는 초기 선택 ID 주입이나 부모가 보내는 선택 변경 메시지 계약이 없습니다.
|
|
|
|
## 5. 공유 모드와 문제 확인
|
|
|
|
이미 발급된 공유 조직도 토큰을 사용하는 경우 `/embed/picker?token=...`에 나머지 옵션을 함께 전달합니다. 이 경로는 공개 조직도 조회를 사용하며 `includeInternal`과 `includePrivate`는 적용하지 않습니다. 공유 토큰을 RP access token이나 refresh token으로 대체하지 않습니다. [임베딩 토큰 설계](../../orgfront/docs/orgchart-embedding-token-design.md)에 나오는 권장 발급 API는 설계 제안이므로 이 가이드의 구현된 발급 절차로 취급하지 않습니다.
|
|
|
|
| 증상 | 확인할 항목 |
|
|
| --- | --- |
|
|
| 로그인 화면 또는 빈 iframe | 공개 URL, OrgFront 로그인, iframe 허용 헤더, 브라우저 쿠키 제한 |
|
|
| `ready` 이후 오류 | OrgFront 조직도 API 응답과 현재 사용자의 조회 권한 |
|
|
| 부모가 선택 결과를 무시함 | 정확한 origin, `event.source`, 이번 nonce, `rootTenantId`, `payload.mode` |
|
|
| 조직·사용자가 보이지 않음 | Backend 조회 범위, root/allowlist 조합, 내부·비공개 표시 옵션, 사용자 활성 상태 |
|
|
| 취소를 눌러도 모달이 남음 | 부모의 `orgfront:picker:cancel` 처리와 닫기 함수 연결 |
|
|
| 테스트 iframe에서 한글이 깨짐 | 테스트 HTML의 `<meta charset="utf-8">`와 응답 인코딩 |
|
|
|
|
## 6. 구현과 검증 참고
|
|
|
|
- [독립 Picker SDK 구현](../../orgfront/src/sdk/org-context-chart/index.ts), [SDK 테스트](../../orgfront/src/sdk/org-context-chart/orgContextChart.test.ts)
|
|
|
|
- [메시지 전송·옵션 해석](../../orgfront/src/features/orgchart/routes/OrgPickerPage.tsx), [트리 범위 구성](../../orgfront/src/features/orgchart/pickerTree.ts), [항목 타입](../../orgfront/src/features/orgchart/pickerTypes.ts)
|
|
- [인증 진입](../../orgfront/src/features/auth/LoginPage.tsx), [인증 callback 처리](../../orgfront/src/features/auth/AuthCallbackPage.tsx)
|
|
- [DevFront 다중선택 연동 사례](../../devfront/src/features/clients/RPLoginMemberAddDialog.tsx)
|
|
- [실제 선택기 브라우저 통합 테스트](../../devfront/tests/whitelist-real-picker.spec.ts): `cd devfront && pnpm exec playwright test --config playwright.picker.config.ts --workers=1`
|
|
|
|
이 테스트는 두 Frontend의 실제 화면과 선택 메시지를 사용하고, 인증 상태와 Backend 응답에는 fixture를 사용합니다. 실제 외부 도메인의 SSO·iframe 허용 여부는 대상 배포에서 별도로 확인합니다.
|