@pixygon/auth 1.0.0

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.
@@ -0,0 +1,480 @@
1
+ /**
2
+ * @pixygon/auth - Auth Provider
3
+ * React context provider for authentication state management
4
+ */
5
+
6
+ import {
7
+ createContext,
8
+ useContext,
9
+ useEffect,
10
+ useState,
11
+ useCallback,
12
+ useMemo,
13
+ type ReactNode,
14
+ } from 'react';
15
+ import type {
16
+ AuthConfig,
17
+ AuthContextValue,
18
+ AuthState,
19
+ AuthStatus,
20
+ AuthError,
21
+ UserRole,
22
+ LoginRequest,
23
+ LoginResponse,
24
+ RegisterRequest,
25
+ RegisterResponse,
26
+ VerifyRequest,
27
+ VerifyResponse,
28
+ ForgotPasswordRequest,
29
+ ForgotPasswordResponse,
30
+ RecoverPasswordRequest,
31
+ RecoverPasswordResponse,
32
+ ResendVerificationRequest,
33
+ ResendVerificationResponse,
34
+ } from '../types';
35
+ import { createTokenStorage } from '../utils/storage';
36
+ import { createAuthApiClient } from '../api/client';
37
+
38
+ // ============================================================================
39
+ // Context
40
+ // ============================================================================
41
+
42
+ const AuthContext = createContext<AuthContextValue | null>(null);
43
+
44
+ // ============================================================================
45
+ // Default Config
46
+ // ============================================================================
47
+
48
+ const defaultConfig: Partial<AuthConfig> = {
49
+ autoRefresh: true,
50
+ refreshThreshold: 300, // 5 minutes before expiry
51
+ debug: false,
52
+ };
53
+
54
+ // ============================================================================
55
+ // Provider Props
56
+ // ============================================================================
57
+
58
+ export interface AuthProviderProps {
59
+ config: AuthConfig;
60
+ children: ReactNode;
61
+ }
62
+
63
+ // ============================================================================
64
+ // Provider Component
65
+ // ============================================================================
66
+
67
+ export function AuthProvider({ config: userConfig, children }: AuthProviderProps) {
68
+ const config = useMemo(() => ({ ...defaultConfig, ...userConfig }), [userConfig]);
69
+
70
+ // Initialize storage and API client
71
+ const tokenStorage = useMemo(
72
+ () => createTokenStorage(config.appId, config.storage),
73
+ [config.appId, config.storage]
74
+ );
75
+
76
+ const apiClient = useMemo(
77
+ () => createAuthApiClient(config as AuthConfig, tokenStorage),
78
+ [config, tokenStorage]
79
+ );
80
+
81
+ // Auth state
82
+ const [state, setState] = useState<AuthState>({
83
+ user: null,
84
+ accessToken: null,
85
+ refreshToken: null,
86
+ status: 'idle',
87
+ isLoading: true,
88
+ error: null,
89
+ });
90
+
91
+ const log = useCallback(
92
+ (...args: unknown[]) => {
93
+ if (config.debug) {
94
+ console.log('[PixygonAuth]', ...args);
95
+ }
96
+ },
97
+ [config.debug]
98
+ );
99
+
100
+ // ========================================================================
101
+ // Initialization - Restore auth state from storage
102
+ // ========================================================================
103
+
104
+ useEffect(() => {
105
+ let mounted = true;
106
+
107
+ async function initializeAuth() {
108
+ log('Initializing auth...');
109
+
110
+ try {
111
+ const stored = await tokenStorage.getAll();
112
+
113
+ if (!stored.accessToken || !stored.user) {
114
+ log('No stored auth found');
115
+ if (mounted) {
116
+ setState((prev) => ({
117
+ ...prev,
118
+ status: 'unauthenticated',
119
+ isLoading: false,
120
+ }));
121
+ }
122
+ return;
123
+ }
124
+
125
+ // Check if token is expired
126
+ const isExpired = await tokenStorage.isTokenExpired();
127
+
128
+ if (isExpired && stored.refreshToken) {
129
+ log('Token expired, attempting refresh...');
130
+ try {
131
+ const response = await apiClient.refreshToken({
132
+ refreshToken: stored.refreshToken,
133
+ });
134
+
135
+ if (mounted) {
136
+ setState({
137
+ user: stored.user,
138
+ accessToken: response.token,
139
+ refreshToken: response.refreshToken,
140
+ status: 'authenticated',
141
+ isLoading: false,
142
+ error: null,
143
+ });
144
+ apiClient.setAccessToken(response.token);
145
+ }
146
+ } catch (error) {
147
+ log('Token refresh failed:', error);
148
+ await tokenStorage.clear();
149
+ if (mounted) {
150
+ setState({
151
+ user: null,
152
+ accessToken: null,
153
+ refreshToken: null,
154
+ status: 'unauthenticated',
155
+ isLoading: false,
156
+ error: null,
157
+ });
158
+ }
159
+ }
160
+ } else if (!isExpired) {
161
+ log('Restoring auth from storage');
162
+ apiClient.setAccessToken(stored.accessToken);
163
+
164
+ if (mounted) {
165
+ setState({
166
+ user: stored.user,
167
+ accessToken: stored.accessToken,
168
+ refreshToken: stored.refreshToken,
169
+ status: stored.user.isVerified ? 'authenticated' : 'verifying',
170
+ isLoading: false,
171
+ error: null,
172
+ });
173
+ }
174
+ } else {
175
+ log('Token expired and no refresh token available');
176
+ await tokenStorage.clear();
177
+ if (mounted) {
178
+ setState({
179
+ user: null,
180
+ accessToken: null,
181
+ refreshToken: null,
182
+ status: 'unauthenticated',
183
+ isLoading: false,
184
+ error: null,
185
+ });
186
+ }
187
+ }
188
+ } catch (error) {
189
+ log('Auth initialization error:', error);
190
+ if (mounted) {
191
+ setState((prev) => ({
192
+ ...prev,
193
+ status: 'unauthenticated',
194
+ isLoading: false,
195
+ error: {
196
+ code: 'UNKNOWN_ERROR',
197
+ message: 'Failed to initialize authentication',
198
+ },
199
+ }));
200
+ }
201
+ }
202
+ }
203
+
204
+ initializeAuth();
205
+
206
+ return () => {
207
+ mounted = false;
208
+ };
209
+ }, [apiClient, tokenStorage, log]);
210
+
211
+ // ========================================================================
212
+ // Auto Token Refresh
213
+ // ========================================================================
214
+
215
+ useEffect(() => {
216
+ if (!config.autoRefresh || state.status !== 'authenticated') {
217
+ return;
218
+ }
219
+
220
+ const checkAndRefresh = async () => {
221
+ const willExpire = await tokenStorage.willExpireSoon(config.refreshThreshold || 300);
222
+ if (willExpire && state.refreshToken) {
223
+ log('Token expiring soon, refreshing...');
224
+ try {
225
+ const response = await apiClient.refreshToken({
226
+ refreshToken: state.refreshToken,
227
+ });
228
+
229
+ setState((prev) => ({
230
+ ...prev,
231
+ accessToken: response.token,
232
+ refreshToken: response.refreshToken,
233
+ }));
234
+ apiClient.setAccessToken(response.token);
235
+ } catch (error) {
236
+ log('Auto-refresh failed:', error);
237
+ }
238
+ }
239
+ };
240
+
241
+ // Check every minute
242
+ const interval = setInterval(checkAndRefresh, 60000);
243
+
244
+ return () => clearInterval(interval);
245
+ }, [
246
+ config.autoRefresh,
247
+ config.refreshThreshold,
248
+ state.status,
249
+ state.refreshToken,
250
+ apiClient,
251
+ tokenStorage,
252
+ log,
253
+ ]);
254
+
255
+ // ========================================================================
256
+ // Auth Actions
257
+ // ========================================================================
258
+
259
+ const login = useCallback(
260
+ async (credentials: LoginRequest): Promise<LoginResponse> => {
261
+ setState((prev) => ({ ...prev, isLoading: true, error: null }));
262
+
263
+ try {
264
+ const response = await apiClient.login(credentials);
265
+
266
+ const newStatus: AuthStatus = response.user.isVerified ? 'authenticated' : 'verifying';
267
+
268
+ setState({
269
+ user: response.user,
270
+ accessToken: response.token,
271
+ refreshToken: response.refreshToken || null,
272
+ status: newStatus,
273
+ isLoading: false,
274
+ error: null,
275
+ });
276
+
277
+ return response;
278
+ } catch (error) {
279
+ const authError = error as AuthError;
280
+ setState((prev) => ({
281
+ ...prev,
282
+ isLoading: false,
283
+ error: authError,
284
+ }));
285
+ config.onError?.(authError);
286
+ throw error;
287
+ }
288
+ },
289
+ [apiClient, config]
290
+ );
291
+
292
+ const register = useCallback(
293
+ async (data: RegisterRequest): Promise<RegisterResponse> => {
294
+ setState((prev) => ({ ...prev, isLoading: true, error: null }));
295
+
296
+ try {
297
+ const response = await apiClient.register(data);
298
+
299
+ setState((prev) => ({
300
+ ...prev,
301
+ isLoading: false,
302
+ }));
303
+
304
+ return response;
305
+ } catch (error) {
306
+ const authError = error as AuthError;
307
+ setState((prev) => ({
308
+ ...prev,
309
+ isLoading: false,
310
+ error: authError,
311
+ }));
312
+ config.onError?.(authError);
313
+ throw error;
314
+ }
315
+ },
316
+ [apiClient, config]
317
+ );
318
+
319
+ const verify = useCallback(
320
+ async (data: VerifyRequest): Promise<VerifyResponse> => {
321
+ setState((prev) => ({ ...prev, isLoading: true, error: null }));
322
+
323
+ try {
324
+ const response = await apiClient.verify(data);
325
+
326
+ setState({
327
+ user: response.user,
328
+ accessToken: response.token,
329
+ refreshToken: response.refreshToken || null,
330
+ status: 'authenticated',
331
+ isLoading: false,
332
+ error: null,
333
+ });
334
+
335
+ return response;
336
+ } catch (error) {
337
+ const authError = error as AuthError;
338
+ setState((prev) => ({
339
+ ...prev,
340
+ isLoading: false,
341
+ error: authError,
342
+ }));
343
+ config.onError?.(authError);
344
+ throw error;
345
+ }
346
+ },
347
+ [apiClient, config]
348
+ );
349
+
350
+ const resendVerification = useCallback(
351
+ async (data: ResendVerificationRequest): Promise<ResendVerificationResponse> => {
352
+ return apiClient.resendVerification(data);
353
+ },
354
+ [apiClient]
355
+ );
356
+
357
+ const forgotPassword = useCallback(
358
+ async (data: ForgotPasswordRequest): Promise<ForgotPasswordResponse> => {
359
+ return apiClient.forgotPassword(data);
360
+ },
361
+ [apiClient]
362
+ );
363
+
364
+ const recoverPassword = useCallback(
365
+ async (data: RecoverPasswordRequest): Promise<RecoverPasswordResponse> => {
366
+ return apiClient.recoverPassword(data);
367
+ },
368
+ [apiClient]
369
+ );
370
+
371
+ const logout = useCallback(async (): Promise<void> => {
372
+ log('Logging out...');
373
+
374
+ await tokenStorage.clear();
375
+ apiClient.setAccessToken(null);
376
+
377
+ setState({
378
+ user: null,
379
+ accessToken: null,
380
+ refreshToken: null,
381
+ status: 'unauthenticated',
382
+ isLoading: false,
383
+ error: null,
384
+ });
385
+
386
+ config.onLogout?.();
387
+ }, [tokenStorage, apiClient, config, log]);
388
+
389
+ const refreshTokens = useCallback(async (): Promise<void> => {
390
+ if (!state.refreshToken) {
391
+ throw new Error('No refresh token available');
392
+ }
393
+
394
+ const response = await apiClient.refreshToken({
395
+ refreshToken: state.refreshToken,
396
+ });
397
+
398
+ setState((prev) => ({
399
+ ...prev,
400
+ accessToken: response.token,
401
+ refreshToken: response.refreshToken,
402
+ }));
403
+ }, [apiClient, state.refreshToken]);
404
+
405
+ // ========================================================================
406
+ // Utility Functions
407
+ // ========================================================================
408
+
409
+ const getAccessToken = useCallback(() => state.accessToken, [state.accessToken]);
410
+
411
+ const hasRole = useCallback(
412
+ (role: UserRole | UserRole[]): boolean => {
413
+ if (!state.user) return false;
414
+
415
+ const roles = Array.isArray(role) ? role : [role];
416
+ return roles.includes(state.user.role);
417
+ },
418
+ [state.user]
419
+ );
420
+
421
+ // ========================================================================
422
+ // Context Value
423
+ // ========================================================================
424
+
425
+ const contextValue = useMemo<AuthContextValue>(
426
+ () => ({
427
+ // State
428
+ ...state,
429
+ isAuthenticated: state.status === 'authenticated',
430
+ isVerified: state.user?.isVerified ?? false,
431
+
432
+ // Actions
433
+ login,
434
+ register,
435
+ logout,
436
+ verify,
437
+ resendVerification,
438
+ forgotPassword,
439
+ recoverPassword,
440
+ refreshTokens,
441
+
442
+ // Utilities
443
+ getAccessToken,
444
+ hasRole,
445
+
446
+ // Config
447
+ config: config as AuthConfig,
448
+ }),
449
+ [
450
+ state,
451
+ login,
452
+ register,
453
+ logout,
454
+ verify,
455
+ resendVerification,
456
+ forgotPassword,
457
+ recoverPassword,
458
+ refreshTokens,
459
+ getAccessToken,
460
+ hasRole,
461
+ config,
462
+ ]
463
+ );
464
+
465
+ return <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>;
466
+ }
467
+
468
+ // ============================================================================
469
+ // Hook
470
+ // ============================================================================
471
+
472
+ export function useAuthContext(): AuthContextValue {
473
+ const context = useContext(AuthContext);
474
+ if (!context) {
475
+ throw new Error('useAuthContext must be used within an AuthProvider');
476
+ }
477
+ return context;
478
+ }
479
+
480
+ export { AuthContext };