@iyulab/enterprise
v0.35.1
Published
Enterprise utilities and components for iyulab framework
Readme
@iyulab/enterprise
iyulab 프레임워크의 엔터프라이즈 통합 패키지. 폼 레이아웃 컴포넌트·API 설정·도메인 헬퍼를 제공합니다.
Installation
npm install @iyulab/enterprise @iyulab/components@iyulab/components 는 필수 peer 다(로케일·포맷·디자인 토큰). React 폼 레이아웃(/react)을 쓰면 react 도 필요하다.
| 진입점 | 내용 |
|--------|------|
| @iyulab/enterprise | 아래 표의 고유 export |
| @iyulab/enterprise/react | FormSection · FormRow |
| @iyulab/enterprise/icons | house 아이콘 라이브러리 등록(부수효과 import) |
Quick Start
React 앱의 첫 페이지 — 폼 섹션 하나(Vite 기준, index.html 이 src/main.tsx 를 모듈로 싣는다).
npm install @iyulab/enterprise @iyulab/components @lit/react react react-dom// src/main.tsx
import '@iyulab/components/styles/tokens.css';
import { createRoot } from 'react-dom/client';
import { UInput, UTextarea, UButton } from '@iyulab/components/react';
import { FormSection, FormRow } from '@iyulab/enterprise/react';
import { DateHelper } from '@iyulab/enterprise';
function OrderForm() {
return (
<form style={{ maxWidth: 640, margin: 32 }} onSubmit={(e) => e.preventDefault()}>
<FormSection title="Order">
<FormRow>
<UInput name="customer" label="Customer" required />
<UInput name="due" label="Due date" value={DateHelper.formatDate(new Date())} />
</FormRow>
<FormRow full>
<UTextarea name="note" label="Note" />
</FormRow>
</FormSection>
<UButton type="submit" color="primary">Save</UButton>
</form>
);
}
createRoot(document.body.appendChild(document.createElement('div'))).render(<OrderForm />);데이터 서비스(createODataService)·인증(createAuthClient)·권한 store 는 아래 Usage 에 있습니다.
무엇을 제공하나
1. 고유 export (enterprise에서만 제공)
| Export | 종류 | 용도 |
|--------|------|------|
| FormSection | React (/react) | 제목 + 세로 스택 폼 섹션 |
| FormRow | React (/react) | 2컬럼 그리드 폼 행(full로 1컬럼) |
| u-list-page | 커스텀 엘리먼트 (/list-page) | 목록 화면 골격 — 머리·필터·툴바·뷰·페이저·빈/오류 슬롯, 소스를 뷰·페이저에 묶고 view 로 표↔카드 전환. 표는 그리지 않는다. 상세: skills/iyulab-enterprise/references/list.md |
| ListPage | React (/react) | u-list-page 의 얇은 래퍼(@lit/react — 선택 peer) |
| ApiConfig | class | baseUrl/OData·API prefix·dev 판별 중앙 설정 |
| createODataService | factory | OData v4 + custom REST CRUD 서비스(401·토스트·에러파싱) |
| ApiError | class | HTTP status + OData error.details(필드별 검증 상세)를 실은 API 호출 실패 에러 |
| applyFieldErrors · clearFieldErrors | function | 서버 거절의 필드 상세(ApiError.details 의 target)를 폼 안 그 name 의 컨트롤에 — 칸 옆 오류 · 고치면 걷힘 · 못 내려간 항목은 폼 단위로 돌려준다 |
| wasNotified | function | 이 실패를 서비스가 notify.error 로 이미 알렸는가 — 경계의 이중 토스트 방지 |
| createAuthClient | factory | 쿠키 세션 인증(fetchMe/login/logout) — 제네릭 user/자격증명 |
| createPermissionStore · hasPermission 외 | store | 권한 스냅샷 store + 판정 free 함수 |
| bindSource · SourceBinding | function · Lit controller | 데이터 소스(@iyulab/flex-table 의 createODataSource·createArraySource)를 «뷰 어휘» 를 말하는 요소(표 · 카드 뷰 · 페이저 · 검색 칸)에 묶는다 — 상태를 속성에 쓰고 sort-change·page-change·search 를 소스 조작으로. 상세: skills/iyulab-enterprise/references/list.md |
| DateHelper | class | 날짜 포맷·날짜 계산(로컬 달력 — 'YYYY-MM-DD' 는 그 날로 읽는다) |
| ProgressHelper | class | 진행률 계산 |
| UrgencyHelper | class | 긴급도 계산 |
2. 심볼 출처 (v0.3.0부터 하위 패키지 재노출 제거됨)
@iyulab/enterprise는 자기 고유 export만 제공합니다(위 표). @iyulab/components(컴포넌트), @iyulab/data-components(데이터 그리드/시트), @iyulab/modern-app(앱 프레임워크, 라우팅)은 각 패키지에서 직접 import하세요.
v0.2.x까지는 위 3개 패키지를
export *로 재노출했으나, 심볼 출처 혼란과 exact-pin(v0.2.2 이전) 조합 시 이중 인스턴스 위험 때문에 v0.3.0에서 제거했습니다. 재노출에 의존하던 코드는@iyulab/enterprise가 아닌 각 하위 패키지에서 직접 import하도록 수정해야 합니다.
Usage
폼 레이아웃 (React)
React 컴포넌트는 @iyulab/enterprise/react 서브패스에 있습니다. react 는 선택 peer 의존이라, React 를 쓰지 않는 앱(프리셋·데이터 서비스·인증 클라이언트만 쓰는 앱)에는 설치되지 않습니다.
import { FormSection, FormRow } from '@iyulab/enterprise/react'
import { UInput, UTextarea } from '@iyulab/components/react'
<FormSection title="기본 정보">
<FormRow>
<UInput label="이름" />
<UInput label="코드" />
</FormRow>
<FormRow full>
<UTextarea label="비고" />
</FormRow>
</FormSection>FormRow는 기본 2컬럼 그리드입니다. 한 칸을 차지하려면full을, 다른 열 수가 필요하면columns를 씁니다.- 열 폭 — 선언한 열은 실제로 균등합니다. 트랙은
1fr이 아니라minmax(0, 1fr)입니다. 그리드 아이템의 기본min-width: auto는 내용의 min-content 아래로 줄어들기를 거부해서, 순수1fr로 두면 긴 내용이 든 칸만 부풀고 나머지가 찌그러집니다(400px 컨테이너 실측: 2열 425/8, 3열 425/8/8).minmax(0, 1fr)은 트랙 최소를 0으로 만들어 같은 경우를 196/196·128/128/128로 만듭니다. - ⚠긴 불가분 문자열 자체의 넘침은 별개 축입니다. 칸이 균등해져도 그 안의 긴 문자열은 자기 칸을 넘습니다 — 셀 쪽에서
overflow-wrap으로 다룹니다(@iyulab/components의 엘리먼트는 이미 그 값을 갖습니다). - 두 컴포넌트 모두
className·style을 받아 기본값 뒤에 병합합니다 — 블록을 복제하지 않고 조정하는 정규 경로입니다.FormSection은 제목 줄만 바꾸는titleStyle도 받습니다.
<FormRow columns={3}>…</FormRow>
<FormSection title="기본 정보" style={{ marginBottom: 32 }}>…</FormSection>이 계층이 무엇을 소유하고 무엇을 소유하지 않는지, 새 패턴이 언제 추가되는지는 LOB 계층 헌장 이 정합니다. 패턴을 제안하기 전에 읽어 주세요.
API 설정
import { ApiConfig } from '@iyulab/enterprise'
ApiConfig.initialize({ baseUrl: '/', odataPrefix: '$data', apiPrefix: 'api' })
ApiConfig.getODataUrl('Orders') // → /$data/Orders
ApiConfig.getApiUrl('auth/me') // → /api/auth/me
ApiConfig.getUrlWithParams('report', { year: 2026, active: true })
ApiConfig.isDevelopment // 환경 판별OData 서비스 (createODataService)
OData v4 + custom REST CRUD 를 한 번에 구성한다. 401 세션 처리·에러 메시지 추출·성공 토스트·204 빈 바디 안전 파싱이 내장돼 있고, 도메인/로케일 요소는 전부 주입으로 앱 adapter 에 남긴다.
import { createODataService } from '@iyulab/enterprise'
import { app } from '@iyulab/modern-app'
// 앱 adapter (예: src/lib/odata.ts) — 라이브러리는 이 배선만 받는다.
export const svc = createODataService({
baseUrl: window.location.origin,
onUnauthorized: () => { window.location.href = '/' }, // 세션 만료 리다이렉트는 앱이 결정
notify: { success: (m) => app.success(m), error: (m) => app.error(m) },
messages: { // 기본은 영어 — 로케일 오버라이드
saved: '저장되었습니다', updated: '수정되었습니다', deleted: '삭제되었습니다',
sessionExpired: '세션이 만료되었습니다. 다시 로그인하세요.',
},
})
await svc.odataGet<Order>('Orders', { $top: '20' }) // value 배열 언랩
// 서버가 페이지를 자르면(@odata.nextLink) odataGet 은 끝까지 따라가 전량을 모은다.
// maxRows 를 주면 그 수를 넘는 순간 남은 페이지를 읽지 않고 RangeError 를 던진다(자르지 않는다).
await svc.odataGet<Order>('Orders', undefined, { maxRows: 5000 })
// 페이지 단위로 읽어야 하는 화면은 페이지 API 를 쓴다 — nextLink 는 조립하지 말고 그대로 넘긴다.
const page = await svc.odataGetPage<Order>('Orders', { $count: 'true' }) // { value, nextLink?, count? }
if (page.nextLink) await svc.odataGetNextPage<Order>(page.nextLink)
await svc.odataPost<Order>('Orders', { name: 'A', note: '' }) // '' → null 정규화 + 성공 토스트
svc.odataUrl('Orders') // flex-table useODataSource 엔드포인트
svc.sourceDefaults // { baseUrl, onUnauthorized } 주입용
// custom REST — GET/POST/PUT/PATCH/DELETE 전부, 204 빈 바디 안전 파싱 포함
await svc.apiGet<Order>('reports/summary')
await svc.apiPost<Order>('orders', { name: 'A' })
await svc.apiPut<Order>('orders/7', { name: 'A (revised)' }) // 리소스 전체 교체
await svc.apiPatch<Order>('orders/7', { note: 'urgent' })
await svc.apiDelete('orders/7') // 204 안전
// 로그인 — 401 이 「세션 만료」가 아니라 「자격 증명 틀림」인 유일한 호출이다.
// 전역 onUnauthorized 를 이 호출에서만 끄면, 서버가 준 사유가 그대로 ApiError.message 로 온다.
await svc.apiPost('auth/login', creds, { onUnauthorized: false })
// 연결 프로브 — 「응답이 왔는가」만 묻는다. fetchRaw 는 던지지도, 401 훅을 부르지도 않는다.
const probe = await svc.fetchRaw(svc.apiUrl('ping'))
const reachable = true // 여기 닿았다는 것 자체가 답이다(probe.status 는 볼 필요 없다)
// body가 FormData 인스턴스면 그대로(직렬화 없이) 멀티파트로 전송된다 —
// Content-Type은 브라우저가 boundary와 함께 자동 설정한다. apiPost/apiPut/apiPatch 전부 동일.
const form = new FormData()
form.append('file', file)
await svc.apiPost<Order>('orders/7/attachments', form)주입 항목:
| config | 용도 |
|--------|------|
| baseUrl | 모든 요청의 오리진 (필수) |
| odataPrefix / apiPrefix | 엔드포인트 prefix (기본 $data / api) |
| onUnauthorized(status) | 401 시 호출 — 리다이렉트/재진입 가드는 앱이 처리 |
| notify.success/error | 토스트 훅 (생략 시 토스트 없음 — 순수). 메서드마다 걸리는 방식이 다르다 — 바로 아래 표 참조 |
| onMutated(mutation) | 쓰기가 성공(2xx)한 뒤 한 번 — 캐시 무효화 자리. 아래 절 참조 (0.19.0~) |
| messages | 사용자 대면 문구 (기본 영어, 지정 키만 대체) |
| formatError(info) | 에러 메시지 포매팅 오버라이드 (앱별 정책) — info 는 status/statusText/rawMessage/code(서버의 거절 코드)/details(검증된 error.details)/body 를 받는다 |
onMutated — 쓰기 뒤 무효화
목록·상세·대시보드가 같은 데이터를 따로 들고 있으면, 한쪽에서 저장한 뒤 다른 쪽이 낡는다 (예: 목록 위 오버레이에서 상세를 저장하고 닫으면 목록은 재마운트되지 않는다). 서비스가 쓰기를 전부 소유하므로 «무엇이 쓰였는가» 도 서비스가 알린다 — 쓰기 메서드를 한 겹씩 감쌀 필요가 없다.
export const svc = createODataService({
baseUrl: window.location.origin,
onMutated: ({ method, source, target, id }) => invalidate(source === 'odata' ? target : 'api'),
})- 성공한 쓰기마다 한 번 —
odataPost·odataPatch·odataDelete,apiPost·apiPut·apiPatch·apiDelete와 그*Quiet변형 전부(odataPut은 없다). 토스트 여부와 무관하다. - 실패한 쓰기·읽기(
fetchRaw포함)에는 부르지 않는다. mutation={ method, source: 'odata' | 'api', target, id? }—target은odata면 엔티티 셋 이름,api면 넘긴 경로 그대로.id는 키로 지정한 쓰기(odataPatch·odataDelete)에만.- 콜백이 던져도 쓰기는 성공으로 끝난다(예외는
reportError로 보고) — 관찰자의 실패가 «저장 실패» 로 보이면 안 된다.
notify 가 걸리는 자리 — 축은 «쓰기 ↔ 읽기» 다
| 메서드 | 실패 시 error | 성공 시 success |
|---|---|---|
| odataPost · odataPatch · odataDelete | ✅ (401 제외) | ✅ saved·updated·deleted |
| apiPost · apiPut · apiPatch · apiDelete | ✅ (401 제외) | ❌ |
| *Quiet 전부 (odataPostQuiet … apiDeleteQuiet) | ❌ | ❌ |
| odataGet · odataGetPage · odataGetNextPage · odataGetById · odataCount · apiGet · fetchRaw | ❌ | ❌ |
- 조회는 통지하지 않는다. 빈 화면 자체가 신호이고, 목록을 열 때마다 토스트가 뜨면 읽을 수 없다.
- 쓰기는 통지한다. 결과가 화면에 안 보일 수 있기 때문이다 — 서버가 409 와 사유를 돌려줘도, 통지가 없으면 아무 일도 일어나지 않은 화면과 구별되지 않는다.
- 401 은 통지하지 않는다 —
onUnauthorized가 이미 안내하므로 겹친다. api*에는 성공 토스트가 없다. 임의의 RPC(상태 전이·발행·업로드)를 태우는 경로라 «저장되었습니다» 같은 문구를 라이브러리가 지어낼 수 없다. 실패 메시지는 서버가 주므로 어느 엔드포인트에서나 뜻이 통하지만, 성공 문구는 그렇지 않다 — 필요하면 호출한 쪽이 띄운다.- 이미 자기 래퍼로 통지하고 있다면
*Quiet로 바꾼다. 이중 토스트를 막는 탈출구이고,odata*Quiet와 같은 관용구다(한 사용자 액션이 여러 요청을 낼 때도 같은 것을 쓴다).
401 축 — 전역 정책과 그 호출 단위 예외
onUnauthorized 는 서비스 전역 정책이다: 401 이 오면 훅을 부르고 messages.sessionExpired
로 단락한다. 이 기본값은 거의 항상 옳지만 두 자리에서 틀린다.
| 자리 | 왜 틀리는가 | 해법 |
|---|---|---|
| 로그인 자체 | 401 이 「세션이 끊겼다」가 아니라 「자격 증명이 틀렸다」를 뜻한다. 전역 훅이 발화하면 로그인 화면에서 로그인 화면으로 리다이렉트되고, 던져지는 메시지가 sessionExpired 라 화면이 실패 사유를 지어내야 한다 | { onUnauthorized: false } |
| 연결 프로브 | 「응답이 왔는가」 만 묻는 호출이라, 401 은 서버가 살아서 거절한 것 = 「닿았다」다. 훅+예외를 타면 로그인하지 않은 사용자에게 늘 「서버에 연결할 수 없습니다」가 뜬다 | fetchRaw |
// 401 을 «그 호출의 결과» 로 받는다 — 훅 없음, 메시지 덮어쓰기 없음.
await svc.apiPost('auth/login', creds, { onUnauthorized: false })fetchRaw를 뺀 모든 요청 메서드가 이 옵션을 받는다(마지막 선택 인자 —fetchRaw는 애초에 401 훅을 태우지 않는다). 부분집합으로 두면 다음 소비자가 다른 메서드에서 같은 벽을 만난다.- 끄는 축만 있다. 새 동작을 켜는 스위치가 아니라 전역 정책의 탈출구이므로, 옵션을 생략한 호출은 이 옵션이 생기기 전과 한 글자도 다르게 동작하지 않는다.
authenticate()같은 이름 있는 프리미티브는 두지 않는다 — 그것은 엔드포인트 경로·바디 모양·토큰 처리 규약을 라이브러리가 안다고 가정하는 도메인 이름이다. 그 규약은 앱마다 다르므로 adapter 에 남기고, 라이브러리는 범용 축만 연다.
fetchRaw — 「raw」 는 정책을 태우지 않는다는 뜻이다
응답을 그대로 돌려준다. 비-2xx 에도 던지지 않고 onUnauthorized 도 부르지 않는다 —
상태 판단은 호출부의 몫이다. 상태에 따라 예외·토스트·세션 처리를 원하면 apiGet 을 쓴다.
⚠0.15.0 이전에는 이 문장이 선언에 적혀 있으면서 실제로는 비-2xx 에 던졌다. 선언이 옳고 구현이 틀렸던 자리라 구현을 고쳤다.
fetchRaw의 throw 에 기대고 있었다면 호출부에서if (!res.ok) throw …로 바꾸거나apiGet으로 옮긴다.
도메인 액션(상태 전이 등)·엔티티 목록·권한 코드는 라이브러리에 넣지 말고 앱 adapter 에 둔다.
실패 응답 — ApiError
모든 메서드는 실패 시 ApiError(Error 상속)를 던진다. status 로 상태별 분기하고,
서버가 OData v4 오류 봉투에 필드별 검증 상세(error.details)를 실어 보내면 details 로
읽어 폼의 필드별 오류 표시에 바로 연결할 수 있다.
import { applyFieldErrors } from '@iyulab/enterprise'
try {
await svc.odataPost('Roles', draft)
} catch (e) {
// [{ code: 'ValidationFailed', message: 'The Name field is required.', target: 'Name' }, …]
const { applied, formLevel } = applyFieldErrors(form, e)
// applied: 칸에 내려간 항목(control · message) — 요약에 칸으로 가는 링크로
// formLevel: target 이 없거나 그 이름의 칸이 없는 항목 — 요약에 링크 없는 줄로
}applyFieldErrors(root, error, { message? }) 는 target 과 같은 name 의 컨트롤(네이티브 폼 요소 ·
@iyulab/components 폼 컨트롤)에 setCustomValidity(message) 를 하고, components 컨트롤이면 validate() 로
오류를 보인다. 사용자가 그 칸을 고치면(input·change) 그 메시지를 걷고, 다시 부르면 앞의 것을 먼저 걷는다
(clearFieldErrors(root) 로도 걷는다). message 로 문장을 바꾼다 — detail.code 로 지역화할 때(예: 값을 그
타입으로 읽지 못한 Unconvertible 만 앱의 언어로). ApiError 가 아닌 실패는 빈 결과다.
| 필드 | 설명 |
|------|------|
| message | 사용자 대면 메시지 (formatError → 서버 raw → status 폴백 순으로 결정) |
| status | HTTP status |
| code | 서버가 정한 거절 코드 — OData error.code(봉투가 없으면 최상위 code → Code). 같은 상태 안의 실패를 가른다(예: 403 중 «비밀번호 변경 필요»). 없거나 빈 문자열이면 undefined |
| details | error.details 항목 배열 — 규격상 code/message 는 필수, target(속성 이름)은 선택. 상세가 없거나 규격 형태가 아니면 undefined |
| body | 실패 응답의 본문 — JSON 이면 파싱한 값, 아니면 텍스트, 비었으면 undefined. 거절이 싣는 «충돌의 현재 상태»(409 의 새 판정, 412 의 현재 표현)를 같은 요청을 다시 보내지 않고 읽는다. 모양은 서버 계약이라 unknown — 호출부가 좁힌다. formatError 가 받는 body 와 같은 값. 표준 cause 처럼 열거되지 않는다(직렬화·로그에 실리지 않음) |
| cause | 표준 Error.cause — 응답 없이 실패했을 때(인증 클라이언트의 네트워크 오류, status 0) 전송이 던진 예외. 진단용 |
| notified | 서비스가 이 실패를 notify.error 로 이미 사용자에게 알렸는가(읽기 전용). 쓰기 실패를 알렸으면 true, 읽기·*Quiet·401 이나 notify.error 미설정이면 false |
경계에서 «알리지 않은 것만» 알리기 — notified · wasNotified
쓰기 실패는 서비스가 알린 뒤 같은 에러를 다시 던진다(호출부 흐름을 멈추게 하려고). 그 에러가 전역
unhandledrejection 핸들러나 error boundary 까지 올라오면, 거기서 다시 토스트를 띄우면 이중 통지가 되고
안 띄우면 읽기 실패가 조용해진다. 어느 쪽인지는 에러가 들고 온다:
import { ApiError, wasNotified } from '@iyulab/enterprise'
window.addEventListener('unhandledrejection', (ev) => {
const err = ev.reason
if (err instanceof ApiError || wasNotified(err)) ev.preventDefault()
if (err instanceof ApiError && !err.notified) app.error(err.message) // 읽기 실패 등
})wasNotified(err) 는 ApiError 가 아닌 실패(네트워크 오류 등)에도 같은 답을 준다 — 쓰기 경로에서 알렸다면
true. 표식은 서비스만 세울 수 있다(에러 객체의 필드가 아니다).
OData v4 적합성 (conformance)
서비스가 OData v4 의 어느 조항을 따르는지 — «구현» 행은 전부 tests/odata-conformance.test.ts 가
명세의 예시 응답 모양으로 잰다. 서버가 새 조항을 쓰기 시작하는 날(서버 주도 페이징 등) 소비자가
먼저 깨지지 않게 하려는 표다.
| 조항 | 상태 | 메모 |
|---|---|---|
| 서버 주도 페이징 — @odata.nextLink(상대·절대) | 구현 | odataGet 은 끝까지 따라가고, odataGetPage 는 nextLink 를 준다. 서비스 오리진 밖 · 이미 읽은 링크로의 순환은 던진다 |
| nextLink 를 불투명하게 따라가기(Protocol §11.2.6.7) | 구현 | 서버가 쓴 쿼리가 바이트 그대로 나간다(@iyulab/http-client 0.10.3 이상 — 범위 ^0.10.0 이면 재설치로 받는다) |
| $count=true → @odata.count · odataCount | 구현 | odataCount 는 $top=0&$count=true 로 행 없이 건수만 |
| 오류 봉투 — error.code·message·details[](code·message·target) | 구현 | ApiError.message·ApiError.code·ApiError.details · innererror 는 해석하지 않는다(서버의 디버깅용이지 계약이 아니다) — 원문 body 에만 있고, body 는 열거되지 않아 에러를 직렬화해도 따라 나가지 않는다 |
| 생성 201 · 수정/삭제 204 No Content | 구현 | 빈 바디를 파싱하지 않는다 |
| 키 리터럴(URL Conventions §4.3.1) | 호출자 | id 는 URL 에 쓰이는 리터럴 그대로다 — GUID·숫자는 그대로, 문자열 키는 호출자가 '…' 로 감싸고 ' 를 '' 로. 클라이언트는 메타데이터 없이 둘을 가를 수 없다 |
| 낙관적 동시성 — ETag · If-Match · 412 | 미구현 | 서버 지원 확인 뒤에 연다 |
| Prefer(return=… · odata.maxpagesize) · $batch · @odata.deltaLink | 미구현 | |
| 429 · Retry-After | 미구현 | 다른 오류와 같이 ApiError 로 던진다(재시도하지 않는다) |
인증 + 권한 (createAuthClient · 권한 store)
쿠키 세션 인증 흐름과 권한 스냅샷을 승격. 사용자·자격증명 형태는 앱이 제네릭으로 정의하고, 권한 코드는 불투명 문자열로만 다룬다. getPermissions 를 주면 로그인/세션 조회 성공 시 권한 store 가 자동 갱신된다.
import { createAuthClient, hasPermission, setPermissions } from '@iyulab/enterprise'
interface User { Id: string; Permissions: string[] } // 도메인 형태 = 앱 소유
export const auth = createAuthClient<User, { Username: string; Password: string }>({
meUrl: '/api/auth/me', loginUrl: '/api/auth/login', logoutUrl: '/api/auth/logout',
getPermissions: (u) => u.Permissions, // 성공 시 권한 store 자동 set
messages: { invalidCredentials: '사용자명 또는 비밀번호가 올바르지 않습니다.' },
})
// 부팅 게이트 — 답은 셋이다
const session = await auth.fetchMe()
if (session.status === 'anonymous') showLogin() // 401 — 로그인 화면
else if (session.status === 'unknown') showOffline(session.error) // 답을 못 받음 — 로그인으로 보내지 않는다
// @iyulab/modern-app 의 부팅 게이트는 이 답을 그대로 받는다
await app.load({ auth: { me: () => auth.fetchMe(), renderLogin, renderUnavailable }, /* … */ })
// 로그아웃도 결과를 본다 — 서버가 세션을 끊지 못했으면(2xx·401 이 아닌 답 · 응답 없음) 쿠키가 살아 있다
const out = await auth.logout()
if (out.ok) showLogin()
else showError(out.message) // 화면을 지키고 다시 시도하게 한다
// 어디서나 권한 판정(부팅 스냅샷)
if (hasPermission('orders.write')) { /* 저장 버튼 노출 */ }주입 항목(위 예시가 쓴 것 외 나머지):
| config | 용도 |
|--------|------|
| baseUrl | 상대 URL 앞에 붙일 오리진 (기본 '' = same-origin) |
| credentials | fetch credentials 모드 (기본 'same-origin' — 쿠키 세션) |
| extractLoginError | 로그인 실패(non-401) 응답 바디에서 사용자 대면 메시지 추출 오버라이드 (기본: 봉투 error.message → message → Message) |
| permissionStore | 권한 자동 갱신 대상 store (기본 defaultPermissionStore) — 격리가 필요하면 createPermissionStore()로 별도 store를 만들어 주입 |
| loginUser | 로그인 성공 뒤 세션 사용자를 어디서 얻는가 — 'body'(기본: 로그인 응답 본문) · 'me'(meUrl 재조회 — 로그인 응답이 { Success, User, Token } 같은 봉투인 서버). LoginResult 성공에는 응답 원문 body 도 실린다 |
fetchMe()는 던지지 않고{ status: 'authenticated', user }·{ status: 'anonymous' }(401) ·{ status: 'unknown', error }(그 밖의 비-2xx · 네트워크 · 읽을 수 없는 본문) 중 하나를 준다. 로그인 게이트는anonymous에서만 연다 —unknown을 미인증으로 읽으면 서버가 잠깐 503 을 낸 순간 모두가 로그인 화면으로 간다. 라이브러리는 리다이렉트하지 않는다.login()실패는{ ok: false, message, error }—error는ApiError(status·code·details, 응답이 없으면status: 0)라 429 · 403 거절 코드 같은 갈래로 화면을 나눌 수 있다.- 권한 store 는 «모름» 과 «없음» 을 가른다(
isKnown()·permissionsKnown()).unknown세션은 마지막으로 알던 권한을 건드리지 않는다. - 도메인 판정(
isPortalUser등)·권한 코드 상수는 라이브러리가 아니라 앱 adapter 에 둔다.
도메인 헬퍼
import { DateHelper } from '@iyulab/enterprise'
import { formatCurrency } from '@iyulab/components/dist/utilities/format.js' // 통화·숫자 포맷은 components 의 것(배럴은 컴포넌트를 전부 등록한다)
DateHelper.formatDate('2026-10-09') // 그 날(로컬 달력)로 읽는다
formatCurrency(1234000, 'KRW') // ₩1,234,000Development
npm run buildLicense
MIT
