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

View File

@@ -5,8 +5,9 @@ LinkSyncServer - Authentication Endpoints
import hashlib
import os
import secrets
import uuid
from datetime import datetime, timedelta
from typing import Optional
from typing import List, Optional
import bcrypt
import jwt
@@ -178,6 +179,32 @@ async def logout():
return {"message": "Logged out successfully"}
@router.get("/api-keys", response_model=List[dict])
async def list_api_keys(
current_user: dict = Depends(get_current_user),
):
db = get_session()
try:
user = db.query(User).filter(User.username == current_user["username"]).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
return [
{
"id": str(key.id),
"key_id": str(key.id),
"name": key.name,
"is_active": key.is_active,
"expires_at": key.expires_at.isoformat() if key.expires_at else None,
"created_at": key.created_at.isoformat() if key.created_at else None,
}
for key in api_keys
]
finally:
db.close()
@router.post("/api-key", response_model=ApiKeyResponse)
async def create_api_key(
name: str = "default",
@@ -204,7 +231,7 @@ async def create_api_key(
return {
"api_key": raw_key,
"key_id": api_key.id,
"key_id": str(api_key.id),
"name": api_key.name,
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
}
@@ -223,14 +250,19 @@ async def get_api_key_info(
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
parsed_key_id = uuid.UUID(key_id)
except (ValueError, AttributeError):
raise HTTPException(status_code=404, detail="API key not found")
api_key = db.query(ApiKey).filter(
ApiKey.id == key_id, ApiKey.user_id == user.id
ApiKey.id == parsed_key_id, ApiKey.user_id == user.id
).first()
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
return {
"key_id": api_key.id,
"key_id": str(api_key.id),
"name": api_key.name,
"is_active": api_key.is_active,
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
@@ -251,8 +283,13 @@ async def delete_api_key(
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
parsed_key_id = uuid.UUID(key_id)
except (ValueError, AttributeError):
raise HTTPException(status_code=404, detail="API key not found")
api_key = db.query(ApiKey).filter(
ApiKey.id == key_id, ApiKey.user_id == user.id
ApiKey.id == parsed_key_id, ApiKey.user_id == user.id
).first()
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")

View File

@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy import and_, or_
from models.base import AuditLog, Bookmark, Collection, CollectionBookmark, get_session
from models.base import AuditLog, Bookmark, Collection, CollectionBookmark, User, get_session
from queries.executor import execute_query
router = APIRouter(prefix="/api/collections", tags=["Collections"])
@@ -49,6 +49,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, entity_type, entity_id, user_id, old_value=None, new_value=None):
try:
audit = AuditLog(
@@ -73,14 +80,18 @@ async def list_collections(
):
db = get_session()
try:
user_id = get_current_user_id(request) if request else None
username = get_current_user_id(request) if request else None
query = db.query(Collection)
if user_id:
if username:
user = db.query(User).filter(User.username == username).first()
if user:
query = query.filter(
or_(Collection.created_by == user_id, Collection.is_public == True)
or_(Collection.created_by == user.id, Collection.is_public == True)
)
else:
query = query.filter(Collection.is_public == True)
else:
query = query.filter(Collection.is_public == True)
collections = query.order_by(Collection.created_at.desc()).offset(offset).limit(limit).all()
return [c.to_dict() for c in collections]
finally:
@@ -91,14 +102,14 @@ async def list_collections(
async def get_collection(collection_id: str):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
collection = db.query(Collection).filter(Collection.id == parse_uuid(collection_id)).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
result = collection.to_dict()
if collection.query_type == "static":
links = (
db.query(CollectionBookmark)
.filter(CollectionBookmark.collection_id == collection_id)
.filter(CollectionBookmark.collection_id == parse_uuid(collection_id))
.all()
)
result["link_ids"] = [lb.bookmark_id for lb in links]
@@ -111,30 +122,40 @@ async def get_collection(collection_id: str):
async def create_collection(data: CollectionCreate, request: Request):
db = get_session()
try:
user_id = get_current_user_id(request)
if not user_id:
username = get_current_user_id(request)
if not username:
raise HTTPException(status_code=401, detail="Authentication required")
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
created_by_id = user.id
collection = Collection(
id=str(uuid.uuid4()),
id=uuid.uuid4(),
name=data.name,
description=data.description,
query_type=data.query_type,
query_expression=data.query_expression,
is_public=data.is_public,
created_by=user_id,
created_by=created_by_id,
)
db.add(collection)
db.flush()
if data.query_type == "static" and data.link_ids:
for link_id in data.link_ids:
cb = CollectionBookmark(collection_id=collection.id, bookmark_id=link_id)
try:
lid = uuid.UUID(link_id) if isinstance(link_id, str) else link_id
except (ValueError, AttributeError):
continue
cb = CollectionBookmark(collection_id=collection.id, bookmark_id=lid)
db.add(cb)
db.commit()
db.refresh(collection)
log_audit(db, "create", "Collection", collection.id, user_id, new_value=collection.to_dict())
log_audit(db, "create", "Collection", collection.id, created_by_id, new_value=collection.to_dict())
return collection.to_dict()
finally:
db.close()
@@ -144,7 +165,7 @@ async def create_collection(data: CollectionCreate, request: Request):
async def update_collection(collection_id: str, data: CollectionUpdate, request: Request):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
collection = db.query(Collection).filter(Collection.id == parse_uuid(collection_id)).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
@@ -155,8 +176,9 @@ async def update_collection(collection_id: str, data: CollectionUpdate, request:
db.commit()
db.refresh(collection)
user_id = get_current_user_id(request)
log_audit(db, "update", "Collection", collection_id, user_id, old_value=old_value, new_value=collection.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", "Collection", collection.id, user.id if user else None, old_value=old_value, new_value=collection.to_dict())
return collection.to_dict()
finally:
db.close()
@@ -166,20 +188,21 @@ async def update_collection(collection_id: str, data: CollectionUpdate, request:
async def delete_collection(collection_id: str, request: Request):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
collection = db.query(Collection).filter(Collection.id == parse_uuid(collection_id)).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
old_value = collection.to_dict()
if collection.query_type == "static":
db.query(CollectionBookmark).filter(
CollectionBookmark.collection_id == collection_id
CollectionBookmark.collection_id == collection.id
).delete()
db.delete(collection)
db.commit()
user_id = get_current_user_id(request)
log_audit(db, "delete", "Collection", collection_id, user_id, old_value=old_value)
return {"message": "Collection deleted successfully", "deleted_id": collection_id}
username = get_current_user_id(request)
user = db.query(User).filter(User.username == username).first() if username else None
log_audit(db, "delete", "Collection", collection.id, user.id if user else None, old_value=old_value)
return {"message": "Collection deleted successfully", "deleted_id": str(collection.id)}
finally:
db.close()
@@ -188,7 +211,7 @@ async def delete_collection(collection_id: str, request: Request):
async def refresh_collection(collection_id: str):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
collection = db.query(Collection).filter(Collection.id == parse_uuid(collection_id)).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
if collection.query_type != "dynamic":
@@ -212,7 +235,10 @@ async def refresh_collection(collection_id: str):
async def add_links_to_collection(collection_id: str, link_ids: List[str]):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
parsed_cid = parse_uuid(collection_id)
if parsed_cid is None:
raise HTTPException(status_code=404, detail="Collection not found")
collection = db.query(Collection).filter(Collection.id == parsed_cid).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
if collection.query_type != "static":
@@ -221,13 +247,17 @@ async def add_links_to_collection(collection_id: str, link_ids: List[str]):
existing = {
cb.bookmark_id
for cb in db.query(CollectionBookmark)
.filter(CollectionBookmark.collection_id == collection_id)
.filter(CollectionBookmark.collection_id == parsed_cid)
.all()
}
added = 0
for link_id in link_ids:
if link_id not in existing:
db.add(CollectionBookmark(collection_id=collection_id, bookmark_id=link_id))
try:
lid = uuid.UUID(link_id) if isinstance(link_id, str) else link_id
except (ValueError, AttributeError):
continue
if lid not in existing:
db.add(CollectionBookmark(collection_id=parsed_cid, bookmark_id=lid))
added += 1
db.commit()
@@ -240,15 +270,25 @@ async def add_links_to_collection(collection_id: str, link_ids: List[str]):
async def remove_links_from_collection(collection_id: str, link_ids: List[str]):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
parsed_cid = parse_uuid(collection_id)
if parsed_cid is None:
raise HTTPException(status_code=404, detail="Collection not found")
collection = db.query(Collection).filter(Collection.id == parsed_cid).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
parsed_link_ids = []
for link_id in link_ids:
try:
parsed_link_ids.append(uuid.UUID(link_id) if isinstance(link_id, str) else link_id)
except (ValueError, AttributeError):
continue
removed = (
db.query(CollectionBookmark)
.filter(
CollectionBookmark.collection_id == collection_id,
CollectionBookmark.bookmark_id.in_(link_ids),
CollectionBookmark.collection_id == parsed_cid,
CollectionBookmark.bookmark_id.in_(parsed_link_ids),
)
.delete(synchronize_session=False)
)

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")

View File

@@ -36,11 +36,18 @@ class SyncResponse(BaseModel):
synced_count: int
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 apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData]) -> SyncResponse:
db = get_session()
try:
actions = []
server_bookmarks = {b.id: b for b in db.query(Bookmark).all()}
server_bookmarks = {str(b.id): b for b in db.query(Bookmark).all()}
for bm in browser_bookmarks:
existing = server_bookmarks.get(bm.id)
@@ -58,8 +65,12 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData
existing.is_bookmarked = bm.is_bookmarked
actions.append({"type": "update", "link_id": bm.id})
else:
try:
bm_uuid = uuid.UUID(bm.id)
except (ValueError, AttributeError):
bm_uuid = uuid.uuid4()
new_bm = Bookmark(
id=bm.id,
id=bm_uuid,
url=bm.url,
title=bm.title,
description=bm.description,
@@ -86,8 +97,12 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData
existing.is_bookmarked = bm.is_bookmarked
actions.append({"type": "update", "link_id": bm.id})
else:
try:
bm_uuid = uuid.UUID(bm.id)
except (ValueError, AttributeError):
bm_uuid = uuid.uuid4()
new_bm = Bookmark(
id=bm.id,
id=bm_uuid,
url=bm.url,
title=bm.title,
description=bm.description,
@@ -103,8 +118,12 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData
elif sync_config.mode == "server-authoritative":
if not existing:
try:
bm_uuid = uuid.UUID(bm.id)
except (ValueError, AttributeError):
bm_uuid = uuid.uuid4()
new_bm = Bookmark(
id=bm.id,
id=bm_uuid,
url=bm.url,
title=bm.title,
description=bm.description,
@@ -122,7 +141,9 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData
browser_ids = {bm.id for bm in browser_bookmarks}
for server_id in server_bookmarks:
if server_id not in browser_ids:
db.query(Bookmark).filter(Bookmark.id == server_id).delete()
parsed_id = parse_uuid(server_id)
if parsed_id:
db.query(Bookmark).filter(Bookmark.id == parsed_id).delete()
actions.append({"type": "delete", "link_id": server_id})
db.commit()
@@ -155,7 +176,10 @@ async def get_collection(collection_id: str):
db = get_session()
try:
from models.base import Collection
collection = db.query(Collection).filter(Collection.id == collection_id).first()
parsed_id = parse_uuid(collection_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Collection not found")
collection = db.query(Collection).filter(Collection.id == parsed_id).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
return collection.to_dict()
@@ -168,7 +192,10 @@ async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]):
db = get_session()
try:
from models.base import Collection, CollectionBookmark
collection = db.query(Collection).filter(Collection.id == collection_id).first()
parsed_cid = parse_uuid(collection_id)
if parsed_cid is None:
raise HTTPException(status_code=404, detail="Collection not found")
collection = db.query(Collection).filter(Collection.id == parsed_cid).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
if collection.query_type != "static":
@@ -176,16 +203,19 @@ async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]):
added = 0
for bid in bookmark_ids:
parsed_bid = parse_uuid(bid)
if parsed_bid is None:
continue
existing = (
db.query(CollectionBookmark)
.filter(
CollectionBookmark.collection_id == collection_id,
CollectionBookmark.bookmark_id == bid,
CollectionBookmark.collection_id == parsed_cid,
CollectionBookmark.bookmark_id == parsed_bid,
)
.first()
)
if not existing:
db.add(CollectionBookmark(collection_id=collection_id, bookmark_id=bid))
db.add(CollectionBookmark(collection_id=parsed_cid, bookmark_id=parsed_bid))
added += 1
db.commit()
@@ -199,12 +229,15 @@ async def delete_collection(collection_id: str):
db = get_session()
try:
from models.base import Collection, CollectionBookmark
collection = db.query(Collection).filter(Collection.id == collection_id).first()
parsed_cid = parse_uuid(collection_id)
if parsed_cid is None:
raise HTTPException(status_code=404, detail="Collection not found")
collection = db.query(Collection).filter(Collection.id == parsed_cid).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
db.query(CollectionBookmark).filter(
CollectionBookmark.collection_id == collection_id
CollectionBookmark.collection_id == parsed_cid
).delete()
db.delete(collection)
db.commit()

View File

@@ -28,6 +28,13 @@ class TagUpdate(BaseModel):
description: Optional[str] = Field(None, max_length=500)
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("/", response_model=List[dict])
async def list_tags(
page: int = Query(1, ge=1),
@@ -58,7 +65,10 @@ async def tag_count():
async def get_tag(tag_id: str):
db = get_session()
try:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
parsed_id = parse_uuid(tag_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Tag not found")
tag = db.query(Tag).filter(Tag.id == parsed_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
return tag.to_dict()
@@ -86,7 +96,10 @@ async def get_tag_links(
):
db = get_session()
try:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
parsed_id = parse_uuid(tag_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Tag not found")
tag = db.query(Tag).filter(Tag.id == parsed_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
@@ -112,7 +125,7 @@ async def create_tag(data: TagCreate):
raise HTTPException(status_code=400, detail="Tag already exists")
tag = Tag(
id=str(uuid.uuid4()),
id=uuid.uuid4(),
name=data.name,
color=data.color,
description=data.description,
@@ -129,7 +142,10 @@ async def create_tag(data: TagCreate):
async def update_tag(tag_id: str, data: TagUpdate):
db = get_session()
try:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
parsed_id = parse_uuid(tag_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Tag not found")
tag = db.query(Tag).filter(Tag.id == parsed_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
@@ -148,7 +164,10 @@ async def update_tag(tag_id: str, data: TagUpdate):
async def delete_tag(tag_id: str):
db = get_session()
try:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
parsed_id = parse_uuid(tag_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Tag not found")
tag = db.query(Tag).filter(Tag.id == parsed_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")

View File

@@ -3,10 +3,11 @@ LinkSyncServer - Main Application
"""
import os
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from contextlib import asynccontextmanager
from api.routes import router as api_router
@@ -51,5 +52,35 @@ def health():
@app.get("/")
def index(request):
return templates.TemplateResponse("index.html", {"request": request})
def index():
return RedirectResponse(url="/login")
@app.get("/login")
def login_page(request: Request):
return templates.TemplateResponse("login.html", {"request": request})
@app.get("/dashboard")
def dashboard(request: Request):
return templates.TemplateResponse("dashboard.html", {"request": request})
@app.get("/links")
def links_page(request: Request):
return templates.TemplateResponse("links.html", {"request": request})
@app.get("/collections")
def collections_page(request: Request):
return templates.TemplateResponse("collections.html", {"request": request})
@app.get("/api-keys")
def apikeys_page(request: Request):
return templates.TemplateResponse("apikeys.html", {"request": request})
@app.get("/admin")
def admin_page(request: Request):
return templates.TemplateResponse("admin.html", {"request": request})

View File

@@ -23,7 +23,8 @@ CREATE TABLE api_keys (
name VARCHAR(100),
expires_at TIMESTAMP WITH TIME ZONE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Tags table
@@ -92,7 +93,8 @@ CREATE TABLE audit_log (
old_value JSONB,
new_value JSONB,
ip_address INET,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Create audit log index

View File

@@ -6,7 +6,7 @@ services:
ports:
- "5000:5000"
environment:
- DATABASE_URL=postgresql://linksync:${DB_PASSWORD}@db:5432/linksync
- DATABASE_URL=postgresql://linksync:${DB_PASSWORD:-password}@db:5432/linksync
- SECRET_KEY=${SECRET_KEY:-$(openssl rand -base64 32)}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}

View File

@@ -18,6 +18,7 @@ from sqlalchemy import (
JSON,
text,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, sessionmaker
from sqlalchemy.sql import func
@@ -58,7 +59,7 @@ class User(Base, TimestampMixin):
"""User model for authentication."""
__tablename__ = "users"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String(100), unique=True, nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
@@ -72,7 +73,7 @@ class User(Base, TimestampMixin):
def to_dict(self):
return {
"id": self.id,
"id": str(self.id),
"username": self.username,
"email": self.email,
"role": self.role,
@@ -86,8 +87,8 @@ class ApiKey(Base, TimestampMixin):
"""API Key for authentication."""
__tablename__ = "api_keys"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
key_hash = Column(String(255), nullable=False, unique=True)
name = Column(String(100))
expires_at = Column(DateTime)
@@ -100,14 +101,14 @@ class Tag(Base, TimestampMixin):
"""Tag model for bookmarks."""
__tablename__ = "tags"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), unique=True, nullable=False, index=True)
color = Column(String(7))
description = Column(Text)
def to_dict(self):
return {
"id": self.id,
"id": str(self.id),
"name": self.name,
"color": self.color,
"description": self.description,
@@ -120,7 +121,7 @@ class Bookmark(Base, TimestampMixin):
"""Bookmark/Link model with Firefox-compatible fields."""
__tablename__ = "links"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
url = Column(String(2048), nullable=False, index=True)
title = Column(String(255), nullable=False)
description = Column(Text)
@@ -130,8 +131,8 @@ class Bookmark(Base, TimestampMixin):
path = Column(String(512), nullable=True)
visit_count = Column(Integer, default=0)
is_bookmarked = Column(Boolean, default=False)
source_set_id = Column(String(36), ForeignKey("links.id"))
user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
source_set_id = Column(UUID(as_uuid=True), ForeignKey("links.id"))
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
user = relationship("User", back_populates="bookmarks")
source_set = relationship("Bookmark", remote_side=[id])
@@ -139,7 +140,7 @@ class Bookmark(Base, TimestampMixin):
def to_dict(self):
return {
"id": self.id,
"id": str(self.id),
"url": self.url,
"title": self.title,
"description": self.description,
@@ -149,8 +150,8 @@ class Bookmark(Base, TimestampMixin):
"path": self.path,
"visit_count": self.visit_count,
"is_bookmarked": self.is_bookmarked,
"source_set_id": self.source_set_id,
"user_id": self.user_id,
"source_set_id": str(self.source_set_id) if self.source_set_id else None,
"user_id": str(self.user_id) if self.user_id else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@@ -160,26 +161,26 @@ class Collection(Base, TimestampMixin):
"""Collection model for bookmark sets."""
__tablename__ = "collections"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(200), nullable=False)
description = Column(Text)
query_type = Column(String(20), nullable=False)
query_expression = Column(JSON)
is_public = Column(Boolean, default=False)
created_by = Column(String(36), ForeignKey("users.id"), nullable=False)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
user = relationship("User", back_populates="collections")
collection_bookmarks = relationship("CollectionBookmark", back_populates="collection")
def to_dict(self):
return {
"id": self.id,
"id": str(self.id),
"name": self.name,
"description": self.description,
"query_type": self.query_type,
"query_expression": self.query_expression,
"is_public": self.is_public,
"created_by": self.created_by,
"created_by": str(self.created_by),
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@@ -187,10 +188,10 @@ class Collection(Base, TimestampMixin):
class CollectionBookmark(Base, TimestampMixin):
"""Junction table for static collections."""
__tablename__ = "collection_bookmarks"
__tablename__ = "collection_links"
collection_id = Column(String(36), ForeignKey("collections.id"), primary_key=True)
bookmark_id = Column(String(36), ForeignKey("links.id"), primary_key=True)
collection_id = Column(UUID(as_uuid=True), ForeignKey("collections.id"), primary_key=True)
bookmark_id = Column(UUID(as_uuid=True), ForeignKey("links.id"), primary_key=True)
collection = relationship("Collection", back_populates="collection_bookmarks")
bookmark = relationship("Bookmark", back_populates="collection_bookmarks")
@@ -200,11 +201,11 @@ class AuditLog(Base, TimestampMixin):
"""Audit log for tracking changes."""
__tablename__ = "audit_log"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
action = Column(String(100), nullable=False)
entity_type = Column(String(50), nullable=False)
entity_id = Column(String(36))
entity_id = Column(UUID(as_uuid=True))
old_value = Column(JSON)
new_value = Column(JSON)
ip_address = Column(String(45))

View File

@@ -9,6 +9,7 @@ psycopg2-binary==2.9.9
alembic==1.13.1
# Authentication
PyJWT==2.8.0
python-jose[cryptography]==3.3.0
pycryptodome==3.19.0
bcrypt==4.1.2

View File

@@ -9,8 +9,10 @@
--border: #e2e8f0;
--success: #22c55e;
--error: #ef4444;
--warning: #f59e0b;
--radius: 8px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15);
}
* {
@@ -63,6 +65,598 @@ body {
color: var(--primary);
}
.nav-user {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-user span {
font-weight: 500;
color: var(--text);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 1.25rem;
border-radius: var(--radius);
text-decoration: none;
font-weight: 500;
font-size: 0.875rem;
border: none;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
}
.btn-secondary {
background: var(--secondary);
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #475569;
}
.btn-danger {
background: var(--error);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
}
.btn-outline {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
}
.btn-outline:hover {
background: var(--bg);
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
.btn-full {
width: 100%;
}
.btn-icon {
padding: 0.375rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-muted);
border-radius: 4px;
}
.btn-icon:hover {
background: var(--bg);
color: var(--text);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h1 {
font-size: 1.75rem;
}
.search-bar {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.search-bar input {
flex: 1;
padding: 0.625rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.875rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.375rem;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.875rem;
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
.form-group textarea {
resize: vertical;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: auto;
}
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.error-message {
background: #fef2f2;
color: var(--error);
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.success-message {
background: #f0fdf4;
color: var(--success);
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.loading {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-muted);
}
.empty-state p {
margin-bottom: 1rem;
}
/* Login Page */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-container {
width: 100%;
max-width: 400px;
padding: 1rem;
}
.login-card {
background: var(--surface);
padding: 2.5rem;
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.login-card h1 {
text-align: center;
color: var(--primary);
margin-bottom: 0.5rem;
}
.login-subtitle {
text-align: center;
color: var(--text-muted);
margin-bottom: 2rem;
}
/* Dashboard */
.dashboard-header {
margin-bottom: 2rem;
}
.dashboard-header h1 {
font-size: 1.75rem;
margin-bottom: 0.25rem;
}
.welcome-text {
color: var(--text-muted);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2.5rem;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
text-align: center;
box-shadow: var(--shadow);
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--primary);
}
.stat-label {
color: var(--text-muted);
font-size: 0.875rem;
margin: 0.25rem 0 0.75rem;
}
.stat-link {
color: var(--primary);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
}
.stat-link:hover {
text-decoration: underline;
}
.quick-actions h2,
.admin-quick h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
}
.action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.action-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
text-align: center;
text-decoration: none;
transition: box-shadow 0.2s, border-color 0.2s;
}
.action-card:hover {
box-shadow: var(--shadow-lg);
border-color: var(--primary);
}
.action-icon {
display: block;
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.action-label {
color: var(--text);
font-weight: 500;
font-size: 0.875rem;
}
/* Tables */
.data-table {
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.data-table table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
background: var(--bg);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.data-table td {
font-size: 0.875rem;
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table tr:hover td {
background: #f8fafc;
}
.data-table .actions {
display: flex;
gap: 0.5rem;
}
.data-table .truncate {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Links page */
.links-table .link-url {
color: var(--primary);
text-decoration: none;
}
.links-table .link-url:hover {
text-decoration: underline;
}
.links-table .tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.links-table .tag {
background: #e0e7ff;
color: #3730a3;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
}
/* Collections grid */
.collections-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.collection-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow);
}
.collection-card h3 {
margin-bottom: 0.5rem;
color: var(--text);
}
.collection-card p {
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
.collection-card .meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.collection-card .badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-static {
background: #dbeafe;
color: #1e40af;
}
.badge-dynamic {
background: #fef3c7;
color: #92400e;
}
.badge-public {
background: #dcfce7;
color: #166534;
}
.badge-private {
background: #f1f5f9;
color: #475569;
}
.collection-card .actions {
display: flex;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
/* API Keys */
.api-keys-table .key-value {
font-family: "Fira Code", "Cascadia Code", monospace;
font-size: 0.75rem;
color: var(--text-muted);
}
.api-keys-table .status-active {
color: var(--success);
font-weight: 500;
}
.api-keys-table .status-inactive {
color: var(--error);
font-weight: 500;
}
.key-result {
margin: 1rem 0;
}
.key-display {
display: flex;
gap: 0.5rem;
align-items: center;
background: var(--bg);
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin: 0.75rem 0;
}
.key-display code {
flex: 1;
font-family: "Fira Code", "Cascadia Code", monospace;
font-size: 0.875rem;
word-break: break-all;
}
/* Users table */
.users-table .role-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.role-admin {
background: #fef3c7;
color: #92400e;
}
.role-user {
background: #e0e7ff;
color: #3730a3;
}
/* Modal */
.modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background: var(--surface);
border-radius: var(--radius);
padding: 1.5rem;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-content.modal-sm {
max-width: 400px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.25rem;
}
.modal-header h2 {
font-size: 1.25rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-muted);
padding: 0.25rem;
line-height: 1;
}
.modal-close:hover {
color: var(--text);
}
/* Hero (legacy) */
.hero {
text-align: center;
padding: 4rem 1rem;
@@ -85,33 +679,6 @@ body {
justify-content: center;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
border-radius: var(--radius);
text-decoration: none;
font-weight: 500;
transition: background 0.2s;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
}
.btn-secondary {
background: var(--secondary);
color: white;
}
.btn-secondary:hover {
background: #475569;
}
.section {
margin: 3rem 0;
}
@@ -207,4 +774,15 @@ body {
.hero-actions {
flex-direction: column;
}
.page-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.data-table {
overflow-x: auto;
}
.data-table table {
min-width: 600px;
}
}

View File

@@ -0,0 +1,180 @@
document.addEventListener('DOMContentLoaded', function() {
const usersList = document.getElementById('users-list');
const modal = document.getElementById('user-modal');
const deleteModal = document.getElementById('delete-user-modal');
const form = document.getElementById('user-form');
let deleteTargetId = null;
async function loadUsers() {
try {
const users = await LinkSync.getUsers();
renderUsers(Array.isArray(users) ? users : []);
} catch (err) {
usersList.innerHTML = `<div class="empty-state"><p>Failed to load users: ${err.message}</p></div>`;
}
}
function renderUsers(users) {
if (!users || users.length === 0) {
usersList.innerHTML = '<div class="empty-state"><p>No users found.</p></div>';
return;
}
usersList.innerHTML = `
<div class="data-table">
<table>
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${users.map(user => `
<tr>
<td>${escapeHtml(user.username)}</td>
<td>${escapeHtml(user.email)}</td>
<td><span class="role-badge role-${user.role}">${user.role}</span></td>
<td>${user.is_active ? 'Active' : 'Inactive'}</td>
<td>${formatDate(user.created_at)}</td>
<td class="actions">
<button class="btn-icon" data-action="edit" data-id="${user.id}" title="Edit">&#9998;</button>
<button class="btn-icon" data-action="delete" data-id="${user.id}" title="Delete">&#128465;</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
usersList.querySelectorAll('[data-action="edit"]').forEach(btn => {
btn.addEventListener('click', () => editUser(btn.dataset.id));
});
usersList.querySelectorAll('[data-action="delete"]').forEach(btn => {
btn.addEventListener('click', () => confirmDelete(btn.dataset.id));
});
}
function openUserModal(user = null) {
document.getElementById('user-modal-title').textContent = user ? 'Edit User' : 'Create User';
document.getElementById('user-id').value = user ? user.id : '';
document.getElementById('user-username').value = user ? user.username : '';
document.getElementById('user-username').disabled = !!user;
document.getElementById('user-email').value = user ? user.email : '';
document.getElementById('user-password').value = '';
document.getElementById('user-password').required = !user;
document.getElementById('user-password').placeholder = user ? 'Leave blank to keep current' : '';
document.getElementById('user-role').value = user ? user.role : 'user';
document.getElementById('user-active').checked = user ? user.is_active : true;
modal.style.display = 'flex';
}
async function editUser(id) {
try {
const users = await LinkSync.getUsers();
const user = (Array.isArray(users) ? users : []).find(u => u.id === id);
if (user) openUserModal(user);
} catch (err) {
alert('Failed to load user details');
}
}
function confirmDelete(id) {
deleteTargetId = id;
deleteModal.style.display = 'flex';
}
function closeModal() {
modal.style.display = 'none';
form.reset();
document.getElementById('user-username').disabled = false;
}
function closeDeleteModal() {
deleteModal.style.display = 'none';
deleteTargetId = null;
}
document.getElementById('new-user-btn').addEventListener('click', () => openUserModal());
document.getElementById('user-modal-close').addEventListener('click', closeModal);
document.getElementById('user-cancel-btn').addEventListener('click', closeModal);
document.getElementById('delete-user-cancel-btn').addEventListener('click', closeDeleteModal);
modal.querySelector('.modal-overlay').addEventListener('click', closeModal);
deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal);
form.addEventListener('submit', async function(e) {
e.preventDefault();
const saveBtn = document.getElementById('user-save-btn');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
const id = document.getElementById('user-id').value;
const data = {
username: document.getElementById('user-username').value,
email: document.getElementById('user-email').value,
role: document.getElementById('user-role').value,
is_active: document.getElementById('user-active').checked,
};
const password = document.getElementById('user-password').value;
if (password) data.password = password;
try {
if (id) {
await LinkSync.updateUser(id, data);
} else {
if (!password) {
alert('Password is required for new users');
saveBtn.disabled = false;
saveBtn.textContent = 'Save';
return;
}
await LinkSync.createUser(data);
}
closeModal();
loadUsers();
} catch (err) {
alert('Failed to save user: ' + err.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save';
}
});
document.getElementById('confirm-delete-user-btn').addEventListener('click', async function() {
if (!deleteTargetId) return;
try {
await LinkSync.deleteUser(deleteTargetId);
closeDeleteModal();
loadUsers();
} catch (err) {
alert('Failed to delete user: ' + err.message);
}
});
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('action') === 'new-user') {
openUserModal();
}
loadUsers();
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString();
}
});

View File

@@ -0,0 +1,156 @@
document.addEventListener('DOMContentLoaded', function() {
const keysList = document.getElementById('api-keys-list');
const keyModal = document.getElementById('key-modal');
const keyResultModal = document.getElementById('key-result-modal');
const deleteModal = document.getElementById('delete-key-modal');
const keyForm = document.getElementById('key-form');
let deleteTargetId = null;
async function loadKeys() {
try {
const keys = await LinkSync.getApiKeys();
renderKeys(Array.isArray(keys) ? keys : []);
} catch (err) {
keysList.innerHTML = `<div class="empty-state"><p>Failed to load API keys: ${err.message}</p></div>`;
}
}
function renderKeys(keys) {
if (!keys || keys.length === 0) {
keysList.innerHTML = '<div class="empty-state"><p>No API keys found.</p><button class="btn btn-primary" id="empty-add-key-btn">+ Create your first API key</button></div>';
document.getElementById('empty-add-key-btn').addEventListener('click', openKeyModal);
return;
}
keysList.innerHTML = `
<div class="data-table">
<table>
<thead>
<tr>
<th>Name</th>
<th>Key ID</th>
<th>Status</th>
<th>Created</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${keys.map(key => `
<tr>
<td>${escapeHtml(key.name)}</td>
<td class="key-value">${escapeHtml(key.key_id || key.id)}</td>
<td class="${key.is_active ? 'status-active' : 'status-inactive'}">${key.is_active ? 'Active' : 'Inactive'}</td>
<td>${formatDate(key.created_at)}</td>
<td>${key.expires_at ? formatDate(key.expires_at) : 'Never'}</td>
<td class="actions">
<button class="btn-icon" data-action="delete" data-id="${key.key_id || key.id}" title="Delete">&#128465;</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
keysList.querySelectorAll('[data-action="delete"]').forEach(btn => {
btn.addEventListener('click', () => confirmDeleteKey(btn.dataset.id));
});
}
function openKeyModal() {
document.getElementById('key-name').value = '';
keyModal.style.display = 'flex';
}
function closeKeyModal() {
keyModal.style.display = 'none';
keyForm.reset();
}
function closeKeyResultModal() {
keyResultModal.style.display = 'none';
}
function closeDeleteModal() {
deleteModal.style.display = 'none';
deleteTargetId = null;
}
document.getElementById('new-key-btn').addEventListener('click', openKeyModal);
document.getElementById('key-modal-close').addEventListener('click', closeKeyModal);
document.getElementById('key-cancel-btn').addEventListener('click', closeKeyModal);
document.getElementById('key-result-close').addEventListener('click', closeKeyResultModal);
document.getElementById('key-done-btn').addEventListener('click', closeKeyResultModal);
document.getElementById('delete-key-cancel-btn').addEventListener('click', closeDeleteModal);
keyModal.querySelector('.modal-overlay').addEventListener('click', closeKeyModal);
keyResultModal.querySelector('.modal-overlay').addEventListener('click', closeKeyResultModal);
deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal);
keyForm.addEventListener('submit', async function(e) {
e.preventDefault();
const saveBtn = document.getElementById('key-save-btn');
saveBtn.disabled = true;
saveBtn.textContent = 'Creating...';
const name = document.getElementById('key-name').value;
try {
const result = await LinkSync.createApiKey(name);
closeKeyModal();
document.getElementById('new-key-value').textContent = result.api_key;
keyResultModal.style.display = 'flex';
loadKeys();
} catch (err) {
alert('Failed to create API key: ' + err.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Create';
}
});
document.getElementById('copy-key-btn').addEventListener('click', function() {
const key = document.getElementById('new-key-value').textContent;
navigator.clipboard.writeText(key).then(() => {
this.textContent = 'Copied!';
setTimeout(() => { this.textContent = 'Copy'; }, 2000);
});
});
function confirmDeleteKey(id) {
deleteTargetId = id;
deleteModal.style.display = 'flex';
}
document.getElementById('confirm-delete-key-btn').addEventListener('click', async function() {
if (!deleteTargetId) return;
try {
await LinkSync.deleteApiKey(deleteTargetId);
closeDeleteModal();
loadKeys();
} catch (err) {
alert('Failed to delete API key: ' + err.message);
}
});
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('action') === 'new') {
openKeyModal();
}
loadKeys();
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString();
}
});

View File

@@ -0,0 +1,144 @@
document.addEventListener('DOMContentLoaded', function() {
const collectionsList = document.getElementById('collections-list');
const modal = document.getElementById('collection-modal');
const deleteModal = document.getElementById('delete-collection-modal');
const form = document.getElementById('collection-form');
let deleteTargetId = null;
async function loadCollections() {
try {
const collections = await LinkSync.getCollections();
renderCollections(Array.isArray(collections) ? collections : []);
} catch (err) {
collectionsList.innerHTML = `<div class="empty-state"><p>Failed to load collections: ${err.message}</p></div>`;
}
}
function renderCollections(collections) {
if (!collections || collections.length === 0) {
collectionsList.innerHTML = '<div class="empty-state"><p>No collections found.</p><button class="btn btn-primary" id="empty-add-col-btn">+ Create your first collection</button></div>';
document.getElementById('empty-add-col-btn').addEventListener('click', openCollectionModal);
return;
}
collectionsList.innerHTML = collections.map(col => `
<div class="collection-card">
<h3>${escapeHtml(col.name)}</h3>
<p>${escapeHtml(col.description || 'No description')}</p>
<div class="meta">
<span class="badge badge-${col.query_type}">${col.query_type}</span>
<span class="badge ${col.is_public ? 'badge-public' : 'badge-private'}">${col.is_public ? 'Public' : 'Private'}</span>
</div>
<div class="actions">
<button class="btn btn-sm btn-outline" data-action="edit" data-id="${col.id}">Edit</button>
<button class="btn btn-sm btn-danger" data-action="delete" data-id="${col.id}">Delete</button>
</div>
</div>
`).join('');
collectionsList.querySelectorAll('[data-action="edit"]').forEach(btn => {
btn.addEventListener('click', () => editCollection(btn.dataset.id));
});
collectionsList.querySelectorAll('[data-action="delete"]').forEach(btn => {
btn.addEventListener('click', () => confirmDeleteCollection(btn.dataset.id));
});
}
function openCollectionModal(col = null) {
document.getElementById('collection-modal-title').textContent = col ? 'Edit Collection' : 'Create Collection';
document.getElementById('collection-id').value = col ? col.id : '';
document.getElementById('collection-name').value = col ? col.name : '';
document.getElementById('collection-description').value = col ? (col.description || '') : '';
document.getElementById('collection-type').value = col ? col.query_type : 'static';
document.getElementById('collection-public').checked = col ? col.is_public : false;
modal.style.display = 'flex';
}
async function editCollection(id) {
try {
const collections = await LinkSync.getCollections();
const col = (Array.isArray(collections) ? collections : []).find(c => c.id === id);
if (col) openCollectionModal(col);
} catch (err) {
alert('Failed to load collection details');
}
}
function confirmDeleteCollection(id) {
deleteTargetId = id;
deleteModal.style.display = 'flex';
}
function closeModal() {
modal.style.display = 'none';
form.reset();
}
function closeDeleteModal() {
deleteModal.style.display = 'none';
deleteTargetId = null;
}
document.getElementById('new-collection-btn').addEventListener('click', () => openCollectionModal());
document.getElementById('collection-modal-close').addEventListener('click', closeModal);
document.getElementById('collection-cancel-btn').addEventListener('click', closeModal);
document.getElementById('delete-collection-cancel-btn').addEventListener('click', closeDeleteModal);
modal.querySelector('.modal-overlay').addEventListener('click', closeModal);
deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal);
form.addEventListener('submit', async function(e) {
e.preventDefault();
const saveBtn = document.getElementById('collection-save-btn');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
const id = document.getElementById('collection-id').value;
const data = {
name: document.getElementById('collection-name').value,
description: document.getElementById('collection-description').value || null,
query_type: document.getElementById('collection-type').value,
is_public: document.getElementById('collection-public').checked,
};
try {
if (id) {
await LinkSync.updateCollection(id, data);
} else {
await LinkSync.createCollection(data);
}
closeModal();
loadCollections();
} catch (err) {
alert('Failed to save collection: ' + err.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save';
}
});
document.getElementById('confirm-delete-collection-btn').addEventListener('click', async function() {
if (!deleteTargetId) return;
try {
await LinkSync.deleteCollection(deleteTargetId);
closeDeleteModal();
loadCollections();
} catch (err) {
alert('Failed to delete collection: ' + err.message);
}
});
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('action') === 'new') {
openCollectionModal();
}
loadCollections();
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
});

View File

@@ -0,0 +1,27 @@
document.addEventListener('DOMContentLoaded', async function() {
const user = JSON.parse(localStorage.getItem('user') || 'null');
if (!user) {
window.location.href = '/login';
return;
}
document.getElementById('current-user').textContent = user.username;
if (user.role === 'admin') {
document.getElementById('admin-section').style.display = '';
}
try {
const [links, collections, keys] = await Promise.all([
LinkSync.getLinks({ limit: 1 }),
LinkSync.getCollections(),
LinkSync.getApiKeys(),
]);
document.getElementById('link-count').textContent = Array.isArray(links) ? links.length : 0;
document.getElementById('collection-count').textContent = Array.isArray(collections) ? collections.length : 0;
document.getElementById('api-key-count').textContent = Array.isArray(keys) ? keys.length : 0;
} catch (err) {
console.error('Failed to load stats:', err);
}
});

View File

@@ -0,0 +1,178 @@
document.addEventListener('DOMContentLoaded', function() {
const linksList = document.getElementById('links-list');
const modal = document.getElementById('link-modal');
const deleteModal = document.getElementById('delete-modal');
const form = document.getElementById('link-form');
const searchInput = document.getElementById('search-input');
let deleteTargetId = null;
async function loadLinks(search = '') {
try {
const links = await LinkSync.getLinks(search ? { search } : {});
renderLinks(Array.isArray(links) ? links : []);
} catch (err) {
linksList.innerHTML = `<div class="empty-state"><p>Failed to load links: ${err.message}</p></div>`;
}
}
function renderLinks(links) {
if (!links || links.length === 0) {
linksList.innerHTML = '<div class="empty-state"><p>No links found.</p><button class="btn btn-primary" id="empty-add-btn">+ Add your first link</button></div>';
document.getElementById('empty-add-btn').addEventListener('click', openModal);
return;
}
linksList.innerHTML = `
<div class="data-table">
<table>
<thead>
<tr>
<th>Title</th>
<th>URL</th>
<th>Tags</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${links.map(link => `
<tr>
<td>${escapeHtml(link.title)}</td>
<td class="truncate"><a href="${escapeHtml(link.url)}" target="_blank" class="link-url">${escapeHtml(link.url)}</a></td>
<td><div class="tags">${(link.tags || []).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div></td>
<td>${formatDate(link.created_at)}</td>
<td class="actions">
<button class="btn-icon" data-action="edit" data-id="${link.id}" title="Edit">&#9998;</button>
<button class="btn-icon" data-action="delete" data-id="${link.id}" title="Delete">&#128465;</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
linksList.querySelectorAll('[data-action="edit"]').forEach(btn => {
btn.addEventListener('click', () => editLink(btn.dataset.id));
});
linksList.querySelectorAll('[data-action="delete"]').forEach(btn => {
btn.addEventListener('click', () => confirmDelete(btn.dataset.id));
});
}
function openModal(link = null) {
document.getElementById('modal-title').textContent = link ? 'Edit Link' : 'Add Link';
document.getElementById('link-id').value = link ? link.id : '';
document.getElementById('link-url').value = link ? link.url : '';
document.getElementById('link-title').value = link ? link.title : '';
document.getElementById('link-description').value = link ? (link.description || '') : '';
document.getElementById('link-notes').value = link ? (link.notes || '') : '';
document.getElementById('link-tags').value = link ? (link.tags || []).join(', ') : '';
document.getElementById('link-favicon').value = link ? (link.favicon_url || '') : '';
document.getElementById('link-path').value = link ? (link.path || '') : '';
modal.style.display = 'flex';
}
async function editLink(id) {
try {
const links = await LinkSync.getLinks();
const link = (Array.isArray(links) ? links : []).find(l => l.id === id);
if (link) openModal(link);
} catch (err) {
alert('Failed to load link details');
}
}
function confirmDelete(id) {
deleteTargetId = id;
deleteModal.style.display = 'flex';
}
function closeModal() {
modal.style.display = 'none';
form.reset();
}
function closeDeleteModal() {
deleteModal.style.display = 'none';
deleteTargetId = null;
}
document.getElementById('new-link-btn').addEventListener('click', () => openModal());
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('cancel-btn').addEventListener('click', closeModal);
document.getElementById('delete-cancel-btn').addEventListener('click', closeDeleteModal);
modal.querySelector('.modal-overlay').addEventListener('click', closeModal);
deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal);
form.addEventListener('submit', async function(e) {
e.preventDefault();
const saveBtn = document.getElementById('save-btn');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
const id = document.getElementById('link-id').value;
const tagsRaw = document.getElementById('link-tags').value;
const data = {
url: document.getElementById('link-url').value,
title: document.getElementById('link-title').value,
description: document.getElementById('link-description').value || null,
notes: document.getElementById('link-notes').value || null,
tags: tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t) : [],
favicon_url: document.getElementById('link-favicon').value || null,
path: document.getElementById('link-path').value || null,
};
try {
if (id) {
await LinkSync.updateLink(id, data);
} else {
await LinkSync.createLink(data);
}
closeModal();
loadLinks(searchInput.value);
} catch (err) {
alert('Failed to save link: ' + err.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save';
}
});
document.getElementById('confirm-delete-btn').addEventListener('click', async function() {
if (!deleteTargetId) return;
try {
await LinkSync.deleteLink(deleteTargetId);
closeDeleteModal();
loadLinks(searchInput.value);
} catch (err) {
alert('Failed to delete link: ' + err.message);
}
});
document.getElementById('search-btn').addEventListener('click', () => loadLinks(searchInput.value));
searchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') loadLinks(searchInput.value);
});
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('action') === 'new') {
openModal();
}
loadLinks();
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString();
}
});

View File

@@ -15,7 +15,20 @@ document.addEventListener("DOMContentLoaded", function () {
headers,
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
let errorMsg = `HTTP ${response.status}`;
try {
const error = await response.json();
if (typeof error.detail === 'string') {
errorMsg = error.detail;
} else if (Array.isArray(error.detail)) {
errorMsg = error.detail.map(d => d.msg || d).join(', ');
} else if (error.detail && typeof error.detail === 'object') {
errorMsg = error.detail.detail || error.detail.msg || JSON.stringify(error.detail);
}
} catch {
// ignore parse error, use default
}
throw new Error(errorMsg);
}
return response.json();
}
@@ -50,6 +63,15 @@ document.addEventListener("DOMContentLoaded", function () {
body: JSON.stringify(data),
});
},
async updateCollection(id, data) {
return apiFetch(`/collections/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
async deleteCollection(id) {
return apiFetch(`/collections/${id}`, { method: "DELETE" });
},
async executeQuery(expression, limit = 20) {
return apiFetch(`/queries/execute?expression=${encodeURIComponent(expression)}&limit=${limit}`);
},
@@ -67,8 +89,41 @@ document.addEventListener("DOMContentLoaded", function () {
localStorage.setItem("token", data.access_token);
return data;
},
async getMe() {
return apiFetch("/auth/me");
},
logout() {
localStorage.removeItem("token");
localStorage.removeItem("user");
},
async getUsers() {
return apiFetch("/admin/users");
},
async createUser(data) {
return apiFetch("/admin/users", {
method: "POST",
body: JSON.stringify(data),
});
},
async updateUser(id, data) {
return apiFetch(`/admin/users/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
async deleteUser(id) {
return apiFetch(`/admin/users/${id}`, { method: "DELETE" });
},
async getApiKeys() {
return apiFetch("/auth/api-keys");
},
async createApiKey(name) {
return apiFetch(`/auth/api-key?name=${encodeURIComponent(name)}`, {
method: "POST",
});
},
async deleteApiKey(id) {
return apiFetch(`/auth/api-key/${id}`, { method: "DELETE" });
},
};
});

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}Admin - LinkSync{% endblock %}
{% block content %}
<div class="page-header">
<h1>User Management</h1>
<button class="btn btn-primary" id="new-user-btn">+ New User</button>
</div>
<div id="users-list" class="users-table">
<div class="loading">Loading users...</div>
</div>
<div id="user-modal" class="modal" style="display: none;">
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h2 id="user-modal-title">Create User</h2>
<button class="modal-close" id="user-modal-close">&times;</button>
</div>
<form id="user-form">
<input type="hidden" id="user-id">
<div class="form-group">
<label for="user-username">Username *</label>
<input type="text" id="user-username" required>
</div>
<div class="form-group">
<label for="user-email">Email *</label>
<input type="email" id="user-email" required>
</div>
<div class="form-group">
<label for="user-password">Password *</label>
<input type="password" id="user-password" required>
</div>
<div class="form-group">
<label for="user-role">Role</label>
<select id="user-role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="user-active" checked>
Active
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="user-cancel-btn">Cancel</button>
<button type="submit" class="btn btn-primary" id="user-save-btn">Save</button>
</div>
</form>
</div>
</div>
<div id="delete-user-modal" class="modal" style="display: none;">
<div class="modal-overlay"></div>
<div class="modal-content modal-sm">
<h2>Delete User</h2>
<p>Are you sure you want to delete this user? This action cannot be undone.</p>
<div class="form-actions">
<button class="btn btn-secondary" id="delete-user-cancel-btn">Cancel</button>
<button class="btn btn-danger" id="confirm-delete-user-btn">Delete</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="/static/js/admin-page.js"></script>
{% endblock %}

View File

@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}API Keys - LinkSync{% endblock %}
{% block content %}
<div class="page-header">
<h1>API Keys</h1>
<button class="btn btn-primary" id="new-key-btn">+ New API Key</button>
</div>
<div id="api-keys-list" class="api-keys-table">
<div class="loading">Loading API keys...</div>
</div>
<div id="key-modal" class="modal" style="display: none;">
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h2>Create API Key</h2>
<button class="modal-close" id="key-modal-close">&times;</button>
</div>
<form id="key-form">
<div class="form-group">
<label for="key-name">Key Name *</label>
<input type="text" id="key-name" placeholder="e.g., Firefox Extension" required>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="key-cancel-btn">Cancel</button>
<button type="submit" class="btn btn-primary" id="key-save-btn">Create</button>
</div>
</form>
</div>
</div>
<div id="key-result-modal" class="modal" style="display: none;">
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h2>API Key Created</h2>
<button class="modal-close" id="key-result-close">&times;</button>
</div>
<div class="key-result">
<p><strong>Copy this key now. You will not be able to see it again.</strong></p>
<div class="key-display">
<code id="new-key-value"></code>
<button class="btn btn-sm" id="copy-key-btn">Copy</button>
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" id="key-done-btn">Done</button>
</div>
</div>
</div>
<div id="delete-key-modal" class="modal" style="display: none;">
<div class="modal-overlay"></div>
<div class="modal-content modal-sm">
<h2>Delete API Key</h2>
<p>Are you sure you want to delete this API key? Any connections using this key will stop working.</p>
<div class="form-actions">
<button class="btn btn-secondary" id="delete-key-cancel-btn">Cancel</button>
<button class="btn btn-danger" id="confirm-delete-key-btn">Delete</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="/static/js/apikeys-page.js"></script>
{% endblock %}

View File

@@ -10,13 +10,19 @@
<body>
<nav class="navbar">
<div class="nav-brand">
<a href="/">LinkSync</a>
<a href="/dashboard">LinkSync</a>
</div>
<div class="nav-links">
<a href="/#links">Links</a>
<a href="/#collections">Collections</a>
<a href="/#queries">Queries</a>
<a href="/api/docs" target="_blank">API Docs</a>
<div class="nav-links" id="nav-links">
<a href="/dashboard">Dashboard</a>
<a href="/links">Links</a>
<a href="/collections">Collections</a>
<a href="/api-keys">API Keys</a>
<a href="/admin" id="admin-nav" style="display: none;">Admin</a>
<a href="/docs" target="_blank">API</a>
</div>
<div class="nav-user">
<span id="nav-username"></span>
<button class="btn btn-sm btn-outline" id="logout-btn">Logout</button>
</div>
</nav>
<main class="container">
@@ -26,6 +32,27 @@
<p>LinkSyncServer &copy; 2026</p>
</footer>
<script src="/static/js/main.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const token = localStorage.getItem('token');
const user = JSON.parse(localStorage.getItem('user') || 'null');
if (!token) {
window.location.href = '/login';
return;
}
if (user) {
document.getElementById('nav-username').textContent = user.username;
if (user.role === 'admin') {
document.getElementById('admin-nav').style.display = '';
}
}
document.getElementById('logout-btn').addEventListener('click', function() {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
});
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}Collections - LinkSync{% endblock %}
{% block content %}
<div class="page-header">
<h1>Collections</h1>
<button class="btn btn-primary" id="new-collection-btn">+ New Collection</button>
</div>
<div id="collections-list" class="collections-grid">
<div class="loading">Loading collections...</div>
</div>
<div id="collection-modal" class="modal" style="display: none;">
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h2 id="collection-modal-title">Create Collection</h2>
<button class="modal-close" id="collection-modal-close">&times;</button>
</div>
<form id="collection-form">
<input type="hidden" id="collection-id">
<div class="form-group">
<label for="collection-name">Name *</label>
<input type="text" id="collection-name" required>
</div>
<div class="form-group">
<label for="collection-description">Description</label>
<textarea id="collection-description" rows="2"></textarea>
</div>
<div class="form-group">
<label for="collection-type">Type</label>
<select id="collection-type">
<option value="static">Static (manual links)</option>
<option value="dynamic">Dynamic (query-based)</option>
</select>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="collection-public">
Public
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="collection-cancel-btn">Cancel</button>
<button type="submit" class="btn btn-primary" id="collection-save-btn">Save</button>
</div>
</form>
</div>
</div>
<div id="delete-collection-modal" class="modal" style="display: none;">
<div class="modal-overlay"></div>
<div class="modal-content modal-sm">
<h2>Delete Collection</h2>
<p>Are you sure you want to delete this collection? This action cannot be undone.</p>
<div class="form-actions">
<button class="btn btn-secondary" id="delete-collection-cancel-btn">Cancel</button>
<button class="btn btn-danger" id="confirm-delete-collection-btn">Delete</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="/static/js/collections-page.js"></script>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}Dashboard - LinkSync{% endblock %}
{% block content %}
<div class="dashboard-header">
<h1>Dashboard</h1>
<p class="welcome-text">Welcome, <span id="current-user"></span></p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="link-count">-</div>
<div class="stat-label">Total Links</div>
<a href="/links" class="stat-link">View all &rarr;</a>
</div>
<div class="stat-card">
<div class="stat-value" id="collection-count">-</div>
<div class="stat-label">Collections</div>
<a href="/collections" class="stat-link">View all &rarr;</a>
</div>
<div class="stat-card">
<div class="stat-value" id="api-key-count">-</div>
<div class="stat-label">API Keys</div>
<a href="/api-keys" class="stat-link">Manage &rarr;</a>
</div>
</div>
<div class="quick-actions">
<h2>Quick Actions</h2>
<div class="action-grid">
<a href="/links?action=new" class="action-card">
<span class="action-icon">+</span>
<span class="action-label">Add Link</span>
</a>
<a href="/collections?action=new" class="action-card">
<span class="action-icon">+</span>
<span class="action-label">New Collection</span>
</a>
<a href="/api-keys?action=new" class="action-card">
<span class="action-icon">+</span>
<span class="action-label">Create API Key</span>
</a>
<a href="/docs" target="_blank" class="action-card">
<span class="action-icon">&#128736;</span>
<span class="action-label">API Documentation</span>
</a>
</div>
</div>
<div id="admin-section" class="admin-quick" style="display: none;">
<h2>Admin</h2>
<div class="action-grid">
<a href="/admin" class="action-card">
<span class="action-icon">&#128100;</span>
<span class="action-label">Manage Users</span>
</a>
<a href="/admin?action=new-user" class="action-card">
<span class="action-icon">+</span>
<span class="action-label">Create User</span>
</a>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="/static/js/dashboard.js"></script>
{% endblock %}

View File

@@ -1,60 +1,15 @@
{% extends "base.html" %}
{% block title %}LinkSync - Home{% endblock %}
{% block content %}
<div class="hero">
<h1>LinkSync Server</h1>
<p>Self-hosted bookmark server with advanced collection and query capabilities.</p>
<div class="hero-actions">
<a href="/api/docs" class="btn btn-primary">API Documentation</a>
<a href="/api/links/" class="btn btn-secondary">Browse Links</a>
</div>
</div>
<section id="links" class="section">
<h2>Quick Links</h2>
<div class="card-grid">
<div class="card">
<h3>Links</h3>
<p>Manage your bookmarks with full CRUD operations.</p>
<a href="/api/links/">View API</a>
</div>
<div class="card">
<h3>Collections</h3>
<p>Organize links into static or dynamic collections.</p>
<a href="/api/collections/">View API</a>
</div>
<div class="card">
<h3>Queries</h3>
<p>Execute advanced queries with AND, OR, XOR operations.</p>
<a href="/api/queries/">View API</a>
</div>
<div class="card">
<h3>Sync</h3>
<p>Sync bookmarks with browser extensions.</p>
<a href="/api/sync/">View API</a>
</div>
</div>
</section>
<section id="collections" class="section">
<h2>Features</h2>
<ul class="feature-list">
<li>True Collections - Static or dynamic sets of links</li>
<li>Advanced Query Engine - AND, OR, XOR set operations</li>
<li>Firefox-Compatible Fields - All bookmark attributes supported</li>
<li>Multi-User Support - Authentication with roles</li>
<li>RESTful API - Full CRUD operations</li>
<li>Docker-Ready - Easy deployment</li>
</ul>
</section>
<section id="queries" class="section">
<h2>Query Syntax</h2>
<div class="code-block">
<code>('term1', 'term2') OR tagA AND tagB XOR url:example.com</code>
</div>
<p>Precedence: <code>()</code> &gt; XOR &gt; AND &gt; OR</p>
</section>
{% endblock %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LinkSync</title>
<script>
const token = localStorage.getItem('token');
window.location.href = token ? '/dashboard' : '/login';
</script>
</head>
<body>
<p>Redirecting...</p>
</body>
</html>

View File

@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}Links - LinkSync{% endblock %}
{% block content %}
<div class="page-header">
<h1>Links</h1>
<button class="btn btn-primary" id="new-link-btn">+ New Link</button>
</div>
<div class="search-bar">
<input type="text" id="search-input" placeholder="Search links by title or URL...">
<button class="btn btn-secondary" id="search-btn">Search</button>
</div>
<div id="links-list" class="links-table">
<div class="loading">Loading links...</div>
</div>
<div id="link-modal" class="modal" style="display: none;">
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-title">Add Link</h2>
<button class="modal-close" id="modal-close">&times;</button>
</div>
<form id="link-form">
<input type="hidden" id="link-id">
<div class="form-group">
<label for="link-url">URL *</label>
<input type="url" id="link-url" required>
</div>
<div class="form-group">
<label for="link-title">Title *</label>
<input type="text" id="link-title" required>
</div>
<div class="form-group">
<label for="link-description">Description</label>
<textarea id="link-description" rows="2"></textarea>
</div>
<div class="form-group">
<label for="link-notes">Notes</label>
<textarea id="link-notes" rows="3"></textarea>
</div>
<div class="form-group">
<label for="link-tags">Tags (comma-separated)</label>
<input type="text" id="link-tags" placeholder="tag1, tag2, tag3">
</div>
<div class="form-group">
<label for="link-favicon">Favicon URL</label>
<input type="url" id="link-favicon">
</div>
<div class="form-group">
<label for="link-path">Path/Folder</label>
<input type="text" id="link-path">
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancel-btn">Cancel</button>
<button type="submit" class="btn btn-primary" id="save-btn">Save</button>
</div>
</form>
</div>
</div>
<div id="delete-modal" class="modal" style="display: none;">
<div class="modal-overlay"></div>
<div class="modal-content modal-sm">
<h2>Delete Link</h2>
<p>Are you sure you want to delete this link? This action cannot be undone.</p>
<div class="form-actions">
<button class="btn btn-secondary" id="delete-cancel-btn">Cancel</button>
<button class="btn btn-danger" id="confirm-delete-btn">Delete</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="/static/js/links-page.js"></script>
{% endblock %}

View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - LinkSync</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body class="login-page">
<div class="login-container">
<div class="login-card">
<h1>LinkSync</h1>
<p class="login-subtitle">Sign in to your account</p>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div id="login-error" class="error-message" style="display: none;"></div>
<button type="submit" class="btn btn-primary btn-full" id="login-btn">Sign In</button>
</form>
</div>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = document.getElementById('login-btn');
const error = document.getElementById('login-error');
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
btn.disabled = true;
btn.textContent = 'Signing in...';
error.style.display = 'none';
try {
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || 'Invalid credentials');
}
const data = await response.json();
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
window.location.href = '/dashboard';
} catch (err) {
error.textContent = err.message;
error.style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = 'Sign In';
}
});
if (localStorage.getItem('token')) {
window.location.href = '/dashboard';
}
</script>
</body>
</html>

View File

@@ -1,32 +1,88 @@
"""
LinkSyncServer - Test Configuration
Database lifecycle:
- pytest_configure: Creates fresh test database before any tests run
- pytest_unconfigure: Destroys test database after all tests complete
- Per-test: Transaction rollback ensures test isolation
"""
import os
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from models.base import Base, get_engine
from models.base import Base
import models.base
SQLALCHEMY_DATABASE_URL = "sqlite:///test_linksync.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
TEST_DATABASE_URL = "sqlite:///test_linksync.db"
def pytest_configure(config):
"""Called before any tests run. Creates fresh test database."""
if os.path.exists("test_linksync.db"):
os.remove("test_linksync.db")
test_engine = create_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=test_engine)
# Override the module-level get_engine to return our test engine
original_get_engine = models.base.get_engine
original_get_session = models.base.get_session
def test_get_engine():
return test_engine
def test_get_session():
Session = sessionmaker(bind=test_engine)
return Session()
models.base.get_engine = test_get_engine
models.base.get_session = test_get_session
# Store originals for cleanup
config._test_engine = test_engine
config._original_get_engine = original_get_engine
config._original_get_session = original_get_session
def pytest_unconfigure(config):
"""Called after all tests complete. Destroys test database."""
# Restore original functions
if hasattr(config, "_original_get_engine"):
models.base.get_engine = config._original_get_engine
models.base.get_session = config._original_get_session
# Drop all tables
if hasattr(config, "_test_engine"):
Base.metadata.drop_all(bind=config._test_engine)
config._test_engine.dispose()
# Remove the test database file
if os.path.exists("test_linksync.db"):
os.remove("test_linksync.db")
@pytest.fixture(scope="session")
def test_engine():
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
def test_engine(pytestconfig):
"""Provides the test engine for direct database access in tests."""
return pytestconfig._test_engine
@pytest.fixture
def db_session(test_engine):
"""Provides a transactional session that rolls back after each test."""
connection = test_engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
Session = sessionmaker(bind=connection)
session = Session()
yield session
@@ -37,6 +93,7 @@ def db_session(test_engine):
@pytest.fixture
def client():
"""Provides a TestClient with the test database already configured."""
from app import app
with TestClient(app) as c:
yield c

View File

@@ -0,0 +1,223 @@
"""
LinkSyncServer - Admin API Tests
"""
import uuid
import pytest
from fastapi.testclient import TestClient
class TestAdmin:
def test_list_users(self, client: TestClient, admin_token: str):
response = client.get(
"/api/admin/users",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_create_user(self, client: TestClient, admin_token: str):
unique = str(uuid.uuid4())[:8]
response = client.post(
"/api/admin/users",
json={
"username": f"admin_created_{unique}",
"email": f"admin_{unique}@example.com",
"password": "testpass123",
"role": "user",
"is_active": True,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 201
data = response.json()
assert data["username"] == f"admin_created_{unique}"
def test_create_admin_user(self, client: TestClient, admin_token: str):
unique = str(uuid.uuid4())[:8]
response = client.post(
"/api/admin/users",
json={
"username": f"admin_user_{unique}",
"email": f"adminuser_{unique}@example.com",
"password": "testpass123",
"role": "admin",
"is_active": True,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 201
assert response.json()["role"] == "admin"
def test_create_user_duplicate(self, client: TestClient, admin_token: str):
unique = str(uuid.uuid4())[:8]
client.post(
"/api/admin/users",
json={
"username": f"dup_admin_{unique}",
"email": f"dupadmin_{unique}@example.com",
"password": "testpass123",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
response = client.post(
"/api/admin/users",
json={
"username": f"dup_admin_{unique}",
"email": f"dupadmin2_{unique}@example.com",
"password": "testpass123",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 400
def test_get_user(self, client: TestClient, admin_token: str):
users = client.get("/api/admin/users", headers={"Authorization": f"Bearer {admin_token}"}).json()
if users:
user_id = users[0]["id"]
response = client.get(
f"/api/admin/users/{user_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
assert "username" in response.json()
def test_get_user_not_found(self, client: TestClient, admin_token: str):
response = client.get(
"/api/admin/users/00000000-0000-0000-0000-000000000000",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 404
def test_update_user(self, client: TestClient, admin_token: str):
unique = str(uuid.uuid4())[:8]
create_resp = client.post(
"/api/admin/users",
json={
"username": f"update_user_{unique}",
"email": f"update_{unique}@example.com",
"password": "testpass123",
"role": "user",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
user_id = create_resp.json()["id"]
response = client.put(
f"/api/admin/users/{user_id}",
json={"email": f"updated_{unique}@example.com"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
assert response.json()["email"] == f"updated_{unique}@example.com"
def test_update_user_role(self, client: TestClient, admin_token: str):
unique = str(uuid.uuid4())[:8]
create_resp = client.post(
"/api/admin/users",
json={
"username": f"role_user_{unique}",
"email": f"role_{unique}@example.com",
"password": "testpass123",
"role": "user",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
user_id = create_resp.json()["id"]
response = client.put(
f"/api/admin/users/{user_id}",
json={"role": "admin"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
assert response.json()["role"] == "admin"
def test_deactivate_user(self, client: TestClient, admin_token: str):
unique = str(uuid.uuid4())[:8]
create_resp = client.post(
"/api/admin/users",
json={
"username": f"deact_user_{unique}",
"email": f"deact_{unique}@example.com",
"password": "testpass123",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
user_id = create_resp.json()["id"]
response = client.put(
f"/api/admin/users/{user_id}",
json={"is_active": False},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
assert response.json()["is_active"] is False
def test_delete_user(self, client: TestClient, admin_token: str):
unique = str(uuid.uuid4())[:8]
create_resp = client.post(
"/api/admin/users",
json={
"username": f"del_user_{unique}",
"email": f"del_{unique}@example.com",
"password": "testpass123",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
user_id = create_resp.json()["id"]
response = client.delete(
f"/api/admin/users/{user_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
def test_delete_self_not_allowed(self, client: TestClient, admin_token: str):
users = client.get("/api/admin/users", headers={"Authorization": f"Bearer {admin_token}"}).json()
admin_user = next((u for u in users if u["username"] == "admin"), None)
if admin_user:
response = client.delete(
f"/api/admin/users/{admin_user['id']}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 400
def test_stats(self, client: TestClient, admin_token: str):
response = client.get(
"/api/admin/stats",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert "total_users" in data
assert "total_bookmarks" in data
def test_audit_log(self, client: TestClient, admin_token: str):
response = client.get(
"/api/admin/audit",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_admin_required_for_user_list(self, client: TestClient):
unique = str(uuid.uuid4())[:8]
client.post(
"/api/auth/register",
json={
"username": f"regular_{unique}",
"email": f"regular_{unique}@example.com",
"password": "testpass123",
},
)
resp = client.post(
"/api/auth/login",
data={"username": f"regular_{unique}", "password": "testpass123"},
)
token = resp.json()["access_token"]
response = client.get(
"/api/admin/users",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 403
def test_unauthenticated_admin_access(self, client: TestClient):
response = client.get("/api/admin/users")
assert response.status_code == 401

View File

@@ -0,0 +1,95 @@
"""
LinkSyncServer - Auth API Tests (extended)
"""
import uuid
import pytest
from fastapi.testclient import TestClient
class TestAuthExtended:
def test_list_api_keys(self, client: TestClient, admin_token: str):
response = client.get(
"/api/auth/api-keys",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_list_api_keys_empty(self, client: TestClient, admin_token: str):
response = client.get(
"/api/auth/api-keys",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_create_and_list_api_key(self, client: TestClient, admin_token: str):
import uuid
key_name = f"test-key-{uuid.uuid4().hex[:8]}"
client.post(
"/api/auth/api-key",
params={"name": key_name},
headers={"Authorization": f"Bearer {admin_token}"},
)
response = client.get(
"/api/auth/api-keys",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
keys = response.json()
assert any(k["name"] == key_name for k in keys)
def test_get_api_key(self, client: TestClient, admin_token: str):
import uuid
key_name = f"get-key-{uuid.uuid4().hex[:8]}"
create_resp = client.post(
"/api/auth/api-key",
params={"name": key_name},
headers={"Authorization": f"Bearer {admin_token}"},
)
key_id = create_resp.json()["key_id"]
response = client.get(
f"/api/auth/api-key/{key_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == key_name
def test_get_api_key_not_found(self, client: TestClient, admin_token: str):
response = client.get(
"/api/auth/api-key/00000000-0000-0000-0000-000000000000",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 404
def test_delete_api_key(self, client: TestClient, admin_token: str):
import uuid
key_name = f"del-key-{uuid.uuid4().hex[:8]}"
create_resp = client.post(
"/api/auth/api-key",
params={"name": key_name},
headers={"Authorization": f"Bearer {admin_token}"},
)
key_id = create_resp.json()["key_id"]
response = client.delete(
f"/api/auth/api-key/{key_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
def test_delete_api_key_not_found(self, client: TestClient, admin_token: str):
response = client.delete(
"/api/auth/api-key/00000000-0000-0000-0000-000000000000",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 404
def test_api_key_requires_auth(self, client: TestClient):
response = client.get("/api/auth/api-keys")
assert response.status_code == 401
def test_create_api_key_requires_auth(self, client: TestClient):
response = client.post("/api/auth/api-key", params={"name": "test"})
assert response.status_code == 401

View File

@@ -0,0 +1,93 @@
"""
LinkSyncServer - Sync API Tests
"""
import uuid
import pytest
from fastapi.testclient import TestClient
class TestSync:
def test_sync_list_collections(self, client: TestClient):
response = client.get("/api/sync/collections")
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_sync_get_collection_not_found(self, client: TestClient, auth_headers: dict):
response = client.get(
"/api/sync/collections/00000000-0000-0000-0000-000000000000",
headers=auth_headers,
)
assert response.status_code == 404
def test_sync_create_collection_and_get(self, client: TestClient, auth_headers: dict):
col_name = f"Sync Get Test {uuid.uuid4().hex[:8]}"
create_resp = client.post(
"/api/collections/",
json={
"name": col_name,
"description": "Test",
"query_type": "static",
"is_public": False,
"link_ids": [],
},
headers=auth_headers,
)
collection_id = create_resp.json()["id"]
response = client.get(
f"/api/sync/collections/{collection_id}",
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["name"] == col_name
def test_sync_add_links(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
bm_data = sample_bookmark_data.copy()
bm_data["url"] = f"https://sync-test-{uuid.uuid4().hex[:8]}.com"
bm_data["title"] = f"Sync Test {uuid.uuid4().hex[:8]}"
bm_resp = client.post("/api/links/", json=bm_data, headers=auth_headers)
bookmark_id = bm_resp.json()["id"]
col_name = f"Sync Add Links {uuid.uuid4().hex[:8]}"
col_resp = client.post(
"/api/collections/",
json={
"name": col_name,
"query_type": "static",
"is_public": False,
"link_ids": [],
},
headers=auth_headers,
)
collection_id = col_resp.json()["id"]
response = client.post(
f"/api/sync/collections/{collection_id}/add-links",
json=[bookmark_id],
headers=auth_headers,
)
assert response.status_code == 200
def test_sync_delete_collection(self, client: TestClient, auth_headers: dict):
col_name = f"Sync Delete {uuid.uuid4().hex[:8]}"
create_resp = client.post(
"/api/collections/",
json={
"name": col_name,
"query_type": "static",
"is_public": False,
"link_ids": [],
},
headers=auth_headers,
)
collection_id = create_resp.json()["id"]
response = client.delete(
f"/api/sync/collections/{collection_id}",
headers=auth_headers,
)
assert response.status_code == 200
def test_sync_list_collections_no_auth(self, client: TestClient):
response = client.get("/api/sync/collections")
assert response.status_code == 200

View File

@@ -0,0 +1,118 @@
"""
LinkSyncServer - Tags API Tests
"""
import uuid
import pytest
from fastapi.testclient import TestClient
class TestTags:
def test_list_tags_empty(self, client: TestClient, auth_headers: dict):
response = client.get("/api/tags/", headers=auth_headers)
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_create_tag(self, client: TestClient, auth_headers: dict):
tag_name = f"test-tag-{uuid.uuid4().hex[:8]}"
response = client.post(
"/api/tags/",
json={"name": tag_name, "color": "#ff0000", "description": "A test tag"},
headers=auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["name"] == tag_name
def test_create_tag_duplicate(self, client: TestClient, auth_headers: dict):
tag_name = f"dup-tag-{uuid.uuid4().hex[:8]}"
client.post(
"/api/tags/",
json={"name": tag_name},
headers=auth_headers,
)
response = client.post(
"/api/tags/",
json={"name": tag_name},
headers=auth_headers,
)
assert response.status_code == 400
def test_list_tags(self, client: TestClient, auth_headers: dict):
client.post("/api/tags/", json={"name": f"list-tag-1-{uuid.uuid4().hex[:8]}"}, headers=auth_headers)
client.post("/api/tags/", json={"name": f"list-tag-2-{uuid.uuid4().hex[:8]}"}, headers=auth_headers)
response = client.get("/api/tags/", headers=auth_headers)
assert response.status_code == 200
assert len(response.json()) >= 2
def test_get_tag(self, client: TestClient, auth_headers: dict):
tag_name = f"get-tag-{uuid.uuid4().hex[:8]}"
create_resp = client.post(
"/api/tags/",
json={"name": tag_name},
headers=auth_headers,
)
tag_id = create_resp.json()["id"]
response = client.get(f"/api/tags/{tag_id}", headers=auth_headers)
assert response.status_code == 200
assert response.json()["name"] == tag_name
def test_get_tag_not_found(self, client: TestClient, auth_headers: dict):
response = client.get("/api/tags/00000000-0000-0000-0000-000000000000", headers=auth_headers)
assert response.status_code == 404
def test_get_tag_by_name(self, client: TestClient, auth_headers: dict):
tag_name = f"find-by-name-{uuid.uuid4().hex[:8]}"
client.post("/api/tags/", json={"name": tag_name}, headers=auth_headers)
response = client.get(f"/api/tags/name/{tag_name}", headers=auth_headers)
assert response.status_code == 200
assert response.json()["name"] == tag_name
def test_get_tag_by_name_not_found(self, client: TestClient, auth_headers: dict):
response = client.get("/api/tags/name/nonexistent-tag-xyz", headers=auth_headers)
assert response.status_code == 404
def test_update_tag(self, client: TestClient, auth_headers: dict):
tag_name = f"update-tag-{uuid.uuid4().hex[:8]}"
create_resp = client.post(
"/api/tags/",
json={"name": tag_name, "color": "#000000"},
headers=auth_headers,
)
tag_id = create_resp.json()["id"]
response = client.put(
f"/api/tags/{tag_id}",
json={"color": "#ffffff", "description": "Updated"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["color"] == "#ffffff"
def test_delete_tag(self, client: TestClient, auth_headers: dict):
tag_name = f"delete-tag-{uuid.uuid4().hex[:8]}"
create_resp = client.post(
"/api/tags/",
json={"name": tag_name},
headers=auth_headers,
)
tag_id = create_resp.json()["id"]
response = client.delete(f"/api/tags/{tag_id}", headers=auth_headers)
assert response.status_code == 200
def test_get_tag_links(self, client: TestClient, auth_headers: dict):
tag_name = f"links-tag-{uuid.uuid4().hex[:8]}"
create_resp = client.post(
"/api/tags/",
json={"name": tag_name},
headers=auth_headers,
)
tag_id = create_resp.json()["id"]
response = client.get(f"/api/tags/{tag_id}/links", headers=auth_headers)
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_tag_count(self, client: TestClient, auth_headers: dict):
response = client.get("/api/tags/count", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "count" in data