Compare commits

...

8 Commits

Author SHA1 Message Date
DGSoft
71bea0ae7d Wrap AuthProvider with ToastProvider so AuthContext can use useToast 2025-12-12 12:55:04 +01:00
DGSoft
fac9b2e4ac Add read-marker line in chat: store last-seen timestamps and render subtle horizontal line after last-read message (channels & DMs) 2025-12-12 12:53:52 +01:00
DGSoft
df394b3b7d Add 'read up to here' marker line in chat messages 2025-12-12 12:45:03 +01:00
DGSoft
f52fb50a39 Add session expiration handling with toast notification and auto-redirect to login 2025-12-12 12:39:34 +01:00
DGSoft
c7cfbad3d8 Remove online status display for own messages in chat components 2025-12-12 12:37:08 +01:00
DGSoft
9a557d28a2 Fix user-status endpoint: resolve TypeError from unhashable User objects in deduplication logic 2025-12-12 11:35:23 +01:00
DGSoft
382d4ac3f0 feat: Restrict user-status endpoint to department members and chat partners
- Only return users from same department as current user
- Include users with existing private chat conversations
- Remove current user from results for privacy
- Improve performance by limiting user list
2025-12-12 11:29:13 +01:00
DGSoft
cfd7068af5 feat: Add blinking envelope icons for unread messages
- Implement unread message indicators with Material-UI icons
- Add BlinkingEnvelope component with theme-compatible colors
- Create UnreadMessagesContext for managing unread states
- Integrate WebSocket message handling for real-time notifications
- Icons only appear for inactive channels/DMs, disappear when opened
- Add test functionality (double-click to mark as unread)
- Fix WebSocket URL handling for production deployment
- Unify WebSocket architecture using presence connection for all messages
2025-12-12 11:26:36 +01:00
45 changed files with 1523 additions and 1730 deletions

View File

@ -1,10 +1,13 @@
from fastapi import FastAPI from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pathlib import Path from pathlib import Path
from app.database import create_db_and_tables from app.database import create_db_and_tables, get_session
from app.config import get_settings from app.config import get_settings
from app.routers import auth, departments, channels, messages, files, websocket, snippets, admin, direct_messages, kanban from app.routers import auth, departments, channels, messages, files, websocket, snippets, admin, direct_messages, kanban
from app.auth import get_current_user
from app.models import User, DirectMessage, Department
from sqlmodel import Session, select
settings = get_settings() settings = get_settings()
@ -18,9 +21,10 @@ app = FastAPI(
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=[
settings.frontend_url, "http://localhost:5173",
"http://localhost:5173",
"http://localhost:3000", "http://localhost:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
"https://collabrix.apex-project.de", "https://collabrix.apex-project.de",
"http://collabrix.apex-project.de" "http://collabrix.apex-project.de"
], ],
@ -71,3 +75,73 @@ def read_root():
def health_check(): def health_check():
"""Health check endpoint""" """Health check endpoint"""
return {"status": "healthy"} return {"status": "healthy"}
@app.get("/user-status")
def get_user_statuses(session: Session = Depends(get_session), current_user: User = Depends(get_current_user)):
"""Get online status for users in same department and users with existing private chats"""
from app.websocket import manager
# Get users from same department
department_users = []
if current_user.departments:
dept_ids = [dept.id for dept in current_user.departments]
dept_statement = select(User).where(User.departments.any(Department.id.in_(dept_ids)))
department_users = session.exec(dept_statement).all()
# Get users with existing private chats
# Find all direct messages where current user is sender or receiver
dm_statement = select(DirectMessage).where(
(DirectMessage.sender_id == current_user.id) | (DirectMessage.receiver_id == current_user.id)
)
direct_messages = session.exec(dm_statement).all()
# Extract unique user IDs (excluding current user)
chat_partner_ids = set()
for dm in direct_messages:
if dm.sender_id != current_user.id:
chat_partner_ids.add(dm.sender_id)
if dm.receiver_id != current_user.id:
chat_partner_ids.add(dm.receiver_id)
# Get chat partner users
if chat_partner_ids:
chat_partners_statement = select(User).where(User.id.in_(chat_partner_ids))
chat_partners = session.exec(chat_partners_statement).all()
else:
chat_partners = []
# Combine and deduplicate users by ID
all_user_ids = set()
all_users = []
# Add department users
for user in department_users:
if user.id not in all_user_ids:
all_user_ids.add(user.id)
all_users.append(user)
# Add chat partners
for user in chat_partners:
if user.id not in all_user_ids:
all_user_ids.add(user.id)
all_users.append(user)
# Remove current user from the list
all_users = [user for user in all_users if user.id != current_user.id]
# Get their statuses
statuses = manager.get_all_user_statuses()
# Build response
result = []
for user in all_users:
status = statuses.get(user.id, "offline")
result.append({
"user_id": user.id,
"username": user.username,
"full_name": user.full_name,
"status": status
})
return result

View File

@ -10,6 +10,12 @@ class SnippetVisibility(str, Enum):
ORGANIZATION = "organization" ORGANIZATION = "organization"
class UserRole(str, Enum):
USER = "user"
ADMIN = "admin"
SUPERADMIN = "superadmin"
class Language(SQLModel, table=True): class Language(SQLModel, table=True):
__tablename__ = "language" __tablename__ = "language"
@ -64,7 +70,7 @@ class User(SQLModel, table=True):
profile_picture: Optional[str] = None profile_picture: Optional[str] = None
theme: str = Field(default="light") # 'light' or 'dark' theme: str = Field(default="light") # 'light' or 'dark'
is_active: bool = Field(default=True) is_active: bool = Field(default=True)
is_admin: bool = Field(default=False) role: UserRole = Field(default=UserRole.USER)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships # Relationships

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
from typing import List from typing import List
from app.database import get_session from app.database import get_session
from app.models import User, Department, UserDepartmentLink, Channel, Snippet, SnippetDepartmentLink, Language, Translation from app.models import User, Department, UserDepartmentLink, Channel, Snippet, SnippetDepartmentLink, Language, Translation, UserRole
from app.schemas import ( from app.schemas import (
DepartmentCreate, DepartmentResponse, DepartmentCreate, DepartmentResponse,
ChannelCreate, ChannelResponse, ChannelCreate, ChannelResponse,
@ -32,7 +32,7 @@ class UserDepartmentAssignment(BaseModel):
class UserAdminUpdate(BaseModel): class UserAdminUpdate(BaseModel):
user_id: int user_id: int
is_admin: bool role: UserRole
class SnippetDepartmentAccess(BaseModel): class SnippetDepartmentAccess(BaseModel):
@ -43,8 +43,8 @@ class SnippetDepartmentAccess(BaseModel):
def require_admin(current_user: User = Depends(get_current_user)) -> User: def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""Verify that the current user is an admin""" """Verify that the current user is an admin or superadmin"""
if not current_user.is_admin: if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required" detail="Admin privileges required"
@ -52,12 +52,22 @@ def require_admin(current_user: User = Depends(get_current_user)) -> User:
return current_user return current_user
def require_superadmin(current_user: User = Depends(get_current_user)) -> User:
"""Verify that the current user is a superadmin"""
if current_user.role != UserRole.SUPERADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Superadmin privileges required"
)
return current_user
# ========== User Management ========== # ========== User Management ==========
@router.get("/users", response_model=List[UserResponse]) @router.get("/users", response_model=List[UserResponse])
def get_all_users( def get_all_users(
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Get all users (Admin only)""" """Get all users (Admin only)"""
statement = select(User) statement = select(User)
@ -65,14 +75,14 @@ def get_all_users(
return users return users
@router.patch("/users/{user_id}/admin") @router.patch("/users/{user_id}/role")
def toggle_admin_status( def update_user_role(
user_id: int, user_id: int,
is_admin: bool, role: UserRole,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Make a user admin or remove admin privileges""" """Update a user's role (Superadmin only)"""
user = session.get(User, user_id) user = session.get(User, user_id)
if not user: if not user:
raise HTTPException( raise HTTPException(
@ -80,12 +90,19 @@ def toggle_admin_status(
detail="User not found" detail="User not found"
) )
user.is_admin = is_admin # Prevent superadmin from demoting themselves
if admin.id == user_id and role != UserRole.SUPERADMIN:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot change your own superadmin role"
)
user.role = role
session.add(user) session.add(user)
session.commit() session.commit()
session.refresh(user) session.refresh(user)
return {"message": f"User {user.username} admin status updated", "is_admin": is_admin} return {"message": f"User {user.username} role updated to {role.value}", "role": role}
# ========== Department Management ========== # ========== Department Management ==========
@ -94,7 +111,7 @@ def toggle_admin_status(
def create_department( def create_department(
department_data: DepartmentCreate, department_data: DepartmentCreate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Create a new department (Admin only)""" """Create a new department (Admin only)"""
department = Department(**department_data.model_dump()) department = Department(**department_data.model_dump())
@ -107,7 +124,7 @@ def create_department(
@router.get("/departments", response_model=List[DepartmentResponse]) @router.get("/departments", response_model=List[DepartmentResponse])
def get_all_departments( def get_all_departments(
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Get all departments (Admin only)""" """Get all departments (Admin only)"""
statement = select(Department) statement = select(Department)
@ -120,7 +137,7 @@ def update_department(
department_id: int, department_id: int,
department_data: DepartmentCreate, department_data: DepartmentCreate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Update a department (Admin only)""" """Update a department (Admin only)"""
department = session.get(Department, department_id) department = session.get(Department, department_id)
@ -145,7 +162,7 @@ def toggle_department_snippets(
department_id: int, department_id: int,
enabled: bool, enabled: bool,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Enable or disable snippet access for entire department (master switch)""" """Enable or disable snippet access for entire department (master switch)"""
department = session.get(Department, department_id) department = session.get(Department, department_id)
@ -171,7 +188,7 @@ def toggle_department_snippets(
def delete_department( def delete_department(
department_id: int, department_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Delete a department (Admin only)""" """Delete a department (Admin only)"""
department = session.get(Department, department_id) department = session.get(Department, department_id)
@ -209,7 +226,7 @@ def assign_user_to_department(
department_id: int, department_id: int,
user_id: int, user_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Assign a user to a department (Admin only)""" """Assign a user to a department (Admin only)"""
# Check if department exists # Check if department exists
@ -254,7 +271,7 @@ def remove_user_from_department(
department_id: int, department_id: int,
user_id: int, user_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Remove a user from a department (Admin only)""" """Remove a user from a department (Admin only)"""
statement = select(UserDepartmentLink).where( statement = select(UserDepartmentLink).where(
@ -279,7 +296,7 @@ def remove_user_from_department(
def get_department_members( def get_department_members(
department_id: int, department_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Get all members of a department (Admin only)""" """Get all members of a department (Admin only)"""
department = session.get(Department, department_id) department = session.get(Department, department_id)
@ -352,7 +369,7 @@ def delete_channel(
@router.get("/languages", response_model=List[LanguageResponse]) @router.get("/languages", response_model=List[LanguageResponse])
def get_languages( def get_languages(
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""List all available UI languages.""" """List all available UI languages."""
ensure_default_languages(session) ensure_default_languages(session)
@ -364,7 +381,7 @@ def get_languages(
def create_language( def create_language(
language_data: LanguageCreate, language_data: LanguageCreate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Create a new UI language.""" """Create a new UI language."""
code = language_data.code.strip().lower() code = language_data.code.strip().lower()
@ -408,7 +425,7 @@ def create_language(
def delete_language( def delete_language(
language_id: int, language_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Remove a UI language.""" """Remove a UI language."""
language = session.get(Language, language_id) language = session.get(Language, language_id)
@ -438,7 +455,7 @@ def delete_language(
@router.get("/translations", response_model=List[TranslationGroupResponse]) @router.get("/translations", response_model=List[TranslationGroupResponse])
def get_translations( def get_translations(
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Retrieve translation values grouped by attribute.""" """Retrieve translation values grouped by attribute."""
ensure_default_languages(session) ensure_default_languages(session)
@ -498,7 +515,7 @@ def get_translations(
def update_translation( def update_translation(
payload: TranslationUpdateRequest, payload: TranslationUpdateRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Update a single translation entry.""" """Update a single translation entry."""
translation = session.get(Translation, payload.translation_id) translation = session.get(Translation, payload.translation_id)
@ -527,7 +544,7 @@ def update_translation(
def get_snippet_departments( def get_snippet_departments(
snippet_id: int, snippet_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Get all departments and their access status for a snippet. """Get all departments and their access status for a snippet.
By default, snippets are disabled for all departments. By default, snippets are disabled for all departments.
@ -563,7 +580,7 @@ def get_snippet_departments(
def toggle_snippet_department_access( def toggle_snippet_department_access(
access_data: SnippetDepartmentAccess, access_data: SnippetDepartmentAccess,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Enable or disable a snippet for a specific department. """Enable or disable a snippet for a specific department.
By default, all snippets are disabled for all departments. By default, all snippets are disabled for all departments.

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
from typing import List from typing import List
from app.database import get_session from app.database import get_session
from app.models import Channel, Department, User, KanbanBoard, KanbanColumn from app.models import Channel, Department, User, KanbanBoard, KanbanColumn, UserRole
from app.schemas import ChannelCreate, ChannelResponse from app.schemas import ChannelCreate, ChannelResponse
from app.auth import get_current_user from app.auth import get_current_user
@ -107,7 +107,7 @@ def get_channel(
user = session.exec(statement).first() user = session.exec(statement).first()
user_dept_ids = [dept.id for dept in user.departments] if user else [] user_dept_ids = [dept.id for dept in user.departments] if user else []
if channel.department_id not in user_dept_ids: if channel.department_id not in user_dept_ids and current_user.role != UserRole.SUPERADMIN:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this channel" detail="You don't have access to this channel"
@ -128,7 +128,7 @@ def get_channels_by_department(
user = session.exec(statement).first() user = session.exec(statement).first()
user_dept_ids = [dept.id for dept in user.departments] if user else [] user_dept_ids = [dept.id for dept in user.departments] if user else []
if department_id not in user_dept_ids: if department_id not in user_dept_ids and current_user.role != UserRole.SUPERADMIN:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this department" detail="You don't have access to this department"

View File

@ -1,8 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select, or_, and_ from sqlmodel import Session, select, or_, and_
from sqlalchemy.orm import joinedload
from typing import List from typing import List
from app.database import get_session from app.database import get_session
from app.models import DirectMessage, User from app.models import DirectMessage, User, Snippet
from app.schemas import DirectMessageCreate, DirectMessageResponse from app.schemas import DirectMessageCreate, DirectMessageResponse
from app.auth import get_current_user from app.auth import get_current_user
from app.websocket import manager from app.websocket import manager
@ -50,6 +51,20 @@ async def create_direct_message(
response.sender_full_name = current_user.full_name response.sender_full_name = current_user.full_name
response.sender_profile_picture = current_user.profile_picture response.sender_profile_picture = current_user.profile_picture
# Load snippet data if present
snippet_data = None
if new_message.snippet_id:
snippet = session.get(Snippet, new_message.snippet_id)
if snippet:
snippet_data = {
"id": snippet.id,
"title": snippet.title,
"content": snippet.content,
"language": snippet.language,
"created_at": snippet.created_at.isoformat(),
"updated_at": snippet.updated_at.isoformat()
}
# Broadcast via WebSocket to receiver (using negative user ID as "channel") # Broadcast via WebSocket to receiver (using negative user ID as "channel")
response_data = { response_data = {
"id": new_message.id, "id": new_message.id,
@ -63,7 +78,7 @@ async def create_direct_message(
"created_at": new_message.created_at.isoformat(), "created_at": new_message.created_at.isoformat(),
"is_read": new_message.is_read, "is_read": new_message.is_read,
"snippet_id": new_message.snippet_id, "snippet_id": new_message.snippet_id,
"snippet": None "snippet": snippet_data
} }
# Broadcast to both sender and receiver using their user IDs as "channel" # Broadcast to both sender and receiver using their user IDs as "channel"
@ -76,6 +91,9 @@ async def create_direct_message(
-current_user.id -current_user.id
) )
# Update user activity
manager.update_activity(current_user.id)
return response return response
@ -105,6 +123,7 @@ def get_conversation(
and_(DirectMessage.sender_id == user_id, DirectMessage.receiver_id == current_user.id) and_(DirectMessage.sender_id == user_id, DirectMessage.receiver_id == current_user.id)
) )
) )
.options(joinedload(DirectMessage.snippet))
.order_by(DirectMessage.created_at.desc()) .order_by(DirectMessage.created_at.desc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)

View File

@ -7,7 +7,7 @@ import uuid
import aiofiles import aiofiles
from urllib.parse import quote from urllib.parse import quote
from app.database import get_session from app.database import get_session
from app.models import FileAttachment, Message, User, Channel from app.models import FileAttachment, Message, User, Channel, UserRole
from app.schemas import FileAttachmentResponse, MessageResponse from app.schemas import FileAttachmentResponse, MessageResponse
from app.auth import get_current_user from app.auth import get_current_user
from app.config import get_settings from app.config import get_settings
@ -266,7 +266,8 @@ async def upload_file_with_message(
reply_to_data = { reply_to_data = {
"id": reply_msg.id, "id": reply_msg.id,
"content": reply_msg.content, "content": reply_msg.content,
"sender_username": reply_sender.username if reply_sender else "Unknown" "sender_username": reply_sender.username if reply_sender else "Unknown",
"sender_full_name": reply_sender.full_name if reply_sender else None
} }
attachment_data = { attachment_data = {
@ -409,7 +410,7 @@ async def update_file_permission(
) )
# Check if user is the uploader or an admin # Check if user is the uploader or an admin
if file_attachment.uploader_id != current_user.id and not current_user.is_admin: if file_attachment.uploader_id != current_user.id and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only the uploader or an admin can change file permissions" detail="Only the uploader or an admin can change file permissions"

View File

@ -4,7 +4,7 @@ from typing import List
from app.database import get_session from app.database import get_session
from app.models import ( from app.models import (
KanbanBoard, KanbanColumn, KanbanCard, Channel, User, KanbanBoard, KanbanColumn, KanbanCard, Channel, User,
KanbanChecklist, KanbanChecklistItem KanbanChecklist, KanbanChecklistItem, UserRole
) )
from app.schemas import ( from app.schemas import (
KanbanBoardCreate, KanbanBoardUpdate, KanbanBoardResponse, KanbanBoardCreate, KanbanBoardUpdate, KanbanBoardResponse,
@ -38,7 +38,7 @@ def create_board(
# Check if user has access to the channel's department # Check if user has access to the channel's department
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this channel" detail="Access denied to this channel"
@ -82,7 +82,7 @@ def get_board_by_channel(
) )
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this channel" detail="Access denied to this channel"
@ -141,7 +141,7 @@ def update_board(
# Check access via channel # Check access via channel
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -174,7 +174,7 @@ def create_column(
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -213,7 +213,7 @@ def update_column(
board = session.get(KanbanBoard, column.board_id) board = session.get(KanbanBoard, column.board_id)
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -246,7 +246,7 @@ def delete_column(
board = session.get(KanbanBoard, column.board_id) board = session.get(KanbanBoard, column.board_id)
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -276,7 +276,7 @@ def create_card(
board = session.get(KanbanBoard, column.board_id) board = session.get(KanbanBoard, column.board_id)
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -320,7 +320,7 @@ def update_card(
board = session.get(KanbanBoard, column.board_id) board = session.get(KanbanBoard, column.board_id)
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -354,7 +354,7 @@ def delete_card(
board = session.get(KanbanBoard, column.board_id) board = session.get(KanbanBoard, column.board_id)
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -401,7 +401,7 @@ def move_card(
channel = session.get(Channel, source_board.channel_id) channel = session.get(Channel, source_board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -436,7 +436,7 @@ def create_checklist(
board = session.get(KanbanBoard, card.column.board_id) board = session.get(KanbanBoard, card.column.board_id)
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -473,7 +473,7 @@ def get_checklist(
board = session.get(KanbanBoard, checklist.card.column.board_id) board = session.get(KanbanBoard, checklist.card.column.board_id)
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -501,7 +501,7 @@ def update_checklist(
board = session.get(KanbanBoard, checklist.card.column.board_id) board = session.get(KanbanBoard, checklist.card.column.board_id)
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -537,7 +537,7 @@ def delete_checklist(
board = session.get(KanbanBoard, checklist.card.column.board_id) board = session.get(KanbanBoard, checklist.card.column.board_id)
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -569,7 +569,7 @@ def create_checklist_item(
board = session.get(KanbanBoard, checklist.card.column.board_id) board = session.get(KanbanBoard, checklist.card.column.board_id)
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -608,7 +608,7 @@ def update_checklist_item(
board = session.get(KanbanBoard, item.checklist.card.column.board_id) board = session.get(KanbanBoard, item.checklist.card.column.board_id)
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -646,7 +646,7 @@ def delete_checklist_item(
board = session.get(KanbanBoard, item.checklist.card.column.board_id) board = session.get(KanbanBoard, item.checklist.card.column.board_id)
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"
@ -677,7 +677,7 @@ def get_card_checklists(
board = session.get(KanbanBoard, card.column.board_id) board = session.get(KanbanBoard, card.column.board_id)
channel = session.get(Channel, board.channel_id) channel = session.get(Channel, board.channel_id)
user_departments = [dept.id for dept in current_user.departments] user_departments = [dept.id for dept in current_user.departments]
if channel.department_id not in user_departments and not current_user.is_admin: if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied" detail="Access denied"

View File

@ -4,7 +4,7 @@ from sqlalchemy.orm import joinedload
from typing import List from typing import List
import os import os
from app.database import get_session from app.database import get_session
from app.models import Message, Channel, User, FileAttachment from app.models import Message, Channel, User, FileAttachment, Snippet
from app.schemas import MessageCreate, MessageResponse from app.schemas import MessageCreate, MessageResponse
from app.auth import get_current_user from app.auth import get_current_user
from app.websocket import manager from app.websocket import manager
@ -66,7 +66,22 @@ async def create_message(
reply_to_data = { reply_to_data = {
"id": reply_msg.id, "id": reply_msg.id,
"content": reply_msg.content, "content": reply_msg.content,
"sender_username": reply_sender.username if reply_sender else "Unknown" "sender_username": reply_sender.username if reply_sender else "Unknown",
"sender_full_name": reply_sender.full_name if reply_sender else None
}
# Load snippet data if present
snippet_data = None
if new_message.snippet_id:
snippet = session.get(Snippet, new_message.snippet_id)
if snippet:
snippet_data = {
"id": snippet.id,
"title": snippet.title,
"content": snippet.content,
"language": snippet.language,
"created_at": snippet.created_at.isoformat(),
"updated_at": snippet.updated_at.isoformat()
} }
response_data = { response_data = {
@ -81,7 +96,7 @@ async def create_message(
"snippet_id": new_message.snippet_id, "snippet_id": new_message.snippet_id,
"reply_to_id": new_message.reply_to_id, "reply_to_id": new_message.reply_to_id,
"reply_to": reply_to_data, "reply_to": reply_to_data,
"snippet": None, "snippet": snippet_data,
"attachments": [], "attachments": [],
"is_deleted": False "is_deleted": False
} }
@ -95,6 +110,9 @@ async def create_message(
message_data.channel_id message_data.channel_id
) )
# Update user activity
manager.update_activity(current_user.id)
# Return proper response # Return proper response
response = MessageResponse.model_validate(new_message) response = MessageResponse.model_validate(new_message)
response.sender_username = current_user.username response.sender_username = current_user.username
@ -124,6 +142,7 @@ def get_channel_messages(
select(Message) select(Message)
.where(Message.channel_id == channel_id) .where(Message.channel_id == channel_id)
.options(joinedload(Message.attachments)) .options(joinedload(Message.attachments))
.options(joinedload(Message.snippet))
.order_by(Message.created_at.desc()) .order_by(Message.created_at.desc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
@ -147,7 +166,8 @@ def get_channel_messages(
msg_response.reply_to = { msg_response.reply_to = {
"id": reply_msg.id, "id": reply_msg.id,
"content": reply_msg.content, "content": reply_msg.content,
"sender_username": reply_sender.username if reply_sender else "Unknown" "sender_username": reply_sender.username if reply_sender else "Unknown",
"sender_full_name": reply_sender.full_name if reply_sender else None
} }
responses.append(msg_response) responses.append(msg_response)

View File

@ -3,7 +3,7 @@ from sqlmodel import Session, select, or_, and_
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
from app.database import get_session from app.database import get_session
from app.models import Snippet, User, Department, SnippetVisibility, SnippetDepartmentLink from app.models import Snippet, User, Department, SnippetVisibility, SnippetDepartmentLink, UserRole
from app.schemas import ( from app.schemas import (
SnippetCreate, SnippetCreate,
SnippetUpdate, SnippetUpdate,
@ -83,7 +83,7 @@ def create_snippet(
# Check if user belongs to that department # Check if user belongs to that department
user_dept_ids = [dept.id for dept in current_user.departments] user_dept_ids = [dept.id for dept in current_user.departments]
if snippet_data.department_id not in user_dept_ids: if snippet_data.department_id not in user_dept_ids and current_user.role != UserRole.SUPERADMIN:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="You don't belong to this department" detail="You don't belong to this department"

View File

@ -2,12 +2,12 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
from sqlmodel import Session from sqlmodel import Session
from app.database import get_session from app.database import get_session
from app.websocket import manager from app.websocket import manager
from app.auth import decode_access_token from app.auth import decode_access_token, get_current_user
from app.models import User, Channel from app.models import User, Channel, UserRole
from sqlmodel import select from sqlmodel import select
import json import json
router = APIRouter() router = APIRouter(tags=["WebSocket"])
@router.websocket("/ws/{channel_id}") @router.websocket("/ws/{channel_id}")
@ -34,12 +34,13 @@ async def websocket_endpoint(
return return
# Negative channel_id means direct messages (user_id) # Negative channel_id means direct messages (user_id)
# channel_id 0 means presence-only connection
if channel_id < 0: if channel_id < 0:
# Direct message connection - verify it's the user's own connection # Direct message connection - verify it's the user's own connection
if -channel_id != user.id: if -channel_id != user.id:
await websocket.close(code=1008, reason="Access denied") await websocket.close(code=1008, reason="Access denied")
return return
else: elif channel_id > 0:
# Regular channel - verify channel exists and user has access # Regular channel - verify channel exists and user has access
channel = session.get(Channel, channel_id) channel = session.get(Channel, channel_id)
if not channel: if not channel:
@ -47,12 +48,16 @@ async def websocket_endpoint(
return return
user_dept_ids = [dept.id for dept in user.departments] user_dept_ids = [dept.id for dept in user.departments]
if channel.department_id not in user_dept_ids: if channel.department_id not in user_dept_ids and user.role != UserRole.SUPERADMIN:
await websocket.close(code=1008, reason="Access denied") await websocket.close(code=1008, reason="Access denied")
return return
# channel_id 0 is allowed for presence-only connections
# Connect to channel # Connect to channel
await manager.connect(websocket, channel_id) await manager.connect(websocket, channel_id, user.id)
# Broadcast user status update (online)
await manager.broadcast_user_status_update(user.id, "online")
try: try:
# Send welcome message # Send welcome message
@ -89,7 +94,11 @@ async def websocket_endpoint(
) )
except WebSocketDisconnect: except WebSocketDisconnect:
manager.disconnect(websocket, channel_id) manager.disconnect(websocket, channel_id, user.id)
# Broadcast user status update (offline)
await manager.broadcast_user_status_update(user.id, "offline")
except Exception as e: except Exception as e:
manager.disconnect(websocket, channel_id) manager.disconnect(websocket, channel_id, user.id)
# Broadcast user status update (offline)
await manager.broadcast_user_status_update(user.id, "offline")
print(f"WebSocket error: {e}") print(f"WebSocket error: {e}")

View File

@ -1,6 +1,13 @@
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime
from enum import Enum
class UserRole(str, Enum):
USER = "user"
ADMIN = "admin"
SUPERADMIN = "superadmin"
# User Schemas # User Schemas
@ -31,7 +38,7 @@ class UserLogin(BaseModel):
class UserResponse(UserBase): class UserResponse(UserBase):
id: int id: int
is_active: bool is_active: bool
is_admin: bool = False role: UserRole = UserRole.USER
created_at: datetime created_at: datetime
class Config: class Config:

View File

@ -1,21 +1,33 @@
from fastapi import WebSocket, WebSocketDisconnect from fastapi import WebSocket, WebSocketDisconnect
from typing import Dict, List from typing import Dict, List, Optional
import json import json
import time
from datetime import datetime, timedelta
class ConnectionManager: class ConnectionManager:
def __init__(self): def __init__(self):
# Maps channel_id to list of WebSocket connections # Maps channel_id to list of WebSocket connections
self.active_connections: Dict[int, List[WebSocket]] = {} self.active_connections: Dict[int, List[WebSocket]] = {}
# Maps user_id to their connection info
self.user_connections: Dict[int, Dict] = {}
async def connect(self, websocket: WebSocket, channel_id: int): async def connect(self, websocket: WebSocket, channel_id: int, user_id: int):
"""Accept a new WebSocket connection for a channel""" """Accept a new WebSocket connection for a channel"""
await websocket.accept() await websocket.accept()
if channel_id not in self.active_connections: if channel_id not in self.active_connections:
self.active_connections[channel_id] = [] self.active_connections[channel_id] = []
self.active_connections[channel_id].append(websocket) self.active_connections[channel_id].append(websocket)
# Track user connection
self.user_connections[user_id] = {
'websocket': websocket,
'channel_id': channel_id,
'last_activity': time.time(),
'connected_at': time.time()
}
def disconnect(self, websocket: WebSocket, channel_id: int): def disconnect(self, websocket: WebSocket, channel_id: int, user_id: int):
"""Remove a WebSocket connection""" """Remove a WebSocket connection"""
if channel_id in self.active_connections: if channel_id in self.active_connections:
if websocket in self.active_connections[channel_id]: if websocket in self.active_connections[channel_id]:
@ -24,6 +36,30 @@ class ConnectionManager:
# Clean up empty channel lists # Clean up empty channel lists
if not self.active_connections[channel_id]: if not self.active_connections[channel_id]:
del self.active_connections[channel_id] del self.active_connections[channel_id]
# Remove user connection
if user_id in self.user_connections:
del self.user_connections[user_id]
def update_activity(self, user_id: int):
"""Update last activity time for a user"""
if user_id in self.user_connections:
self.user_connections[user_id]['last_activity'] = time.time()
def get_user_status(self, user_id: int) -> str:
"""Get user online status"""
if user_id not in self.user_connections:
return 'offline'
# User is online as long as they have an active connection
return 'online'
def get_all_user_statuses(self) -> Dict[int, str]:
"""Get status for all users"""
statuses = {}
for user_id in self.user_connections:
statuses[user_id] = self.get_user_status(user_id)
return statuses
async def send_personal_message(self, message: str, websocket: WebSocket): async def send_personal_message(self, message: str, websocket: WebSocket):
"""Send a message to a specific WebSocket""" """Send a message to a specific WebSocket"""
@ -42,9 +78,43 @@ class ConnectionManager:
# Mark for removal if send fails # Mark for removal if send fails
disconnected.append(connection) disconnected.append(connection)
# Remove disconnected clients # Also broadcast to channel 0 (global listeners) for messages
if message.get("type") in ["message", "direct_message"] and 0 in self.active_connections:
for connection in self.active_connections[0]:
try:
await connection.send_text(message_str)
except Exception:
pass
async def broadcast_user_status_update(self, user_id: int, status: str):
"""Broadcast user status update to all connected clients"""
message = {
"type": "user_status_update",
"user_id": user_id,
"status": status,
"timestamp": time.time()
}
# Broadcast to all channels (presence connections are on channel 0)
for channel_id in self.active_connections:
message_str = json.dumps(message)
disconnected = []
for connection in self.active_connections[channel_id]:
try:
await connection.send_text(message_str)
except Exception:
disconnected.append(connection)
# Clean up disconnected clients
for connection in disconnected: for connection in disconnected:
self.disconnect(connection, channel_id) user_id_to_remove = None
for uid, conn_info in self.user_connections.items():
if conn_info['websocket'] == connection:
user_id_to_remove = uid
break
if user_id_to_remove:
self.disconnect(connection, channel_id, user_id_to_remove)
# Global connection manager instance # Global connection manager instance

View File

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
Migration script to migrate is_admin to role column in user table
"""
import sys
import os
# Add parent directory to path to import app modules
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text
from app.database import engine
def migrate():
"""Migrate is_admin column to role column in user table"""
with engine.connect() as conn:
# Check if role column already exists
result = conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='user' AND column_name='role'
"""))
if result.fetchone():
print("✅ Column 'role' already exists in user table")
# Check if is_admin column still exists
result2 = conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='user' AND column_name='is_admin'
"""))
if result2.fetchone():
# Migrate existing admin users
conn.execute(text("""
UPDATE "user" SET role = 'ADMIN' WHERE is_admin = true
"""))
# Set default role for non-admin users
conn.execute(text("""
UPDATE "user" SET role = 'USER' WHERE role IS NULL OR role = 'user'
"""))
# Drop the old is_admin column
conn.execute(text("""
ALTER TABLE "user" DROP COLUMN is_admin
"""))
conn.commit()
print("✅ Migrated existing data and dropped is_admin column")
else:
# Correct any wrong values
conn.execute(text("""
UPDATE "user" SET role = 'ADMIN' WHERE role = 'admin'
"""))
conn.execute(text("""
UPDATE "user" SET role = 'USER' WHERE role = 'user'
"""))
conn.execute(text("""
UPDATE "user" SET role = 'SUPERADMIN' WHERE role = 'superadmin'
"""))
conn.commit()
print("✅ Corrected role values to enum names")
return
# Add role column
conn.execute(text("""
ALTER TABLE "user" ADD COLUMN role VARCHAR(20) DEFAULT 'USER'
"""))
# Migrate existing admin users
conn.execute(text("""
UPDATE "user" SET role = 'ADMIN' WHERE is_admin = true
"""))
# Set default role for non-admin users
conn.execute(text("""
UPDATE "user" SET role = 'USER' WHERE role IS NULL
"""))
# Drop the old is_admin column
conn.execute(text("""
ALTER TABLE "user" DROP COLUMN is_admin
"""))
conn.commit() # Commit the changes
print("✅ Successfully migrated is_admin to role column")
print("✅ Existing admins have been assigned 'admin' role")
print("✅ Other users have been assigned 'user' role")
if __name__ == "__main__":
migrate()

View File

@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""
Script to set Ronny's role to SUPERADMIN
"""
import sys
import os
# Add parent directory to path to import app modules
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text
from app.database import engine
def set_ronny_superadmin():
"""Set Ronny's role to SUPERADMIN"""
with engine.connect() as conn:
# Update Ronny's role
conn.execute(text("""
UPDATE "user" SET role = 'SUPERADMIN' WHERE username = 'Ronny'
"""))
conn.commit()
print("✅ Ronny's role updated to SUPERADMIN")
if __name__ == "__main__":
set_ronny_superadmin()

View File

@ -9,11 +9,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.6",
"@mui/material": "^7.3.6",
"axios": "^1.6.2",
"prism-react-renderer": "^1.3.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0"
"axios": "^1.6.2",
"prism-react-renderer": "^1.3.5"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.43", "@types/react": "^18.2.43",

View File

@ -1,6 +1,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext'; import { useAuth } from './contexts/AuthContext';
import { isAdmin } from './types';
import Login from './components/Auth/Login'; import Login from './components/Auth/Login';
import Register from './components/Auth/Register'; import Register from './components/Auth/Register';
import ChatView from './components/Chat/ChatView'; import ChatView from './components/Chat/ChatView';
@ -22,7 +23,7 @@ const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <Navigate to="/login" />; return <Navigate to="/login" />;
} }
if (!user?.is_admin) { if (!isAdmin(user)) {
return <Navigate to="/" />; return <Navigate to="/" />;
} }
@ -38,7 +39,7 @@ const AppContent: React.FC = () => {
// Speichere nur Pfade innerhalb der geschützten Bereiche (nicht login/register) // Speichere nur Pfade innerhalb der geschützten Bereiche (nicht login/register)
if (isAuthenticated && !location.pathname.startsWith('/login') && !location.pathname.startsWith('/register')) { if (isAuthenticated && !location.pathname.startsWith('/login') && !location.pathname.startsWith('/register')) {
// Prüfe Admin-Berechtigung für Admin-Pfade // Prüfe Admin-Berechtigung für Admin-Pfade
if (location.pathname.startsWith('/admin') && !user?.is_admin) { if (location.pathname.startsWith('/admin') && !isAdmin(user)) {
return; // Speichere keine ungültigen Admin-Pfade return; // Speichere keine ungültigen Admin-Pfade
} }
localStorage.setItem('lastVisitedPath', location.pathname + location.search); localStorage.setItem('lastVisitedPath', location.pathname + location.search);
@ -85,7 +86,7 @@ const RouteRestorer: React.FC = () => {
if (lastVisitedPath && lastVisitedPath !== '/') { if (lastVisitedPath && lastVisitedPath !== '/') {
// Prüfe, ob der Pfad gültig ist // Prüfe, ob der Pfad gültig ist
const isAdminPath = lastVisitedPath.startsWith('/admin'); const isAdminPath = lastVisitedPath.startsWith('/admin');
if (isAdminPath && !user.is_admin) { if (isAdminPath && !isAdmin(user)) {
// Benutzer hat keinen Admin-Zugriff, bleibe auf der Hauptseite // Benutzer hat keinen Admin-Zugriff, bleibe auf der Hauptseite
localStorage.removeItem('lastVisitedPath'); localStorage.removeItem('lastVisitedPath');
sessionStorage.setItem('routeRestored', 'true'); sessionStorage.setItem('routeRestored', 'true');

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import api, { adminLanguagesAPI, adminTranslationsAPI, snippetsAPI } from '../../services/api'; import api, { adminLanguagesAPI, adminTranslationsAPI, snippetsAPI } from '../../services/api';
import type { import type {
Channel, Channel,
@ -8,6 +9,7 @@ import type {
TranslationGroup, TranslationGroup,
User, User,
} from '../../types'; } from '../../types';
import { UserRole, isSuperAdmin } from '../../types';
type TabKey = 'users' | 'departments' | 'channels' | 'snippets' | 'languages'; type TabKey = 'users' | 'departments' | 'channels' | 'snippets' | 'languages';
@ -26,6 +28,7 @@ type SnippetAccessEntry = {
}; };
const AdminPanel: React.FC = () => { const AdminPanel: React.FC = () => {
const { user: currentUser } = useAuth();
const [activeTab, setActiveTab] = useState<TabKey>('users'); const [activeTab, setActiveTab] = useState<TabKey>('users');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -252,14 +255,18 @@ const AdminPanel: React.FC = () => {
usersLoaded, usersLoaded,
]); ]);
const toggleAdmin = useCallback( const updateUserRole = useCallback(
async (userId: number, isAdmin: boolean) => { async (userId: number, newRole: UserRole) => {
setError(null); setError(null);
try { try {
await api.patch(`/admin/users/${userId}/admin`, null, { params: { is_admin: !isAdmin } }); await api.patch(`/admin/users/${userId}/role`, { role: newRole });
setUsers((prev) => prev.map((user) => (user.id === userId ? { ...user, is_admin: !isAdmin } : user))); setUsers((prev) => {
const user = prev.find(u => u.id === userId);
setGlobalError(`Rolle von ${user?.username} wurde zu ${newRole} geändert.`);
return prev.map((user) => (user.id === userId ? { ...user, role: newRole } : user));
});
} catch (err) { } catch (err) {
setGlobalError('Admin-Status konnte nicht geändert werden.'); setGlobalError('Rolle konnte nicht geändert werden.');
} }
}, },
[setGlobalError] [setGlobalError]
@ -672,7 +679,11 @@ const AdminPanel: React.FC = () => {
{user.full_name || '-'} {user.full_name || '-'}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-sm"> <td className="px-4 py-3 whitespace-nowrap text-sm">
{user.is_admin ? ( {user.role === 'superadmin' ? (
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
SuperAdmin
</span>
) : user.role === 'admin' ? (
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"> <span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
Admin Admin
</span> </span>
@ -683,12 +694,19 @@ const AdminPanel: React.FC = () => {
)} )}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-xs"> <td className="px-4 py-3 whitespace-nowrap text-xs">
<button {isSuperAdmin(currentUser) && user.id !== currentUser?.id ? (
onClick={() => toggleAdmin(user.id, user.is_admin)} <select
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" value={user.role}
> onChange={(e) => updateUserRole(user.id, e.target.value as UserRole)}
{user.is_admin ? 'Admin entfernen' : 'Admin machen'} className="text-xs border border-gray-300 rounded px-2 py-1 bg-white dark:bg-gray-700 dark:border-gray-600"
</button> >
<option value={UserRole.USER}>User</option>
<option value={UserRole.ADMIN}>Admin</option>
<option value={UserRole.SUPERADMIN}>SuperAdmin</option>
</select>
) : (
<span className="text-gray-500">-</span>
)}
</td> </td>
</tr> </tr>
))} ))}

View File

@ -71,7 +71,7 @@ const Login: React.FC = () => {
<p className="mt-4 text-center text-gray-600 dark:text-gray-400"> <p className="mt-4 text-center text-gray-600 dark:text-gray-400">
Don't have an account?{' '} Don't have an account?{' '}
<Link to="/register" className="text-indigo-600 dark:text-indigo-400 hover:underline"> <Link to="/register" className="text-blue-600 dark:text-blue-400 hover:underline">
Register Register
</Link> </Link>
</p> </p>

View File

@ -98,7 +98,7 @@ const Register: React.FC = () => {
<p className="mt-4 text-center text-gray-600 dark:text-gray-400"> <p className="mt-4 text-center text-gray-600 dark:text-gray-400">
Already have an account?{' '} Already have an account?{' '}
<Link to="/login" className="text-indigo-600 dark:text-indigo-400 hover:underline"> <Link to="/login" className="text-blue-600 dark:text-blue-400 hover:underline">
Login Login
</Link> </Link>
</p> </p>

View File

@ -6,13 +6,15 @@ import MessageInput from './MessageInput';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import DirectMessagesSidebar from './DirectMessagesSidebar'; import DirectMessagesSidebar from './DirectMessagesSidebar';
import DirectMessageView from './DirectMessageView'; import DirectMessageView from './DirectMessageView';
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
const ChatView: React.FC = () => { const ChatView: React.FC = () => {
const [channels, setChannels] = useState<Channel[]>([]); const [channels, setChannels] = useState<Channel[]>([]);
const [departments, setDepartments] = useState<Department[]>([]); const [departments, setDepartments] = useState<Department[]>([]);
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null); const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null);
const [selectedUser, setSelectedUser] = useState<User | null>(null); const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [replyTo, setReplyTo] = useState<{ id: number; content: string; sender_username: string } | null>(null); const [replyTo, setReplyTo] = useState<{ id: number; content: string; sender_username: string; sender_full_name?: string } | null>(null);
const { markChannelAsRead, markDirectMessageAsRead, setActiveChannel, setActiveDirectMessage } = useUnreadMessages();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@ -55,6 +57,9 @@ const ChatView: React.FC = () => {
onSelectChannel={(channel) => { onSelectChannel={(channel) => {
setSelectedChannel(channel); setSelectedChannel(channel);
setSelectedUser(null); setSelectedUser(null);
setActiveChannel(channel.id);
setActiveDirectMessage(null);
markChannelAsRead(channel.id);
}} }}
/> />
@ -110,6 +115,9 @@ const ChatView: React.FC = () => {
onSelectUser={(user) => { onSelectUser={(user) => {
setSelectedUser(user); setSelectedUser(user);
setSelectedChannel(null); setSelectedChannel(null);
setActiveDirectMessage(user.id);
setActiveChannel(null);
markDirectMessageAsRead(user.id);
}} }}
/> />
</div> </div>

View File

@ -1,8 +1,10 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { directMessagesAPI } from '../../services/api'; import { directMessagesAPI, getApiUrl } from '../../services/api';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import type { User } from '../../types'; import type { User } from '../../types';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { useUserStatus } from '../../contexts/UserStatusContext';
import UserStatusIndicator from '../common/UserStatusIndicator';
interface DirectMessage { interface DirectMessage {
id: number; id: number;
@ -24,12 +26,14 @@ interface DirectMessageViewProps {
const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => { const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
const { addToast } = useToast(); const { addToast } = useToast();
const { getUserStatus } = useUserStatus();
const [messages, setMessages] = useState<DirectMessage[]>([]); const [messages, setMessages] = useState<DirectMessage[]>([]);
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const [lastReadIndex, setLastReadIndex] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
loadMessages(); loadMessages();
@ -37,24 +41,44 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
// Set up WebSocket for real-time updates // Set up WebSocket for real-time updates
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token && currentUser) { if (token && currentUser) {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // Use API URL to determine WebSocket host
const wsHost = import.meta.env.VITE_WS_URL || `${wsProtocol}//localhost:8000`; const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const ws = new WebSocket(`${wsHost}/ws/${-currentUser.id}?token=${token}`); const url = new URL(apiUrl);
const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${url.host}/api/ws/${-currentUser.id}?token=${token}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('DirectMessage WebSocket connected for user:', currentUser.id);
};
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); try {
if (data.type === 'direct_message') { const data = JSON.parse(event.data);
// Only add if message is from/to the selected user if (data.type === 'direct_message') {
const msg = data.message; // Only add if message is from/to the selected user
if ( const msg = data.message;
(msg.sender_id === user.id && msg.receiver_id === currentUser.id) || if (
(msg.sender_id === currentUser.id && msg.receiver_id === user.id) (msg.sender_id === user.id && msg.receiver_id === currentUser.id) ||
) { (msg.sender_id === currentUser.id && msg.receiver_id === user.id)
setMessages((prevMessages) => [...prevMessages, msg]); ) {
// Message marking is now handled globally in UnreadMessagesContext
setMessages((prevMessages) => [...prevMessages, msg]);
}
} }
} catch (error) {
console.error('Error parsing DirectMessage WebSocket message:', error);
} }
}; };
ws.onerror = (error) => {
console.error('DirectMessage WebSocket error for user', currentUser.id, ':', error);
};
ws.onclose = (event) => {
console.log('DirectMessage WebSocket closed for user', currentUser.id, 'Code:', event.code, 'Reason:', event.reason);
};
return () => { return () => {
ws.close(); ws.close();
}; };
@ -65,6 +89,32 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
scrollToBottom(); scrollToBottom();
}, [messages]); }, [messages]);
// compute last-read index for direct messages using is_read or stored last-seen timestamp
useEffect(() => {
try {
const lastSeenIso = localStorage.getItem(`dm_last_seen_${user.id}`);
const lastSeen = lastSeenIso ? new Date(lastSeenIso) : null;
let idx: number | null = null;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
const created = new Date(msg.created_at);
// Consider message read if it's sent by current user, or marked is_read (i.e., receiver has read it),
// or its created_at is <= lastSeen timestamp
if (
(currentUser && msg.sender_id === currentUser.id) ||
msg.is_read ||
(lastSeen && created <= lastSeen)
) {
idx = i;
break;
}
}
setLastReadIndex(idx);
} catch (e) {
setLastReadIndex(null);
}
}, [messages, user.id, currentUser]);
const loadMessages = async () => { const loadMessages = async () => {
try { try {
const data = await directMessagesAPI.getConversation(user.id); const data = await directMessagesAPI.getConversation(user.id);
@ -131,14 +181,16 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
<div className="flex-1 overflow-y-auto p-3 space-y-3 bg-gray-50 dark:bg-gray-900"> <div className="flex-1 overflow-y-auto p-3 space-y-3 bg-gray-50 dark:bg-gray-900">
{messages.map((message) => { {messages.map((message) => {
const isOwnMessage = message.sender_id === currentUser?.id; const isOwnMessage = message.sender_id === currentUser?.id;
const markerAfter = lastReadIndex !== null && messages[lastReadIndex] && messages[lastReadIndex].id === message.id;
return ( return (
<div key={message.id} className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}> <React.Fragment key={message.id}>
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}> <div className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}>
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
{/* Profile Picture / Initials */} {/* Profile Picture / Initials */}
{message.sender_profile_picture ? ( {message.sender_profile_picture ? (
<img <img
src={`http://localhost:8000/${message.sender_profile_picture}`} src={getApiUrl(message.sender_profile_picture)}
alt={message.sender_username} alt={message.sender_username}
className="w-8 h-8 rounded-full object-cover flex-shrink-0" className="w-8 h-8 rounded-full object-cover flex-shrink-0"
/> />
@ -151,9 +203,14 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
{/* Message Bubble */} {/* Message Bubble */}
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}> <div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<div className="flex items-baseline space-x-2 mb-1"> <div className="flex items-baseline space-x-2 mb-1">
<span className="font-semibold text-xs text-gray-900 dark:text-white"> <div className="flex items-center space-x-1">
{message.sender_username} <span className="font-semibold text-xs text-gray-900 dark:text-white">
</span> {message.sender_full_name || message.sender_username}
</span>
{message.sender_id !== currentUser?.id && (
<UserStatusIndicator status={getUserStatus(message.sender_id)} size="sm" />
)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span> </span>
@ -161,8 +218,8 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
<div className={`px-3 py-1 rounded-lg ${ <div className={`px-3 py-1 rounded-lg ${
isOwnMessage isOwnMessage
? 'bg-blue-500 text-white rounded-br-none' ? 'bg-blue-500 bg-opacity-80 text-white rounded-br-none'
: 'bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600 rounded-bl-none' : 'bg-white bg-opacity-80 dark:bg-gray-700 dark:bg-opacity-20 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600 rounded-bl-none'
}`}> }`}>
<div className="text-sm whitespace-pre-wrap break-words"> <div className="text-sm whitespace-pre-wrap break-words">
{message.content} {message.content}
@ -170,7 +227,11 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{markerAfter && (
<div className="w-full h-px bg-gray-300/40 dark:bg-gray-600/30 my-2" />
)}
</React.Fragment>
); );
})} })}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />

View File

@ -1,7 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useUserStatus } from '../../contexts/UserStatusContext';
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
import axios from 'axios'; import axios from 'axios';
import { getApiUrl } from '../../services/api';
import type { User } from '../../types'; import type { User } from '../../types';
import UserStatusIndicator from '../common/UserStatusIndicator';
import BlinkingEnvelope from '../common/BlinkingEnvelope';
interface DirectMessagesSidebarProps { interface DirectMessagesSidebarProps {
onSelectUser: (user: User) => void; onSelectUser: (user: User) => void;
@ -18,6 +23,8 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
const [showUserPicker, setShowUserPicker] = useState(false); const [showUserPicker, setShowUserPicker] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const { getUserStatus } = useUserStatus();
const { hasUnreadDirectMessage, markDirectMessageAsUnread } = useUnreadMessages();
useEffect(() => { useEffect(() => {
// Load conversations on mount // Load conversations on mount
@ -27,7 +34,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
const loadConversations = async () => { const loadConversations = async () => {
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const response = await axios.get('http://localhost:8000/direct-messages/conversations', { const response = await axios.get(getApiUrl('/direct-messages/conversations'), {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
@ -49,7 +56,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
setLoading(true); setLoading(true);
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const response = await axios.get('http://localhost:8000/admin/users', { const response = await axios.get(getApiUrl('/admin/users'), {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
@ -112,6 +119,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
<button <button
key={user.id} key={user.id}
onClick={() => onSelectUser(user)} onClick={() => onSelectUser(user)}
onDoubleClick={() => markDirectMessageAsUnread(user.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${ className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
selectedUserId === user.id selectedUserId === user.id
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100' ? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
@ -119,8 +127,9 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
}`} }`}
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span> <UserStatusIndicator status={getUserStatus(user.id)} size="sm" />
<span>{user.username}</span> <span>{user.username}</span>
<BlinkingEnvelope hasNewMessages={hasUnreadDirectMessage(user.id)} />
</div> </div>
{user.full_name && ( {user.full_name && (
<div className="text-xs text-gray-500 dark:text-gray-400 ml-4"> <div className="text-xs text-gray-500 dark:text-gray-400 ml-4">
@ -183,19 +192,28 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
onClick={() => handleAddUser(user)} onClick={() => handleAddUser(user)}
className="w-full text-left p-2.5 border border-gray-200 dark:border-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-700" className="w-full text-left p-2.5 border border-gray-200 dark:border-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
> >
<div className="font-medium text-sm text-gray-900 dark:text-white"> <div className="flex items-center space-x-2">
{user.username} <UserStatusIndicator status={getUserStatus(user.id)} size="sm" />
<div className="flex items-center space-x-2">
<UserStatusIndicator status={getUserStatus(user.id)} size="sm" />
<div className="flex-1">
<div className="font-medium text-sm text-gray-900 dark:text-white">
{user.username}
</div>
{user.full_name && (
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
{user.full_name}
</div>
)}
{user.email && (
<div className="text-xs text-gray-500 dark:text-gray-500 mt-0.5">
{user.email}
</div>
)}
</div>
<BlinkingEnvelope hasNewMessages={hasUnreadDirectMessage(user.id)} />
</div>
</div> </div>
{user.full_name && (
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
{user.full_name}
</div>
)}
{user.email && (
<div className="text-xs text-gray-500 dark:text-gray-500 mt-0.5">
{user.email}
</div>
)}
</button> </button>
))} ))}
</div> </div>

View File

@ -7,7 +7,7 @@ import { useToast } from '../../contexts/ToastContext';
interface MessageInputProps { interface MessageInputProps {
channelId: number; channelId: number;
replyTo?: { id: number; content: string; sender_username: string } | null; replyTo?: { id: number; content: string; sender_username: string; sender_full_name?: string } | null;
onCancelReply?: () => void; onCancelReply?: () => void;
} }
@ -85,18 +85,18 @@ const MessageInput: React.FC<MessageInputProps> = ({ channelId, replyTo, onCance
return ( return (
<div className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-3"> <div className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-3">
{replyTo && ( {replyTo && (
<div className="mb-2 p-2 bg-indigo-50 dark:bg-indigo-900 rounded flex items-start justify-between"> <div className="mb-2 p-2 bg-blue-50 dark:bg-blue-900 rounded flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="text-xs font-medium text-indigo-900 dark:text-indigo-100"> <div className="text-xs font-medium text-blue-900 dark:text-blue-100">
Replying to {replyTo.sender_username} Replying to {replyTo.sender_full_name || replyTo.sender_username}
</div> </div>
<div className="text-xs text-indigo-700 dark:text-indigo-200 mt-0.5 truncate"> <div className="text-xs text-blue-700 dark:text-blue-200 mt-0.5 truncate">
{replyTo.content} {replyTo.content}
</div> </div>
</div> </div>
<button <button
onClick={onCancelReply} onClick={onCancelReply}
className="text-indigo-900 dark:text-indigo-100 hover:text-red-600 text-sm ml-2" className="text-blue-900 dark:text-blue-100 hover:text-red-600 text-sm ml-2"
> >
</button> </button>
@ -104,13 +104,13 @@ const MessageInput: React.FC<MessageInputProps> = ({ channelId, replyTo, onCance
)} )}
{selectedSnippet && ( {selectedSnippet && (
<div className="mb-2 p-2 bg-indigo-100 dark:bg-indigo-900 rounded flex items-center justify-between"> <div className="mb-2 p-2 bg-blue-100 dark:bg-blue-900 rounded flex items-center justify-between">
<span className="text-xs text-indigo-900 dark:text-indigo-100"> <span className="text-xs text-blue-900 dark:text-blue-100">
📋 {selectedSnippet.title} ({selectedSnippet.language}) {selectedSnippet.title} ({selectedSnippet.language})
</span> </span>
<button <button
onClick={() => setSelectedSnippet(null)} onClick={() => setSelectedSnippet(null)}
className="text-indigo-900 dark:text-indigo-100 hover:text-red-600 text-sm" className="text-blue-900 dark:text-blue-100 hover:text-red-600 text-sm"
> >
</button> </button>

View File

@ -1,19 +1,23 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { messagesAPI, filesAPI } from '../../services/api'; import { messagesAPI, filesAPI, getApiUrl } from '../../services/api';
import type { Message } from '../../types'; import type { Message } from '../../types';
import CodeBlock from '../common/CodeBlock'; import CodeBlock from '../common/CodeBlock';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { useUserStatus } from '../../contexts/UserStatusContext';
import UserStatusIndicator from '../common/UserStatusIndicator';
interface MessageListProps { interface MessageListProps {
channelId: number; channelId: number;
onReply?: (message: { id: number; content: string; sender_username: string }) => void; onReply?: (message: { id: number; content: string; sender_username: string; sender_full_name?: string }) => void;
} }
const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => { const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
const { user } = useAuth(); const { user } = useAuth();
const { addToast } = useToast(); const { addToast } = useToast();
const { getUserStatus } = useUserStatus();
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [lastReadIndex, setLastReadIndex] = useState<number | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [openMenuId, setOpenMenuId] = useState<number | null>(null); const [openMenuId, setOpenMenuId] = useState<number | null>(null);
@ -40,10 +44,11 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
// Set up WebSocket for real-time updates // Set up WebSocket for real-time updates
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token && channelId > 0) { if (token && channelId > 0) {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // Use API URL to determine WebSocket host and add /ws path
const wsHost = import.meta.env.VITE_WS_URL || `${wsProtocol}//localhost:8000`; const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const wsUrl = `${wsHost}/ws/${channelId}?token=${token}`; const url = new URL(apiUrl);
console.log('Connecting to WebSocket:', wsUrl); const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${url.host}/api/ws/${channelId}?token=${token}`;
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
wsRef.current = ws; wsRef.current = ws;
@ -53,36 +58,41 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); try {
if (data.type === 'message') { const data = JSON.parse(event.data);
// Add new message immediately to the list, but avoid duplicates if (data.type === 'message') {
setMessages((prevMessages) => { // Message marking is now handled globally in UnreadMessagesContext
// Check if message already exists
if (prevMessages.some(msg => msg.id === data.message.id)) { // Add new message immediately to the list, but avoid duplicates
return prevMessages; setMessages((prevMessages) => {
} // Check if message already exists
const updated = [...prevMessages, data.message]; if (prevMessages.some(msg => msg.id === data.message.id)) {
// Keep only the most recent messages when limit is exceeded return prevMessages;
if (updated.length > MESSAGES_LIMIT * 3) { }
return updated.slice(-MESSAGES_LIMIT * 2); const updated = [...prevMessages, data.message];
} // Keep only the most recent messages when limit is exceeded
return updated; if (updated.length > MESSAGES_LIMIT * 3) {
}); return updated.slice(-MESSAGES_LIMIT * 2);
} else if (data.type === 'message_deleted') { }
// Replace deleted message with placeholder return updated;
setMessages((prevMessages) => });
prevMessages.map(msg => } else if (data.type === 'message_deleted') {
msg.id === data.message_id // Replace deleted message with placeholder
? { ...msg, deleted: true } setMessages((prevMessages) =>
: msg prevMessages.map(msg =>
) msg.id === data.message_id
); ? { ...msg, deleted: true }
: msg
)
);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
} }
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
console.error('WebSocket error for channel', channelId, ':', error); console.error('WebSocket error for channel', channelId, ':', error);
console.error('WebSocket URL was:', wsUrl);
}; };
ws.onclose = (event) => { ws.onclose = (event) => {
@ -110,6 +120,31 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
scrollToBottom(); scrollToBottom();
}, [messages]); }, [messages]);
// compute last-read index based on stored last-seen timestamp for this channel
useEffect(() => {
try {
const lastSeenIso = localStorage.getItem(`channel_last_seen_${channelId}`);
if (!lastSeenIso) {
setLastReadIndex(null);
return;
}
const lastSeen = new Date(lastSeenIso);
// find last message with created_at <= lastSeen or message sent by current user
let idx: number | null = null;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
const created = new Date(msg.created_at);
if ((user && msg.sender_id === user.id) || created <= lastSeen) {
idx = i;
break;
}
}
setLastReadIndex(idx);
} catch (e) {
setLastReadIndex(null);
}
}, [messages, channelId, user]);
const loadMessages = async (append = false) => { const loadMessages = async (append = false) => {
try { try {
const offset = append ? messages.length : 0; const offset = append ? messages.length : 0;
@ -268,52 +303,63 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
// Deleted message - simple text without bubble (check both deleted and is_deleted) // Deleted message - simple text without bubble (check both deleted and is_deleted)
if (message.deleted || message.is_deleted) { if (message.deleted || message.is_deleted) {
const markerAfter = lastReadIndex !== null && messages[lastReadIndex] && messages[lastReadIndex].id === message.id;
return ( return (
<div <React.Fragment key={message.id}>
key={message.id} <div
id={`message-${message.id}`} id={`message-${message.id}`}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'} p-2`} className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'} p-2`}
> >
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}> <div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
{message.sender_profile_picture ? ( {message.sender_profile_picture ? (
<img <img
src={`http://localhost:8000/${message.sender_profile_picture}`} src={getApiUrl(message.sender_profile_picture)}
alt={message.sender_username} alt={message.sender_username}
className="w-8 h-8 rounded-full object-cover flex-shrink-0" className="w-8 h-8 rounded-full object-cover flex-shrink-0"
/> />
) : ( ) : (
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"> <div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{getInitials(message.sender_full_name, message.sender_username)} {getInitials(message.sender_full_name, message.sender_username)}
</div> </div>
)} )}
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}> <div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<div className="flex items-baseline space-x-2 mb-1"> <div className="flex items-baseline space-x-2 mb-1">
<span className="font-semibold text-xs text-gray-900 dark:text-white"> <div className="flex items-center space-x-1">
{message.sender_username || 'Unknown'} <span className="font-semibold text-xs text-gray-900 dark:text-white">
</span> {message.sender_full_name || message.sender_username || 'Unknown'}
<span className="text-xs text-gray-500 dark:text-gray-400"> </span>
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {message.sender_id !== user?.id && (
<UserStatusIndicator status={getUserStatus(message.sender_id)} size="sm" />
)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
Diese Nachricht wurde gelöscht
</span> </span>
</div> </div>
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
Diese Nachricht wurde gelöscht
</span>
</div> </div>
</div> </div>
</div> {markerAfter && (
<div className="w-full h-px bg-gray-300/40 dark:bg-gray-600/30 my-2" />
)}
</React.Fragment>
); );
} }
const markerAfter = lastReadIndex !== null && messages[lastReadIndex] && messages[lastReadIndex].id === message.id;
return ( return (
<div <React.Fragment key={message.id}>
key={message.id} <div
id={`message-${message.id}`} id={`message-${message.id}`}
className={`group flex ${isOwnMessage ? 'justify-end' : 'justify-start'} hover:bg-gray-100 dark:hover:bg-gray-800 rounded p-2 -m-2 transition-all`} className={`group flex ${isOwnMessage ? 'justify-end' : 'justify-start'} hover:bg-gray-100 dark:hover:bg-gray-800 rounded p-2 -m-2 transition-all`}
> >
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}> <div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
{message.sender_profile_picture ? ( {message.sender_profile_picture ? (
<img <img
src={`http://localhost:8000/${message.sender_profile_picture}`} src={getApiUrl(message.sender_profile_picture)}
alt={message.sender_username} alt={message.sender_username}
className="w-8 h-8 rounded-full object-cover flex-shrink-0" className="w-8 h-8 rounded-full object-cover flex-shrink-0"
/> />
@ -325,9 +371,14 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'} relative`}> <div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'} relative`}>
<div className="flex items-baseline space-x-2 mb-1"> <div className="flex items-baseline space-x-2 mb-1">
<span className="font-semibold text-xs text-gray-900 dark:text-white"> <div className="flex items-center space-x-1">
{message.sender_username || 'Unknown'} <span className="font-semibold text-xs text-gray-900 dark:text-white">
</span> {message.sender_full_name || message.sender_username || 'Unknown'}
</span>
{message.sender_id !== user?.id && (
<UserStatusIndicator status={getUserStatus(message.sender_id)} size="sm" />
)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span> </span>
@ -335,8 +386,8 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
<div className={`px-1 py-1 rounded-lg relative ${ <div className={`px-1 py-1 rounded-lg relative ${
isOwnMessage isOwnMessage
? 'bg-blue-500 text-white rounded-br-none' ? 'bg-blue-500 bg-opacity-80 text-white rounded-br-none'
: 'bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600 rounded-bl-none' : 'bg-white bg-opacity-80 dark:bg-gray-700 dark:bg-opacity-20 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600 rounded-bl-none'
}`}> }`}>
{/* Hover Action Buttons */} {/* Hover Action Buttons */}
<div className={`absolute ${isOwnMessage ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} top-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 px-2`}> <div className={`absolute ${isOwnMessage ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} top-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 px-2`}>
@ -346,7 +397,8 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
onReply({ onReply({
id: message.id, id: message.id,
content: message.content, content: message.content,
sender_username: message.sender_username || 'Unknown' sender_username: message.sender_username || 'Unknown',
sender_full_name: message.sender_full_name
}); });
}} }}
className="p-1.5 bg-white dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-full shadow-lg border border-gray-200 dark:border-gray-600" className="p-1.5 bg-white dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-full shadow-lg border border-gray-200 dark:border-gray-600"
@ -415,7 +467,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
onClick={() => message.reply_to && scrollToMessage(message.reply_to.id)} onClick={() => message.reply_to && scrollToMessage(message.reply_to.id)}
> >
<div className="text-xs font-medium text-gray-700 dark:text-gray-300"> <div className="text-xs font-medium text-gray-700 dark:text-gray-300">
{message.reply_to.sender_username} {message.reply_to.sender_full_name || message.reply_to.sender_username}
</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-400 truncate"> <div className="text-xs text-gray-600 dark:text-gray-400 truncate">
{message.reply_to.content} {message.reply_to.content}
@ -732,8 +784,12 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
)} )}
</div> </div>
</div> </div>
</div>
</div> </div>
</div> {markerAfter && (
<div className="w-full h-px bg-gray-300/40 dark:bg-gray-600/30 my-2" />
)}
</React.Fragment>
); );
})} })}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />

View File

@ -1,11 +1,14 @@
import React from 'react'; import React from 'react';
import type { Channel, Department } from '../../types'; import type { Channel, Department } from '../../types';
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
import BlinkingEnvelope from '../common/BlinkingEnvelope';
interface SidebarProps { interface SidebarProps {
channels: Channel[]; channels: Channel[];
departments: Department[]; departments: Department[];
selectedChannel: Channel | null; selectedChannel: Channel | null;
onSelectChannel: (channel: Channel) => void; onSelectChannel: (channel: Channel) => void;
onDeleteChannel?: (channel: Channel) => void;
} }
const Sidebar: React.FC<SidebarProps> = ({ const Sidebar: React.FC<SidebarProps> = ({
@ -13,7 +16,10 @@ const Sidebar: React.FC<SidebarProps> = ({
departments, departments,
selectedChannel, selectedChannel,
onSelectChannel, onSelectChannel,
onDeleteChannel,
}) => { }) => {
const { hasUnreadChannel, markChannelAsUnread } = useUnreadMessages();
// Group channels by department // Group channels by department
const channelsByDept = channels.reduce((acc, channel) => { const channelsByDept = channels.reduce((acc, channel) => {
if (!acc[channel.department_id]) { if (!acc[channel.department_id]) {
@ -23,8 +29,26 @@ const Sidebar: React.FC<SidebarProps> = ({
return acc; return acc;
}, {} as Record<number, Channel[]>); }, {} as Record<number, Channel[]>);
const handleChannelClick = (channel: Channel) => {
onSelectChannel(channel);
};
const handleChannelDoubleClick = (channel: Channel) => {
// Test function: mark as unread on double click
markChannelAsUnread(channel.id);
};
const handleDeleteClick = (channel: Channel, e: React.MouseEvent) => {
e.stopPropagation();
if (onDeleteChannel) {
if (window.confirm(`Channel "${channel.name}" und alle seine Inhalte (Nachrichten, Kanban-Board) wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`)) {
onDeleteChannel(channel);
}
}
};
return ( return (
<div className="w-52 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col"> <div className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700"> <div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-base text-gray-900 dark:text-white">Channels</h3> <h3 className="font-semibold text-base text-gray-900 dark:text-white">Channels</h3>
</div> </div>
@ -37,17 +61,34 @@ const Sidebar: React.FC<SidebarProps> = ({
</div> </div>
{channelsByDept[dept.id]?.map((channel) => ( {channelsByDept[dept.id]?.map((channel) => (
<button <div key={channel.id} className="relative group">
key={channel.id} <button
onClick={() => onSelectChannel(channel)} onClick={() => handleChannelClick(channel)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${ onDoubleClick={() => handleChannelDoubleClick(channel)}
selectedChannel?.id === channel.id className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100' selectedChannel?.id === channel.id
: 'text-gray-700 dark:text-gray-300' ? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
}`} : 'text-gray-700 dark:text-gray-300'
> }`}
# {channel.name} >
</button> <div className="flex items-center justify-between">
<span># {channel.name}</span>
<BlinkingEnvelope hasNewMessages={hasUnreadChannel(channel.id)} />
</div>
</button>
{onDeleteChannel && (
<button
onClick={(e) => handleDeleteClick(channel, e)}
className="absolute right-1 top-1 opacity-0 group-hover:opacity-100 p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-opacity"
title="Channel löschen"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
))} ))}
</div> </div>
))} ))}

View File

@ -0,0 +1,96 @@
import React from 'react';
import type { KanbanCard } from '../../types';
interface KanbanArchiveModalProps {
isOpen: boolean;
onClose: () => void;
archivedCards: KanbanCard[];
}
const KanbanArchiveModal: React.FC<KanbanArchiveModalProps> = ({ isOpen, onClose, archivedCards }) => {
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-6xl w-full mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Archivierte Tasks
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
{archivedCards.length === 0 ? (
<div className="text-center py-8">
<div className="text-gray-500 dark:text-gray-400">Keine archivierten Tasks gefunden</div>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Titel
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Beschreibung
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Erstellt am
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Abgeschlossen am
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{archivedCards.map((card) => (
<tr key={card.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{card.title}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-300">
{card.description || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">
{formatDate(card.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">
{card.updated_at ? formatDate(card.updated_at) : formatDate(card.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
};
export default KanbanArchiveModal;

View File

@ -5,6 +5,7 @@ import { kanbanAPI, channelsAPI, departmentsAPI } from '../../services/api';
import type { KanbanBoardWithColumns, KanbanColumn, KanbanCard, Channel, Department } from '../../types'; import type { KanbanBoardWithColumns, KanbanColumn, KanbanCard, Channel, Department } from '../../types';
import KanbanColumnComponent from './KanbanColumn'; import KanbanColumnComponent from './KanbanColumn';
import KanbanCardModal from './KanbanCardModal'; import KanbanCardModal from './KanbanCardModal';
import KanbanArchiveModal from './KanbanArchiveModal';
import KanbanSidebar from './KanbanSidebar'; import KanbanSidebar from './KanbanSidebar';
const KanbanBoard: React.FC = () => { const KanbanBoard: React.FC = () => {
@ -20,6 +21,7 @@ const KanbanBoard: React.FC = () => {
const [sidebarLoading, setSidebarLoading] = useState(true); const [sidebarLoading, setSidebarLoading] = useState(true);
const [selectedCard, setSelectedCard] = useState<KanbanCard | null>(null); const [selectedCard, setSelectedCard] = useState<KanbanCard | null>(null);
const [showCardModal, setShowCardModal] = useState(false); const [showCardModal, setShowCardModal] = useState(false);
const [showArchiveModal, setShowArchiveModal] = useState(false);
useEffect(() => { useEffect(() => {
loadSidebarData(); loadSidebarData();
@ -256,6 +258,12 @@ const KanbanBoard: React.FC = () => {
setShowCardModal(true); setShowCardModal(true);
}; };
const getArchivedCards = () => {
if (!board) return [];
const doneColumn = board.columns.find(col => col.name.toLowerCase() === 'done');
return doneColumn ? doneColumn.cards : [];
};
if (sidebarLoading) { if (sidebarLoading) {
return ( return (
<div className="h-full flex items-center justify-center text-gray-500 dark:text-gray-400"> <div className="h-full flex items-center justify-center text-gray-500 dark:text-gray-400">
@ -285,43 +293,70 @@ const KanbanBoard: React.FC = () => {
Kanban-Board nicht gefunden Kanban-Board nicht gefunden
</div> </div>
) : ( ) : (
<div className="flex-1 p-4 overflow-y-auto"> <div className="flex-1 flex flex-col">
<div className="flex items-center justify-between mb-4"> {/* Header */}
<div> <div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-2">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white"> <div className="flex items-center justify-between">
{board.name} <div>
</h1> <h2 className="text-base font-semibold text-gray-900 dark:text-white">
{selectedChannel && ( Kanban Board
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> </h2>
#{selectedChannel.name} {selectedChannel && (
</p> <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
)} #{selectedChannel.name}
</p>
)}
{selectedChannel?.description && (
<p className="text-xs text-gray-600 dark:text-gray-400">
{selectedChannel.description}
</p>
)}
</div>
<button
onClick={() => setShowArchiveModal(true)}
className="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title="Archiv anzeigen"
>
Archiv
</button>
</div> </div>
</div> </div>
<div className="flex gap-4 overflow-x-auto pb-4"> {/* Board Content */}
{board.columns.map((column) => ( <div className="flex-1 p-4 overflow-y-auto">
<KanbanColumnComponent <div className="flex gap-4">
key={column.id} {board.columns.map((column) => (
column={column} <KanbanColumnComponent
onUpdateColumn={handleUpdateColumn} key={column.id}
onDeleteColumn={handleDeleteColumn} column={column}
onCreateCard={handleCreateCard} onUpdateColumn={handleUpdateColumn}
onDeleteCard={handleDeleteCard} onDeleteColumn={handleDeleteColumn}
onMoveCard={handleMoveCard} onCreateCard={handleCreateCard}
onCardClick={handleCardClick} onDeleteCard={handleDeleteCard}
onMoveCard={handleMoveCard}
onCardClick={handleCardClick}
/>
))}
</div>
{showCardModal && selectedCard && (
<KanbanCardModal
card={selectedCard}
onClose={() => {
setShowCardModal(false);
setSelectedCard(null);
}}
onUpdate={handleUpdateCard}
/> />
))} )}
</div> </div>
{showCardModal && selectedCard && ( {/* Archive Modal */}
<KanbanCardModal {board && (
card={selectedCard} <KanbanArchiveModal
onClose={() => { isOpen={showArchiveModal}
setShowCardModal(false); onClose={() => setShowArchiveModal(false)}
setSelectedCard(null); archivedCards={getArchivedCards()}
}}
onUpdate={handleUpdateCard}
/> />
)} )}
</div> </div>

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { getApiUrl } from '../../services/api';
import type { KanbanCard } from '../../types'; import type { KanbanCard } from '../../types';
interface KanbanCardProps { interface KanbanCardProps {
@ -91,7 +92,7 @@ const KanbanCard: React.FC<KanbanCardProps> = ({
<div className="flex items-center"> <div className="flex items-center">
{card.assignee.profile_picture ? ( {card.assignee.profile_picture ? (
<img <img
src={`http://localhost:8000/${card.assignee.profile_picture}`} src={getApiUrl(card.assignee.profile_picture)}
alt={card.assignee.username} alt={card.assignee.username}
className="w-5 h-5 rounded-full object-cover" className="w-5 h-5 rounded-full object-cover"
title={card.assignee.username} title={card.assignee.username}

View File

@ -87,7 +87,7 @@ const KanbanColumn: React.FC<KanbanColumnProps> = ({
return ( return (
<div <div
className={`flex-shrink-0 w-72 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-3 ${ className={`flex-1 min-w-72 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-3 ${
draggedOver ? 'ring-1 ring-blue-500 bg-blue-50 dark:bg-blue-900/10' : '' draggedOver ? 'ring-1 ring-blue-500 bg-blue-50 dark:bg-blue-900/10' : ''
}`} }`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
@ -133,20 +133,31 @@ const KanbanColumn: React.FC<KanbanColumnProps> = ({
{column.cards.length} {column.cards.length}
</span> </span>
</div> </div>
{(() => { <div className="flex items-center gap-1">
const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done']; <button
return !defaultColumns.includes(column.name) && ( onClick={() => onCreateCard(column.id)}
<button className="text-gray-400 hover:text-green-500 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => onDeleteColumn(column.id)} title="Neue Karte hinzufügen"
className="text-gray-400 hover:text-red-500 p-1" >
title="Spalte löschen" <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> </svg>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> </button>
</svg> {(() => {
</button> const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done'];
); return !defaultColumns.includes(column.name) && (
})()} <button
onClick={() => onDeleteColumn(column.id)}
className="text-gray-400 hover:text-red-500 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
title="Spalte löschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
);
})()}
</div>
</div> </div>
{/* Cards */} {/* Cards */}

View File

@ -6,6 +6,7 @@ interface KanbanSidebarProps {
departments: Department[]; departments: Department[];
selectedChannel: Channel | null; selectedChannel: Channel | null;
onSelectChannel: (channel: Channel) => void; onSelectChannel: (channel: Channel) => void;
onDeleteChannel?: (channel: Channel) => void;
} }
const KanbanSidebar: React.FC<KanbanSidebarProps> = ({ const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
@ -13,7 +14,9 @@ const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
departments, departments,
selectedChannel, selectedChannel,
onSelectChannel, onSelectChannel,
onDeleteChannel,
}) => { }) => {
// Group channels by department // Group channels by department
const channelsByDept = channels.reduce((acc, channel) => { const channelsByDept = channels.reduce((acc, channel) => {
if (!acc[channel.department_id]) { if (!acc[channel.department_id]) {
@ -23,8 +26,17 @@ const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
return acc; return acc;
}, {} as Record<number, Channel[]>); }, {} as Record<number, Channel[]>);
const handleDeleteClick = (channel: Channel, e: React.MouseEvent) => {
e.stopPropagation();
if (onDeleteChannel) {
if (window.confirm(`Channel "${channel.name}" und alle seine Inhalte (Nachrichten, Kanban-Board) wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`)) {
onDeleteChannel(channel);
}
}
};
return ( return (
<div className="w-52 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col"> <div className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700"> <div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-base text-gray-900 dark:text-white">Kanban Boards</h3> <h3 className="font-semibold text-base text-gray-900 dark:text-white">Kanban Boards</h3>
</div> </div>
@ -37,25 +49,38 @@ const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
</div> </div>
{channelsByDept[dept.id]?.map((channel) => ( {channelsByDept[dept.id]?.map((channel) => (
<button <div key={channel.id} className="relative group">
key={channel.id} <button
onClick={() => onSelectChannel(channel)} onClick={() => onSelectChannel(channel)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${ className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
selectedChannel?.id === channel.id selectedChannel?.id === channel.id
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100 border-r-2 border-blue-500' ? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100 border-r-2 border-blue-500'
: 'text-gray-700 dark:text-gray-300' : 'text-gray-700 dark:text-gray-300'
}`} }`}
> >
<div className="flex items-center"> <div className="flex items-center">
<span className="text-gray-500 dark:text-gray-400 mr-2">#</span> <span className="text-gray-500 dark:text-gray-400 mr-2">#</span>
<span className="truncate">{channel.name}</span> <span className="truncate">{channel.name}</span>
</div>
{channel.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{channel.description}
</div> </div>
{channel.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{channel.description}
</div>
)}
</button>
{onDeleteChannel && (
<button
onClick={(e) => handleDeleteClick(channel, e)}
className="absolute right-1 top-1 opacity-0 group-hover:opacity-100 p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-opacity"
title="Channel löschen"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)} )}
</button> </div>
))} ))}
</div> </div>
))} ))}

View File

@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { departmentsAPI } from '../../services/api'; import { useAuth } from '../../contexts/AuthContext';
import { departmentsAPI, getApiUrl } from '../../services/api';
import type { Department } from '../../types'; import type { Department } from '../../types';
import { isAdmin } from '../../types';
import ToastContainer from '../ToastContainer'; import ToastContainer from '../ToastContainer';
const Layout: React.FC = () => { const Layout: React.FC = () => {
@ -49,7 +50,7 @@ const Layout: React.FC = () => {
return; return;
} }
if (user.is_admin) { if (isAdmin(user)) {
if (isMounted) { if (isMounted) {
setHasSnippetAccess(true); setHasSnippetAccess(true);
} }
@ -76,17 +77,22 @@ const Layout: React.FC = () => {
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, [user?.id, user?.is_admin]); }, [user?.id, isAdmin(user)]);
return ( return (
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900"> <div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900">
{/* Header */} {/* Header */}
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-2"> <header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-3 relative">
<div className="flex items-center justify-between"> <div className="flex items-center">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-16">
<h1 className="text-base font-bold text-gray-900 dark:text-white"> <div className="flex items-center space-x-2">
Collabrix <h1 className="text-base font-bold text-gray-900 dark:text-white">
</h1> Collabrix
</h1>
<span className="text-xs text-gray-500 dark:text-gray-400 mt-1 font-inter">
Struktur für Teams
</span>
</div>
<nav className="flex space-x-1.5"> <nav className="flex space-x-1.5">
<Link <Link
to="/" to="/"
@ -98,7 +104,7 @@ const Layout: React.FC = () => {
> >
Chat Chat
</Link> </Link>
{(user?.is_admin || hasSnippetAccess) && ( {(isAdmin(user) || hasSnippetAccess) && (
<Link <Link
to="/snippets" to="/snippets"
className={`px-3 py-1.5 text-sm rounded ${ className={`px-3 py-1.5 text-sm rounded ${
@ -120,22 +126,10 @@ const Layout: React.FC = () => {
> >
Kanban Kanban
</Link> </Link>
{user?.is_admin && (
<Link
to="/admin"
className={`px-3 py-1.5 text-sm rounded ${
location.pathname === '/admin'
? 'bg-blue-500 text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Admin
</Link>
)}
</nav> </nav>
</div> </div>
<div className="flex items-center space-x-3"> <div className="absolute right-3 top-2 flex items-center space-x-3">
<ToastContainer /> <ToastContainer />
<div className="flex items-center space-x-2.5 relative user-menu-container"> <div className="flex items-center space-x-2.5 relative user-menu-container">
<button <button
@ -144,7 +138,7 @@ const Layout: React.FC = () => {
> >
{user?.profile_picture ? ( {user?.profile_picture ? (
<img <img
src={`http://localhost:8000/${user.profile_picture}`} src={getApiUrl(user.profile_picture)}
alt={user.username} alt={user.username}
className="w-8 h-8 rounded-full object-cover" className="w-8 h-8 rounded-full object-cover"
/> />
@ -154,7 +148,7 @@ const Layout: React.FC = () => {
</div> </div>
)} )}
<span className="text-sm font-medium text-gray-900 dark:text-white"> <span className="text-sm font-medium text-gray-900 dark:text-white">
{user?.username} {user?.full_name || user?.username}
</span> </span>
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
@ -170,6 +164,15 @@ const Layout: React.FC = () => {
> >
Profil Profil
</Link> </Link>
{isAdmin(user) && (
<Link
to="/admin"
onClick={() => setUserMenuOpen(false)}
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Administration
</Link>
)}
<button <button
onClick={() => { onClick={() => {
toggleTheme(); toggleTheme();

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { authAPI, departmentsAPI } from '../../services/api'; import { authAPI, departmentsAPI, getApiUrl } from '../../services/api';
import type { Department } from '../../types'; import type { Department } from '../../types';
const ProfilePage: React.FC = () => { const ProfilePage: React.FC = () => {
@ -147,14 +147,14 @@ const ProfilePage: React.FC = () => {
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
{profilePicture ? ( {profilePicture ? (
<img <img
src={`http://localhost:8000/${profilePicture}`} src={getApiUrl(profilePicture)}
alt="Profile" alt="Profile"
className="w-32 h-32 rounded-full object-cover mb-4" className="w-32 h-32 rounded-full object-cover mb-4"
onError={(e) => { onError={(e) => {
console.error('Failed to load image:', `http://localhost:8000/${profilePicture}`); console.error('Failed to load image:', getApiUrl(profilePicture));
e.currentTarget.style.display = 'none'; e.currentTarget.style.display = 'none';
}} }}
onLoad={() => console.log('Image loaded successfully:', `http://localhost:8000/${profilePicture}`)} onLoad={() => console.log('Image loaded successfully:', getApiUrl(profilePicture))}
/> />
) : ( ) : (
<div className="w-32 h-32 bg-blue-500 rounded-full flex items-center justify-center text-white text-4xl font-bold mb-4"> <div className="w-32 h-32 bg-blue-500 rounded-full flex items-center justify-center text-white text-4xl font-bold mb-4">
@ -177,7 +177,7 @@ const ProfilePage: React.FC = () => {
{user.username} {user.username}
</h2> </h2>
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
{user.is_admin ? 'Administrator' : 'Benutzer'} {user.role === 'superadmin' ? 'SuperAdministrator' : user.role === 'admin' ? 'Administrator' : 'Benutzer'}
</p> </p>
</div> </div>

View File

@ -38,7 +38,7 @@ const LANGUAGES = [
interface SnippetEditorProps { interface SnippetEditorProps {
snippet: Snippet | null; snippet: Snippet | null;
onSave: () => void; onSave: (createdSnippet?: Snippet) => void;
onCancel: () => void; onCancel: () => void;
} }
@ -98,13 +98,14 @@ const SnippetEditor: React.FC<SnippetEditorProps> = ({ snippet, onSave, onCancel
department_id: visibility === 'department' ? departmentId : undefined, department_id: visibility === 'department' ? departmentId : undefined,
}; };
let result;
if (snippet) { if (snippet) {
await snippetsAPI.update(snippet.id, data); result = await snippetsAPI.update(snippet.id, data);
} else { } else {
await snippetsAPI.create(data); result = await snippetsAPI.create(data);
} }
onSave(); onSave(result);
} catch (error: any) { } catch (error: any) {
console.error('Failed to save snippet:', error); console.error('Failed to save snippet:', error);
addToast(error.response?.data?.detail || 'Failed to save snippet', 'error'); addToast(error.response?.data?.detail || 'Failed to save snippet', 'error');

View File

@ -69,10 +69,17 @@ const SnippetLibrary: React.FC = () => {
setShowEditor(true); setShowEditor(true);
}; };
const handleSave = async () => { const handleSave = async (createdSnippet?: Snippet) => {
setShowEditor(false); setShowEditor(false);
setEditingSnippet(null); setEditingSnippet(null);
await loadSnippets();
if (createdSnippet) {
// Add the new snippet to the existing list
setSnippets(prev => [createdSnippet, ...prev]);
} else {
// For updates, reload all snippets
await loadSnippets();
}
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {

View File

@ -0,0 +1,20 @@
import React from 'react';
import { MarkChatUnread } from '@mui/icons-material';
interface BlinkingEnvelopeProps {
hasNewMessages: boolean;
className?: string;
}
const BlinkingEnvelope: React.FC<BlinkingEnvelopeProps> = ({ hasNewMessages, className = '' }) => {
if (!hasNewMessages) return null;
return (
<MarkChatUnread
className={`text-gray-900 dark:text-white animate-pulse ${className}`}
style={{ fontSize: '18px' }}
/>
);
};
export default BlinkingEnvelope;

View File

@ -0,0 +1,21 @@
import React from 'react';
import { useUserStatus } from '../../contexts/UserStatusContext';
import UserStatusIndicator from './UserStatusIndicator';
const HeaderStatusIndicator: React.FC = () => {
const { userStatuses } = useUserStatus();
const onlineCount = userStatuses.filter(user => user.status === 'online').length;
const totalCount = userStatuses.length;
return (
<div className="flex items-center space-x-2 px-3 py-1 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<UserStatusIndicator status="online" size="sm" />
<span className="text-sm text-gray-700 dark:text-gray-300">
{onlineCount}/{totalCount} online
</span>
</div>
);
};
export default HeaderStatusIndicator;

View File

@ -0,0 +1,35 @@
import React from 'react';
import { UserStatus } from '../../contexts/UserStatusContext';
interface UserStatusIndicatorProps {
status: UserStatus;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const UserStatusIndicator: React.FC<UserStatusIndicatorProps> = ({
status,
size = 'sm',
className = ''
}) => {
const sizeClasses = {
sm: 'w-2 h-2',
md: 'w-3 h-3',
lg: 'w-4 h-4'
};
const statusClasses: Record<UserStatus, string> = {
online: 'bg-green-500',
away: 'bg-yellow-500',
offline: 'bg-red-500'
};
return (
<div
className={`${sizeClasses[size]} ${statusClasses[status]} rounded-full border border-white dark:border-gray-800 ${className}`}
title={`Status: ${status}`}
/>
);
};
export default UserStatusIndicator;

View File

@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { authAPI } from '../services/api'; import { authAPI } from '../services/api';
import { useToast } from './ToastContext';
import type { User, LoginRequest, RegisterRequest } from '../types'; import type { User, LoginRequest, RegisterRequest } from '../types';
interface AuthContextType { interface AuthContextType {
@ -10,6 +11,7 @@ interface AuthContextType {
logout: () => void; logout: () => void;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
isAuthenticated: boolean; isAuthenticated: boolean;
presenceWs: WebSocket | null;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
@ -17,13 +19,35 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(localStorage.getItem('token')); const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
const [presenceWs, setPresenceWs] = useState<WebSocket | null>(null);
const { addToast } = useToast();
useEffect(() => { useEffect(() => {
if (token) { if (token && window.location.pathname !== '/login' && window.location.pathname !== '/register') {
loadUser(); loadUser();
} else if (!token && window.location.pathname !== '/login' && window.location.pathname !== '/register') {
// Redirect to login if no token and not on auth pages
window.location.href = '/login';
} }
}, [token]); }, [token]);
// Listen for session expired events
useEffect(() => {
const handleSessionExpired = () => {
addToast('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning', 5000);
setToken(null);
setUser(null);
closePresenceConnection();
// Redirect to login after a short delay to show the toast
setTimeout(() => {
window.location.href = '/login';
}, 1000);
};
window.addEventListener('sessionExpired', handleSessionExpired);
return () => window.removeEventListener('sessionExpired', handleSessionExpired);
}, [addToast]);
const loadUser = async () => { const loadUser = async () => {
try { try {
const userData = await authAPI.getCurrentUser(); const userData = await authAPI.getCurrentUser();
@ -35,9 +59,61 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
root.classList.remove('light', 'dark'); root.classList.remove('light', 'dark');
root.classList.add(userData.theme); root.classList.add(userData.theme);
} }
// Open presence connection when user is loaded
openPresenceConnection();
} catch (error) { } catch (error) {
// If loading user fails, clear token and user
console.error('Failed to load user:', error); console.error('Failed to load user:', error);
logout(); localStorage.removeItem('token');
setToken(null);
setUser(null);
closePresenceConnection();
// Show toast and redirect to login
addToast('Sitzung konnte nicht wiederhergestellt werden. Bitte melden Sie sich erneut an.', 'error', 5000);
setTimeout(() => {
window.location.href = '/login';
}, 1000);
}
};
const openPresenceConnection = () => {
if (token && !presenceWs) {
// Use API URL to determine WebSocket host
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const url = new URL(apiUrl);
const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${url.host}/api/ws/0?token=${token}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Presence WebSocket connected');
};
ws.onclose = () => {
console.log('Presence WebSocket disconnected');
setPresenceWs(null);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'message' || data.type === 'direct_message') {
// Dispatch custom event for unread messages
window.dispatchEvent(new CustomEvent('unreadMessage', { detail: data }));
}
} catch (error) {
console.error('Error parsing presence WebSocket message:', error);
}
};
setPresenceWs(ws);
}
};
const closePresenceConnection = () => {
if (presenceWs) {
presenceWs.close();
setPresenceWs(null);
} }
}; };
@ -45,6 +121,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const response = await authAPI.login(data); const response = await authAPI.login(data);
localStorage.setItem('token', response.access_token); localStorage.setItem('token', response.access_token);
setToken(response.access_token); setToken(response.access_token);
// Load user immediately after login
await loadUser();
}; };
const register = async (data: RegisterRequest) => { const register = async (data: RegisterRequest) => {
@ -54,6 +132,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}; };
const logout = () => { const logout = () => {
// Close presence connection before logging out
closePresenceConnection();
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('lastVisitedPath'); localStorage.removeItem('lastVisitedPath');
sessionStorage.removeItem('routeRestored'); sessionStorage.removeItem('routeRestored');
@ -80,6 +160,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
logout, logout,
refreshUser, refreshUser,
isAuthenticated: !!token && !!user, isAuthenticated: !!token && !!user,
presenceWs,
}} }}
> >
{children} {children}

View File

@ -0,0 +1,136 @@
import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
import { useAuth } from './AuthContext';
interface UnreadMessagesContextType {
unreadChannels: Set<number>;
unreadDirectMessages: Set<number>;
activeChannelId: number | null;
activeDirectMessageUserId: number | null;
markChannelAsRead: (channelId: number) => void;
markChannelAsUnread: (channelId: number) => void;
markDirectMessageAsRead: (userId: number) => void;
markDirectMessageAsUnread: (userId: number) => void;
hasUnreadChannel: (channelId: number) => boolean;
hasUnreadDirectMessage: (userId: number) => boolean;
setActiveChannel: (channelId: number | null) => void;
setActiveDirectMessage: (userId: number | null) => void;
}
const UnreadMessagesContext = createContext<UnreadMessagesContextType | undefined>(undefined);
export const useUnreadMessages = () => {
const context = useContext(UnreadMessagesContext);
if (context === undefined) {
throw new Error('useUnreadMessages must be used within a UnreadMessagesProvider');
}
return context;
};
interface UnreadMessagesProviderProps {
children: ReactNode;
}
export const UnreadMessagesProvider: React.FC<UnreadMessagesProviderProps> = ({ children }) => {
const [unreadChannels, setUnreadChannels] = useState<Set<number>>(new Set());
const [unreadDirectMessages, setUnreadDirectMessages] = useState<Set<number>>(new Set());
const [activeChannelId, setActiveChannelId] = useState<number | null>(null);
const [activeDirectMessageUserId, setActiveDirectMessageUserId] = useState<number | null>(null);
const { user } = useAuth();
// Listen for unread message events from the presence WebSocket
useEffect(() => {
const handleUnreadMessage = (event: CustomEvent) => {
const data = event.detail;
if (data.type === 'message' && data.message) {
// Mark channel as unread if message is from another user and channel is not active
if (user && data.message.sender_id !== user.id && activeChannelId !== data.message.channel_id) {
markChannelAsUnread(data.message.channel_id);
}
} else if (data.type === 'direct_message' && data.message) {
// Mark DM as unread if message is from another user and DM is not active
if (user && data.message.sender_id !== user.id && activeDirectMessageUserId !== data.message.sender_id) {
markDirectMessageAsUnread(data.message.sender_id);
}
}
};
window.addEventListener('unreadMessage', handleUnreadMessage as EventListener);
return () => {
window.removeEventListener('unreadMessage', handleUnreadMessage as EventListener);
};
}, [user, activeChannelId, activeDirectMessageUserId]);
const markChannelAsRead = (channelId: number) => {
setUnreadChannels(prev => {
const newSet = new Set(prev);
newSet.delete(channelId);
return newSet;
});
try {
// store last seen timestamp locally so MessageList can render a read marker
localStorage.setItem(`channel_last_seen_${channelId}`, new Date().toISOString());
} catch (e) {
// ignore storage errors
}
};
const markChannelAsUnread = (channelId: number) => {
setUnreadChannels(prev => new Set(prev).add(channelId));
};
const markDirectMessageAsRead = (userId: number) => {
setUnreadDirectMessages(prev => {
const newSet = new Set(prev);
newSet.delete(userId);
return newSet;
});
try {
// store last seen timestamp for direct messages
localStorage.setItem(`dm_last_seen_${userId}`, new Date().toISOString());
} catch (e) {
// ignore
}
};
const markDirectMessageAsUnread = (userId: number) => {
setUnreadDirectMessages(prev => new Set(prev).add(userId));
};
const setActiveChannel = (channelId: number | null) => {
setActiveChannelId(channelId);
};
const setActiveDirectMessage = (userId: number | null) => {
setActiveDirectMessageUserId(userId);
};
const hasUnreadChannel = (channelId: number) => {
return unreadChannels.has(channelId);
};
const hasUnreadDirectMessage = (userId: number) => {
return unreadDirectMessages.has(userId);
};
return (
<UnreadMessagesContext.Provider
value={{
unreadChannels,
unreadDirectMessages,
activeChannelId,
activeDirectMessageUserId,
markChannelAsRead,
markChannelAsUnread,
markDirectMessageAsRead,
markDirectMessageAsUnread,
hasUnreadChannel,
hasUnreadDirectMessage,
setActiveChannel,
setActiveDirectMessage,
}}
>
{children}
</UnreadMessagesContext.Provider>
);
};

View File

@ -0,0 +1,111 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { userStatusAPI } from '../services/api';
import { useAuth } from './AuthContext';
export type UserStatus = 'online' | 'away' | 'offline';
interface UserStatusInfo {
user_id: number;
username: string;
full_name: string;
status: UserStatus;
}
interface UserStatusContextType {
userStatuses: UserStatusInfo[];
getUserStatus: (userId: number) => UserStatus;
refreshStatuses: () => Promise<void>;
}
const UserStatusContext = createContext<UserStatusContextType | undefined>(undefined);
export const useUserStatus = () => {
const context = useContext(UserStatusContext);
if (context === undefined) {
throw new Error('useUserStatus must be used within a UserStatusProvider');
}
return context;
};
interface UserStatusProviderProps {
children: ReactNode;
}
export const UserStatusProvider: React.FC<UserStatusProviderProps> = ({ children }) => {
const [userStatuses, setUserStatuses] = useState<UserStatusInfo[]>([]);
const { token, presenceWs } = useAuth();
const refreshStatuses = async () => {
try {
const statuses = await userStatusAPI.getUserStatuses();
setUserStatuses(statuses.map(s => ({ ...s, status: s.status as UserStatus })));
} catch (error) {
console.error('Failed to load user statuses:', error);
}
};
const getUserStatus = (userId: number): UserStatus => {
const userStatus = userStatuses.find(u => u.user_id === userId);
return userStatus?.status || 'offline';
};
// Handle real-time status updates via WebSocket
useEffect(() => {
if (!presenceWs) return;
const handleMessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'user_status_update') {
setUserStatuses(prevStatuses => {
const updatedStatuses = prevStatuses.map(status =>
status.user_id === data.user_id
? { ...status, status: data.status as UserStatus }
: status
);
// If user not in list, add them (shouldn't happen but safety check)
const existingUser = updatedStatuses.find(s => s.user_id === data.user_id);
if (!existingUser) {
// We need to fetch user info for new users
refreshStatuses();
return prevStatuses;
}
return updatedStatuses;
});
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
presenceWs.addEventListener('message', handleMessage);
return () => {
presenceWs.removeEventListener('message', handleMessage);
};
}, [presenceWs]);
useEffect(() => {
// Initial load
if (token) {
refreshStatuses();
}
// Refresh every 30 seconds as fallback
const interval = setInterval(() => {
if (token) {
refreshStatuses();
}
}, 30000);
return () => clearInterval(interval);
}, [token]);
return (
<UserStatusContext.Provider value={{ userStatuses, getUserStatus, refreshStatuses }}>
{children}
</UserStatusContext.Provider>
);
};

View File

@ -5,15 +5,21 @@ import './index.css';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext'; import { ToastProvider } from './contexts/ToastContext';
import { UserStatusProvider } from './contexts/UserStatusContext';
import { UnreadMessagesProvider } from './contexts/UnreadMessagesContext';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ThemeProvider> <ThemeProvider>
<AuthProvider> <ToastProvider>
<ToastProvider> <AuthProvider>
<App /> <UserStatusProvider>
</ToastProvider> <UnreadMessagesProvider>
</AuthProvider> <App />
</UnreadMessagesProvider>
</UserStatusProvider>
</AuthProvider>
</ToastProvider>
</ThemeProvider> </ThemeProvider>
</StrictMode> </StrictMode>
); );

View File

@ -24,6 +24,22 @@ api.interceptors.request.use((config) => {
return config; return config;
}); });
// Handle 401 responses (token expired/invalid)
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token is invalid or expired, logout user
localStorage.removeItem('token');
localStorage.removeItem('lastVisitedPath');
sessionStorage.removeItem('routeRestored');
// Dispatch event to notify AuthContext
window.dispatchEvent(new CustomEvent('sessionExpired'));
}
return Promise.reject(error);
}
);
export const authAPI = { export const authAPI = {
login: async (data: LoginRequest): Promise<AuthResponse> => { login: async (data: LoginRequest): Promise<AuthResponse> => {
const response = await api.post('/auth/login', data); const response = await api.post('/auth/login', data);
@ -89,6 +105,11 @@ export const channelsAPI = {
const response = await api.post('/channels/', data); const response = await api.post('/channels/', data);
return response.data; return response.data;
}, },
delete: async (channelId: number) => {
const response = await api.delete(`/channels/${channelId}`);
return response.data;
},
}; };
export const messagesAPI = { export const messagesAPI = {
@ -359,4 +380,11 @@ export const kanbanAPI = {
}, },
}; };
export const userStatusAPI = {
getUserStatuses: async (): Promise<Array<{user_id: number, username: string, full_name: string, status: string}>> => {
const response = await api.get('/user-status');
return response.data;
},
};
export default api; export default api;

View File

@ -1,3 +1,17 @@
export enum UserRole {
USER = 'user',
ADMIN = 'admin',
SUPERADMIN = 'superadmin'
}
export const isAdmin = (user: User | null | undefined): boolean => {
return user?.role === UserRole.ADMIN || user?.role === UserRole.SUPERADMIN;
};
export const isSuperAdmin = (user: User | null | undefined): boolean => {
return user?.role === UserRole.SUPERADMIN;
};
export interface User { export interface User {
id: number; id: number;
username: string; username: string;
@ -6,7 +20,7 @@ export interface User {
profile_picture?: string; profile_picture?: string;
theme?: string; theme?: string;
is_active: boolean; is_active: boolean;
is_admin: boolean; role: UserRole;
created_at: string; created_at: string;
} }
@ -43,6 +57,7 @@ export interface Message {
id: number; id: number;
content: string; content: string;
sender_username: string; sender_username: string;
sender_full_name?: string;
}; };
is_deleted?: boolean; is_deleted?: boolean;
deleted?: boolean; deleted?: boolean;

View File

@ -7,4 +7,4 @@ echo "📍 API: http://localhost:8000"
echo "📚 Docs: http://localhost:8000/docs" echo "📚 Docs: http://localhost:8000/docs"
echo "" echo ""
/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload /bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload