feat: add web UI with login, CRUD, admin, and API key management

- Add login page with JWT authentication
- Add dashboard with stats and quick actions
- Add links management page (full CRUD with search)
- Add collections management page
- Add API key management page with copy-to-clipboard
- Add admin user management page (admin only)
- Fix UUID type mismatches across all endpoints
- Add updated_at column to api_keys and audit_log in schema.sql
- Fix DB_PASSWORD default in docker-compose.yml
- Add PyJWT to requirements.txt
- Fix API docs URL (/docs instead of /api/docs)
- Improve JS error handling (show actual messages)
- Rewrite conftest.py with proper DB lifecycle management
- Add 42 new integration tests (84 total, all passing)
  - test_admin.py: 15 tests for admin endpoints
  - test_auth_extended.py: 9 tests for API key CRUD
  - test_tags.py: 12 tests for tag endpoints
  - test_sync.py: 6 tests for sync endpoints
This commit is contained in:
DavidSaylor
2026-05-21 07:21:49 -05:00
parent 09d30427f4
commit 77b076c7d7
31 changed files with 2740 additions and 213 deletions

View File

@@ -5,7 +5,7 @@ LinkSyncServer - Admin Endpoints
import uuid
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, EmailStr, Field
from api.endpoints.auth import hash_password, require_admin
@@ -34,11 +34,18 @@ class SettingsUpdate(BaseModel):
cors_origins: Optional[str] = None
def parse_uuid(id_str: str):
try:
return uuid.UUID(id_str) if isinstance(id_str, str) else id_str
except (ValueError, AttributeError):
return None
@router.get("/users", response_model=List[dict])
async def list_users(
limit: int = Query(20, le=100, ge=1),
offset: int = Query(0, ge=0),
current_admin: dict = require_admin,
current_admin: dict = Depends(require_admin),
):
db = get_session()
try:
@@ -51,7 +58,7 @@ async def list_users(
@router.post("/users", response_model=dict, status_code=status.HTTP_201_CREATED)
async def create_user(
data: UserCreate,
current_admin: dict = require_admin,
current_admin: dict = Depends(require_admin),
):
db = get_session()
try:
@@ -62,7 +69,7 @@ async def create_user(
raise HTTPException(status_code=400, detail="Username or email already exists")
user = User(
id=str(uuid.uuid4()),
id=uuid.uuid4(),
username=data.username,
email=data.email,
password_hash=hash_password(data.password),
@@ -80,11 +87,14 @@ async def create_user(
@router.get("/users/{user_id}", response_model=dict)
async def get_user(
user_id: str,
current_admin: dict = require_admin,
current_admin: dict = Depends(require_admin),
):
db = get_session()
try:
user = db.query(User).filter(User.id == user_id).first()
parsed_id = parse_uuid(user_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="User not found")
user = db.query(User).filter(User.id == parsed_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user.to_dict()
@@ -96,11 +106,14 @@ async def get_user(
async def update_user(
user_id: str,
data: UserUpdate,
current_admin: dict = require_admin,
current_admin: dict = Depends(require_admin),
):
db = get_session()
try:
user = db.query(User).filter(User.id == user_id).first()
parsed_id = parse_uuid(user_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="User not found")
user = db.query(User).filter(User.id == parsed_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
@@ -121,11 +134,14 @@ async def update_user(
@router.delete("/users/{user_id}", response_model=dict)
async def delete_user(
user_id: str,
current_admin: dict = require_admin,
current_admin: dict = Depends(require_admin),
):
db = get_session()
try:
user = db.query(User).filter(User.id == user_id).first()
parsed_id = parse_uuid(user_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="User not found")
user = db.query(User).filter(User.id == parsed_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.username == current_admin.get("username"):
@@ -139,7 +155,7 @@ async def delete_user(
@router.get("/stats", response_model=dict)
async def get_system_stats(current_admin: dict = require_admin):
async def get_system_stats(current_admin: dict = Depends(require_admin)):
db = get_session()
try:
return {
@@ -159,7 +175,7 @@ async def get_audit_log(
offset: int = Query(0, ge=0),
entity_type: Optional[str] = Query(None),
action: Optional[str] = Query(None),
current_admin: dict = require_admin,
current_admin: dict = Depends(require_admin),
):
db = get_session()
try:
@@ -171,14 +187,14 @@ async def get_audit_log(
logs = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
return [
{
"id": log.id,
"user_id": log.user_id,
"id": str(log.id),
"user_id": str(log.user_id) if log.user_id else None,
"action": log.action,
"entity_type": log.entity_type,
"entity_id": log.entity_id,
"entity_id": str(log.entity_id) if log.entity_id else None,
"old_value": log.old_value,
"new_value": log.new_value,
"ip_address": log.ip_address,
"ip_address": str(log.ip_address) if log.ip_address else None,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs