Skip to content

Commit 4a135f2

Browse files
authored
API for HA
API for HA
2 parents ad74cd6 + 569b694 commit 4a135f2

13 files changed

Lines changed: 1272 additions & 160 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ docker-compose.override.yml
3232

3333
# Claude Code
3434
CLAUDE.md
35+
.claude/
36+
37+
# VS Code workspace
38+
*.code-workspace
3539

3640
# Data directory (bind mount)
3741
data/

backend/app/api/auth.py

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
"""
22
Authentication API routes.
33
"""
4-
from typing import Optional
4+
import secrets
5+
from typing import Optional, List
56
from fastapi import APIRouter, Depends, HTTPException, status, Request
67
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
78
from sqlalchemy.ext.asyncio import AsyncSession
89
from sqlalchemy import select
9-
from pydantic import BaseModel
10-
from datetime import datetime, timezone
10+
from pydantic import BaseModel, Field
11+
from datetime import datetime, timezone, timedelta
1112
from loguru import logger
1213

1314
from app.database import get_db
14-
from app.models import User
15+
from app.models import User, APIToken
1516
from app.utils.auth import verify_password, create_access_token, decode_access_token, get_password_hash
1617
from app.utils.rate_limit import login_rate_limiter
1718

@@ -182,10 +183,12 @@ async def login(
182183

183184

184185
async def get_current_user(
186+
request: Request,
185187
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
186188
db: AsyncSession = Depends(get_db)
187189
) -> User:
188-
"""Get current authenticated user from JWT token."""
190+
"""Get current authenticated user from JWT token or API key."""
191+
# Try JWT auth first
189192
if credentials:
190193
token = credentials.credentials
191194
payload = decode_access_token(token)
@@ -198,6 +201,42 @@ async def get_current_user(
198201
if user and user.is_active:
199202
return user
200203

204+
# Fall back to API key auth via X-API-Key header
205+
api_key = request.headers.get("X-API-Key")
206+
if api_key:
207+
result = await db.execute(
208+
select(APIToken).where(
209+
APIToken.token == api_key,
210+
APIToken.is_active == True,
211+
)
212+
)
213+
api_token = result.scalar_one_or_none()
214+
215+
if api_token:
216+
# Check expiration
217+
if api_token.expires_at and api_token.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
218+
raise HTTPException(
219+
status_code=status.HTTP_401_UNAUTHORIZED,
220+
detail="API key has expired"
221+
)
222+
223+
# Load associated user
224+
result = await db.execute(select(User).where(User.id == api_token.user_id))
225+
user = result.scalar_one_or_none()
226+
227+
if user and user.is_active:
228+
# Update last_used timestamp
229+
api_token.last_used = datetime.now(timezone.utc)
230+
await db.flush()
231+
# Store API key name for downstream use (e.g., set_by in temp limits)
232+
request.state.api_key_name = api_token.name
233+
return user
234+
235+
raise HTTPException(
236+
status_code=status.HTTP_401_UNAUTHORIZED,
237+
detail="Invalid API key"
238+
)
239+
201240
raise HTTPException(
202241
status_code=status.HTTP_401_UNAUTHORIZED,
203242
detail="Not authenticated"
@@ -243,3 +282,121 @@ def require_admin(current_user: User = Depends(get_current_user)) -> User:
243282
detail="Admin privileges required"
244283
)
245284
return current_user
285+
286+
287+
# --- API Key Management ---
288+
289+
class CreateAPIKeyRequest(BaseModel):
290+
name: str = Field(..., min_length=1, max_length=100, description="Descriptive name for the API key")
291+
expires_in_days: Optional[int] = Field(None, ge=1, le=365, description="Days until expiration (null = never)")
292+
293+
294+
class APIKeyResponse(BaseModel):
295+
id: int
296+
name: str
297+
token: str
298+
token_preview: str
299+
created_at: str
300+
expires_at: Optional[str]
301+
last_used: Optional[str]
302+
is_active: bool
303+
304+
305+
class CreateAPIKeyResponse(BaseModel):
306+
id: int
307+
name: str
308+
token: str
309+
created_at: str
310+
expires_at: Optional[str]
311+
312+
313+
@router.get("/api-keys", response_model=List[APIKeyResponse])
314+
async def list_api_keys(
315+
current_user: User = Depends(require_admin),
316+
db: AsyncSession = Depends(get_db)
317+
):
318+
"""List all API keys for the current user. Tokens are masked."""
319+
result = await db.execute(
320+
select(APIToken)
321+
.where(APIToken.user_id == current_user.id)
322+
.order_by(APIToken.created_at.desc())
323+
)
324+
tokens = result.scalars().all()
325+
326+
return [
327+
APIKeyResponse(
328+
id=t.id,
329+
name=t.name or "Unnamed",
330+
token=t.token or "",
331+
token_preview=t.token[:8] + "..." if t.token else "",
332+
created_at=t.created_at.isoformat() + "Z" if t.created_at else "",
333+
expires_at=(t.expires_at.isoformat() + "Z") if t.expires_at else None,
334+
last_used=(t.last_used.isoformat() + "Z") if t.last_used else None,
335+
is_active=t.is_active,
336+
)
337+
for t in tokens
338+
]
339+
340+
341+
@router.post("/api-keys", response_model=CreateAPIKeyResponse, status_code=status.HTTP_201_CREATED)
342+
async def create_api_key(
343+
request: CreateAPIKeyRequest,
344+
current_user: User = Depends(require_admin),
345+
db: AsyncSession = Depends(get_db)
346+
):
347+
"""Create a new API key. The full token is returned only once."""
348+
token = secrets.token_hex(32) # 64-char hex string
349+
350+
expires_at = None
351+
if request.expires_in_days:
352+
expires_at = datetime.now(timezone.utc) + timedelta(days=request.expires_in_days)
353+
354+
api_token = APIToken(
355+
user_id=current_user.id,
356+
token=token,
357+
name=request.name,
358+
expires_at=expires_at,
359+
is_active=True,
360+
)
361+
db.add(api_token)
362+
await db.flush()
363+
await db.refresh(api_token)
364+
365+
logger.info(f"API key '{request.name}' created by {current_user.username}")
366+
367+
return CreateAPIKeyResponse(
368+
id=api_token.id,
369+
name=api_token.name or "Unnamed",
370+
token=token,
371+
created_at=api_token.created_at.isoformat() + "Z" if api_token.created_at else "",
372+
expires_at=(api_token.expires_at.isoformat() + "Z") if api_token.expires_at else None,
373+
)
374+
375+
376+
@router.delete("/api-keys/{key_id}")
377+
async def revoke_api_key(
378+
key_id: int,
379+
current_user: User = Depends(require_admin),
380+
db: AsyncSession = Depends(get_db)
381+
):
382+
"""Revoke an API key (soft-delete by setting is_active=False)."""
383+
result = await db.execute(
384+
select(APIToken).where(
385+
APIToken.id == key_id,
386+
APIToken.user_id == current_user.id,
387+
)
388+
)
389+
api_token = result.scalar_one_or_none()
390+
391+
if not api_token:
392+
raise HTTPException(
393+
status_code=status.HTTP_404_NOT_FOUND,
394+
detail="API key not found"
395+
)
396+
397+
api_token.is_active = False
398+
await db.flush()
399+
400+
logger.info(f"API key '{api_token.name}' (id={key_id}) revoked by {current_user.username}")
401+
402+
return {"message": "API key revoked"}

backend/app/api/bandwidth.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class TemporaryLimitRequest(BaseModel):
2828
le=168, # Max 7 days
2929
description="Duration in hours (min: >0, max: 168 = 7 days). Use 0.5 for 30 minutes."
3030
)
31+
source: Optional[str] = Field(None, max_length=200, description="Source identifier (e.g., 'Home Assistant - Gaming PC')")
3132

3233

3334
class TemporaryLimitResponse(BaseModel):
@@ -37,6 +38,8 @@ class TemporaryLimitResponse(BaseModel):
3738
upload_mbps: Optional[float] = None
3839
expires_at: Optional[str] = None
3940
remaining_minutes: Optional[float] = None
41+
source: Optional[str] = None
42+
set_by: Optional[str] = None
4043

4144

4245
@router.get("/current")
@@ -358,7 +361,9 @@ async def get_temporary_limits(request: Request):
358361
download_mbps=temp_limits.get('download_mbps'),
359362
upload_mbps=temp_limits.get('upload_mbps'),
360363
expires_at=expires_at.isoformat() + 'Z',
361-
remaining_minutes=round(remaining, 1)
364+
remaining_minutes=round(remaining, 1),
365+
source=temp_limits.get('source'),
366+
set_by=temp_limits.get('set_by'),
362367
)
363368

364369
return TemporaryLimitResponse(active=False)
@@ -385,30 +390,38 @@ async def set_temporary_limits(
385390
# Pydantic validates duration_hours > 0 and <= 168 hours (7 days)
386391
expires_at = datetime.now(timezone.utc) + timedelta(hours=limits.duration_hours)
387392

393+
# Use API key name when authenticated via API key
394+
api_key_name = getattr(request.state, 'api_key_name', None)
395+
set_by = f"API: {api_key_name}" if api_key_name else current_user.username
396+
388397
# Use lock for thread-safe access to temporary limits
389398
async with polling_monitor._temporary_limits_lock:
390399
polling_monitor._temporary_limits = {
391400
'download_mbps': limits.download_mbps,
392401
'upload_mbps': limits.upload_mbps,
393402
'expires_at': expires_at,
394-
'set_by': current_user.username,
395-
'set_at': datetime.now(timezone.utc)
403+
'set_by': set_by,
404+
'set_at': datetime.now(timezone.utc),
405+
'source': limits.source,
396406
}
397407

398408
remaining = limits.duration_hours * 60
399409

410+
source_info = f", source='{limits.source}'" if limits.source else ""
400411
logger.info(
401-
f"Temporary limits set by {current_user.username}: "
412+
f"Temporary limits set by {set_by}: "
402413
f"download={limits.download_mbps} Mbps, upload={limits.upload_mbps} Mbps, "
403-
f"expires in {limits.duration_hours} hours"
414+
f"expires in {limits.duration_hours} hours{source_info}"
404415
)
405416

406417
return TemporaryLimitResponse(
407418
active=True,
408419
download_mbps=limits.download_mbps,
409420
upload_mbps=limits.upload_mbps,
410421
expires_at=expires_at.isoformat() + 'Z',
411-
remaining_minutes=round(remaining, 1)
422+
remaining_minutes=round(remaining, 1),
423+
source=limits.source,
424+
set_by=set_by,
412425
)
413426

414427
except HTTPException:

frontend/src/api/client.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ import type {
2525
DecisionLogsResponse,
2626
SNMPDiscoverResponse,
2727
SNMPSpeedResponse,
28+
APIKeyInfo,
29+
CreateAPIKeyRequest,
30+
CreateAPIKeyResponse,
2831
} from '@/types';
2932

3033
class ApiClient {
@@ -394,6 +397,8 @@ class ApiClient {
394397
upload_mbps: number | null;
395398
expires_at: string | null;
396399
remaining_minutes: number | null;
400+
source: string | null;
401+
set_by: string | null;
397402
}> {
398403
return this.deduplicatedGet('/bandwidth/temporary-limits');
399404
}
@@ -402,12 +407,15 @@ class ApiClient {
402407
download_mbps?: number | null;
403408
upload_mbps?: number | null;
404409
duration_hours: number;
410+
source?: string;
405411
}): Promise<{
406412
active: boolean;
407413
download_mbps: number | null;
408414
upload_mbps: number | null;
409415
expires_at: string | null;
410416
remaining_minutes: number | null;
417+
source: string | null;
418+
set_by: string | null;
411419
}> {
412420
const response = await this.client.post('/bandwidth/temporary-limits', params);
413421
return response.data;
@@ -443,6 +451,22 @@ class ApiClient {
443451
return response.data;
444452
}
445453

454+
// API Key management endpoints
455+
async getAPIKeys(): Promise<APIKeyInfo[]> {
456+
const response = await this.client.get<APIKeyInfo[]>('/auth/api-keys');
457+
return response.data;
458+
}
459+
460+
async createAPIKey(params: CreateAPIKeyRequest): Promise<CreateAPIKeyResponse> {
461+
const response = await this.client.post<CreateAPIKeyResponse>('/auth/api-keys', params);
462+
return response.data;
463+
}
464+
465+
async revokeAPIKey(keyId: number): Promise<{ message: string }> {
466+
const response = await this.client.delete(`/auth/api-keys/${keyId}`);
467+
return response.data;
468+
}
469+
446470
// Setup wizard endpoints
447471
async initializeConfig(): Promise<{ success: boolean; message: string }> {
448472
const response = await this.client.post('/settings/initialize-config');

frontend/src/components/ActiveStreams.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { Play, Pause, StopCircle, Loader2, AlertCircle, X } from 'lucide-react';
2828
import { StreamCountChart } from './StreamCountChart';
2929
import type { TimeRange, DataInterval } from './BandwidthChart';
30+
import type { ZoomRange } from '@/hooks/useChartZoom';
3031
import { formatInTimeZone } from 'date-fns-tz';
3132

3233
const getStateIcon = (state: string) => {
@@ -58,9 +59,10 @@ const getStateBadgeVariant = (state: string): "default" | "secondary" | "destruc
5859
interface ActiveStreamsProps {
5960
timeRange: TimeRange;
6061
dataInterval: DataInterval;
62+
zoomRange?: ZoomRange | null;
6163
}
6264

63-
export const ActiveStreams: React.FC<ActiveStreamsProps> = ({ timeRange, dataInterval }) => {
65+
export const ActiveStreams: React.FC<ActiveStreamsProps> = ({ timeRange, dataInterval, zoomRange }) => {
6466
const { user } = useAuth();
6567
const isAdmin = user?.role === 'admin';
6668

@@ -119,7 +121,7 @@ export const ActiveStreams: React.FC<ActiveStreamsProps> = ({ timeRange, dataInt
119121
if (isLoading) {
120122
return (
121123
<>
122-
<StreamCountChart timeRange={timeRange} dataInterval={dataInterval} />
124+
<StreamCountChart timeRange={timeRange} dataInterval={dataInterval} zoomRange={zoomRange} />
123125
<Card>
124126
<CardContent className="flex justify-center p-8">
125127
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -131,7 +133,7 @@ export const ActiveStreams: React.FC<ActiveStreamsProps> = ({ timeRange, dataInt
131133

132134
return (
133135
<>
134-
<StreamCountChart timeRange={timeRange} dataInterval={dataInterval} />
136+
<StreamCountChart timeRange={timeRange} dataInterval={dataInterval} zoomRange={zoomRange} />
135137
<Card>
136138
<CardHeader>
137139
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">

0 commit comments

Comments
 (0)