mirror of
https://github.com/OHV-IT/collabrix.git
synced 2025-12-16 00:58:37 +01:00
Compare commits
8 Commits
a7ff948e7e
...
71bea0ae7d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71bea0ae7d | ||
|
|
fac9b2e4ac | ||
|
|
df394b3b7d | ||
|
|
f52fb50a39 | ||
|
|
c7cfbad3d8 | ||
|
|
9a557d28a2 | ||
|
|
382d4ac3f0 | ||
|
|
cfd7068af5 |
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
91
backend/scripts/migrate_user_roles.py
Normal file
91
backend/scripts/migrate_user_roles.py
Normal 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()
|
||||||
27
backend/scripts/set_ronny_superadmin.py
Normal file
27
backend/scripts/set_ronny_superadmin.py
Normal 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()
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
96
frontend/src/components/Kanban/KanbanArchiveModal.tsx
Normal file
96
frontend/src/components/Kanban/KanbanArchiveModal.tsx
Normal 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;
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
20
frontend/src/components/common/BlinkingEnvelope.tsx
Normal file
20
frontend/src/components/common/BlinkingEnvelope.tsx
Normal 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;
|
||||||
21
frontend/src/components/common/HeaderStatusIndicator.tsx
Normal file
21
frontend/src/components/common/HeaderStatusIndicator.tsx
Normal 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;
|
||||||
35
frontend/src/components/common/UserStatusIndicator.tsx
Normal file
35
frontend/src/components/common/UserStatusIndicator.tsx
Normal 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;
|
||||||
@ -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}
|
||||||
|
|||||||
136
frontend/src/contexts/UnreadMessagesContext.tsx
Normal file
136
frontend/src/contexts/UnreadMessagesContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
111
frontend/src/contexts/UserStatusContext.tsx
Normal file
111
frontend/src/contexts/UserStatusContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user