@ph-cms/client-sdk 0.1.3 → 0.1.5

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
@@ -8,6 +8,8 @@ This package provides:
8
8
  - Auth provider interfaces and implementations
9
9
  - React context and hooks for consuming the client in UI code
10
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)
11
13
 
12
14
  ## Installation
13
15
 
@@ -15,45 +17,226 @@ This package provides:
15
17
  npm install @ph-cms/client-sdk
16
18
  ```
17
19
 
18
- > **Note:** `@tanstack/react-query` is a direct dependency from v0.1.1. You no longer need to install it manually unless you use it in your own application code.
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.
19
21
 
20
- If you use the React bindings, ensure you have `react` installed:
22
+ ---
23
+
24
+ ## Authentication Architecture
25
+
26
+ ### Overview
27
+
28
+ The SDK's authentication system is composed of three layers:
29
+
30
+ | Layer | Component | Role |
31
+ |---|---|---|
32
+ | **Provider** | `LocalAuthProvider` / `FirebaseAuthProvider` | 토큰 저장·조회·삭제를 담당하는 저수준 어댑터 |
33
+ | **Module** | `AuthModule` (`client.auth`) | 서버 API 호출 (`login`, `loginWithFirebase`, `me`, `refresh`, `logout`) |
34
+ | **Hook / Context** | `PHCMSProvider`, `useAuth` | React 상태 관리 — 토큰 유무에 따른 조건부 프로필 조회, 로그인·로그아웃 액션 |
21
35
 
22
- ```bash
23
- npm install react react-dom
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
+ └─────────────────────────────────────────────────────────┘
24
67
  ```
25
68
 
26
- If you use Firebase auth integration:
69
+ ### Authentication Lifecycle
70
+
71
+ #### 1. 초기 마운트 (비인증 상태)
72
+
73
+ ```
74
+ App Mount
75
+ → PHCMSProvider mount
76
+ → authProvider.hasToken() ← false (localStorage에 토큰 없음)
77
+ → useQuery enabled: false ← me() 호출하지 않음
78
+ → user: null, isAuthenticated: false
79
+ ```
80
+
81
+ 서버에 불필요한 401 요청을 보내지 않습니다.
82
+
83
+ #### 2. 초기 마운트 (기존 세션 복원)
84
+
85
+ ```
86
+ App Mount
87
+ → PHCMSProvider mount
88
+ → authProvider.hasToken() ← true (localStorage에 토큰 존재)
89
+ → useQuery enabled: true ← me() 자동 호출
90
+ → GET /api/auth/me
91
+ → user: { ... }, isAuthenticated: true
92
+ ```
93
+
94
+ 페이지 새로고침이나 재방문 시 기존 세션이 자동으로 복원됩니다.
95
+
96
+ #### 3. 로그인 (이메일/비밀번호)
27
97
 
28
- ```bash
29
- npm install firebase
98
+ ```
99
+ user calls login({ email, password })
100
+ → POST /api/auth/login
101
+ → 서버 응답: { accessToken, refreshToken }
102
+ → provider.setTokens(accessToken, refreshToken) ← localStorage 저장
103
+ → refetchQueries(['auth', 'me'])
104
+ → GET /api/auth/me
105
+ → user: { ... }, isAuthenticated: true
30
106
  ```
31
107
 
32
- ## Usage (Core SDK)
108
+ #### 4. 로그인 (Firebase)
109
+
110
+ ```
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
118
+ ```
119
+
120
+ Firebase ID 토큰을 PH-CMS 자체 토큰으로 교환한 뒤, 동일한 프로필 조회 흐름을 따릅니다.
121
+
122
+ #### 5. 로그아웃
123
+
124
+ ```
125
+ user calls logout()
126
+ → provider.logout() ← localStorage 토큰 삭제
127
+ → POST /api/auth/logout ← 서버 세션 무효화 (실패해도 무시)
128
+ → setQueryData(['auth','me'], null)
129
+ → user: null, isAuthenticated: false
130
+ ```
131
+
132
+ ### `refetchQueries` vs `invalidateQueries`
133
+
134
+ 로그인 성공 후 프로필 조회에는 `invalidateQueries` 대신 `refetchQueries`를 사용합니다.
135
+
136
+ - `invalidateQueries`는 쿼리를 stale로 표시만 하고, `enabled: true`인 쿼리만 자동 refetch합니다.
137
+ - 로그인 직후에는 `provider.setTokens()`로 토큰이 저장되지만, React 리렌더가 아직 발생하지 않아 `hasToken()`의 반환값이 context에 반영되지 않은 상태입니다.
138
+ - 따라서 `enabled`가 여전히 `false`일 수 있고, `invalidateQueries`만으로는 `me()` 호출이 보장되지 않습니다.
139
+ - `refetchQueries`는 `enabled` 상태와 무관하게 즉시 네트워크 요청을 강제하므로, 토큰 저장 직후 프로필 조회를 확실히 보장합니다.
140
+
141
+ ---
142
+
143
+ ## Auth Providers
144
+
145
+ ### `LocalAuthProvider`
146
+
147
+ 이메일/비밀번호 기반 인증에 사용합니다. 토큰을 `localStorage`에 저장합니다.
33
148
 
34
149
  ```ts
35
- import { PHCMSClient } from '@ph-cms/client-sdk';
150
+ import { LocalAuthProvider, PHCMSClient } from '@ph-cms/client-sdk';
151
+
152
+ const authProvider = new LocalAuthProvider('my_app_');
36
153
 
37
154
  const client = new PHCMSClient({
38
155
  baseURL: 'https://api.example.com',
156
+ auth: authProvider,
39
157
  });
158
+ ```
159
+
160
+ | 생성자 인자 | 타입 | 기본값 | 설명 |
161
+ |---|---|---|---|
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()` | 토큰 삭제 |
40
173
 
41
- // Use the modules directly
42
- const contents = await client.content.list({
43
- page: 1,
44
- limit: 20,
174
+ ### `FirebaseAuthProvider`
175
+
176
+ Firebase Authentication과 연동하여 사용합니다. Firebase ID 토큰을 PH-CMS 서버 토큰으로 교환하는 방식입니다.
177
+
178
+ ```ts
179
+ import { FirebaseAuthProvider, PHCMSClient } from '@ph-cms/client-sdk';
180
+ import { initializeApp } from 'firebase/app';
181
+ import { getAuth } from 'firebase/auth';
182
+
183
+ const firebaseApp = initializeApp({ /* Firebase config */ });
184
+ const firebaseAuth = getAuth(firebaseApp);
185
+
186
+ const authProvider = new FirebaseAuthProvider(firebaseAuth, 'my_app_fb_');
187
+
188
+ const client = new PHCMSClient({
189
+ baseURL: 'https://api.example.com',
190
+ auth: authProvider,
45
191
  });
46
192
  ```
47
193
 
48
- ## React Usage (v0.1.1+)
194
+ | 생성자 인자 | 타입 | 기본값 | 설명 |
195
+ |---|---|---|---|
196
+ | `auth` | `firebase/auth.Auth` | (필수) | Firebase Auth 인스턴스 |
197
+ | `storageKeyPrefix` | `string` | `'ph_cms_fb_'` | `localStorage` 키 접두사 |
198
+
199
+ #### 주요 메서드
200
+
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()` 호출 |
208
+
209
+ ### `AuthProvider` Interface
210
+
211
+ 커스텀 인증 프로바이더를 구현하려면 아래 인터페이스를 구현합니다:
212
+
213
+ ```ts
214
+ interface AuthProvider {
215
+ type: 'FIREBASE' | 'LOCAL';
216
+ getToken(): Promise<string | null>;
217
+ hasToken(): boolean;
218
+ onTokenExpired(callback: () => Promise<void>): void;
219
+ logout(): Promise<void>;
220
+ }
221
+ ```
222
+
223
+ `hasToken()`은 **동기** 메서드여야 합니다. React 렌더 사이클에서 `useQuery`의 `enabled` 조건으로 사용되므로, 비동기 호출은 허용되지 않습니다.
224
+
225
+ ---
49
226
 
50
- From version 0.1.1, `PHCMSProvider` automatically includes a `QueryClientProvider`. You don't need to wrap your app with `QueryClientProvider` manually to use PH-CMS hooks.
227
+ ## React Usage
51
228
 
52
229
  ### Basic Setup
53
230
 
54
231
  ```tsx
55
- import { PHCMSClient, PHCMSProvider } from '@ph-cms/client-sdk';
56
- import { client } from './lib/sdk'; // Your pre-configured client
232
+ import { PHCMSClient, LocalAuthProvider, PHCMSProvider } from '@ph-cms/client-sdk';
233
+
234
+ const authProvider = new LocalAuthProvider('my_app_');
235
+
236
+ const client = new PHCMSClient({
237
+ baseURL: 'https://api.example.com',
238
+ auth: authProvider,
239
+ });
57
240
 
58
241
  export function App() {
59
242
  return (
@@ -64,73 +247,263 @@ export function App() {
64
247
  }
65
248
  ```
66
249
 
67
- ### Using Hooks
250
+ `PHCMSProvider`는 내부적으로 `QueryClientProvider`를 포함하므로, 별도로 추가할 필요가 없습니다.
251
+ 외부에서 직접 관리하는 `QueryClient`를 사용하려면 `queryClient` prop으로 전달합니다:
68
252
 
69
253
  ```tsx
70
- import { useContent, useUser } from '@ph-cms/client-sdk';
254
+ import { QueryClient } from '@tanstack/react-query';
71
255
 
72
- function MyComponent() {
73
- const { data: user, isLoading: userLoading } = useUser();
74
- const { data: contents, isLoading: contentLoading } = useContent();
256
+ const queryClient = new QueryClient();
257
+
258
+ <PHCMSProvider client={client} queryClient={queryClient}>
259
+ ...
260
+ </PHCMSProvider>
261
+ ```
75
262
 
76
- if (userLoading || contentLoading) return <div>Loading...</div>;
263
+ ### Authentication with `useAuth`
264
+
265
+ `useAuth`는 인증 상태와 액션을 통합 제공하는 메인 훅입니다.
266
+
267
+ ```tsx
268
+ import { useAuth } from '@ph-cms/client-sdk';
269
+
270
+ function AuthComponent() {
271
+ const {
272
+ // 상태
273
+ user, // UserDto | null — 현재 로그인한 사용자 프로필
274
+ isAuthenticated, // boolean
275
+ isLoading, // boolean — 프로필 로딩 중 여부
276
+
277
+ // 액션 (모두 Promise 반환)
278
+ login, // (data: LoginRequest) => Promise<AuthResponse>
279
+ loginWithFirebase, // (data: FirebaseExchangeRequest) => Promise<AuthResponse>
280
+ register, // (data: RegisterRequest) => Promise<AuthResponse>
281
+ logout, // () => Promise<void>
282
+
283
+ // 뮤테이션 상태 (isPending, error 등)
284
+ loginStatus,
285
+ loginWithFirebaseStatus,
286
+ registerStatus,
287
+ logoutStatus,
288
+ } = useAuth();
289
+
290
+ // ...
291
+ }
292
+ ```
293
+
294
+ #### 이메일/비밀번호 로그인
295
+
296
+ ```tsx
297
+ function LoginForm() {
298
+ const { login, loginStatus, isAuthenticated, user } = useAuth();
299
+ const [email, setEmail] = useState('');
300
+ const [password, setPassword] = useState('');
301
+
302
+ if (isAuthenticated) {
303
+ return <p>Welcome, {user?.displayName}</p>;
304
+ }
305
+
306
+ const handleSubmit = async (e: React.FormEvent) => {
307
+ e.preventDefault();
308
+ try {
309
+ await login({ email, password });
310
+ // 성공 시 자동으로 me() 호출 → user, isAuthenticated 갱신
311
+ } catch (error) {
312
+ console.error('Login failed:', error);
313
+ }
314
+ };
77
315
 
78
316
  return (
79
- <div>
80
- <h1>Hello, {user?.email}</h1>
81
- <ul>
82
- {contents?.items.map(item => (
83
- <li key={item.uid}>{item.title}</li>
84
- ))}
85
- </ul>
86
- </div>
317
+ <form onSubmit={handleSubmit}>
318
+ <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
319
+ <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
320
+ <button type="submit" disabled={loginStatus.isPending}>
321
+ {loginStatus.isPending ? '로그인 중...' : '로그인'}
322
+ </button>
323
+ {loginStatus.error && <p>{(loginStatus.error as Error).message}</p>}
324
+ </form>
87
325
  );
88
326
  }
89
327
  ```
90
328
 
91
- ### Custom QueryClient (Optional)
329
+ #### Firebase 로그인
92
330
 
93
- If your application already uses React Query and you want to share the cache/settings:
331
+ Firebase 로그인은 단계로 이루어집니다:
332
+ 1. Firebase SDK로 인증하여 ID 토큰을 획득
333
+ 2. `loginWithFirebase()`로 PH-CMS 서버에 토큰 교환 요청
94
334
 
95
335
  ```tsx
96
- import { QueryClient } from '@tanstack/react-query';
97
- import { PHCMSClient, PHCMSProvider } from '@ph-cms/client-sdk';
336
+ import { FirebaseAuthProvider } from '@ph-cms/client-sdk';
337
+ import { getAuth, signInWithPopup, GoogleAuthProvider } from 'firebase/auth';
98
338
 
99
- const queryClient = new QueryClient();
100
- const client = new PHCMSClient({ baseURL: '...' });
339
+ // firebaseProvider는 초기화 시 생성
340
+ declare const firebaseProvider: FirebaseAuthProvider;
341
+
342
+ function FirebaseLoginButton() {
343
+ const { loginWithFirebase, loginWithFirebaseStatus } = useAuth();
344
+
345
+ const handleFirebaseLogin = async () => {
346
+ // 1) Firebase 인증 (Google 팝업 예시)
347
+ const auth = getAuth();
348
+ await signInWithPopup(auth, new GoogleAuthProvider());
349
+
350
+ // 2) Firebase ID 토큰 획득
351
+ const idToken = await firebaseProvider.getIdToken();
352
+ if (!idToken) return;
353
+
354
+ // 3) PH-CMS 서버에 토큰 교환 → 자동으로 me() 호출
355
+ await loginWithFirebase({ idToken });
356
+ };
101
357
 
102
- export function App() {
103
358
  return (
104
- <PHCMSProvider client={client} queryClient={queryClient}>
105
- {/* Both your app and PH-CMS hooks will use the same queryClient */}
106
- <YourComponents />
107
- </PHCMSProvider>
359
+ <button onClick={handleFirebaseLogin} disabled={loginWithFirebaseStatus.isPending}>
360
+ {loginWithFirebaseStatus.isPending ? 'Firebase 로그인 중...' : 'Google 로그인'}
361
+ </button>
108
362
  );
109
363
  }
110
364
  ```
111
365
 
112
- ## Admin SDK (@ph-cms/client-sdk-admin)
366
+ #### 회원가입
367
+
368
+ ```tsx
369
+ function RegisterForm() {
370
+ const { register, registerStatus } = useAuth();
371
+
372
+ const handleRegister = async (formData: {
373
+ email: string;
374
+ password: string;
375
+ display_name: string;
376
+ username?: string;
377
+ }) => {
378
+ await register(formData);
379
+ // 성공 시 자동으로 me() 호출 → 즉시 인증 상태로 전환
380
+ };
381
+
382
+ // ...
383
+ }
384
+ ```
113
385
 
114
- For administrative tasks, use `@ph-cms/client-sdk-admin`. It follows the same provider pattern:
386
+ #### 로그아웃
115
387
 
116
388
  ```tsx
117
- import { PHCMSAdminProvider } from '@ph-cms/client-sdk-admin';
118
- import { adminClient } from './lib/sdk';
389
+ function LogoutButton() {
390
+ const { logout } = useAuth();
391
+
392
+ return <button onClick={() => logout()}>로그아웃</button>;
393
+ }
394
+ ```
395
+
396
+ ### Context API (`usePHCMSContext`)
397
+
398
+ `useAuth` 대신 context에 직접 접근할 수도 있습니다.
399
+
400
+ ```tsx
401
+ import { usePHCMSContext } from '@ph-cms/client-sdk';
402
+
403
+ function MyComponent() {
404
+ const {
405
+ client, // PHCMSClient 인스턴스
406
+ user, // UserDto | null
407
+ isAuthenticated, // boolean
408
+ isLoading, // boolean
409
+ refreshUser, // () => Promise<void> — 수동으로 프로필 다시 조회
410
+ } = usePHCMSContext();
411
+
412
+ // 프로필 정보가 변경된 후 수동 갱신
413
+ const handleProfileUpdate = async () => {
414
+ await client.auth.me(); // or your update API
415
+ await refreshUser();
416
+ };
417
+ }
418
+ ```
419
+
420
+ ### Standalone (Non-React) Usage
421
+
422
+ React 없이 `PHCMSClient`와 `AuthModule`을 직접 사용할 수 있습니다.
423
+
424
+ ```ts
425
+ import { PHCMSClient, LocalAuthProvider } from '@ph-cms/client-sdk';
426
+
427
+ const authProvider = new LocalAuthProvider('my_app_');
428
+ const client = new PHCMSClient({
429
+ baseURL: 'https://api.example.com',
430
+ auth: authProvider,
431
+ });
432
+
433
+ // 로그인
434
+ const authResponse = await client.auth.login({
435
+ email: 'user@example.com',
436
+ password: 'password',
437
+ });
438
+ // → authProvider에 토큰이 자동 저장됨
439
+
440
+ // 프로필 조회
441
+ const me = await client.auth.me();
442
+ console.log(me.email);
443
+
444
+ // 토큰 갱신
445
+ const refreshToken = authProvider.getRefreshToken();
446
+ if (refreshToken) {
447
+ const newTokens = await client.auth.refresh(refreshToken);
448
+ authProvider.setTokens(newTokens.accessToken, newTokens.refreshToken);
449
+ }
450
+
451
+ // 로그아웃
452
+ await client.auth.logout();
453
+ ```
454
+
455
+ ### Legacy Hooks
456
+
457
+ 하위 호환성을 위해 개별 훅도 제공됩니다. 새 코드에서는 `useAuth`를 권장합니다.
458
+
459
+ | Hook | 설명 |
460
+ |---|---|
461
+ | `useUser()` | `{ data: UserDto \| null, isLoading, isAuthenticated }` |
462
+ | `useLogin()` | `{ mutateAsync: login }` |
463
+ | `useLogout()` | `{ mutateAsync: logout }` |
464
+
465
+ ---
466
+
467
+ ## Using Data Hooks
468
+
469
+ ### Content
470
+
471
+ ```tsx
472
+ import { useContentList, useContentDetail } from '@ph-cms/client-sdk';
473
+
474
+ function ContentList() {
475
+ const { data, isLoading } = useContentList({ limit: 10 });
476
+
477
+ if (isLoading) return <div>Loading...</div>;
119
478
 
120
- export function App() {
121
479
  return (
122
- <PHCMSAdminProvider client={adminClient}>
123
- <AdminComponents />
124
- </PHCMSAdminProvider>
480
+ <ul>
481
+ {data?.content.map(item => (
482
+ <li key={item.uid}>{item.title}</li>
483
+ ))}
484
+ </ul>
125
485
  );
126
486
  }
127
487
  ```
128
488
 
129
- ## Media & File Upload (v0.1.2+)
489
+ ### Channel
490
+
491
+ ```tsx
492
+ import { useChannelList } from '@ph-cms/client-sdk';
493
+
494
+ function ChannelList() {
495
+ const { data, isLoading } = useChannelList({ limit: 20 });
496
+ // ...
497
+ }
498
+ ```
499
+
500
+ ---
501
+
502
+ ## Media & File Upload
130
503
 
131
- Uploading media files follows a 3-step process: Request a Ticket -> Upload to S3 -> Create/Update Content.
504
+ Uploading media files follows a 3-step process: Request a Ticket Upload to S3 Create/Update Content.
132
505
 
133
- ### 1. Simple Workflow Example
506
+ ### Simple Workflow Example
134
507
 
135
508
  ```tsx
136
509
  import { useMediaUploadTickets, useUploadToS3, useCreateContent } from '@ph-cms/client-sdk';
@@ -149,8 +522,6 @@ function MediaUploader() {
149
522
  filename: file.name,
150
523
  contentType: file.type,
151
524
  fileSize: file.size,
152
- // width: 1024, (optional)
153
- // height: 768 (optional)
154
525
  }]);
155
526
 
156
527
  const { mediaUid, uploadUrl } = tickets[0];
@@ -160,7 +531,6 @@ function MediaUploader() {
160
531
 
161
532
  // Step 3: Use the mediaUid to create content
162
533
  await createContent({
163
- channelSlug: 'my-channel',
164
534
  type: 'post',
165
535
  title: 'Post with image',
166
536
  mediaAttachments: [mediaUid]
@@ -171,14 +541,68 @@ function MediaUploader() {
171
541
  }
172
542
  ```
173
543
 
174
- ### 2. Supported Media Types
175
- The system automatically classifies media based on the `contentType`:
176
- - `image/*` -> `image`
177
- - `video/*` -> `video`
178
- - `audio/*` -> `audio`
179
- - `application/pdf`, `.docx`, etc. -> `document`
180
- - Others -> `file`
544
+ ---
545
+
546
+ ## Error Handling
547
+
548
+ SDK는 종류의 에러를 throw합니다:
549
+
550
+ | Error Class | 상황 | 주요 필드 |
551
+ |---|---|---|
552
+ | `ValidationError` | Zod 스키마 검증 실패 (클라이언트 측) | `errors: ZodIssue[]` |
553
+ | `ApiError` | 서버가 2xx 이외의 응답을 반환 | `statusCode: number`, `originalError: any` |
554
+ | `PHCMSError` | 네트워크 오류 등 기타 에러 | `message: string` |
555
+
556
+ ```tsx
557
+ import { ApiError } from '@ph-cms/client-sdk';
558
+
559
+ try {
560
+ await login({ email, password });
561
+ } catch (error) {
562
+ if (error instanceof ApiError) {
563
+ if (error.statusCode === 401) {
564
+ console.error('잘못된 인증 정보입니다.');
565
+ }
566
+ }
567
+ }
568
+ ```
569
+
570
+ ---
571
+
572
+ ## API Reference
573
+
574
+ ### `PHCMSClient`
575
+
576
+ ```ts
577
+ const client = new PHCMSClient({
578
+ baseURL: string; // 서버 URL (필수)
579
+ apiPrefix?: string; // API 경로 접두사 (기본값: '/api')
580
+ auth?: AuthProvider; // 인증 프로바이더
581
+ timeout?: number; // 요청 타임아웃 ms (기본값: 10000)
582
+ });
583
+
584
+ client.authProvider // AuthProvider | undefined — 인증 프로바이더 접근
585
+ client.axiosInstance // AxiosInstance — 내부 axios 인스턴스 직접 접근
586
+ client.auth // AuthModule
587
+ client.content // ContentModule
588
+ client.channel // ChannelModule
589
+ client.terms // TermsModule
590
+ client.media // MediaModule
591
+ ```
592
+
593
+ ### `AuthModule` (`client.auth`)
594
+
595
+ | 메서드 | 설명 |
596
+ |---|---|
597
+ | `login(data: LoginRequest)` | 이메일/비밀번호 로그인 → `AuthResponse` |
598
+ | `loginWithFirebase(data: FirebaseExchangeRequest)` | Firebase ID 토큰 교환 → `AuthResponse` |
599
+ | `register(data: RegisterRequest)` | 회원가입 → `AuthResponse` |
600
+ | `me()` | 현재 사용자 프로필 조회 → `UserDto` |
601
+ | `refresh(refreshToken: string)` | 토큰 갱신 → `{ accessToken, refreshToken }` |
602
+ | `logout()` | 로그아웃 (프로바이더 토큰 삭제 + 서버 세션 무효화) |
603
+
604
+ ---
181
605
 
182
606
  ## License
183
607
 
184
- MIT
608
+ MIT
@@ -1,5 +1,5 @@
1
- import { AuthProvider } from "./interfaces";
2
1
  import type { Auth } from "firebase/auth";
2
+ import { AuthProvider } from "./interfaces";
3
3
  export declare class FirebaseAuthProvider implements AuthProvider {
4
4
  private readonly auth;
5
5
  private readonly storageKeyPrefix;
@@ -8,6 +8,7 @@ export declare class FirebaseAuthProvider implements AuthProvider {
8
8
  private refreshToken;
9
9
  private onExpiredCallback;
10
10
  constructor(auth: Auth, storageKeyPrefix?: string);
11
+ hasToken(): boolean;
11
12
  setTokens(accessToken: string, refreshToken: string): void;
12
13
  getToken(): Promise<string | null>;
13
14
  onTokenExpired(callback: () => Promise<void>): void;
@@ -14,6 +14,9 @@ class FirebaseAuthProvider {
14
14
  this.refreshToken = localStorage.getItem(`${this.storageKeyPrefix}refresh_token`);
15
15
  }
16
16
  }
17
+ hasToken() {
18
+ return this.accessToken !== null || this.refreshToken !== null;
19
+ }
17
20
  setTokens(accessToken, refreshToken) {
18
21
  this.accessToken = accessToken;
19
22
  this.refreshToken = refreshToken;
@@ -5,6 +5,12 @@ export interface AuthProvider {
5
5
  * Should handle refreshing if necessary (or return null/throw if expired and can't refresh).
6
6
  */
7
7
  getToken(): Promise<string | null>;
8
+ /**
9
+ * Returns true if the provider currently holds a token (access or refresh).
10
+ * This is a synchronous check used to decide whether to attempt API calls
11
+ * that require authentication (e.g. /auth/me).
12
+ */
13
+ hasToken(): boolean;
8
14
  /**
9
15
  * Sets a callback to be called when the token is known to be expired by the external world (e.g. 401 response).
10
16
  */
@@ -11,6 +11,7 @@ export declare class LocalAuthProvider implements AuthProvider {
11
11
  refreshToken: string;
12
12
  }>) | undefined);
13
13
  setTokens(accessToken: string, refreshToken: string): void;
14
+ hasToken(): boolean;
14
15
  getToken(): Promise<string | null>;
15
16
  onTokenExpired(callback: () => Promise<void>): void;
16
17
  logout(): Promise<void>;
@@ -22,6 +22,9 @@ class LocalAuthProvider {
22
22
  localStorage.setItem(`${this.storageKeyPrefix}refresh_token`, refreshToken);
23
23
  }
24
24
  }
25
+ hasToken() {
26
+ return this.accessToken !== null || this.refreshToken !== null;
27
+ }
25
28
  async getToken() {
26
29
  // Ideally check expiration here using jwt-decode, but for now return what we have.
27
30
  // If it's expired, the API will return 401, triggering the interceptor which calls onTokenExpired.
package/dist/client.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { AxiosInstance } from 'axios';
2
2
  import { AuthProvider } from './auth/interfaces';
3
3
  import { AuthModule } from './modules/auth';
4
- import { ContentModule } from './modules/content';
5
4
  import { ChannelModule } from './modules/channel';
6
- import { TermsModule } from './modules/terms';
5
+ import { ContentModule } from './modules/content';
7
6
  import { MediaModule } from './modules/media';
7
+ import { TermsModule } from './modules/terms';
8
8
  export interface PHCMSClientConfig {
9
9
  baseURL: string;
10
10
  apiPrefix?: string;
@@ -19,5 +19,7 @@ export declare class PHCMSClient {
19
19
  readonly channel: ChannelModule;
20
20
  readonly terms: TermsModule;
21
21
  readonly media: MediaModule;
22
+ /** Exposes the auth provider so UI layers can check token state synchronously. */
23
+ get authProvider(): AuthProvider | undefined;
22
24
  constructor(config: PHCMSClientConfig);
23
25
  }
package/dist/client.js CHANGED
@@ -7,11 +7,15 @@ exports.PHCMSClient = void 0;
7
7
  const axios_1 = __importDefault(require("axios"));
8
8
  const errors_1 = require("./errors");
9
9
  const auth_1 = require("./modules/auth");
10
- const content_1 = require("./modules/content");
11
10
  const channel_1 = require("./modules/channel");
12
- const terms_1 = require("./modules/terms");
11
+ const content_1 = require("./modules/content");
13
12
  const media_1 = require("./modules/media");
13
+ const terms_1 = require("./modules/terms");
14
14
  class PHCMSClient {
15
+ /** Exposes the auth provider so UI layers can check token state synchronously. */
16
+ get authProvider() {
17
+ return this.config.auth;
18
+ }
15
19
  constructor(config) {
16
20
  this.config = config;
17
21
  const normalizedApiPrefix = `/${(config.apiPrefix || '/api').replace(/^\/+|\/+$/g, '')}`;
package/dist/context.d.ts CHANGED
@@ -1,12 +1,14 @@
1
- import React, { ReactNode } from 'react';
1
+ import { UserDto } from '@ph-cms/api-contract';
2
2
  import { QueryClient } from '@tanstack/react-query';
3
+ import React, { ReactNode } from 'react';
3
4
  import { PHCMSClient } from './client';
4
- import { UserDto } from '@ph-cms/api-contract';
5
5
  export interface PHCMSContextType {
6
6
  client: PHCMSClient;
7
7
  user: UserDto | null;
8
8
  isAuthenticated: boolean;
9
9
  isLoading: boolean;
10
+ /** Manually trigger a refetch of the current user profile. */
11
+ refreshUser: () => Promise<void>;
10
12
  }
11
13
  export interface PHCMSProviderProps {
12
14
  client: PHCMSClient;
package/dist/context.js CHANGED
@@ -34,40 +34,71 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.usePHCMS = exports.usePHCMSContext = exports.PHCMSProvider = void 0;
37
- const react_1 = __importStar(require("react"));
38
37
  const react_query_1 = require("@tanstack/react-query");
38
+ const react_1 = __importStar(require("react"));
39
39
  const PHCMSContext = (0, react_1.createContext)(null);
40
+ const AUTH_ME_QUERY_KEY = ['auth', 'me'];
40
41
  /**
41
42
  * Internal component to handle auth state and provide to context
42
43
  */
43
44
  const PHCMSInternalProvider = ({ client, children }) => {
44
- const { data: user, isLoading, isError } = (0, react_query_1.useQuery)({
45
- queryKey: ['auth', 'me'],
45
+ const queryClient = (0, react_query_1.useQueryClient)();
46
+ // Track token presence in state to trigger re-renders when it changes.
47
+ // Initial value from provider.
48
+ const [hasToken, setHasToken] = (0, react_1.useState)(() => client.authProvider?.hasToken() ?? false);
49
+ // The 'me' query is only enabled if we have a token.
50
+ const { data: user, isLoading, isError, refetch } = (0, react_query_1.useQuery)({
51
+ queryKey: [...AUTH_ME_QUERY_KEY],
46
52
  queryFn: () => client.auth.me(),
53
+ enabled: hasToken,
47
54
  retry: false,
48
55
  staleTime: 1000 * 60 * 5,
49
56
  });
57
+ // Function to update token state and optionally refetch
58
+ const refreshUser = (0, react_1.useCallback)(async () => {
59
+ const currentHasToken = client.authProvider?.hasToken() ?? false;
60
+ setHasToken(currentHasToken);
61
+ if (currentHasToken) {
62
+ await refetch();
63
+ }
64
+ else {
65
+ // If no token, clear the query data
66
+ queryClient.setQueryData([...AUTH_ME_QUERY_KEY], null);
67
+ }
68
+ }, [client.authProvider, refetch, queryClient]);
69
+ // Effect to sync state if provider changes externally (rare but possible)
70
+ (0, react_1.useEffect)(() => {
71
+ const interval = setInterval(() => {
72
+ const currentHasToken = client.authProvider?.hasToken() ?? false;
73
+ if (currentHasToken !== hasToken) {
74
+ setHasToken(currentHasToken);
75
+ }
76
+ }, 2000); // Check every 2s as a fallback
77
+ return () => clearInterval(interval);
78
+ }, [client.authProvider, hasToken]);
50
79
  const value = (0, react_1.useMemo)(() => ({
51
80
  client,
52
81
  user: user || null,
53
- isAuthenticated: !!user && !isError,
54
- isLoading,
55
- }), [client, user, isError, isLoading]);
56
- return (react_1.default.createElement(PHCMSContext.Provider, { value: value }, children));
82
+ isAuthenticated: !!user && !isError && hasToken,
83
+ isLoading: hasToken ? isLoading : false,
84
+ refreshUser,
85
+ }), [client, user, isError, isLoading, hasToken, refreshUser]);
86
+ return react_1.default.createElement(PHCMSContext.Provider, { value: value }, children);
57
87
  };
58
88
  /**
59
89
  * Root Provider for PH-CMS
60
90
  * Automatically includes QueryClientProvider
61
91
  */
62
92
  const PHCMSProvider = ({ client, queryClient, children }) => {
63
- const internalQueryClient = (0, react_1.useMemo)(() => queryClient ?? new react_query_1.QueryClient({
64
- defaultOptions: {
65
- queries: {
66
- refetchOnWindowFocus: false,
67
- retry: false,
93
+ const internalQueryClient = (0, react_1.useMemo)(() => queryClient ??
94
+ new react_query_1.QueryClient({
95
+ defaultOptions: {
96
+ queries: {
97
+ refetchOnWindowFocus: false,
98
+ retry: false,
99
+ },
68
100
  },
69
- },
70
- }), [queryClient]);
101
+ }), [queryClient]);
71
102
  return (react_1.default.createElement(react_query_1.QueryClientProvider, { client: internalQueryClient },
72
103
  react_1.default.createElement(PHCMSInternalProvider, { client: client }, children)));
73
104
  };
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Unified Auth Hook
3
- * Returns both the current auth status and the actions
3
+ * Returns both the current auth status and the actions.
4
4
  */
5
5
  export declare const useAuth: () => {
6
6
  user: {
@@ -128,6 +128,30 @@ export declare const useAuth: () => {
128
128
  email: string;
129
129
  password: string;
130
130
  }, unknown>;
131
+ loginWithFirebaseStatus: import("@tanstack/react-query").UseMutationResult<{
132
+ refreshToken: string;
133
+ user: {
134
+ uid: string;
135
+ email: string;
136
+ username: string | null;
137
+ display_name: string;
138
+ avatar_url: string | null;
139
+ phone_number: string | null;
140
+ email_verified_at: string | null;
141
+ phone_verified_at: string | null;
142
+ locale: string;
143
+ timezone: string;
144
+ status: string;
145
+ role: string[];
146
+ profile_data: Record<string, any>;
147
+ last_login_at: string | null;
148
+ created_at: string;
149
+ updated_at: string;
150
+ };
151
+ accessToken: string;
152
+ }, Error, {
153
+ idToken: string;
154
+ }, unknown>;
131
155
  registerStatus: import("@tanstack/react-query").UseMutationResult<{
132
156
  refreshToken: string;
133
157
  user: {
@@ -3,36 +3,37 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.useLogout = exports.useLogin = exports.useUser = exports.useAuth = void 0;
4
4
  const react_query_1 = require("@tanstack/react-query");
5
5
  const context_1 = require("../context");
6
+ const AUTH_ME_QUERY_KEY = ['auth', 'me'];
6
7
  /**
7
8
  * Unified Auth Hook
8
- * Returns both the current auth status and the actions
9
+ * Returns both the current auth status and the actions.
9
10
  */
10
11
  const useAuth = () => {
11
- const { user, isAuthenticated, isLoading } = (0, context_1.usePHCMSContext)();
12
+ const { user, isAuthenticated, isLoading, refreshUser } = (0, context_1.usePHCMSContext)();
12
13
  const client = (0, context_1.usePHCMS)();
13
14
  const queryClient = (0, react_query_1.useQueryClient)();
14
15
  const loginMutation = (0, react_query_1.useMutation)({
15
16
  mutationFn: (data) => client.auth.login(data),
16
- onSuccess: () => {
17
- queryClient.invalidateQueries({ queryKey: ['auth', 'me'] });
17
+ onSuccess: async () => {
18
+ await refreshUser();
18
19
  },
19
20
  });
20
21
  const loginWithFirebaseMutation = (0, react_query_1.useMutation)({
21
22
  mutationFn: (data) => client.auth.loginWithFirebase(data),
22
- onSuccess: () => {
23
- queryClient.invalidateQueries({ queryKey: ['auth', 'me'] });
23
+ onSuccess: async () => {
24
+ await refreshUser();
24
25
  },
25
26
  });
26
27
  const registerMutation = (0, react_query_1.useMutation)({
27
28
  mutationFn: (data) => client.auth.register(data),
28
- onSuccess: () => {
29
- queryClient.invalidateQueries({ queryKey: ['auth', 'me'] });
29
+ onSuccess: async () => {
30
+ await refreshUser();
30
31
  },
31
32
  });
32
33
  const logoutMutation = (0, react_query_1.useMutation)({
33
34
  mutationFn: () => client.auth.logout(),
34
- onSuccess: () => {
35
- queryClient.setQueryData(['auth', 'me'], null);
35
+ onSuccess: async () => {
36
+ await refreshUser(); // This will clear state since hasToken will be false
36
37
  queryClient.invalidateQueries();
37
38
  },
38
39
  });
@@ -46,6 +47,7 @@ const useAuth = () => {
46
47
  logout: logoutMutation.mutateAsync,
47
48
  // Access to mutation objects for loading/error states if needed
48
49
  loginStatus: loginMutation,
50
+ loginWithFirebaseStatus: loginWithFirebaseMutation,
49
51
  registerStatus: registerMutation,
50
52
  logoutStatus: logoutMutation,
51
53
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ph-cms/client-sdk",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Unified PH-CMS Client SDK (React + Core)",
5
5
  "keywords": [],
6
6
  "license": "MIT",