From 08a2fbe750208ce04a22334fbfdc38387c0b289e Mon Sep 17 00:00:00 2001 From: IvanPartsunev Date: Fri, 14 Nov 2025 20:38:06 +0200 Subject: [PATCH 01/26] feat: add Accountant role with tailored permissions across frontend and backend - Added "Accountant" to user roles with specific access restrictions and UI updates in UserManagement and Navigation pages. - Updated role-based document filtering logic to allow Accountants access only to accounting documents. - Enabled Accountants to upload, view, and delete accounting documents while restricting actions on other document types. - Updated backend endpoints and file operations to enforce Accountant --- mp_web_app/backend/files/operations.py | 3 +- mp_web_app/backend/files/routers.py | 38 ++++++++- mp_web_app/backend/users/roles.py | 4 +- mp_web_app/backend/users/routers.py | 12 ++- mp_web_app/frontend/app-config.ts | 4 +- .../frontend/components/upload-file.tsx | 35 ++++++-- mp_web_app/frontend/pages/Navigation.tsx | 83 ++++++++++++++++--- .../frontend/pages/admin/UserManagement.tsx | 2 + 8 files changed, 151 insertions(+), 30 deletions(-) diff --git a/mp_web_app/backend/files/operations.py b/mp_web_app/backend/files/operations.py index bbad6bd..eb962d0 100644 --- a/mp_web_app/backend/files/operations.py +++ b/mp_web_app/backend/files/operations.py @@ -137,7 +137,7 @@ def download_file(file_metadata: FileMetadata | list[FileMetadata], user: User, file_meta_object = get_db_metadata(file_metadata, repo) user_id = None - if user.role != UserRole.REGULAR_USER.value: + if user.role != UserRole.REGULAR_USER.value and user.role != UserRole.ACCOUNTANT.value: user_id = user.id is_allowed = _check_file_allowed_to_user(file_meta_object, user_id) @@ -190,6 +190,7 @@ def _check_file_allowed_to_user(file_metadata: FileMetadata, user_id: str | None 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 diff --git a/mp_web_app/backend/files/routers.py b/mp_web_app/backend/files/routers.py index 57054f6..b5c96d3 100644 --- a/mp_web_app/backend/files/routers.py +++ b/mp_web_app/backend/files/routers.py @@ -25,9 +25,15 @@ 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 ) @@ -46,9 +52,21 @@ 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)) @@ -58,10 +76,22 @@ 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)) diff --git a/mp_web_app/backend/users/roles.py b/mp_web_app/backend/users/roles.py index 1446b92..c2d4303 100644 --- a/mp_web_app/backend/users/roles.py +++ b/mp_web_app/backend/users/roles.py @@ -5,12 +5,14 @@ class UserRole(StrEnum): REGULAR_USER = "regular" BOARD = "board" CONTROL = "control" + ACCOUNTANT = "accountant" ADMIN = "admin" ROLE_HIERARCHY: dict[UserRole, list[UserRole]] = { - UserRole.ADMIN: [UserRole.ADMIN, UserRole.CONTROL, UserRole.BOARD, UserRole.REGULAR_USER], + UserRole.ADMIN: [UserRole.ADMIN, UserRole.CONTROL, UserRole.BOARD, UserRole.ACCOUNTANT, UserRole.REGULAR_USER], UserRole.CONTROL: [UserRole.CONTROL, UserRole.BOARD, UserRole.REGULAR_USER], UserRole.BOARD: [UserRole.BOARD, UserRole.REGULAR_USER], + UserRole.ACCOUNTANT: [UserRole.ACCOUNTANT], UserRole.REGULAR_USER: [UserRole.REGULAR_USER], } diff --git a/mp_web_app/backend/users/routers.py b/mp_web_app/backend/users/routers.py index ca64a5f..56f72f9 100644 --- a/mp_web_app/backend/users/routers.py +++ b/mp_web_app/backend/users/routers.py @@ -26,7 +26,8 @@ @user_router.get("/list", response_model=list[User], status_code=status.HTTP_200_OK) async def users_list( - user_repo: UserRepository = Depends(get_user_repository), user=Depends(role_required([UserRole.REGULAR_USER])) + user_repo: UserRepository = Depends(get_user_repository), + user=Depends(role_required([UserRole.REGULAR_USER, UserRole.ACCOUNTANT])) ): try: return list_users(user_repo) @@ -146,6 +147,15 @@ async def user_update( ): """Update a user (ADMIN only).""" try: + # Validate role if provided + if user_data.role is not None: + valid_roles = [role.value for role in UserRole] + if user_data.role not in valid_roles: + raise HTTPException( + status_code=400, + detail=f"Invalid role. Must be one of: {', '.join(valid_roles)}" + ) + # Get user by ID to get their email existing_user = get_user_by_id(user_id, user_repo) return update_user(user_id, existing_user.email, user_data, user_repo) diff --git a/mp_web_app/frontend/app-config.ts b/mp_web_app/frontend/app-config.ts index 57e38b7..58fd49e 100644 --- a/mp_web_app/frontend/app-config.ts +++ b/mp_web_app/frontend/app-config.ts @@ -3,5 +3,5 @@ // For production, use your custom API domain. // Make sure the subdomain matches the one in your cdk/app.py -export const API_BASE_URL = "https://api.ivan-partsunev.com/api/"; -// export const API_BASE_URL = "http://127.0.0.1:8000/api/"; +// export const API_BASE_URL = "https://api.ivan-partsunev.com/api/"; +export const API_BASE_URL = "http://127.0.0.1:8000/api/"; diff --git a/mp_web_app/frontend/components/upload-file.tsx b/mp_web_app/frontend/components/upload-file.tsx index 85a7829..2e1dc11 100644 --- a/mp_web_app/frontend/components/upload-file.tsx +++ b/mp_web_app/frontend/components/upload-file.tsx @@ -45,7 +45,10 @@ export default function UploadFile() { const [usersError, setUsersError] = useState(""); const [selectedUserIds, setSelectedUserIds] = useState([]); - // Frontend guard: only allow admins to access this page (keep your current logic if you prefer) + // Get user role + const [userRole, setUserRole] = useState(""); + + // Frontend guard: only allow admins and accountants to access this page useEffect(() => { try { const token = localStorage.getItem("access_token"); @@ -59,9 +62,17 @@ export default function UploadFile() { .replace(/_/g, "/") .padEnd(Math.ceil(base64Url.length / 4) * 4, "="); const payload = JSON.parse(atob(base64)); - if (String(payload?.role ?? "").toUpperCase() !== "ADMIN") { + const role = String(payload?.role ?? "").toLowerCase(); + setUserRole(role); + + if (role !== "admin" && role !== "accountant") { navigate("/"); } + + // If accountant, set default file type to accounting + if (role === "accountant") { + setFileType("accounting"); + } } catch { navigate("/login"); } @@ -175,16 +186,22 @@ export default function UploadFile() { value={fileType} onChange={(e) => setFileType(e.target.value)} className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" - disabled={submitting} + disabled={submitting || userRole === "accountant"} required > - {fileTypeOptions.map((opt) => ( - - ))} + {fileTypeOptions + .filter((opt) => userRole !== "accountant" || opt.value === "accounting") + .map((opt) => ( + + ))} -

Използвайте валидна стойност според FileType в бекенда.

+

+ {userRole === "accountant" + ? "Счетоводителите могат да качват само счетоводни документи." + : "Използвайте валидна стойност според FileType в бекенда."} +

diff --git a/mp_web_app/frontend/pages/Navigation.tsx b/mp_web_app/frontend/pages/Navigation.tsx index 2bfb390..8fdcb60 100644 --- a/mp_web_app/frontend/pages/Navigation.tsx +++ b/mp_web_app/frontend/pages/Navigation.tsx @@ -117,7 +117,7 @@ export function Navigation() { const filterDropdown = (dropdown: any[]) => dropdown.filter((item) => !item.requiresAuth || isLoggedIn); // Decode role from access token to check role - const getUserRole = (): "admin" | "board" | "control" | "regular" | null => { + const getUserRole = (): "admin" | "board" | "control" | "accountant" | "regular" | null => { try { const token = localStorage.getItem("access_token"); if (!token) return null; @@ -128,7 +128,7 @@ export function Navigation() { .padEnd(Math.ceil(base64Url.length / 4) * 4, "="); const payload = JSON.parse(atob(base64)); const role = String(payload?.role || "").toLowerCase(); - if (role === "admin" || role === "board" || role === "control" || role === "regular") return role as any; + if (role === "admin" || role === "board" || role === "control" || role === "accountant" || role === "regular") return role as any; return null; } catch { return null; @@ -137,6 +137,7 @@ export function Navigation() { const role = getUserRole(); const isAdmin = role === "admin"; + const isAccountant = role === "accountant"; const isBoardOrControl = role === "board" || role === "control"; // Handle animation for mobile menu @@ -202,11 +203,30 @@ export function Navigation() { } // Role-based filtering for Documents - const documentsItems = isDocuments - ? isAdmin || isBoardOrControl - ? link.dropdown - : link.dropdown.filter((item: any) => item.to === "/governing-documents" || item.to === "/forms") - : filterDropdown(link.dropdown); + let documentsItems = link.dropdown; + if (isDocuments) { + if (isAdmin || isBoardOrControl) { + // Admin, Board, Control: see all documents + documentsItems = link.dropdown; + } else if (isAccountant) { + // Accountant: see ONLY accounting documents + documentsItems = link.dropdown.filter((item: any) => + item.to === "/accounting-documents" + ); + } else { + // Regular users: see all except "Счетоводни документи" (accounting) + documentsItems = link.dropdown.filter((item: any) => + item.to === "/governing-documents" || + item.to === "/forms" || + item.to === "/minutes" || + item.to === "/transcripts" || + item.to === "/mydocuments" || + item.to === "/others" + ); + } + } else { + documentsItems = filterDropdown(link.dropdown); + } const itemsToRender = isDocuments ? documentsItems : link.dropdown; @@ -254,6 +274,18 @@ export function Navigation() { )} + {/* Accountant upload action for mobile */} + {isLoggedIn && isAccountant && ( + + )} {/* Auth section for mobile */} {!isLoggedIn ? (
@@ -321,11 +353,30 @@ export function Navigation() { } // Role-based filtering for Documents - const documentsItems = isDocuments - ? isAdmin || isBoardOrControl - ? link.dropdown - : link.dropdown.filter((item: any) => item.to === "/governing-documents" || item.to === "/forms") - : filterDropdown(link.dropdown); + let documentsItems = link.dropdown; + if (isDocuments) { + if (isAdmin || isBoardOrControl) { + // Admin, Board, Control: see all documents + documentsItems = link.dropdown; + } else if (isAccountant) { + // Accountant: see ONLY accounting documents + documentsItems = link.dropdown.filter((item: any) => + item.to === "/accounting-documents" + ); + } else { + // Regular users: see all except "Счетоводні документи" (accounting) + documentsItems = link.dropdown.filter((item: any) => + item.to === "/governing-documents" || + item.to === "/forms" || + item.to === "/minutes" || + item.to === "/transcripts" || + item.to === "/mydocuments" || + item.to === "/others" + ); + } + } else { + documentsItems = filterDropdown(link.dropdown); + } const itemsToRender = isDocuments ? documentsItems : link.dropdown; @@ -368,6 +419,14 @@ export function Navigation() { )} + {/* Accountant upload action for desktop */} + {isLoggedIn && isAccountant && ( + + + + )} {/* Auth section for desktop */} {!isLoggedIn ? ( diff --git a/mp_web_app/frontend/pages/admin/UserManagement.tsx b/mp_web_app/frontend/pages/admin/UserManagement.tsx index c03aad1..818816e 100644 --- a/mp_web_app/frontend/pages/admin/UserManagement.tsx +++ b/mp_web_app/frontend/pages/admin/UserManagement.tsx @@ -25,6 +25,7 @@ const roleTranslations: Record = { regular: "Обикновен", board: "УС", control: "КС", + accountant: "Счетоводител", admin: "Админ", }; @@ -191,6 +192,7 @@ export default function UserManagement() { Обикновен УС КС + Счетоводител Администратор From 6eab9abfb3d819ea14c99046269838bd1ca7ca0b Mon Sep 17 00:00:00 2001 From: IvanPartsunev Date: Sat, 15 Nov 2025 01:24:32 +0200 Subject: [PATCH 02/26] feat: improve navigation and enhance UI responsiveness across components - Added `useNavigate` hook and introduced smooth navigation transitions to prevent flickering on mobile. - Adjusted breakpoint for mobile devices and improved display across varying screen sizes. - Enhanced `Table`, `Navigation`, and `Card` components with responsive layouts and refined styles. - Introduced `TableSkeleton` and `TablePagination` for better loading indicators and pagination control. - Updated admin and product-related layouts to follow consistent visual hierarchy. - Improved gallery card and news card interactions with animated scaling effects. --- .../frontend/components/admin-layout.tsx | 4 +- .../frontend/components/files-table.tsx | 25 +- .../components/gallery-image-card.tsx | 60 ++-- mp_web_app/frontend/components/logo.tsx | 48 ++- mp_web_app/frontend/components/news-card.tsx | 93 ++++-- .../frontend/components/products-table.tsx | 6 +- mp_web_app/frontend/components/ui/button.tsx | 57 ++-- mp_web_app/frontend/components/ui/card.tsx | 36 ++- mp_web_app/frontend/components/ui/dialog.tsx | 4 +- .../components/ui/loading-spinner.tsx | 4 +- .../components/ui/navigation-menu.tsx | 4 +- mp_web_app/frontend/components/ui/select.tsx | 4 +- mp_web_app/frontend/components/ui/table.tsx | 133 ++++++++- .../frontend/components/upload-file.tsx | 90 +++++- mp_web_app/frontend/index.html | 3 + mp_web_app/frontend/pages/Base.tsx | 18 +- mp_web_app/frontend/pages/Gallery.tsx | 107 +++++-- mp_web_app/frontend/pages/Home.tsx | 140 +++++---- mp_web_app/frontend/pages/Navigation.tsx | 274 +++++++++++------- mp_web_app/frontend/pages/Products.tsx | 20 +- mp_web_app/frontend/pages/about-us/Board.tsx | 23 +- .../frontend/pages/about-us/Control.tsx | 23 +- .../frontend/pages/admin/AdminPanel.tsx | 84 ++++-- .../pages/admin/DocumentsManagement.tsx | 5 +- .../frontend/pages/admin/NewsManagement.tsx | 24 +- .../pages/admin/ProductsManagement.tsx | 9 +- .../frontend/pages/admin/UserManagement.tsx | 10 +- .../pages/lists/CooperativeMembers.tsx | 26 +- mp_web_app/frontend/pages/lists/Proxies.tsx | 92 ++++-- mp_web_app/frontend/src/styles/global.css | 194 ++++++++++++- 30 files changed, 1185 insertions(+), 435 deletions(-) diff --git a/mp_web_app/frontend/components/admin-layout.tsx b/mp_web_app/frontend/components/admin-layout.tsx index 07b51a1..269cc38 100644 --- a/mp_web_app/frontend/components/admin-layout.tsx +++ b/mp_web_app/frontend/components/admin-layout.tsx @@ -8,12 +8,12 @@ interface AdminLayoutProps { export function AdminLayout({title, children}: AdminLayoutProps) { return ( -
+
{title} - {children} + {children}
); diff --git a/mp_web_app/frontend/components/files-table.tsx b/mp_web_app/frontend/components/files-table.tsx index a36f7f0..39e922f 100644 --- a/mp_web_app/frontend/components/files-table.tsx +++ b/mp_web_app/frontend/components/files-table.tsx @@ -119,15 +119,28 @@ export function FilesTable({fileType, title = "Документи"}: FilesTableP }; return ( -
- {title &&

{title}

} +
+ {/* Hero Section */} + {title && ( +
+
+
+
+

+ {title} +

+
+
+
+ )} + +
Списък с налични файлове - -
+ @@ -177,7 +190,6 @@ export function FilesTable({fileType, title = "Документи"}: FilesTableP ))}
-
{/* Pagination footer */} {totalPages > 1 && ( @@ -289,6 +301,7 @@ export function FilesTable({fileType, title = "Документи"}: FilesTableP )}
-
+
+
); } diff --git a/mp_web_app/frontend/components/gallery-image-card.tsx b/mp_web_app/frontend/components/gallery-image-card.tsx index 8e66ff3..12dd538 100644 --- a/mp_web_app/frontend/components/gallery-image-card.tsx +++ b/mp_web_app/frontend/components/gallery-image-card.tsx @@ -1,14 +1,13 @@ import React, {useState} from "react"; import {Card} from "@/components/ui/card"; -import {Dialog, DialogContent, DialogTitle} from "@/components/ui/dialog"; interface GalleryImageCardProps { imageUrl: string; imageName: string; + onClick?: () => void; } -export function GalleryImageCard({imageUrl, imageName}: GalleryImageCardProps) { - const [isOpen, setIsOpen] = useState(false); +export function GalleryImageCard({imageUrl, imageName, onClick}: GalleryImageCardProps) { const [imageLoaded, setImageLoaded] = useState(false); const [aspectRatio, setAspectRatio] = useState<"portrait" | "landscape" | "square">("square"); @@ -29,41 +28,26 @@ export function GalleryImageCard({imageUrl, imageName}: GalleryImageCardProps) { }; return ( - <> - setIsOpen(true)} - > -
- {!imageLoaded && ( -
-

Зареждане...

-
- )} - {imageName} -
-
- - - - {imageName} -
- {imageName} + +
+ {!imageLoaded && ( +
+

Зареждане...

- -
- + )} + {imageName} +
+ ); } diff --git a/mp_web_app/frontend/components/logo.tsx b/mp_web_app/frontend/components/logo.tsx index 7d6250e..fb7f6fd 100644 --- a/mp_web_app/frontend/components/logo.tsx +++ b/mp_web_app/frontend/components/logo.tsx @@ -1,26 +1,42 @@ export function Logo() { + return ( -
- {/* background image */} +
+ {/* Forest-themed hero background image */}