@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 +366 -135
- package/dist/auth/base-provider.d.ts +151 -0
- package/dist/auth/base-provider.js +210 -0
- package/dist/auth/firebase-provider.d.ts +46 -12
- package/dist/auth/firebase-provider.js +54 -42
- package/dist/auth/interfaces.d.ts +25 -2
- package/dist/auth/jwt-utils.d.ts +53 -0
- package/dist/auth/jwt-utils.js +85 -0
- package/dist/auth/local-provider.d.ts +34 -17
- package/dist/auth/local-provider.js +45 -39
- package/dist/client.d.ts +23 -0
- package/dist/client.js +116 -26
- package/dist/context.d.ts +10 -0
- package/dist/context.js +18 -7
- package/dist/hooks/useFirebaseAuthSync.d.ts +108 -0
- package/dist/hooks/useFirebaseAuthSync.js +221 -0
- package/dist/index.d.ts +9 -6
- package/dist/index.js +9 -6
- package/dist/modules/auth.d.ts +2 -2
- package/dist/modules/auth.js +11 -20
- package/dist/types.d.ts +15 -1
- package/dist/types.js +7 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
# @ph-cms/client-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
PH-CMS 클라이언트 SDK — 브라우저 및 React 애플리케이션을 위한 통합 클라이언트.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**주요 기능:**
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- Auth
|
|
9
|
-
- React
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
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
|
-
>
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
→
|
|
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
|
|
143
|
+
→ useQuery enabled: true
|
|
144
|
+
→ authStatus: 'loading' ← 이 시점에 스플래시 화면 표시 가능
|
|
90
145
|
→ GET /api/auth/me
|
|
91
|
-
→ user: { ... }
|
|
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
|
-
→
|
|
102
|
-
→
|
|
103
|
-
→ refetchQueries(['auth', 'me'])
|
|
154
|
+
→ provider.setTokens(accessToken, refreshToken)
|
|
155
|
+
→ refreshUser()
|
|
104
156
|
→ GET /api/auth/me
|
|
105
|
-
→ user: { ... }
|
|
157
|
+
→ authStatus: 'authenticated', user: { ... }
|
|
106
158
|
```
|
|
107
159
|
|
|
108
|
-
#### 4.
|
|
160
|
+
#### 4. 로그아웃
|
|
109
161
|
|
|
110
162
|
```
|
|
111
|
-
user calls
|
|
112
|
-
→
|
|
113
|
-
→ 서버
|
|
114
|
-
→
|
|
115
|
-
|
|
116
|
-
→
|
|
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
|
-
|
|
171
|
+
### Token Refresh (자동 토큰 갱신)
|
|
172
|
+
|
|
173
|
+
SDK는 두 가지 방식으로 토큰을 자동 갱신합니다:
|
|
121
174
|
|
|
122
|
-
####
|
|
175
|
+
#### Proactive Refresh (사전 갱신)
|
|
176
|
+
|
|
177
|
+
Provider의 `getToken()` 호출 시 JWT의 `exp` 클레임을 검사합니다.
|
|
178
|
+
만료 임박 시 (`expiryBufferMs` 이내, 기본 60초) 서버에 갱신 요청을 보냅니다.
|
|
123
179
|
|
|
124
180
|
```
|
|
125
|
-
|
|
126
|
-
→ provider.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
190
|
+
#### Reactive Refresh (401 대응 갱신)
|
|
133
191
|
|
|
134
|
-
|
|
192
|
+
서버가 401을 반환하면 interceptor가 자동으로 토큰을 갱신하고 원본 요청을 재시도합니다.
|
|
135
193
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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` 키
|
|
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과
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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
|
-
// 성공
|
|
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
|
-
// 성공
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
742
|
+
// Step 2: S3에 파일 직접 업로드
|
|
530
743
|
await uploadToS3({ url: uploadUrl, file });
|
|
531
744
|
|
|
532
|
-
// Step 3:
|
|
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
|