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

@@ -55,6 +55,13 @@ def get_current_user_id(request: Request) -> Optional[str]:
return 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
def log_audit(db, action: str, entity_type: str, entity_id: str, user_id: Optional[str], old_value=None, new_value=None):
try:
audit = AuditLog(
@@ -105,7 +112,10 @@ async def list_bookmarks(
async def get_bookmark(bookmark_id: str):
db = get_session()
try:
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
parsed_id = parse_uuid(bookmark_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Bookmark not found")
bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first()
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
return bookmark.to_dict()
@@ -117,9 +127,14 @@ async def get_bookmark(bookmark_id: str):
async def create_bookmark(data: BookmarkCreate, request: Request):
db = get_session()
try:
user_id = get_current_user_id(request)
user_id = None
username = get_current_user_id(request)
if username:
user = db.query(User).filter(User.username == username).first()
if user:
user_id = user.id
bookmark = Bookmark(
id=str(uuid.uuid4()),
id=uuid.uuid4(),
url=data.url,
title=data.title,
description=data.description,
@@ -144,7 +159,10 @@ async def create_bookmark(data: BookmarkCreate, request: Request):
async def update_bookmark(bookmark_id: str, data: BookmarkUpdate, request: Request):
db = get_session()
try:
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
parsed_id = parse_uuid(bookmark_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Bookmark not found")
bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first()
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
@@ -155,8 +173,9 @@ async def update_bookmark(bookmark_id: str, data: BookmarkUpdate, request: Reque
db.commit()
db.refresh(bookmark)
user_id = get_current_user_id(request)
log_audit(db, "update", "Bookmark", bookmark_id, user_id, old_value=old_value, new_value=bookmark.to_dict())
username = get_current_user_id(request)
user = db.query(User).filter(User.username == username).first() if username else None
log_audit(db, "update", "Bookmark", bookmark.id, user.id if user else None, old_value=old_value, new_value=bookmark.to_dict())
return bookmark.to_dict()
finally:
db.close()
@@ -166,16 +185,20 @@ async def update_bookmark(bookmark_id: str, data: BookmarkUpdate, request: Reque
async def delete_bookmark(bookmark_id: str, request: Request):
db = get_session()
try:
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
parsed_id = parse_uuid(bookmark_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Bookmark not found")
bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first()
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
old_value = bookmark.to_dict()
db.delete(bookmark)
db.commit()
user_id = get_current_user_id(request)
log_audit(db, "delete", "Bookmark", bookmark_id, user_id, old_value=old_value)
return {"message": "Bookmark deleted successfully", "deleted_id": bookmark_id}
username = get_current_user_id(request)
user = db.query(User).filter(User.username == username).first() if username else None
log_audit(db, "delete", "Bookmark", bookmark.id, user.id if user else None, old_value=old_value)
return {"message": "Bookmark deleted successfully", "deleted_id": str(bookmark.id)}
finally:
db.close()
@@ -188,7 +211,10 @@ class TagList(BaseModel):
async def add_tags(bookmark_id: str, data: TagList, request: Request):
db = get_session()
try:
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
parsed_id = parse_uuid(bookmark_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Bookmark not found")
bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first()
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
@@ -211,7 +237,10 @@ async def add_tags(bookmark_id: str, data: TagList, request: Request):
async def remove_tags(bookmark_id: str, data: TagList, request: Request):
db = get_session()
try:
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
parsed_id = parse_uuid(bookmark_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Bookmark not found")
bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first()
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")