Forráskód Böngészése

feat: Dashboard auth flow

- /login page: login + register forms
- ProtectedRoute: redirect to /login if no JWT token
- lib/auth.ts: JWT storage, auto-refresh, apiFetch with Bearer token
- All pages wrapped with ProtectedRoute
- devicesApi/useDevices/useDashboardStats: use JWT auth

Flow: / → no token → redirect /login → POST /v1/auth/login → JWT stored → /
kingkong 2 hónapja
szülő
commit
2291fdef0d

+ 30 - 0
apps/web-dashboard/src/components/ProtectedRoute.tsx

@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react'
+import { getStoredUser, getAccessToken } from '../lib/auth'
+
+interface ProtectedRouteProps {
+  children: React.ReactNode
+}
+
+export function ProtectedRoute({ children }: ProtectedRouteProps) {
+  const [checking, setChecking] = useState(true)
+
+  useEffect(() => {
+    const token = getAccessToken()
+    if (!token) {
+      window.location.href = '/login'
+      return
+    }
+    // Token exists — render children
+    setChecking(false)
+  }, [])
+
+  if (checking) {
+    return (
+      <div className="min-h-screen flex items-center justify-center bg-gray-50">
+        <div className="text-gray-500">Loading...</div>
+      </div>
+    )
+  }
+
+  return <>{children}</>
+}

+ 2 - 7
apps/web-dashboard/src/hooks/useDashboardStats.ts

@@ -1,4 +1,5 @@
 import { useQuery } from '@tanstack/react-query'
+import { devicesApi } from '../lib/auth'
 
 interface DashboardStats {
   devicesOnline: number
@@ -7,16 +8,10 @@ interface DashboardStats {
   devicesTotal: number
 }
 
-async function fetchDashboardStats(): Promise<DashboardStats> {
-  const res = await fetch('http://localhost:3001/v1/devices/stats')
-  if (!res.ok) throw new Error('Failed to fetch dashboard stats')
-  return res.json()
-}
-
 export function useDashboardStats() {
   return useQuery({
     queryKey: ['dashboard-stats'],
-    queryFn: fetchDashboardStats,
+    queryFn: () => devicesApi.stats(),
     refetchInterval: 30_000,
   })
 }

+ 3 - 17
apps/web-dashboard/src/hooks/useDevices.ts

@@ -1,4 +1,5 @@
 import { useQuery } from '@tanstack/react-query'
+import { devicesApi } from '../lib/auth'
 
 interface Device {
   id: string
@@ -14,25 +15,10 @@ interface Device {
   updatedAt: string
 }
 
-async function fetchDevices(projectId?: string): Promise<Device[]> {
-  const url = projectId
-    ? `http://localhost:3001/v1/devices?projectId=${projectId}`
-    : 'http://localhost:3001/v1/devices'
-  const res = await fetch(url)
-  if (!res.ok) throw new Error('Failed to fetch devices')
-  return res.json()
-}
-
-async function fetchDevice(id: string): Promise<Device> {
-  const res = await fetch(`http://localhost:3001/v1/devices/${id}`)
-  if (!res.ok) throw new Error(`Failed to fetch device ${id}`)
-  return res.json()
-}
-
 export function useDevices(projectId?: string) {
   return useQuery({
     queryKey: ['devices', projectId],
-    queryFn: () => fetchDevices(projectId),
+    queryFn: () => devicesApi.list(projectId),
     refetchInterval: 15_000,
   })
 }
@@ -40,7 +26,7 @@ export function useDevices(projectId?: string) {
 export function useDevice(id: string) {
   return useQuery({
     queryKey: ['device', id],
-    queryFn: () => fetchDevice(id),
+    queryFn: () => devicesApi.get(id),
     refetchInterval: 15_000,
     enabled: !!id,
   })

+ 145 - 0
apps/web-dashboard/src/lib/auth.ts

@@ -0,0 +1,145 @@
+const API_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001'
+
+export interface AuthUser {
+  id: string
+  email: string
+  name: string
+  avatarUrl: string | null
+}
+
+export interface AuthTokens {
+  accessToken: string
+  refreshToken: string
+  expiresIn: number
+  user: AuthUser
+}
+
+const ACCESS_TOKEN_KEY = 'timelapse_access_token'
+const REFRESH_TOKEN_KEY = 'timelapse_refresh_token'
+const USER_KEY = 'timelapse_user'
+
+// ── Token storage ────────────────────────────────────────────────
+
+export function getAccessToken(): string | null {
+  if (typeof window === 'undefined') return null
+  return localStorage.getItem(ACCESS_TOKEN_KEY)
+}
+
+export function getRefreshToken(): string | null {
+  if (typeof window === 'undefined') return null
+  return localStorage.getItem(REFRESH_TOKEN_KEY)
+}
+
+export function getStoredUser(): AuthUser | null {
+  if (typeof window === 'undefined') return null
+  const raw = localStorage.getItem(USER_KEY)
+  if (!raw) return null
+  try { return JSON.parse(raw) } catch { return null }
+}
+
+export function storeTokens(tokens: AuthTokens): void {
+  localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
+  localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
+  localStorage.setItem(USER_KEY, JSON.stringify(tokens.user))
+}
+
+export function clearAuth(): void {
+  localStorage.removeItem(ACCESS_TOKEN_KEY)
+  localStorage.removeItem(REFRESH_TOKEN_KEY)
+  localStorage.removeItem(USER_KEY)
+}
+
+// ── API helpers ──────────────────────────────────────────────────
+
+async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
+  const token = getAccessToken()
+  const headers: Record<string, string> = {
+    'Content-Type': 'application/json',
+    ...(options.headers as Record<string, string>),
+  }
+  if (token) headers['Authorization'] = `Bearer ${token}`
+
+  const res = await fetch(`${API_URL}${path}`, { ...options, headers })
+
+  if (res.status === 401) {
+    // Try refresh
+    const refreshed = await tryRefresh()
+    if (refreshed) {
+      const retryHeaders = { ...headers, Authorization: `Bearer ${getAccessToken()}` }
+      const retry = await fetch(`${API_URL}${path}`, { ...options, headers: retryHeaders })
+      if (!retry.ok) throw new Error(`API error ${retry.status}`)
+      return retry.json() as Promise<T>
+    }
+    clearAuth()
+    if (typeof window !== 'undefined') window.location.href = '/login'
+    throw new Error('Unauthorized')
+  }
+
+  if (!res.ok) {
+    const body = await res.json().catch(() => ({ message: 'Unknown error' }))
+    throw new Error(body.message || `API error ${res.status}`)
+  }
+
+  return res.json() as Promise<T>
+}
+
+export async function tryRefresh(): Promise<boolean> {
+  const refreshToken = getRefreshToken()
+  if (!refreshToken) return false
+
+  try {
+    const res = await fetch(`${API_URL}/v1/auth/refresh`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ refreshToken }),
+    })
+    if (!res.ok) return false
+    const tokens: AuthTokens = await res.json()
+    storeTokens(tokens)
+    return true
+  } catch {
+    return false
+  }
+}
+
+// ── Auth API calls ──────────────────────────────────────────────
+
+export const authApi = {
+  login: (email: string, password: string) =>
+    apiFetch<AuthTokens>('/v1/auth/login', {
+      method: 'POST',
+      body: JSON.stringify({ email, password }),
+    }),
+
+  register: (email: string, password: string, name: string) =>
+    apiFetch<AuthTokens>('/v1/auth/register', {
+      method: 'POST',
+      body: JSON.stringify({ email, password, name }),
+    }),
+
+  logout: () =>
+    apiFetch('/v1/auth/logout', {
+      method: 'POST',
+      body: JSON.stringify({ refreshToken: getRefreshToken() }),
+    }).finally(() => clearAuth()),
+
+  me: () => apiFetch<AuthUser>('/v1/auth/me'),
+}
+
+// ── Devices API (JWT-authenticated) ─────────────────────────────
+
+export const devicesApi = {
+  list: (projectId?: string) => {
+    const qs = projectId ? `?projectId=${projectId}` : ''
+    return apiFetch<any[]>(`/v1/devices${qs}`)
+  },
+
+  get: (id: string) => apiFetch<any>(`/v1/devices/${id}`),
+
+  stats: () => apiFetch<{ devicesOnline: number; devicesOffline: number; capturesToday: number; devicesTotal: number }>('/v1/devices/stats'),
+
+  heartbeats: (id: string, limit?: number) => {
+    const qs = limit ? `?limit=${limit}` : ''
+    return apiFetch<any[]>(`/v1/devices/${id}/heartbeats${qs}`)
+  },
+}

+ 43 - 20
apps/web-dashboard/src/pages/devices/[id].tsx

@@ -1,12 +1,24 @@
 import Head from 'next/head'
 import Link from 'next/link'
 import { useRouter } from 'next/router'
+import { useEffect, useState } from 'react'
 import { useDevice } from '../../hooks/useDevices'
+import { ProtectedRoute } from '../../components/ProtectedRoute'
+import { getStoredUser, authApi } from '../../lib/auth'
+import type { AuthUser } from '../../lib/auth'
 
-export default function DeviceDetailPage() {
+function DeviceDetailContent() {
   const router = useRouter()
   const { id } = router.query as { id?: string }
   const { data: device, isLoading } = useDevice(id ?? '')
+  const [user, setUser] = useState<AuthUser | null>(null)
+
+  useEffect(() => { setUser(getStoredUser()) }, [])
+
+  async function handleLogout() {
+    try { await authApi.logout() } catch {}
+    router.push('/login')
+  }
 
   return (
     <>
@@ -15,14 +27,23 @@ export default function DeviceDetailPage() {
       </Head>
 
       <div className="min-h-screen bg-gray-50">
-        <header className="bg-white border-b border-gray-200 px-6 py-4">
-          <div className="flex items-center gap-4">
-            <Link href="/devices" className="text-sm text-gray-500 hover:text-gray-900">
-              ← Devices
-            </Link>
-            <h1 className="text-xl font-bold text-gray-900">
-              {isLoading ? '...' : device?.name ?? 'Device not found'}
-            </h1>
+        <header className="bg-white border-b border-gray-200 px-6 py-3">
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-4">
+              <Link href="/devices" className="text-sm text-gray-500 hover:text-gray-900">← Devices</Link>
+              <h1 className="text-lg font-bold text-gray-900">
+                {isLoading ? '...' : device?.name ?? 'Device not found'}
+              </h1>
+            </div>
+            <div className="flex items-center gap-3 border-l border-gray-200 pl-4">
+              <span className="text-sm text-gray-600">{user?.name ?? user?.email ?? '—'}</span>
+              <button
+                onClick={handleLogout}
+                className="rounded-lg border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
+              >
+                Logout
+              </button>
+            </div>
           </div>
         </header>
 
@@ -33,19 +54,14 @@ export default function DeviceDetailPage() {
             <p className="text-red-400">Device not found.</p>
           ) : (
             <div className="space-y-6">
-              {/* Status card */}
               <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
                 <div className="flex items-center justify-between mb-4">
                   <h2 className="font-semibold text-gray-700">Status</h2>
-                  <span
-                    className={`rounded-full px-3 py-1 text-sm font-medium ${
-                      device.status === 'online'
-                        ? 'bg-emerald-100 text-emerald-700'
-                        : device.status === 'offline'
-                        ? 'bg-gray-100 text-gray-600'
-                        : 'bg-amber-100 text-amber-700'
-                    }`}
-                  >
+                  <span className={`rounded-full px-3 py-1 text-sm font-medium ${
+                    device.status === 'online' ? 'bg-emerald-100 text-emerald-700' :
+                    device.status === 'offline' ? 'bg-gray-100 text-gray-600' :
+                    'bg-amber-100 text-amber-700'
+                  }`}>
                     {device.status}
                   </span>
                 </div>
@@ -72,7 +88,6 @@ export default function DeviceDetailPage() {
                 </div>
               </div>
 
-              {/* Config card */}
               {device.config && (
                 <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
                   <h2 className="font-semibold text-gray-700 mb-4">Configuration</h2>
@@ -88,3 +103,11 @@ export default function DeviceDetailPage() {
     </>
   )
 }
+
+export default function DeviceDetailPage() {
+  return (
+    <ProtectedRoute>
+      <DeviceDetailContent />
+    </ProtectedRoute>
+  )
+}

+ 35 - 4
apps/web-dashboard/src/pages/devices/index.tsx

@@ -1,10 +1,24 @@
 import Head from 'next/head'
 import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { useEffect, useState } from 'react'
 import { useDevices } from '../../hooks/useDevices'
 import { DeviceCard } from '../../components/DeviceCard'
+import { ProtectedRoute } from '../../components/ProtectedRoute'
+import { getStoredUser, authApi } from '../../lib/auth'
+import type { AuthUser } from '../../lib/auth'
 
-export default function DevicesPage() {
+function DevicesContent() {
+  const router = useRouter()
   const { data: devices = [], isLoading } = useDevices()
+  const [user, setUser] = useState<AuthUser | null>(null)
+
+  useEffect(() => { setUser(getStoredUser()) }, [])
+
+  async function handleLogout() {
+    try { await authApi.logout() } catch {}
+    router.push('/login')
+  }
 
   return (
     <>
@@ -13,11 +27,20 @@ export default function DevicesPage() {
       </Head>
 
       <div className="min-h-screen bg-gray-50">
-        <header className="bg-white border-b border-gray-200 px-6 py-4">
+        <header className="bg-white border-b border-gray-200 px-6 py-3">
           <div className="flex items-center justify-between">
             <div className="flex items-center gap-4">
               <Link href="/" className="text-sm text-gray-500 hover:text-gray-900">← Dashboard</Link>
-              <h1 className="text-xl font-bold text-gray-900">Devices</h1>
+              <h1 className="text-lg font-bold text-gray-900">Devices</h1>
+            </div>
+            <div className="flex items-center gap-3 border-l border-gray-200 pl-4">
+              <span className="text-sm text-gray-600">{user?.name ?? user?.email ?? '—'}</span>
+              <button
+                onClick={handleLogout}
+                className="rounded-lg border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
+              >
+                Logout
+              </button>
             </div>
           </div>
         </header>
@@ -40,7 +63,7 @@ export default function DevicesPage() {
                   status={d.status}
                   lastSeenAt={d.lastSeenAt}
                   firmwareVersion={d.firmwareVersion}
-                  onClick={() => window.location.href = `/devices/${d.id}`}
+                  onClick={() => router.push(`/devices/${d.id}`)}
                 />
               ))}
             </div>
@@ -50,3 +73,11 @@ export default function DevicesPage() {
     </>
   )
 }
+
+export default function DevicesPage() {
+  return (
+    <ProtectedRoute>
+      <DevicesContent />
+    </ProtectedRoute>
+  )
+}

+ 43 - 8
apps/web-dashboard/src/pages/index.tsx

@@ -1,13 +1,27 @@
 import Head from 'next/head'
 import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { useEffect, useState } from 'react'
 import { useDashboardStats } from '../hooks/useDashboardStats'
 import { useDevices } from '../hooks/useDevices'
 import { StatsCard } from '../components/StatsCard'
 import { DeviceCard } from '../components/DeviceCard'
+import { ProtectedRoute } from '../components/ProtectedRoute'
+import { getStoredUser, authApi } from '../lib/auth'
+import type { AuthUser } from '../lib/auth'
 
-export default function DashboardPage() {
+function DashboardContent() {
+  const router = useRouter()
   const { data: stats, isLoading: statsLoading } = useDashboardStats()
   const { data: devices = [], isLoading: devicesLoading } = useDevices()
+  const [user, setUser] = useState<AuthUser | null>(null)
+
+  useEffect(() => { setUser(getStoredUser()) }, [])
+
+  async function handleLogout() {
+    try { await authApi.logout() } catch {}
+    router.push('/login')
+  }
 
   return (
     <>
@@ -17,13 +31,26 @@ export default function DashboardPage() {
 
       <div className="min-h-screen bg-gray-50">
         {/* Header */}
-        <header className="bg-white border-b border-gray-200 px-6 py-4">
+        <header className="bg-white border-b border-gray-200 px-6 py-3">
           <div className="flex items-center justify-between">
-            <h1 className="text-xl font-bold text-gray-900">Construction Timelapse</h1>
-            <nav className="flex gap-4 text-sm text-gray-500">
-              <Link href="/" className="text-blue-600 font-medium">Dashboard</Link>
-              <Link href="/devices">Devices</Link>
-            </nav>
+            <h1 className="text-lg font-bold text-gray-900">Construction Timelapse</h1>
+            <div className="flex items-center gap-4">
+              <nav className="flex gap-4 text-sm text-gray-500">
+                <Link href="/" className="text-blue-600 font-medium">Dashboard</Link>
+                <Link href="/devices">Devices</Link>
+              </nav>
+              <div className="flex items-center gap-3 border-l border-gray-200 pl-4">
+                <span className="text-sm text-gray-600">
+                  {user?.name ?? user?.email ?? '—'}
+                </span>
+                <button
+                  onClick={handleLogout}
+                  className="rounded-lg border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors"
+                >
+                  Logout
+                </button>
+              </div>
+            </div>
           </div>
         </header>
 
@@ -80,7 +107,7 @@ export default function DashboardPage() {
                   status={d.status}
                   lastSeenAt={d.lastSeenAt}
                   firmwareVersion={d.firmwareVersion}
-                  onClick={() => window.location.href = `/devices/${d.id}`}
+                  onClick={() => router.push(`/devices/${d.id}`)}
                 />
               ))}
             </div>
@@ -90,3 +117,11 @@ export default function DashboardPage() {
     </>
   )
 }
+
+export default function DashboardPage() {
+  return (
+    <ProtectedRoute>
+      <DashboardContent />
+    </ProtectedRoute>
+  )
+}

+ 118 - 0
apps/web-dashboard/src/pages/login.tsx

@@ -0,0 +1,118 @@
+import Head from 'next/head'
+import { useState } from 'react'
+import { useRouter } from 'next/router'
+import { authApi, storeTokens, clearAuth } from '../lib/auth'
+
+export default function LoginPage() {
+  const router = useRouter()
+  const [mode, setMode] = useState<'login' | 'register'>('login')
+  const [email, setEmail] = useState('')
+  const [password, setPassword] = useState('')
+  const [name, setName] = useState('')
+  const [error, setError] = useState('')
+  const [loading, setLoading] = useState(false)
+
+  async function handleSubmit(e: React.FormEvent) {
+    e.preventDefault()
+    setError('')
+    setLoading(true)
+
+    try {
+      clearAuth()
+      const tokens = mode === 'login'
+        ? await authApi.login(email, password)
+        : await authApi.register(email, password, name)
+      storeTokens(tokens)
+      router.push('/')
+    } catch (err: any) {
+      setError(err.message || 'Authentication failed')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <>
+      <Head>
+        <title>{mode === 'login' ? 'Login' : 'Register'} — Timelapse</title>
+      </Head>
+
+      <div className="min-h-screen flex items-center justify-center bg-gray-50">
+        <div className="w-full max-w-sm">
+          <div className="bg-white rounded-2xl shadow-lg border border-gray-200 p-8">
+            <div className="text-center mb-8">
+              <h1 className="text-2xl font-bold text-gray-900">Construction Timelapse</h1>
+              <p className="mt-1 text-sm text-gray-500">
+                {mode === 'login' ? 'Sign in to your account' : 'Create your account'}
+              </p>
+            </div>
+
+            <form onSubmit={handleSubmit} className="space-y-4">
+              {mode === 'register' && (
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
+                  <input
+                    type="text"
+                    required
+                    value={name}
+                    onChange={e => setName(e.target.value)}
+                    className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+                    placeholder="Your name"
+                  />
+                </div>
+              )}
+
+              <div>
+                <label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
+                <input
+                  type="email"
+                  required
+                  value={email}
+                  onChange={e => setEmail(e.target.value)}
+                  className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+                  placeholder="you@example.com"
+                />
+              </div>
+
+              <div>
+                <label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
+                <input
+                  type="password"
+                  required
+                  value={password}
+                  onChange={e => setPassword(e.target.value)}
+                  className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+                  placeholder="••••••••"
+                  minLength={8}
+                />
+              </div>
+
+              {error && (
+                <div className="rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-sm text-red-600">
+                  {error}
+                </div>
+              )}
+
+              <button
+                type="submit"
+                disabled={loading}
+                className="w-full rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white font-medium py-2 px-4 text-sm transition-colors"
+              >
+                {loading ? 'Please wait...' : mode === 'login' ? 'Sign in' : 'Create account'}
+              </button>
+            </form>
+
+            <div className="mt-4 text-center">
+              <button
+                onClick={() => { setMode(mode === 'login' ? 'register' : 'login'); setError('') }}
+                className="text-sm text-blue-600 hover:underline"
+              >
+                {mode === 'login' ? "Don't have an account? Register" : 'Already have an account? Sign in'}
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </>
+  )
+}