refactor: convert to bun workspaces monorepo

- Move web app into apps/web/
- Create packages/shared/ with shared types
- Create plugins/decky-vault/ scaffold
- Root package.json manages workspaces only
This commit is contained in:
2026-06-28 05:20:28 +08:00
parent c4bede20d4
commit cd72b7a948
345 changed files with 488 additions and 126 deletions
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest"
import fs from "node:fs"
import path from "node:path"
describe("auth form email placeholder text", () => {
it("all auth forms use you@deckyvault.xyz not you@example.com", () => {
const files = [
"components/auth/signup-form-step.tsx",
"components/auth/login-form.tsx",
"components/auth/forgot-password-form.tsx",
]
for (const file of files) {
const fullPath = path.join(process.cwd(), file)
const content = fs.readFileSync(fullPath, "utf8")
expect(content).not.toContain('"you@example.com"')
expect(content).toContain('"you@deckyvault.xyz"')
}
})
})
@@ -0,0 +1,111 @@
"use client"
import { useState, useRef } from "react"
import { KeyRound, Loader2, ArrowLeft } from "lucide-react"
import { authClient } from "@/lib/auth-client"
import {
forgotPasswordSchema,
} from "@/lib/auth/validation"
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
import Link from "next/link"
import { useRouter } from "next/navigation"
export default function ForgotPasswordForm() {
const router = useRouter()
const [email, setEmail] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [turnstileToken, setTurnstileToken] = useState("")
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
const result = forgotPasswordSchema.safeParse({ email })
if (!result.success) {
setError(result.error.issues[0].message)
return
}
setIsLoading(true)
const { error } = await authClient.emailOtp.requestPasswordReset({
email,
fetchOptions: {
headers: {
"x-captcha-response": turnstileToken,
},
},
})
setIsLoading(false)
if (error) {
setError(
error.message || "Something went wrong. Please try again.",
)
turnstileRef.current?.reset()
setTurnstileToken("")
return
}
// Redirect to reset password page with email
router.push(`/reset-password?email=${encodeURIComponent(email)}`)
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="text-center mb-2">
<KeyRound className="h-10 w-10 text-primary mx-auto mb-3" />
<h1 className="text-xl font-bold text-text">
Forgot your password?
</h1>
<p className="text-sm text-text/50 mt-2 leading-relaxed">
Enter your email and we&apos;ll send you a verification code to
reset your password.
</p>
</div>
{error && (
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
{error}
</div>
)}
<div>
<label className="text-sm text-text/60 block mb-1.5">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@deckyvault.xyz"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
</div>
<button
type="submit"
disabled={isLoading || !turnstileToken}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Send verification code
</button>
<TurnstileWidget
ref={turnstileRef}
onToken={setTurnstileToken}
onExpire={() => setTurnstileToken("")}
/>
<Link
href="/login"
className="flex items-center justify-center gap-2 text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
>
<ArrowLeft className="h-4 w-4" />
Back to sign in
</Link>
</form>
)
}
+355
View File
@@ -0,0 +1,355 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { Loader2, Key } from "lucide-react"
import { authClient } from "@/lib/auth-client"
import {
loginEmailSchema,
loginSchema,
} from "@/lib/auth/validation"
import SocialButtons from "./social-buttons"
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
function isWebAuthnAbortError(err: unknown): boolean {
if (err instanceof DOMException && err.name === "AbortError") return true
const msg = err instanceof Error ? err.message : String(err ?? "")
return msg.includes("abort signal") || msg.includes("AbortError")
}
// Suppress the console.error that @better-auth/passkey logs internally
// when a WebAuthn ceremony is aborted (expected on navigation/remount).
let suppressPasskeyErrors = false
const originalConsoleError = console.error
const passkeyErrorPattern = /\[Better Auth\] Error verifying passkey/
console.error = (...args: unknown[]) => {
if (suppressPasskeyErrors) {
const msg = typeof args[0] === 'string' ? args[0] : ''
if (passkeyErrorPattern.test(msg) || args.some(a => isWebAuthnAbortError(a))) {
return
}
}
originalConsoleError(...args)
}
export default function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const redirectTo = searchParams.get("redirect") || "/"
const [showPassword, setShowPassword] = useState(false)
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [emailChecked, setEmailChecked] = useState(false)
const [turnstileToken, setTurnstileToken] = useState("")
const mountedRef = useRef(true)
const passkeyInitiatedRef = useRef(false)
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
// Redirect to the intended page after successful login
const handleLoginSuccess = useCallback(() => {
if (mountedRef.current) router.push(redirectTo)
}, [router, redirectTo])
// Preload passkeys for conditional UI — must be called on mount when
// both email + password fields are in the DOM.
useEffect(() => {
mountedRef.current = true
if ("PublicKeyCredential" in window && !passkeyInitiatedRef.current) {
passkeyInitiatedRef.current = true
suppressPasskeyErrors = true
authClient.signIn.passkey({
autoFill: true,
fetchOptions: {
onSuccess: handleLoginSuccess,
},
}).catch((err) => {
if (!isWebAuthnAbortError(err)) {
console.warn("[passkey-conditional-ui]", err)
}
}).finally(() => {
suppressPasskeyErrors = false
})
}
return () => { mountedRef.current = false }
}, [handleLoginSuccess])
const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
const result = loginEmailSchema.safeParse({ email })
if (!result.success) {
setError(result.error.issues[0].message)
return
}
setIsLoading(true)
try {
const res = await fetch("/api/auth/check-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
})
if (!res.ok) {
const data = await res.json()
setError(data.error || "Something went wrong. Please try again.")
return
}
const data = await res.json()
if (!data.exists) {
setError("No account found with this email.")
return
}
// Email exists, show password field
setEmailChecked(true)
setShowPassword(true)
} catch {
setError("Something went wrong. Please try again.")
} finally {
setIsLoading(false)
}
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
const result = loginSchema.safeParse({ email, password })
if (!result.success) {
setError(result.error.issues[0].message)
return
}
setIsLoading(true)
const { error } = await authClient.signIn.email({
email,
password,
fetchOptions: {
headers: {
"x-captcha-response": turnstileToken,
},
},
})
setIsLoading(false)
if (error) {
setError(error.message || "Invalid credentials. Please try again.")
turnstileRef.current?.reset()
setTurnstileToken("")
return
}
router.push(redirectTo)
}
const handleChangeEmail = () => {
setShowPassword(false)
setPassword("")
setError("")
setEmailChecked(false)
turnstileRef.current?.reset()
setTurnstileToken("")
}
const handlePasskeyError = useCallback((ctx: { error?: { message?: string } }) => {
// Only show errors that aren't from conditional UI cancellation
// (user dismissing the browser prompt is expected and not an error)
const msg = ctx.error?.message || ""
if (
!msg.includes("No available") &&
!msg.includes("cancelled") &&
!msg.includes("NotAllowed") &&
!msg.includes("aborted")
) {
setError(msg || "Passkey sign-in failed. Please try again.")
}
}, [])
const handlePasskeyLogin = async () => {
setError("")
setIsLoading(true)
suppressPasskeyErrors = true
try {
const result = await authClient.signIn.passkey({
autoFill: false,
fetchOptions: {
onSuccess: handleLoginSuccess,
onError: handlePasskeyError,
},
}).catch((err) => {
if (isWebAuthnAbortError(err)) return { data: null, error: null }
return { data: null, error: { message: err?.message || "Passkey sign-in failed" } }
})
if (result?.error) {
handlePasskeyError({ error: result.error })
}
} catch {
setError("Passkey sign-in failed. Please try again.")
} finally {
suppressPasskeyErrors = false
setIsLoading(false)
}
}
return (
<div className="space-y-5">
<div className="text-center mb-2">
<h1 className="text-xl font-bold text-text">
Welcome back
</h1>
<p className="text-sm text-text/50 mt-1">
{showPassword
? "Signing in as "
: "Sign in to DeckyVault"}
{showPassword && (
<>
<strong className="text-text">{email}</strong>
{" "}
<button
onClick={handleChangeEmail}
className="text-primary hover:underline text-xs"
>
change
</button>
</>
)}
</p>
</div>
<SocialButtons />
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text/40 uppercase">
or continue with email
</span>
<div className="flex-1 h-px bg-border" />
</div>
{error && (
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
{error}
{error.includes("No account found") && (
<>
{" "}
<Link
href="/signup"
className="text-primary font-semibold hover:underline"
>
Create one
</Link>
</>
)}
</div>
)}
{/* Single form always contains both email and password inputs
so that WebAuthn conditional UI (autofill) works correctly.
The password field is visually hidden until the email is verified. */}
<form onSubmit={showPassword ? handleLogin : handleEmailSubmit} className="space-y-4">
<div>
<label className="text-sm text-text/60 block mb-1.5">
Email
</label>
<input
type="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@deckyvault.xyz"
autoComplete="username webauthn"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
</div>
{/* Always render the password input for WebAuthn conditional UI,
but visually hide it until the email is verified */}
<div className={showPassword ? "" : "h-0 overflow-hidden opacity-0 pointer-events-none"}>
<label className="text-sm text-text/60 block mb-1.5">
Password
</label>
<input
type="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
autoComplete="current-password webauthn"
autoFocus={showPassword}
tabIndex={showPassword ? 0 : -1}
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
</div>
{showPassword && (
<div className="text-right">
<Link
href="/forgot-password"
className="text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
>
Forgot password?
</Link>
</div>
)}
{showPassword && (
<TurnstileWidget
ref={turnstileRef}
onToken={setTurnstileToken}
onExpire={() => {
setTurnstileToken("")
}}
/>
)}
<button
type="submit"
disabled={isLoading}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
{showPassword ? "Sign in" : "Continue"}
</button>
{showPassword && (
<div className="text-center p-3 rounded-lg bg-primary/5 border border-primary/10">
<p className="text-xs text-text/50">
Your browser may offer to sign in with a passkey
</p>
</div>
)}
</form>
{/* Explicit passkey login button */}
{"PublicKeyCredential" in globalThis && !showPassword && (
<button
onClick={handlePasskeyLogin}
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-lg border border-border bg-text/5 text-text/70 text-sm font-medium hover:bg-text/10 hover:text-text transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Key className="h-4 w-4" />
)}
Sign in with a passkey
</button>
)}
<p className="text-center text-sm text-text/50">
Don&apos;t have an account?{" "}
<Link
href="/signup"
className="text-primary hover:underline cursor-pointer"
>
Create one
</Link>
</p>
</div>
)
}
+125
View File
@@ -0,0 +1,125 @@
"use client"
import { useRef, useCallback, useMemo } from "react"
interface OtpInputProps {
length?: number
value: string
onChange: (value: string) => void
disabled?: boolean
error?: string
}
export default function OtpInput({
length = 6,
value,
onChange,
disabled = false,
error,
}: OtpInputProps) {
const digits = useMemo(
() =>
value
.split("")
.concat(Array(length).fill(""))
.slice(0, length),
[value, length],
)
const refs = useRef<(HTMLInputElement | null)[]>([])
const updateDigits = useCallback(
(newDigits: string[]) => {
onChange(newDigits.join(""))
},
[onChange],
)
const handleChange = useCallback(
(index: number, val: string) => {
// Only allow single digit
const digit = val.replace(/\D/g, "").slice(-1)
const newDigits = [...digits]
newDigits[index] = digit
updateDigits(newDigits)
// Auto-advance to next box
if (digit && index < length - 1) {
refs.current[index + 1]?.focus()
}
},
[digits, length, updateDigits],
)
const handleKeyDown = useCallback(
(index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace") {
if (!digits[index] && index > 0) {
// Move to previous box if current is empty
refs.current[index - 1]?.focus()
const newDigits = [...digits]
newDigits[index - 1] = ""
updateDigits(newDigits)
}
} else if (e.key === "ArrowLeft" && index > 0) {
refs.current[index - 1]?.focus()
} else if (e.key === "ArrowRight" && index < length - 1) {
refs.current[index + 1]?.focus()
}
},
[digits, length, updateDigits],
)
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault()
const pasted = e.clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, length)
const newDigits = Array(length).fill("")
for (let i = 0; i < pasted.length; i++) {
newDigits[i] = pasted[i]
}
updateDigits(newDigits)
// Focus last filled box or next empty
const focusIndex = Math.min(pasted.length, length - 1)
refs.current[focusIndex]?.focus()
},
[length, updateDigits],
)
return (
<div>
<div className="flex gap-2 justify-center">
{Array.from({ length }).map((_, i) => (
<input
key={i}
ref={(el) => {
refs.current[i] = el
}}
type="text"
inputMode="numeric"
maxLength={1}
value={digits[i]}
onChange={(e) => handleChange(i, e.target.value)}
onKeyDown={(e) => handleKeyDown(i, e)}
onPaste={handlePaste}
disabled={disabled}
aria-label={`Digit ${i + 1} of ${length}`}
className={`w-12 h-14 text-center text-xl font-bold rounded-lg border bg-text/5 text-text outline-none transition-colors ${
error
? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/50"
: "border-border focus:border-primary focus:ring-2 focus:ring-primary/50"
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
/>
))}
</div>
{error && (
<p className="text-red-400 text-sm mt-2 text-center">
{error}
</p>
)}
</div>
)
}
@@ -0,0 +1,143 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { ArrowLeft, Loader2, Mail } from "lucide-react"
import { authClient } from "@/lib/auth-client"
import OtpInput from "./otp-input"
interface OtpVerificationStepProps {
email: string
onSuccess: () => void
onBack: () => void
}
export default function OtpVerificationStep({
email,
onSuccess,
onBack,
}: OtpVerificationStepProps) {
const [otp, setOtp] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [resendTimer, setResendTimer] = useState(300) // 5 minutes
const canResend = resendTimer <= 0
// Countdown timer
useEffect(() => {
if (resendTimer <= 0) return
const interval = setInterval(() => {
setResendTimer((prev) => prev - 1)
}, 1000)
return () => clearInterval(interval)
}, [resendTimer])
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, "0")}`
}
const handleVerify = useCallback(
async (otpValue: string) => {
if (otpValue.length !== 6) return
setIsLoading(true)
setError("")
const { error } = await authClient.emailOtp.verifyEmail({
email,
otp: otpValue,
})
setIsLoading(false)
if (error) {
setError(
error.code === "TOO_MANY_ATTEMPTS"
? "Too many attempts. Please request a new code."
: "Invalid code. Please try again.",
)
return
}
onSuccess()
},
[email, onSuccess],
)
const handleResend = async () => {
setError("")
await authClient.emailOtp.sendVerificationOtp({
email,
type: "email-verification",
})
setResendTimer(300)
}
return (
<div className="space-y-5">
<div className="text-center mb-2">
<Mail className="h-10 w-10 text-primary mx-auto mb-3" />
<h1 className="text-xl font-bold text-text">
Verify your email
</h1>
<p className="text-sm text-text/50 mt-2">
We sent a 6-digit code to{" "}
<strong className="text-text">{email}</strong>
</p>
</div>
<div>
<label className="text-sm text-text/60 block mb-2 text-center">
Verification code
</label>
<OtpInput
value={otp}
onChange={(value) => {
setOtp(value)
if (value.length === 6) {
handleVerify(value)
}
}}
disabled={isLoading}
error={error}
/>
</div>
<div className="text-center text-sm">
{canResend ? (
<button
onClick={handleResend}
className="text-primary hover:underline"
>
Resend code
</button>
) : (
<span className="text-text/40">
Resend code in{" "}
<span className="text-primary font-semibold">
{formatTime(resendTimer)}
</span>
</span>
)}
</div>
<button
onClick={() => handleVerify(otp)}
disabled={isLoading || otp.length !== 6}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Verify
</button>
<button
onClick={onBack}
className="flex items-center justify-center gap-2 w-full text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
>
<ArrowLeft className="h-4 w-4" />
Back to signup
</button>
</div>
)
}
@@ -0,0 +1,115 @@
"use client"
import { useState } from "react"
import { KeyRound, Check, Loader2, Fingerprint } from "lucide-react"
import { authClient } from "@/lib/auth-client"
interface PasskeySetupStepProps {
onSuccess: () => void
onSkip: () => void
}
export default function PasskeySetupStep({
onSuccess,
onSkip,
}: PasskeySetupStepProps) {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const isPasskeySupported =
typeof window !== "undefined" &&
"PublicKeyCredential" in window
const handleAddPasskey = async () => {
setIsLoading(true)
setError("")
const { error } = await authClient.passkey.addPasskey({
name: "Primary passkey",
})
setIsLoading(false)
if (error) {
setError(
error.message ||
"Failed to set up passkey. You can try again later.",
)
return
}
onSuccess()
}
return (
<div className="space-y-5">
<div className="text-center mb-2">
<KeyRound className="h-10 w-10 text-primary mx-auto mb-3" />
<h1 className="text-xl font-bold text-text">
Set up a passkey
</h1>
<p className="text-sm text-text/50 mt-2 leading-relaxed">
Sign in faster with biometrics or your device&apos;s security
key. No password needed.
</p>
</div>
{/* Benefits list */}
<div className="bg-text/5 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3 text-sm text-text/60">
<Check className="h-4 w-4 text-green-400 shrink-0" />
Faster sign-in with fingerprint or face
</div>
<div className="flex items-center gap-3 text-sm text-text/60">
<Check className="h-4 w-4 text-green-400 shrink-0" />
More secure than passwords
</div>
<div className="flex items-center gap-3 text-sm text-text/60">
<Check className="h-4 w-4 text-green-400 shrink-0" />
Works across your devices
</div>
</div>
{error && (
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
{error}
</div>
)}
{isPasskeySupported ? (
<>
<button
onClick={handleAddPasskey}
disabled={isLoading}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Fingerprint className="h-4 w-4" />
)}
Set up passkey
</button>
<button
onClick={onSkip}
className="w-full py-3 rounded-lg border border-border text-text/60 text-sm hover:bg-text/5 transition-colors cursor-pointer"
>
Skip for now
</button>
</>
) : (
<div className="text-center">
<p className="text-sm text-text/40 mb-4">
Passkeys are not supported on this device.
</p>
<button
onClick={onSkip}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer"
>
Continue
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,97 @@
"use client"
import { ShieldCheck, ShieldAlert, ShieldQuestion } from "lucide-react"
import { motion } from "motion/react"
import {
checkPasswordStrength,
type StrengthLevel,
} from "@/lib/auth/password-strength"
interface PasswordStrengthProps {
password: string
}
const levelColors: Record<StrengthLevel, string> = {
weak: "#ef4444",
fair: "#f59e0b",
good: "#eab308",
strong: "#22c55e",
excellent: "#10b981",
}
const levelLabels: Record<StrengthLevel, string> = {
weak: "Weak",
fair: "Fair",
good: "Good",
strong: "Strong",
excellent: "Excellent",
}
const levelBars: Record<StrengthLevel, number> = {
weak: 1,
fair: 2,
good: 3,
strong: 4,
excellent: 5,
}
export default function PasswordStrengthMeter({
password,
}: PasswordStrengthProps) {
if (!password) return null
const { level, feedback } = checkPasswordStrength(password)
const color = levelColors[level]
const bars = levelBars[level]
return (
<div className="mt-2">
{/* Bar indicator */}
<div className="flex gap-1 mb-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<motion.div
key={i}
className="h-[3px] flex-1 rounded-full"
initial={{ backgroundColor: "rgba(255,255,255,0.1)" }}
animate={{
backgroundColor:
i < bars ? color : "rgba(255,255,255,0.1)",
}}
transition={{ duration: 0.2 }}
/>
))}
</div>
{/* Label and feedback */}
<div className="flex items-start gap-1.5">
{level === "excellent" || level === "strong" ? (
<ShieldCheck
className="h-3.5 w-3.5 mt-0.5 shrink-0"
style={{ color }}
/>
) : level === "weak" ? (
<ShieldAlert
className="h-3.5 w-3.5 mt-0.5 shrink-0"
style={{ color }}
/>
) : (
<ShieldQuestion
className="h-3.5 w-3.5 mt-0.5 shrink-0"
style={{ color }}
/>
)}
<div>
<span className="text-xs font-medium" style={{ color }}>
{levelLabels[level]}
</span>
{feedback.length > 0 &&
feedback[0] !== "Great password!" && (
<span className="text-xs text-[#ebe4f1]/50 ml-1">
{feedback[0]}
</span>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,218 @@
"use client"
import { useState, useEffect } from "react"
import {
Mail,
CheckCircle2,
Loader2,
ArrowLeft,
} from "lucide-react"
import { authClient } from "@/lib/auth-client"
import {
resetPasswordSchema,
} from "@/lib/auth/validation"
import OtpInput from "./otp-input"
import PasswordStrengthMeter from "./password-strength"
import Link from "next/link"
interface ResetPasswordFormProps {
email: string
}
export default function ResetPasswordForm({
email,
}: ResetPasswordFormProps) {
const [otp, setOtp] = useState("")
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [resendTimer, setResendTimer] = useState(300)
const canResend = resendTimer <= 0
// Countdown timer
useEffect(() => {
if (resendTimer <= 0) return
const interval = setInterval(() => {
setResendTimer((prev) => prev - 1)
}, 1000)
return () => clearInterval(interval)
}, [resendTimer])
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, "0")}`
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
const result = resetPasswordSchema.safeParse({
otp,
newPassword,
confirmPassword,
})
if (!result.success) {
setError(result.error.issues[0].message)
return
}
setIsLoading(true)
const { error } = await authClient.emailOtp.resetPassword({
email,
otp,
password: newPassword,
})
setIsLoading(false)
if (error) {
setError(
error.code === "TOO_MANY_ATTEMPTS"
? "Too many attempts. Please request a new code."
: error.message || "Failed to reset password.",
)
return
}
setIsSuccess(true)
}
const handleResend = async () => {
setError("")
await authClient.emailOtp.requestPasswordReset({ email })
setResendTimer(300)
}
if (isSuccess) {
return (
<div className="text-center space-y-5">
<CheckCircle2 className="h-10 w-10 text-green-400 mx-auto" />
<h1 className="text-xl font-bold text-text">
Password reset successful
</h1>
<p className="text-sm text-text/50">
Your password has been updated. You can now sign in with
your new password.
</p>
<Link
href="/login"
className="inline-block w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors text-center cursor-pointer"
>
Sign in
</Link>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="text-center mb-2">
<Mail className="h-10 w-10 text-primary mx-auto mb-3" />
<h1 className="text-xl font-bold text-text">
Check your email
</h1>
<p className="text-sm text-text/50 mt-2">
We sent a 6-digit code to{" "}
<strong className="text-text">{email}</strong>
</p>
</div>
<div>
<label className="text-sm text-text/60 block mb-2 text-center">
Verification code
</label>
<OtpInput
value={otp}
onChange={setOtp}
disabled={isLoading}
/>
</div>
<div className="text-center text-sm">
{canResend ? (
<button
type="button"
onClick={handleResend}
className="text-primary hover:underline"
>
Resend code
</button>
) : (
<span className="text-text/40">
Resend code in{" "}
<span className="text-primary font-semibold">
{formatTime(resendTimer)}
</span>
</span>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text/40">
then set new password
</span>
<div className="flex-1 h-px bg-border" />
</div>
{error && (
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
{error}
</div>
)}
<div>
<label className="text-sm text-text/60 block mb-1.5">
New password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min. 10 characters"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
<PasswordStrengthMeter password={newPassword} />
</div>
<div>
<label className="text-sm text-text/60 block mb-1.5">
Confirm new password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter password"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
{confirmPassword &&
newPassword !== confirmPassword && (
<p className="text-red-400 text-sm mt-1">
Passwords do not match
</p>
)}
</div>
<button
type="submit"
disabled={isLoading || otp.length !== 6}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Reset password
</button>
<Link
href="/login"
className="flex items-center justify-center gap-2 text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
>
<ArrowLeft className="h-4 w-4" />
Back to sign in
</Link>
</form>
)
}
@@ -0,0 +1,168 @@
"use client"
import { useState, useRef } from "react"
import { Loader2 } from "lucide-react"
import { authClient } from "@/lib/auth-client"
import { signupSchema } from "@/lib/auth/validation"
import SocialButtons from "./social-buttons"
import PasswordStrengthMeter from "./password-strength"
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
import Link from "next/link"
interface SignupFormStepProps {
onSuccess: (email: string) => void
}
export default function SignupFormStep({ onSuccess }: SignupFormStepProps) {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [serverError, setServerError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [turnstileToken, setTurnstileToken] = useState("")
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setErrors({})
setServerError("")
const result = signupSchema.safeParse({ name, email, password })
if (!result.success) {
const fieldErrors: Record<string, string> = {}
result.error.issues.forEach((issue) => {
const field = issue.path[0] as string
fieldErrors[field] = issue.message
})
setErrors(fieldErrors)
return
}
setIsLoading(true)
const { error } = await authClient.signUp.email({
name,
email,
password,
fetchOptions: {
headers: {
"x-captcha-response": turnstileToken,
},
},
})
setIsLoading(false)
if (error) {
setServerError(
error.message || "Something went wrong. Please try again.",
)
turnstileRef.current?.reset()
setTurnstileToken("")
return
}
onSuccess(email)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="text-center mb-2">
<h1 className="text-xl font-bold text-text">
Create your account
</h1>
<p className="text-sm text-text/50 mt-1">
Join DeckyVault and start exploring
</p>
</div>
<SocialButtons callbackURL="/signup?step=passkey" />
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text/40 uppercase">
or use email
</span>
<div className="flex-1 h-px bg-border" />
</div>
{serverError && (
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
{serverError}
</div>
)}
<div>
<label className="text-sm text-text/60 block mb-1.5">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
{errors.name && (
<p className="text-red-400 text-sm mt-1">{errors.name}</p>
)}
</div>
<div>
<label className="text-sm text-text/60 block mb-1.5">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@deckyvault.xyz"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
{errors.email && (
<p className="text-red-400 text-sm mt-1">{errors.email}</p>
)}
</div>
<div>
<label className="text-sm text-text/60 block mb-1.5">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 10 characters"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
{errors.password && (
<p className="text-red-400 text-sm mt-1">
{errors.password}
</p>
)}
<PasswordStrengthMeter password={password} />
</div>
<button
type="submit"
disabled={isLoading || !turnstileToken}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Create account
</button>
<TurnstileWidget
ref={turnstileRef}
onToken={setTurnstileToken}
onExpire={() => setTurnstileToken("")}
/>
<p className="text-center text-sm text-text/50">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline cursor-pointer">
Sign in
</Link>
</p>
</form>
)
}
@@ -0,0 +1,74 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import StepIndicator from "./step-indicator"
import SignupFormStep from "./signup-form-step"
import OtpVerificationStep from "./otp-verification-step"
import PasskeySetupStep from "./passkey-setup-step"
export default function SignupWizard() {
const router = useRouter()
const searchParams = useSearchParams()
const stepParam = searchParams.get("step")
const [step, setStep] = useState<1 | 2 | 3>(() => {
if (stepParam === "otp") return 2
if (stepParam === "passkey") return 3
return 1
})
const [email, setEmail] = useState("")
// Update URL when step changes
useEffect(() => {
const params = new URLSearchParams()
if (step === 2) params.set("step", "otp")
if (step === 3) params.set("step", "passkey")
const query = params.toString()
router.replace(`/signup${query ? `?${query}` : ""}`, {
scroll: false,
})
}, [step, router])
const handleSignupSuccess = (userEmail: string) => {
setEmail(userEmail)
setStep(2)
}
const handleOtpSuccess = () => {
setStep(3)
}
const handlePasskeyComplete = () => {
router.push("/")
}
const handlePasskeySkip = () => {
router.push("/")
}
return (
<div className="space-y-5">
<StepIndicator currentStep={step} totalSteps={3} />
{step === 1 && (
<SignupFormStep onSuccess={handleSignupSuccess} />
)}
{step === 2 && (
<OtpVerificationStep
email={email}
onSuccess={handleOtpSuccess}
onBack={() => setStep(1)}
/>
)}
{step === 3 && (
<PasskeySetupStep
onSuccess={handlePasskeyComplete}
onSkip={handlePasskeySkip}
/>
)}
</div>
)
}
@@ -0,0 +1,87 @@
"use client"
import { useState } from "react"
import { Loader2 } from "lucide-react"
import { authClient } from "@/lib/auth-client"
interface SocialButtonsProps {
callbackURL?: string
disabled?: boolean
}
export default function SocialButtons({
callbackURL = "/",
disabled = false,
}: SocialButtonsProps) {
const [loadingProvider, setLoadingProvider] = useState<
"google" | "discord" | null
>(null)
const handleSocialLogin = async (provider: "google" | "discord") => {
setLoadingProvider(provider)
await authClient.signIn.social(
{
provider,
callbackURL,
},
{
onError: () => {
setLoadingProvider(null)
},
},
)
}
const isLoading = loadingProvider !== null
return (
<div className="flex gap-3">
<button
type="button"
onClick={() => handleSocialLogin("google")}
disabled={disabled || isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm font-medium hover:bg-text/10 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{loadingProvider === "google" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<svg className="h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)}
Google
</button>
<button
type="button"
onClick={() => handleSocialLogin("discord")}
disabled={disabled || isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm font-medium hover:bg-text/10 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{loadingProvider === "discord" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="#5865F2">
<path d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.09.09 0 00-.07.03c-.18.33-.39.76-.53 1.09a16.09 16.09 0 00-4.8 0c-.14-.34-.35-.76-.54-1.09-.01-.01-.04-.03-.07-.03-1.5.26-2.93.71-4.27 1.33-.01 0-.02.01-.03.02-2.72 4.07-3.47 8.03-3.1 11.95 0 .01.01.03.02.04 1.69 1.24 3.33 1.99 4.95 2.49.03.01.06 0 .07-.02.38-.52.72-1.07 1.01-1.65.02-.04 0-.08-.04-.09-.55-.2-1.08-.45-1.59-.73-.04-.02-.04-.08 0-.1.11-.08.22-.17.33-.26.02-.02.05-.02.07-.01 3.44 1.57 7.15 1.57 10.55 0 .02-.01.05-.01.07.01.11.09.22.17.33.26.04.02.04.08 0 .1-.51.28-1.04.53-1.59.73-.04.01-.05.06-.04.09.29.58.64 1.13 1.01 1.65.03.01.06.02.09.01 1.62-.5 3.27-1.25 4.96-2.49.01-.01.02-.03.02-.04.44-4.53-.73-8.46-3.1-11.95-.01-.01-.02-.02-.04-.02zM8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12 0 1.17-.84 2.12-1.89 2.12zm6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12 0 1.17-.83 2.12-1.89 2.12z" />
</svg>
)}
Discord
</button>
</div>
)
}
@@ -0,0 +1,34 @@
"use client"
import { motion } from "motion/react"
interface StepIndicatorProps {
currentStep: number
totalSteps: number
}
export default function StepIndicator({
currentStep,
totalSteps,
}: StepIndicatorProps) {
return (
<div className="flex gap-2 justify-center">
{Array.from({ length: totalSteps }).map((_, i) => (
<motion.div
key={i}
className="h-1 w-12 rounded-full"
initial={false}
animate={{
backgroundColor:
i < currentStep - 1
? "#22c55e" // completed - green
: i === currentStep - 1
? "#eb3779" // current - primary
: "rgba(235,228,241,0.1)", // upcoming - border color
}}
transition={{ duration: 0.3 }}
/>
))}
</div>
)
}
@@ -0,0 +1,124 @@
"use client"
import { useEffect, useRef, useImperativeHandle, forwardRef, useId } from "react"
declare global {
interface Window {
turnstile?: {
render: (container: string | HTMLElement, options: TurnstileOptions) => string
reset: (widgetId: string) => void
remove: (widgetId: string) => void
getResponse: (widgetId: string) => string | undefined
}
onloadTurnstileCallback?: () => void
}
}
interface TurnstileOptions {
sitekey: string
theme?: "light" | "dark" | "auto"
callback?: (token: string) => void
"expired-callback"?: () => void
"error-callback"?: () => void
}
export interface TurnstileWidgetHandle {
reset: () => void
getToken: () => string | undefined
}
interface TurnstileWidgetProps {
onToken: (token: string) => void
onExpire?: () => void
onError?: () => void
theme?: "light" | "dark" | "auto"
}
const SCRIPT_ID = "cf-turnstile-script"
const SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback&render=explicit"
export default forwardRef<TurnstileWidgetHandle, TurnstileWidgetProps>(
function TurnstileWidget({ onToken, onExpire, onError, theme = "auto" }, ref) {
const containerRef = useRef<HTMLDivElement>(null)
const widgetIdRef = useRef<string | null>(null)
const scriptLoadedRef = useRef(false)
const id = useId()
// Expose reset and getToken to parent
useImperativeHandle(ref, () => ({
reset: () => {
if (widgetIdRef.current && window.turnstile) {
window.turnstile.reset(widgetIdRef.current)
}
},
getToken: () => {
if (widgetIdRef.current && window.turnstile) {
return window.turnstile.getResponse(widgetIdRef.current)
}
return undefined
},
}))
useEffect(() => {
const container = containerRef.current
if (!container) return
const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY
if (!siteKey) {
console.warn("[Turnstile] NEXT_PUBLIC_TURNSTILE_SITE_KEY is not set")
return
}
function renderWidget() {
if (!window.turnstile || !container || !siteKey) return
// Clear any previous content
container.innerHTML = ""
const widgetId = window.turnstile.render(container, {
sitekey: siteKey,
theme,
callback: (token: string) => onToken(token),
"expired-callback": () => {
widgetIdRef.current = null
onExpire?.()
},
"error-callback": () => {
onError?.()
},
})
widgetIdRef.current = widgetId
scriptLoadedRef.current = true
}
// If script is already loaded, render immediately
if (window.turnstile) {
renderWidget()
return
}
// Set up the onload callback before adding the script
window.onloadTurnstileCallback = renderWidget
// Avoid injecting the script twice
if (!document.getElementById(SCRIPT_ID)) {
const script = document.createElement("script")
script.id = SCRIPT_ID
script.src = SRC
script.async = true
script.defer = true
document.head.appendChild(script)
}
return () => {
// Cleanup widget on unmount
if (widgetIdRef.current && window.turnstile) {
window.turnstile.remove(widgetIdRef.current)
widgetIdRef.current = null
}
}
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return <div ref={containerRef} id={`turnstile-container-${id}`} className="flex justify-center min-h-[65px]" />
},
)