@ph-cms/client-sdk 0.1.6 → 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 +266 -160
- 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/index.d.ts +2 -0
- package/dist/index.js +2 -0
- 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,16 +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
|
-
-
|
|
13
|
-
-
|
|
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
|
-
>
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
→
|
|
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
|
|
143
|
+
→ useQuery enabled: true
|
|
144
|
+
→ authStatus: 'loading' ← 이 시점에 스플래시 화면 표시 가능
|
|
91
145
|
→ GET /api/auth/me
|
|
92
|
-
→ user: { ... }
|
|
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
|
-
→
|
|
103
|
-
→
|
|
104
|
-
→ refetchQueries(['auth', 'me'])
|
|
154
|
+
→ provider.setTokens(accessToken, refreshToken)
|
|
155
|
+
→ refreshUser()
|
|
105
156
|
→ GET /api/auth/me
|
|
106
|
-
→ user: { ... }
|
|
157
|
+
→ authStatus: 'authenticated', user: { ... }
|
|
107
158
|
```
|
|
108
159
|
|
|
109
|
-
#### 4.
|
|
160
|
+
#### 4. 로그아웃
|
|
110
161
|
|
|
111
162
|
```
|
|
112
|
-
user calls
|
|
113
|
-
→
|
|
114
|
-
→ 서버
|
|
115
|
-
→
|
|
116
|
-
|
|
117
|
-
→
|
|
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
|
-
|
|
171
|
+
### Token Refresh (자동 토큰 갱신)
|
|
172
|
+
|
|
173
|
+
SDK는 두 가지 방식으로 토큰을 자동 갱신합니다:
|
|
122
174
|
|
|
123
|
-
####
|
|
175
|
+
#### Proactive Refresh (사전 갱신)
|
|
176
|
+
|
|
177
|
+
Provider의 `getToken()` 호출 시 JWT의 `exp` 클레임을 검사합니다.
|
|
178
|
+
만료 임박 시 (`expiryBufferMs` 이내, 기본 60초) 서버에 갱신 요청을 보냅니다.
|
|
124
179
|
|
|
125
180
|
```
|
|
126
|
-
|
|
127
|
-
→ provider.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
+
**동시 요청 처리:** 여러 요청이 동시에 401을 받으면 하나의 refresh 요청만 실행되고,
|
|
203
|
+
나머지 요청은 큐에서 대기합니다 (de-duplication).
|
|
136
204
|
|
|
137
|
-
|
|
138
|
-
|
|
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` 키
|
|
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과
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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
|
-
// 성공
|
|
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
|
-
// 성공
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
-
|
|
587
|
-
-
|
|
588
|
-
- 앱 재방문 시 localStorage
|
|
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
|
-
|
|
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:
|
|
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:
|
|
742
|
+
// Step 2: S3에 파일 직접 업로드
|
|
655
743
|
await uploadToS3({ url: uploadUrl, file });
|
|
656
744
|
|
|
657
|
-
// Step 3:
|
|
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
|