@ph-cms/client-sdk 0.1.6 → 0.1.8

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,16 +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)
13
- - **Firebase Auth State Sync** — automatic Firebase↔PH-CMS session synchronization (from v0.1.6)
7
+ - 타입이 지정된 API 클라이언트
8
+ - Auth Provider 인터페이스 구현체 (`LocalAuthProvider`, `FirebaseAuthProvider`)
9
+ - React Context Hooks
10
+ - React Query 통합
11
+ - 자동 토큰 갱신 (Proactive + Reactive)
12
+ - Firebase PH-CMS 인증 동기화
13
+ - 세분화된 인증 상태 관리 (`authStatus`)
14
14
 
15
15
  ## Installation
16
16
 
@@ -18,7 +18,82 @@ This package provides:
18
18
  npm install @ph-cms/client-sdk
19
19
  ```
20
20
 
21
- > **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의 자동완성에서 바로 확인할 수 있습니다.
22
97
 
23
98
  ---
24
99
 
@@ -26,46 +101,24 @@ npm install @ph-cms/client-sdk
26
101
 
27
102
  ### Overview
28
103
 
29
- The SDK's authentication system is composed of three layers:
104
+ SDK 인증 시스템은 개의 레이어로 구성됩니다:
30
105
 
31
106
  | Layer | Component | Role |
32
107
  |---|---|---|
33
- | **Provider** | `LocalAuthProvider` / `FirebaseAuthProvider` | 토큰 저장·조회·삭제를 담당하는 저수준 어댑터 |
108
+ | **Provider** | `LocalAuthProvider` / `FirebaseAuthProvider` | 토큰 저장·조회·갱신·삭제를 담당하는 저수준 어댑터 |
34
109
  | **Module** | `AuthModule` (`client.auth`) | 서버 API 호출 (`login`, `loginWithFirebase`, `me`, `refresh`, `logout`) |
35
- | **Hook / Context** | `PHCMSProvider`, `useAuth` | React 상태 관리 — 토큰 유무에 따른 조건부 프로필 조회, 로그인·로그아웃 액션 |
36
-
37
- ```
38
- ┌─────────────────────────────────────────────────────────┐
39
- │ React Component Tree │
40
- │ │
41
- │ PHCMSProvider │
42
- │ └─ PHCMSInternalProvider │
43
- │ ├─ useQuery(['auth','me'])
44
- │ │ enabled: authProvider.hasToken()
45
- │ │ → 토큰 없으면 호출하지 않음 (401 방지)
46
- │ │ → 토큰 있으면 자동으로 me() 호출 │
47
- │ └─ context value: { user, isAuthenticated, ... } │
48
- │ │
49
- │ useAuth() │
50
- │ ├─ login() ──→ AuthModule.login() │
51
- │ │ ├─ POST /api/auth/login │
52
- │ │ └─ provider.setTokens() │
53
- │ │ ──→ refetchQueries(['auth','me']) │
54
- │ │ └─ GET /api/auth/me ← 프로필 보장 │
55
- │ │ │
56
- │ ├─ loginWithFirebase() ──→ AuthModule.loginWithFirebase() │
57
- │ │ ├─ POST /api/auth/firebase/exchange │
58
- │ │ └─ provider.setTokens() │
59
- │ │ ──→ refetchQueries(['auth','me']) │
60
- │ │ └─ GET /api/auth/me ← 프로필 보장 │
61
- │ │ │
62
- │ ├─ register() ──→ AuthModule.register() │
63
- │ │ ──→ refetchQueries(['auth','me']) │
64
- │ │ │
65
- │ └─ logout() ──→ AuthModule.logout() │
66
- │ ──→ setQueryData(['auth','me'], null) │
67
- └─────────────────────────────────────────────────────────┘
68
- ```
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()` (상태 초기화) |
69
122
 
70
123
  ### Authentication Lifecycle
71
124
 
@@ -76,7 +129,7 @@ App Mount
76
129
  → PHCMSProvider mount
77
130
  → authProvider.hasToken() ← false (localStorage에 토큰 없음)
78
131
  → useQuery enabled: false ← me() 호출하지 않음
79
- user: null, isAuthenticated: false
132
+ authStatus: 'unauthenticated'
80
133
  ```
81
134
 
82
135
  서버에 불필요한 401 요청을 보내지 않습니다.
@@ -87,62 +140,79 @@ App Mount
87
140
  App Mount
88
141
  → PHCMSProvider mount
89
142
  → authProvider.hasToken() ← true (localStorage에 토큰 존재)
90
- → useQuery enabled: true ← me() 자동 호출
143
+ → useQuery enabled: true
144
+ → authStatus: 'loading' ← 이 시점에 스플래시 화면 표시 가능
91
145
  → GET /api/auth/me
92
- → user: { ... }, isAuthenticated: true
146
+ authStatus: 'authenticated', user: { ... }
93
147
  ```
94
148
 
95
- 페이지 새로고침이나 재방문 시 기존 세션이 자동으로 복원됩니다.
96
-
97
149
  #### 3. 로그인 (이메일/비밀번호)
98
150
 
99
151
  ```
100
152
  user calls login({ email, password })
101
153
  → POST /api/auth/login
102
- 서버 응답: { accessToken, refreshToken }
103
- provider.setTokens(accessToken, refreshToken) ← localStorage 저장
104
- → refetchQueries(['auth', 'me'])
154
+ provider.setTokens(accessToken, refreshToken)
155
+ refreshUser()
105
156
  → GET /api/auth/me
106
- → user: { ... }, isAuthenticated: true
157
+ authStatus: 'authenticated', user: { ... }
107
158
  ```
108
159
 
109
- #### 4. 로그인 (Firebase)
160
+ #### 4. 로그아웃
110
161
 
111
162
  ```
112
- user calls loginWithFirebase({ idToken })
113
- POST /api/auth/firebase/exchange
114
- → 서버 응답: { accessToken, refreshToken }
115
- provider.setTokens(accessToken, refreshToken) ← localStorage 저장
116
- refetchQueries(['auth', 'me'])
117
- GET /api/auth/me
118
- → 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
119
169
  ```
120
170
 
121
- Firebase ID 토큰을 PH-CMS 자체 토큰으로 교환한 뒤, 동일한 프로필 조회 흐름을 따릅니다.
171
+ ### Token Refresh (자동 토큰 갱신)
172
+
173
+ SDK는 두 가지 방식으로 토큰을 자동 갱신합니다:
122
174
 
123
- #### 5. 로그아웃
175
+ #### Proactive Refresh (사전 갱신)
176
+
177
+ Provider의 `getToken()` 호출 시 JWT의 `exp` 클레임을 검사합니다.
178
+ 만료 임박 시 (`expiryBufferMs` 이내, 기본 60초) 서버에 갱신 요청을 보냅니다.
124
179
 
125
180
  ```
126
- user calls logout()
127
- → provider.logout() ← localStorage 토큰 삭제
128
- POST /api/auth/logout ← 서버 세션 무효화 (실패해도 무시)
129
- setQueryData(['auth','me'], null)
130
- 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}
131
188
  ```
132
189
 
133
- ### `refetchQueries` vs `invalidateQueries`
190
+ #### Reactive Refresh (401 대응 갱신)
191
+
192
+ 서버가 401을 반환하면 interceptor가 자동으로 토큰을 갱신하고 원본 요청을 재시도합니다.
193
+
194
+ ```
195
+ API Request → 401 Unauthorized
196
+ → coordinatedRefresh(refreshToken)
197
+ → POST /api/auth/refresh (with _skipAuth flag)
198
+ → 성공: provider.setTokens() → 원본 요청 재시도
199
+ → 실패: ApiError throw
200
+ ```
134
201
 
135
- 로그인 성공 프로필 조회에는 `invalidateQueries` 대신 `refetchQueries`를 사용합니다.
202
+ **동시 요청 처리:** 여러 요청이 동시에 401을 받으면 하나의 refresh 요청만 실행되고,
203
+ 나머지 요청은 큐에서 대기합니다 (de-duplication).
136
204
 
137
- - `invalidateQueries`는 쿼리를 stale로 표시만 하고, `enabled: true`인 쿼리만 자동 refetch합니다.
138
- - 로그인 직후에는 `provider.setTokens()`로 토큰이 저장되지만, React 리렌더가 아직 발생하지 않아 `hasToken()`의 반환값이 context에 반영되지 않은 상태입니다.
139
- - 따라서 `enabled`가 여전히 `false`일 수 있고, `invalidateQueries`만으로는 `me()` 호출이 보장되지 않습니다.
140
- - `refetchQueries`는 `enabled` 상태와 무관하게 즉시 네트워크 요청을 강제하므로, 토큰 저장 직후 프로필 조회를 확실히 보장합니다.
205
+ **순환 방지:** refresh 요청 자체는 `_skipAuth` 플래그가 붙어 request interceptor에서
206
+ 토큰 첨부를 건너뛰므로, `getToken()` `tryRefresh()` 재귀 호출이 발생하지 않습니다.
141
207
 
142
208
  ---
143
209
 
144
210
  ## Auth Providers
145
211
 
212
+ 모든 Provider는 `BaseAuthProvider` 추상 클래스를 상속합니다.
213
+ 공통 로직(토큰 저장, 갱신, de-duplication, localStorage 관리)은 `BaseAuthProvider`에 구현되어 있고,
214
+ 각 Provider는 `getToken()`과 `logout()` 등 고유 로직만 오버라이드합니다.
215
+
146
216
  ### `LocalAuthProvider`
147
217
 
148
218
  이메일/비밀번호 기반 인증에 사용합니다. 토큰을 `localStorage`에 저장합니다.
@@ -150,7 +220,9 @@ user calls logout()
150
220
  ```ts
151
221
  import { LocalAuthProvider, PHCMSClient } from '@ph-cms/client-sdk';
152
222
 
153
- const authProvider = new LocalAuthProvider('my_app_');
223
+ const authProvider = new LocalAuthProvider('my_app_', {
224
+ expiryBufferMs: 60_000, // 선택 — 만료 60초 전부터 갱신 시도 (기본값)
225
+ });
154
226
 
155
227
  const client = new PHCMSClient({
156
228
  baseURL: 'https://api.example.com',
@@ -160,21 +232,12 @@ const client = new PHCMSClient({
160
232
 
161
233
  | 생성자 인자 | 타입 | 기본값 | 설명 |
162
234
  |---|---|---|---|
163
- | `storageKeyPrefix` | `string` | `'ph_cms_'` | `localStorage` 키 접두사. `{prefix}access_token`, `{prefix}refresh_token` 형태로 저장됩니다. |
164
-
165
- #### 주요 메서드
166
-
167
- | 메서드 | 설명 |
168
- |---|---|
169
- | `hasToken()` | `accessToken` 또는 `refreshToken`이 존재하면 `true` 반환 |
170
- | `getToken()` | 현재 `accessToken` 반환 (`Promise<string \| null>`) |
171
- | `setTokens(access, refresh)` | 토큰 저장 (login 성공 시 SDK가 자동 호출) |
172
- | `getRefreshToken()` | 현재 `refreshToken` 반환 |
173
- | `logout()` | 토큰 삭제 |
235
+ | `storageKeyPrefix` | `string` | `'ph_cms_'` | `localStorage` 키 접두사 (`{prefix}access_token`, `{prefix}refresh_token`) |
236
+ | `options.expiryBufferMs` | `number` | `60_000` | 만료 몇 ms 전부터 토큰을 갱신할지 |
174
237
 
175
238
  ### `FirebaseAuthProvider`
176
239
 
177
- Firebase Authentication과 연동하여 사용합니다. Firebase ID 토큰을 PH-CMS 서버 토큰으로 교환하는 방식입니다.
240
+ Firebase Authentication과 연동합니다. PH-CMS 토큰이 없으면 Firebase ID 토큰으로 fallback합니다.
178
241
 
179
242
  ```ts
180
243
  import { FirebaseAuthProvider, PHCMSClient } from '@ph-cms/client-sdk';
@@ -184,7 +247,9 @@ import { getAuth } from 'firebase/auth';
184
247
  const firebaseApp = initializeApp({ /* Firebase config */ });
185
248
  const firebaseAuth = getAuth(firebaseApp);
186
249
 
187
- const authProvider = new FirebaseAuthProvider(firebaseAuth, 'my_app_fb_');
250
+ const authProvider = new FirebaseAuthProvider(firebaseAuth, 'my_app_fb_', {
251
+ expiryBufferMs: 60_000,
252
+ });
188
253
 
189
254
  const client = new PHCMSClient({
190
255
  baseURL: 'https://api.example.com',
@@ -196,32 +261,41 @@ const client = new PHCMSClient({
196
261
  |---|---|---|---|
197
262
  | `auth` | `firebase/auth.Auth` | (필수) | Firebase Auth 인스턴스 |
198
263
  | `storageKeyPrefix` | `string` | `'ph_cms_fb_'` | `localStorage` 키 접두사 |
264
+ | `options.expiryBufferMs` | `number` | `60_000` | 만료 몇 ms 전부터 토큰을 갱신할지 |
199
265
 
200
- #### 주요 메서드
266
+ **`getToken()` 동작:**
201
267
 
202
- | 메서드 | 설명 |
203
- |---|---|
204
- | `hasToken()` | PH-CMS 교환 토큰이 존재하면 `true` 반환 |
205
- | `getToken()` | PH-CMS `accessToken` → Firebase ID 토큰 순으로 fallback 반환 |
206
- | `getIdToken()` | Firebase ID 토큰을 직접 반환 (토큰 교환 시 사용) |
207
- | `setTokens(access, refresh)` | 교환된 PH-CMS 토큰 저장 (SDK가 자동 호출) |
208
- | `logout()` | PH-CMS 토큰 삭제 + `firebase.auth.signOut()` 호출 |
268
+ 1. PH-CMS access token이 유효하면 → 그대로 반환
269
+ 2. 만료 임박이면 → `tryRefresh()` 시도 → 성공하면 새 토큰 반환
270
+ 3. PH-CMS 토큰이 없거나 갱신 실패 → Firebase ID 토큰으로 fallback
209
271
 
210
272
  ### `AuthProvider` Interface
211
273
 
212
- 커스텀 인증 프로바이더를 구현하려면 아래 인터페이스를 구현합니다:
274
+ 커스텀 인증 Provider를 구현하려면 아래 인터페이스를 따릅니다.
275
+ `BaseAuthProvider`를 상속하면 대부분의 메서드가 이미 구현되어 있으므로, `type`과 `getToken()`만 구현하면 됩니다.
213
276
 
214
277
  ```ts
215
278
  interface AuthProvider {
216
279
  type: 'FIREBASE' | 'LOCAL';
217
280
  getToken(): Promise<string | null>;
218
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;
219
285
  onTokenExpired(callback: () => Promise<void>): void;
220
286
  logout(): Promise<void>;
221
287
  }
222
288
  ```
223
289
 
224
- `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()` | 세션 정리 (토큰 삭제) |
225
299
 
226
300
  ---
227
301
 
@@ -248,7 +322,7 @@ export function App() {
248
322
  }
249
323
  ```
250
324
 
251
- `PHCMSProvider`는 내부적으로 `QueryClientProvider`를 포함하므로, 별도로 추가할 필요가 없습니다.
325
+ `PHCMSProvider`는 내부적으로 `QueryClientProvider`를 포함합니다.
252
326
  외부에서 직접 관리하는 `QueryClient`를 사용하려면 `queryClient` prop으로 전달합니다:
253
327
 
254
328
  ```tsx
@@ -261,6 +335,39 @@ const queryClient = new QueryClient();
261
335
  </PHCMSProvider>
262
336
  ```
263
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
+
264
371
  ### Authentication with `useAuth`
265
372
 
266
373
  `useAuth`는 인증 상태와 액션을 통합 제공하는 메인 훅입니다.
@@ -271,9 +378,9 @@ import { useAuth } from '@ph-cms/client-sdk';
271
378
  function AuthComponent() {
272
379
  const {
273
380
  // 상태
274
- user, // UserDto | null — 현재 로그인한 사용자 프로필
381
+ user, // UserDto | null
275
382
  isAuthenticated, // boolean
276
- isLoading, // boolean — 프로필 로딩 중 여부
383
+ isLoading, // boolean
277
384
 
278
385
  // 액션 (모두 Promise 반환)
279
386
  login, // (data: LoginRequest) => Promise<AuthResponse>
@@ -287,8 +394,6 @@ function AuthComponent() {
287
394
  registerStatus,
288
395
  logoutStatus,
289
396
  } = useAuth();
290
-
291
- // ...
292
397
  }
293
398
  ```
294
399
 
@@ -301,14 +406,14 @@ function LoginForm() {
301
406
  const [password, setPassword] = useState('');
302
407
 
303
408
  if (isAuthenticated) {
304
- return <p>Welcome, {user?.displayName}</p>;
409
+ return <p>Welcome, {user?.display_name}</p>;
305
410
  }
306
411
 
307
412
  const handleSubmit = async (e: React.FormEvent) => {
308
413
  e.preventDefault();
309
414
  try {
310
415
  await login({ email, password });
311
- // 성공 자동으로 me() 호출 → user, isAuthenticated 갱신
416
+ // 성공 자동으로 me() 호출 → user, isAuthenticated 갱신
312
417
  } catch (error) {
313
418
  console.error('Login failed:', error);
314
419
  }
@@ -330,30 +435,22 @@ function LoginForm() {
330
435
  #### Firebase 로그인
331
436
 
332
437
  Firebase 로그인은 두 단계로 이루어집니다:
333
- 1. Firebase SDK로 인증하여 ID 토큰을 획득
334
- 2. `loginWithFirebase()`로 PH-CMS 서버에 토큰 교환 요청
438
+ 1. Firebase SDK로 인증하여 ID 토큰 획득
439
+ 2. `loginWithFirebase()`로 PH-CMS 서버에 토큰 교환
335
440
 
336
441
  ```tsx
337
- import { FirebaseAuthProvider } from '@ph-cms/client-sdk';
338
442
  import { getAuth, signInWithPopup, GoogleAuthProvider } from 'firebase/auth';
339
443
 
340
- // firebaseProvider는 앱 초기화 시 생성
341
- declare const firebaseProvider: FirebaseAuthProvider;
342
-
343
444
  function FirebaseLoginButton() {
344
445
  const { loginWithFirebase, loginWithFirebaseStatus } = useAuth();
345
446
 
346
447
  const handleFirebaseLogin = async () => {
347
- // 1) Firebase 인증 (Google 팝업 예시)
348
448
  const auth = getAuth();
349
- await signInWithPopup(auth, new GoogleAuthProvider());
350
-
351
- // 2) Firebase ID 토큰 획득
352
- const idToken = await firebaseProvider.getIdToken();
353
- if (!idToken) return;
449
+ const result = await signInWithPopup(auth, new GoogleAuthProvider());
450
+ const idToken = await result.user.getIdToken();
354
451
 
355
- // 3) PH-CMS 서버에 토큰 교환 → 자동으로 me() 호출
356
452
  await loginWithFirebase({ idToken });
453
+ // 성공 → 자동으로 me() 호출 → 인증 상태 갱신
357
454
  };
358
455
 
359
456
  return (
@@ -377,7 +474,7 @@ function RegisterForm() {
377
474
  username?: string;
378
475
  }) => {
379
476
  await register(formData);
380
- // 성공 자동으로 me() 호출 → 즉시 인증 상태로 전환
477
+ // 성공 자동으로 me() 호출 → 즉시 인증 상태로 전환
381
478
  };
382
479
 
383
480
  // ...
@@ -407,20 +504,21 @@ function MyComponent() {
407
504
  user, // UserDto | null
408
505
  isAuthenticated, // boolean
409
506
  isLoading, // boolean
507
+ authStatus, // 'loading' | 'authenticated' | 'unauthenticated'
508
+ hasToken, // boolean — Provider에 토큰이 존재하는지
410
509
  refreshUser, // () => Promise<void> — 수동으로 프로필 다시 조회
411
510
  } = usePHCMSContext();
412
511
 
413
- // 프로필 정보가 변경된 후 수동 갱신
414
512
  const handleProfileUpdate = async () => {
415
- await client.auth.me(); // or your update API
416
- await refreshUser();
513
+ // 프로필 수정 API 호출
514
+ await refreshUser(); // context 갱신
417
515
  };
418
516
  }
419
517
  ```
420
518
 
421
519
  ### Standalone (Non-React) Usage
422
520
 
423
- React 없이 `PHCMSClient`와 `AuthModule`을 직접 사용할 수 있습니다.
521
+ React 없이 `PHCMSClient`를 직접 사용할 수 있습니다.
424
522
 
425
523
  ```ts
426
524
  import { PHCMSClient, LocalAuthProvider } from '@ph-cms/client-sdk';
@@ -431,18 +529,18 @@ const client = new PHCMSClient({
431
529
  auth: authProvider,
432
530
  });
433
531
 
434
- // 로그인
532
+ // 로그인 — provider에 토큰이 자동 저장됨
435
533
  const authResponse = await client.auth.login({
436
534
  email: 'user@example.com',
437
535
  password: 'password',
438
536
  });
439
- // → authProvider에 토큰이 자동 저장됨
440
537
 
441
538
  // 프로필 조회
442
539
  const me = await client.auth.me();
443
540
  console.log(me.email);
444
541
 
445
- // 토큰 갱신
542
+ // 토큰 갱신 (수동)
543
+ // 일반적으로 SDK가 자동으로 처리하지만, 필요 시 직접 호출 가능
446
544
  const refreshToken = authProvider.getRefreshToken();
447
545
  if (refreshToken) {
448
546
  const newTokens = await client.auth.refresh(refreshToken);
@@ -465,9 +563,7 @@ await client.auth.logout();
465
563
 
466
564
  ### Firebase Auth Sync
467
565
 
468
- Firebase Authentication을 사용하는 경우, Firebase의 인증 상태가 변경될 때마다 PH-CMS 백엔드와 자동으로 동기화할 수 있습니다.
469
-
470
- SDK는 두 가지 방식을 제공합니다:
566
+ Firebase 인증 상태가 변경될 PH-CMS 백엔드와 자동으로 동기화할 수 있습니다.
471
567
 
472
568
  | 방식 | 용도 |
473
569
  |---|---|
@@ -490,7 +586,7 @@ Firebase onAuthStateChanged
490
586
  → refreshUser() ← 상태 초기화
491
587
  ```
492
588
 
493
- #### `<FirebaseAuthSync>` 컴포넌트 사용
589
+ #### `<FirebaseAuthSync>` 컴포넌트
494
590
 
495
591
  `<PHCMSProvider>` 안에서 사용합니다:
496
592
 
@@ -511,16 +607,14 @@ function App() {
511
607
  }
512
608
  ```
513
609
 
514
- Props:
515
-
516
610
  | Prop | 타입 | 기본값 | 설명 |
517
611
  |---|---|---|---|
518
612
  | `firebaseAuth` | `firebase/auth.Auth` | (필수) | Firebase Auth 인스턴스 |
519
- | `logoutOnFirebaseSignOut` | `boolean` | `true` | Firebase 로그아웃 시 PH-CMS도 자동 로그아웃할지 여부 |
613
+ | `logoutOnFirebaseSignOut` | `boolean` | `true` | Firebase 로그아웃 시 PH-CMS도 자동 로그아웃할지 |
520
614
  | `onSyncSuccess` | `() => void` | — | 토큰 교환 성공 시 콜백 |
521
615
  | `onSyncError` | `(error: unknown) => void` | — | 토큰 교환 실패 시 콜백 |
522
616
 
523
- #### `useFirebaseAuthSync` 훅 사용
617
+ #### `useFirebaseAuthSync` 훅
524
618
 
525
619
  ```tsx
526
620
  import { useFirebaseAuthSync } from '@ph-cms/client-sdk';
@@ -541,33 +635,28 @@ function AppContent() {
541
635
  }
542
636
  ```
543
637
 
544
- 반환값:
545
-
546
- | 필드 | 타입 | 설명 |
547
- |---|---|---|
548
- | `isSyncing` | `boolean` | 토큰 교환 요청이 진행 중인지 여부 |
549
-
550
- #### 전체 구성 예시 (Firebase + LocalAuthProvider)
638
+ #### 전체 구성 예시 (Firebase)
551
639
 
552
640
  ```tsx
553
- import { PHCMSClient, LocalAuthProvider, FirebaseAuthProvider, PHCMSProvider, FirebaseAuthSync } from '@ph-cms/client-sdk';
641
+ import {
642
+ PHCMSClient,
643
+ FirebaseAuthProvider,
644
+ PHCMSProvider,
645
+ FirebaseAuthSync,
646
+ } from '@ph-cms/client-sdk';
554
647
  import { initializeApp } from 'firebase/app';
555
648
  import { getAuth } from 'firebase/auth';
556
649
 
557
- // 1. Firebase 초기화
558
650
  const firebaseApp = initializeApp({ /* config */ });
559
651
  const firebaseAuth = getAuth(firebaseApp);
560
652
 
561
- // 2. PH-CMS 프로바이더 (Firebase 타입)
562
- const authProvider = new FirebaseAuthProvider(firebaseAuth, 'my_app_');
653
+ const authProvider = new FirebaseAuthProvider(firebaseAuth, 'my_app_fb_');
563
654
 
564
- // 3. PH-CMS 클라이언트
565
655
  const client = new PHCMSClient({
566
656
  baseURL: 'https://api.example.com',
567
657
  auth: authProvider,
568
658
  });
569
659
 
570
- // 4. 앱 구성
571
660
  function App() {
572
661
  return (
573
662
  <PHCMSProvider client={client}>
@@ -583,9 +672,10 @@ function App() {
583
672
  ```
584
673
 
585
674
  이 구성에서는:
586
- - 사용자가 Firebase (예: Google 로그인)로 인증하면, `FirebaseAuthSync`가 자동으로 PH-CMS 토큰 교환 + 프로필 조회를 수행합니다.
587
- - 사용자가 Firebase에서 로그아웃하면, PH-CMS 세션도 자동으로 정리됩니다.
588
- - 앱 재방문 시 localStorage 토큰이 남아있으면 `PHCMSProvider`가 자동으로 `me()`를 호출하여 세션을 복원합니다.
675
+ - Firebase 로그인 `FirebaseAuthSync`가 자동으로 PH-CMS 토큰 교환 + 프로필 조회를 수행
676
+ - Firebase 로그아웃 PH-CMS 세션도 자동 정리
677
+ - 앱 재방문 시 localStorage 토큰으로 세션 자동 복원
678
+ - 토큰 만료 시 자동 갱신 (proactive + reactive)
589
679
 
590
680
  ---
591
681
 
@@ -626,9 +716,7 @@ function ChannelList() {
626
716
 
627
717
  ## Media & File Upload
628
718
 
629
- Uploading media files follows a 3-step process: Request a Ticket Upload to S3 Create/Update Content.
630
-
631
- ### Simple Workflow Example
719
+ 미디어 업로드는 3단계로 진행됩니다: Ticket 발급S3 업로드Content 생성/수정.
632
720
 
633
721
  ```tsx
634
722
  import { useMediaUploadTickets, useUploadToS3, useCreateContent } from '@ph-cms/client-sdk';
@@ -642,7 +730,7 @@ function MediaUploader() {
642
730
  const file = event.target.files?.[0];
643
731
  if (!file) return;
644
732
 
645
- // Step 1: Get Upload Ticket (Presigned URL)
733
+ // Step 1: Upload Ticket 발급 (Presigned URL)
646
734
  const tickets = await getTickets([{
647
735
  filename: file.name,
648
736
  contentType: file.type,
@@ -651,14 +739,14 @@ function MediaUploader() {
651
739
 
652
740
  const { mediaUid, uploadUrl } = tickets[0];
653
741
 
654
- // Step 2: Upload File directly to S3
742
+ // Step 2: S3에 파일 직접 업로드
655
743
  await uploadToS3({ url: uploadUrl, file });
656
744
 
657
- // Step 3: Use the mediaUid to create content
745
+ // Step 3: mediaUid Content 생성
658
746
  await createContent({
659
747
  type: 'post',
660
748
  title: 'Post with image',
661
- mediaAttachments: [mediaUid]
749
+ mediaAttachments: [mediaUid],
662
750
  });
663
751
  };
664
752
 
@@ -706,7 +794,7 @@ const client = new PHCMSClient({
706
794
  timeout?: number; // 요청 타임아웃 ms (기본값: 10000)
707
795
  });
708
796
 
709
- client.authProvider // AuthProvider | undefined — 인증 프로바이더 접근
797
+ client.authProvider // AuthProvider | undefined
710
798
  client.axiosInstance // AxiosInstance — 내부 axios 인스턴스 직접 접근
711
799
  client.auth // AuthModule
712
800
  client.content // ContentModule
@@ -726,6 +814,24 @@ client.media // MediaModule
726
814
  | `refresh(refreshToken: string)` | 토큰 갱신 → `{ accessToken, refreshToken }` |
727
815
  | `logout()` | 로그아웃 (프로바이더 토큰 삭제 + 서버 세션 무효화) |
728
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
+
729
835
  ---
730
836
 
731
837
  ## License