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:
@@ -5,7 +5,7 @@ LinkSyncServer - Admin Endpoints
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import List, Optional
|
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 pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
from api.endpoints.auth import hash_password, require_admin
|
from api.endpoints.auth import hash_password, require_admin
|
||||||
@@ -34,11 +34,18 @@ class SettingsUpdate(BaseModel):
|
|||||||
cors_origins: Optional[str] = None
|
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])
|
@router.get("/users", response_model=List[dict])
|
||||||
async def list_users(
|
async def list_users(
|
||||||
limit: int = Query(20, le=100, ge=1),
|
limit: int = Query(20, le=100, ge=1),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
current_admin: dict = require_admin,
|
current_admin: dict = Depends(require_admin),
|
||||||
):
|
):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
try:
|
||||||
@@ -51,7 +58,7 @@ async def list_users(
|
|||||||
@router.post("/users", response_model=dict, status_code=status.HTTP_201_CREATED)
|
@router.post("/users", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
data: UserCreate,
|
data: UserCreate,
|
||||||
current_admin: dict = require_admin,
|
current_admin: dict = Depends(require_admin),
|
||||||
):
|
):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
try:
|
||||||
@@ -62,7 +69,7 @@ async def create_user(
|
|||||||
raise HTTPException(status_code=400, detail="Username or email already exists")
|
raise HTTPException(status_code=400, detail="Username or email already exists")
|
||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
id=str(uuid.uuid4()),
|
id=uuid.uuid4(),
|
||||||
username=data.username,
|
username=data.username,
|
||||||
email=data.email,
|
email=data.email,
|
||||||
password_hash=hash_password(data.password),
|
password_hash=hash_password(data.password),
|
||||||
@@ -80,11 +87,14 @@ async def create_user(
|
|||||||
@router.get("/users/{user_id}", response_model=dict)
|
@router.get("/users/{user_id}", response_model=dict)
|
||||||
async def get_user(
|
async def get_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
current_admin: dict = require_admin,
|
current_admin: dict = Depends(require_admin),
|
||||||
):
|
):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
return user.to_dict()
|
return user.to_dict()
|
||||||
@@ -96,11 +106,14 @@ async def get_user(
|
|||||||
async def update_user(
|
async def update_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
data: UserUpdate,
|
data: UserUpdate,
|
||||||
current_admin: dict = require_admin,
|
current_admin: dict = Depends(require_admin),
|
||||||
):
|
):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
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)
|
@router.delete("/users/{user_id}", response_model=dict)
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
current_admin: dict = require_admin,
|
current_admin: dict = Depends(require_admin),
|
||||||
):
|
):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
if user.username == current_admin.get("username"):
|
if user.username == current_admin.get("username"):
|
||||||
@@ -139,7 +155,7 @@ async def delete_user(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_model=dict)
|
@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()
|
db = get_session()
|
||||||
try:
|
try:
|
||||||
return {
|
return {
|
||||||
@@ -159,7 +175,7 @@ async def get_audit_log(
|
|||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
entity_type: Optional[str] = Query(None),
|
entity_type: Optional[str] = Query(None),
|
||||||
action: Optional[str] = Query(None),
|
action: Optional[str] = Query(None),
|
||||||
current_admin: dict = require_admin,
|
current_admin: dict = Depends(require_admin),
|
||||||
):
|
):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
try:
|
||||||
@@ -171,14 +187,14 @@ async def get_audit_log(
|
|||||||
logs = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
|
logs = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": log.id,
|
"id": str(log.id),
|
||||||
"user_id": log.user_id,
|
"user_id": str(log.user_id) if log.user_id else None,
|
||||||
"action": log.action,
|
"action": log.action,
|
||||||
"entity_type": log.entity_type,
|
"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,
|
"old_value": log.old_value,
|
||||||
"new_value": log.new_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,
|
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||||
}
|
}
|
||||||
for log in logs
|
for log in logs
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ LinkSyncServer - Authentication Endpoints
|
|||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import jwt
|
import jwt
|
||||||
@@ -178,6 +179,32 @@ async def logout():
|
|||||||
return {"message": "Logged out successfully"}
|
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)
|
@router.post("/api-key", response_model=ApiKeyResponse)
|
||||||
async def create_api_key(
|
async def create_api_key(
|
||||||
name: str = "default",
|
name: str = "default",
|
||||||
@@ -204,7 +231,7 @@ async def create_api_key(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"api_key": raw_key,
|
"api_key": raw_key,
|
||||||
"key_id": api_key.id,
|
"key_id": str(api_key.id),
|
||||||
"name": api_key.name,
|
"name": api_key.name,
|
||||||
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
"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:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
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(
|
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()
|
).first()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise HTTPException(status_code=404, detail="API key not found")
|
raise HTTPException(status_code=404, detail="API key not found")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"key_id": api_key.id,
|
"key_id": str(api_key.id),
|
||||||
"name": api_key.name,
|
"name": api_key.name,
|
||||||
"is_active": api_key.is_active,
|
"is_active": api_key.is_active,
|
||||||
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
"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:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
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(
|
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()
|
).first()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise HTTPException(status_code=404, detail="API key not found")
|
raise HTTPException(status_code=404, detail="API key not found")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Request, status
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import and_, or_
|
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
|
from queries.executor import execute_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/collections", tags=["Collections"])
|
router = APIRouter(prefix="/api/collections", tags=["Collections"])
|
||||||
@@ -49,6 +49,13 @@ def get_current_user_id(request: Request) -> Optional[str]:
|
|||||||
return None
|
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):
|
def log_audit(db, action, entity_type, entity_id, user_id, old_value=None, new_value=None):
|
||||||
try:
|
try:
|
||||||
audit = AuditLog(
|
audit = AuditLog(
|
||||||
@@ -73,12 +80,16 @@ async def list_collections(
|
|||||||
):
|
):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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)
|
query = db.query(Collection)
|
||||||
if user_id:
|
if username:
|
||||||
query = query.filter(
|
user = db.query(User).filter(User.username == username).first()
|
||||||
or_(Collection.created_by == user_id, Collection.is_public == True)
|
if user:
|
||||||
)
|
query = query.filter(
|
||||||
|
or_(Collection.created_by == user.id, Collection.is_public == True)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = query.filter(Collection.is_public == True)
|
||||||
else:
|
else:
|
||||||
query = query.filter(Collection.is_public == True)
|
query = query.filter(Collection.is_public == True)
|
||||||
collections = query.order_by(Collection.created_at.desc()).offset(offset).limit(limit).all()
|
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):
|
async def get_collection(collection_id: str):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not collection:
|
||||||
raise HTTPException(status_code=404, detail="Collection not found")
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
result = collection.to_dict()
|
result = collection.to_dict()
|
||||||
if collection.query_type == "static":
|
if collection.query_type == "static":
|
||||||
links = (
|
links = (
|
||||||
db.query(CollectionBookmark)
|
db.query(CollectionBookmark)
|
||||||
.filter(CollectionBookmark.collection_id == collection_id)
|
.filter(CollectionBookmark.collection_id == parse_uuid(collection_id))
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
result["link_ids"] = [lb.bookmark_id for lb in links]
|
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):
|
async def create_collection(data: CollectionCreate, request: Request):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
try:
|
||||||
user_id = get_current_user_id(request)
|
username = get_current_user_id(request)
|
||||||
if not user_id:
|
if not username:
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
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(
|
collection = Collection(
|
||||||
id=str(uuid.uuid4()),
|
id=uuid.uuid4(),
|
||||||
name=data.name,
|
name=data.name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
query_type=data.query_type,
|
query_type=data.query_type,
|
||||||
query_expression=data.query_expression,
|
query_expression=data.query_expression,
|
||||||
is_public=data.is_public,
|
is_public=data.is_public,
|
||||||
created_by=user_id,
|
created_by=created_by_id,
|
||||||
)
|
)
|
||||||
db.add(collection)
|
db.add(collection)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
if data.query_type == "static" and data.link_ids:
|
if data.query_type == "static" and data.link_ids:
|
||||||
for link_id in 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.add(cb)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(collection)
|
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()
|
return collection.to_dict()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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):
|
async def update_collection(collection_id: str, data: CollectionUpdate, request: Request):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not collection:
|
||||||
raise HTTPException(status_code=404, detail="Collection not found")
|
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.commit()
|
||||||
db.refresh(collection)
|
db.refresh(collection)
|
||||||
user_id = get_current_user_id(request)
|
username = get_current_user_id(request)
|
||||||
log_audit(db, "update", "Collection", collection_id, user_id, old_value=old_value, new_value=collection.to_dict())
|
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()
|
return collection.to_dict()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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):
|
async def delete_collection(collection_id: str, request: Request):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not collection:
|
||||||
raise HTTPException(status_code=404, detail="Collection not found")
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
|
|
||||||
old_value = collection.to_dict()
|
old_value = collection.to_dict()
|
||||||
if collection.query_type == "static":
|
if collection.query_type == "static":
|
||||||
db.query(CollectionBookmark).filter(
|
db.query(CollectionBookmark).filter(
|
||||||
CollectionBookmark.collection_id == collection_id
|
CollectionBookmark.collection_id == collection.id
|
||||||
).delete()
|
).delete()
|
||||||
db.delete(collection)
|
db.delete(collection)
|
||||||
db.commit()
|
db.commit()
|
||||||
user_id = get_current_user_id(request)
|
username = get_current_user_id(request)
|
||||||
log_audit(db, "delete", "Collection", collection_id, user_id, old_value=old_value)
|
user = db.query(User).filter(User.username == username).first() if username else None
|
||||||
return {"message": "Collection deleted successfully", "deleted_id": collection_id}
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -188,7 +211,7 @@ async def delete_collection(collection_id: str, request: Request):
|
|||||||
async def refresh_collection(collection_id: str):
|
async def refresh_collection(collection_id: str):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not collection:
|
||||||
raise HTTPException(status_code=404, detail="Collection not found")
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
if collection.query_type != "dynamic":
|
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]):
|
async def add_links_to_collection(collection_id: str, link_ids: List[str]):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not collection:
|
||||||
raise HTTPException(status_code=404, detail="Collection not found")
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
if collection.query_type != "static":
|
if collection.query_type != "static":
|
||||||
@@ -221,13 +247,17 @@ async def add_links_to_collection(collection_id: str, link_ids: List[str]):
|
|||||||
existing = {
|
existing = {
|
||||||
cb.bookmark_id
|
cb.bookmark_id
|
||||||
for cb in db.query(CollectionBookmark)
|
for cb in db.query(CollectionBookmark)
|
||||||
.filter(CollectionBookmark.collection_id == collection_id)
|
.filter(CollectionBookmark.collection_id == parsed_cid)
|
||||||
.all()
|
.all()
|
||||||
}
|
}
|
||||||
added = 0
|
added = 0
|
||||||
for link_id in link_ids:
|
for link_id in link_ids:
|
||||||
if link_id not in existing:
|
try:
|
||||||
db.add(CollectionBookmark(collection_id=collection_id, bookmark_id=link_id))
|
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
|
added += 1
|
||||||
|
|
||||||
db.commit()
|
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]):
|
async def remove_links_from_collection(collection_id: str, link_ids: List[str]):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not collection:
|
||||||
raise HTTPException(status_code=404, detail="Collection not found")
|
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 = (
|
removed = (
|
||||||
db.query(CollectionBookmark)
|
db.query(CollectionBookmark)
|
||||||
.filter(
|
.filter(
|
||||||
CollectionBookmark.collection_id == collection_id,
|
CollectionBookmark.collection_id == parsed_cid,
|
||||||
CollectionBookmark.bookmark_id.in_(link_ids),
|
CollectionBookmark.bookmark_id.in_(parsed_link_ids),
|
||||||
)
|
)
|
||||||
.delete(synchronize_session=False)
|
.delete(synchronize_session=False)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ def get_current_user_id(request: Request) -> Optional[str]:
|
|||||||
return None
|
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):
|
def log_audit(db, action: str, entity_type: str, entity_id: str, user_id: Optional[str], old_value=None, new_value=None):
|
||||||
try:
|
try:
|
||||||
audit = AuditLog(
|
audit = AuditLog(
|
||||||
@@ -105,7 +112,10 @@ async def list_bookmarks(
|
|||||||
async def get_bookmark(bookmark_id: str):
|
async def get_bookmark(bookmark_id: str):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not bookmark:
|
||||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||||
return bookmark.to_dict()
|
return bookmark.to_dict()
|
||||||
@@ -117,9 +127,14 @@ async def get_bookmark(bookmark_id: str):
|
|||||||
async def create_bookmark(data: BookmarkCreate, request: Request):
|
async def create_bookmark(data: BookmarkCreate, request: Request):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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(
|
bookmark = Bookmark(
|
||||||
id=str(uuid.uuid4()),
|
id=uuid.uuid4(),
|
||||||
url=data.url,
|
url=data.url,
|
||||||
title=data.title,
|
title=data.title,
|
||||||
description=data.description,
|
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):
|
async def update_bookmark(bookmark_id: str, data: BookmarkUpdate, request: Request):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not bookmark:
|
||||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
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.commit()
|
||||||
db.refresh(bookmark)
|
db.refresh(bookmark)
|
||||||
user_id = get_current_user_id(request)
|
username = get_current_user_id(request)
|
||||||
log_audit(db, "update", "Bookmark", bookmark_id, user_id, old_value=old_value, new_value=bookmark.to_dict())
|
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()
|
return bookmark.to_dict()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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):
|
async def delete_bookmark(bookmark_id: str, request: Request):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not bookmark:
|
||||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||||
|
|
||||||
old_value = bookmark.to_dict()
|
old_value = bookmark.to_dict()
|
||||||
db.delete(bookmark)
|
db.delete(bookmark)
|
||||||
db.commit()
|
db.commit()
|
||||||
user_id = get_current_user_id(request)
|
username = get_current_user_id(request)
|
||||||
log_audit(db, "delete", "Bookmark", bookmark_id, user_id, old_value=old_value)
|
user = db.query(User).filter(User.username == username).first() if username else None
|
||||||
return {"message": "Bookmark deleted successfully", "deleted_id": bookmark_id}
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -188,7 +211,10 @@ class TagList(BaseModel):
|
|||||||
async def add_tags(bookmark_id: str, data: TagList, request: Request):
|
async def add_tags(bookmark_id: str, data: TagList, request: Request):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not bookmark:
|
||||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
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):
|
async def remove_tags(bookmark_id: str, data: TagList, request: Request):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not bookmark:
|
||||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,18 @@ class SyncResponse(BaseModel):
|
|||||||
synced_count: int
|
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:
|
def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData]) -> SyncResponse:
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
try:
|
||||||
actions = []
|
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:
|
for bm in browser_bookmarks:
|
||||||
existing = server_bookmarks.get(bm.id)
|
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
|
existing.is_bookmarked = bm.is_bookmarked
|
||||||
actions.append({"type": "update", "link_id": bm.id})
|
actions.append({"type": "update", "link_id": bm.id})
|
||||||
else:
|
else:
|
||||||
|
try:
|
||||||
|
bm_uuid = uuid.UUID(bm.id)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
bm_uuid = uuid.uuid4()
|
||||||
new_bm = Bookmark(
|
new_bm = Bookmark(
|
||||||
id=bm.id,
|
id=bm_uuid,
|
||||||
url=bm.url,
|
url=bm.url,
|
||||||
title=bm.title,
|
title=bm.title,
|
||||||
description=bm.description,
|
description=bm.description,
|
||||||
@@ -86,8 +97,12 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData
|
|||||||
existing.is_bookmarked = bm.is_bookmarked
|
existing.is_bookmarked = bm.is_bookmarked
|
||||||
actions.append({"type": "update", "link_id": bm.id})
|
actions.append({"type": "update", "link_id": bm.id})
|
||||||
else:
|
else:
|
||||||
|
try:
|
||||||
|
bm_uuid = uuid.UUID(bm.id)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
bm_uuid = uuid.uuid4()
|
||||||
new_bm = Bookmark(
|
new_bm = Bookmark(
|
||||||
id=bm.id,
|
id=bm_uuid,
|
||||||
url=bm.url,
|
url=bm.url,
|
||||||
title=bm.title,
|
title=bm.title,
|
||||||
description=bm.description,
|
description=bm.description,
|
||||||
@@ -103,8 +118,12 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData
|
|||||||
|
|
||||||
elif sync_config.mode == "server-authoritative":
|
elif sync_config.mode == "server-authoritative":
|
||||||
if not existing:
|
if not existing:
|
||||||
|
try:
|
||||||
|
bm_uuid = uuid.UUID(bm.id)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
bm_uuid = uuid.uuid4()
|
||||||
new_bm = Bookmark(
|
new_bm = Bookmark(
|
||||||
id=bm.id,
|
id=bm_uuid,
|
||||||
url=bm.url,
|
url=bm.url,
|
||||||
title=bm.title,
|
title=bm.title,
|
||||||
description=bm.description,
|
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}
|
browser_ids = {bm.id for bm in browser_bookmarks}
|
||||||
for server_id in server_bookmarks:
|
for server_id in server_bookmarks:
|
||||||
if server_id not in browser_ids:
|
if server_id not in browser_ids:
|
||||||
db.query(Bookmark).filter(Bookmark.id == server_id).delete()
|
parsed_id = parse_uuid(server_id)
|
||||||
actions.append({"type": "delete", "link_id": server_id})
|
if parsed_id:
|
||||||
|
db.query(Bookmark).filter(Bookmark.id == parsed_id).delete()
|
||||||
|
actions.append({"type": "delete", "link_id": server_id})
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return SyncResponse(actions=actions, synced_count=len(actions))
|
return SyncResponse(actions=actions, synced_count=len(actions))
|
||||||
@@ -155,7 +176,10 @@ async def get_collection(collection_id: str):
|
|||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
try:
|
||||||
from models.base import Collection
|
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:
|
if not collection:
|
||||||
raise HTTPException(status_code=404, detail="Collection not found")
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
return collection.to_dict()
|
return collection.to_dict()
|
||||||
@@ -168,7 +192,10 @@ async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]):
|
|||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
try:
|
||||||
from models.base import Collection, CollectionBookmark
|
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:
|
if not collection:
|
||||||
raise HTTPException(status_code=404, detail="Collection not found")
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
if collection.query_type != "static":
|
if collection.query_type != "static":
|
||||||
@@ -176,16 +203,19 @@ async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]):
|
|||||||
|
|
||||||
added = 0
|
added = 0
|
||||||
for bid in bookmark_ids:
|
for bid in bookmark_ids:
|
||||||
|
parsed_bid = parse_uuid(bid)
|
||||||
|
if parsed_bid is None:
|
||||||
|
continue
|
||||||
existing = (
|
existing = (
|
||||||
db.query(CollectionBookmark)
|
db.query(CollectionBookmark)
|
||||||
.filter(
|
.filter(
|
||||||
CollectionBookmark.collection_id == collection_id,
|
CollectionBookmark.collection_id == parsed_cid,
|
||||||
CollectionBookmark.bookmark_id == bid,
|
CollectionBookmark.bookmark_id == parsed_bid,
|
||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if not existing:
|
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
|
added += 1
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -199,12 +229,15 @@ async def delete_collection(collection_id: str):
|
|||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
try:
|
||||||
from models.base import Collection, CollectionBookmark
|
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:
|
if not collection:
|
||||||
raise HTTPException(status_code=404, detail="Collection not found")
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
|
|
||||||
db.query(CollectionBookmark).filter(
|
db.query(CollectionBookmark).filter(
|
||||||
CollectionBookmark.collection_id == collection_id
|
CollectionBookmark.collection_id == parsed_cid
|
||||||
).delete()
|
).delete()
|
||||||
db.delete(collection)
|
db.delete(collection)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ class TagUpdate(BaseModel):
|
|||||||
description: Optional[str] = Field(None, max_length=500)
|
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])
|
@router.get("/", response_model=List[dict])
|
||||||
async def list_tags(
|
async def list_tags(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -58,7 +65,10 @@ async def tag_count():
|
|||||||
async def get_tag(tag_id: str):
|
async def get_tag(tag_id: str):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not tag:
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
raise HTTPException(status_code=404, detail="Tag not found")
|
||||||
return tag.to_dict()
|
return tag.to_dict()
|
||||||
@@ -86,7 +96,10 @@ async def get_tag_links(
|
|||||||
):
|
):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not tag:
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
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")
|
raise HTTPException(status_code=400, detail="Tag already exists")
|
||||||
|
|
||||||
tag = Tag(
|
tag = Tag(
|
||||||
id=str(uuid.uuid4()),
|
id=uuid.uuid4(),
|
||||||
name=data.name,
|
name=data.name,
|
||||||
color=data.color,
|
color=data.color,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
@@ -129,7 +142,10 @@ async def create_tag(data: TagCreate):
|
|||||||
async def update_tag(tag_id: str, data: TagUpdate):
|
async def update_tag(tag_id: str, data: TagUpdate):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not tag:
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
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):
|
async def delete_tag(tag_id: str):
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
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:
|
if not tag:
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
raise HTTPException(status_code=404, detail="Tag not found")
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ LinkSyncServer - Main Application
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from api.routes import router as api_router
|
from api.routes import router as api_router
|
||||||
@@ -51,5 +52,35 @@ def health():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def index(request):
|
def index():
|
||||||
return templates.TemplateResponse("index.html", {"request": request})
|
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})
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ CREATE TABLE api_keys (
|
|||||||
name VARCHAR(100),
|
name VARCHAR(100),
|
||||||
expires_at TIMESTAMP WITH TIME ZONE,
|
expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
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
|
-- Tags table
|
||||||
@@ -92,7 +93,8 @@ CREATE TABLE audit_log (
|
|||||||
old_value JSONB,
|
old_value JSONB,
|
||||||
new_value JSONB,
|
new_value JSONB,
|
||||||
ip_address INET,
|
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
|
-- Create audit log index
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
environment:
|
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)}
|
- SECRET_KEY=${SECRET_KEY:-$(openssl rand -base64 32)}
|
||||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from sqlalchemy import (
|
|||||||
JSON,
|
JSON,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import relationship, sessionmaker
|
from sqlalchemy.orm import relationship, sessionmaker
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
@@ -58,7 +59,7 @@ class User(Base, TimestampMixin):
|
|||||||
"""User model for authentication."""
|
"""User model for authentication."""
|
||||||
__tablename__ = "users"
|
__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)
|
username = Column(String(100), unique=True, nullable=False, index=True)
|
||||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
password_hash = Column(String(255), nullable=False)
|
password_hash = Column(String(255), nullable=False)
|
||||||
@@ -72,7 +73,7 @@ class User(Base, TimestampMixin):
|
|||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": str(self.id),
|
||||||
"username": self.username,
|
"username": self.username,
|
||||||
"email": self.email,
|
"email": self.email,
|
||||||
"role": self.role,
|
"role": self.role,
|
||||||
@@ -86,8 +87,8 @@ class ApiKey(Base, TimestampMixin):
|
|||||||
"""API Key for authentication."""
|
"""API Key for authentication."""
|
||||||
__tablename__ = "api_keys"
|
__tablename__ = "api_keys"
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
|
||||||
key_hash = Column(String(255), nullable=False, unique=True)
|
key_hash = Column(String(255), nullable=False, unique=True)
|
||||||
name = Column(String(100))
|
name = Column(String(100))
|
||||||
expires_at = Column(DateTime)
|
expires_at = Column(DateTime)
|
||||||
@@ -100,14 +101,14 @@ class Tag(Base, TimestampMixin):
|
|||||||
"""Tag model for bookmarks."""
|
"""Tag model for bookmarks."""
|
||||||
__tablename__ = "tags"
|
__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)
|
name = Column(String(100), unique=True, nullable=False, index=True)
|
||||||
color = Column(String(7))
|
color = Column(String(7))
|
||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": str(self.id),
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
@@ -120,7 +121,7 @@ class Bookmark(Base, TimestampMixin):
|
|||||||
"""Bookmark/Link model with Firefox-compatible fields."""
|
"""Bookmark/Link model with Firefox-compatible fields."""
|
||||||
__tablename__ = "links"
|
__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)
|
url = Column(String(2048), nullable=False, index=True)
|
||||||
title = Column(String(255), nullable=False)
|
title = Column(String(255), nullable=False)
|
||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
@@ -130,8 +131,8 @@ class Bookmark(Base, TimestampMixin):
|
|||||||
path = Column(String(512), nullable=True)
|
path = Column(String(512), nullable=True)
|
||||||
visit_count = Column(Integer, default=0)
|
visit_count = Column(Integer, default=0)
|
||||||
is_bookmarked = Column(Boolean, default=False)
|
is_bookmarked = Column(Boolean, default=False)
|
||||||
source_set_id = Column(String(36), ForeignKey("links.id"))
|
source_set_id = Column(UUID(as_uuid=True), ForeignKey("links.id"))
|
||||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
user = relationship("User", back_populates="bookmarks")
|
user = relationship("User", back_populates="bookmarks")
|
||||||
source_set = relationship("Bookmark", remote_side=[id])
|
source_set = relationship("Bookmark", remote_side=[id])
|
||||||
@@ -139,7 +140,7 @@ class Bookmark(Base, TimestampMixin):
|
|||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": str(self.id),
|
||||||
"url": self.url,
|
"url": self.url,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
@@ -149,8 +150,8 @@ class Bookmark(Base, TimestampMixin):
|
|||||||
"path": self.path,
|
"path": self.path,
|
||||||
"visit_count": self.visit_count,
|
"visit_count": self.visit_count,
|
||||||
"is_bookmarked": self.is_bookmarked,
|
"is_bookmarked": self.is_bookmarked,
|
||||||
"source_set_id": self.source_set_id,
|
"source_set_id": str(self.source_set_id) if self.source_set_id else None,
|
||||||
"user_id": self.user_id,
|
"user_id": str(self.user_id) if self.user_id else None,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at 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,
|
"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."""
|
"""Collection model for bookmark sets."""
|
||||||
__tablename__ = "collections"
|
__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)
|
name = Column(String(200), nullable=False)
|
||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
query_type = Column(String(20), nullable=False)
|
query_type = Column(String(20), nullable=False)
|
||||||
query_expression = Column(JSON)
|
query_expression = Column(JSON)
|
||||||
is_public = Column(Boolean, default=False)
|
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")
|
user = relationship("User", back_populates="collections")
|
||||||
collection_bookmarks = relationship("CollectionBookmark", back_populates="collection")
|
collection_bookmarks = relationship("CollectionBookmark", back_populates="collection")
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": str(self.id),
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
"query_type": self.query_type,
|
"query_type": self.query_type,
|
||||||
"query_expression": self.query_expression,
|
"query_expression": self.query_expression,
|
||||||
"is_public": self.is_public,
|
"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,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_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):
|
class CollectionBookmark(Base, TimestampMixin):
|
||||||
"""Junction table for static collections."""
|
"""Junction table for static collections."""
|
||||||
__tablename__ = "collection_bookmarks"
|
__tablename__ = "collection_links"
|
||||||
|
|
||||||
collection_id = Column(String(36), ForeignKey("collections.id"), primary_key=True)
|
collection_id = Column(UUID(as_uuid=True), ForeignKey("collections.id"), primary_key=True)
|
||||||
bookmark_id = Column(String(36), ForeignKey("links.id"), primary_key=True)
|
bookmark_id = Column(UUID(as_uuid=True), ForeignKey("links.id"), primary_key=True)
|
||||||
|
|
||||||
collection = relationship("Collection", back_populates="collection_bookmarks")
|
collection = relationship("Collection", back_populates="collection_bookmarks")
|
||||||
bookmark = relationship("Bookmark", back_populates="collection_bookmarks")
|
bookmark = relationship("Bookmark", back_populates="collection_bookmarks")
|
||||||
@@ -200,11 +201,11 @@ class AuditLog(Base, TimestampMixin):
|
|||||||
"""Audit log for tracking changes."""
|
"""Audit log for tracking changes."""
|
||||||
__tablename__ = "audit_log"
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
action = Column(String(100), nullable=False)
|
action = Column(String(100), nullable=False)
|
||||||
entity_type = Column(String(50), 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)
|
old_value = Column(JSON)
|
||||||
new_value = Column(JSON)
|
new_value = Column(JSON)
|
||||||
ip_address = Column(String(45))
|
ip_address = Column(String(45))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ psycopg2-binary==2.9.9
|
|||||||
alembic==1.13.1
|
alembic==1.13.1
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
|
PyJWT==2.8.0
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
pycryptodome==3.19.0
|
pycryptodome==3.19.0
|
||||||
bcrypt==4.1.2
|
bcrypt==4.1.2
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
--border: #e2e8f0;
|
--border: #e2e8f0;
|
||||||
--success: #22c55e;
|
--success: #22c55e;
|
||||||
--error: #ef4444;
|
--error: #ef4444;
|
||||||
|
--warning: #f59e0b;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
--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);
|
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 {
|
.hero {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4rem 1rem;
|
padding: 4rem 1rem;
|
||||||
@@ -85,33 +679,6 @@ body {
|
|||||||
justify-content: center;
|
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 {
|
.section {
|
||||||
margin: 3rem 0;
|
margin: 3rem 0;
|
||||||
}
|
}
|
||||||
@@ -207,4 +774,15 @@ body {
|
|||||||
.hero-actions {
|
.hero-actions {
|
||||||
flex-direction: column;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
180
LinkSyncServer/static/js/admin-page.js
Normal file
180
LinkSyncServer/static/js/admin-page.js
Normal 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">✎</button>
|
||||||
|
<button class="btn-icon" data-action="delete" data-id="${user.id}" title="Delete">🗑</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();
|
||||||
|
}
|
||||||
|
});
|
||||||
156
LinkSyncServer/static/js/apikeys-page.js
Normal file
156
LinkSyncServer/static/js/apikeys-page.js
Normal 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">🗑</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();
|
||||||
|
}
|
||||||
|
});
|
||||||
144
LinkSyncServer/static/js/collections-page.js
Normal file
144
LinkSyncServer/static/js/collections-page.js
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
27
LinkSyncServer/static/js/dashboard.js
Normal file
27
LinkSyncServer/static/js/dashboard.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
178
LinkSyncServer/static/js/links-page.js
Normal file
178
LinkSyncServer/static/js/links-page.js
Normal 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">✎</button>
|
||||||
|
<button class="btn-icon" data-action="delete" data-id="${link.id}" title="Delete">🗑</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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -15,7 +15,20 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
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();
|
return response.json();
|
||||||
}
|
}
|
||||||
@@ -50,6 +63,15 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
body: JSON.stringify(data),
|
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) {
|
async executeQuery(expression, limit = 20) {
|
||||||
return apiFetch(`/queries/execute?expression=${encodeURIComponent(expression)}&limit=${limit}`);
|
return apiFetch(`/queries/execute?expression=${encodeURIComponent(expression)}&limit=${limit}`);
|
||||||
},
|
},
|
||||||
@@ -67,8 +89,41 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
localStorage.setItem("token", data.access_token);
|
localStorage.setItem("token", data.access_token);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
async getMe() {
|
||||||
|
return apiFetch("/auth/me");
|
||||||
|
},
|
||||||
logout() {
|
logout() {
|
||||||
localStorage.removeItem("token");
|
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" });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
72
LinkSyncServer/templates/admin.html
Normal file
72
LinkSyncServer/templates/admin.html
Normal 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">×</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 %}
|
||||||
70
LinkSyncServer/templates/apikeys.html
Normal file
70
LinkSyncServer/templates/apikeys.html
Normal 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">×</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">×</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 %}
|
||||||
@@ -10,13 +10,19 @@
|
|||||||
<body>
|
<body>
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="nav-brand">
|
<div class="nav-brand">
|
||||||
<a href="/">LinkSync</a>
|
<a href="/dashboard">LinkSync</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-links">
|
<div class="nav-links" id="nav-links">
|
||||||
<a href="/#links">Links</a>
|
<a href="/dashboard">Dashboard</a>
|
||||||
<a href="/#collections">Collections</a>
|
<a href="/links">Links</a>
|
||||||
<a href="/#queries">Queries</a>
|
<a href="/collections">Collections</a>
|
||||||
<a href="/api/docs" target="_blank">API Docs</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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main class="container">
|
<main class="container">
|
||||||
@@ -26,6 +32,27 @@
|
|||||||
<p>LinkSyncServer © 2026</p>
|
<p>LinkSyncServer © 2026</p>
|
||||||
</footer>
|
</footer>
|
||||||
<script src="/static/js/main.js"></script>
|
<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 %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
68
LinkSyncServer/templates/collections.html
Normal file
68
LinkSyncServer/templates/collections.html
Normal 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">×</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 %}
|
||||||
68
LinkSyncServer/templates/dashboard.html
Normal file
68
LinkSyncServer/templates/dashboard.html
Normal 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 →</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 →</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 →</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">🛠</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">👤</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 %}
|
||||||
@@ -1,60 +1,15 @@
|
|||||||
{% extends "base.html" %}
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
{% block title %}LinkSync - Home{% endblock %}
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
{% block content %}
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<div class="hero">
|
<title>LinkSync</title>
|
||||||
<h1>LinkSync Server</h1>
|
<script>
|
||||||
<p>Self-hosted bookmark server with advanced collection and query capabilities.</p>
|
const token = localStorage.getItem('token');
|
||||||
<div class="hero-actions">
|
window.location.href = token ? '/dashboard' : '/login';
|
||||||
<a href="/api/docs" class="btn btn-primary">API Documentation</a>
|
</script>
|
||||||
<a href="/api/links/" class="btn btn-secondary">Browse Links</a>
|
</head>
|
||||||
</div>
|
<body>
|
||||||
</div>
|
<p>Redirecting...</p>
|
||||||
|
</body>
|
||||||
<section id="links" class="section">
|
</html>
|
||||||
<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> > XOR > AND > OR</p>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
80
LinkSyncServer/templates/links.html
Normal file
80
LinkSyncServer/templates/links.html
Normal 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">×</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 %}
|
||||||
74
LinkSyncServer/templates/login.html
Normal file
74
LinkSyncServer/templates/login.html
Normal 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>
|
||||||
@@ -1,32 +1,88 @@
|
|||||||
"""
|
"""
|
||||||
LinkSyncServer - Test Configuration
|
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
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.orm import sessionmaker
|
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"
|
TEST_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)
|
|
||||||
|
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")
|
@pytest.fixture(scope="session")
|
||||||
def test_engine():
|
def test_engine(pytestconfig):
|
||||||
Base.metadata.create_all(bind=engine)
|
"""Provides the test engine for direct database access in tests."""
|
||||||
yield engine
|
return pytestconfig._test_engine
|
||||||
Base.metadata.drop_all(bind=engine)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def db_session(test_engine):
|
def db_session(test_engine):
|
||||||
|
"""Provides a transactional session that rolls back after each test."""
|
||||||
connection = test_engine.connect()
|
connection = test_engine.connect()
|
||||||
transaction = connection.begin()
|
transaction = connection.begin()
|
||||||
session = TestingSessionLocal(bind=connection)
|
Session = sessionmaker(bind=connection)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
@@ -37,6 +93,7 @@ def db_session(test_engine):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
|
"""Provides a TestClient with the test database already configured."""
|
||||||
from app import app
|
from app import app
|
||||||
with TestClient(app) as c:
|
with TestClient(app) as c:
|
||||||
yield c
|
yield c
|
||||||
|
|||||||
223
LinkSyncServer/tests/test_admin.py
Normal file
223
LinkSyncServer/tests/test_admin.py
Normal 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
|
||||||
95
LinkSyncServer/tests/test_auth_extended.py
Normal file
95
LinkSyncServer/tests/test_auth_extended.py
Normal 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
|
||||||
93
LinkSyncServer/tests/test_sync.py
Normal file
93
LinkSyncServer/tests/test_sync.py
Normal 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
|
||||||
118
LinkSyncServer/tests/test_tags.py
Normal file
118
LinkSyncServer/tests/test_tags.py
Normal 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
|
||||||
Reference in New Issue
Block a user