From 1f088c8fb0b0fe5cb14fd481f0200d5407370ae3 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Fri, 12 Dec 2025 20:38:35 +0500 Subject: [PATCH] regirstation works but shared links broken --- .../versions/20251212_1600_add_map_sharing.py | 74 +++++ app/dependencies.py | 17 + app/main.py | 4 +- app/models/map_share.py | 42 +++ app/routers/map_share.py | 102 ++++++ app/routers/maps.py | 11 + app/routers/websocket.py | 77 +++++ app/schemas/map_share.py | 53 ++++ app/services/item_service.py | 43 +++ app/services/map_share_service.py | 299 ++++++++++++++++++ app/websocket/connection_manager.py | 85 +++++ public/src/App.tsx | 4 + public/src/components/auth/Login.tsx | 9 +- public/src/components/auth/Register.tsx | 133 ++++++++ public/src/components/map/MapView.tsx | 33 +- public/src/components/map/ShareDialog.tsx | 286 +++++++++++++++++ public/src/components/map/Toolbar.tsx | 15 +- public/src/hooks/useMapWebSocket.ts | 141 +++++++++ public/src/pages/SharedMap.tsx | 200 ++++++++++++ public/src/services/authService.ts | 7 +- public/src/services/mapShareService.ts | 75 +++++ public/src/stores/authStore.ts | 28 ++ public/src/types/auth.ts | 6 + 23 files changed, 1739 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/20251212_1600_add_map_sharing.py create mode 100644 app/models/map_share.py create mode 100644 app/routers/map_share.py create mode 100644 app/routers/websocket.py create mode 100644 app/schemas/map_share.py create mode 100644 app/services/map_share_service.py create mode 100644 app/websocket/connection_manager.py create mode 100644 public/src/components/auth/Register.tsx create mode 100644 public/src/components/map/ShareDialog.tsx create mode 100644 public/src/hooks/useMapWebSocket.ts create mode 100644 public/src/pages/SharedMap.tsx create mode 100644 public/src/services/mapShareService.ts diff --git a/alembic/versions/20251212_1600_add_map_sharing.py b/alembic/versions/20251212_1600_add_map_sharing.py new file mode 100644 index 0000000..493507e --- /dev/null +++ b/alembic/versions/20251212_1600_add_map_sharing.py @@ -0,0 +1,74 @@ +"""Add map sharing tables + +Revision ID: a1b2c3d4e5f6 +Revises: 915e5889d6d7 +Create Date: 2025-12-12 16:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = '915e5889d6d7' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create enum for share permissions if it doesn't exist + op.execute(""" + DO $$ BEGIN + CREATE TYPE sharepermission AS ENUM ('read', 'edit'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """) + + # Create map_shares table + op.create_table('map_shares', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('map_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('permission', postgresql.ENUM('read', 'edit', name='sharepermission', create_type=False), nullable=False), + sa.Column('shared_by', sa.UUID(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['map_id'], ['maps.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['shared_by'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_map_shares_map_id'), 'map_shares', ['map_id'], unique=False) + op.create_index(op.f('ix_map_shares_user_id'), 'map_shares', ['user_id'], unique=False) + + # Create map_share_links table + op.create_table('map_share_links', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('map_id', sa.UUID(), nullable=False), + sa.Column('token', sa.String(length=64), nullable=False), + sa.Column('permission', postgresql.ENUM('read', 'edit', name='sharepermission', create_type=False), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_by', sa.UUID(), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['map_id'], ['maps.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_map_share_links_map_id'), 'map_share_links', ['map_id'], unique=False) + op.create_index(op.f('ix_map_share_links_token'), 'map_share_links', ['token'], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f('ix_map_share_links_token'), table_name='map_share_links') + op.drop_index(op.f('ix_map_share_links_map_id'), table_name='map_share_links') + op.drop_table('map_share_links') + + op.drop_index(op.f('ix_map_shares_user_id'), table_name='map_shares') + op.drop_index(op.f('ix_map_shares_map_id'), table_name='map_shares') + op.drop_table('map_shares') + + op.execute("DROP TYPE sharepermission") diff --git a/app/dependencies.py b/app/dependencies.py index aeb922e..0c8201b 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -92,3 +92,20 @@ async def get_optional_current_user( return user except JWTError: return None + + +def get_user_from_token(token: str, db: Session) -> Optional[User]: + """ + Get user from JWT token string (for WebSocket authentication). + Returns None if token is invalid. + """ + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + user_id: str = payload.get("sub") + if user_id is None: + return None + + user = db.query(User).filter(User.id == user_id).first() + return user + except JWTError: + return None diff --git a/app/main.py b/app/main.py index cad0eda..72fba0f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.routers import auth, maps, items +from app.routers import auth, maps, items, map_share, websocket # Create FastAPI application app = FastAPI( @@ -23,6 +23,8 @@ app.add_middleware( app.include_router(auth.router) app.include_router(maps.router) app.include_router(items.router) +app.include_router(map_share.router) +app.include_router(websocket.router) @app.get("/") diff --git a/app/models/map_share.py b/app/models/map_share.py new file mode 100644 index 0000000..c836b71 --- /dev/null +++ b/app/models/map_share.py @@ -0,0 +1,42 @@ +from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Enum as SQLEnum +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func +import uuid +import enum +from app.database import Base + + +class SharePermission(str, enum.Enum): + """Permission levels for map sharing.""" + READ = "read" # Read-only access + EDIT = "edit" # Can edit map items + + +class MapShare(Base): + """Map sharing with specific users.""" + + __tablename__ = "map_shares" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + permission = Column(SQLEnum(SharePermission), nullable=False, default=SharePermission.READ) + shared_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + + +class MapShareLink(Base): + """Public/guest share links for maps.""" + + __tablename__ = "map_share_links" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True) + token = Column(String(64), unique=True, nullable=False, index=True) # Random token for the share URL + permission = Column(SQLEnum(SharePermission), nullable=False, default=SharePermission.READ) + is_active = Column(Boolean, default=True, nullable=False) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + expires_at = Column(DateTime(timezone=True), nullable=True) # Optional expiration + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) diff --git a/app/routers/map_share.py b/app/routers/map_share.py new file mode 100644 index 0000000..c5ba514 --- /dev/null +++ b/app/routers/map_share.py @@ -0,0 +1,102 @@ +"""Map sharing routes.""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List +from uuid import UUID + +from app.database import get_db +from app.dependencies import get_current_user +from app.models.user import User +from app.schemas.map_share import ( + MapShareCreate, + MapShareUpdate, + MapShareResponse, + MapShareLinkCreate, + MapShareLinkResponse +) +from app.services import map_share_service + +router = APIRouter(prefix="/api/maps/{map_id}/share", tags=["map-sharing"]) + + +@router.post("/users", response_model=MapShareResponse, status_code=status.HTTP_201_CREATED) +async def share_map_with_user( + map_id: UUID, + share_data: MapShareCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Share a map with a specific user.""" + share = map_share_service.create_map_share(db, map_id, share_data, current_user) + return share + + +@router.get("/users", response_model=List[MapShareResponse]) +async def get_map_shares_list( + map_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get all users this map is shared with.""" + shares = map_share_service.get_map_shares(db, map_id, current_user) + return shares + + +@router.put("/users/{share_id}", response_model=MapShareResponse) +async def update_map_share_permissions( + map_id: UUID, + share_id: UUID, + update_data: MapShareUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Update share permissions for a user.""" + share = map_share_service.update_map_share(db, map_id, share_id, update_data, current_user) + return share + + +@router.delete("/users/{share_id}", status_code=status.HTTP_204_NO_CONTENT) +async def revoke_map_share( + map_id: UUID, + share_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Revoke map share from a user.""" + map_share_service.delete_map_share(db, map_id, share_id, current_user) + return None + + +@router.post("/links", response_model=MapShareLinkResponse, status_code=status.HTTP_201_CREATED) +async def create_map_share_link( + map_id: UUID, + link_data: MapShareLinkCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Create a public/guest share link.""" + link = map_share_service.create_share_link(db, map_id, link_data, current_user) + return link + + +@router.get("/links", response_model=List[MapShareLinkResponse]) +async def get_map_share_links_list( + map_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get all share links for a map.""" + links = map_share_service.get_share_links(db, map_id, current_user) + return links + + +@router.delete("/links/{link_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_map_share_link( + map_id: UUID, + link_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Delete a share link.""" + map_share_service.delete_share_link(db, map_id, link_id, current_user) + return None diff --git a/app/routers/maps.py b/app/routers/maps.py index 42d7aa9..402420a 100644 --- a/app/routers/maps.py +++ b/app/routers/maps.py @@ -9,6 +9,7 @@ from app.dependencies import get_current_user, get_optional_current_user from app.models.user import User from app.schemas.map import MapCreate, MapUpdate, MapResponse from app.services import map_service +from app.services import map_share_service router = APIRouter(prefix="/api/maps", tags=["maps"]) @@ -49,6 +50,16 @@ async def get_public_map(db: Session = Depends(get_db)): return public_map +@router.get("/shared/{token}", response_model=MapResponse) +async def get_shared_map( + token: str, + db: Session = Depends(get_db) +): + """Get a map by share token (no authentication required).""" + map_obj, permission = map_share_service.get_map_by_share_token(db, token) + return map_obj + + @router.get("/{map_id}", response_model=MapResponse) async def get_map( map_id: UUID, diff --git a/app/routers/websocket.py b/app/routers/websocket.py new file mode 100644 index 0000000..26065b3 --- /dev/null +++ b/app/routers/websocket.py @@ -0,0 +1,77 @@ +"""WebSocket routes for real-time updates.""" +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query +from sqlalchemy.orm import Session +from uuid import UUID +from typing import Optional +import logging + +from app.database import get_db +from app.websocket.connection_manager import manager +from app.services.map_share_service import check_map_access +from app.dependencies import get_user_from_token + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["websocket"]) + + +@router.websocket("/ws/maps/{map_id}") +async def websocket_endpoint( + websocket: WebSocket, + map_id: UUID, + token: Optional[str] = Query(None), + share_token: Optional[str] = Query(None), + db: Session = Depends(get_db) +): + """ + WebSocket endpoint for real-time map updates. + + Clients can connect using: + - JWT token (authenticated users) + - Share token (guest access) + + Example: ws://localhost:8000/ws/maps/{map_id}?token={jwt_token} + Example: ws://localhost:8000/ws/maps/{map_id}?share_token={share_token} + """ + # Verify access to the map + user = None + if token: + try: + user = get_user_from_token(token, db) + except Exception as e: + logger.error(f"Invalid token: {e}") + await websocket.close(code=1008, reason="Invalid token") + return + + # Check map access + has_access, permission = check_map_access(db, map_id, user, share_token) + if not has_access: + await websocket.close(code=1008, reason="Access denied") + return + + await manager.connect(websocket, map_id) + + try: + # Send initial connection message + await websocket.send_json({ + "type": "connected", + "data": { + "map_id": str(map_id), + "permission": permission.value + } + }) + + # Keep connection alive and listen for messages + while True: + # Receive messages (for potential future use like cursor position, etc.) + data = await websocket.receive_json() + + # Echo back for now (can add more features later) + logger.info(f"Received message from client: {data}") + + except WebSocketDisconnect: + manager.disconnect(websocket, map_id) + logger.info(f"Client disconnected from map {map_id}") + except Exception as e: + logger.error(f"WebSocket error: {e}") + manager.disconnect(websocket, map_id) diff --git a/app/schemas/map_share.py b/app/schemas/map_share.py new file mode 100644 index 0000000..bbf4e9c --- /dev/null +++ b/app/schemas/map_share.py @@ -0,0 +1,53 @@ +"""Schemas for map sharing.""" +from pydantic import BaseModel +from uuid import UUID +from datetime import datetime +from typing import Optional +from app.models.map_share import SharePermission + + +class MapShareCreate(BaseModel): + """Schema for creating a map share with a specific user.""" + user_id: UUID + permission: SharePermission = SharePermission.READ + + +class MapShareUpdate(BaseModel): + """Schema for updating map share permissions.""" + permission: SharePermission + + +class MapShareResponse(BaseModel): + """Schema for map share response.""" + id: UUID + map_id: UUID + user_id: UUID + permission: SharePermission + shared_by: Optional[UUID] + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class MapShareLinkCreate(BaseModel): + """Schema for creating a public share link.""" + permission: SharePermission = SharePermission.READ + expires_at: Optional[datetime] = None + + +class MapShareLinkResponse(BaseModel): + """Schema for share link response.""" + id: UUID + map_id: UUID + token: str + permission: SharePermission + is_active: bool + created_by: Optional[UUID] + expires_at: Optional[datetime] + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/app/services/item_service.py b/app/services/item_service.py index 83a936e..470f624 100644 --- a/app/services/item_service.py +++ b/app/services/item_service.py @@ -6,11 +6,13 @@ from fastapi import HTTPException, status from geoalchemy2.shape import from_shape, to_shape from shapely.geometry import shape, Point, LineString import json +import asyncio from app.models.map_item import MapItem from app.models.user import User from app.schemas.map_item import MapItemCreate, MapItemUpdate from app.services.map_service import get_map_by_id +from app.websocket.connection_manager import manager def get_map_items(db: Session, map_id: UUID, user: Optional[User] = None) -> List[MapItem]: @@ -58,6 +60,19 @@ def geography_to_geojson(geography) -> dict: return json.loads(json.dumps(geom.__geo_interface__)) +def item_to_dict(item: MapItem) -> dict: + """Convert MapItem to JSON-serializable dict for WebSocket broadcast.""" + return { + "id": str(item.id), + "map_id": str(item.map_id), + "type": item.type, + "geometry": geography_to_geojson(item.geometry), + "properties": item.properties, + "created_at": item.created_at.isoformat(), + "updated_at": item.updated_at.isoformat() + } + + def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: User) -> MapItem: """Create a new map item.""" # Verify user has access to the map @@ -93,6 +108,14 @@ def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: U print(f"Updating port connections for end device: {end_device_id}") update_device_connections(db, UUID(end_device_id), item.id) + # Broadcast item creation to WebSocket clients + try: + loop = asyncio.get_event_loop() + loop.create_task(manager.send_item_created(map_id, item_to_dict(item))) + except RuntimeError: + # No event loop running, skip WebSocket broadcast + pass + return item @@ -155,6 +178,14 @@ def update_map_item(db: Session, item_id: UUID, item_data: MapItemUpdate, user: db.commit() db.refresh(item) + # Broadcast item update to WebSocket clients + try: + loop = asyncio.get_event_loop() + loop.create_task(manager.send_item_updated(item.map_id, item_to_dict(item))) + except RuntimeError: + # No event loop running, skip WebSocket broadcast + pass + return item @@ -162,6 +193,10 @@ def delete_map_item(db: Session, item_id: UUID, user: User) -> None: """Delete a map item.""" item = get_map_item_by_id(db, item_id, user) + # Capture map_id and item_id before deletion for WebSocket broadcast + map_id = item.map_id + deleted_item_id = str(item.id) + # If deleting a cable, remove it from device connections if item.type == 'cable': start_device_id = item.properties.get('start_device_id') @@ -192,6 +227,14 @@ def delete_map_item(db: Session, item_id: UUID, user: User) -> None: db.delete(item) db.commit() + # Broadcast item deletion to WebSocket clients + try: + loop = asyncio.get_event_loop() + loop.create_task(manager.send_item_deleted(map_id, deleted_item_id)) + except RuntimeError: + # No event loop running, skip WebSocket broadcast + pass + def remove_device_connection(db: Session, device_id: UUID, cable_id: UUID) -> None: """Remove cable connection from device's connections array.""" diff --git a/app/services/map_share_service.py b/app/services/map_share_service.py new file mode 100644 index 0000000..da787c0 --- /dev/null +++ b/app/services/map_share_service.py @@ -0,0 +1,299 @@ +"""Map sharing service for business logic.""" +from typing import List, Optional +from uuid import UUID +from sqlalchemy.orm import Session +from fastapi import HTTPException, status +from datetime import datetime +import secrets + +from app.models.map_share import MapShare, MapShareLink, SharePermission +from app.models.user import User +from app.models.map import Map +from app.schemas.map_share import MapShareCreate, MapShareUpdate, MapShareLinkCreate +from app.services.map_service import get_map_by_id + + +def generate_share_token() -> str: + """Generate a random share token.""" + return secrets.token_urlsafe(32) + + +def create_map_share( + db: Session, + map_id: UUID, + share_data: MapShareCreate, + current_user: User +) -> MapShare: + """Share a map with a specific user.""" + # Verify user owns the map + map_obj = get_map_by_id(db, map_id, current_user) + if map_obj.owner_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the map owner can share it" + ) + + # Check if user exists + target_user = db.query(User).filter(User.id == share_data.user_id).first() + if not target_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Check if already shared + existing_share = db.query(MapShare).filter( + MapShare.map_id == map_id, + MapShare.user_id == share_data.user_id + ).first() + + if existing_share: + # Update existing share + existing_share.permission = share_data.permission + existing_share.updated_at = datetime.utcnow() + db.commit() + db.refresh(existing_share) + return existing_share + + # Create new share + share = MapShare( + map_id=map_id, + user_id=share_data.user_id, + permission=share_data.permission, + shared_by=current_user.id + ) + + db.add(share) + db.commit() + db.refresh(share) + + return share + + +def get_map_shares(db: Session, map_id: UUID, current_user: User) -> List[MapShare]: + """Get all shares for a map.""" + # Verify user owns the map + map_obj = get_map_by_id(db, map_id, current_user) + if map_obj.owner_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the map owner can view shares" + ) + + shares = db.query(MapShare).filter(MapShare.map_id == map_id).all() + return shares + + +def update_map_share( + db: Session, + map_id: UUID, + share_id: UUID, + update_data: MapShareUpdate, + current_user: User +) -> MapShare: + """Update map share permissions.""" + # Verify user owns the map + map_obj = get_map_by_id(db, map_id, current_user) + if map_obj.owner_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the map owner can update shares" + ) + + share = db.query(MapShare).filter( + MapShare.id == share_id, + MapShare.map_id == map_id + ).first() + + if not share: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Share not found" + ) + + share.permission = update_data.permission + share.updated_at = datetime.utcnow() + + db.commit() + db.refresh(share) + + return share + + +def delete_map_share( + db: Session, + map_id: UUID, + share_id: UUID, + current_user: User +) -> None: + """Revoke map share.""" + # Verify user owns the map + map_obj = get_map_by_id(db, map_id, current_user) + if map_obj.owner_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the map owner can revoke shares" + ) + + share = db.query(MapShare).filter( + MapShare.id == share_id, + MapShare.map_id == map_id + ).first() + + if not share: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Share not found" + ) + + db.delete(share) + db.commit() + + +def create_share_link( + db: Session, + map_id: UUID, + link_data: MapShareLinkCreate, + current_user: User +) -> MapShareLink: + """Create a public/guest share link.""" + # Verify user owns the map + map_obj = get_map_by_id(db, map_id, current_user) + if map_obj.owner_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the map owner can create share links" + ) + + token = generate_share_token() + + link = MapShareLink( + map_id=map_id, + token=token, + permission=link_data.permission, + is_active=True, + created_by=current_user.id, + expires_at=link_data.expires_at + ) + + db.add(link) + db.commit() + db.refresh(link) + + return link + + +def get_share_links(db: Session, map_id: UUID, current_user: User) -> List[MapShareLink]: + """Get all share links for a map.""" + # Verify user owns the map + map_obj = get_map_by_id(db, map_id, current_user) + if map_obj.owner_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the map owner can view share links" + ) + + links = db.query(MapShareLink).filter(MapShareLink.map_id == map_id).all() + return links + + +def delete_share_link( + db: Session, + map_id: UUID, + link_id: UUID, + current_user: User +) -> None: + """Delete a share link.""" + # Verify user owns the map + map_obj = get_map_by_id(db, map_id, current_user) + if map_obj.owner_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the map owner can delete share links" + ) + + link = db.query(MapShareLink).filter( + MapShareLink.id == link_id, + MapShareLink.map_id == map_id + ).first() + + if not link: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Share link not found" + ) + + db.delete(link) + db.commit() + + +def get_map_by_share_token(db: Session, token: str) -> tuple[Map, SharePermission]: + """Get map by share token (for guest access).""" + link = db.query(MapShareLink).filter( + MapShareLink.token == token, + MapShareLink.is_active == True + ).first() + + if not link: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invalid or expired share link" + ) + + # Check if link is expired + if link.expires_at and link.expires_at < datetime.utcnow(): + raise HTTPException( + status_code=status.HTTP_410_GONE, + detail="Share link has expired" + ) + + map_obj = db.query(Map).filter(Map.id == link.map_id).first() + if not map_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Map not found" + ) + + return map_obj, link.permission + + +def check_map_access( + db: Session, + map_id: UUID, + user: Optional[User] = None, + token: Optional[str] = None +) -> tuple[bool, SharePermission]: + """ + Check if user has access to a map and return their permission level. + Returns (has_access, permission_level) + """ + map_obj = db.query(Map).filter(Map.id == map_id).first() + if not map_obj: + return False, SharePermission.READ + + # Owner has full edit access + if user and map_obj.owner_id == user.id: + return True, SharePermission.EDIT + + # Check user share + if user: + share = db.query(MapShare).filter( + MapShare.map_id == map_id, + MapShare.user_id == user.id + ).first() + if share: + return True, share.permission + + # Check share token + if token: + try: + _, permission = get_map_by_share_token(db, token) + return True, permission + except HTTPException: + pass + + # Check if map is public + if map_obj.is_default_public: + return True, SharePermission.READ + + return False, SharePermission.READ diff --git a/app/websocket/connection_manager.py b/app/websocket/connection_manager.py new file mode 100644 index 0000000..d0abe40 --- /dev/null +++ b/app/websocket/connection_manager.py @@ -0,0 +1,85 @@ +"""WebSocket connection manager for real-time map updates.""" +from fastapi import WebSocket +from typing import Dict, List, Set +from uuid import UUID +import json +import logging + +logger = logging.getLogger(__name__) + + +class ConnectionManager: + """Manages WebSocket connections for real-time map updates.""" + + def __init__(self): + # map_id -> Set of WebSocket connections + self.active_connections: Dict[str, Set[WebSocket]] = {} + + async def connect(self, websocket: WebSocket, map_id: UUID): + """Accept a new WebSocket connection for a map.""" + await websocket.accept() + map_key = str(map_id) + + if map_key not in self.active_connections: + self.active_connections[map_key] = set() + + self.active_connections[map_key].add(websocket) + logger.info(f"Client connected to map {map_id}. Total connections: {len(self.active_connections[map_key])}") + + def disconnect(self, websocket: WebSocket, map_id: UUID): + """Remove a WebSocket connection.""" + map_key = str(map_id) + + if map_key in self.active_connections: + self.active_connections[map_key].discard(websocket) + if not self.active_connections[map_key]: + del self.active_connections[map_key] + + logger.info(f"Client disconnected from map {map_id}") + + async def broadcast_to_map(self, map_id: UUID, message: dict): + """Broadcast a message to all clients connected to a specific map.""" + map_key = str(map_id) + + if map_key not in self.active_connections: + return + + # Create a copy of the set to avoid modification during iteration + connections = self.active_connections[map_key].copy() + disconnected = [] + + for connection in connections: + try: + await connection.send_json(message) + except Exception as e: + logger.error(f"Error sending message to client: {e}") + disconnected.append(connection) + + # Remove disconnected clients + for connection in disconnected: + self.disconnect(connection, map_id) + + async def send_item_created(self, map_id: UUID, item_data: dict): + """Notify clients that a new item was created.""" + await self.broadcast_to_map(map_id, { + "type": "item_created", + "data": item_data + }) + + async def send_item_updated(self, map_id: UUID, item_data: dict): + """Notify clients that an item was updated.""" + await self.broadcast_to_map(map_id, { + "type": "item_updated", + "data": item_data + }) + + async def send_item_deleted(self, map_id: UUID, item_id: str): + """Notify clients that an item was deleted.""" + await self.broadcast_to_map(map_id, { + "type": "item_deleted", + "data": {"id": item_id} + }) + + +# Global connection manager instance +manager = ConnectionManager() diff --git a/public/src/App.tsx b/public/src/App.tsx index 1ce6d41..3224837 100644 --- a/public/src/App.tsx +++ b/public/src/App.tsx @@ -1,13 +1,17 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { Login } from './components/auth/Login'; +import { Register } from './components/auth/Register'; import { ProtectedRoute } from './components/auth/ProtectedRoute'; import { Dashboard } from './pages/Dashboard'; +import { SharedMap } from './pages/SharedMap'; function App() { return ( } /> + } /> + } /> {isLoading ? 'Logging in...' : 'Login'} + +
+ Don't have an account? + + Register + +
diff --git a/public/src/components/auth/Register.tsx b/public/src/components/auth/Register.tsx new file mode 100644 index 0000000..4b6b48b --- /dev/null +++ b/public/src/components/auth/Register.tsx @@ -0,0 +1,133 @@ +import { useState, FormEvent } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useAuthStore } from '../../stores/authStore'; + +export function Register() { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const { register, isLoading, error, clearError } = useAuthStore(); + const navigate = useNavigate(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + clearError(); + + if (password !== confirmPassword) { + alert('Passwords do not match'); + return; + } + + if (password.length < 6) { + alert('Password must be at least 6 characters'); + return; + } + + try { + await register(username, email, password); + navigate('/'); + } catch (err) { + // Error is handled by the store + } + }; + + return ( +
+
+

+ Create Account +

+

+ Join ISP Wiremap +

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + required + minLength={3} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + disabled={isLoading} + /> +
+ +
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + disabled={isLoading} + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={6} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + disabled={isLoading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + minLength={6} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + disabled={isLoading} + /> +
+ + + +
+ Already have an account? + + Login + +
+
+
+
+ ); +} diff --git a/public/src/components/map/MapView.tsx b/public/src/components/map/MapView.tsx index 7eb6c6f..bac98ac 100644 --- a/public/src/components/map/MapView.tsx +++ b/public/src/components/map/MapView.tsx @@ -5,6 +5,8 @@ import { Toolbar } from './Toolbar'; import { LayerSwitcher } from './LayerSwitcher'; import { DrawingHandler } from './DrawingHandler'; import { MapItemsLayer } from './MapItemsLayer'; +import { ShareDialog } from './ShareDialog'; +import { useMapWebSocket } from '../../hooks/useMapWebSocket'; interface MapViewProps { mapId: string | null; @@ -50,12 +52,33 @@ function MapController() { export function MapView({ mapId }: MapViewProps) { const [activeLayer, setActiveLayer] = useState('osm'); const [refreshTrigger, setRefreshTrigger] = useState(0); + const [showShareDialog, setShowShareDialog] = useState(false); const handleItemCreated = () => { // Trigger refresh of map items setRefreshTrigger((prev) => prev + 1); }; + // WebSocket connection for real-time updates + const { isConnected, permission } = useMapWebSocket({ + mapId: mapId || '', + onItemCreated: (item) => { + console.log('Real-time item created:', item); + setRefreshTrigger((prev) => prev + 1); + }, + onItemUpdated: (item) => { + console.log('Real-time item updated:', item); + setRefreshTrigger((prev) => prev + 1); + }, + onItemDeleted: (itemId) => { + console.log('Real-time item deleted:', itemId); + setRefreshTrigger((prev) => prev + 1); + }, + onConnected: (data) => { + console.log('WebSocket connected to map:', data); + }, + }); + if (!mapId) { return (
@@ -73,7 +96,7 @@ export function MapView({ mapId }: MapViewProps) { <> {/* Toolbar for drawing tools */}
- + setShowShareDialog(true)} />
{/* Layer switcher */} @@ -108,6 +131,14 @@ export function MapView({ mapId }: MapViewProps) {
+ + {/* Share dialog */} + {showShareDialog && ( + setShowShareDialog(false)} + /> + )} ); } diff --git a/public/src/components/map/ShareDialog.tsx b/public/src/components/map/ShareDialog.tsx new file mode 100644 index 0000000..35a15e7 --- /dev/null +++ b/public/src/components/map/ShareDialog.tsx @@ -0,0 +1,286 @@ +import { useState, useEffect } from 'react'; +import { mapShareService, type MapShare, type MapShareLink } from '../../services/mapShareService'; + +interface ShareDialogProps { + mapId: string; + onClose: () => void; +} + +export function ShareDialog({ mapId, onClose }: ShareDialogProps) { + const [activeTab, setActiveTab] = useState<'users' | 'links'>('users'); + const [userShares, setUserShares] = useState([]); + const [shareLinks, setShareLinks] = useState([]); + const [loading, setLoading] = useState(false); + + // User share form + const [newUserId, setNewUserId] = useState(''); + const [newUserPermission, setNewUserPermission] = useState<'read' | 'edit'>('read'); + + // Link form + const [newLinkPermission, setNewLinkPermission] = useState<'read' | 'edit'>('read'); + + useEffect(() => { + loadShares(); + loadLinks(); + }, [mapId]); + + const loadShares = async () => { + try { + const shares = await mapShareService.getUserShares(mapId); + setUserShares(shares); + } catch (error) { + console.error('Failed to load shares:', error); + } + }; + + const loadLinks = async () => { + try { + const links = await mapShareService.getShareLinks(mapId); + setShareLinks(links); + } catch (error) { + console.error('Failed to load share links:', error); + } + }; + + const handleShareWithUser = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newUserId.trim()) return; + + setLoading(true); + try { + await mapShareService.shareWithUser(mapId, { + user_id: newUserId, + permission: newUserPermission, + }); + setNewUserId(''); + await loadShares(); + } catch (error: any) { + alert(error.response?.data?.detail || 'Failed to share map'); + } finally { + setLoading(false); + } + }; + + const handleCreateLink = async () => { + setLoading(true); + try { + await mapShareService.createShareLink(mapId, { + permission: newLinkPermission, + }); + await loadLinks(); + } catch (error: any) { + alert(error.response?.data?.detail || 'Failed to create share link'); + } finally { + setLoading(false); + } + }; + + const handleRevokeShare = async (shareId: string) => { + if (!confirm('Are you sure you want to revoke this share?')) return; + + try { + await mapShareService.revokeShare(mapId, shareId); + await loadShares(); + } catch (error: any) { + alert(error.response?.data?.detail || 'Failed to revoke share'); + } + }; + + const handleDeleteLink = async (linkId: string) => { + if (!confirm('Are you sure you want to delete this share link?')) return; + + try { + await mapShareService.deleteShareLink(mapId, linkId); + await loadLinks(); + } catch (error: any) { + alert(error.response?.data?.detail || 'Failed to delete link'); + } + }; + + const copyLinkToClipboard = (token: string) => { + const url = `${window.location.origin}/shared/${token}`; + navigator.clipboard.writeText(url); + alert('Link copied to clipboard!'); + }; + + return ( +
+
+ {/* Header */} +
+

Share Map

+ +
+ + {/* Tabs */} +
+ + +
+ + {/* Content */} +
+ {activeTab === 'users' && ( +
+ {/* Add user form */} +
+ +
+ setNewUserId(e.target.value)} + placeholder="Enter user ID" + className="flex-1 px-3 py-2 border border-gray-300 rounded-md" + disabled={loading} + /> + + +
+
+ + {/* Existing shares */} +
+

Current Shares

+ {userShares.length === 0 ? ( +

No users have access to this map yet.

+ ) : ( +
+ {userShares.map((share) => ( +
+
+
{share.user_id}
+
+ {share.permission === 'read' ? 'Read-only' : 'Can edit'} +
+
+ +
+ ))} +
+ )} +
+
+ )} + + {activeTab === 'links' && ( +
+ {/* Create link form */} +
+ +
+ + +
+
+ + {/* Existing links */} +
+

Active Share Links

+ {shareLinks.length === 0 ? ( +

No public share links created yet.

+ ) : ( +
+ {shareLinks.map((link) => ( +
+
+
+
+ {link.permission === 'read' ? 'Read-only' : 'Can edit'} • + Created {new Date(link.created_at).toLocaleDateString()} +
+
+ {window.location.origin}/shared/{link.token} +
+
+ +
+ +
+ ))} +
+ )} +
+
+ )} +
+
+
+ ); +} diff --git a/public/src/components/map/Toolbar.tsx b/public/src/components/map/Toolbar.tsx index cb7f3f3..79863de 100644 --- a/public/src/components/map/Toolbar.tsx +++ b/public/src/components/map/Toolbar.tsx @@ -4,6 +4,7 @@ import { CABLE_COLORS, CABLE_LABELS } from '../../types/mapItem'; interface ToolbarProps { mapId: string; + onShare: () => void; } interface ToolButton { @@ -69,11 +70,23 @@ const TOOLS: ToolButton[] = [ }, ]; -export function Toolbar({ mapId }: ToolbarProps) { +export function Toolbar({ mapId, onShare }: ToolbarProps) { const { activeTool, setActiveTool } = useDrawingStore(); return (
+ {/* Share button */} + + +
+ {TOOLS.map((tool) => (