private shares and revokation works
This commit is contained in:
@@ -63,7 +63,7 @@ async def revoke_map_share(
|
|||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Revoke map share from a user."""
|
"""Revoke map share from a user."""
|
||||||
map_share_service.delete_map_share(db, map_id, share_id, current_user)
|
await map_share_service.delete_map_share(db, map_id, share_id, current_user)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -60,9 +60,20 @@ async def websocket_endpoint(
|
|||||||
# CRITICAL: Close the DB session immediately after authentication
|
# CRITICAL: Close the DB session immediately after authentication
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
# Add to connection manager (don't call accept again)
|
# Add to connection manager (don't call accept again, connect method will accept)
|
||||||
manager.active_connections.setdefault(str(map_id), set()).add(websocket)
|
# Note: We need to call connect but it will try to accept again, so we skip it
|
||||||
logger.info(f"Client connected to map {map_id}. Total connections: {len(manager.active_connections[str(map_id)])}")
|
# Instead, manually add the connection
|
||||||
|
user_id = user.id if user else None
|
||||||
|
map_key = str(map_id)
|
||||||
|
|
||||||
|
if map_key not in manager.active_connections:
|
||||||
|
manager.active_connections[map_key] = set()
|
||||||
|
|
||||||
|
manager.active_connections[map_key].add((websocket, user_id))
|
||||||
|
manager.websocket_to_map[websocket] = map_key
|
||||||
|
|
||||||
|
user_info = f"user {user_id}" if user_id else "guest"
|
||||||
|
logger.info(f"Client ({user_info}) connected to map {map_id}. Total connections: {len(manager.active_connections[map_key])}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Send initial connection message
|
# Send initial connection message
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from app.models.map_share import SharePermission
|
|||||||
|
|
||||||
class MapShareCreate(BaseModel):
|
class MapShareCreate(BaseModel):
|
||||||
"""Schema for creating a map share with a specific user."""
|
"""Schema for creating a map share with a specific user."""
|
||||||
user_id: UUID
|
user_identifier: str # Can be username, email, or UUID
|
||||||
permission: SharePermission = SharePermission.READ
|
permission: SharePermission = SharePermission.READ
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,53 @@ from shapely.geometry import shape, Point, LineString
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from app.models.map_item import MapItem
|
from app.models.map_item import MapItem
|
||||||
|
from app.models.map import Map
|
||||||
|
from app.models.map_share import MapShare, SharePermission
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.map_item import MapItemCreate, MapItemUpdate
|
from app.schemas.map_item import MapItemCreate, MapItemUpdate
|
||||||
from app.services.map_service import get_map_by_id
|
from app.services.map_service import get_map_by_id
|
||||||
|
|
||||||
|
|
||||||
|
def check_edit_permission(db: Session, map_id: UUID, user: User) -> None:
|
||||||
|
"""Check if user has edit permission on a map. Raises exception if not."""
|
||||||
|
map_obj = db.query(Map).filter(Map.id == map_id).first()
|
||||||
|
|
||||||
|
if not map_obj:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Map not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Owner always has edit permission
|
||||||
|
if map_obj.owner_id == user.id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Admin always has edit permission
|
||||||
|
if user.is_admin:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if user has share access
|
||||||
|
share = db.query(MapShare).filter(
|
||||||
|
MapShare.map_id == map_id,
|
||||||
|
MapShare.user_id == user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not share:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You don't have access to this map"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if share permission is EDIT
|
||||||
|
if share.permission != SharePermission.EDIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You only have read-only access to this map"
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def get_map_items(db: Session, map_id: UUID, user: Optional[User] = None) -> List[MapItem]:
|
def get_map_items(db: Session, map_id: UUID, user: Optional[User] = None) -> List[MapItem]:
|
||||||
"""Get all items for a map."""
|
"""Get all items for a map."""
|
||||||
# Verify user has access to the map
|
# Verify user has access to the map
|
||||||
@@ -60,8 +102,8 @@ def geography_to_geojson(geography) -> dict:
|
|||||||
|
|
||||||
def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: User) -> MapItem:
|
def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: User) -> MapItem:
|
||||||
"""Create a new map item."""
|
"""Create a new map item."""
|
||||||
# Verify user has access to the map
|
# Verify user has edit permission on the map
|
||||||
get_map_by_id(db, map_id, user)
|
check_edit_permission(db, map_id, user)
|
||||||
|
|
||||||
# Convert GeoJSON to PostGIS geography
|
# Convert GeoJSON to PostGIS geography
|
||||||
geometry_wkt = geojson_to_geography(item_data.geometry)
|
geometry_wkt = geojson_to_geography(item_data.geometry)
|
||||||
@@ -142,6 +184,9 @@ def update_map_item(db: Session, item_id: UUID, item_data: MapItemUpdate, user:
|
|||||||
"""Update a map item."""
|
"""Update a map item."""
|
||||||
item = get_map_item_by_id(db, item_id, user)
|
item = get_map_item_by_id(db, item_id, user)
|
||||||
|
|
||||||
|
# Verify user has edit permission on the map
|
||||||
|
check_edit_permission(db, item.map_id, user)
|
||||||
|
|
||||||
# Update fields if provided
|
# Update fields if provided
|
||||||
if item_data.type is not None:
|
if item_data.type is not None:
|
||||||
item.type = item_data.type
|
item.type = item_data.type
|
||||||
@@ -162,6 +207,9 @@ def delete_map_item(db: Session, item_id: UUID, user: User) -> None:
|
|||||||
"""Delete a map item."""
|
"""Delete a map item."""
|
||||||
item = get_map_item_by_id(db, item_id, user)
|
item = get_map_item_by_id(db, item_id, user)
|
||||||
|
|
||||||
|
# Verify user has edit permission on the map
|
||||||
|
check_edit_permission(db, item.map_id, user)
|
||||||
|
|
||||||
# Capture map_id and item_id before deletion for WebSocket broadcast
|
# Capture map_id and item_id before deletion for WebSocket broadcast
|
||||||
map_id = item.map_id
|
map_id = item.map_id
|
||||||
deleted_item_id = str(item.id)
|
deleted_item_id = str(item.id)
|
||||||
|
|||||||
@@ -6,12 +6,28 @@ from fastapi import HTTPException, status
|
|||||||
|
|
||||||
from app.models.map import Map
|
from app.models.map import Map
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.map_share import MapShare
|
||||||
from app.schemas.map import MapCreate, MapUpdate
|
from app.schemas.map import MapCreate, MapUpdate
|
||||||
|
|
||||||
|
|
||||||
def get_user_maps(db: Session, user_id: UUID) -> List[Map]:
|
def get_user_maps(db: Session, user_id: UUID) -> List[Map]:
|
||||||
"""Get all maps owned by a user."""
|
"""Get all maps owned by or shared with a user."""
|
||||||
return db.query(Map).filter(Map.owner_id == user_id).order_by(Map.updated_at.desc()).all()
|
# Get owned maps
|
||||||
|
owned_maps = db.query(Map).filter(Map.owner_id == user_id).all()
|
||||||
|
|
||||||
|
# Get shared maps
|
||||||
|
shared_map_ids = db.query(MapShare.map_id).filter(MapShare.user_id == user_id).all()
|
||||||
|
shared_map_ids = [share.map_id for share in shared_map_ids]
|
||||||
|
|
||||||
|
shared_maps = []
|
||||||
|
if shared_map_ids:
|
||||||
|
shared_maps = db.query(Map).filter(Map.id.in_(shared_map_ids)).all()
|
||||||
|
|
||||||
|
# Combine and sort by updated_at
|
||||||
|
all_maps = owned_maps + shared_maps
|
||||||
|
all_maps.sort(key=lambda m: m.updated_at, reverse=True)
|
||||||
|
|
||||||
|
return all_maps
|
||||||
|
|
||||||
|
|
||||||
def get_map_by_id(db: Session, map_id: UUID, user: Optional[User] = None) -> Map:
|
def get_map_by_id(db: Session, map_id: UUID, user: Optional[User] = None) -> Map:
|
||||||
@@ -26,7 +42,15 @@ def get_map_by_id(db: Session, map_id: UUID, user: Optional[User] = None) -> Map
|
|||||||
|
|
||||||
# If user is provided, check authorization
|
# If user is provided, check authorization
|
||||||
if user:
|
if user:
|
||||||
if map_obj.owner_id != user.id and not user.is_admin:
|
# Check if user is owner, admin, or has been granted access via share
|
||||||
|
is_owner = map_obj.owner_id == user.id
|
||||||
|
is_admin = user.is_admin
|
||||||
|
has_share_access = db.query(MapShare).filter(
|
||||||
|
MapShare.map_id == map_id,
|
||||||
|
MapShare.user_id == user.id
|
||||||
|
).first() is not None
|
||||||
|
|
||||||
|
if not (is_owner or is_admin or has_share_access):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have permission to access this map"
|
detail="You don't have permission to access this map"
|
||||||
|
|||||||
@@ -33,18 +33,37 @@ def create_map_share(
|
|||||||
detail="Only the map owner can share it"
|
detail="Only the map owner can share it"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if user exists
|
# Look up user by username, email, or UUID
|
||||||
target_user = db.query(User).filter(User.id == share_data.user_id).first()
|
target_user = None
|
||||||
|
user_identifier = share_data.user_identifier.strip()
|
||||||
|
|
||||||
|
# Try UUID first
|
||||||
|
try:
|
||||||
|
user_uuid = UUID(user_identifier)
|
||||||
|
target_user = db.query(User).filter(User.id == user_uuid).first()
|
||||||
|
except ValueError:
|
||||||
|
# Not a valid UUID, try username or email
|
||||||
|
target_user = db.query(User).filter(
|
||||||
|
(User.username == user_identifier) | (User.email == user_identifier)
|
||||||
|
).first()
|
||||||
|
|
||||||
if not target_user:
|
if not target_user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="User not found"
|
detail=f"User not found with identifier: {user_identifier}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prevent sharing with self
|
||||||
|
if target_user.id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot share map with yourself"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if already shared
|
# Check if already shared
|
||||||
existing_share = db.query(MapShare).filter(
|
existing_share = db.query(MapShare).filter(
|
||||||
MapShare.map_id == map_id,
|
MapShare.map_id == map_id,
|
||||||
MapShare.user_id == share_data.user_id
|
MapShare.user_id == target_user.id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing_share:
|
if existing_share:
|
||||||
@@ -58,7 +77,7 @@ def create_map_share(
|
|||||||
# Create new share
|
# Create new share
|
||||||
share = MapShare(
|
share = MapShare(
|
||||||
map_id=map_id,
|
map_id=map_id,
|
||||||
user_id=share_data.user_id,
|
user_id=target_user.id,
|
||||||
permission=share_data.permission,
|
permission=share_data.permission,
|
||||||
shared_by=current_user.id
|
shared_by=current_user.id
|
||||||
)
|
)
|
||||||
@@ -120,7 +139,7 @@ def update_map_share(
|
|||||||
return share
|
return share
|
||||||
|
|
||||||
|
|
||||||
def delete_map_share(
|
async def delete_map_share(
|
||||||
db: Session,
|
db: Session,
|
||||||
map_id: UUID,
|
map_id: UUID,
|
||||||
share_id: UUID,
|
share_id: UUID,
|
||||||
@@ -146,9 +165,16 @@ def delete_map_share(
|
|||||||
detail="Share not found"
|
detail="Share not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get user_id before deleting the share
|
||||||
|
revoked_user_id = share.user_id
|
||||||
|
|
||||||
db.delete(share)
|
db.delete(share)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Disconnect the user's WebSocket connections
|
||||||
|
from app.websocket.connection_manager import manager
|
||||||
|
await manager.disconnect_user(map_id, revoked_user_id)
|
||||||
|
|
||||||
|
|
||||||
def create_share_link(
|
def create_share_link(
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""WebSocket connection manager for real-time map updates."""
|
"""WebSocket connection manager for real-time map updates."""
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
from typing import Dict, List, Set
|
from typing import Dict, List, Set, Optional, Tuple
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -12,10 +12,12 @@ class ConnectionManager:
|
|||||||
"""Manages WebSocket connections for real-time map updates."""
|
"""Manages WebSocket connections for real-time map updates."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# map_id -> Set of WebSocket connections
|
# map_id -> Set of (WebSocket, user_id) tuples
|
||||||
self.active_connections: Dict[str, Set[WebSocket]] = {}
|
self.active_connections: Dict[str, Set[Tuple[WebSocket, Optional[UUID]]]] = {}
|
||||||
|
# websocket -> map_id mapping for quick lookup
|
||||||
|
self.websocket_to_map: Dict[WebSocket, str] = {}
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket, map_id: UUID):
|
async def connect(self, websocket: WebSocket, map_id: UUID, user_id: Optional[UUID] = None):
|
||||||
"""Accept a new WebSocket connection for a map."""
|
"""Accept a new WebSocket connection for a map."""
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
map_key = str(map_id)
|
map_key = str(map_id)
|
||||||
@@ -23,20 +25,63 @@ class ConnectionManager:
|
|||||||
if map_key not in self.active_connections:
|
if map_key not in self.active_connections:
|
||||||
self.active_connections[map_key] = set()
|
self.active_connections[map_key] = set()
|
||||||
|
|
||||||
self.active_connections[map_key].add(websocket)
|
# Store websocket with user_id
|
||||||
logger.info(f"Client connected to map {map_id}. Total connections: {len(self.active_connections[map_key])}")
|
self.active_connections[map_key].add((websocket, user_id))
|
||||||
|
self.websocket_to_map[websocket] = map_key
|
||||||
|
|
||||||
|
user_info = f"user {user_id}" if user_id else "guest"
|
||||||
|
logger.info(f"Client ({user_info}) connected to map {map_id}. Total connections: {len(self.active_connections[map_key])}")
|
||||||
|
|
||||||
def disconnect(self, websocket: WebSocket, map_id: UUID):
|
def disconnect(self, websocket: WebSocket, map_id: UUID):
|
||||||
"""Remove a WebSocket connection."""
|
"""Remove a WebSocket connection."""
|
||||||
map_key = str(map_id)
|
map_key = str(map_id)
|
||||||
|
|
||||||
if map_key in self.active_connections:
|
if map_key in self.active_connections:
|
||||||
self.active_connections[map_key].discard(websocket)
|
# Find and remove the tuple containing this websocket
|
||||||
|
self.active_connections[map_key] = {
|
||||||
|
conn for conn in self.active_connections[map_key]
|
||||||
|
if conn[0] != websocket
|
||||||
|
}
|
||||||
if not self.active_connections[map_key]:
|
if not self.active_connections[map_key]:
|
||||||
del self.active_connections[map_key]
|
del self.active_connections[map_key]
|
||||||
|
|
||||||
|
# Remove from websocket_to_map
|
||||||
|
self.websocket_to_map.pop(websocket, None)
|
||||||
|
|
||||||
logger.info(f"Client disconnected from map {map_id}")
|
logger.info(f"Client disconnected from map {map_id}")
|
||||||
|
|
||||||
|
async def disconnect_user(self, map_id: UUID, user_id: UUID):
|
||||||
|
"""Disconnect all connections for a specific user on a specific map."""
|
||||||
|
map_key = str(map_id)
|
||||||
|
|
||||||
|
if map_key not in self.active_connections:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find all websockets for this user
|
||||||
|
connections_to_close = [
|
||||||
|
websocket for websocket, uid in self.active_connections[map_key]
|
||||||
|
if uid == user_id
|
||||||
|
]
|
||||||
|
|
||||||
|
# Close each connection
|
||||||
|
for websocket in connections_to_close:
|
||||||
|
try:
|
||||||
|
await websocket.close(code=1008, reason="Access revoked")
|
||||||
|
logger.info(f"Closed WebSocket for user {user_id} on map {map_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error closing WebSocket for user {user_id}: {e}")
|
||||||
|
|
||||||
|
# Remove from active connections
|
||||||
|
self.active_connections[map_key] = {
|
||||||
|
conn for conn in self.active_connections[map_key]
|
||||||
|
if conn[0] != websocket
|
||||||
|
}
|
||||||
|
self.websocket_to_map.pop(websocket, None)
|
||||||
|
|
||||||
|
# Clean up empty map entry
|
||||||
|
if not self.active_connections[map_key]:
|
||||||
|
del self.active_connections[map_key]
|
||||||
|
|
||||||
async def broadcast_to_map(self, map_id: UUID, message: dict):
|
async def broadcast_to_map(self, map_id: UUID, message: dict):
|
||||||
"""Broadcast a message to all clients connected to a specific map."""
|
"""Broadcast a message to all clients connected to a specific map."""
|
||||||
map_key = str(map_id)
|
map_key = str(map_id)
|
||||||
@@ -48,16 +93,16 @@ class ConnectionManager:
|
|||||||
connections = self.active_connections[map_key].copy()
|
connections = self.active_connections[map_key].copy()
|
||||||
disconnected = []
|
disconnected = []
|
||||||
|
|
||||||
for connection in connections:
|
for websocket, user_id in connections:
|
||||||
try:
|
try:
|
||||||
await connection.send_json(message)
|
await websocket.send_json(message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending message to client: {e}")
|
logger.error(f"Error sending message to client: {e}")
|
||||||
disconnected.append(connection)
|
disconnected.append(websocket)
|
||||||
|
|
||||||
# Remove disconnected clients
|
# Remove disconnected clients
|
||||||
for connection in disconnected:
|
for websocket in disconnected:
|
||||||
self.disconnect(connection, map_id)
|
self.disconnect(websocket, map_id)
|
||||||
|
|
||||||
async def send_item_created(self, map_id: UUID, item_data: dict):
|
async def send_item_created(self, map_id: UUID, item_data: dict):
|
||||||
"""Notify clients that a new item was created."""
|
"""Notify clients that a new item was created."""
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(user.id);
|
||||||
|
alert('Your User ID has been copied to clipboard!');
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 bg-blue-700 hover:bg-blue-800 rounded text-xs"
|
||||||
|
title="Copy your User ID for sharing"
|
||||||
|
>
|
||||||
|
Copy My ID
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="px-3 py-1 bg-blue-700 hover:bg-blue-800 rounded text-sm"
|
className="px-3 py-1 bg-blue-700 hover:bg-blue-800 rounded text-sm"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useDrawingStore } from '../../stores/drawingStore';
|
|||||||
interface MapItemsLayerProps {
|
interface MapItemsLayerProps {
|
||||||
mapId: string;
|
mapId: string;
|
||||||
refreshTrigger: number;
|
refreshTrigger: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom marker icons for devices using CSS
|
// Custom marker icons for devices using CSS
|
||||||
@@ -58,7 +59,7 @@ const outdoorApIcon = new L.DivIcon({
|
|||||||
iconAnchor: [20, 40],
|
iconAnchor: [20, 40],
|
||||||
});
|
});
|
||||||
|
|
||||||
export function MapItemsLayer({ mapId, refreshTrigger }: MapItemsLayerProps) {
|
export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapItemsLayerProps) {
|
||||||
const [items, setItems] = useState<MapItem[]>([]);
|
const [items, setItems] = useState<MapItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
@@ -145,10 +146,12 @@ export function MapItemsLayer({ mapId, refreshTrigger }: MapItemsLayerProps) {
|
|||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
contextmenu: (e) => {
|
contextmenu: (e) => {
|
||||||
L.DomEvent.stopPropagation(e);
|
L.DomEvent.stopPropagation(e);
|
||||||
|
if (!readOnly) {
|
||||||
setContextMenu({
|
setContextMenu({
|
||||||
item,
|
item,
|
||||||
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
|
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -230,10 +233,12 @@ export function MapItemsLayer({ mapId, refreshTrigger }: MapItemsLayerProps) {
|
|||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
contextmenu: (e) => {
|
contextmenu: (e) => {
|
||||||
L.DomEvent.stopPropagation(e);
|
L.DomEvent.stopPropagation(e);
|
||||||
|
if (!readOnly) {
|
||||||
setContextMenu({
|
setContextMenu({
|
||||||
item,
|
item,
|
||||||
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
|
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -290,10 +295,12 @@ export function MapItemsLayer({ mapId, refreshTrigger }: MapItemsLayerProps) {
|
|||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
contextmenu: (e) => {
|
contextmenu: (e) => {
|
||||||
L.DomEvent.stopPropagation(e);
|
L.DomEvent.stopPropagation(e);
|
||||||
|
if (!readOnly) {
|
||||||
setContextMenu({
|
setContextMenu({
|
||||||
item,
|
item,
|
||||||
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
|
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useMapStore } from '../../stores/mapStore';
|
import { useMapStore } from '../../stores/mapStore';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { mapService } from '../../services/mapService';
|
import { mapService } from '../../services/mapService';
|
||||||
|
|
||||||
interface MapListSidebarProps {
|
interface MapListSidebarProps {
|
||||||
@@ -9,6 +10,7 @@ interface MapListSidebarProps {
|
|||||||
|
|
||||||
export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarProps) {
|
export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarProps) {
|
||||||
const { maps, setMaps, addMap, removeMap, setLoading, setError } = useMapStore();
|
const { maps, setMaps, addMap, removeMap, setLoading, setError } = useMapStore();
|
||||||
|
const { user } = useAuthStore();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [newMapName, setNewMapName] = useState('');
|
const [newMapName, setNewMapName] = useState('');
|
||||||
|
|
||||||
@@ -104,7 +106,11 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-200">
|
<div className="divide-y divide-gray-200">
|
||||||
{maps.map((map) => (
|
{maps.map((map) => {
|
||||||
|
const isOwner = user && map.owner_id === user.id;
|
||||||
|
const isShared = !isOwner;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={map.id}
|
key={map.id}
|
||||||
className={`p-4 cursor-pointer hover:bg-gray-50 ${
|
className={`p-4 cursor-pointer hover:bg-gray-50 ${
|
||||||
@@ -114,7 +120,14 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
|
|||||||
>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-medium text-gray-900">{map.name}</h3>
|
<h3 className="font-medium text-gray-900">{map.name}</h3>
|
||||||
|
{isShared && (
|
||||||
|
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded">
|
||||||
|
Shared
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{map.description && (
|
{map.description && (
|
||||||
<p className="text-sm text-gray-600 mt-1">{map.description}</p>
|
<p className="text-sm text-gray-600 mt-1">{map.description}</p>
|
||||||
)}
|
)}
|
||||||
@@ -122,6 +135,7 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
|
|||||||
{new Date(map.updated_at).toLocaleDateString()}
|
{new Date(map.updated_at).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{isOwner && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -131,9 +145,11 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,7 +96,11 @@ export function MapView({ mapId }: MapViewProps) {
|
|||||||
<>
|
<>
|
||||||
{/* Toolbar for drawing tools */}
|
{/* Toolbar for drawing tools */}
|
||||||
<div style={{ position: 'fixed', left: '220px', top: '70px', zIndex: 9999 }}>
|
<div style={{ position: 'fixed', left: '220px', top: '70px', zIndex: 9999 }}>
|
||||||
<Toolbar mapId={mapId} onShare={() => setShowShareDialog(true)} />
|
<Toolbar
|
||||||
|
mapId={mapId}
|
||||||
|
onShare={() => setShowShareDialog(true)}
|
||||||
|
readOnly={permission === 'read'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Layer switcher */}
|
{/* Layer switcher */}
|
||||||
@@ -124,11 +128,13 @@ export function MapView({ mapId }: MapViewProps) {
|
|||||||
maxNativeZoom={layer.maxNativeZoom}
|
maxNativeZoom={layer.maxNativeZoom}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Drawing handler for creating new items */}
|
{/* Drawing handler for creating new items - disabled for read-only */}
|
||||||
|
{permission !== 'read' && (
|
||||||
<DrawingHandler mapId={mapId} onItemCreated={handleItemCreated} />
|
<DrawingHandler mapId={mapId} onItemCreated={handleItemCreated} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Render existing map items */}
|
{/* Render existing map items */}
|
||||||
<MapItemsLayer mapId={mapId} refreshTrigger={refreshTrigger} />
|
<MapItemsLayer mapId={mapId} refreshTrigger={refreshTrigger} readOnly={permission === 'read'} />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await mapShareService.shareWithUser(mapId, {
|
await mapShareService.shareWithUser(mapId, {
|
||||||
user_id: newUserId,
|
user_identifier: newUserId.trim(),
|
||||||
permission: newUserPermission,
|
permission: newUserPermission,
|
||||||
});
|
});
|
||||||
setNewUserId('');
|
setNewUserId('');
|
||||||
@@ -57,6 +57,7 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
alert('Map shared successfully!');
|
alert('Map shared successfully!');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Share error:', error);
|
console.error('Share error:', error);
|
||||||
|
// Show detailed error message
|
||||||
const message = error.response?.data?.detail || error.message || 'Failed to share map';
|
const message = error.response?.data?.detail || error.message || 'Failed to share map';
|
||||||
alert(message);
|
alert(message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -158,15 +159,18 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
{/* Add user form */}
|
{/* Add user form */}
|
||||||
<form onSubmit={handleShareWithUser} className="mb-6">
|
<form onSubmit={handleShareWithUser} className="mb-6">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Share with User (by User ID)
|
Share with User
|
||||||
</label>
|
</label>
|
||||||
|
<p className="text-xs text-gray-500 mb-2">
|
||||||
|
Enter a username, email, or user ID
|
||||||
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newUserId}
|
value={newUserId}
|
||||||
onChange={(e) => setNewUserId(e.target.value)}
|
onChange={(e) => setNewUserId(e.target.value)}
|
||||||
placeholder="Enter user ID"
|
placeholder="Enter username, email, or user ID"
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md"
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CABLE_COLORS, CABLE_LABELS } from '../../types/mapItem';
|
|||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
mapId: string;
|
mapId: string;
|
||||||
onShare: () => void;
|
onShare: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolButton {
|
interface ToolButton {
|
||||||
@@ -70,11 +71,18 @@ const TOOLS: ToolButton[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Toolbar({ mapId, onShare }: ToolbarProps) {
|
export function Toolbar({ mapId, onShare, readOnly = false }: ToolbarProps) {
|
||||||
const { activeTool, setActiveTool } = useDrawingStore();
|
const { activeTool, setActiveTool } = useDrawingStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow-lg rounded-lg p-2 space-y-1" style={{ minWidth: '150px' }}>
|
<div className="bg-white shadow-lg rounded-lg p-2 space-y-1" style={{ minWidth: '150px' }}>
|
||||||
|
{/* Read-only indicator */}
|
||||||
|
{readOnly && (
|
||||||
|
<div className="w-full px-3 py-2 rounded bg-yellow-100 text-yellow-800 text-xs font-medium mb-2 text-center">
|
||||||
|
Read-Only Mode
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Share button */}
|
{/* Share button */}
|
||||||
<button
|
<button
|
||||||
onClick={onShare}
|
onClick={onShare}
|
||||||
@@ -87,26 +95,32 @@ export function Toolbar({ mapId, onShare }: ToolbarProps) {
|
|||||||
|
|
||||||
<div className="border-t border-gray-200 my-2"></div>
|
<div className="border-t border-gray-200 my-2"></div>
|
||||||
|
|
||||||
{TOOLS.map((tool) => (
|
{TOOLS.map((tool) => {
|
||||||
|
const isDisabled = readOnly && tool.id !== 'select';
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
onClick={() => setActiveTool(tool.id)}
|
onClick={() => !isDisabled && setActiveTool(tool.id)}
|
||||||
|
disabled={isDisabled}
|
||||||
className={`w-full px-3 py-2 rounded text-left flex items-center gap-2 transition-colors ${
|
className={`w-full px-3 py-2 rounded text-left flex items-center gap-2 transition-colors ${
|
||||||
activeTool === tool.id
|
isDisabled
|
||||||
|
? 'opacity-50 cursor-not-allowed text-gray-400'
|
||||||
|
: activeTool === tool.id
|
||||||
? 'bg-blue-100 text-blue-700 font-medium'
|
? 'bg-blue-100 text-blue-700 font-medium'
|
||||||
: 'hover:bg-gray-100 text-gray-700'
|
: 'hover:bg-gray-100 text-gray-700'
|
||||||
}`}
|
}`}
|
||||||
title={tool.description}
|
title={isDisabled ? 'Not available in read-only mode' : tool.description}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="text-lg"
|
className="text-lg"
|
||||||
style={tool.color ? { color: tool.color } : undefined}
|
style={tool.color && !isDisabled ? { color: tool.color } : undefined}
|
||||||
>
|
>
|
||||||
{tool.icon}
|
{tool.icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm">{tool.label}</span>
|
<span className="text-sm">{tool.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user