diff --git a/LinkSyncServer/api/endpoints/admin.py b/LinkSyncServer/api/endpoints/admin.py index 37c262d..7c32d5d 100644 --- a/LinkSyncServer/api/endpoints/admin.py +++ b/LinkSyncServer/api/endpoints/admin.py @@ -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 diff --git a/LinkSyncServer/api/endpoints/auth.py b/LinkSyncServer/api/endpoints/auth.py index 77a1efe..f0d4c5f 100644 --- a/LinkSyncServer/api/endpoints/auth.py +++ b/LinkSyncServer/api/endpoints/auth.py @@ -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") diff --git a/LinkSyncServer/api/endpoints/collections.py b/LinkSyncServer/api/endpoints/collections.py index ae2898b..aa64037 100644 --- a/LinkSyncServer/api/endpoints/collections.py +++ b/LinkSyncServer/api/endpoints/collections.py @@ -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,12 +80,16 @@ 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: - query = query.filter( - or_(Collection.created_by == user_id, Collection.is_public == True) - ) + 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) + ) + 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() @@ -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) ) diff --git a/LinkSyncServer/api/endpoints/links.py b/LinkSyncServer/api/endpoints/links.py index 0a4ec49..eaf860f 100644 --- a/LinkSyncServer/api/endpoints/links.py +++ b/LinkSyncServer/api/endpoints/links.py @@ -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") diff --git a/LinkSyncServer/api/endpoints/sync.py b/LinkSyncServer/api/endpoints/sync.py index 5d72446..d0d8b0c 100644 --- a/LinkSyncServer/api/endpoints/sync.py +++ b/LinkSyncServer/api/endpoints/sync.py @@ -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,8 +141,10 @@ 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() - actions.append({"type": "delete", "link_id": server_id}) + 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() return SyncResponse(actions=actions, synced_count=len(actions)) @@ -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() diff --git a/LinkSyncServer/api/endpoints/tags.py b/LinkSyncServer/api/endpoints/tags.py index d4fac00..f0f7403 100644 --- a/LinkSyncServer/api/endpoints/tags.py +++ b/LinkSyncServer/api/endpoints/tags.py @@ -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") diff --git a/LinkSyncServer/app.py b/LinkSyncServer/app.py index 2b0b39c..c002ef9 100644 --- a/LinkSyncServer/app.py +++ b/LinkSyncServer/app.py @@ -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}) diff --git a/LinkSyncServer/config/schema.sql b/LinkSyncServer/config/schema.sql index 56661fb..a4a5c60 100644 --- a/LinkSyncServer/config/schema.sql +++ b/LinkSyncServer/config/schema.sql @@ -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 diff --git a/LinkSyncServer/docker-compose.yml b/LinkSyncServer/docker-compose.yml index 02cc3de..0badb85 100644 --- a/LinkSyncServer/docker-compose.yml +++ b/LinkSyncServer/docker-compose.yml @@ -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} diff --git a/LinkSyncServer/models/base.py b/LinkSyncServer/models/base.py index cdf4aed..6a7b7d7 100644 --- a/LinkSyncServer/models/base.py +++ b/LinkSyncServer/models/base.py @@ -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)) diff --git a/LinkSyncServer/requirements.txt b/LinkSyncServer/requirements.txt index 8725f0d..da6abc2 100644 --- a/LinkSyncServer/requirements.txt +++ b/LinkSyncServer/requirements.txt @@ -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 diff --git a/LinkSyncServer/static/css/main.css b/LinkSyncServer/static/css/main.css index 08fb630..3dd9397 100644 --- a/LinkSyncServer/static/css/main.css +++ b/LinkSyncServer/static/css/main.css @@ -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; + } } diff --git a/LinkSyncServer/static/js/admin-page.js b/LinkSyncServer/static/js/admin-page.js new file mode 100644 index 0000000..238d5ed --- /dev/null +++ b/LinkSyncServer/static/js/admin-page.js @@ -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 = `
Failed to load users: ${err.message}
No users found.
| Username | +Role | +Status | +Created | +Actions | +|
|---|---|---|---|---|---|
| ${escapeHtml(user.username)} | +${escapeHtml(user.email)} | +${user.role} | +${user.is_active ? 'Active' : 'Inactive'} | +${formatDate(user.created_at)} | ++ + + | +
Failed to load API keys: ${err.message}
No API keys found.
| Name | +Key ID | +Status | +Created | +Expires | +Actions | +
|---|---|---|---|---|---|
| ${escapeHtml(key.name)} | +${escapeHtml(key.key_id || key.id)} | +${key.is_active ? 'Active' : 'Inactive'} | +${formatDate(key.created_at)} | +${key.expires_at ? formatDate(key.expires_at) : 'Never'} | ++ + | +
Failed to load collections: ${err.message}
No collections found.
${escapeHtml(col.description || 'No description')}
+ +Failed to load links: ${err.message}
No links found.
| Title | +URL | +Tags | +Created | +Actions | +
|---|---|---|---|---|
| ${escapeHtml(link.title)} | +${escapeHtml(link.url)} | ++ | ${formatDate(link.created_at)} | ++ + + | +