regirstation works but shared links broken

This commit is contained in:
2025-12-12 20:38:35 +05:00
parent 4d3085623a
commit 1f088c8fb0
23 changed files with 1739 additions and 5 deletions

View File

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

View File

@@ -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

View File

@@ -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("/")

42
app/models/map_share.py Normal file
View File

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

102
app/routers/map_share.py Normal file
View File

@@ -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

View File

@@ -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,

77
app/routers/websocket.py Normal file
View File

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

53
app/schemas/map_share.py Normal file
View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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()

View File

@@ -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 (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/shared/:token" element={<SharedMap />} />
<Route
path="/"
element={

View File

@@ -1,5 +1,5 @@
import { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, Link } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
export function Login() {
@@ -71,6 +71,13 @@ export function Login() {
>
{isLoading ? 'Logging in...' : 'Login'}
</button>
<div className="text-center mt-4">
<span className="text-gray-600">Don't have an account? </span>
<Link to="/register" className="text-blue-600 hover:text-blue-700 font-medium">
Register
</Link>
</div>
</form>
</div>
</div>

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h1 className="text-2xl font-bold mb-2 text-center text-gray-800">
Create Account
</h1>
<p className="text-center text-gray-600 mb-6">
Join ISP Wiremap
</p>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => 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}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => 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}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => 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}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => 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}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating account...' : 'Register'}
</button>
<div className="text-center mt-4">
<span className="text-gray-600">Already have an account? </span>
<Link to="/login" className="text-blue-600 hover:text-blue-700 font-medium">
Login
</Link>
</div>
</form>
</div>
</div>
);
}

View File

@@ -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<MapLayer>('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 (
<div className="flex-1 flex items-center justify-center bg-gray-100">
@@ -73,7 +96,7 @@ export function MapView({ mapId }: MapViewProps) {
<>
{/* Toolbar for drawing tools */}
<div style={{ position: 'fixed', left: '220px', top: '70px', zIndex: 9999 }}>
<Toolbar mapId={mapId} />
<Toolbar mapId={mapId} onShare={() => setShowShareDialog(true)} />
</div>
{/* Layer switcher */}
@@ -108,6 +131,14 @@ export function MapView({ mapId }: MapViewProps) {
<MapItemsLayer mapId={mapId} refreshTrigger={refreshTrigger} />
</MapContainer>
</div>
{/* Share dialog */}
{showShareDialog && (
<ShareDialog
mapId={mapId}
onClose={() => setShowShareDialog(false)}
/>
)}
</>
);
}

View File

@@ -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<MapShare[]>([]);
const [shareLinks, setShareLinks] = useState<MapShareLink[]>([]);
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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style={{ zIndex: 10001 }}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h2 className="text-xl font-bold text-gray-800">Share Map</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl"
>
×
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200">
<button
onClick={() => setActiveTab('users')}
className={`flex-1 px-6 py-3 font-medium ${
activeTab === 'users'
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-600 hover:text-gray-800'
}`}
>
Share with Users
</button>
<button
onClick={() => setActiveTab('links')}
className={`flex-1 px-6 py-3 font-medium ${
activeTab === 'links'
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-600 hover:text-gray-800'
}`}
>
Public Links
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto" style={{ maxHeight: 'calc(80vh - 200px)' }}>
{activeTab === 'users' && (
<div>
{/* Add user form */}
<form onSubmit={handleShareWithUser} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Share with User (by User ID)
</label>
<div className="flex gap-2">
<input
type="text"
value={newUserId}
onChange={(e) => setNewUserId(e.target.value)}
placeholder="Enter user ID"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md"
disabled={loading}
/>
<select
value={newUserPermission}
onChange={(e) => setNewUserPermission(e.target.value as 'read' | 'edit')}
className="px-3 py-2 border border-gray-300 rounded-md"
disabled={loading}
>
<option value="read">Read-only</option>
<option value="edit">Can Edit</option>
</select>
<button
type="submit"
disabled={loading || !newUserId.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Share
</button>
</div>
</form>
{/* Existing shares */}
<div>
<h3 className="font-medium text-gray-700 mb-3">Current Shares</h3>
{userShares.length === 0 ? (
<p className="text-gray-500 text-sm">No users have access to this map yet.</p>
) : (
<div className="space-y-2">
{userShares.map((share) => (
<div
key={share.id}
className="flex items-center justify-between p-3 border border-gray-200 rounded-md"
>
<div>
<div className="font-medium text-sm">{share.user_id}</div>
<div className="text-xs text-gray-500">
{share.permission === 'read' ? 'Read-only' : 'Can edit'}
</div>
</div>
<button
onClick={() => handleRevokeShare(share.id)}
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded"
>
Revoke
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
{activeTab === 'links' && (
<div>
{/* Create link form */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Create Public Share Link
</label>
<div className="flex gap-2">
<select
value={newLinkPermission}
onChange={(e) => setNewLinkPermission(e.target.value as 'read' | 'edit')}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md"
disabled={loading}
>
<option value="read">Read-only</option>
<option value="edit">Can Edit</option>
</select>
<button
onClick={handleCreateLink}
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Create Link
</button>
</div>
</div>
{/* Existing links */}
<div>
<h3 className="font-medium text-gray-700 mb-3">Active Share Links</h3>
{shareLinks.length === 0 ? (
<p className="text-gray-500 text-sm">No public share links created yet.</p>
) : (
<div className="space-y-3">
{shareLinks.map((link) => (
<div
key={link.id}
className="p-3 border border-gray-200 rounded-md"
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="text-xs text-gray-500 mb-1">
{link.permission === 'read' ? 'Read-only' : 'Can edit'}
Created {new Date(link.created_at).toLocaleDateString()}
</div>
<div className="font-mono text-xs bg-gray-100 p-2 rounded break-all">
{window.location.origin}/shared/{link.token}
</div>
</div>
<button
onClick={() => handleDeleteLink(link.id)}
className="ml-2 text-red-600 hover:bg-red-50 p-1 rounded text-sm"
>
Delete
</button>
</div>
<button
onClick={() => copyLinkToClipboard(link.token)}
className="text-sm text-blue-600 hover:text-blue-700"
>
Copy Link
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="bg-white shadow-lg rounded-lg p-2 space-y-1" style={{ minWidth: '150px' }}>
{/* Share button */}
<button
onClick={onShare}
className="w-full px-3 py-2 rounded text-left flex items-center gap-2 transition-colors bg-green-100 text-green-700 hover:bg-green-200 font-medium mb-2"
title="Share this map"
>
<span className="text-lg">🔗</span>
<span className="text-sm">Share</span>
</button>
<div className="border-t border-gray-200 my-2"></div>
{TOOLS.map((tool) => (
<button
key={tool.id}

View File

@@ -0,0 +1,141 @@
import { useEffect, useRef, useState } from 'react';
import { authService } from '../services/authService';
interface WebSocketMessage {
type: 'connected' | 'item_created' | 'item_updated' | 'item_deleted';
data: any;
}
interface UseMapWebSocketOptions {
mapId: string;
shareToken?: string;
onItemCreated?: (item: any) => void;
onItemUpdated?: (item: any) => void;
onItemDeleted?: (itemId: string) => void;
onConnected?: (data: any) => void;
}
export function useMapWebSocket({
mapId,
shareToken,
onItemCreated,
onItemUpdated,
onItemDeleted,
onConnected,
}: UseMapWebSocketOptions) {
const [isConnected, setIsConnected] = useState(false);
const [permission, setPermission] = useState<'read' | 'edit' | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
useEffect(() => {
const connect = () => {
// Get the token for authenticated users
const token = authService.getAccessToken();
// Build WebSocket URL
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = import.meta.env.VITE_API_URL?.replace(/^https?:\/\//, '') || window.location.host;
let wsUrl = `${wsProtocol}//${wsHost}/ws/maps/${mapId}`;
// Add authentication
const params = new URLSearchParams();
if (token) {
params.append('token', token);
}
if (shareToken) {
params.append('share_token', shareToken);
}
if (params.toString()) {
wsUrl += `?${params.toString()}`;
}
console.log('Connecting to WebSocket:', wsUrl);
try {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log('WebSocket connected');
setIsConnected(true);
reconnectAttemptsRef.current = 0;
};
ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
console.log('WebSocket message received:', message);
switch (message.type) {
case 'connected':
setPermission(message.data.permission);
onConnected?.(message.data);
break;
case 'item_created':
onItemCreated?.(message.data);
break;
case 'item_updated':
onItemUpdated?.(message.data);
break;
case 'item_deleted':
onItemDeleted?.(message.data.id);
break;
default:
console.log('Unknown message type:', message.type);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
setIsConnected(false);
wsRef.current = null;
// Attempt to reconnect with exponential backoff
if (reconnectAttemptsRef.current < 5) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`Reconnecting in ${delay}ms...`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
}
};
} catch (error) {
console.error('Error creating WebSocket:', error);
}
};
connect();
// Cleanup on unmount
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [mapId, shareToken, onItemCreated, onItemUpdated, onItemDeleted, onConnected]);
return {
isConnected,
permission,
};
}

View File

@@ -0,0 +1,200 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { LayerSwitcher } from '../components/map/LayerSwitcher';
import { DrawingHandler } from '../components/map/DrawingHandler';
import { MapItemsLayer } from '../components/map/MapItemsLayer';
import { Toolbar } from '../components/map/Toolbar';
import { useMapWebSocket } from '../hooks/useMapWebSocket';
import { apiClient } from '../services/api';
type MapLayer = 'osm' | 'google' | 'esri';
const MAP_LAYERS = {
osm: {
name: 'OpenStreetMap',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 25,
},
google: {
name: 'Google Satellite',
url: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
attribution: '&copy; Google',
maxZoom: 25,
maxNativeZoom: 22,
},
esri: {
name: 'ESRI Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: 'Tiles &copy; Esri',
maxZoom: 25,
},
};
function MapController() {
const map = useMap();
useEffect(() => {
setTimeout(() => {
map.invalidateSize();
}, 100);
}, [map]);
return null;
}
export function SharedMap() {
const { token } = useParams<{ token: string }>();
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [mapData, setMapData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load map data using share token
useEffect(() => {
const loadMap = async () => {
if (!token) return;
try {
// Make an unauthenticated request with share token
const response = await apiClient.get(`/api/maps/shared/${token}`);
setMapData(response.data);
setLoading(false);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to load shared map');
setLoading(false);
}
};
loadMap();
}, [token]);
const handleItemCreated = () => {
setRefreshTrigger((prev) => prev + 1);
};
// WebSocket connection with share token
const { isConnected, permission } = useMapWebSocket({
mapId: mapData?.id || '',
shareToken: token,
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 shared map:', data);
},
});
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<div className="text-gray-500 text-lg">Loading shared map...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<div className="text-red-600 text-lg mb-2">Error</div>
<div className="text-gray-600">{error}</div>
</div>
</div>
);
}
if (!mapData) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<div className="text-gray-500 text-lg">Map not found</div>
</div>
</div>
);
}
const layer = MAP_LAYERS[activeLayer];
const isReadOnly = permission === 'read';
return (
<div className="flex flex-col h-screen">
{/* Header */}
<div className="bg-white shadow-sm border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800">{mapData.name}</h1>
<p className="text-sm text-gray-500">
{isReadOnly ? 'View-only access' : 'Edit access'} Shared map
</p>
</div>
<div className="flex items-center gap-2">
{isConnected && (
<div className="flex items-center gap-2 text-sm text-green-600">
<div className="w-2 h-2 bg-green-600 rounded-full"></div>
Live
</div>
)}
</div>
</div>
</div>
{/* Map */}
<div className="flex-1 relative">
{/* Toolbar */}
{!isReadOnly && (
<div style={{ position: 'fixed', left: '20px', top: '90px', zIndex: 9999 }}>
<Toolbar mapId={mapData.id} onShare={() => {}} />
</div>
)}
{/* Layer switcher */}
<div style={{ position: 'fixed', right: '20px', top: '90px', zIndex: 9999 }}>
<LayerSwitcher
activeLayer={activeLayer}
onLayerChange={setActiveLayer}
layers={MAP_LAYERS}
/>
</div>
<MapContainer
center={[0, 0]}
zoom={2}
className="h-full w-full"
style={{ background: '#f0f0f0' }}
>
<MapController />
<TileLayer
key={activeLayer}
url={layer.url}
attribution={layer.attribution}
maxZoom={layer.maxZoom}
maxNativeZoom={layer.maxNativeZoom}
/>
{/* Drawing handler for edit access */}
{!isReadOnly && (
<DrawingHandler mapId={mapData.id} onItemCreated={handleItemCreated} />
)}
{/* Render existing map items */}
<MapItemsLayer mapId={mapData.id} refreshTrigger={refreshTrigger} />
</MapContainer>
</div>
</div>
);
}

View File

@@ -1,7 +1,12 @@
import { apiClient } from './api';
import type { LoginRequest, TokenResponse, User } from '../types/auth';
import type { LoginRequest, RegisterRequest, TokenResponse, User, UserWithToken } from '../types/auth';
export const authService = {
async register(data: RegisterRequest): Promise<UserWithToken> {
const response = await apiClient.post<UserWithToken>('/api/auth/register', data);
return response.data;
},
async login(credentials: LoginRequest): Promise<TokenResponse> {
const response = await apiClient.post<TokenResponse>('/api/auth/login', credentials);
return response.data;

View File

@@ -0,0 +1,75 @@
import { apiClient } from './api';
export interface MapShare {
id: string;
map_id: string;
user_id: string;
permission: 'read' | 'edit';
shared_by: string | null;
created_at: string;
updated_at: string;
}
export interface MapShareLink {
id: string;
map_id: string;
token: string;
permission: 'read' | 'edit';
is_active: boolean;
created_by: string | null;
expires_at: string | null;
created_at: string;
updated_at: string;
}
export interface CreateMapShare {
user_id: string;
permission: 'read' | 'edit';
}
export interface CreateShareLink {
permission: 'read' | 'edit';
expires_at?: string;
}
export const mapShareService = {
// Share with specific user
async shareWithUser(mapId: string, data: CreateMapShare): Promise<MapShare> {
const response = await apiClient.post<MapShare>(`/api/maps/${mapId}/share/users`, data);
return response.data;
},
// Get all user shares for a map
async getUserShares(mapId: string): Promise<MapShare[]> {
const response = await apiClient.get<MapShare[]>(`/api/maps/${mapId}/share/users`);
return response.data;
},
// Update share permission
async updateShare(mapId: string, shareId: string, permission: 'read' | 'edit'): Promise<MapShare> {
const response = await apiClient.put<MapShare>(`/api/maps/${mapId}/share/users/${shareId}`, { permission });
return response.data;
},
// Revoke user share
async revokeShare(mapId: string, shareId: string): Promise<void> {
await apiClient.delete(`/api/maps/${mapId}/share/users/${shareId}`);
},
// Create public share link
async createShareLink(mapId: string, data: CreateShareLink): Promise<MapShareLink> {
const response = await apiClient.post<MapShareLink>(`/api/maps/${mapId}/share/links`, data);
return response.data;
},
// Get all share links for a map
async getShareLinks(mapId: string): Promise<MapShareLink[]> {
const response = await apiClient.get<MapShareLink[]>(`/api/maps/${mapId}/share/links`);
return response.data;
},
// Delete share link
async deleteShareLink(mapId: string, linkId: string): Promise<void> {
await apiClient.delete(`/api/maps/${mapId}/share/links/${linkId}`);
},
};

View File

@@ -8,6 +8,7 @@ interface AuthState {
isLoading: boolean;
error: string | null;
register: (username: string, email: string, password: string) => Promise<void>;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
loadUser: () => Promise<void>;
@@ -20,6 +21,33 @@ export const useAuthStore = create<AuthState>((set) => ({
isLoading: false,
error: null,
register: async (username, email, password) => {
set({ isLoading: true, error: null });
try {
const response = await authService.register({ username, email, password });
authService.saveTokens({
access_token: response.access_token,
refresh_token: response.refresh_token,
token_type: response.token_type
});
const user: User = {
id: response.id,
username: response.username,
email: response.email,
is_admin: response.is_admin,
created_at: response.created_at,
updated_at: response.updated_at
};
set({ user, isAuthenticated: true, isLoading: false });
} catch (error: any) {
const message = error.response?.data?.detail || 'Registration failed';
set({ error: message, isLoading: false, isAuthenticated: false });
throw error;
}
},
login: async (username, password) => {
set({ isLoading: true, error: null });
try {

View File

@@ -12,6 +12,12 @@ export interface LoginRequest {
password: string;
}
export interface RegisterRequest {
username: string;
email: string;
password: string;
}
export interface TokenResponse {
access_token: string;
refresh_token: string;