@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,411 @@
1
+ /**
2
+ * @pixygon/auth - Verify Form Component
3
+ * Email verification form for Pixygon Account
4
+ */
5
+
6
+ import { useState, useCallback, useRef, useEffect, type FormEvent, type KeyboardEvent, type ClipboardEvent } from 'react';
7
+ import { useAuth } from '../hooks';
8
+ import type { VerifyFormProps, AuthError } from '../types';
9
+
10
+ export function VerifyForm({
11
+ userName,
12
+ onSuccess,
13
+ onError,
14
+ onNavigateLogin,
15
+ showBranding = true,
16
+ className = '',
17
+ }: VerifyFormProps) {
18
+ const { verify, resendVerification, isLoading, error } = useAuth();
19
+
20
+ const [code, setCode] = useState(['', '', '', '', '', '']);
21
+ const [localError, setLocalError] = useState<string | null>(null);
22
+ const [resendSuccess, setResendSuccess] = useState(false);
23
+ const [resendCooldown, setResendCooldown] = useState(0);
24
+
25
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
26
+
27
+ // Focus first input on mount
28
+ useEffect(() => {
29
+ inputRefs.current[0]?.focus();
30
+ }, []);
31
+
32
+ // Resend cooldown timer
33
+ useEffect(() => {
34
+ if (resendCooldown > 0) {
35
+ const timer = setTimeout(() => setResendCooldown(resendCooldown - 1), 1000);
36
+ return () => clearTimeout(timer);
37
+ }
38
+ }, [resendCooldown]);
39
+
40
+ const handleChange = useCallback(
41
+ (index: number, value: string) => {
42
+ if (!/^\d*$/.test(value)) return; // Only allow digits
43
+
44
+ const newCode = [...code];
45
+ newCode[index] = value.slice(-1); // Only keep last digit
46
+ setCode(newCode);
47
+
48
+ // Auto-focus next input
49
+ if (value && index < 5) {
50
+ inputRefs.current[index + 1]?.focus();
51
+ }
52
+
53
+ // Auto-submit when all filled
54
+ if (newCode.every((digit) => digit) && value) {
55
+ handleSubmitCode(newCode.join(''));
56
+ }
57
+ },
58
+ [code]
59
+ );
60
+
61
+ const handleKeyDown = useCallback(
62
+ (index: number, e: KeyboardEvent<HTMLInputElement>) => {
63
+ if (e.key === 'Backspace' && !code[index] && index > 0) {
64
+ inputRefs.current[index - 1]?.focus();
65
+ }
66
+ },
67
+ [code]
68
+ );
69
+
70
+ const handlePaste = useCallback(
71
+ (e: ClipboardEvent<HTMLInputElement>) => {
72
+ e.preventDefault();
73
+ const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6);
74
+ if (pasted.length === 6) {
75
+ const newCode = pasted.split('');
76
+ setCode(newCode);
77
+ inputRefs.current[5]?.focus();
78
+ handleSubmitCode(pasted);
79
+ }
80
+ },
81
+ []
82
+ );
83
+
84
+ const handleSubmitCode = useCallback(
85
+ async (verificationCode: string) => {
86
+ setLocalError(null);
87
+ setResendSuccess(false);
88
+
89
+ try {
90
+ const response = await verify({ userName, verificationCode });
91
+ onSuccess?.(response.user);
92
+ } catch (err) {
93
+ const authError = err as AuthError;
94
+ onError?.(authError);
95
+ // Clear code on error
96
+ setCode(['', '', '', '', '', '']);
97
+ inputRefs.current[0]?.focus();
98
+ }
99
+ },
100
+ [userName, verify, onSuccess, onError]
101
+ );
102
+
103
+ const handleSubmit = useCallback(
104
+ (e: FormEvent) => {
105
+ e.preventDefault();
106
+ const verificationCode = code.join('');
107
+ if (verificationCode.length !== 6) {
108
+ setLocalError('Please enter the complete 6-digit code');
109
+ return;
110
+ }
111
+ handleSubmitCode(verificationCode);
112
+ },
113
+ [code, handleSubmitCode]
114
+ );
115
+
116
+ const handleResend = useCallback(async () => {
117
+ if (resendCooldown > 0) return;
118
+
119
+ setLocalError(null);
120
+ setResendSuccess(false);
121
+
122
+ try {
123
+ await resendVerification({ userName });
124
+ setResendSuccess(true);
125
+ setResendCooldown(60); // 60 second cooldown
126
+ } catch (err) {
127
+ const authError = err as AuthError;
128
+ setLocalError(authError.message);
129
+ }
130
+ }, [userName, resendVerification, resendCooldown]);
131
+
132
+ const displayError = localError || error?.message;
133
+
134
+ return (
135
+ <div className={`pixygon-auth-container ${className}`}>
136
+ <style>{`
137
+ .pixygon-auth-container {
138
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
139
+ background: #0f0f0f;
140
+ color: #ffffff;
141
+ padding: 2rem;
142
+ border-radius: 1rem;
143
+ max-width: 400px;
144
+ width: 100%;
145
+ margin: 0 auto;
146
+ }
147
+
148
+ .pixygon-auth-header {
149
+ text-align: center;
150
+ margin-bottom: 2rem;
151
+ }
152
+
153
+ .pixygon-auth-logo {
154
+ width: 64px;
155
+ height: 64px;
156
+ margin: 0 auto 1rem;
157
+ display: block;
158
+ }
159
+
160
+ .pixygon-auth-title {
161
+ font-size: 1.5rem;
162
+ font-weight: 600;
163
+ margin: 0 0 0.5rem;
164
+ }
165
+
166
+ .pixygon-auth-subtitle {
167
+ font-size: 0.875rem;
168
+ color: #a3a3a3;
169
+ margin: 0;
170
+ }
171
+
172
+ .pixygon-auth-form {
173
+ display: flex;
174
+ flex-direction: column;
175
+ gap: 1.5rem;
176
+ }
177
+
178
+ .pixygon-auth-code-container {
179
+ display: flex;
180
+ gap: 0.5rem;
181
+ justify-content: center;
182
+ }
183
+
184
+ .pixygon-auth-code-input {
185
+ width: 3rem;
186
+ height: 3.5rem;
187
+ text-align: center;
188
+ font-size: 1.5rem;
189
+ font-weight: 600;
190
+ background: #262626;
191
+ border: 1px solid #404040;
192
+ border-radius: 0.5rem;
193
+ color: #ffffff;
194
+ outline: none;
195
+ transition: border-color 0.2s, box-shadow 0.2s;
196
+ }
197
+
198
+ .pixygon-auth-code-input:focus {
199
+ border-color: #6366f1;
200
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
201
+ }
202
+
203
+ .pixygon-auth-code-input.error {
204
+ border-color: #ef4444;
205
+ }
206
+
207
+ .pixygon-auth-button {
208
+ background: #6366f1;
209
+ color: white;
210
+ border: none;
211
+ border-radius: 0.5rem;
212
+ padding: 0.875rem 1.5rem;
213
+ font-size: 1rem;
214
+ font-weight: 600;
215
+ cursor: pointer;
216
+ transition: background 0.2s;
217
+ display: flex;
218
+ align-items: center;
219
+ justify-content: center;
220
+ gap: 0.5rem;
221
+ }
222
+
223
+ .pixygon-auth-button:hover:not(:disabled) {
224
+ background: #4f46e5;
225
+ }
226
+
227
+ .pixygon-auth-button:disabled {
228
+ opacity: 0.6;
229
+ cursor: not-allowed;
230
+ }
231
+
232
+ .pixygon-auth-error {
233
+ background: rgba(239, 68, 68, 0.1);
234
+ border: 1px solid #ef4444;
235
+ border-radius: 0.5rem;
236
+ padding: 0.75rem 1rem;
237
+ color: #fca5a5;
238
+ font-size: 0.875rem;
239
+ }
240
+
241
+ .pixygon-auth-success {
242
+ background: rgba(34, 197, 94, 0.1);
243
+ border: 1px solid #22c55e;
244
+ border-radius: 0.5rem;
245
+ padding: 0.75rem 1rem;
246
+ color: #86efac;
247
+ font-size: 0.875rem;
248
+ }
249
+
250
+ .pixygon-auth-link {
251
+ color: #6366f1;
252
+ text-decoration: none;
253
+ font-size: 0.875rem;
254
+ cursor: pointer;
255
+ background: none;
256
+ border: none;
257
+ padding: 0;
258
+ font-family: inherit;
259
+ }
260
+
261
+ .pixygon-auth-link:hover {
262
+ color: #818cf8;
263
+ text-decoration: underline;
264
+ }
265
+
266
+ .pixygon-auth-link:disabled {
267
+ color: #737373;
268
+ cursor: not-allowed;
269
+ }
270
+
271
+ .pixygon-auth-footer {
272
+ text-align: center;
273
+ margin-top: 1.5rem;
274
+ font-size: 0.875rem;
275
+ color: #a3a3a3;
276
+ }
277
+
278
+ .pixygon-auth-branding {
279
+ display: flex;
280
+ align-items: center;
281
+ justify-content: center;
282
+ gap: 0.5rem;
283
+ margin-top: 1.5rem;
284
+ font-size: 0.75rem;
285
+ color: #737373;
286
+ }
287
+
288
+ .pixygon-auth-spinner {
289
+ width: 20px;
290
+ height: 20px;
291
+ border: 2px solid transparent;
292
+ border-top-color: currentColor;
293
+ border-radius: 50%;
294
+ animation: pixygon-spin 0.8s linear infinite;
295
+ }
296
+
297
+ @keyframes pixygon-spin {
298
+ to { transform: rotate(360deg); }
299
+ }
300
+
301
+ .pixygon-auth-resend {
302
+ text-align: center;
303
+ font-size: 0.875rem;
304
+ color: #a3a3a3;
305
+ }
306
+ `}</style>
307
+
308
+ <div className="pixygon-auth-header">
309
+ <svg
310
+ className="pixygon-auth-logo"
311
+ viewBox="0 0 100 100"
312
+ fill="none"
313
+ xmlns="http://www.w3.org/2000/svg"
314
+ >
315
+ <circle cx="50" cy="50" r="45" fill="#6366f1" />
316
+ <path
317
+ d="M30 45L45 30L70 55L55 70L30 45Z"
318
+ fill="white"
319
+ fillOpacity="0.9"
320
+ />
321
+ <path
322
+ d="M35 55L50 70L45 75L30 60L35 55Z"
323
+ fill="white"
324
+ fillOpacity="0.7"
325
+ />
326
+ </svg>
327
+ <h1 className="pixygon-auth-title">Verify Email</h1>
328
+ <p className="pixygon-auth-subtitle">
329
+ Enter the 6-digit code sent to your email
330
+ </p>
331
+ </div>
332
+
333
+ <form className="pixygon-auth-form" onSubmit={handleSubmit}>
334
+ {displayError && (
335
+ <div className="pixygon-auth-error">{displayError}</div>
336
+ )}
337
+
338
+ {resendSuccess && (
339
+ <div className="pixygon-auth-success">
340
+ Verification code sent! Check your email.
341
+ </div>
342
+ )}
343
+
344
+ <div className="pixygon-auth-code-container">
345
+ {code.map((digit, index) => (
346
+ <input
347
+ key={index}
348
+ ref={(el) => (inputRefs.current[index] = el)}
349
+ className={`pixygon-auth-code-input ${displayError ? 'error' : ''}`}
350
+ type="text"
351
+ inputMode="numeric"
352
+ maxLength={1}
353
+ value={digit}
354
+ onChange={(e) => handleChange(index, e.target.value)}
355
+ onKeyDown={(e) => handleKeyDown(index, e)}
356
+ onPaste={handlePaste}
357
+ disabled={isLoading}
358
+ aria-label={`Digit ${index + 1}`}
359
+ />
360
+ ))}
361
+ </div>
362
+
363
+ <button
364
+ type="submit"
365
+ className="pixygon-auth-button"
366
+ disabled={isLoading || code.some((d) => !d)}
367
+ >
368
+ {isLoading ? (
369
+ <>
370
+ <div className="pixygon-auth-spinner" />
371
+ Verifying...
372
+ </>
373
+ ) : (
374
+ 'Verify Email'
375
+ )}
376
+ </button>
377
+
378
+ <div className="pixygon-auth-resend">
379
+ Didn't receive the code?{' '}
380
+ <button
381
+ type="button"
382
+ className="pixygon-auth-link"
383
+ onClick={handleResend}
384
+ disabled={resendCooldown > 0}
385
+ >
386
+ {resendCooldown > 0 ? `Resend in ${resendCooldown}s` : 'Resend code'}
387
+ </button>
388
+ </div>
389
+ </form>
390
+
391
+ {onNavigateLogin && (
392
+ <div className="pixygon-auth-footer">
393
+ Wrong account?{' '}
394
+ <button
395
+ type="button"
396
+ className="pixygon-auth-link"
397
+ onClick={onNavigateLogin}
398
+ >
399
+ Sign in with a different account
400
+ </button>
401
+ </div>
402
+ )}
403
+
404
+ {showBranding && (
405
+ <div className="pixygon-auth-branding">
406
+ Secured by Pixygon Account
407
+ </div>
408
+ )}
409
+ </div>
410
+ );
411
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @pixygon/auth/components
3
+ * Branded authentication components for Pixygon Account
4
+ *
5
+ * Usage:
6
+ * ```tsx
7
+ * import { PixygonAuth, LoginForm } from '@pixygon/auth/components';
8
+ *
9
+ * // All-in-one component
10
+ * <PixygonAuth
11
+ * mode="login"
12
+ * onSuccess={(user) => console.log('Logged in:', user)}
13
+ * onModeChange={(mode) => router.push(`/auth/${mode}`)}
14
+ * />
15
+ *
16
+ * // Or individual forms
17
+ * <LoginForm
18
+ * onSuccess={(user) => console.log('Logged in:', user)}
19
+ * onNavigateRegister={() => router.push('/register')}
20
+ * />
21
+ * ```
22
+ */
23
+
24
+ // Main component
25
+ export { PixygonAuth } from './PixygonAuth';
26
+
27
+ // Individual forms
28
+ export { LoginForm } from './LoginForm';
29
+ export { RegisterForm } from './RegisterForm';
30
+ export { VerifyForm } from './VerifyForm';
31
+ export { ForgotPasswordForm } from './ForgotPasswordForm';
32
+
33
+ // Re-export types for convenience
34
+ export type {
35
+ PixygonAuthProps,
36
+ LoginFormProps,
37
+ RegisterFormProps,
38
+ VerifyFormProps,
39
+ ForgotPasswordFormProps,
40
+ } from '../types';