@ph-cms/client-sdk 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,16 @@
1
1
  # @ph-cms/client-sdk
2
2
 
3
- Unified PH-CMS client SDK for browser and React applications.
3
+ PH-CMS 클라이언트 SDK 브라우저 React 애플리케이션을 위한 통합 클라이언트.
4
4
 
5
- This package provides:
5
+ **주요 기능:**
6
6
 
7
- - A typed API client
8
- - Auth provider interfaces and implementations
9
- - React context and hooks for consuming the client in UI code
10
- - **Integrated React Query support** (from v0.1.1)
11
- - **Automatic Auth State Management** (from v0.1.3)
12
- - **Conditional profile fetching & guaranteed post-login profile load** (from v0.1.4)
7
+ - 타입이 지정된 API 클라이언트
8
+ - Auth Provider 인터페이스 구현체 (`LocalAuthProvider`, `FirebaseAuthProvider`)
9
+ - React Context Hooks
10
+ - React Query 통합
11
+ - 자동 토큰 갱신 (Proactive + Reactive)
12
+ - Firebase PH-CMS 인증 동기화
13
+ - 세분화된 인증 상태 관리 (`authStatus`)
13
14
 
14
15
  ## Installation
15
16
 
@@ -17,7 +18,82 @@ This package provides:
17
18
  npm install @ph-cms/client-sdk
18
19
  ```
19
20
 
20
- > **Note:** `@tanstack/react-query` is a direct dependency. You no longer need to install it manually unless you use it in your own application code.
21
+ > `@tanstack/react-query`는 직접 의존성으로 포함되어 있어 별도 설치가 필요 없습니다.
22
+ > Firebase를 사용하는 경우 `firebase`를 별도로 설치하세요 (선택적 peer dependency).
23
+
24
+ ---
25
+
26
+ ## Type Imports
27
+
28
+ SDK에서 사용하는 모든 타입은 `@ph-cms/client-sdk`에서 직접 import할 수 있습니다.
29
+ `@ph-cms/api-contract`를 직접 참조할 필요 없이 SDK 하나로 모든 타입을 가져올 수 있습니다.
30
+
31
+ ```ts
32
+ // SDK 내부 타입
33
+ import type {
34
+ AuthProvider,
35
+ AuthStatus,
36
+ JwtPayload,
37
+ PHCMSClientConfig,
38
+ PHCMSContextType,
39
+ PHCMSProviderProps,
40
+ UseFirebaseAuthSyncOptions,
41
+ UseFirebaseAuthSyncReturn,
42
+ FirebaseAuthSyncProps,
43
+ } from '@ph-cms/client-sdk';
44
+
45
+ // API 도메인 타입 (api-contract에서 re-export)
46
+ import type {
47
+ // Auth
48
+ LoginRequest,
49
+ RegisterRequest,
50
+ RefreshTokenRequest,
51
+ FirebaseExchangeRequest,
52
+ AuthResponse,
53
+
54
+ // User
55
+ UserDto,
56
+
57
+ // Channel
58
+ ChannelDto,
59
+ CreateChannelDto,
60
+ ListChannelQuery,
61
+ CheckHierarchyQuery,
62
+ PagedChannelListResponse,
63
+
64
+ // Content
65
+ ContentDto,
66
+ ContentMediaDto,
67
+ CreateContentRequest,
68
+ UpdateContentRequest,
69
+ ListContentQuery,
70
+ PagedContentListResponse,
71
+
72
+ // Hierarchy / Policy
73
+ HierarchySetDto,
74
+ PermissionPolicySetDto,
75
+
76
+ // Terms
77
+ TermDto,
78
+ ListTermsQuery,
79
+ PagedTermListResponse,
80
+
81
+ // Geo
82
+ GeoJSON,
83
+ BoundsQuery,
84
+
85
+ // Media
86
+ MediaUploadTicketRequest,
87
+ MediaUploadTicketResponse,
88
+ MediaUploadTicketBatchRequest,
89
+ MediaUploadTicketBatchResponse,
90
+
91
+ // Common
92
+ PagedResponse,
93
+ } from '@ph-cms/client-sdk';
94
+ ```
95
+
96
+ 타입들은 `src/types.ts`에 도메인별로 정리되어 있으므로, IDE의 자동완성에서 바로 확인할 수 있습니다.
21
97
 
22
98
  ---
23
99
 
@@ -25,46 +101,24 @@ npm install @ph-cms/client-sdk
25
101
 
26
102
  ### Overview
27
103
 
28
- The SDK's authentication system is composed of three layers:
104
+ SDK 인증 시스템은 개의 레이어로 구성됩니다:
29
105
 
30
106
  | Layer | Component | Role |
31
107
  |---|---|---|
32
- | **Provider** | `LocalAuthProvider` / `FirebaseAuthProvider` | 토큰 저장·조회·삭제를 담당하는 저수준 어댑터 |
108
+ | **Provider** | `LocalAuthProvider` / `FirebaseAuthProvider` | 토큰 저장·조회·갱신·삭제를 담당하는 저수준 어댑터 |
33
109
  | **Module** | `AuthModule` (`client.auth`) | 서버 API 호출 (`login`, `loginWithFirebase`, `me`, `refresh`, `logout`) |
34
- | **Hook / Context** | `PHCMSProvider`, `useAuth` | React 상태 관리 — 토큰 유무에 따른 조건부 프로필 조회, 로그인·로그아웃 액션 |
35
-
36
- ```
37
- ┌─────────────────────────────────────────────────────────┐
38
- │ React Component Tree │
39
- │ │
40
- │ PHCMSProvider │
41
- │ └─ PHCMSInternalProvider │
42
- │ ├─ useQuery(['auth','me'])
43
- │ │ enabled: authProvider.hasToken()
44
- │ │ → 토큰 없으면 호출하지 않음 (401 방지)
45
- │ │ → 토큰 있으면 자동으로 me() 호출 │
46
- │ └─ context value: { user, isAuthenticated, ... } │
47
- │ │
48
- │ useAuth() │
49
- │ ├─ login() ──→ AuthModule.login() │
50
- │ │ ├─ POST /api/auth/login │
51
- │ │ └─ provider.setTokens() │
52
- │ │ ──→ refetchQueries(['auth','me']) │
53
- │ │ └─ GET /api/auth/me ← 프로필 보장 │
54
- │ │ │
55
- │ ├─ loginWithFirebase() ──→ AuthModule.loginWithFirebase() │
56
- │ │ ├─ POST /api/auth/firebase/exchange │
57
- │ │ └─ provider.setTokens() │
58
- │ │ ──→ refetchQueries(['auth','me']) │
59
- │ │ └─ GET /api/auth/me ← 프로필 보장 │
60
- │ │ │
61
- │ ├─ register() ──→ AuthModule.register() │
62
- │ │ ──→ refetchQueries(['auth','me']) │
63
- │ │ │
64
- │ └─ logout() ──→ AuthModule.logout() │
65
- │ ──→ setQueryData(['auth','me'], null) │
66
- └─────────────────────────────────────────────────────────┘
67
- ```
110
+ | **Hook / Context** | `PHCMSProvider`, `useAuth` | React 상태 관리 — `authStatus`, 로그인·로그아웃 액션 |
111
+
112
+ **PHCMSProvider** — `useQuery(['auth','me'])`를 `authProvider.hasToken()`이 `true`일 때만 실행하여 불필요한 401을 방지하고, context로 `{ user, authStatus, hasToken, ... }`을 제공합니다.
113
+
114
+ **useAuth()** 각 액션은 서버 호출 → `provider.setTokens()` → `refreshUser()`(= `me()` 재조회) 순서로 동작합니다:
115
+
116
+ | 액션 | 서버 호출 | 후속 동작 |
117
+ |---|---|---|
118
+ | `login()` | `POST /api/auth/login` | `setTokens()` → `refreshUser()` |
119
+ | `loginWithFirebase()` | `POST /api/auth/firebase/exchange` | `setTokens()` → `refreshUser()` |
120
+ | `register()` | `POST /api/auth/register` | `setTokens()` → `refreshUser()` |
121
+ | `logout()` | `POST /api/auth/logout` | `provider.logout()` → `refreshUser()` (상태 초기화) |
68
122
 
69
123
  ### Authentication Lifecycle
70
124
 
@@ -75,7 +129,7 @@ App Mount
75
129
  → PHCMSProvider mount
76
130
  → authProvider.hasToken() ← false (localStorage에 토큰 없음)
77
131
  → useQuery enabled: false ← me() 호출하지 않음
78
- user: null, isAuthenticated: false
132
+ authStatus: 'unauthenticated'
79
133
  ```
80
134
 
81
135
  서버에 불필요한 401 요청을 보내지 않습니다.
@@ -86,62 +140,79 @@ App Mount
86
140
  App Mount
87
141
  → PHCMSProvider mount
88
142
  → authProvider.hasToken() ← true (localStorage에 토큰 존재)
89
- → useQuery enabled: true ← me() 자동 호출
143
+ → useQuery enabled: true
144
+ → authStatus: 'loading' ← 이 시점에 스플래시 화면 표시 가능
90
145
  → GET /api/auth/me
91
- → user: { ... }, isAuthenticated: true
146
+ authStatus: 'authenticated', user: { ... }
92
147
  ```
93
148
 
94
- 페이지 새로고침이나 재방문 시 기존 세션이 자동으로 복원됩니다.
95
-
96
149
  #### 3. 로그인 (이메일/비밀번호)
97
150
 
98
151
  ```
99
152
  user calls login({ email, password })
100
153
  → POST /api/auth/login
101
- 서버 응답: { accessToken, refreshToken }
102
- provider.setTokens(accessToken, refreshToken) ← localStorage 저장
103
- → refetchQueries(['auth', 'me'])
154
+ provider.setTokens(accessToken, refreshToken)
155
+ refreshUser()
104
156
  → GET /api/auth/me
105
- → user: { ... }, isAuthenticated: true
157
+ authStatus: 'authenticated', user: { ... }
106
158
  ```
107
159
 
108
- #### 4. 로그인 (Firebase)
160
+ #### 4. 로그아웃
109
161
 
110
162
  ```
111
- user calls loginWithFirebase({ idToken })
112
- POST /api/auth/firebase/exchange
113
- → 서버 응답: { accessToken, refreshToken }
114
- provider.setTokens(accessToken, refreshToken) ← localStorage 저장
115
- refetchQueries(['auth', 'me'])
116
- GET /api/auth/me
117
- → user: { ... }, isAuthenticated: true
163
+ user calls logout()
164
+ provider.logout() ← localStorage 토큰 삭제
165
+ POST /api/auth/logout ← 서버 세션 무효화 (실패해도 무시)
166
+ refreshUser()
167
+ hasToken: false → queryData 초기화
168
+ authStatus: 'unauthenticated', user: null
118
169
  ```
119
170
 
120
- Firebase ID 토큰을 PH-CMS 자체 토큰으로 교환한 뒤, 동일한 프로필 조회 흐름을 따릅니다.
171
+ ### Token Refresh (자동 토큰 갱신)
172
+
173
+ SDK는 두 가지 방식으로 토큰을 자동 갱신합니다:
121
174
 
122
- #### 5. 로그아웃
175
+ #### Proactive Refresh (사전 갱신)
176
+
177
+ Provider의 `getToken()` 호출 시 JWT의 `exp` 클레임을 검사합니다.
178
+ 만료 임박 시 (`expiryBufferMs` 이내, 기본 60초) 서버에 갱신 요청을 보냅니다.
123
179
 
124
180
  ```
125
- user calls logout()
126
- → provider.logout() ← localStorage 토큰 삭제
127
- POST /api/auth/logout ← 서버 세션 무효화 (실패해도 무시)
128
- setQueryData(['auth','me'], null)
129
- user: null, isAuthenticated: false
181
+ Request Interceptor
182
+ → provider.getToken()
183
+ JWT exp 검사: 만료 임박?
184
+ Yes: tryRefresh() → POST /api/auth/refresh
185
+ 토큰 저장 → 새 accessToken 반환
186
+ → No: 기존 accessToken 반환
187
+ → Authorization: Bearer {token}
130
188
  ```
131
189
 
132
- ### `refetchQueries` vs `invalidateQueries`
190
+ #### Reactive Refresh (401 대응 갱신)
133
191
 
134
- 로그인 성공 프로필 조회에는 `invalidateQueries` 대신 `refetchQueries`를 사용합니다.
192
+ 서버가 401을 반환하면 interceptor가 자동으로 토큰을 갱신하고 원본 요청을 재시도합니다.
135
193
 
136
- - `invalidateQueries`는 쿼리를 stale로 표시만 하고, `enabled: true`인 쿼리만 자동 refetch합니다.
137
- - 로그인 직후에는 `provider.setTokens()`로 토큰이 저장되지만, React 리렌더가 아직 발생하지 않아 `hasToken()`의 반환값이 context에 반영되지 않은 상태입니다.
138
- - 따라서 `enabled`가 여전히 `false`일 수 있고, `invalidateQueries`만으로는 `me()` 호출이 보장되지 않습니다.
139
- - `refetchQueries`는 `enabled` 상태와 무관하게 즉시 네트워크 요청을 강제하므로, 토큰 저장 직후 프로필 조회를 확실히 보장합니다.
194
+ ```
195
+ API Request 401 Unauthorized
196
+ coordinatedRefresh(refreshToken)
197
+ POST /api/auth/refresh (with _skipAuth flag)
198
+ → 성공: provider.setTokens() → 원본 요청 재시도
199
+ → 실패: ApiError throw
200
+ ```
201
+
202
+ **동시 요청 처리:** 여러 요청이 동시에 401을 받으면 하나의 refresh 요청만 실행되고,
203
+ 나머지 요청은 큐에서 대기합니다 (de-duplication).
204
+
205
+ **순환 방지:** refresh 요청 자체는 `_skipAuth` 플래그가 붙어 request interceptor에서
206
+ 토큰 첨부를 건너뛰므로, `getToken()` → `tryRefresh()` 재귀 호출이 발생하지 않습니다.
140
207
 
141
208
  ---
142
209
 
143
210
  ## Auth Providers
144
211
 
212
+ 모든 Provider는 `BaseAuthProvider` 추상 클래스를 상속합니다.
213
+ 공통 로직(토큰 저장, 갱신, de-duplication, localStorage 관리)은 `BaseAuthProvider`에 구현되어 있고,
214
+ 각 Provider는 `getToken()`과 `logout()` 등 고유 로직만 오버라이드합니다.
215
+
145
216
  ### `LocalAuthProvider`
146
217
 
147
218
  이메일/비밀번호 기반 인증에 사용합니다. 토큰을 `localStorage`에 저장합니다.
@@ -149,7 +220,9 @@ user calls logout()
149
220
  ```ts
150
221
  import { LocalAuthProvider, PHCMSClient } from '@ph-cms/client-sdk';
151
222
 
152
- const authProvider = new LocalAuthProvider('my_app_');
223
+ const authProvider = new LocalAuthProvider('my_app_', {
224
+ expiryBufferMs: 60_000, // 선택 — 만료 60초 전부터 갱신 시도 (기본값)
225
+ });
153
226
 
154
227
  const client = new PHCMSClient({
155
228
  baseURL: 'https://api.example.com',
@@ -159,21 +232,12 @@ const client = new PHCMSClient({
159
232
 
160
233
  | 생성자 인자 | 타입 | 기본값 | 설명 |
161
234
  |---|---|---|---|
162
- | `storageKeyPrefix` | `string` | `'ph_cms_'` | `localStorage` 키 접두사. `{prefix}access_token`, `{prefix}refresh_token` 형태로 저장됩니다. |
163
-
164
- #### 주요 메서드
165
-
166
- | 메서드 | 설명 |
167
- |---|---|
168
- | `hasToken()` | `accessToken` 또는 `refreshToken`이 존재하면 `true` 반환 |
169
- | `getToken()` | 현재 `accessToken` 반환 (`Promise<string \| null>`) |
170
- | `setTokens(access, refresh)` | 토큰 저장 (login 성공 시 SDK가 자동 호출) |
171
- | `getRefreshToken()` | 현재 `refreshToken` 반환 |
172
- | `logout()` | 토큰 삭제 |
235
+ | `storageKeyPrefix` | `string` | `'ph_cms_'` | `localStorage` 키 접두사 (`{prefix}access_token`, `{prefix}refresh_token`) |
236
+ | `options.expiryBufferMs` | `number` | `60_000` | 만료 몇 ms 전부터 토큰을 갱신할지 |
173
237
 
174
238
  ### `FirebaseAuthProvider`
175
239
 
176
- Firebase Authentication과 연동하여 사용합니다. Firebase ID 토큰을 PH-CMS 서버 토큰으로 교환하는 방식입니다.
240
+ Firebase Authentication과 연동합니다. PH-CMS 토큰이 없으면 Firebase ID 토큰으로 fallback합니다.
177
241
 
178
242
  ```ts
179
243
  import { FirebaseAuthProvider, PHCMSClient } from '@ph-cms/client-sdk';
@@ -183,7 +247,9 @@ import { getAuth } from 'firebase/auth';
183
247
  const firebaseApp = initializeApp({ /* Firebase config */ });
184
248
  const firebaseAuth = getAuth(firebaseApp);
185
249
 
186
- const authProvider = new FirebaseAuthProvider(firebaseAuth, 'my_app_fb_');
250
+ const authProvider = new FirebaseAuthProvider(firebaseAuth, 'my_app_fb_', {
251
+ expiryBufferMs: 60_000,
252
+ });
187
253
 
188
254
  const client = new PHCMSClient({
189
255
  baseURL: 'https://api.example.com',
@@ -195,32 +261,41 @@ const client = new PHCMSClient({
195
261
  |---|---|---|---|
196
262
  | `auth` | `firebase/auth.Auth` | (필수) | Firebase Auth 인스턴스 |
197
263
  | `storageKeyPrefix` | `string` | `'ph_cms_fb_'` | `localStorage` 키 접두사 |
264
+ | `options.expiryBufferMs` | `number` | `60_000` | 만료 몇 ms 전부터 토큰을 갱신할지 |
198
265
 
199
- #### 주요 메서드
266
+ **`getToken()` 동작:**
200
267
 
201
- | 메서드 | 설명 |
202
- |---|---|
203
- | `hasToken()` | PH-CMS 교환 토큰이 존재하면 `true` 반환 |
204
- | `getToken()` | PH-CMS `accessToken` → Firebase ID 토큰 순으로 fallback 반환 |
205
- | `getIdToken()` | Firebase ID 토큰을 직접 반환 (토큰 교환 시 사용) |
206
- | `setTokens(access, refresh)` | 교환된 PH-CMS 토큰 저장 (SDK가 자동 호출) |
207
- | `logout()` | PH-CMS 토큰 삭제 + `firebase.auth.signOut()` 호출 |
268
+ 1. PH-CMS access token이 유효하면 → 그대로 반환
269
+ 2. 만료 임박이면 → `tryRefresh()` 시도 → 성공하면 새 토큰 반환
270
+ 3. PH-CMS 토큰이 없거나 갱신 실패 → Firebase ID 토큰으로 fallback
208
271
 
209
272
  ### `AuthProvider` Interface
210
273
 
211
- 커스텀 인증 프로바이더를 구현하려면 아래 인터페이스를 구현합니다:
274
+ 커스텀 인증 Provider를 구현하려면 아래 인터페이스를 따릅니다.
275
+ `BaseAuthProvider`를 상속하면 대부분의 메서드가 이미 구현되어 있으므로, `type`과 `getToken()`만 구현하면 됩니다.
212
276
 
213
277
  ```ts
214
278
  interface AuthProvider {
215
279
  type: 'FIREBASE' | 'LOCAL';
216
280
  getToken(): Promise<string | null>;
217
281
  hasToken(): boolean;
282
+ getRefreshToken(): string | null;
283
+ setRefreshFn(fn: (refreshToken: string) => Promise<{ accessToken: string; refreshToken: string }>): void;
284
+ setTokens(accessToken: string, refreshToken: string): void;
218
285
  onTokenExpired(callback: () => Promise<void>): void;
219
286
  logout(): Promise<void>;
220
287
  }
221
288
  ```
222
289
 
223
- `hasToken()`은 **동기** 메서드여야 합니다. React 렌더 사이클에서 `useQuery`의 `enabled` 조건으로 사용되므로, 비동기 호출은 허용되지 않습니다.
290
+ | 메서드 | 설명 |
291
+ |---|---|
292
+ | `hasToken()` | 토큰 존재 여부 (동기). React의 `useQuery` `enabled` 조건에 사용되므로 반드시 동기여야 합니다 |
293
+ | `getToken()` | 유효한 access token 반환. 만료 시 자동 갱신 시도 후 반환 |
294
+ | `getRefreshToken()` | 현재 refresh token 반환 |
295
+ | `setRefreshFn(fn)` | `PHCMSClient`가 생성 시 호출. Provider가 자체적으로 토큰을 갱신할 수 있게 함 |
296
+ | `setTokens(access, refresh)` | 새 토큰 쌍 저장 (login/refresh 성공 시 자동 호출) |
297
+ | `onTokenExpired(callback)` | 토큰 만료 + 갱신 실패 시 호출할 콜백 등록 |
298
+ | `logout()` | 세션 정리 (토큰 삭제) |
224
299
 
225
300
  ---
226
301
 
@@ -247,7 +322,7 @@ export function App() {
247
322
  }
248
323
  ```
249
324
 
250
- `PHCMSProvider`는 내부적으로 `QueryClientProvider`를 포함하므로, 별도로 추가할 필요가 없습니다.
325
+ `PHCMSProvider`는 내부적으로 `QueryClientProvider`를 포함합니다.
251
326
  외부에서 직접 관리하는 `QueryClient`를 사용하려면 `queryClient` prop으로 전달합니다:
252
327
 
253
328
  ```tsx
@@ -260,6 +335,39 @@ const queryClient = new QueryClient();
260
335
  </PHCMSProvider>
261
336
  ```
262
337
 
338
+ ### Authentication Status (`authStatus`)
339
+
340
+ Context는 세분화된 인증 상태를 `authStatus`로 제공합니다:
341
+
342
+ | `authStatus` | `hasToken` | `user` | 의미 |
343
+ |---|---|---|---|
344
+ | `'unauthenticated'` | `false` | `null` | 미로그인 (토큰 없음) → 로그인 화면 표시 |
345
+ | `'loading'` | `true` | `null` | 세션 복원 중 (`me()` 호출 중) → 스플래시 화면 표시 |
346
+ | `'authenticated'` | `true` | `UserDto` | 인증 완료 → 메인 화면 표시 |
347
+ | `'unauthenticated'` | `true` | `null` | 토큰 있으나 `me()` 실패 → 재로그인 유도 |
348
+
349
+ ```tsx
350
+ import { usePHCMSContext } from '@ph-cms/client-sdk';
351
+
352
+ function AppRouter() {
353
+ const { authStatus, hasToken } = usePHCMSContext();
354
+
355
+ switch (authStatus) {
356
+ case 'loading':
357
+ return <SplashScreen />;
358
+ case 'authenticated':
359
+ return <MainApp />;
360
+ case 'unauthenticated':
361
+ // hasToken이 true이면 토큰은 있었으나 만료/검증 실패
362
+ if (hasToken) return <SessionExpiredScreen />;
363
+ return <LoginScreen />;
364
+ }
365
+ }
366
+ ```
367
+
368
+ > `isAuthenticated`와 `isLoading`은 하위 호환을 위해 유지되지만,
369
+ > 내부적으로 `authStatus`에서 파생됩니다 (`isAuthenticated === authStatus === 'authenticated'`).
370
+
263
371
  ### Authentication with `useAuth`
264
372
 
265
373
  `useAuth`는 인증 상태와 액션을 통합 제공하는 메인 훅입니다.
@@ -270,9 +378,9 @@ import { useAuth } from '@ph-cms/client-sdk';
270
378
  function AuthComponent() {
271
379
  const {
272
380
  // 상태
273
- user, // UserDto | null — 현재 로그인한 사용자 프로필
381
+ user, // UserDto | null
274
382
  isAuthenticated, // boolean
275
- isLoading, // boolean — 프로필 로딩 중 여부
383
+ isLoading, // boolean
276
384
 
277
385
  // 액션 (모두 Promise 반환)
278
386
  login, // (data: LoginRequest) => Promise<AuthResponse>
@@ -286,8 +394,6 @@ function AuthComponent() {
286
394
  registerStatus,
287
395
  logoutStatus,
288
396
  } = useAuth();
289
-
290
- // ...
291
397
  }
292
398
  ```
293
399
 
@@ -300,14 +406,14 @@ function LoginForm() {
300
406
  const [password, setPassword] = useState('');
301
407
 
302
408
  if (isAuthenticated) {
303
- return <p>Welcome, {user?.displayName}</p>;
409
+ return <p>Welcome, {user?.display_name}</p>;
304
410
  }
305
411
 
306
412
  const handleSubmit = async (e: React.FormEvent) => {
307
413
  e.preventDefault();
308
414
  try {
309
415
  await login({ email, password });
310
- // 성공 자동으로 me() 호출 → user, isAuthenticated 갱신
416
+ // 성공 자동으로 me() 호출 → user, isAuthenticated 갱신
311
417
  } catch (error) {
312
418
  console.error('Login failed:', error);
313
419
  }
@@ -329,30 +435,22 @@ function LoginForm() {
329
435
  #### Firebase 로그인
330
436
 
331
437
  Firebase 로그인은 두 단계로 이루어집니다:
332
- 1. Firebase SDK로 인증하여 ID 토큰을 획득
333
- 2. `loginWithFirebase()`로 PH-CMS 서버에 토큰 교환 요청
438
+ 1. Firebase SDK로 인증하여 ID 토큰 획득
439
+ 2. `loginWithFirebase()`로 PH-CMS 서버에 토큰 교환
334
440
 
335
441
  ```tsx
336
- import { FirebaseAuthProvider } from '@ph-cms/client-sdk';
337
442
  import { getAuth, signInWithPopup, GoogleAuthProvider } from 'firebase/auth';
338
443
 
339
- // firebaseProvider는 앱 초기화 시 생성
340
- declare const firebaseProvider: FirebaseAuthProvider;
341
-
342
444
  function FirebaseLoginButton() {
343
445
  const { loginWithFirebase, loginWithFirebaseStatus } = useAuth();
344
446
 
345
447
  const handleFirebaseLogin = async () => {
346
- // 1) Firebase 인증 (Google 팝업 예시)
347
448
  const auth = getAuth();
348
- await signInWithPopup(auth, new GoogleAuthProvider());
449
+ const result = await signInWithPopup(auth, new GoogleAuthProvider());
450
+ const idToken = await result.user.getIdToken();
349
451
 
350
- // 2) Firebase ID 토큰 획득
351
- const idToken = await firebaseProvider.getIdToken();
352
- if (!idToken) return;
353
-
354
- // 3) PH-CMS 서버에 토큰 교환 → 자동으로 me() 호출
355
452
  await loginWithFirebase({ idToken });
453
+ // 성공 → 자동으로 me() 호출 → 인증 상태 갱신
356
454
  };
357
455
 
358
456
  return (
@@ -376,7 +474,7 @@ function RegisterForm() {
376
474
  username?: string;
377
475
  }) => {
378
476
  await register(formData);
379
- // 성공 자동으로 me() 호출 → 즉시 인증 상태로 전환
477
+ // 성공 자동으로 me() 호출 → 즉시 인증 상태로 전환
380
478
  };
381
479
 
382
480
  // ...
@@ -406,20 +504,21 @@ function MyComponent() {
406
504
  user, // UserDto | null
407
505
  isAuthenticated, // boolean
408
506
  isLoading, // boolean
507
+ authStatus, // 'loading' | 'authenticated' | 'unauthenticated'
508
+ hasToken, // boolean — Provider에 토큰이 존재하는지
409
509
  refreshUser, // () => Promise<void> — 수동으로 프로필 다시 조회
410
510
  } = usePHCMSContext();
411
511
 
412
- // 프로필 정보가 변경된 후 수동 갱신
413
512
  const handleProfileUpdate = async () => {
414
- await client.auth.me(); // or your update API
415
- await refreshUser();
513
+ // 프로필 수정 API 호출
514
+ await refreshUser(); // context 갱신
416
515
  };
417
516
  }
418
517
  ```
419
518
 
420
519
  ### Standalone (Non-React) Usage
421
520
 
422
- React 없이 `PHCMSClient`와 `AuthModule`을 직접 사용할 수 있습니다.
521
+ React 없이 `PHCMSClient`를 직접 사용할 수 있습니다.
423
522
 
424
523
  ```ts
425
524
  import { PHCMSClient, LocalAuthProvider } from '@ph-cms/client-sdk';
@@ -430,18 +529,18 @@ const client = new PHCMSClient({
430
529
  auth: authProvider,
431
530
  });
432
531
 
433
- // 로그인
532
+ // 로그인 — provider에 토큰이 자동 저장됨
434
533
  const authResponse = await client.auth.login({
435
534
  email: 'user@example.com',
436
535
  password: 'password',
437
536
  });
438
- // → authProvider에 토큰이 자동 저장됨
439
537
 
440
538
  // 프로필 조회
441
539
  const me = await client.auth.me();
442
540
  console.log(me.email);
443
541
 
444
- // 토큰 갱신
542
+ // 토큰 갱신 (수동)
543
+ // 일반적으로 SDK가 자동으로 처리하지만, 필요 시 직접 호출 가능
445
544
  const refreshToken = authProvider.getRefreshToken();
446
545
  if (refreshToken) {
447
546
  const newTokens = await client.auth.refresh(refreshToken);
@@ -462,6 +561,122 @@ await client.auth.logout();
462
561
  | `useLogin()` | `{ mutateAsync: login }` |
463
562
  | `useLogout()` | `{ mutateAsync: logout }` |
464
563
 
564
+ ### Firebase Auth Sync
565
+
566
+ Firebase 인증 상태가 변경될 때 PH-CMS 백엔드와 자동으로 동기화할 수 있습니다.
567
+
568
+ | 방식 | 용도 |
569
+ |---|---|
570
+ | `useFirebaseAuthSync` 훅 | 기존 컴포넌트에 동기화 로직을 삽입할 때 |
571
+ | `<FirebaseAuthSync>` 컴포넌트 | 컴포넌트 트리를 감싸서 선언적으로 사용할 때 |
572
+
573
+ #### 동기화 동작
574
+
575
+ ```
576
+ Firebase onAuthStateChanged
577
+
578
+ ├─ fbUser 존재 + PH-CMS 비인증 상태
579
+ │ → fbUser.getIdToken()
580
+ │ → client.auth.loginWithFirebase({ idToken })
581
+ │ → provider.setTokens(...)
582
+ │ → refreshUser() ← me() 호출하여 프로필 로드
583
+
584
+ └─ fbUser null (로그아웃) + PH-CMS 인증 상태
585
+ → client.auth.logout()
586
+ → refreshUser() ← 상태 초기화
587
+ ```
588
+
589
+ #### `<FirebaseAuthSync>` 컴포넌트
590
+
591
+ `<PHCMSProvider>` 안에서 사용합니다:
592
+
593
+ ```tsx
594
+ import { PHCMSProvider, FirebaseAuthSync } from '@ph-cms/client-sdk';
595
+ import { getAuth } from 'firebase/auth';
596
+
597
+ const firebaseAuth = getAuth(firebaseApp);
598
+
599
+ function App() {
600
+ return (
601
+ <PHCMSProvider client={client}>
602
+ <FirebaseAuthSync firebaseAuth={firebaseAuth}>
603
+ <MainContent />
604
+ </FirebaseAuthSync>
605
+ </PHCMSProvider>
606
+ );
607
+ }
608
+ ```
609
+
610
+ | Prop | 타입 | 기본값 | 설명 |
611
+ |---|---|---|---|
612
+ | `firebaseAuth` | `firebase/auth.Auth` | (필수) | Firebase Auth 인스턴스 |
613
+ | `logoutOnFirebaseSignOut` | `boolean` | `true` | Firebase 로그아웃 시 PH-CMS도 자동 로그아웃할지 |
614
+ | `onSyncSuccess` | `() => void` | — | 토큰 교환 성공 시 콜백 |
615
+ | `onSyncError` | `(error: unknown) => void` | — | 토큰 교환 실패 시 콜백 |
616
+
617
+ #### `useFirebaseAuthSync` 훅
618
+
619
+ ```tsx
620
+ import { useFirebaseAuthSync } from '@ph-cms/client-sdk';
621
+ import { getAuth } from 'firebase/auth';
622
+
623
+ const firebaseAuth = getAuth(firebaseApp);
624
+
625
+ function AppContent() {
626
+ const { isSyncing } = useFirebaseAuthSync({
627
+ firebaseAuth,
628
+ onSyncSuccess: () => console.log('Firebase↔PH-CMS 동기화 완료'),
629
+ onSyncError: (err) => console.error('동기화 실패:', err),
630
+ });
631
+
632
+ if (isSyncing) return <div>인증 동기화 중...</div>;
633
+
634
+ return <MainContent />;
635
+ }
636
+ ```
637
+
638
+ #### 전체 구성 예시 (Firebase)
639
+
640
+ ```tsx
641
+ import {
642
+ PHCMSClient,
643
+ FirebaseAuthProvider,
644
+ PHCMSProvider,
645
+ FirebaseAuthSync,
646
+ } from '@ph-cms/client-sdk';
647
+ import { initializeApp } from 'firebase/app';
648
+ import { getAuth } from 'firebase/auth';
649
+
650
+ const firebaseApp = initializeApp({ /* config */ });
651
+ const firebaseAuth = getAuth(firebaseApp);
652
+
653
+ const authProvider = new FirebaseAuthProvider(firebaseAuth, 'my_app_fb_');
654
+
655
+ const client = new PHCMSClient({
656
+ baseURL: 'https://api.example.com',
657
+ auth: authProvider,
658
+ });
659
+
660
+ function App() {
661
+ return (
662
+ <PHCMSProvider client={client}>
663
+ <FirebaseAuthSync
664
+ firebaseAuth={firebaseAuth}
665
+ onSyncError={(err) => alert('인증 동기화에 실패했습니다.')}
666
+ >
667
+ <Router />
668
+ </FirebaseAuthSync>
669
+ </PHCMSProvider>
670
+ );
671
+ }
672
+ ```
673
+
674
+ 이 구성에서는:
675
+ - Firebase 로그인 시 `FirebaseAuthSync`가 자동으로 PH-CMS 토큰 교환 + 프로필 조회를 수행
676
+ - Firebase 로그아웃 시 PH-CMS 세션도 자동 정리
677
+ - 앱 재방문 시 localStorage 토큰으로 세션 자동 복원
678
+ - 토큰 만료 시 자동 갱신 (proactive + reactive)
679
+
465
680
  ---
466
681
 
467
682
  ## Using Data Hooks
@@ -501,9 +716,7 @@ function ChannelList() {
501
716
 
502
717
  ## Media & File Upload
503
718
 
504
- Uploading media files follows a 3-step process: Request a Ticket Upload to S3 Create/Update Content.
505
-
506
- ### Simple Workflow Example
719
+ 미디어 업로드는 3단계로 진행됩니다: Ticket 발급S3 업로드Content 생성/수정.
507
720
 
508
721
  ```tsx
509
722
  import { useMediaUploadTickets, useUploadToS3, useCreateContent } from '@ph-cms/client-sdk';
@@ -517,7 +730,7 @@ function MediaUploader() {
517
730
  const file = event.target.files?.[0];
518
731
  if (!file) return;
519
732
 
520
- // Step 1: Get Upload Ticket (Presigned URL)
733
+ // Step 1: Upload Ticket 발급 (Presigned URL)
521
734
  const tickets = await getTickets([{
522
735
  filename: file.name,
523
736
  contentType: file.type,
@@ -526,14 +739,14 @@ function MediaUploader() {
526
739
 
527
740
  const { mediaUid, uploadUrl } = tickets[0];
528
741
 
529
- // Step 2: Upload File directly to S3
742
+ // Step 2: S3에 파일 직접 업로드
530
743
  await uploadToS3({ url: uploadUrl, file });
531
744
 
532
- // Step 3: Use the mediaUid to create content
745
+ // Step 3: mediaUid Content 생성
533
746
  await createContent({
534
747
  type: 'post',
535
748
  title: 'Post with image',
536
- mediaAttachments: [mediaUid]
749
+ mediaAttachments: [mediaUid],
537
750
  });
538
751
  };
539
752
 
@@ -581,7 +794,7 @@ const client = new PHCMSClient({
581
794
  timeout?: number; // 요청 타임아웃 ms (기본값: 10000)
582
795
  });
583
796
 
584
- client.authProvider // AuthProvider | undefined — 인증 프로바이더 접근
797
+ client.authProvider // AuthProvider | undefined
585
798
  client.axiosInstance // AxiosInstance — 내부 axios 인스턴스 직접 접근
586
799
  client.auth // AuthModule
587
800
  client.content // ContentModule
@@ -601,6 +814,24 @@ client.media // MediaModule
601
814
  | `refresh(refreshToken: string)` | 토큰 갱신 → `{ accessToken, refreshToken }` |
602
815
  | `logout()` | 로그아웃 (프로바이더 토큰 삭제 + 서버 세션 무효화) |
603
816
 
817
+ ### JWT Utilities
818
+
819
+ 클라이언트에서 토큰 상태를 확인할 수 있는 유틸리티입니다 (서명 검증은 하지 않음).
820
+
821
+ ```ts
822
+ import {
823
+ decodeJwtPayload,
824
+ getTokenExpirationMs,
825
+ isTokenExpired,
826
+ getTokenTTL,
827
+ } from '@ph-cms/client-sdk';
828
+
829
+ const payload = decodeJwtPayload(token); // JwtPayload | null
830
+ const expiresAt = getTokenExpirationMs(token); // number (ms) | null
831
+ const expired = isTokenExpired(token, 60_000); // boolean | null
832
+ const ttl = getTokenTTL(token); // number (ms) | null
833
+ ```
834
+
604
835
  ---
605
836
 
606
837
  ## License