diff --git a/backend/app/main.py b/backend/app/main.py index 0dbdd8e..f5badb4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,10 +1,13 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles 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.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 +from sqlmodel import Session, select settings = get_settings() @@ -18,9 +21,10 @@ app = FastAPI( app.add_middleware( CORSMiddleware, allow_origins=[ - settings.frontend_url, - "http://localhost:5173", + "http://localhost:5173", "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000", "https://collabrix.apex-project.de", "http://collabrix.apex-project.de" ], @@ -71,3 +75,32 @@ def read_root(): def health_check(): """Health check endpoint""" 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 all users""" + from app.websocket import manager + + # Get all users + statement = select(User) + users = session.exec(statement).all() + + # Get their statuses + statuses = manager.get_all_user_statuses() + + # Build response + result = [] + for user in users: + status = statuses.get(user.id, "offline") + # If user has no WebSocket connection but is the current user, mark as online + if status == "offline" and user.id == current_user.id: + status = "online" + result.append({ + "user_id": user.id, + "username": user.username, + "full_name": user.full_name, + "status": status + }) + + return result diff --git a/backend/app/models.py b/backend/app/models.py index 7083a79..1ce0894 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -10,6 +10,12 @@ class SnippetVisibility(str, Enum): ORGANIZATION = "organization" +class UserRole(str, Enum): + USER = "user" + ADMIN = "admin" + SUPERADMIN = "superadmin" + + class Language(SQLModel, table=True): __tablename__ = "language" @@ -64,7 +70,7 @@ class User(SQLModel, table=True): profile_picture: Optional[str] = None theme: str = Field(default="light") # 'light' or 'dark' 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) # Relationships diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 3fd09c7..d1bf2fe 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel import Session, select from typing import List 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 ( DepartmentCreate, DepartmentResponse, ChannelCreate, ChannelResponse, @@ -32,7 +32,7 @@ class UserDepartmentAssignment(BaseModel): class UserAdminUpdate(BaseModel): user_id: int - is_admin: bool + role: UserRole class SnippetDepartmentAccess(BaseModel): @@ -43,8 +43,8 @@ class SnippetDepartmentAccess(BaseModel): def require_admin(current_user: User = Depends(get_current_user)) -> User: - """Verify that the current user is an admin""" - if not current_user.is_admin: + """Verify that the current user is an admin or superadmin""" + if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required" @@ -52,12 +52,22 @@ def require_admin(current_user: User = Depends(get_current_user)) -> 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 ========== @router.get("/users", response_model=List[UserResponse]) def get_all_users( session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """Get all users (Admin only)""" statement = select(User) @@ -65,14 +75,14 @@ def get_all_users( return users -@router.patch("/users/{user_id}/admin") -def toggle_admin_status( +@router.patch("/users/{user_id}/role") +def update_user_role( user_id: int, - is_admin: bool, + role: UserRole, 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) if not user: raise HTTPException( @@ -80,12 +90,19 @@ def toggle_admin_status( 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.commit() 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 ========== @@ -94,7 +111,7 @@ def toggle_admin_status( def create_department( department_data: DepartmentCreate, session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """Create a new department (Admin only)""" department = Department(**department_data.model_dump()) @@ -107,7 +124,7 @@ def create_department( @router.get("/departments", response_model=List[DepartmentResponse]) def get_all_departments( session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """Get all departments (Admin only)""" statement = select(Department) @@ -120,7 +137,7 @@ def update_department( department_id: int, department_data: DepartmentCreate, session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """Update a department (Admin only)""" department = session.get(Department, department_id) @@ -145,7 +162,7 @@ def toggle_department_snippets( department_id: int, enabled: bool, 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)""" department = session.get(Department, department_id) @@ -171,7 +188,7 @@ def toggle_department_snippets( def delete_department( department_id: int, session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """Delete a department (Admin only)""" department = session.get(Department, department_id) @@ -209,7 +226,7 @@ def assign_user_to_department( department_id: int, user_id: int, session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """Assign a user to a department (Admin only)""" # Check if department exists @@ -254,7 +271,7 @@ def remove_user_from_department( department_id: int, user_id: int, session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """Remove a user from a department (Admin only)""" statement = select(UserDepartmentLink).where( @@ -279,7 +296,7 @@ def remove_user_from_department( def get_department_members( department_id: int, session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """Get all members of a department (Admin only)""" department = session.get(Department, department_id) @@ -352,7 +369,7 @@ def delete_channel( @router.get("/languages", response_model=List[LanguageResponse]) def get_languages( session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """List all available UI languages.""" ensure_default_languages(session) @@ -364,7 +381,7 @@ def get_languages( def create_language( language_data: LanguageCreate, session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """Create a new UI language.""" code = language_data.code.strip().lower() @@ -408,7 +425,7 @@ def create_language( def delete_language( language_id: int, session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """Remove a UI language.""" language = session.get(Language, language_id) @@ -438,7 +455,7 @@ def delete_language( @router.get("/translations", response_model=List[TranslationGroupResponse]) def get_translations( session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """Retrieve translation values grouped by attribute.""" ensure_default_languages(session) @@ -498,7 +515,7 @@ def get_translations( def update_translation( payload: TranslationUpdateRequest, session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """Update a single translation entry.""" translation = session.get(Translation, payload.translation_id) @@ -527,7 +544,7 @@ def update_translation( def get_snippet_departments( snippet_id: int, 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. By default, snippets are disabled for all departments. @@ -563,7 +580,7 @@ def get_snippet_departments( def toggle_snippet_department_access( access_data: SnippetDepartmentAccess, session: Session = Depends(get_session), - admin: User = Depends(require_admin) + admin: User = Depends(require_superadmin) ): """Enable or disable a snippet for a specific department. By default, all snippets are disabled for all departments. diff --git a/backend/app/routers/channels.py b/backend/app/routers/channels.py index 8b10f98..b599e5b 100644 --- a/backend/app/routers/channels.py +++ b/backend/app/routers/channels.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel import Session, select from typing import List 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.auth import get_current_user @@ -107,7 +107,7 @@ def get_channel( user = session.exec(statement).first() 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( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this channel" @@ -128,7 +128,7 @@ def get_channels_by_department( user = session.exec(statement).first() 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( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this department" diff --git a/backend/app/routers/direct_messages.py b/backend/app/routers/direct_messages.py index 1153e50..3558b11 100644 --- a/backend/app/routers/direct_messages.py +++ b/backend/app/routers/direct_messages.py @@ -1,8 +1,9 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel import Session, select, or_, and_ +from sqlalchemy.orm import joinedload from typing import List 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.auth import get_current_user from app.websocket import manager @@ -50,6 +51,20 @@ async def create_direct_message( response.sender_full_name = current_user.full_name 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") response_data = { "id": new_message.id, @@ -63,7 +78,7 @@ async def create_direct_message( "created_at": new_message.created_at.isoformat(), "is_read": new_message.is_read, "snippet_id": new_message.snippet_id, - "snippet": None + "snippet": snippet_data } # Broadcast to both sender and receiver using their user IDs as "channel" @@ -76,6 +91,9 @@ async def create_direct_message( -current_user.id ) + # Update user activity + manager.update_activity(current_user.id) + return response @@ -105,6 +123,7 @@ def get_conversation( and_(DirectMessage.sender_id == user_id, DirectMessage.receiver_id == current_user.id) ) ) + .options(joinedload(DirectMessage.snippet)) .order_by(DirectMessage.created_at.desc()) .offset(offset) .limit(limit) diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 9cbf2d2..2d7fc45 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -7,7 +7,7 @@ import uuid import aiofiles from urllib.parse import quote 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.auth import get_current_user from app.config import get_settings @@ -266,7 +266,8 @@ async def upload_file_with_message( reply_to_data = { "id": reply_msg.id, "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 = { @@ -409,7 +410,7 @@ async def update_file_permission( ) # 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( status_code=status.HTTP_403_FORBIDDEN, detail="Only the uploader or an admin can change file permissions" diff --git a/backend/app/routers/kanban.py b/backend/app/routers/kanban.py index eab5151..160eede 100644 --- a/backend/app/routers/kanban.py +++ b/backend/app/routers/kanban.py @@ -4,7 +4,7 @@ from typing import List from app.database import get_session from app.models import ( KanbanBoard, KanbanColumn, KanbanCard, Channel, User, - KanbanChecklist, KanbanChecklistItem + KanbanChecklist, KanbanChecklistItem, UserRole ) from app.schemas import ( KanbanBoardCreate, KanbanBoardUpdate, KanbanBoardResponse, @@ -38,7 +38,7 @@ def create_board( # Check if user has access to the channel's department 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( status_code=status.HTTP_403_FORBIDDEN, 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] - 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this channel" @@ -141,7 +141,7 @@ def update_board( # Check access via channel channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -174,7 +174,7 @@ def create_column( channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -213,7 +213,7 @@ def update_column( board = session.get(KanbanBoard, column.board_id) channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -246,7 +246,7 @@ def delete_column( board = session.get(KanbanBoard, column.board_id) channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -276,7 +276,7 @@ def create_card( board = session.get(KanbanBoard, column.board_id) channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -320,7 +320,7 @@ def update_card( board = session.get(KanbanBoard, column.board_id) channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -354,7 +354,7 @@ def delete_card( board = session.get(KanbanBoard, column.board_id) channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -401,7 +401,7 @@ def move_card( channel = session.get(Channel, source_board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -436,7 +436,7 @@ def create_checklist( board = session.get(KanbanBoard, card.column.board_id) channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -473,7 +473,7 @@ def get_checklist( board = session.get(KanbanBoard, checklist.card.column.board_id) channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -501,7 +501,7 @@ def update_checklist( board = session.get(KanbanBoard, checklist.card.column.board_id) channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -537,7 +537,7 @@ def delete_checklist( board = session.get(KanbanBoard, checklist.card.column.board_id) channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -569,7 +569,7 @@ def create_checklist_item( board = session.get(KanbanBoard, checklist.card.column.board_id) channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -608,7 +608,7 @@ def update_checklist_item( board = session.get(KanbanBoard, item.checklist.card.column.board_id) channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -646,7 +646,7 @@ def delete_checklist_item( board = session.get(KanbanBoard, item.checklist.card.column.board_id) channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" @@ -677,7 +677,7 @@ def get_card_checklists( board = session.get(KanbanBoard, card.column.board_id) channel = session.get(Channel, board.channel_id) 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( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" diff --git a/backend/app/routers/messages.py b/backend/app/routers/messages.py index b165a6b..2b1c640 100644 --- a/backend/app/routers/messages.py +++ b/backend/app/routers/messages.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import joinedload from typing import List import os 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.auth import get_current_user from app.websocket import manager @@ -66,7 +66,22 @@ async def create_message( reply_to_data = { "id": reply_msg.id, "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 = { @@ -81,7 +96,7 @@ async def create_message( "snippet_id": new_message.snippet_id, "reply_to_id": new_message.reply_to_id, "reply_to": reply_to_data, - "snippet": None, + "snippet": snippet_data, "attachments": [], "is_deleted": False } @@ -95,6 +110,9 @@ async def create_message( message_data.channel_id ) + # Update user activity + manager.update_activity(current_user.id) + # Return proper response response = MessageResponse.model_validate(new_message) response.sender_username = current_user.username @@ -124,6 +142,7 @@ def get_channel_messages( select(Message) .where(Message.channel_id == channel_id) .options(joinedload(Message.attachments)) + .options(joinedload(Message.snippet)) .order_by(Message.created_at.desc()) .offset(offset) .limit(limit) @@ -147,7 +166,8 @@ def get_channel_messages( msg_response.reply_to = { "id": reply_msg.id, "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) diff --git a/backend/app/routers/snippets.py b/backend/app/routers/snippets.py index 34c6cda..42f77ae 100644 --- a/backend/app/routers/snippets.py +++ b/backend/app/routers/snippets.py @@ -3,7 +3,7 @@ from sqlmodel import Session, select, or_, and_ from typing import List, Optional from datetime import datetime 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 ( SnippetCreate, SnippetUpdate, @@ -83,7 +83,7 @@ def create_snippet( # Check if user belongs to that department 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( status_code=status.HTTP_403_FORBIDDEN, detail="You don't belong to this department" diff --git a/backend/app/routers/websocket.py b/backend/app/routers/websocket.py index 34d4fa0..8ee784d 100644 --- a/backend/app/routers/websocket.py +++ b/backend/app/routers/websocket.py @@ -2,12 +2,12 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query from sqlmodel import Session from app.database import get_session from app.websocket import manager -from app.auth import decode_access_token -from app.models import User, Channel +from app.auth import decode_access_token, get_current_user +from app.models import User, Channel, UserRole from sqlmodel import select import json -router = APIRouter() +router = APIRouter(tags=["WebSocket"]) @router.websocket("/ws/{channel_id}") @@ -34,12 +34,13 @@ async def websocket_endpoint( return # Negative channel_id means direct messages (user_id) + # channel_id 0 means presence-only connection if channel_id < 0: # Direct message connection - verify it's the user's own connection if -channel_id != user.id: await websocket.close(code=1008, reason="Access denied") return - else: + elif channel_id > 0: # Regular channel - verify channel exists and user has access channel = session.get(Channel, channel_id) if not channel: @@ -47,12 +48,16 @@ async def websocket_endpoint( return 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") return + # channel_id 0 is allowed for presence-only connections # 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: # Send welcome message @@ -89,7 +94,11 @@ async def websocket_endpoint( ) 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: - 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}") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 54901ee..680a8c1 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,6 +1,13 @@ from pydantic import BaseModel, EmailStr from typing import Optional, List from datetime import datetime +from enum import Enum + + +class UserRole(str, Enum): + USER = "user" + ADMIN = "admin" + SUPERADMIN = "superadmin" # User Schemas @@ -31,7 +38,7 @@ class UserLogin(BaseModel): class UserResponse(UserBase): id: int is_active: bool - is_admin: bool = False + role: UserRole = UserRole.USER created_at: datetime class Config: diff --git a/backend/app/websocket.py b/backend/app/websocket.py index 2f6b329..6c2f09b 100644 --- a/backend/app/websocket.py +++ b/backend/app/websocket.py @@ -1,21 +1,33 @@ from fastapi import WebSocket, WebSocketDisconnect -from typing import Dict, List +from typing import Dict, List, Optional import json +import time +from datetime import datetime, timedelta class ConnectionManager: def __init__(self): # Maps channel_id to list of WebSocket connections 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""" await websocket.accept() if channel_id not in self.active_connections: self.active_connections[channel_id] = [] 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""" if channel_id in self.active_connections: if websocket in self.active_connections[channel_id]: @@ -24,6 +36,30 @@ class ConnectionManager: # Clean up empty channel lists if not 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): """Send a message to a specific WebSocket""" @@ -42,9 +78,43 @@ class ConnectionManager: # Mark for removal if send fails 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: - 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 diff --git a/backend/scripts/migrate_user_roles.py b/backend/scripts/migrate_user_roles.py new file mode 100644 index 0000000..5cf0f19 --- /dev/null +++ b/backend/scripts/migrate_user_roles.py @@ -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() \ No newline at end of file diff --git a/backend/scripts/set_ronny_superadmin.py b/backend/scripts/set_ronny_superadmin.py new file mode 100644 index 0000000..6b610b6 --- /dev/null +++ b/backend/scripts/set_ronny_superadmin.py @@ -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() \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 89cd445..2d5fe6c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,11 +9,15 @@ "preview": "vite preview" }, "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-dom": "^18.2.0", - "react-router-dom": "^6.20.0", - "axios": "^1.6.2", - "prism-react-renderer": "^1.3.5" + "react-router-dom": "^6.20.0" }, "devDependencies": { "@types/react": "^18.2.43", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 74bffe8..b89af3b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react'; import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from './contexts/AuthContext'; +import { isAdmin } from './types'; import Login from './components/Auth/Login'; import Register from './components/Auth/Register'; import ChatView from './components/Chat/ChatView'; @@ -22,7 +23,7 @@ const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { return ; } - if (!user?.is_admin) { + if (!isAdmin(user)) { return ; } @@ -38,7 +39,7 @@ const AppContent: React.FC = () => { // Speichere nur Pfade innerhalb der geschützten Bereiche (nicht login/register) if (isAuthenticated && !location.pathname.startsWith('/login') && !location.pathname.startsWith('/register')) { // 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 } localStorage.setItem('lastVisitedPath', location.pathname + location.search); @@ -85,7 +86,7 @@ const RouteRestorer: React.FC = () => { if (lastVisitedPath && lastVisitedPath !== '/') { // Prüfe, ob der Pfad gültig ist const isAdminPath = lastVisitedPath.startsWith('/admin'); - if (isAdminPath && !user.is_admin) { + if (isAdminPath && !isAdmin(user)) { // Benutzer hat keinen Admin-Zugriff, bleibe auf der Hauptseite localStorage.removeItem('lastVisitedPath'); sessionStorage.setItem('routeRestored', 'true'); diff --git a/frontend/src/components/Admin/AdminPanel.old.tsx b/frontend/src/components/Admin/AdminPanel.old.tsx deleted file mode 100644 index 731c8ad..0000000 --- a/frontend/src/components/Admin/AdminPanel.old.tsx +++ /dev/null @@ -1,1387 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import api, { adminLanguagesAPI, adminTranslationsAPI, snippetsAPI } from '../../services/api'; -import type { - Channel, - Department, - Language, - Snippet, - TranslationGroup, - User, -} from '../../types'; - -type TabKey = 'users' | 'departments' | 'channels' | 'snippets' | 'languages'; - -type DepartmentSnippetEntry = { - snippet_id: number; - snippet_title: string; - snippet_language: string; - snippet_owner: string; - enabled: boolean; -}; - -type SnippetAccessEntry = { - department_id: number; - department_name: string; - enabled: boolean; -}; - -const AdminPanel: React.FC = () => { - const [activeTab, setActiveTab] = useState('users'); - const [error, setError] = useState(null); - - const [loading, setLoading] = useState(false); - const [languagesLoading, setLanguagesLoading] = useState(false); - const [translationsLoading, setTranslationsLoading] = useState(false); - const [loadingSnippets, setLoadingSnippets] = useState(false); - const [snippetAccessLoading, setSnippetAccessLoading] = useState(false); - - const [users, setUsers] = useState([]); - const [departments, setDepartments] = useState([]); - const [channels, setChannels] = useState([]); - const [snippets, setSnippets] = useState([]); - const [uiLanguages, setUiLanguages] = useState([]); - const [translations, setTranslations] = useState([]); - const [languageModalOpen, setLanguageModalOpen] = useState(false); - - const [usersLoaded, setUsersLoaded] = useState(false); - const [departmentsLoaded, setDepartmentsLoaded] = useState(false); - const [channelsLoaded, setChannelsLoaded] = useState(false); - const [snippetsLoaded, setSnippetsLoaded] = useState(false); - const [languagesLoaded, setLanguagesLoaded] = useState(false); - const [translationsLoaded, setTranslationsLoaded] = useState(false); - - const [selectedUserId, setSelectedUserId] = useState(null); - const [assignDeptId, setAssignDeptId] = useState(null); - - const [newDeptName, setNewDeptName] = useState(''); - const [newDeptDesc, setNewDeptDesc] = useState(''); - - const [editingDept, setEditingDept] = useState(null); - const [editDeptName, setEditDeptName] = useState(''); - const [editDeptDesc, setEditDeptDesc] = useState(''); - const [deptSnippets, setDeptSnippets] = useState([]); - - const [newChannelName, setNewChannelName] = useState(''); - const [newChannelDesc, setNewChannelDesc] = useState(''); - const [channelDeptId, setChannelDeptId] = useState(null); - - const [selectedSnippetId, setSelectedSnippetId] = useState(null); - const [snippetAccess, setSnippetAccess] = useState([]); - - const [newLanguageCode, setNewLanguageCode] = useState(''); - const [newLanguageName, setNewLanguageName] = useState(''); - - const [translationSaving, setTranslationSaving] = useState(null); - - const navItems = useMemo>( - () => [ - { key: 'users', label: 'Benutzer', description: 'Verwalten Sie Benutzer und Admin-Rechte.' }, - { key: 'departments', label: 'Abteilungen', description: 'Strukturieren Sie Teams und Rechte.' }, - { key: 'channels', label: 'Channels', description: 'Organisieren Sie Kommunikationsräume.' }, - { key: 'snippets', label: 'Snippets', description: 'Steuern Sie Code-Snippet Zugriff.' }, - { key: 'languages', label: 'Sprachen', description: 'Verwalten Sie UI-Sprachen & Texte.' }, - ], - [] - ); - - const setGlobalError = useCallback((message: string) => { - console.error(message); - setError(message); - }, []); - - const loadUsers = useCallback(async () => { - setLoading(true); - setError(null); - try { - const response = await api.get('/admin/users'); - setUsers(response.data); - setUsersLoaded(true); - } catch (err) { - setGlobalError('Benutzer konnten nicht geladen werden.'); - } finally { - setLoading(false); - } - }, [setGlobalError]); - - const loadDepartments = useCallback(async () => { - setLoading(true); - setError(null); - try { - const response = await api.get('/admin/departments'); - setDepartments(response.data); - setDepartmentsLoaded(true); - } catch (err) { - setGlobalError('Abteilungen konnten nicht geladen werden.'); - } finally { - setLoading(false); - } - }, [setGlobalError]); - - const loadChannels = useCallback(async () => { - setLoading(true); - setError(null); - try { - const response = await api.get('/admin/channels'); - setChannels(response.data); - setChannelsLoaded(true); - } catch (err) { - setGlobalError('Channels konnten nicht geladen werden.'); - } finally { - setLoading(false); - } - }, [setGlobalError]); - - const loadSnippets = useCallback(async () => { - setLoading(true); - setError(null); - try { - const response = await snippetsAPI.getAll(); - setSnippets(response); - setSnippetsLoaded(true); - } catch (err) { - setGlobalError('Snippets konnten nicht geladen werden.'); - } finally { - setLoading(false); - } - }, [setGlobalError]); - - const loadLanguages = useCallback(async () => { - setLanguagesLoading(true); - setError(null); - try { - const items = await adminLanguagesAPI.getAll(); - setUiLanguages(items); - setLanguagesLoaded(true); - } catch (err) { - setGlobalError('Sprachen konnten nicht geladen werden.'); - } finally { - setLanguagesLoading(false); - } - }, [setGlobalError]); - - const loadTranslations = useCallback(async () => { - setTranslationsLoading(true); - setError(null); - try { - const items = await adminTranslationsAPI.getAll(); - setTranslations(items); - setTranslationsLoaded(true); - } catch (err) { - setGlobalError('Übersetzungen konnten nicht geladen werden.'); - } finally { - setTranslationsLoading(false); - } - }, [setGlobalError]); - - const ensureDeptSnippetsLoaded = useCallback( - async (departmentId: number) => { - setLoadingSnippets(true); - setError(null); - try { - const departmentSnippets = await snippetsAPI.getAll({ visibility: 'department' }); - const enriched = await Promise.all( - departmentSnippets.map(async (snippet: Snippet) => { - const accessResponse = await api.get( - `/admin/snippets/${snippet.id}/departments` - ); - const entry = accessResponse.data.find((item) => item.department_id === departmentId); - return { - snippet_id: snippet.id, - snippet_title: snippet.title, - snippet_language: snippet.language, - snippet_owner: snippet.owner_username ?? 'Unbekannt', - enabled: entry?.enabled ?? false, - } as DepartmentSnippetEntry; - }) - ); - setDeptSnippets(enriched); - } catch (err) { - setGlobalError('Snippet-Berechtigungen konnten nicht geladen werden.'); - } finally { - setLoadingSnippets(false); - } - }, - [setGlobalError] - ); - - const fetchSnippetAccess = useCallback(async (snippetId: number) => { - setSnippetAccessLoading(true); - setError(null); - try { - const response = await api.get(`/admin/snippets/${snippetId}/departments`); - setSnippetAccess(response.data); - } catch (err) { - setGlobalError('Abteilungszugriffe konnten nicht geladen werden.'); - } finally { - setSnippetAccessLoading(false); - } - }, [setGlobalError]); - - useEffect(() => { - if (activeTab === 'users' && !usersLoaded) { - loadUsers(); - } - if ((activeTab === 'departments' || activeTab === 'channels' || activeTab === 'snippets') && !departmentsLoaded) { - loadDepartments(); - } - if (activeTab === 'channels' && !channelsLoaded) { - loadChannels(); - } - if (activeTab === 'snippets' && !snippetsLoaded) { - loadSnippets(); - } - if (activeTab === 'languages' && !languagesLoaded) { - loadLanguages(); - } - if (activeTab === 'languages' && !translationsLoaded) { - loadTranslations(); - } - }, [ - activeTab, - channelsLoaded, - departmentsLoaded, - languagesLoaded, - loadChannels, - loadDepartments, - loadLanguages, - loadSnippets, - loadTranslations, - loadUsers, - snippetsLoaded, - translationsLoaded, - usersLoaded, - ]); - - const toggleAdmin = useCallback( - async (userId: number, isAdmin: boolean) => { - setError(null); - try { - await api.patch(`/admin/users/${userId}/admin`, null, { params: { is_admin: !isAdmin } }); - setUsers((prev) => prev.map((user) => (user.id === userId ? { ...user, is_admin: !isAdmin } : user))); - } catch (err) { - setGlobalError('Admin-Status konnte nicht geändert werden.'); - } - }, - [setGlobalError] - ); - - const assignUserToDepartment = useCallback( - async (event: React.FormEvent) => { - event.preventDefault(); - if (!selectedUserId || !assignDeptId) { - setGlobalError('Bitte wählen Sie einen Benutzer und eine Abteilung aus.'); - return; - } - setError(null); - try { - await api.post(`/admin/departments/${assignDeptId}/members`, null, { - params: { user_id: selectedUserId }, - }); - setSelectedUserId(null); - setAssignDeptId(null); - } catch (err) { - setGlobalError('Konnte Benutzer nicht zuweisen.'); - } - }, - [assignDeptId, selectedUserId, setGlobalError] - ); - - const createDepartment = useCallback( - async (event: React.FormEvent) => { - event.preventDefault(); - if (!newDeptName.trim()) { - setGlobalError('Der Abteilungsname darf nicht leer sein.'); - return; - } - setError(null); - try { - const response = await api.post('/admin/departments', { - name: newDeptName.trim(), - description: newDeptDesc.trim() || undefined, - }); - setDepartments((prev) => [...prev, response.data]); - setNewDeptName(''); - setNewDeptDesc(''); - } catch (err) { - setGlobalError('Abteilung konnte nicht erstellt werden.'); - } - }, - [newDeptDesc, newDeptName, setGlobalError] - ); - - const startEditDepartment = useCallback( - (department: Department) => { - setEditingDept(department); - setEditDeptName(department.name); - setEditDeptDesc(department.description ?? ''); - if (department.snippets_enabled) { - ensureDeptSnippetsLoaded(department.id); - } else { - setDeptSnippets([]); - } - }, - [ensureDeptSnippetsLoaded] - ); - - const cancelEditDepartment = useCallback(() => { - setEditingDept(null); - setEditDeptName(''); - setEditDeptDesc(''); - setDeptSnippets([]); - }, []); - - const updateDepartment = useCallback( - async (event: React.FormEvent) => { - event.preventDefault(); - if (!editingDept) { - return; - } - if (!editDeptName.trim()) { - setGlobalError('Der Abteilungsname darf nicht leer sein.'); - return; - } - setError(null); - try { - const response = await api.put(`/admin/departments/${editingDept.id}`, { - name: editDeptName.trim(), - description: editDeptDesc.trim() || undefined, - }); - setDepartments((prev) => prev.map((dept) => (dept.id === editingDept.id ? response.data : dept))); - setEditingDept(response.data); - } catch (err) { - setGlobalError('Abteilung konnte nicht aktualisiert werden.'); - } - }, - [editDeptDesc, editDeptName, editingDept, setGlobalError] - ); - - const deleteDepartment = useCallback( - async (departmentId: number) => { - if (!window.confirm('Möchten Sie diese Abteilung wirklich löschen?')) { - return; - } - setError(null); - try { - await api.delete(`/admin/departments/${departmentId}`); - setDepartments((prev) => prev.filter((dept) => dept.id !== departmentId)); - if (editingDept && editingDept.id === departmentId) { - cancelEditDepartment(); - } - } catch (err) { - setGlobalError('Abteilung konnte nicht gelöscht werden.'); - } - }, - [cancelEditDepartment, editingDept, setGlobalError] - ); - - const toggleDepartmentSnippetAccess = useCallback( - async (departmentId: number, enabled: boolean) => { - setError(null); - try { - await api.patch(`/admin/departments/${departmentId}/snippets`, null, { params: { enabled: !enabled } }); - setDepartments((prev) => - prev.map((dept) => - dept.id === departmentId ? { ...dept, snippets_enabled: !enabled } : dept - ) - ); - if (editingDept && editingDept.id === departmentId) { - const updated = { ...editingDept, snippets_enabled: !enabled }; - setEditingDept(updated); - if (!enabled) { - ensureDeptSnippetsLoaded(departmentId); - } else { - setDeptSnippets([]); - } - } - } catch (err) { - setGlobalError('Snippet-Hauptschalter konnte nicht gesetzt werden.'); - } - }, - [editingDept, ensureDeptSnippetsLoaded, setGlobalError] - ); - - const toggleDepartmentSnippet = useCallback( - async (snippetId: number, enabled: boolean) => { - if (!editingDept) { - return; - } - setError(null); - try { - await api.post('/admin/snippets/departments/toggle', { - snippet_id: snippetId, - department_id: editingDept.id, - enabled, - }); - setDeptSnippets((prev) => - prev.map((item) => (item.snippet_id === snippetId ? { ...item, enabled } : item)) - ); - } catch (err) { - setGlobalError('Snippet-Zugriff konnte nicht angepasst werden.'); - } - }, - [editingDept, setGlobalError] - ); - - const createChannel = useCallback( - async (event: React.FormEvent) => { - event.preventDefault(); - if (!newChannelName.trim() || !channelDeptId) { - setGlobalError('Bitte geben Sie einen Namen an und wählen Sie eine Abteilung.'); - return; - } - setError(null); - try { - const response = await api.post('/admin/channels', { - name: newChannelName.trim(), - description: newChannelDesc.trim() || undefined, - department_id: channelDeptId, - }); - setChannels((prev) => [...prev, response.data]); - setNewChannelName(''); - setNewChannelDesc(''); - setChannelDeptId(null); - } catch (err) { - setGlobalError('Channel konnte nicht erstellt werden.'); - } - }, - [channelDeptId, newChannelDesc, newChannelName, setGlobalError] - ); - - const deleteChannel = useCallback( - async (channelId: number, channelName: string) => { - if (!window.confirm(`Channel "${channelName}" löschen?`)) { - return; - } - setError(null); - try { - await api.delete(`/admin/channels/${channelId}`); - setChannels((prev) => prev.filter((channel) => channel.id !== channelId)); - } catch (err) { - setGlobalError('Channel konnte nicht gelöscht werden.'); - } - }, - [setGlobalError] - ); - - const toggleSnippetAccess = useCallback( - async (snippetId: number, departmentId: number, enabled: boolean) => { - setError(null); - try { - await api.post('/admin/snippets/departments/toggle', { - snippet_id: snippetId, - department_id: departmentId, - enabled, - }); - setSnippetAccess((prev) => - prev.map((entry) => - entry.department_id === departmentId ? { ...entry, enabled } : entry - ) - ); - } catch (err) { - setGlobalError('Snippet-Berechtigung konnte nicht geändert werden.'); - } - }, - [setGlobalError] - ); - - const openLanguageModal = () => { - setError(null); - setNewLanguageCode(''); - setNewLanguageName(''); - setLanguageModalOpen(true); - }; - - const closeLanguageModal = useCallback(() => { - setLanguageModalOpen(false); - setNewLanguageCode(''); - setNewLanguageName(''); - }, []); - - const createLanguage = useCallback( - async (event: React.FormEvent) => { - event.preventDefault(); - const code = newLanguageCode.trim().toLowerCase(); - const name = newLanguageName.trim(); - if (!code || !name) { - setGlobalError('Bitte geben Sie Code und Anzeigename an.'); - return; - } - setError(null); - try { - const language = await adminLanguagesAPI.create({ code, name }); - setUiLanguages((prev) => [...prev, language].sort((a, b) => a.name.localeCompare(b.name))); - closeLanguageModal(); - if (translationsLoaded) { - loadTranslations(); - } - } catch (err) { - setGlobalError('Sprache konnte nicht erstellt werden.'); - } - }, - [ - closeLanguageModal, - loadTranslations, - newLanguageCode, - newLanguageName, - setGlobalError, - translationsLoaded, - ] - ); - - const deleteLanguage = useCallback( - async (languageId: number, languageName: string) => { - if (!window.confirm(`Sprache "${languageName}" löschen?`)) { - return; - } - setError(null); - try { - await adminLanguagesAPI.delete(languageId); - setUiLanguages((prev) => prev.filter((language) => language.id !== languageId)); - setTranslations((prev) => - prev.map((group) => ({ - ...group, - entries: group.entries.filter((entry) => entry.language_id !== languageId), - })) - ); - } catch (err) { - setGlobalError('Sprache konnte nicht gelöscht werden.'); - } - }, - [setGlobalError] - ); - - const updateTranslationDraft = useCallback((translationId: number, value: string) => { - setTranslations((prev) => - prev.map((group) => ({ - ...group, - entries: group.entries.map((entry) => - entry.translation_id === translationId ? { ...entry, value } : entry - ), - })) - ); - }, []); - - const saveTranslationValue = useCallback( - async (translationId: number, value: string) => { - setError(null); - setTranslationSaving(translationId); - try { - await adminTranslationsAPI.update({ translation_id: translationId, value }); - } catch (err) { - setGlobalError('Übersetzung konnte nicht gespeichert werden.'); - } finally { - setTranslationSaving(null); - } - }, - [setGlobalError] - ); - - const handleSnippetToggle = useCallback( - (snippet: Snippet) => { - if (selectedSnippetId === snippet.id) { - setSelectedSnippetId(null); - setSnippetAccess([]); - return; - } - setSelectedSnippetId(snippet.id); - setSnippetAccess([]); - fetchSnippetAccess(snippet.id); - }, - [fetchSnippetAccess, selectedSnippetId] - ); - - const stats = useMemo(() => { - const departmentSnippets = snippets.filter((snippet) => snippet.visibility === 'department'); - const organizationSnippets = snippets.filter((snippet) => snippet.visibility === 'organization'); - return { - total: snippets.length, - department: departmentSnippets.length, - organization: organizationSnippets.length, - }; - }, [snippets]); - - return ( -
-
-
- - -
-
-
-

Adminbereich

-

- Verwalten Sie Benutzer, Strukturen und Inhalte Ihrer Organisation. -

-
-
- - {error && ( -
- {error} -
- )} - - {activeTab === 'users' && ( -
-
-

- Benutzer-Verwaltung -

- {loading ? ( -

Lädt...

- ) : ( -
- - - - - - - - - - - - {users.map((user) => ( - - - - - - - - ))} - -
UsernameEmailNameAdminAktionen
- {user.username} - - {user.email} - - {user.full_name || '-'} - - {user.is_admin ? ( - - Admin - - ) : ( - - User - - )} - - -
-
- )} -
- -
-

- User zu Abteilung zuweisen -

-
-
- - -
-
- - -
- -
-
-
- )} - - {activeTab === 'departments' && ( -
- {editingDept && ( -
-

- Abteilung bearbeiten: {editingDept.name} -

-
-
- - setEditDeptName(event.target.value)} - required - className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" - /> -
-
- -