Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
08a2fbe
feat: add Accountant role with tailored permissions across frontend a…
IvanPartsunev Nov 14, 2025
6eab9ab
feat: improve navigation and enhance UI responsiveness across components
IvanPartsunev Nov 14, 2025
d09d8d8
feat: add Footer, Header, and GalleryModal components with responsive…
IvanPartsunev Nov 14, 2025
97c0351
chore: remove unused Badge import and update API base URL to production
IvanPartsunev Nov 14, 2025
07db8d8
feat: enhance mobile navigation, gallery modal, and image loading
IvanPartsunev Nov 14, 2025
b77902d
chore: remove unused ImageIcon import from Gallery page
IvanPartsunev Nov 14, 2025
dca7f5b
feat: refine GalleryModal styles for better responsiveness and visuals
IvanPartsunev Nov 15, 2025
ae7bb51
feat: replace manual API calls with React Query hooks and introduce c…
IvanPartsunev Nov 15, 2025
31e15c1
feat: extend admin panel functionality and improve file upload UI
IvanPartsunev Nov 15, 2025
0006aa2
feat: grant S3:DeleteObject permission in BackendStack
IvanPartsunev Nov 15, 2025
4640608
feat: add reusable React Query hooks and extend admin panel functiona…
IvanPartsunev Nov 15, 2025
b695386
refactor: update cache invalidation step and adjust middleware order …
IvanPartsunev Nov 15, 2025
e0f9f43
refactor: simplify Navigation component structure and update integrat…
IvanPartsunev Nov 15, 2025
8516a00
refactor: remove unused `Outlet` import from Navigation component
IvanPartsunev Nov 15, 2025
8eec6d9
feat: enhance file handling and upload UX, optimize caching
IvanPartsunev Nov 15, 2025
8d6a137
refactor: simplify navigation styles and adjust integration in compon…
IvanPartsunev Nov 15, 2025
091f961
**Refactor and enhance UI, backend endpoints, and data handling**
IvanPartsunev Nov 15, 2025
45d8775
fix: correct attribute name usage in DynamoDB update and adjust token…
IvanPartsunev Nov 18, 2025
f4a45b5
refactor: standardize table column widths and enhance data handling
IvanPartsunev Nov 18, 2025
da946a0
refactor: enhance file access logic and add user account status checks
IvanPartsunev Nov 18, 2025
cc739dc
refactor: add role-based conditional rendering for contact info visib…
IvanPartsunev Nov 18, 2025
eea56b3
fix: correct member code validation logic in `CooperativeMembers` and…
IvanPartsunev Dec 7, 2025
d542e2b
feat: add comprehensive error handling for image uploads and validations
IvanPartsunev Dec 7, 2025
ab33de9
refactor: enhance error handling, caching, and UI consistency across …
IvanPartsunev Dec 7, 2025
56f1588
refactor: unify table column styles and enhance metadata handling
IvanPartsunev Dec 7, 2025
0c2fcc0
refactor: remove redundant whitespaces and standardize formatting in …
IvanPartsunev Dec 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 2 additions & 12 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -178,18 +178,8 @@ frontend-deploy: frontend-build ## Build and deploy frontend to S3
aws s3 sync mp_web_app/frontend/dist/ s3://frontendstack-frontendsitebucket127f9fa2-zjnv4evaddvi/ --delete
@echo "$(GREEN)✓ Frontend deployed to S3$(NC)"

frontend-invalidate: ## Invalidate CloudFront cache
@echo "$(BLUE)Invalidating CloudFront cache...$(NC)"
@DISTRIBUTION_ID=$$(aws cloudfront list-distributions --query "DistributionList.Items[?Origins.Items[?DomainName=='frontendstack-frontendsitebucket127f9fa2-zjnv4evaddit.s3.amazonaws.com']].Id" --output text); \
if [ -z "$$DISTRIBUTION_ID" ]; then \
echo "$(RED)✗ CloudFront distribution not found$(NC)"; \
exit 1; \
fi; \
aws cloudfront create-invalidation --distribution-id $$DISTRIBUTION_ID --paths "/*"; \
echo "$(GREEN)✓ CloudFront cache invalidated (Distribution: $$DISTRIBUTION_ID)$(NC)"

frontend-deploy-all: frontend-deploy frontend-invalidate ## Build, deploy to S3, and invalidate CloudFront
@echo "$(GREEN)✓ Frontend fully deployed and cache invalidated$(NC)"
frontend-deploy-all: frontend-deploy ## Build, deploy to S3
@echo "$(GREEN)✓ Frontend fully deployed$(NC)"

##@ Utilities

Expand Down
4 changes: 4 additions & 0 deletions mp_web_app/backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from gallery.routers import gallery_router
from mail.routers import mail_router
from members.routers import member_router
from middleware.cache_headers import CacheControlMiddleware
from news.routers import news_router
from products.routers import product_router
from users.routers import user_router
Expand All @@ -25,6 +26,9 @@
allow_headers=["*"],
)

# Add cache control middleware
app.add_middleware(CacheControlMiddleware)

app.include_router(user_router, prefix="/api/users")
app.include_router(auth_router, prefix="/api/auth")
app.include_router(mail_router, prefix="/api/mail")
Expand Down
11 changes: 9 additions & 2 deletions mp_web_app/backend/auth/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import Literal, Optional
from uuid import uuid4

from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import EmailStr, ValidationError
Expand All @@ -20,7 +20,7 @@
from users.roles import ROLE_HIERARCHY, UserRole

REFRESH_TABLE_NAME = os.environ.get("REFRESH_TABLE_NAME")
ACCESS_TOKEN_EXPIRE_MINUTES = 1
ACCESS_TOKEN_EXPIRE_MINUTES = 5
REFRESH_TOKEN_EXPIRE_DAYS = 7

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
Expand Down Expand Up @@ -157,6 +157,9 @@ def get_current_user(token: str = Depends(oauth2_scheme), repo: UserRepository =
user = get_user_by_id(user_id, repo)
if not user:
raise UserNotFoundError("User not found")
# Check if user account is active
if not user.active:
raise UnauthorizedError("Account is not active")
return user
except UnauthorizedError as e:
raise HTTPException(status_code=401, detail=str(e))
Expand All @@ -170,6 +173,10 @@ def authenticate_user(email: str, password: str, repo: UserRepository) -> Option
if not user:
return None

# Check if user account is active
if not user.active:
return None

if verify_password(password_hash=user.password_hash, password=password, salt=user.salt):
return user
return None
Expand Down
1 change: 1 addition & 0 deletions mp_web_app/backend/files/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class FileMetadata(BaseModel):
file_name: str | None = None
file_type: FileType
uploaded_by: str | None = None
uploaded_by_name: str | None = None # User's full name for display
created_at: str = datetime.now().isoformat()


Expand Down
110 changes: 87 additions & 23 deletions mp_web_app/backend/files/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from fastapi.responses import StreamingResponse

from app_config import AllowedFileExtensions
from database.repositories import FileMetadataRepository
from database.repositories import FileMetadataRepository, UserRepository
from files.exceptions import (
FileAccessDeniedError,
FileNotFoundError,
Expand All @@ -27,6 +27,7 @@

BUCKET = os.environ.get("UPLOADS_BUCKET")
UPLOADS_TABLE_NAME = os.environ.get("UPLOADS_TABLE_NAME")
USERS_TABLE_NAME = os.environ.get("USERS_TABLE_NAME")


@lru_cache
Expand All @@ -35,6 +36,36 @@ def get_allowed_file_extensions():
return AllowedFileExtensions().allowed_file_extensions


def _enrich_with_user_names(files_metadata: list[FileMetadata]):
"""Enrich file metadata with uploader names."""
if not files_metadata:
return

# Collect unique user IDs
user_ids = {fm.uploaded_by for fm in files_metadata if fm.uploaded_by}
if not user_ids:
return

# Fetch users and create a map
user_repo = UserRepository(USERS_TABLE_NAME)
users_map = {}

for user_id in user_ids:
try:
response = user_repo.table.get_item(Key={"id": user_id})
if "Item" in response:
user = user_repo.convert_item_to_object(response["Item"])
users_map[user.id] = f"{user.first_name} {user.last_name}"
except Exception:
# If fetch fails, continue without this user's name
continue

# Enrich file metadata with user names
for fm in files_metadata:
if fm.uploaded_by:
fm.uploaded_by_name = users_map.get(fm.uploaded_by, fm.uploaded_by)


def get_uploads_repository():
return FileMetadataRepository(UPLOADS_TABLE_NAME)

Expand Down Expand Up @@ -92,6 +123,10 @@ def get_files_metadata(file_type: str, repo: FileMetadataRepository):
items.extend(response["Items"])

files_metadata = [repo.convert_item_to_object(item) for item in items]

# Enrich with user names
_enrich_with_user_names(files_metadata)

return files_metadata

except Exception as e:
Expand Down Expand Up @@ -126,32 +161,40 @@ def _create_file_name(file_name: str, original_name: str):
extension = original_name.split(".")[-1]
if extension not in allowed:
raise InvalidFileExtensionError(extension, allowed)
cleaned_file_name = re.sub(r"[^A-Za-z0-9.\-_\s]", "", file_name).strip()
file_name_parts = re.split(r"[.\s\-_]", cleaned_file_name)
# Keep Cyrillic and other Unicode characters, only remove special chars that break S3
cleaned_file_name = re.sub(r'[<>:"/\\|?*]', "", file_name).strip()
# Replace spaces with underscores for cleaner URLs
cleaned_file_name = cleaned_file_name.replace(" ", "_")
date_tag = f"{now.year!s}_{str(now.month).zfill(2)}_{str(now.day).zfill(2)}"
file_name = f"{date_tag}_{'_'.join([p.lower() for p in file_name_parts if p != ''])}_{str(uuid4())[:8]}.{extension}"
file_name = f"{date_tag}_{cleaned_file_name}_{str(uuid4())[:8]}.{extension}"
return file_name


def download_file(file_metadata: FileMetadata | list[FileMetadata], user: User, repo: FileMetadataRepository):
file_meta_object = get_db_metadata(file_metadata, repo)

user_id = None
if user.role != UserRole.REGULAR_USER.value:
user_id = user.id
# All logged-in users should have access to their allowed files
user_id = user.id
user_role = user.role

is_allowed = _check_file_allowed_to_user(file_meta_object, user_id)
is_allowed = _check_file_allowed_to_user(file_meta_object, user_id, user_role)
if not is_allowed:
raise FileAccessDeniedError(file_meta_object.file_name)

s3 = boto3.client("s3")
try:
s3_object = s3.get_object(Bucket=file_meta_object.bucket, Key=file_meta_object.key)
file_stream = s3_object["Body"]

# Properly encode filename for Cyrillic and other Unicode characters
from urllib.parse import quote

encoded_filename = quote(file_meta_object.file_name)

return StreamingResponse(
file_stream,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{file_meta_object.key}"'},
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
)
except s3.exceptions.NoSuchKey:
raise FileNotFoundError("File not found")
Expand All @@ -175,26 +218,47 @@ def _validate_metadata(file_metadata: FileMetadata, db_meta_object: FileMetadata
return file_metadata.model_dump() == db_meta_object.model_dump(include=fields)


def _check_file_allowed_to_user(file_metadata: FileMetadata, user_id: str | None = None) -> bool:
public_allowed_types = [
def _check_file_allowed_to_user(file_metadata: FileMetadata, user_id: str, user_role: str) -> bool:
# Public documents - accessible to everyone (logged in or not)
public_types = [
FileType.governing_documents.value,
FileType.forms.value,
]

private_allowed_types = [
# Documents accessible to all logged-in users
logged_in_types = [
FileType.minutes.value,
FileType.governing_documents.value,
FileType.forms.value,
FileType.transcripts.value,
FileType.accounting.value,
FileType.private_documents.value,
FileType.others.value,
]
is_allowed_to_user = True
if user_id:
is_allowed_type = file_metadata.file_type.value in private_allowed_types

# Accounting documents - only for admin, board, control, accountant
accounting_allowed_roles = [
UserRole.ADMIN.value,
UserRole.BOARD.value,
UserRole.CONTROL.value,
UserRole.ACCOUNTANT.value,
]

file_type = file_metadata.file_type.value

# Public documents - always allowed
if file_type in public_types:
return True

# Documents for all logged-in users
if file_type in logged_in_types:
return True

# Accounting documents - role-based access
if file_type == FileType.accounting.value:
return user_role in accounting_allowed_roles

# Private documents - only for specified users
if file_type == FileType.private_documents.value:
if file_metadata.allowed_to:
is_allowed_to_user = user_id in file_metadata.allowed_to
else:
is_allowed_type = file_metadata.file_type.value in public_allowed_types
return is_allowed_type and is_allowed_to_user
return user_id in file_metadata.allowed_to
return False

# Default deny
return False
32 changes: 28 additions & 4 deletions mp_web_app/backend/files/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,13 @@ async def file_create(
allowed_to: list[str] = Form([]),
file: UploadFile = File(...),
repo: FileMetadataRepository = Depends(get_uploads_repository),
user=Depends(role_required([UserRole.ADMIN])),
user=Depends(role_required([UserRole.ADMIN, UserRole.ACCOUNTANT])),
):
try:
# Accountants can only upload accounting documents
if user.role == UserRole.ACCOUNTANT.value and file_type != FileType.accounting:
raise HTTPException(status_code=403, detail="Accountants can only upload accounting documents")

file_metadata = FileMetadataFull(
file_name=file_name, file_type=file_type, allowed_to=allowed_to, uploaded_by=user.id
)
Expand All @@ -46,9 +50,19 @@ async def file_create(
async def files_list(
file_type: str,
repo: FileMetadataRepository = Depends(get_uploads_repository),
user=Depends(role_required([UserRole.ADMIN])),
user=Depends(
role_required([UserRole.REGULAR_USER, UserRole.ACCOUNTANT, UserRole.BOARD, UserRole.CONTROL, UserRole.ADMIN])
),
):
try:
# Accountants can only list accounting documents
if user.role == UserRole.ACCOUNTANT.value and file_type != "accounting":
raise HTTPException(status_code=403, detail="Accountants can only access accounting documents")

# Regular users cannot list accounting documents
if user.role == UserRole.REGULAR_USER.value and file_type == "accounting":
raise HTTPException(status_code=403, detail="You don't have access to this document type")

return get_files_metadata(file_type, repo)
except MetadataError as e:
raise HTTPException(status_code=500, detail=str(e))
Expand All @@ -58,10 +72,20 @@ async def files_list(
async def file_delete(
file_id: str,
repo: FileMetadataRepository = Depends(get_uploads_repository),
user=Depends(role_required([UserRole.ADMIN])),
user=Depends(role_required([UserRole.ADMIN, UserRole.ACCOUNTANT])),
):
"""Delete a single file by ID (ADMIN only)."""
"""Delete a single file by ID (ADMIN and ACCOUNTANT)."""
try:
# Accountants can only delete accounting documents
if user.role == UserRole.ACCOUNTANT.value:
response = repo.table.get_item(Key={"id": file_id})
if "Item" not in response:
raise HTTPException(status_code=404, detail="File not found")

file_metadata = repo.convert_item_to_object_full(response["Item"])
if file_metadata.file_type != FileType.accounting:
raise HTTPException(status_code=403, detail="Accountants can only delete accounting documents")

delete_file(file_id, repo)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
Expand Down
Loading
Loading