|
|
@@ -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}`)
|
|
|
+ },
|
|
|
+}
|