Skip to content

Commit a6d8ad1

Browse files
committed
Add password encryption to SQLite database and web app
Implements password-based authentication for the EVM portfolio app with graceful migration support for existing data. Backend changes: - Add database migration (004) to create auth table for password storage - Implement password hashing using Bun's built-in bcrypt-compatible API - Add session management system with in-memory session storage - Create auth endpoints: /auth/setup, /auth/login, /auth/logout, /auth/status - Add authentication middleware to protect all routes (except auth routes) - Password is optional during migration; middleware allows access until password is set Frontend changes: - Add Login screen for password authentication - Add Setup screen for first-time password creation - Create ProtectedRoute component to guard authenticated pages - Add useAuth hook for authentication state management - Add logout button to sidebar navigation - Implement automatic redirection based on auth state The implementation gracefully handles existing installations by: 1. Creating auth table with nullable passwordHash field 2. Allowing unauthenticated access when no password is set 3. Redirecting to setup page on first visit 4. Requiring authentication once password is configured No username required as the app assumes single user per machine.
1 parent 2d8833f commit a6d8ad1

13 files changed

Lines changed: 619 additions & 14 deletions

File tree

client/src/App.tsx

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,35 @@ import { BalanceCard } from './components/BalanceCard'
55
import { ChainCard } from './components/ChainCard'
66
import { Layout } from './components/Layout'
77
import { PortfolioCard } from './components/PortfolioCard'
8+
import { ProtectedRoute } from './components/ProtectedRoute'
89
import { TokenCard } from './components/TokenCard'
910
import { Home } from './screens/Home'
11+
import { Login } from './screens/Login'
12+
import { Setup } from './screens/Setup'
1013

1114
function App() {
1215
return (
13-
<Layout>
14-
<Routes>
15-
<Route path="/" element={<Home />} />
16-
<Route path="/portfolio" element={<PortfolioCard />} />
17-
<Route path="/chains" element={<ChainCard />} />
18-
<Route path="/accounts" element={<AccountCard />} />
19-
<Route path="/tokens" element={<TokenCard />} />
20-
<Route path="/balances" element={<BalanceCard />} />
21-
</Routes>
22-
</Layout>
16+
<Routes>
17+
<Route path="/login" element={<Login />} />
18+
<Route path="/setup" element={<Setup />} />
19+
<Route
20+
path="/*"
21+
element={
22+
<ProtectedRoute>
23+
<Layout>
24+
<Routes>
25+
<Route path="/" element={<Home />} />
26+
<Route path="/portfolio" element={<PortfolioCard />} />
27+
<Route path="/chains" element={<ChainCard />} />
28+
<Route path="/accounts" element={<AccountCard />} />
29+
<Route path="/tokens" element={<TokenCard />} />
30+
<Route path="/balances" element={<BalanceCard />} />
31+
</Routes>
32+
</Layout>
33+
</ProtectedRoute>
34+
}
35+
/>
36+
</Routes>
2337
)
2438
}
2539

client/src/components/Layout.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import { ArrowUpRight } from 'lucide-react'
2-
import { Link, useLocation } from 'react-router-dom'
1+
import { ArrowUpRight, LogOut } from 'lucide-react'
2+
import { Link, useLocation, useNavigate } from 'react-router-dom'
3+
import { toast } from 'sonner'
34

5+
import { useLogout } from '@/hooks/useAuth'
46
import { useAccounts } from '@/hooks/useHono'
57
import { SERVER_URL } from '@/hooks/useHono'
68
import { useQueues } from '@/hooks/useQueues'
79
import { cn } from '@/lib/utils'
810

911
import { CurrencySelector } from './CurrencySelector'
12+
import { Button } from './ui/button'
1013

1114
const links = [
1215
{
@@ -37,8 +40,20 @@ const links = [
3740

3841
export function Layout({ children }: { children: React.ReactNode }) {
3942
const { pathname } = useLocation()
43+
const navigate = useNavigate()
4044
const queues = useQueues()
4145
const { data: offchainAccounts } = useAccounts('offchain')
46+
const logout = useLogout()
47+
48+
const handleLogout = async () => {
49+
try {
50+
await logout.mutateAsync()
51+
toast.success('Logged out successfully')
52+
navigate('/login')
53+
} catch (error) {
54+
toast.error(error instanceof Error ? error.message : 'Failed to logout')
55+
}
56+
}
4257

4358
return (
4459
<div className="grid grid-cols-[10rem_1fr] lg:grid-cols-[15rem_1fr]">
@@ -85,6 +100,17 @@ export function Layout({ children }: { children: React.ReactNode }) {
85100
<ArrowUpRight className="h-4 w-4" />
86101
</a>
87102
)}
103+
104+
<Button
105+
variant="outline"
106+
size="sm"
107+
onClick={handleLogout}
108+
isLoading={logout.isPending}
109+
className="w-full"
110+
>
111+
<LogOut />
112+
Logout
113+
</Button>
88114
</div>
89115
</aside>
90116

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Navigate } from 'react-router-dom'
2+
3+
import { useAuthStatus } from '@/hooks/useAuth'
4+
5+
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
6+
const { data: authStatus, isLoading } = useAuthStatus()
7+
8+
if (isLoading) {
9+
return (
10+
<div className="flex min-h-screen items-center justify-center">
11+
<div className="text-muted-foreground">Loading...</div>
12+
</div>
13+
)
14+
}
15+
16+
// If password not set, redirect to setup
17+
if (!authStatus?.passwordSet) {
18+
return <Navigate to="/setup" replace />
19+
}
20+
21+
// If password is set but not authenticated, redirect to login
22+
if (!authStatus?.authenticated) {
23+
return <Navigate to="/login" replace />
24+
}
25+
26+
return <>{children}</>
27+
}

client/src/hooks/useAuth.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2+
3+
import { honoClient } from './useHono'
4+
5+
export function useAuthStatus() {
6+
return useQuery({
7+
queryKey: ['auth', 'status'],
8+
queryFn: async () => {
9+
const res = await honoClient.auth.status.$get()
10+
return res.json()
11+
},
12+
retry: false,
13+
})
14+
}
15+
16+
export function useSetupPassword() {
17+
const queryClient = useQueryClient()
18+
19+
return useMutation({
20+
mutationFn: async (password: string) => {
21+
const res = await honoClient.auth.setup.$post({
22+
json: { password },
23+
})
24+
25+
if (!res.ok) {
26+
const error = await res.json()
27+
throw new Error(error.error || 'Failed to set up password')
28+
}
29+
30+
return res.json()
31+
},
32+
onSuccess: () => {
33+
queryClient.invalidateQueries({ queryKey: ['auth', 'status'] })
34+
},
35+
})
36+
}
37+
38+
export function useLogin() {
39+
const queryClient = useQueryClient()
40+
41+
return useMutation({
42+
mutationFn: async (password: string) => {
43+
const res = await honoClient.auth.login.$post({
44+
json: { password },
45+
})
46+
47+
if (!res.ok) {
48+
const error = await res.json()
49+
throw new Error(error.error || 'Failed to login')
50+
}
51+
52+
return res.json()
53+
},
54+
onSuccess: () => {
55+
queryClient.invalidateQueries({ queryKey: ['auth', 'status'] })
56+
},
57+
})
58+
}
59+
60+
export function useLogout() {
61+
const queryClient = useQueryClient()
62+
63+
return useMutation({
64+
mutationFn: async () => {
65+
const res = await honoClient.auth.logout.$post()
66+
67+
if (!res.ok) {
68+
const error = await res.json()
69+
throw new Error(error.error || 'Failed to logout')
70+
}
71+
72+
return res.json()
73+
},
74+
onSuccess: () => {
75+
queryClient.invalidateQueries({ queryKey: ['auth', 'status'] })
76+
},
77+
})
78+
}

client/src/screens/Login.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useState } from 'react'
2+
import { useNavigate } from 'react-router-dom'
3+
import { toast } from 'sonner'
4+
5+
import { Button } from '@/components/ui/button'
6+
import {
7+
Card,
8+
CardContent,
9+
CardDescription,
10+
CardHeader,
11+
CardTitle,
12+
} from '@/components/ui/card'
13+
import { Input } from '@/components/ui/input'
14+
import { useLogin } from '@/hooks/useAuth'
15+
16+
export function Login() {
17+
const [password, setPassword] = useState('')
18+
const login = useLogin()
19+
const navigate = useNavigate()
20+
21+
const handleSubmit = async (e: React.FormEvent) => {
22+
e.preventDefault()
23+
24+
try {
25+
await login.mutateAsync(password)
26+
toast.success('Logged in successfully')
27+
navigate('/')
28+
} catch (error) {
29+
toast.error(error instanceof Error ? error.message : 'Failed to login')
30+
}
31+
}
32+
33+
return (
34+
<div className="flex min-h-screen items-center justify-center p-4">
35+
<Card className="w-full max-w-md">
36+
<CardHeader>
37+
<CardTitle>Login</CardTitle>
38+
<CardDescription>
39+
Enter your password to access your portfolio
40+
</CardDescription>
41+
</CardHeader>
42+
<CardContent>
43+
<form onSubmit={handleSubmit} className="space-y-4">
44+
<div className="space-y-2">
45+
<Input
46+
type="password"
47+
placeholder="Password"
48+
value={password}
49+
onChange={(e) => setPassword(e.target.value)}
50+
autoFocus
51+
required
52+
/>
53+
</div>
54+
<Button type="submit" className="w-full" isLoading={login.isPending}>
55+
Login
56+
</Button>
57+
</form>
58+
</CardContent>
59+
</Card>
60+
</div>
61+
)
62+
}

client/src/screens/Setup.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { useState } from 'react'
2+
import { useNavigate } from 'react-router-dom'
3+
import { toast } from 'sonner'
4+
5+
import { Button } from '@/components/ui/button'
6+
import {
7+
Card,
8+
CardContent,
9+
CardDescription,
10+
CardHeader,
11+
CardTitle,
12+
} from '@/components/ui/card'
13+
import { Input } from '@/components/ui/input'
14+
import { useSetupPassword } from '@/hooks/useAuth'
15+
16+
export function Setup() {
17+
const [password, setPassword] = useState('')
18+
const [confirmPassword, setConfirmPassword] = useState('')
19+
const setupPassword = useSetupPassword()
20+
const navigate = useNavigate()
21+
22+
const handleSubmit = async (e: React.FormEvent) => {
23+
e.preventDefault()
24+
25+
if (password.length < 8) {
26+
toast.error('Password must be at least 8 characters long')
27+
return
28+
}
29+
30+
if (password !== confirmPassword) {
31+
toast.error('Passwords do not match')
32+
return
33+
}
34+
35+
try {
36+
await setupPassword.mutateAsync(password)
37+
toast.success('Password set up successfully')
38+
navigate('/')
39+
} catch (error) {
40+
toast.error(
41+
error instanceof Error ? error.message : 'Failed to set up password'
42+
)
43+
}
44+
}
45+
46+
return (
47+
<div className="flex min-h-screen items-center justify-center p-4">
48+
<Card className="w-full max-w-md">
49+
<CardHeader>
50+
<CardTitle>Set Up Password</CardTitle>
51+
<CardDescription>
52+
Create a password to secure your portfolio. This is a one-time setup.
53+
</CardDescription>
54+
</CardHeader>
55+
<CardContent>
56+
<form onSubmit={handleSubmit} className="space-y-4">
57+
<div className="space-y-2">
58+
<Input
59+
type="password"
60+
placeholder="Password (min 8 characters)"
61+
value={password}
62+
onChange={(e) => setPassword(e.target.value)}
63+
autoFocus
64+
required
65+
/>
66+
</div>
67+
<div className="space-y-2">
68+
<Input
69+
type="password"
70+
placeholder="Confirm password"
71+
value={confirmPassword}
72+
onChange={(e) => setConfirmPassword(e.target.value)}
73+
required
74+
/>
75+
</div>
76+
<Button
77+
type="submit"
78+
className="w-full"
79+
isLoading={setupPassword.isPending}
80+
>
81+
Set Up Password
82+
</Button>
83+
</form>
84+
</CardContent>
85+
</Card>
86+
</div>
87+
)
88+
}

server/src/api.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { Hono } from 'hono'
22
import { cors } from 'hono/cors'
33

44
import { addAccount, deleteAccount, getAccounts } from './handlers/accounts'
5+
import {
6+
getAuthStatus,
7+
login,
8+
logout,
9+
setupPassword,
10+
} from './handlers/auth'
511
import {
612
deleteOffchainBalance,
713
editOffchainBalance,
@@ -15,12 +21,20 @@ import { addChain, deleteChain, getChains } from './handlers/chains'
1521
import { getFiat } from './handlers/fiat'
1622
import { setupDefaultChains, setupDefaultTokens } from './handlers/setup'
1723
import { addToken, deleteToken, getTokens } from './handlers/tokens'
24+
import { authMiddleware } from './middleware/auth'
1825

1926
export const api = new Hono()
2027
api.use(cors())
2128

22-
// API Routes
29+
// Auth routes (public)
30+
api.get('/auth/status', (c) => getAuthStatus(c))
31+
api.post('/auth/setup', (c) => setupPassword(c))
32+
api.post('/auth/login', (c) => login(c))
33+
api.post('/auth/logout', (c) => logout(c))
34+
35+
// Protected API Routes - require authentication if password is set
2336
export const routes = api
37+
.use('*', authMiddleware)
2438
.get('/accounts', (c) => getAccounts(c))
2539
.get('/chains', (c) => getChains(c))
2640
.get('/balances', (c) => getBalances(c))

0 commit comments

Comments
 (0)