@wakastellar/ui 2.1.0 → 2.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wakastellar/ui",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "Zero-config UI Library for Next.js with TweakCN theming and i18n support",
5
5
  "keywords": [
6
6
  "ui",
@@ -0,0 +1,655 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "../../utils"
5
+ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "../../components/card"
6
+ import { Button } from "../../components/button"
7
+ import { Input } from "../../components/input"
8
+ import { Label } from "../../components/label"
9
+ import { Badge } from "../../components/badge"
10
+ import { Separator } from "../../components/separator"
11
+ import { RadioGroup, RadioGroupItem } from "../../components/radio-group"
12
+ import {
13
+ Shield,
14
+ Smartphone,
15
+ Mail,
16
+ Key,
17
+ Copy,
18
+ Check,
19
+ RefreshCw,
20
+ AlertTriangle,
21
+ Lock,
22
+ QrCode,
23
+ Download,
24
+ Eye,
25
+ EyeOff,
26
+ ChevronRight,
27
+ ShieldCheck,
28
+ ShieldAlert,
29
+ Fingerprint,
30
+ MessageSquare,
31
+ } from "lucide-react"
32
+
33
+ // ============================================
34
+ // TYPES
35
+ // ============================================
36
+
37
+ export type TwoFactorMethod = "authenticator" | "sms" | "email" | "hardware"
38
+
39
+ export interface BackupCode {
40
+ code: string
41
+ used: boolean
42
+ }
43
+
44
+ export interface TwoFactorStatus {
45
+ enabled: boolean
46
+ method?: TwoFactorMethod
47
+ phone?: string
48
+ email?: string
49
+ lastVerified?: Date | string
50
+ }
51
+
52
+ export interface Auth2FAProps {
53
+ /** Current 2FA status */
54
+ status?: TwoFactorStatus
55
+ /** Available methods */
56
+ availableMethods?: TwoFactorMethod[]
57
+ /** QR code data URL for authenticator setup */
58
+ qrCodeUrl?: string
59
+ /** Secret key for manual entry */
60
+ secretKey?: string
61
+ /** Backup codes */
62
+ backupCodes?: BackupCode[]
63
+ /** Handler for enabling 2FA */
64
+ onEnable?: (method: TwoFactorMethod, verificationCode: string) => Promise<boolean>
65
+ /** Handler for disabling 2FA */
66
+ onDisable?: (verificationCode: string) => Promise<boolean>
67
+ /** Handler for regenerating backup codes */
68
+ onRegenerateBackupCodes?: () => Promise<BackupCode[]>
69
+ /** Handler for sending verification code */
70
+ onSendCode?: (method: TwoFactorMethod) => Promise<boolean>
71
+ /** Custom className */
72
+ className?: string
73
+ }
74
+
75
+ // ============================================
76
+ // SUBCOMPONENTS
77
+ // ============================================
78
+
79
+ const methodConfig = {
80
+ authenticator: {
81
+ icon: <Smartphone className="h-5 w-5" />,
82
+ title: "Authenticator App",
83
+ description: "Use an app like Google Authenticator or Authy",
84
+ recommended: true,
85
+ },
86
+ sms: {
87
+ icon: <MessageSquare className="h-5 w-5" />,
88
+ title: "SMS",
89
+ description: "Receive codes via text message",
90
+ recommended: false,
91
+ },
92
+ email: {
93
+ icon: <Mail className="h-5 w-5" />,
94
+ title: "Email",
95
+ description: "Receive codes via email",
96
+ recommended: false,
97
+ },
98
+ hardware: {
99
+ icon: <Key className="h-5 w-5" />,
100
+ title: "Hardware Key",
101
+ description: "Use a physical security key (YubiKey, etc.)",
102
+ recommended: true,
103
+ },
104
+ }
105
+
106
+ function MethodSelector({
107
+ methods,
108
+ selected,
109
+ onSelect,
110
+ }: {
111
+ methods: TwoFactorMethod[]
112
+ selected: TwoFactorMethod
113
+ onSelect: (method: TwoFactorMethod) => void
114
+ }) {
115
+ return (
116
+ <RadioGroup value={selected} onValueChange={(v) => onSelect(v as TwoFactorMethod)}>
117
+ <div className="space-y-3">
118
+ {methods.map((method) => {
119
+ const config = methodConfig[method]
120
+ return (
121
+ <Label
122
+ key={method}
123
+ className={cn(
124
+ "flex items-center gap-4 p-4 rounded-lg border cursor-pointer transition-colors",
125
+ selected === method ? "border-primary bg-primary/5" : "hover:bg-muted/50"
126
+ )}
127
+ >
128
+ <RadioGroupItem value={method} />
129
+ <div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center">
130
+ {config.icon}
131
+ </div>
132
+ <div className="flex-1">
133
+ <div className="flex items-center gap-2">
134
+ <span className="font-medium">{config.title}</span>
135
+ {config.recommended && (
136
+ <Badge variant="secondary" className="text-xs">Recommended</Badge>
137
+ )}
138
+ </div>
139
+ <p className="text-sm text-muted-foreground">{config.description}</p>
140
+ </div>
141
+ </Label>
142
+ )
143
+ })}
144
+ </div>
145
+ </RadioGroup>
146
+ )
147
+ }
148
+
149
+ function CodeInput({
150
+ length = 6,
151
+ value,
152
+ onChange,
153
+ }: {
154
+ length?: number
155
+ value: string
156
+ onChange: (value: string) => void
157
+ }) {
158
+ const inputRefs = React.useRef<(HTMLInputElement | null)[]>([])
159
+ const digits = value.padEnd(length, "").split("").slice(0, length)
160
+
161
+ const handleInput = (index: number, newValue: string) => {
162
+ if (!/^\d*$/.test(newValue)) return
163
+
164
+ const newDigits = [...digits]
165
+ newDigits[index] = newValue.slice(-1)
166
+ onChange(newDigits.join(""))
167
+
168
+ if (newValue && index < length - 1) {
169
+ inputRefs.current[index + 1]?.focus()
170
+ }
171
+ }
172
+
173
+ const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
174
+ if (e.key === "Backspace" && !digits[index] && index > 0) {
175
+ inputRefs.current[index - 1]?.focus()
176
+ }
177
+ }
178
+
179
+ const handlePaste = (e: React.ClipboardEvent) => {
180
+ e.preventDefault()
181
+ const pasteData = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length)
182
+ onChange(pasteData)
183
+ }
184
+
185
+ return (
186
+ <div className="flex gap-2 justify-center">
187
+ {digits.map((digit, index) => (
188
+ <Input
189
+ key={index}
190
+ ref={(el) => { inputRefs.current[index] = el }}
191
+ type="text"
192
+ inputMode="numeric"
193
+ maxLength={1}
194
+ value={digit}
195
+ onChange={(e) => handleInput(index, e.target.value)}
196
+ onKeyDown={(e) => handleKeyDown(index, e)}
197
+ onPaste={handlePaste}
198
+ className="w-12 h-14 text-center text-2xl font-mono"
199
+ />
200
+ ))}
201
+ </div>
202
+ )
203
+ }
204
+
205
+ function BackupCodesDisplay({
206
+ codes,
207
+ onRegenerate,
208
+ onDownload,
209
+ }: {
210
+ codes: BackupCode[]
211
+ onRegenerate?: () => void
212
+ onDownload?: () => void
213
+ }) {
214
+ const [copied, setCopied] = React.useState(false)
215
+ const [showCodes, setShowCodes] = React.useState(false)
216
+
217
+ const copyAllCodes = () => {
218
+ const codesText = codes.map((c) => c.code).join("\n")
219
+ navigator.clipboard.writeText(codesText)
220
+ setCopied(true)
221
+ setTimeout(() => setCopied(false), 2000)
222
+ }
223
+
224
+ const unusedCodes = codes.filter((c) => !c.used)
225
+
226
+ return (
227
+ <Card>
228
+ <CardHeader>
229
+ <div className="flex items-center justify-between">
230
+ <div>
231
+ <CardTitle className="flex items-center gap-2">
232
+ <Key className="h-5 w-5" />
233
+ Backup Codes
234
+ </CardTitle>
235
+ <CardDescription>
236
+ {unusedCodes.length} of {codes.length} codes remaining
237
+ </CardDescription>
238
+ </div>
239
+ <Button variant="ghost" size="icon" onClick={() => setShowCodes(!showCodes)}>
240
+ {showCodes ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
241
+ </Button>
242
+ </div>
243
+ </CardHeader>
244
+ <CardContent>
245
+ <div className="grid grid-cols-2 gap-2">
246
+ {codes.map((code, index) => (
247
+ <div
248
+ key={index}
249
+ className={cn(
250
+ "font-mono text-sm p-2 rounded border text-center",
251
+ code.used
252
+ ? "bg-muted text-muted-foreground line-through"
253
+ : "bg-card"
254
+ )}
255
+ >
256
+ {showCodes ? code.code : "••••••••"}
257
+ </div>
258
+ ))}
259
+ </div>
260
+
261
+ {unusedCodes.length < 3 && (
262
+ <div className="mt-4 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 flex items-center gap-3">
263
+ <AlertTriangle className="h-5 w-5 text-yellow-500" />
264
+ <p className="text-sm">You're running low on backup codes. Consider regenerating them.</p>
265
+ </div>
266
+ )}
267
+ </CardContent>
268
+ <CardFooter className="flex gap-2">
269
+ <Button variant="outline" size="sm" onClick={copyAllCodes}>
270
+ {copied ? <Check className="h-4 w-4 mr-2" /> : <Copy className="h-4 w-4 mr-2" />}
271
+ {copied ? "Copied" : "Copy All"}
272
+ </Button>
273
+ {onDownload && (
274
+ <Button variant="outline" size="sm" onClick={onDownload}>
275
+ <Download className="h-4 w-4 mr-2" />
276
+ Download
277
+ </Button>
278
+ )}
279
+ {onRegenerate && (
280
+ <Button variant="outline" size="sm" onClick={onRegenerate}>
281
+ <RefreshCw className="h-4 w-4 mr-2" />
282
+ Regenerate
283
+ </Button>
284
+ )}
285
+ </CardFooter>
286
+ </Card>
287
+ )
288
+ }
289
+
290
+ // ============================================
291
+ // MAIN COMPONENT
292
+ // ============================================
293
+
294
+ export function Auth2FA({
295
+ status = { enabled: false },
296
+ availableMethods = ["authenticator", "sms", "email"],
297
+ qrCodeUrl,
298
+ secretKey,
299
+ backupCodes,
300
+ onEnable,
301
+ onDisable,
302
+ onRegenerateBackupCodes,
303
+ onSendCode,
304
+ className,
305
+ }: Auth2FAProps) {
306
+ const [step, setStep] = React.useState<"overview" | "setup" | "verify" | "disable">("overview")
307
+ const [selectedMethod, setSelectedMethod] = React.useState<TwoFactorMethod>(availableMethods[0])
308
+ const [verificationCode, setVerificationCode] = React.useState("")
309
+ const [isLoading, setIsLoading] = React.useState(false)
310
+ const [error, setError] = React.useState<string | null>(null)
311
+ const [copied, setCopied] = React.useState(false)
312
+ const [codeSent, setCodeSent] = React.useState(false)
313
+
314
+ const handleCopySecret = () => {
315
+ if (secretKey) {
316
+ navigator.clipboard.writeText(secretKey)
317
+ setCopied(true)
318
+ setTimeout(() => setCopied(false), 2000)
319
+ }
320
+ }
321
+
322
+ const handleSendCode = async () => {
323
+ if (!onSendCode) return
324
+ setIsLoading(true)
325
+ try {
326
+ await onSendCode(selectedMethod)
327
+ setCodeSent(true)
328
+ } finally {
329
+ setIsLoading(false)
330
+ }
331
+ }
332
+
333
+ const handleEnable = async () => {
334
+ if (!onEnable) return
335
+ setIsLoading(true)
336
+ setError(null)
337
+ try {
338
+ const success = await onEnable(selectedMethod, verificationCode)
339
+ if (success) {
340
+ setStep("overview")
341
+ setVerificationCode("")
342
+ } else {
343
+ setError("Invalid verification code. Please try again.")
344
+ }
345
+ } finally {
346
+ setIsLoading(false)
347
+ }
348
+ }
349
+
350
+ const handleDisable = async () => {
351
+ if (!onDisable) return
352
+ setIsLoading(true)
353
+ setError(null)
354
+ try {
355
+ const success = await onDisable(verificationCode)
356
+ if (success) {
357
+ setStep("overview")
358
+ setVerificationCode("")
359
+ } else {
360
+ setError("Invalid verification code. Please try again.")
361
+ }
362
+ } finally {
363
+ setIsLoading(false)
364
+ }
365
+ }
366
+
367
+ return (
368
+ <div className={cn("space-y-6 max-w-2xl", className)}>
369
+ {/* Status Card */}
370
+ <Card>
371
+ <CardHeader>
372
+ <div className="flex items-center justify-between">
373
+ <div className="flex items-center gap-3">
374
+ {status.enabled ? (
375
+ <div className="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center">
376
+ <ShieldCheck className="h-6 w-6 text-green-500" />
377
+ </div>
378
+ ) : (
379
+ <div className="h-12 w-12 rounded-full bg-yellow-500/10 flex items-center justify-center">
380
+ <ShieldAlert className="h-6 w-6 text-yellow-500" />
381
+ </div>
382
+ )}
383
+ <div>
384
+ <CardTitle>Two-Factor Authentication</CardTitle>
385
+ <CardDescription>
386
+ {status.enabled
387
+ ? `Enabled via ${methodConfig[status.method!]?.title}`
388
+ : "Add an extra layer of security to your account"}
389
+ </CardDescription>
390
+ </div>
391
+ </div>
392
+ <Badge variant={status.enabled ? "default" : "secondary"}>
393
+ {status.enabled ? "Enabled" : "Disabled"}
394
+ </Badge>
395
+ </div>
396
+ </CardHeader>
397
+ {step === "overview" && (
398
+ <CardFooter>
399
+ {status.enabled ? (
400
+ <div className="flex gap-2 w-full">
401
+ <Button variant="outline" className="flex-1" onClick={() => setStep("setup")}>
402
+ Change Method
403
+ </Button>
404
+ <Button variant="destructive" className="flex-1" onClick={() => setStep("disable")}>
405
+ Disable 2FA
406
+ </Button>
407
+ </div>
408
+ ) : (
409
+ <Button className="w-full" onClick={() => setStep("setup")}>
410
+ <Shield className="h-4 w-4 mr-2" />
411
+ Enable Two-Factor Authentication
412
+ </Button>
413
+ )}
414
+ </CardFooter>
415
+ )}
416
+ </Card>
417
+
418
+ {/* Setup Step */}
419
+ {step === "setup" && (
420
+ <Card>
421
+ <CardHeader>
422
+ <CardTitle>Choose Authentication Method</CardTitle>
423
+ <CardDescription>Select how you want to receive verification codes</CardDescription>
424
+ </CardHeader>
425
+ <CardContent>
426
+ <MethodSelector
427
+ methods={availableMethods}
428
+ selected={selectedMethod}
429
+ onSelect={setSelectedMethod}
430
+ />
431
+ </CardContent>
432
+ <CardFooter className="flex gap-2">
433
+ <Button variant="outline" onClick={() => setStep("overview")}>
434
+ Cancel
435
+ </Button>
436
+ <Button className="flex-1" onClick={() => setStep("verify")}>
437
+ Continue
438
+ <ChevronRight className="h-4 w-4 ml-2" />
439
+ </Button>
440
+ </CardFooter>
441
+ </Card>
442
+ )}
443
+
444
+ {/* Verification Step */}
445
+ {step === "verify" && (
446
+ <Card>
447
+ <CardHeader>
448
+ <CardTitle>
449
+ {selectedMethod === "authenticator" ? "Set Up Authenticator" : "Verify Your Identity"}
450
+ </CardTitle>
451
+ <CardDescription>
452
+ {selectedMethod === "authenticator"
453
+ ? "Scan the QR code with your authenticator app"
454
+ : `Enter the code sent to your ${selectedMethod}`}
455
+ </CardDescription>
456
+ </CardHeader>
457
+ <CardContent className="space-y-6">
458
+ {selectedMethod === "authenticator" && (
459
+ <>
460
+ {qrCodeUrl && (
461
+ <div className="flex justify-center">
462
+ <div className="p-4 bg-white rounded-lg">
463
+ <img src={qrCodeUrl} alt="QR Code" className="h-48 w-48" />
464
+ </div>
465
+ </div>
466
+ )}
467
+
468
+ {secretKey && (
469
+ <div className="space-y-2">
470
+ <Label>Or enter this code manually:</Label>
471
+ <div className="flex items-center gap-2">
472
+ <Input value={secretKey} readOnly className="font-mono" />
473
+ <Button variant="outline" size="icon" onClick={handleCopySecret}>
474
+ {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
475
+ </Button>
476
+ </div>
477
+ </div>
478
+ )}
479
+
480
+ <Separator />
481
+ </>
482
+ )}
483
+
484
+ {(selectedMethod === "sms" || selectedMethod === "email") && !codeSent && (
485
+ <div className="text-center">
486
+ <p className="text-sm text-muted-foreground mb-4">
487
+ We'll send a verification code to your {selectedMethod === "sms" ? "phone" : "email"}
488
+ </p>
489
+ <Button onClick={handleSendCode} disabled={isLoading}>
490
+ {isLoading ? (
491
+ <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
492
+ ) : selectedMethod === "sms" ? (
493
+ <MessageSquare className="h-4 w-4 mr-2" />
494
+ ) : (
495
+ <Mail className="h-4 w-4 mr-2" />
496
+ )}
497
+ Send Code
498
+ </Button>
499
+ </div>
500
+ )}
501
+
502
+ {(selectedMethod === "authenticator" || codeSent) && (
503
+ <div className="space-y-4">
504
+ <Label className="text-center block">Enter the 6-digit code</Label>
505
+ <CodeInput value={verificationCode} onChange={setVerificationCode} />
506
+ {error && (
507
+ <p className="text-sm text-destructive text-center">{error}</p>
508
+ )}
509
+ </div>
510
+ )}
511
+ </CardContent>
512
+ <CardFooter className="flex gap-2">
513
+ <Button variant="outline" onClick={() => setStep("setup")}>
514
+ Back
515
+ </Button>
516
+ <Button
517
+ className="flex-1"
518
+ onClick={handleEnable}
519
+ disabled={verificationCode.length !== 6 || isLoading}
520
+ >
521
+ {isLoading ? (
522
+ <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
523
+ ) : (
524
+ <Lock className="h-4 w-4 mr-2" />
525
+ )}
526
+ Verify & Enable
527
+ </Button>
528
+ </CardFooter>
529
+ </Card>
530
+ )}
531
+
532
+ {/* Disable Step */}
533
+ {step === "disable" && (
534
+ <Card>
535
+ <CardHeader>
536
+ <CardTitle className="text-destructive">Disable Two-Factor Authentication</CardTitle>
537
+ <CardDescription>
538
+ Enter your verification code to confirm. This will make your account less secure.
539
+ </CardDescription>
540
+ </CardHeader>
541
+ <CardContent className="space-y-4">
542
+ <div className="p-4 rounded-lg bg-destructive/10 border border-destructive/20">
543
+ <div className="flex items-start gap-3">
544
+ <AlertTriangle className="h-5 w-5 text-destructive mt-0.5" />
545
+ <div>
546
+ <p className="font-medium text-destructive">Warning</p>
547
+ <p className="text-sm text-muted-foreground">
548
+ Disabling 2FA will remove an important layer of security from your account.
549
+ Anyone with your password will be able to access your account.
550
+ </p>
551
+ </div>
552
+ </div>
553
+ </div>
554
+
555
+ <div className="space-y-2">
556
+ <Label>Enter your verification code</Label>
557
+ <CodeInput value={verificationCode} onChange={setVerificationCode} />
558
+ {error && (
559
+ <p className="text-sm text-destructive text-center">{error}</p>
560
+ )}
561
+ </div>
562
+ </CardContent>
563
+ <CardFooter className="flex gap-2">
564
+ <Button variant="outline" onClick={() => setStep("overview")}>
565
+ Cancel
566
+ </Button>
567
+ <Button
568
+ variant="destructive"
569
+ className="flex-1"
570
+ onClick={handleDisable}
571
+ disabled={verificationCode.length !== 6 || isLoading}
572
+ >
573
+ {isLoading ? (
574
+ <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
575
+ ) : (
576
+ <ShieldAlert className="h-4 w-4 mr-2" />
577
+ )}
578
+ Disable 2FA
579
+ </Button>
580
+ </CardFooter>
581
+ </Card>
582
+ )}
583
+
584
+ {/* Backup Codes */}
585
+ {status.enabled && backupCodes && backupCodes.length > 0 && step === "overview" && (
586
+ <BackupCodesDisplay
587
+ codes={backupCodes}
588
+ onRegenerate={onRegenerateBackupCodes}
589
+ onDownload={() => {
590
+ const codesText = backupCodes.map((c) => c.code).join("\n")
591
+ const blob = new Blob([codesText], { type: "text/plain" })
592
+ const url = URL.createObjectURL(blob)
593
+ const a = document.createElement("a")
594
+ a.href = url
595
+ a.download = "backup-codes.txt"
596
+ a.click()
597
+ }}
598
+ />
599
+ )}
600
+
601
+ {/* Security Tips */}
602
+ <Card>
603
+ <CardHeader>
604
+ <CardTitle className="flex items-center gap-2">
605
+ <Fingerprint className="h-5 w-5" />
606
+ Security Tips
607
+ </CardTitle>
608
+ </CardHeader>
609
+ <CardContent className="space-y-3">
610
+ <div className="flex items-start gap-3">
611
+ <Check className="h-4 w-4 text-green-500 mt-1" />
612
+ <p className="text-sm text-muted-foreground">
613
+ Use an authenticator app for the most secure experience
614
+ </p>
615
+ </div>
616
+ <div className="flex items-start gap-3">
617
+ <Check className="h-4 w-4 text-green-500 mt-1" />
618
+ <p className="text-sm text-muted-foreground">
619
+ Store your backup codes in a safe place (password manager, safe deposit box)
620
+ </p>
621
+ </div>
622
+ <div className="flex items-start gap-3">
623
+ <Check className="h-4 w-4 text-green-500 mt-1" />
624
+ <p className="text-sm text-muted-foreground">
625
+ Never share your verification codes with anyone
626
+ </p>
627
+ </div>
628
+ </CardContent>
629
+ </Card>
630
+ </div>
631
+ )
632
+ }
633
+
634
+ // ============================================
635
+ // PRESET DATA
636
+ // ============================================
637
+
638
+ export const defaultBackupCodes: BackupCode[] = [
639
+ { code: "ABCD-1234", used: false },
640
+ { code: "EFGH-5678", used: false },
641
+ { code: "IJKL-9012", used: true },
642
+ { code: "MNOP-3456", used: false },
643
+ { code: "QRST-7890", used: false },
644
+ { code: "UVWX-1234", used: true },
645
+ { code: "YZAB-5678", used: false },
646
+ { code: "CDEF-9012", used: false },
647
+ ]
648
+
649
+ export const defaultStatus: TwoFactorStatus = {
650
+ enabled: true,
651
+ method: "authenticator",
652
+ lastVerified: new Date("2024-03-01"),
653
+ }
654
+
655
+ export default Auth2FA