Skip to content

Commit cb7dcb2

Browse files
committed
Implement authentication and authorization features
- Add login and logout functionality with session management - Create ProtectedRoute component for route protection - Implement user credential change functionality in settings - Update Sidebar to include logout button - Add LoginPage for user authentication
1 parent 61cd237 commit cb7dcb2

19 files changed

Lines changed: 770 additions & 13 deletions

File tree

frontend/src/App.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
22
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
33
import { AppLayout } from '@/components/layout/AppLayout'
4+
import { ProtectedRoute } from '@/components/auth/ProtectedRoute'
5+
import { LoginPage } from '@/pages/LoginPage'
46
import { SocketsPage } from '@/pages/SocketsPage'
57
import { SocketDetailPage } from '@/pages/SocketDetailPage'
68
import { TasksPage } from '@/pages/TasksPage'
@@ -23,15 +25,18 @@ export default function App() {
2325
<QueryClientProvider client={queryClient}>
2426
<BrowserRouter>
2527
<Routes>
26-
<Route element={<AppLayout />}>
27-
<Route index element={<Navigate to="/sockets" replace />} />
28-
<Route path="/sockets" element={<SocketsPage />} />
29-
<Route path="/sockets/:id" element={<SocketDetailPage />} />
30-
<Route path="/tasks" element={<TasksPage />} />
31-
<Route path="/tasks/new" element={<TaskCreatePage />} />
32-
<Route path="/tasks/:id" element={<TaskDetailPage />} />
33-
<Route path="/history" element={<HistoryPage />} />
34-
<Route path="/settings" element={<SettingsPage />} />
28+
<Route path="/login" element={<LoginPage />} />
29+
<Route element={<ProtectedRoute />}>
30+
<Route element={<AppLayout />}>
31+
<Route index element={<Navigate to="/sockets" replace />} />
32+
<Route path="/sockets" element={<SocketsPage />} />
33+
<Route path="/sockets/:id" element={<SocketDetailPage />} />
34+
<Route path="/tasks" element={<TasksPage />} />
35+
<Route path="/tasks/new" element={<TaskCreatePage />} />
36+
<Route path="/tasks/:id" element={<TaskDetailPage />} />
37+
<Route path="/history" element={<HistoryPage />} />
38+
<Route path="/settings" element={<SettingsPage />} />
39+
</Route>
3540
</Route>
3641
</Routes>
3742
</BrowserRouter>

frontend/src/api/client.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export class ApiError extends Error {
3131

3232
async function request<T>(path: string, init?: RequestInit): Promise<T> {
3333
const res = await fetch(`${BASE_URL}${path}`, {
34+
credentials: 'include',
3435
headers: {
3536
'Content-Type': 'application/json',
3637
...init?.headers,
@@ -39,6 +40,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
3940
})
4041

4142
if (!res.ok) {
43+
if (res.status === 401 && window.location.pathname !== '/login' && path !== '/api/auth/me') {
44+
window.location.href = '/login'
45+
return undefined as T
46+
}
4247
let message = res.statusText
4348
try {
4449
const body = await res.json() as { message?: string; error?: string }
@@ -180,3 +185,25 @@ export const historyApi = {
180185
delete: (id: number) =>
181186
request<void>(`/api/backup-history/${id}`, { method: 'DELETE' }),
182187
}
188+
189+
// ──────────────────────────────────────────
190+
// Auth
191+
// ──────────────────────────────────────────
192+
193+
export const authApi = {
194+
me: () => request<{ username: string }>('/api/auth/me'),
195+
196+
login: (username: string, password: string, rememberMe: boolean) =>
197+
request<{ username: string }>('/api/auth/login', {
198+
method: 'POST',
199+
body: JSON.stringify({ username, password, rememberMe }),
200+
}),
201+
202+
logout: () => request<void>('/api/auth/logout', { method: 'POST' }),
203+
204+
changeCredentials: (currentPassword: string, newUsername?: string, newPassword?: string) =>
205+
request<void>('/api/auth/credentials', {
206+
method: 'PUT',
207+
body: JSON.stringify({ currentPassword, newUsername, newPassword }),
208+
}),
209+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Navigate, Outlet } from 'react-router-dom'
2+
import { useMe } from '@/hooks/useAuth'
3+
4+
export function ProtectedRoute() {
5+
const { data, isLoading, isError } = useMe()
6+
7+
if (isLoading) return null
8+
if (isError) return <Navigate to="/login" replace />
9+
if (!data) return null
10+
11+
return <Outlet />
12+
}

frontend/src/components/layout/Sidebar.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { NavLink } from 'react-router-dom'
2-
import { Database, Github, History, Server, Settings } from 'lucide-react'
1+
import { NavLink, useNavigate } from 'react-router-dom'
2+
import { Database, Github, History, LogOut, Server, Settings } from 'lucide-react'
33
import { useSettings } from '@/hooks/useSettings'
4+
import { useLogout } from '@/hooks/useAuth'
45

56
interface NavItem {
67
to: string
@@ -53,6 +54,29 @@ function IntegrationStatus() {
5354
)
5455
}
5556

57+
function LogoutButton() {
58+
const logout = useLogout()
59+
const navigate = useNavigate()
60+
61+
function handleLogout() {
62+
logout.mutate(undefined, {
63+
onSettled: () => navigate('/login'),
64+
})
65+
}
66+
67+
return (
68+
<button
69+
onClick={handleLogout}
70+
disabled={logout.isPending}
71+
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors w-full disabled:opacity-50"
72+
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
73+
>
74+
<LogOut className="w-4 h-4" />
75+
<span>Sign out</span>
76+
</button>
77+
)
78+
}
79+
5680
export function Sidebar() {
5781
return (
5882
<aside
@@ -121,7 +145,8 @@ export function Sidebar() {
121145
<span>Settings</span>
122146
</NavLink>
123147
<div className="-mx-3 border-b my-2" style={{ borderColor: 'var(--border)', opacity: 0.5 }} />
124-
<div className="mt-2">
148+
<LogoutButton />
149+
<div className="mt-1">
125150
<a
126151
href="https://github.com/nomad4tech/backup-manager"
127152
target="_blank"

frontend/src/hooks/useAuth.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2+
import { authApi } from '@/api/client'
3+
4+
export const AUTH_ME_KEY = ['auth', 'me'] as const
5+
6+
export function useMe() {
7+
return useQuery({
8+
queryKey: AUTH_ME_KEY,
9+
queryFn: authApi.me,
10+
retry: false,
11+
staleTime: Infinity,
12+
refetchOnMount: false,
13+
refetchOnWindowFocus: false,
14+
})
15+
}
16+
17+
export function useLogin() {
18+
const qc = useQueryClient()
19+
return useMutation({
20+
mutationFn: ({ username, password, rememberMe }: {
21+
username: string
22+
password: string
23+
rememberMe: boolean
24+
}) => authApi.login(username, password, rememberMe),
25+
onSuccess: (data) => {
26+
qc.setQueryData(AUTH_ME_KEY, data)
27+
qc.invalidateQueries({ queryKey: AUTH_ME_KEY })
28+
},
29+
})
30+
}
31+
32+
export function useLogout() {
33+
const qc = useQueryClient()
34+
return useMutation({
35+
mutationFn: authApi.logout,
36+
onSettled: () => {
37+
qc.clear()
38+
},
39+
})
40+
}
41+
42+
export function useChangeCredentials() {
43+
return useMutation({
44+
mutationFn: ({ currentPassword, newUsername, newPassword }: {
45+
currentPassword: string
46+
newUsername?: string
47+
newPassword?: string
48+
}) => authApi.changeCredentials(currentPassword, newUsername, newPassword),
49+
})
50+
}

frontend/src/pages/LoginPage.tsx

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { useState } from 'react'
2+
import { useNavigate } from 'react-router-dom'
3+
import { Database } from 'lucide-react'
4+
import { useLogin } from '@/hooks/useAuth'
5+
6+
const inputCls =
7+
'w-full rounded-md border px-3 py-2 text-sm outline-none transition-colors focus:border-[var(--accent)]'
8+
const inputSt = {
9+
backgroundColor: 'var(--bg-base)',
10+
borderColor: 'var(--border)',
11+
color: 'var(--text-primary)',
12+
}
13+
14+
export function LoginPage() {
15+
const navigate = useNavigate()
16+
const login = useLogin()
17+
const [username, setUsername] = useState('')
18+
const [password, setPassword] = useState('')
19+
const [rememberMe, setRememberMe] = useState(false)
20+
const [error, setError] = useState('')
21+
22+
function handleSubmit(e: React.FormEvent) {
23+
e.preventDefault()
24+
setError('')
25+
login.mutate({ username, password, rememberMe }, {
26+
onSuccess: () => {
27+
navigate('/', { replace: true })
28+
},
29+
onError: () => {
30+
setError('Invalid username or password')
31+
},
32+
})
33+
}
34+
35+
return (
36+
<div
37+
className="min-h-screen flex items-center justify-center p-4"
38+
style={{ backgroundColor: 'var(--bg-base)' }}
39+
>
40+
<div
41+
className="w-full max-w-sm rounded-lg border p-8"
42+
style={{ backgroundColor: 'var(--bg-surface)', borderColor: 'var(--border)' }}
43+
>
44+
{/* Logo */}
45+
<div className="flex items-center gap-2 mb-7">
46+
<div
47+
className="w-7 h-7 rounded flex items-center justify-center flex-shrink-0"
48+
style={{ backgroundColor: 'var(--accent)' }}
49+
>
50+
<Database className="w-4 h-4 text-white" />
51+
</div>
52+
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
53+
Backup Manager
54+
</span>
55+
</div>
56+
57+
<h1 className="text-base font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
58+
Sign in
59+
</h1>
60+
<p className="text-xs mb-6" style={{ color: 'var(--text-muted)' }}>
61+
Enter your credentials to continue
62+
</p>
63+
64+
<form onSubmit={handleSubmit} className="space-y-4">
65+
<div>
66+
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
67+
Username
68+
</label>
69+
<input
70+
type="text"
71+
required
72+
className={inputCls}
73+
style={inputSt}
74+
value={username}
75+
onChange={(e) => setUsername(e.target.value)}
76+
autoFocus
77+
autoComplete="username"
78+
/>
79+
</div>
80+
81+
<div>
82+
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
83+
Password
84+
</label>
85+
<input
86+
type="password"
87+
required
88+
className={inputCls}
89+
style={inputSt}
90+
value={password}
91+
onChange={(e) => setPassword(e.target.value)}
92+
autoComplete="current-password"
93+
/>
94+
</div>
95+
96+
<label className="flex items-center gap-2 cursor-pointer select-none">
97+
<input
98+
type="checkbox"
99+
checked={rememberMe}
100+
onChange={(e) => setRememberMe(e.target.checked)}
101+
className="w-4 h-4 rounded flex-shrink-0"
102+
style={{ accentColor: 'var(--accent)' }}
103+
/>
104+
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
105+
Remember me for 30 days
106+
</span>
107+
</label>
108+
109+
{error && (
110+
<p className="text-xs" style={{ color: 'var(--error)' }}>
111+
{error}
112+
</p>
113+
)}
114+
115+
<button
116+
type="submit"
117+
disabled={login.isPending}
118+
className="w-full py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-60"
119+
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
120+
>
121+
{login.isPending ? 'Signing in…' : 'Sign in'}
122+
</button>
123+
</form>
124+
</div>
125+
</div>
126+
)
127+
}

0 commit comments

Comments
 (0)