feat: remove OTP gate from transactions, fix categories auth, add implementation plan
- Remove OtpGateGuard from transactions controller (OTP verified at login) - Fix categories controller to use authenticated user instead of TEMP_USER_ID - Add comprehensive implementation plan document - Update .env.example with WEB_APP_URL - Prepare for admin dashboard development
This commit is contained in:
8
apps/web/.env.local.example
Normal file
8
apps/web/.env.local.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# API Base URL
|
||||
VITE_API_URL=http://localhost:3001
|
||||
|
||||
# Google OAuth Client ID (same as backend)
|
||||
VITE_GOOGLE_CLIENT_ID=your-google-client-id
|
||||
|
||||
# Exchange Rate API
|
||||
VITE_EXCHANGE_RATE_URL=https://api.exchangerate-api.com/v4/latest/IDR
|
||||
1075
apps/web/package-lock.json
generated
1075
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,18 +19,19 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"firebase": "^12.3.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "^9.11.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.64.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"recharts": "^2.15.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@@ -1,64 +1,69 @@
|
||||
import { useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { useAuth } from "./hooks/useAuth";
|
||||
import { AuthForm } from "./components/AuthForm";
|
||||
import { Dashboard } from "./components/Dashboard";
|
||||
import { ThemeProvider } from "./components/ThemeProvider";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './components/ThemeProvider'
|
||||
import { Dashboard } from './components/Dashboard'
|
||||
import { Login } from './components/pages/Login'
|
||||
import { Register } from './components/pages/Register'
|
||||
import { OtpVerification } from './components/pages/OtpVerification'
|
||||
import { AuthCallback } from './components/pages/AuthCallback'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
function AppContent() {
|
||||
// ---- Authentication (MUST be at top level) ----
|
||||
const { user, loading: authLoading, getIdToken } = useAuth();
|
||||
|
||||
// ---- Effects ----
|
||||
|
||||
// ---- Setup Axios Interceptor for Auth ----
|
||||
useEffect(() => {
|
||||
const interceptor = axios.interceptors.request.use(async (config) => {
|
||||
if (user && getIdToken) {
|
||||
try {
|
||||
const token = await getIdToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get auth token:', error);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
return () => {
|
||||
axios.interceptors.request.eject(interceptor);
|
||||
};
|
||||
}, [getIdToken, user]);
|
||||
|
||||
|
||||
// Show loading screen while checking auth
|
||||
if (authLoading) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<Loader2 className="h-12 w-12 animate-spin text-blue-600 mx-auto" />
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// Show auth form if not authenticated
|
||||
if (!user) {
|
||||
return <AuthForm />;
|
||||
return <Navigate to="/auth/login" replace />
|
||||
}
|
||||
|
||||
// Show dashboard if authenticated
|
||||
return <Dashboard />;
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="tabungin-ui-theme">
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
<BrowserRouter>
|
||||
<ThemeProvider defaultTheme="light" storageKey="tabungin-ui-theme">
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route path="/auth/login" element={<PublicRoute><Login /></PublicRoute>} />
|
||||
<Route path="/auth/register" element={<PublicRoute><Register /></PublicRoute>} />
|
||||
<Route path="/auth/otp" element={<OtpVerification />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route path="/*" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { Logo } from './Logo';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
export const AuthForm = () => {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const { signIn, signUp, signInWithGoogle, loading, error } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (isLogin) {
|
||||
await signIn(email, password);
|
||||
} else {
|
||||
await signUp(email, password);
|
||||
}
|
||||
} catch {
|
||||
// Error is handled by useAuth hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
try {
|
||||
await signInWithGoogle();
|
||||
} catch {
|
||||
// Error is handled by useAuth hook
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8 relative">
|
||||
{/* Theme Toggle - positioned in top right */}
|
||||
<div className="absolute top-4 right-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<Logo variant="large" className="mx-auto mb-6" />
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-foreground">
|
||||
{isLogin ? 'Sign in to your account' : 'Create your account'}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Welcome to Tabungin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-destructive">
|
||||
Authentication Error
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-destructive/80">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete={isLogin ? "current-password" : "new-password"}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full"
|
||||
>
|
||||
{loading ? 'Loading...' : (isLogin ? 'Sign in' : 'Sign up')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-destructive/80 h-10 px-4 py-2 w-full"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="currentColor" 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"/>
|
||||
<path fill="currentColor" 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"/>
|
||||
<path fill="currentColor" 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"/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-primary hover:text-primary/80 text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
{isLogin ? "Don't have an account? Sign up" : "Already have an account? Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,8 @@ export function Breadcrumb({ currentPage }: BreadcrumbProps) {
|
||||
return 'Wallets'
|
||||
case '/transactions':
|
||||
return 'Transactions'
|
||||
case '/profile':
|
||||
return 'Profile'
|
||||
default:
|
||||
return page.charAt(0).toUpperCase() + page.slice(1)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import { useState } from "react"
|
||||
import { Routes, Route, useLocation, useNavigate } from "react-router-dom"
|
||||
import { DashboardLayout } from "./layout/DashboardLayout"
|
||||
import { Overview } from "./pages/Overview"
|
||||
import { Wallets } from "./pages/Wallets"
|
||||
import { Transactions } from "./pages/Transactions"
|
||||
import { Profile } from "./pages/Profile"
|
||||
|
||||
export function Dashboard() {
|
||||
const [currentPage, setCurrentPage] = useState("/")
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case "/":
|
||||
return <Overview />
|
||||
case "/wallets":
|
||||
return <Wallets />
|
||||
case "/transactions":
|
||||
return <Transactions />
|
||||
default:
|
||||
return <Overview />
|
||||
}
|
||||
}
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<DashboardLayout currentPage={currentPage} onNavigate={setCurrentPage}>
|
||||
{renderPage()}
|
||||
<DashboardLayout currentPage={location.pathname} onNavigate={navigate}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Overview />} />
|
||||
<Route path="/wallets" element={<Wallets />} />
|
||||
<Route path="/transactions" element={<Transactions />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
</Routes>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import logoLight from '../assets/images/logo.png';
|
||||
import logoDark from '../assets/images/logo-dark.png';
|
||||
import logoLargeLight from '../assets/images/logo-large.png';
|
||||
@@ -10,43 +11,29 @@ interface LogoProps {
|
||||
}
|
||||
|
||||
export const Logo = ({ variant = 'header', className = '' }: LogoProps) => {
|
||||
const { actualTheme } = useTheme();
|
||||
|
||||
const getLogoSrc = () => {
|
||||
const isDark = actualTheme === 'dark';
|
||||
|
||||
switch (variant) {
|
||||
case 'large':
|
||||
return {
|
||||
light: logoLargeLight,
|
||||
dark: logoLargeDark,
|
||||
};
|
||||
return isDark ? logoLargeDark : logoLargeLight;
|
||||
case 'icon':
|
||||
return {
|
||||
light: logoIcon,
|
||||
dark: logoIcon,
|
||||
};
|
||||
return logoIcon;
|
||||
default: // header
|
||||
return {
|
||||
light: logoLight,
|
||||
dark: logoDark,
|
||||
};
|
||||
return isDark ? logoDark : logoLight;
|
||||
}
|
||||
};
|
||||
|
||||
const logos = getLogoSrc();
|
||||
const logoSrc = getLogoSrc();
|
||||
const baseClassName = variant === 'icon' ? 'w-8 h-8' : variant === 'large' ? 'h-12' : 'h-8';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Light mode logo */}
|
||||
<img
|
||||
src={logos.light}
|
||||
alt="Tabungin"
|
||||
className={`${baseClassName} ${className} block dark:hidden`}
|
||||
/>
|
||||
{/* Dark mode logo */}
|
||||
<img
|
||||
src={logos.dark}
|
||||
alt="Tabungin"
|
||||
className={`${baseClassName} ${className} hidden dark:block`}
|
||||
/>
|
||||
</>
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="Tabungin"
|
||||
className={`${baseClassName} ${className}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,12 +9,17 @@ type ThemeProviderProps = {
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
defaultTheme = 'light',
|
||||
storageKey = 'tabungin-ui-theme',
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem(storageKey) as Theme
|
||||
// If system theme is stored, convert to light
|
||||
if (stored === 'system') {
|
||||
return 'light'
|
||||
}
|
||||
return stored || defaultTheme
|
||||
})
|
||||
|
||||
const [actualTheme, setActualTheme] = useState<'dark' | 'light'>('light')
|
||||
|
||||
@@ -23,19 +28,10 @@ export function ThemeProvider({
|
||||
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
.matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
setActualTheme(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
setActualTheme(theme)
|
||||
// Only support light and dark, no system
|
||||
const themeToApply = theme === 'system' ? 'light' : theme
|
||||
root.classList.add(themeToApply)
|
||||
setActualTheme(themeToApply)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
|
||||
@@ -1,47 +1,28 @@
|
||||
import { Moon, Sun, Monitor } from "lucide-react"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useTheme } from "../hooks/useTheme"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, actualTheme } = useTheme()
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === "dark" ? "light" : "dark")
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
title="Toggle theme"
|
||||
>
|
||||
{actualTheme === 'dark' ? (
|
||||
<Moon className="h-4 w-4" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="rounded-full"
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }
|
||||
const response = await axios.get(`${API}/categories`)
|
||||
const categories = response.data
|
||||
|
||||
const options: Option[] = categories.map((cat: any) => ({
|
||||
const options: Option[] = categories.map((cat: { name: string }) => ({
|
||||
label: cat.name,
|
||||
value: cat.name
|
||||
}))
|
||||
@@ -147,9 +147,10 @@ export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }
|
||||
for (const category of categories) {
|
||||
try {
|
||||
await axios.post(`${API}/categories`, { name: category })
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
// Ignore if category already exists (409 conflict)
|
||||
if (error.response?.status !== 409) {
|
||||
const err = error as { response?: { status?: number } }
|
||||
if (err.response?.status !== 409) {
|
||||
console.error('Failed to create category:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Home, Wallet, Receipt, LogOut } from "lucide-react"
|
||||
import { Home, Wallet, Receipt, User, LogOut } from "lucide-react"
|
||||
import { Logo } from "../Logo"
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useAuth } from "@/hooks/useAuth"
|
||||
import { useAuth } from "@/contexts/AuthContext"
|
||||
import { getAvatarUrl } from "@/lib/utils"
|
||||
|
||||
// Menu items
|
||||
const items = [
|
||||
{
|
||||
title: "Overview",
|
||||
@@ -31,6 +31,11 @@ const items = [
|
||||
url: "/transactions",
|
||||
icon: Receipt,
|
||||
},
|
||||
{
|
||||
title: "Profile",
|
||||
url: "/profile",
|
||||
icon: User,
|
||||
},
|
||||
]
|
||||
|
||||
interface AppSidebarProps {
|
||||
@@ -39,12 +44,12 @@ interface AppSidebarProps {
|
||||
}
|
||||
|
||||
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
const { user, signOut } = useAuth()
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mx-auto">
|
||||
<Logo variant="large"/>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
@@ -72,13 +77,29 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{user?.email}</span>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{getAvatarUrl(user?.avatarUrl) ? (
|
||||
<img
|
||||
src={getAvatarUrl(user?.avatarUrl)!}
|
||||
alt={user?.name || user?.email || 'User'}
|
||||
className="h-8 w-8 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<User className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col min-w-0">
|
||||
{user?.name && (
|
||||
<span className="text-sm font-medium truncate">{user.name}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground truncate">{user?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
onClick={logout}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 flex-shrink-0"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
|
||||
96
apps/web/src/components/layout/AuthLayout.tsx
Normal file
96
apps/web/src/components/layout/AuthLayout.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "@/hooks/useTheme"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Logo } from "@/components/Logo"
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function AuthLayout({ children, title, description }: AuthLayoutProps) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === "dark" ? "light" : "dark")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left Side - Branding */}
|
||||
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-primary/10 via-primary/5 to-background relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-grid-white/5 [mask-image:radial-gradient(white,transparent_85%)]" />
|
||||
<div className="relative z-10 flex flex-col justify-between p-12 w-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<Logo variant="large"/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 max-w-md">
|
||||
<h1 className="text-4xl font-bold leading-tight">
|
||||
Manage your finances with ease
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Track expenses, manage budgets, and achieve your financial goals with our intuitive platform.
|
||||
</p>
|
||||
<div className="flex gap-8 pt-4">
|
||||
<div>
|
||||
<div className="text-3xl font-bold">10K+</div>
|
||||
<div className="text-sm text-muted-foreground">Active Users</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold">99%</div>
|
||||
<div className="text-sm text-muted-foreground">Satisfaction</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold">24/7</div>
|
||||
<div className="text-sm text-muted-foreground">Support</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
© 2025 Tabungin. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Auth Form */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Header with Theme Toggle */}
|
||||
<div className="flex justify-between items-center p-6">
|
||||
<div className="lg:hidden flex items-center gap-2">
|
||||
<Logo className="h-8" variant="large" />
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="rounded-full"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Content */}
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight">{title}</h2>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
apps/web/src/components/pages/AuthCallback.tsx
Normal file
33
apps/web/src/components/pages/AuthCallback.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export function AuthCallback() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { updateUser } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token')
|
||||
|
||||
if (token) {
|
||||
// Store token and redirect to dashboard
|
||||
localStorage.setItem('token', token)
|
||||
// Force reload to trigger auth context
|
||||
window.location.href = '/'
|
||||
} else {
|
||||
// No token, redirect to login
|
||||
navigate('/auth/login')
|
||||
}
|
||||
}, [searchParams, navigate, updateUser])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">Completing sign in...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
167
apps/web/src/components/pages/Login.tsx
Normal file
167
apps/web/src/components/pages/Login.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { AuthLayout } from '@/components/layout/AuthLayout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Loader2, Mail, Lock, AlertCircle } from 'lucide-react'
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await login(email, password)
|
||||
|
||||
if (result.requiresOtp) {
|
||||
// Redirect to OTP verification page
|
||||
navigate('/auth/otp', {
|
||||
state: {
|
||||
tempToken: result.tempToken,
|
||||
availableMethods: result.availableMethods
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Login successful, redirect to dashboard
|
||||
navigate('/')
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
setError(error.response?.data?.message || 'Login failed. Please check your credentials.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to backend Google OAuth endpoint
|
||||
window.location.href = `${API_URL}/api/auth/google`
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Welcome Back"
|
||||
description="Sign in to your Tabungin account"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Google Sign In */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={loading || !GOOGLE_CLIENT_ID}
|
||||
>
|
||||
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
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>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email/Password Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-10"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full h-11" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/auth/register" className="font-medium text-primary hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
318
apps/web/src/components/pages/OtpVerification.tsx
Normal file
318
apps/web/src/components/pages/OtpVerification.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { AuthLayout } from '@/components/layout/AuthLayout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Loader2, Mail, Smartphone, AlertCircle, RefreshCw, Shield } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||
|
||||
export function OtpVerification() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { verifyOtp } = useAuth()
|
||||
|
||||
// Get params from either location.state (from login) or URL query params (from Google OAuth)
|
||||
const searchParams = new URLSearchParams(location.search)
|
||||
const urlToken = searchParams.get('token')
|
||||
const urlMethods = searchParams.get('methods')
|
||||
|
||||
const tempToken = location.state?.tempToken || urlToken
|
||||
const availableMethods = location.state?.availableMethods ||
|
||||
(urlMethods ? JSON.parse(decodeURIComponent(urlMethods)) : null)
|
||||
|
||||
const [code, setCode] = useState('')
|
||||
const [method, setMethod] = useState<'email' | 'whatsapp' | 'totp'>(
|
||||
availableMethods?.totp ? 'totp' : availableMethods?.whatsapp ? 'whatsapp' : 'email'
|
||||
)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [resendLoading, setResendLoading] = useState(false)
|
||||
const [resendTimer, setResendTimer] = useState(30)
|
||||
const [canResend, setCanResend] = useState(false)
|
||||
|
||||
// Countdown timer for resend button
|
||||
useEffect(() => {
|
||||
if (resendTimer > 0) {
|
||||
const timer = setTimeout(() => setResendTimer(resendTimer - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
setCanResend(true)
|
||||
}
|
||||
}, [resendTimer])
|
||||
|
||||
if (!tempToken) {
|
||||
navigate('/auth/login')
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await verifyOtp(tempToken, code, method)
|
||||
// Verification successful, redirect to dashboard
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
setError(error.response?.data?.message || 'Invalid OTP code. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResendEmail = async () => {
|
||||
setResendLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
// Call backend to resend OTP with temp token
|
||||
await axios.post(`${API_URL}/api/otp/email/resend`, {
|
||||
tempToken
|
||||
})
|
||||
|
||||
// Reset timer
|
||||
setResendTimer(30)
|
||||
setCanResend(false)
|
||||
setError('')
|
||||
} catch {
|
||||
setError('Failed to resend code. Please try again.')
|
||||
} finally {
|
||||
setResendLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResendWhatsApp = async () => {
|
||||
setResendLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
// Call backend to resend WhatsApp OTP with temp token
|
||||
await axios.post(`${API_URL}/api/otp/whatsapp/resend`, {
|
||||
tempToken
|
||||
})
|
||||
|
||||
// Reset timer
|
||||
setResendTimer(30)
|
||||
setCanResend(false)
|
||||
setError('')
|
||||
} catch {
|
||||
setError('Failed to resend code. Please try again.')
|
||||
setResendLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Verify Your Identity"
|
||||
description="Enter the verification code to continue"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center p-4 bg-primary/5 rounded-lg border border-primary/10">
|
||||
<Shield className="h-5 w-5 text-primary mr-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Two-factor authentication is enabled
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={method} onValueChange={(v) => setMethod(v as 'email' | 'whatsapp' | 'totp')}>
|
||||
<TabsList className={`grid w-full ${availableMethods?.whatsapp ? 'grid-cols-3' : 'grid-cols-2'}`}>
|
||||
{availableMethods?.email && (
|
||||
<TabsTrigger value="email" disabled={loading}>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Email
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{availableMethods?.whatsapp && (
|
||||
<TabsTrigger value="whatsapp" disabled={loading}>
|
||||
<Smartphone className="mr-2 h-4 w-4" />
|
||||
WhatsApp
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{availableMethods?.totp && (
|
||||
<TabsTrigger value="totp" disabled={loading}>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
Authenticator
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="email" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A 6-digit code has been sent to your email address. Please check your inbox.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email-code">Email Code</Label>
|
||||
<Input
|
||||
id="email-code"
|
||||
type="text"
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
required
|
||||
disabled={loading}
|
||||
className="text-center text-2xl tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading || code.length !== 6}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Verify Code'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleResendEmail}
|
||||
disabled={!canResend || resendLoading || loading}
|
||||
>
|
||||
{resendLoading ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : canResend ? (
|
||||
<>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Resend Code
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Resend in {resendTimer}s
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="whatsapp" className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
A 6-digit code has been sent to your WhatsApp number. Please check your WhatsApp messages.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="whatsapp-code">WhatsApp Code</Label>
|
||||
<Input
|
||||
id="whatsapp-code"
|
||||
type="text"
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
required
|
||||
disabled={loading}
|
||||
className="text-center text-2xl tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading || code.length !== 6}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Verify Code'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleResendWhatsApp}
|
||||
disabled={!canResend || resendLoading || loading}
|
||||
>
|
||||
{resendLoading ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : canResend ? (
|
||||
<>
|
||||
<Smartphone className="mr-2 h-4 w-4" />
|
||||
Resend Code
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Smartphone className="mr-2 h-4 w-4" />
|
||||
Resend in {resendTimer}s
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="totp" className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Open your authenticator app and enter the 6-digit code.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totp-code">Authenticator Code</Label>
|
||||
<Input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
required
|
||||
disabled={loading}
|
||||
className="text-center text-2xl tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading || code.length !== 6}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Verify Code'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => navigate('/auth/login')}
|
||||
disabled={loading}
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -279,7 +279,7 @@ export function Overview() {
|
||||
kind: wallet.kind
|
||||
}
|
||||
})
|
||||
}, [wallets, transactions, exchangeRates])
|
||||
}, [wallets, transactions, exchangeRates, dateRange, customStartDate, customEndDate])
|
||||
|
||||
// Flexible trend data based on selected period and date range
|
||||
const trendData = useMemo(() => {
|
||||
@@ -526,7 +526,7 @@ export function Overview() {
|
||||
</div>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<div className="space-y-3 flex flex-col md:flex-row md:justify-between gap-3 md:w-full">
|
||||
<div className="space-y-3 flex flex-col md:flex-row md:flex-wrap md:justify-between gap-3 md:w-full">
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<label className="text-xs font-medium text-muted-foreground flex flex-row flex-nowrap items-center gap-1">
|
||||
@@ -583,7 +583,7 @@ export function Overview() {
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Balance</CardTitle>
|
||||
@@ -628,19 +628,6 @@ export function Overview() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Transactions</CardTitle>
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{getFilteredTransactions(transactions, dateRange, customStartDate, customEndDate).length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getDateRangeLabel(dateRange, customStartDate, customEndDate)} transactions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Second Row: Wallet Breakdown (Full Width) */}
|
||||
@@ -674,10 +661,10 @@ export function Overview() {
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="font-medium text-nowrap hover:text-blue-600 hover:underline text-left"
|
||||
className="font-medium text-nowrap hover:text-blue-600 hover:underline text-left cursor-pointer"
|
||||
onClick={() => {
|
||||
// Navigate to transactions page with wallet filter
|
||||
window.location.href = `/transactions?wallet=${wallet.name}`
|
||||
window.location.href = `/transactions?wallet=${encodeURIComponent(wallet.name)}`
|
||||
}}
|
||||
>
|
||||
{wallet.name}
|
||||
@@ -694,10 +681,10 @@ export function Overview() {
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<button
|
||||
className="hover:text-blue-600 hover:underline"
|
||||
className="hover:text-blue-600 hover:underline cursor-pointer"
|
||||
onClick={() => {
|
||||
// Navigate to transactions page with wallet filter
|
||||
window.location.href = `/transactions?wallet=${wallet.name}`
|
||||
window.location.href = `/transactions?wallet=${encodeURIComponent(wallet.name)}`
|
||||
}}
|
||||
>
|
||||
{wallet.transactionCount}
|
||||
|
||||
1184
apps/web/src/components/pages/Profile.tsx
Normal file
1184
apps/web/src/components/pages/Profile.tsx
Normal file
File diff suppressed because it is too large
Load Diff
204
apps/web/src/components/pages/Register.tsx
Normal file
204
apps/web/src/components/pages/Register.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { AuthLayout } from '@/components/layout/AuthLayout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Loader2, Mail, Lock, User, AlertCircle } from 'lucide-react'
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||
|
||||
export function Register() {
|
||||
const navigate = useNavigate()
|
||||
const { register } = useAuth()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await register(email, password, name || undefined)
|
||||
// Registration successful, redirect to dashboard
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
setError(error.response?.data?.message || 'Registration failed. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoogleSignup = () => {
|
||||
// Redirect to backend Google OAuth endpoint
|
||||
window.location.href = `${API_URL}/api/auth/google`
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Create Account"
|
||||
description="Sign up for Tabungin to start managing your finances"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Google Sign Up */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11"
|
||||
onClick={handleGoogleSignup}
|
||||
disabled={loading || !GOOGLE_CLIENT_ID}
|
||||
>
|
||||
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
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>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email/Password Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name (Optional)</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-10"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10"
|
||||
required
|
||||
disabled={loading}
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Must be at least 8 characters</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full h-11" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
'Create Account'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link to="/auth/login" className="font-medium text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -18,7 +19,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Plus, Search, Edit, Trash2, Receipt, TrendingUp, TrendingDown, Filter } from "lucide-react"
|
||||
import { Plus, Search, Edit, Trash2, Receipt, TrendingUp, TrendingDown, Filter, X } from "lucide-react"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import axios from "axios"
|
||||
import { formatCurrency } from "@/constants/currencies"
|
||||
import { formatLargeNumber } from "@/utils/numberFormat"
|
||||
@@ -61,26 +63,31 @@ interface Transaction {
|
||||
const API = "/api"
|
||||
|
||||
export function Transactions() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [wallets, setWallets] = useState<Wallet[]>([])
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Filters
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
// Filters - initialize from URL parameters
|
||||
const [searchTerm, setSearchTerm] = useState<string>(searchParams.get("search") || "")
|
||||
const [walletFilter, setWalletFilter] = useState<string>("all")
|
||||
const [directionFilter, setDirectionFilter] = useState<string>("all")
|
||||
const [amountMin, setAmountMin] = useState("")
|
||||
const [amountMax, setAmountMax] = useState("")
|
||||
const [dateFrom, setDateFrom] = useState<Date | undefined>(undefined)
|
||||
const [dateTo, setDateTo] = useState<Date | undefined>(undefined)
|
||||
const [directionFilter, setDirectionFilter] = useState<string>(searchParams.get("direction") || "all")
|
||||
const [amountMin, setAmountMin] = useState<string>(searchParams.get("amountMin") || "")
|
||||
const [amountMax, setAmountMax] = useState<string>(searchParams.get("amountMax") || "")
|
||||
const [dateFrom, setDateFrom] = useState<Date | undefined>(
|
||||
searchParams.get("dateFrom") ? new Date(searchParams.get("dateFrom")!) : undefined
|
||||
)
|
||||
const [dateTo, setDateTo] = useState<Date | undefined>(
|
||||
searchParams.get("dateTo") ? new Date(searchParams.get("dateTo")!) : undefined
|
||||
)
|
||||
const [transactionDialogOpen, setTransactionDialogOpen] = useState(false)
|
||||
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({})
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
loadExchangeRates()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const loadExchangeRates = async () => {
|
||||
@@ -100,6 +107,19 @@ export function Transactions() {
|
||||
const walletsRes = await axios.get(`${API}/wallets`)
|
||||
const activeWallets = walletsRes.data.filter((w: Wallet) => !w.deletedAt)
|
||||
setWallets(activeWallets)
|
||||
|
||||
// Set wallet filter from URL after wallets are loaded
|
||||
const walletParam = searchParams.get("wallet")
|
||||
if (walletParam) {
|
||||
// Check if it's a wallet ID or name
|
||||
const walletById = activeWallets.find((w: Wallet) => w.id === walletParam)
|
||||
const walletByName = activeWallets.find((w: Wallet) => w.name === walletParam)
|
||||
if (walletById) {
|
||||
setWalletFilter(walletById.id)
|
||||
} else if (walletByName) {
|
||||
setWalletFilter(walletByName.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Load transactions from all wallets
|
||||
const transactionPromises = activeWallets.map((wallet: Wallet) =>
|
||||
@@ -142,6 +162,16 @@ export function Transactions() {
|
||||
setEditingTransaction(null)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm("")
|
||||
setWalletFilter("all")
|
||||
setDirectionFilter("all")
|
||||
setAmountMin("")
|
||||
setAmountMax("")
|
||||
setDateFrom(undefined)
|
||||
setDateTo(undefined)
|
||||
}
|
||||
|
||||
// Filter transactions
|
||||
const filteredTransactions = useMemo(() => {
|
||||
return transactions.filter(transaction => {
|
||||
@@ -150,8 +180,10 @@ export function Transactions() {
|
||||
const matchesSearch = !searchTerm ||
|
||||
(transaction.memo?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false)
|
||||
|
||||
// Wallet filter
|
||||
const matchesWallet = walletFilter === "all" || transaction.walletId === walletFilter
|
||||
// Wallet filter - support both wallet ID and wallet name
|
||||
const matchesWallet = walletFilter === "all" ||
|
||||
transaction.walletId === walletFilter ||
|
||||
wallets.find(w => w.id === transaction.walletId)?.name === walletFilter
|
||||
|
||||
// Direction filter
|
||||
const matchesDirection = directionFilter === "all" || transaction.direction === directionFilter
|
||||
@@ -167,7 +199,7 @@ export function Transactions() {
|
||||
|
||||
return matchesSearch && matchesWallet && matchesDirection && matchesAmount && matchesDate
|
||||
})
|
||||
}, [transactions, searchTerm, walletFilter, directionFilter, amountMin, amountMax, dateFrom, dateTo])
|
||||
}, [transactions, wallets, searchTerm, walletFilter, directionFilter, amountMin, amountMax, dateFrom, dateTo])
|
||||
|
||||
// Calculate stats for filtered transactions
|
||||
const stats = useMemo(() => {
|
||||
@@ -249,21 +281,7 @@ export function Transactions() {
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Transactions</CardTitle>
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalTransactions}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{transactions.length > filteredTransactions.length &&
|
||||
`Filtered from ${transactions.length} total`
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -304,30 +322,42 @@ export function Transactions() {
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Filters</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<CardContent className="space-y-4">
|
||||
{/* Row 1: Search, Wallet, Direction */}
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Search Memo</label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Search Memo</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search in memo..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wallet Filter */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Wallet</label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Wallet</Label>
|
||||
<Select value={walletFilter} onValueChange={setWalletFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All wallets" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -342,10 +372,10 @@ export function Transactions() {
|
||||
</div>
|
||||
|
||||
{/* Direction Filter */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Direction</label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Direction</Label>
|
||||
<Select value={directionFilter} onValueChange={setDirectionFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All directions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -355,72 +385,106 @@ export function Transactions() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount Range */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Min Amount</label>
|
||||
{/* Row 2: Amount Range */}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Min Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={amountMin}
|
||||
onChange={(e) => setAmountMin(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Max Amount</label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Max Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="No limit"
|
||||
value={amountMax}
|
||||
onChange={(e) => setAmountMax(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">From Date</label>
|
||||
{/* Row 3: Date Range */}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">From Date</Label>
|
||||
<DatePicker
|
||||
date={dateFrom}
|
||||
onDateChange={setDateFrom}
|
||||
placeholder="Select start date"
|
||||
className="w-full"
|
||||
className="w-full h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">To Date</label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">To Date</Label>
|
||||
<DatePicker
|
||||
date={dateTo}
|
||||
onDateChange={setDateTo}
|
||||
placeholder="Select end date"
|
||||
className="w-full"
|
||||
className="w-full h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearchTerm("")
|
||||
setWalletFilter("all")
|
||||
setDirectionFilter("all")
|
||||
setAmountMin("")
|
||||
setAmountMax("")
|
||||
setDateFrom(undefined)
|
||||
setDateTo(undefined)
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Active Filters Badge */}
|
||||
{(searchTerm || walletFilter !== "all" || directionFilter !== "all" || amountMin || amountMax || dateFrom || dateTo) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{searchTerm && (
|
||||
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
|
||||
<Search className="h-3 w-3" />
|
||||
{searchTerm}
|
||||
<button onClick={() => setSearchTerm("")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{walletFilter !== "all" && (
|
||||
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
|
||||
Wallet: {wallets.find(w => w.id === walletFilter)?.name}
|
||||
<button onClick={() => setWalletFilter("all")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{directionFilter !== "all" && (
|
||||
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
|
||||
{directionFilter === "in" ? "Income" : "Expense"}
|
||||
<button onClick={() => setDirectionFilter("all")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(amountMin || amountMax) && (
|
||||
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
|
||||
Amount: {amountMin || "0"} - {amountMax || "∞"}
|
||||
<button onClick={() => { setAmountMin(""); setAmountMax(""); }} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(dateFrom || dateTo) && (
|
||||
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
|
||||
Date: {dateFrom?.toLocaleDateString()} - {dateTo?.toLocaleDateString()}
|
||||
<button onClick={() => { setDateFrom(undefined); setDateTo(undefined); }} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transactions Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Plus, Search, Edit, Trash2, Wallet } from "lucide-react"
|
||||
import { Plus, Search, Edit, Trash2, Wallet, Filter, X } from "lucide-react"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import axios from "axios"
|
||||
import { WalletDialog } from "@/components/dialogs/WalletDialog"
|
||||
import {
|
||||
@@ -50,7 +51,9 @@ export function Wallets() {
|
||||
const [wallets, setWallets] = useState<Wallet[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [kindFilter, setKindFilter] = useState<string>("all")
|
||||
const [currencyFilter, setCurrencyFilter] = useState<string>("all")
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [walletDialogOpen, setWalletDialogOpen] = useState(false)
|
||||
const [editingWallet, setEditingWallet] = useState<Wallet | null>(null)
|
||||
|
||||
@@ -90,14 +93,21 @@ export function Wallets() {
|
||||
setEditingWallet(null)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm("")
|
||||
setKindFilter("all")
|
||||
setCurrencyFilter("all")
|
||||
}
|
||||
|
||||
// Filter wallets
|
||||
const filteredWallets = useMemo(() => {
|
||||
return wallets.filter(wallet => {
|
||||
const matchesSearch = wallet.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesCurrency = currencyFilter === "all" || wallet.currency === currencyFilter
|
||||
return matchesSearch && matchesCurrency
|
||||
const matchesKind = kindFilter === "all" || wallet.kind === kindFilter
|
||||
const matchesCurrency = currencyFilter === "all" || wallet.currency === currencyFilter || wallet.unit === currencyFilter
|
||||
return matchesSearch && matchesKind && matchesCurrency
|
||||
})
|
||||
}, [wallets, searchTerm, currencyFilter])
|
||||
}, [wallets, searchTerm, kindFilter, currencyFilter])
|
||||
|
||||
// Get unique currencies for filter
|
||||
const availableCurrencies = useMemo(() => {
|
||||
@@ -147,6 +157,10 @@ export function Wallets() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:flex-shrink-0">
|
||||
<Button variant="outline" onClick={() => setShowFilters(!showFilters)}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||
</Button>
|
||||
<Button onClick={() => setWalletDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Wallet
|
||||
@@ -196,46 +210,113 @@ export function Wallets() {
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
{showFilters && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Filters</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<CardContent className="space-y-4">
|
||||
{/* Row 1: Search, Type, Currency */}
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{/* Search */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Search Wallet</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search wallets..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={currencyFilter} onValueChange={setCurrencyFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filter by currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Currencies</SelectItem>
|
||||
{availableCurrencies.map(currency => (
|
||||
<SelectItem key={currency} value={currency}>
|
||||
{currency}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Type</Label>
|
||||
<Select value={kindFilter} onValueChange={setKindFilter}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="money">Money</SelectItem>
|
||||
<SelectItem value="asset">Asset</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Currency Filter */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Currency/Unit</Label>
|
||||
<Select value={currencyFilter} onValueChange={setCurrencyFilter}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All currencies" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Currencies/Units</SelectItem>
|
||||
{availableCurrencies.map(currency => (
|
||||
<SelectItem key={currency} value={currency}>
|
||||
{currency}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Active Filters Badge */}
|
||||
{(searchTerm || kindFilter !== "all" || currencyFilter !== "all") && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{searchTerm && (
|
||||
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
|
||||
<Search className="h-3 w-3" />
|
||||
{searchTerm}
|
||||
<button onClick={() => setSearchTerm("")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{kindFilter !== "all" && (
|
||||
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
|
||||
Type: {kindFilter === "money" ? "Money" : "Asset"}
|
||||
<button onClick={() => setKindFilter("all")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{currencyFilter !== "all" && (
|
||||
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
|
||||
Currency: {currencyFilter}
|
||||
<button onClick={() => setCurrencyFilter("all")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wallets Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Wallets ({filteredWallets.length})</CardTitle>
|
||||
<CardDescription>
|
||||
{searchTerm || currencyFilter !== "all"
|
||||
{filteredWallets.length !== wallets.length
|
||||
? `Filtered from ${wallets.length} total wallets`
|
||||
: "All your wallets"
|
||||
}
|
||||
@@ -246,8 +327,8 @@ export function Wallets() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Currency/Unit</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -256,7 +337,7 @@ export function Wallets() {
|
||||
{filteredWallets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8">
|
||||
{searchTerm || currencyFilter !== "all"
|
||||
{filteredWallets.length !== wallets.length
|
||||
? "No wallets match your filters"
|
||||
: "No wallets found. Create your first wallet!"
|
||||
}
|
||||
@@ -266,6 +347,13 @@ export function Wallets() {
|
||||
filteredWallets.map((wallet) => (
|
||||
<TableRow key={wallet.id}>
|
||||
<TableCell className="font-medium text-nowrap">{wallet.name}</TableCell>
|
||||
<TableCell>
|
||||
{wallet.kind === 'money' ? (
|
||||
<Badge variant="outline" className="text-nowrap">{wallet.currency}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-nowrap">{wallet.unit}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -274,13 +362,6 @@ export function Wallets() {
|
||||
{wallet.kind}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{wallet.kind === 'money' ? (
|
||||
<Badge variant="outline" className="text-nowrap">{wallet.currency}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-nowrap">{wallet.unit}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(wallet.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
|
||||
58
apps/web/src/components/ui/alert.tsx
Normal file
58
apps/web/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -145,7 +145,7 @@ const SidebarProvider = React.forwardRef<
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar bg-background",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -185,7 +185,7 @@ const Sidebar = React.forwardRef<
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar bg-background text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -202,7 +202,7 @@ const Sidebar = React.forwardRef<
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
className="w-[--sidebar-width] bg-sidebar bg-background p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
@@ -256,7 +256,7 @@ const Sidebar = React.forwardRef<
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
className="flex h-full w-full flex-col bg-sidebar bg-background group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -308,10 +308,10 @@ const SidebarRail = React.forwardRef<
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar bg-background-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar bg-background",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
@@ -396,7 +396,7 @@ const SidebarSeparator = React.forwardRef<
|
||||
<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
className={cn("mx-2 w-auto bg-sidebar bg-background-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -468,7 +468,7 @@ const SidebarGroupAction = React.forwardRef<
|
||||
ref={ref}
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar bg-background-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
@@ -520,13 +520,13 @@ const SidebarMenuItem = React.forwardRef<
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar bg-background-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar bg-background-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar bg-background-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar bg-background-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
default: "hover:bg-sidebar bg-background-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar bg-background-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
@@ -614,7 +614,7 @@ const SidebarMenuAction = React.forwardRef<
|
||||
ref={ref}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar bg-background-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
@@ -730,8 +730,8 @@ const SidebarMenuSubButton = React.forwardRef<
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar bg-background-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar bg-background-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar bg-background-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
|
||||
52
apps/web/src/components/ui/tabs.tsx
Normal file
52
apps/web/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
158
apps/web/src/contexts/AuthContext.tsx
Normal file
158
apps/web/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
avatarUrl: string | null
|
||||
emailVerified: boolean
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
requiresOtp?: boolean
|
||||
availableMethods?: {
|
||||
email: boolean
|
||||
totp: boolean
|
||||
}
|
||||
tempToken?: string
|
||||
token?: string
|
||||
user?: User
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null
|
||||
token: string | null
|
||||
loading: boolean
|
||||
login: (email: string, password: string) => Promise<LoginResponse>
|
||||
register: (email: string, password: string, name?: string) => Promise<{ token: string; user: User }>
|
||||
logout: () => void
|
||||
verifyOtp: (tempToken: string, code: string, method: 'email' | 'whatsapp' | 'totp') => Promise<{ token: string; user: User }>
|
||||
updateUser: (updates: Partial<User>) => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Load token from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedToken = localStorage.getItem('token')
|
||||
if (storedToken) {
|
||||
setToken(storedToken)
|
||||
fetchUser(storedToken)
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Set axios default auth header when token changes
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||
} else {
|
||||
delete axios.defaults.headers.common['Authorization']
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const fetchUser = async (authToken: string) => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${authToken}` }
|
||||
})
|
||||
setUser(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user:', error)
|
||||
// Token might be invalid, clear it
|
||||
localStorage.removeItem('token')
|
||||
setToken(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const response = await axios.post(`${API_URL}/api/auth/login`, {
|
||||
email,
|
||||
password
|
||||
})
|
||||
|
||||
if (response.data.requiresOtp) {
|
||||
// Return OTP requirement info
|
||||
return response.data
|
||||
}
|
||||
|
||||
// No OTP required, set token and user
|
||||
const { token: authToken, user: userData } = response.data
|
||||
setToken(authToken)
|
||||
setUser(userData)
|
||||
localStorage.setItem('token', authToken)
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
const register = async (email: string, password: string, name?: string) => {
|
||||
const response = await axios.post(`${API_URL}/api/auth/register`, {
|
||||
email,
|
||||
password,
|
||||
name
|
||||
})
|
||||
|
||||
const { token: authToken, user: userData } = response.data
|
||||
setToken(authToken)
|
||||
setUser(userData)
|
||||
localStorage.setItem('token', authToken)
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
const verifyOtp = async (tempToken: string, code: string, method: 'email' | 'whatsapp' | 'totp') => {
|
||||
const response = await axios.post(`${API_URL}/api/auth/verify-otp`, {
|
||||
tempToken,
|
||||
otpCode: code,
|
||||
method
|
||||
})
|
||||
|
||||
const { token: authToken, user: userData } = response.data
|
||||
setToken(authToken)
|
||||
setUser(userData)
|
||||
localStorage.setItem('token', authToken)
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
localStorage.removeItem('token')
|
||||
delete axios.defaults.headers.common['Authorization']
|
||||
}
|
||||
|
||||
const updateUser = (updates: Partial<User>) => {
|
||||
if (user) {
|
||||
setUser({ ...user, ...updates })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, loading, login, register, logout, verifyOtp, updateUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Export useAuth hook separately to avoid fast-refresh issues
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
type User,
|
||||
onAuthStateChanged,
|
||||
signInWithEmailAndPassword,
|
||||
createUserWithEmailAndPassword,
|
||||
signInWithPopup,
|
||||
signOut as firebaseSignOut
|
||||
} from 'firebase/auth';
|
||||
import { auth, googleProvider } from '../lib/firebase';
|
||||
|
||||
export const useAuth = () => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
||||
setUser(user);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
await signInWithEmailAndPassword(auth, email, password);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'An error occurred';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signUp = async (email: string, password: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
await createUserWithEmailAndPassword(auth, email, password);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'An error occurred';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const signInWithGoogle = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const result = await signInWithPopup(auth, googleProvider);
|
||||
console.log('✅ Google sign-in successful:', result.user.email);
|
||||
} catch (error: unknown) {
|
||||
// Handle user cancellation gracefully
|
||||
const errorWithCode = error as { code?: string; message?: string };
|
||||
|
||||
if (errorWithCode.code === 'auth/popup-closed-by-user' ||
|
||||
errorWithCode.code === 'auth/cancelled-popup-request') {
|
||||
// User cancelled - don't show error, just reset loading
|
||||
console.log('ℹ️ User cancelled Google sign-in');
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle common Firebase auth errors
|
||||
let userFriendlyMessage = 'Failed to sign in with Google';
|
||||
|
||||
switch (errorWithCode.code) {
|
||||
case 'auth/popup-blocked':
|
||||
userFriendlyMessage = 'Popup was blocked. Please allow popups for this site and try again.';
|
||||
break;
|
||||
case 'auth/network-request-failed':
|
||||
userFriendlyMessage = 'Network error. Please check your internet connection.';
|
||||
break;
|
||||
case 'auth/too-many-requests':
|
||||
userFriendlyMessage = 'Too many failed attempts. Please try again later.';
|
||||
break;
|
||||
case 'auth/configuration-not-found':
|
||||
userFriendlyMessage = 'Google sign-in is not properly configured. Please contact support.';
|
||||
break;
|
||||
default:
|
||||
userFriendlyMessage = errorWithCode.message || 'An error occurred during Google sign-in';
|
||||
}
|
||||
|
||||
console.error('❌ Google sign-in error:', errorWithCode);
|
||||
setError(userFriendlyMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
await firebaseSignOut(auth);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'An error occurred';
|
||||
setError(message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getIdToken = async () => {
|
||||
if (user) {
|
||||
return await user.getIdToken();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
signIn,
|
||||
signUp,
|
||||
signInWithGoogle,
|
||||
signOut,
|
||||
getIdToken,
|
||||
};
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
|
||||
|
||||
// Validate required environment variables
|
||||
const requiredEnvVars = [
|
||||
'VITE_FIREBASE_API_KEY',
|
||||
'VITE_FIREBASE_AUTH_DOMAIN',
|
||||
'VITE_FIREBASE_PROJECT_ID',
|
||||
'VITE_FIREBASE_STORAGE_BUCKET',
|
||||
'VITE_FIREBASE_MESSAGING_SENDER_ID',
|
||||
'VITE_FIREBASE_APP_ID'
|
||||
];
|
||||
|
||||
const missingVars = requiredEnvVars.filter(varName => !import.meta.env[varName]);
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
console.error('❌ Missing Firebase environment variables:', missingVars);
|
||||
console.error('Please check your .env.local file and ensure all Firebase config variables are set.');
|
||||
console.error('See .env.example for the required variables.');
|
||||
}
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
||||
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: import.meta.env.VITE_FIREBASE_APP_ID,
|
||||
};
|
||||
|
||||
// Initialize Firebase
|
||||
const app = initializeApp(firebaseConfig);
|
||||
|
||||
// Initialize Firebase Authentication and get a reference to the service
|
||||
export const auth = getAuth(app);
|
||||
|
||||
// Initialize Google Auth Provider with additional configuration
|
||||
export const googleProvider = new GoogleAuthProvider();
|
||||
|
||||
// Configure Google provider for better UX
|
||||
googleProvider.setCustomParameters({
|
||||
prompt: 'select_account', // Always show account selection
|
||||
});
|
||||
|
||||
// Add additional scopes if needed
|
||||
googleProvider.addScope('email');
|
||||
googleProvider.addScope('profile');
|
||||
|
||||
export default app;
|
||||
@@ -4,3 +4,21 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||
|
||||
export function getAvatarUrl(avatarUrl: string | null | undefined): string | null {
|
||||
if (!avatarUrl) return null
|
||||
|
||||
// If it's already a full URL (starts with http), return as is
|
||||
if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) {
|
||||
return avatarUrl
|
||||
}
|
||||
|
||||
// If it's a relative path (starts with /), prepend API URL
|
||||
if (avatarUrl.startsWith('/')) {
|
||||
return `${API_URL}${avatarUrl}`
|
||||
}
|
||||
|
||||
return avatarUrl
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user