From d09496ed6a4a20ead0995e9d40b938605da997bd Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 26 Apr 2026 01:17:08 +0800 Subject: [PATCH] feat: add auth UI foundation components and layout --- app/(auth)/layout.tsx | 37 ++++++++ components/auth/otp-input.tsx | 130 ++++++++++++++++++++++++++ components/auth/password-strength.tsx | 97 +++++++++++++++++++ components/auth/social-buttons.tsx | 87 +++++++++++++++++ components/auth/step-indicator.tsx | 34 +++++++ 5 files changed, 385 insertions(+) create mode 100644 app/(auth)/layout.tsx create mode 100644 components/auth/otp-input.tsx create mode 100644 components/auth/password-strength.tsx create mode 100644 components/auth/social-buttons.tsx create mode 100644 components/auth/step-indicator.tsx diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..3157866 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next" +import { Lock } from "lucide-react" +import Link from "next/link" + +export const metadata: Metadata = { + title: "Authentication", +} + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ {/* Background decorative orbs */} +
+
+ + {/* Auth card */} +
+ {/* Logo */} + + + + DeckyVault + + + + {children} +
+
+ ) +} diff --git a/components/auth/otp-input.tsx b/components/auth/otp-input.tsx new file mode 100644 index 0000000..6919e58 --- /dev/null +++ b/components/auth/otp-input.tsx @@ -0,0 +1,130 @@ +"use client" + +import { useRef, useState, useCallback, useEffect } 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, setDigits] = useState( + value.split("").concat(Array(length).fill("")).slice(0, length), + ) + const refs = useRef<(HTMLInputElement | null)[]>([]) + + // Sync with external value + useEffect(() => { + const newDigits = value + .split("") + .concat(Array(length).fill("")) + .slice(0, length) + setDigits(newDigits) + }, [value, length]) + + const updateDigits = useCallback( + (newDigits: string[]) => { + setDigits(newDigits) + 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) => { + 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) => { + 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 ( +
+
+ {Array.from({ length }).map((_, i) => ( + { + 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-11 h-[52px] text-center text-xl font-bold rounded-lg border bg-white/[0.03] text-[#ebe4f1] outline-none transition-colors ${ + error + ? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/50" + : "border-white/10 focus:border-[#eb3779] focus:ring-2 focus:ring-[#eb3779]/50" + } ${disabled ? "opacity-50 cursor-not-allowed" : ""}`} + /> + ))} +
+ {error && ( +

+ {error} +

+ )} +
+ ) +} diff --git a/components/auth/password-strength.tsx b/components/auth/password-strength.tsx new file mode 100644 index 0000000..c86576d --- /dev/null +++ b/components/auth/password-strength.tsx @@ -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 = { + weak: "#ef4444", + fair: "#f59e0b", + good: "#eab308", + strong: "#22c55e", + excellent: "#10b981", +} + +const levelLabels: Record = { + weak: "Weak", + fair: "Fair", + good: "Good", + strong: "Strong", + excellent: "Excellent", +} + +const levelBars: Record = { + 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 ( +
+ {/* Bar indicator */} +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ + {/* Label and feedback */} +
+ {level === "excellent" || level === "strong" ? ( + + ) : level === "weak" ? ( + + ) : ( + + )} +
+ + {levelLabels[level]} + + {feedback.length > 0 && + feedback[0] !== "Great password!" && ( + + — {feedback[0]} + + )} +
+
+
+ ) +} diff --git a/components/auth/social-buttons.tsx b/components/auth/social-buttons.tsx new file mode 100644 index 0000000..77facb2 --- /dev/null +++ b/components/auth/social-buttons.tsx @@ -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 ( +
+ + + +
+ ) +} diff --git a/components/auth/step-indicator.tsx b/components/auth/step-indicator.tsx new file mode 100644 index 0000000..58d729e --- /dev/null +++ b/components/auth/step-indicator.tsx @@ -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 ( +
+ {Array.from({ length: totalSteps }).map((_, i) => ( + + ))} +
+ ) +}