@cryptotaxi247 / CoPilot / commits / b96baf2b

Customer portal cont (#521)

* Implement customer access control in vulnerability search endpoints * Add CaseComment model and migration for incident management * Add CaseComment functionality for case management including create, edit, and delete endpoints * Implement case comments functionality including creation, updating, and deletion in incident management * Add case comments functionality with create, update, and delete operations * Fix fetchCases API endpoint to retrieve cases from the correct path * Add escalated column to alerts and cases for incident management * Add escalation functionality for alerts and cases with access validation * feat: update user form added role select * refactor * chore: update customer_portal dependencies --------- Co-authored-by: Davide Di Modica <webmaster.ddm@gmail.com>

taylor_socfortress committed Oct 17, 2025 at 16:51 UTC b96baf2b868b331b433bfec72d4adca090384b15
30 files changed +1652 -157
backend/alembic/versions/47c5adfa3c7e_add_escalated_column_to_alerts_and_cases.py new
+32
@@ -0,0 +1,32 @@
1 +"""Add escalated column to alerts and cases
2 +
3 +Revision ID: 47c5adfa3c7e
4 +Revises: 5599c90717d4
5 +Create Date: 2025-09-19 13:55:35.348208
6 +
7 +"""
8 +from typing import Sequence, Union
9 +
10 +from alembic import op
11 +import sqlalchemy as sa
12 +from sqlalchemy.dialects import mysql
13 +
14 +# revision identifiers, used by Alembic.
15 +revision: str = '47c5adfa3c7e'
16 +down_revision: Union[str, None] = '5599c90717d4'
17 +branch_labels: Union[str, Sequence[str], None] = None
18 +depends_on: Union[str, Sequence[str], None] = None
19 +
20 +
21 +def upgrade() -> None:
22 + # ### commands auto generated by Alembic - please adjust! ###
23 + op.add_column('incident_management_alert', sa.Column('escalated', sa.Boolean(), nullable=False))
24 + op.add_column('incident_management_case', sa.Column('escalated', sa.Boolean(), nullable=False))
25 + # ### end Alembic commands ###
26 +
27 +
28 +def downgrade() -> None:
29 + # ### commands auto generated by Alembic - please adjust! ###
30 + op.drop_column('incident_management_case', 'escalated')
31 + op.drop_column('incident_management_alert', 'escalated')
32 + # ### end Alembic commands ###
backend/alembic/versions/5599c90717d4_add_comments_to_cases.py new
+39
@@ -0,0 +1,39 @@
1 +"""Add comments to cases
2 +
3 +Revision ID: 5599c90717d4
4 +Revises: d8f9e9ea5502
5 +Create Date: 2025-09-19 08:58:00.060975
6 +
7 +"""
8 +from typing import Sequence, Union
9 +
10 +from alembic import op
11 +import sqlalchemy as sa
12 +from sqlalchemy.dialects import mysql
13 +
14 +# revision identifiers, used by Alembic.
15 +revision: str = '5599c90717d4'
16 +down_revision: Union[str, None] = 'd8f9e9ea5502'
17 +branch_labels: Union[str, Sequence[str], None] = None
18 +depends_on: Union[str, Sequence[str], None] = None
19 +
20 +
21 +def upgrade() -> None:
22 + # ### commands auto generated by Alembic - please adjust! ###
23 + op.create_table('incident_management_case_comment',
24 + sa.Column('id', sa.Integer(), nullable=False),
25 + sa.Column('case_id', sa.Integer(), nullable=True),
26 + sa.Column('comment', sa.String(length=1064), nullable=False),
27 + sa.Column('user_name', sa.String(length=50), nullable=False),
28 + sa.Column('created_at', sa.DateTime(), nullable=False),
29 + sa.ForeignKeyConstraint(['case_id'], ['incident_management_case.id'], ),
30 + sa.PrimaryKeyConstraint('id')
31 + )
32 +
33 + # ### end Alembic commands ###
34 +
35 +
36 +def downgrade() -> None:
37 + # ### commands auto generated by Alembic - please adjust! ###
38 + op.drop_table('incident_management_case_comment')
39 + # ### end Alembic commands ###
backend/app/agents/vulnerabilities/routes/vulnerabilities.py
+13 -3
@@ -37,6 +37,7 @@ from app.agents.vulnerabilities.services.vulnerabilities import (
37 sync_vulnerabilities_for_agent,
38 )
39 from app.auth.routes.auth import AuthHandler
40 +from app.auth.models.users import User
41 from app.db.db_session import get_db
42 from app.db.db_session import get_db_session
43
@@ -358,6 +359,7 @@ async def search_vulnerabilities(
359 page: int = Query(1, description="Page number for pagination", ge=1),
360 page_size: int = Query(50, description="Number of vulnerabilities per page", ge=1, le=1000),
361 include_epss: bool = Query(True, description="Include EPSS scores (may impact performance)"),
362 + current_user: User = Depends(AuthHandler().get_current_user),
363 db: AsyncSession = Depends(get_db),
364 ) -> VulnerabilitySearchResponse:
365 """
@@ -367,12 +369,18 @@ async def search_vulnerabilities(
369 and pagination capabilities. Perfect for exploring vulnerability data without
370 the overhead of database synchronization.
371
372 + **Customer Access Control:**
373 + - Admin/analyst users: Can access vulnerabilities for all customers
374 + - Customer users: Can only access vulnerabilities for their assigned customers
375 + - Customer filtering is automatically applied based on user permissions
376 +
377 **Features:**
378 - Real-time data directly from Wazuh indexer
379 - Advanced filtering by customer, agent, severity, CVE, or package
380 - Efficient pagination for large result sets
381 - No database storage required
382 - Optional EPSS scoring integration
383 + - Automatic customer access filtering based on user role
384
385 **Performance:**
386 - Handles large datasets efficiently with pagination
@@ -382,7 +390,7 @@ async def search_vulnerabilities(
390 - EPSS scoring can be disabled for faster response times
391
392 **Filtering Options:**
385 - - **customer_code**: Filter by specific customer
393 + - **customer_code**: Filter by specific customer (subject to user access permissions)
394 - **agent_name**: Filter by specific agent hostname
395 - **severity**: Filter by vulnerability severity (Critical, High, Medium, Low)
396 - **cve_id**: Search for specific CVE identifier
@@ -403,17 +411,18 @@ async def search_vulnerabilities(
411 - When **include_epss=False**: Results sorted by detection date (newest first), then by severity
412
413 Args:
406 - customer_code: Optional customer code filter
414 + customer_code: Optional customer code filter (filtered by user access)
415 agent_name: Optional agent hostname filter
416 severity: Optional severity filter
417 cve_id: Optional CVE ID filter
418 package_name: Optional package name filter (partial matching)
419 page: Page number for pagination
420 page_size: Number of results per page
421 + current_user: Current authenticated user (automatically injected)
422 db: Database session
423
424 Returns:
416 - VulnerabilitySearchResponse: Paginated vulnerability search results
425 + VulnerabilitySearchResponse: Paginated vulnerability search results filtered by user access
426 """
427 logger.info(
428 f"Searching vulnerabilities from indexer with filters: "
@@ -425,6 +434,7 @@ async def search_vulnerabilities(
434 try:
435 result = await search_vulnerabilities_from_indexer(
436 db_session=db,
437 + current_user=current_user,
438 customer_code=customer_code,
439 agent_name=agent_name,
440 severity=severity,
backend/app/agents/vulnerabilities/services/vulnerabilities.py
+67 -27
@@ -26,6 +26,8 @@ from app.connectors.wazuh_indexer.utils.universal import (
26 )
27 from app.db.universal_models import Agents
28 from app.db.universal_models import AgentVulnerabilities
29 +from app.auth.models.users import User
30 +from app.middleware.customer_access import customer_access_handler
31 from app.threat_intel.schema.epss import EpssThreatIntelRequest
32 from app.threat_intel.services.epss import collect_epss_score
33
@@ -817,6 +819,7 @@ async def delete_vulnerabilities(db_session: AsyncSession, agent_name: Optional[
819
820 async def search_vulnerabilities_from_indexer(
821 db_session: AsyncSession,
822 + current_user: User,
823 customer_code: Optional[str] = None,
824 agent_name: Optional[str] = None,
825 severity: Optional[str] = None,
@@ -831,6 +834,7 @@ async def search_vulnerabilities_from_indexer(
834
835 Args:
836 db_session: Database session for agent lookup
837 + current_user: Current authenticated user for customer access filtering
838 customer_code: Optional customer code filter
839 agent_name: Optional agent hostname filter
840 severity: Optional severity filter
@@ -841,7 +845,7 @@ async def search_vulnerabilities_from_indexer(
845 include_epss: Whether to include EPSS scores (default: True, may impact performance)
846
847 Returns:
844 - VulnerabilitySearchResponse with paginated results
848 + VulnerabilitySearchResponse with paginated results filtered by user access
849 """
850 logger.info(
851 f"Searching vulnerabilities with filters: customer_code={customer_code}, "
@@ -850,6 +854,33 @@ async def search_vulnerabilities_from_indexer(
854 f"include_epss={include_epss}",
855 )
856
857 + # Apply customer access filtering based on user permissions
858 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db_session)
859 + logger.info(f"User {current_user.username} has access to customers: {accessible_customers}")
860 +
861 + # Override customer_code based on user permissions
862 + if "*" not in accessible_customers:
863 + # User has limited access - filter by their accessible customers
864 + if customer_code and customer_code not in accessible_customers:
865 + # User requested a customer they don't have access to
866 + return VulnerabilitySearchResponse(
867 + vulnerabilities=[],
868 + total_count=0,
869 + critical_count=0,
870 + high_count=0,
871 + medium_count=0,
872 + low_count=0,
873 + page=page,
874 + page_size=page_size,
875 + total_pages=0,
876 + has_next=False,
877 + has_previous=False,
878 + success=True,
879 + message=f"Access denied to customer {customer_code}",
880 + filters_applied={},
881 + )
882 + # If no customer_code specified or user has access, we'll filter by accessible customers later
883 +
884 # Build filters applied dict for response
885 filters_applied = {}
886 if customer_code:
@@ -884,35 +915,44 @@ async def search_vulnerabilities_from_indexer(
915 # Get agent information for filtering (if filters are applied)
916 agent_hostnames = []
917
887 - if customer_code or agent_name:
888 - query = select(Agents)
889 - if customer_code:
890 - query = query.filter(Agents.customer_code == customer_code)
891 - if agent_name:
892 - query = query.filter(Agents.hostname == agent_name)
918 + # Build base query for agents
919 + query = select(Agents)
920
894 - result = await db_session.execute(query)
895 - agents = result.scalars().all()
921 + # Apply user access restrictions first
922 + if "*" not in accessible_customers:
923 + # User has limited access - only show their customers' agents
924 + query = query.filter(Agents.customer_code.in_(accessible_customers))
925
897 - if not agents and (customer_code or agent_name):
898 - return VulnerabilitySearchResponse(
899 - vulnerabilities=[],
900 - total_count=0,
901 - critical_count=0,
902 - high_count=0,
903 - medium_count=0,
904 - low_count=0,
905 - page=page,
906 - page_size=page_size,
907 - total_pages=0,
908 - has_next=False,
909 - has_previous=False,
910 - success=True,
911 - message="No agents found matching the specified criteria",
912 - filters_applied=filters_applied,
913 - )
926 + # Apply additional filters if specified
927 + if customer_code:
928 + query = query.filter(Agents.customer_code == customer_code)
929 + if agent_name:
930 + query = query.filter(Agents.hostname == agent_name)
931 +
932 + result = await db_session.execute(query)
933 + agents = result.scalars().all()
934 +
935 + if not agents and (customer_code or agent_name or "*" not in accessible_customers):
936 + return VulnerabilitySearchResponse(
937 + vulnerabilities=[],
938 + total_count=0,
939 + critical_count=0,
940 + high_count=0,
941 + medium_count=0,
942 + low_count=0,
943 + page=page,
944 + page_size=page_size,
945 + total_pages=0,
946 + has_next=False,
947 + has_previous=False,
948 + success=True,
949 + message="No agents found matching the specified criteria or user access permissions",
950 + filters_applied=filters_applied,
951 + )
952
915 - # Build list of agent hostnames for Elasticsearch filtering
953 + # Build list of agent hostnames for Elasticsearch filtering
954 + # If user has restricted access, always filter by their accessible agents
955 + if "*" not in accessible_customers or customer_code or agent_name:
956 for agent in agents:
957 if agent.hostname:
958 agent_hostnames.append(agent.hostname)
backend/app/incidents/models.py
+12
@@ -42,6 +42,7 @@ class Alert(SQLModel, table=True):
42 time_closed: Optional[datetime] = Field(default=None)
43 source: str = Field(max_length=50, nullable=False)
44 assigned_to: Optional[str] = Field(max_length=50, nullable=True)
45 + escalated: bool = Field(default=False, nullable=False)
46
47 comments: List["Comment"] = Relationship(back_populates="alert")
48 assets: List["Asset"] = Relationship(back_populates="alert")
@@ -144,6 +145,15 @@ class CustomerCodeFieldName(SQLModel, table=True):
145 source: str = Field(max_length=50, nullable=False)
146 field_name: str = Field(max_length=100, nullable=False)
147
148 +class CaseComment(SQLModel, table=True):
149 + __tablename__ = "incident_management_case_comment"
150 + id: Optional[int] = Field(default=None, primary_key=True)
151 + case_id: int = Field(default=None, foreign_key="incident_management_case.id")
152 + comment: str = Field(sa_column=Text)
153 + user_name: str = Field(max_length=50, nullable=False)
154 + created_at: datetime = Field(default_factory=datetime.utcnow)
155 +
156 + case: "Case" = Relationship(back_populates="comments")
157
158 class Case(SQLModel, table=True):
159 __tablename__ = "incident_management_case"
@@ -155,9 +165,11 @@ class Case(SQLModel, table=True):
165 assigned_to: Optional[str] = Field(max_length=50, nullable=True)
166 customer_code: Optional[str] = Field(max_length=50, nullable=True)
167 notification_invoked_number: Optional[int] = Field(default=0, nullable=True)
168 + escalated: bool = Field(default=False, nullable=False)
169
170 alerts: List["CaseAlertLink"] = Relationship(back_populates="case")
171 data_store: List["CaseDataStore"] = Relationship(back_populates="case")
172 + comments: List["CaseComment"] = Relationship(back_populates="case")
173
174
175 class CaseAlertLink(SQLModel, table=True):
backend/app/incidents/routes/db_operations.py
+117
@@ -29,6 +29,7 @@ from app.data_store.data_store_operations import (
29 from app.db.db_session import get_db
30 from app.db.universal_models import Customers
31 from app.incidents.models import Alert
32 +from app.incidents.models import CaseComment
33 from app.incidents.models import Comment
34 from app.incidents.models import FieldName
35 from app.incidents.schema.db_operations import AlertContextCreate
@@ -56,6 +57,9 @@ from app.incidents.schema.db_operations import CaseAlertLinksCreate
57 from app.incidents.schema.db_operations import CaseAlertLinksResponse
58 from app.incidents.schema.db_operations import CaseAlertUnLink
59 from app.incidents.schema.db_operations import CaseAlertUnLinkResponse
60 +from app.incidents.schema.db_operations import CaseCommentCreate
61 +from app.incidents.schema.db_operations import CaseCommentEdit
62 +from app.incidents.schema.db_operations import CaseCommentResponse
63 from app.incidents.schema.db_operations import CaseCreate
64 from app.incidents.schema.db_operations import CaseCreateFromAlert
65 from app.incidents.schema.db_operations import CaseDataStoreResponse
@@ -72,6 +76,8 @@ from app.incidents.schema.db_operations import ConfiguredSourcesResponse
76 from app.incidents.schema.db_operations import DefaultReportTemplateFileNames
77 from app.incidents.schema.db_operations import DeleteAlertsRequest
78 from app.incidents.schema.db_operations import DeleteAlertsResponse
79 +from app.incidents.schema.db_operations import EscalateAlert
80 +from app.incidents.schema.db_operations import EscalateCase
81 from app.incidents.schema.db_operations import FieldAndAssetNames
82 from app.incidents.schema.db_operations import FieldAndAssetNamesResponse
83 from app.incidents.schema.db_operations import ListCaseDataStoreResponse
@@ -144,6 +150,7 @@ from app.incidents.services.db_operations import create_asset
150 from app.incidents.services.db_operations import create_case
151 from app.incidents.services.db_operations import create_case_alert_link
152 from app.incidents.services.db_operations import create_case_alert_links_bulk
153 +from app.incidents.services.db_operations import create_case_comment
154 from app.incidents.services.db_operations import create_case_from_alert
155 from app.incidents.services.db_operations import create_comment
156 from app.incidents.services.db_operations import delete_alert
@@ -152,6 +159,7 @@ from app.incidents.services.db_operations import delete_alert_tag
159 from app.incidents.services.db_operations import delete_alert_title_name
160 from app.incidents.services.db_operations import delete_asset_name
161 from app.incidents.services.db_operations import delete_case
162 +from app.incidents.services.db_operations import delete_case_comment
163 from app.incidents.services.db_operations import delete_comment
164 from app.incidents.services.db_operations import delete_field_name
165 from app.incidents.services.db_operations import delete_file_from_case
@@ -160,6 +168,7 @@ from app.incidents.services.db_operations import delete_report_template
168 from app.incidents.services.db_operations import delete_timefield_name
169 from app.incidents.services.db_operations import download_file_from_case
170 from app.incidents.services.db_operations import download_report_template
171 +from app.incidents.services.db_operations import edit_case_comment
172 from app.incidents.services.db_operations import edit_comment
173 from app.incidents.services.db_operations import file_exists
174 from app.incidents.services.db_operations import get_alert_by_id
@@ -198,9 +207,11 @@ from app.incidents.services.db_operations import replace_ioc_name
207 from app.incidents.services.db_operations import replace_timefield_name
208 from app.incidents.services.db_operations import report_template_exists
209 from app.incidents.services.db_operations import update_alert_assigned_to
210 +from app.incidents.services.db_operations import update_alert_escalated
211 from app.incidents.services.db_operations import update_alert_status
212 from app.incidents.services.db_operations import update_case_assigned_to
213 from app.incidents.services.db_operations import update_case_customer_code
214 +from app.incidents.services.db_operations import update_case_escalated
215 from app.incidents.services.db_operations import update_case_status
216 from app.incidents.services.db_operations import upload_file_to_case
217 from app.incidents.services.db_operations import upload_report_template
@@ -416,6 +427,28 @@ async def update_alert_status_endpoint(alert_status: UpdateAlertStatus, db: Asyn
427 return AlertResponse(alert=await update_alert_status(alert_status, db), success=True, message="Alert status updated successfully")
428
429
430 +@incidents_db_operations_router.put("/alert/escalated", response_model=AlertResponse)
431 +async def update_alert_escalated_endpoint(
432 + escalate_alert: EscalateAlert,
433 + current_user: User = Depends(AuthHandler().get_current_user),
434 + db: AsyncSession = Depends(get_db),
435 +):
436 + """Update alert escalated status with customer access validation"""
437 + logger.info(
438 + f"Updating alert {escalate_alert.alert_id} escalated status for user: {current_user.username} with role_id: {current_user.role_id}",
439 + )
440 +
441 + # Get the alert first to check customer access
442 + alert = await get_alert_by_id(escalate_alert.alert_id, db)
443 +
444 + # Check if user has access to this alert's customer
445 + if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
446 + raise HTTPException(status_code=403, detail=f"Access denied to alert {escalate_alert.alert_id} - insufficient customer permissions")
447 +
448 + updated_alert = await update_alert_escalated(escalate_alert.alert_id, escalate_alert.escalated, db)
449 + return AlertResponse(alert=updated_alert, success=True, message="Alert escalated status updated successfully")
450 +
451 +
452 @incidents_db_operations_router.post("/alert/comment", response_model=CommentResponse)
453 async def create_comment_endpoint(
454 comment: CommentCreate,
@@ -474,6 +507,64 @@ async def delete_comment_endpoint(
507 return {"message": "Comment deleted successfully", "success": True}
508
509
510 +@incidents_db_operations_router.post("/case/comment", response_model=CaseCommentResponse)
511 +async def create_case_comment_endpoint(
512 + comment: CaseCommentCreate,
513 + current_user: User = Depends(AuthHandler().get_current_user),
514 + db: AsyncSession = Depends(get_db),
515 +):
516 + # Get the case to check customer access
517 + case = await get_case_by_id(comment.case_id, db)
518 +
519 + # Check if user has access to this case's customer
520 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
521 + raise HTTPException(status_code=403, detail=f"Access denied to case {comment.case_id} - insufficient customer permissions")
522 +
523 + return CaseCommentResponse(comment=await create_case_comment(comment, db), success=True, message="Case comment created successfully")
524 +
525 +
526 +@incidents_db_operations_router.put("/case/comment", response_model=CaseCommentResponse)
527 +async def edit_case_comment_endpoint(
528 + comment: CaseCommentEdit,
529 + current_user: User = Depends(AuthHandler().get_current_user),
530 + db: AsyncSession = Depends(get_db),
531 +):
532 + # Get the case to check customer access
533 + case = await get_case_by_id(comment.case_id, db)
534 +
535 + # Check if user has access to this case's customer
536 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
537 + raise HTTPException(status_code=403, detail=f"Access denied to case {comment.case_id} - insufficient customer permissions")
538 +
539 + return CaseCommentResponse(comment=await edit_case_comment(comment, db), success=True, message="Case comment edited successfully")
540 +
541 +
542 +@incidents_db_operations_router.delete("/case/comment/{comment_id}")
543 +async def delete_case_comment_endpoint(
544 + comment_id: int,
545 + current_user: User = Depends(AuthHandler().get_current_user),
546 + db: AsyncSession = Depends(get_db),
547 +):
548 + # First get the comment to find the case_id
549 + result = await db.execute(select(CaseComment).where(CaseComment.id == comment_id))
550 + comment = result.scalars().first()
551 + if not comment:
552 + raise HTTPException(status_code=404, detail="Comment not found")
553 +
554 + # Get the case to check customer access
555 + case = await get_case_by_id(comment.case_id, db)
556 +
557 + # Check if user has access to this case's customer
558 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
559 + raise HTTPException(
560 + status_code=403,
561 + detail=f"Access denied to comment on case {comment.case_id} - insufficient customer permissions",
562 + )
563 +
564 + await delete_case_comment(comment_id, db)
565 + return {"message": "Case comment deleted successfully", "success": True}
566 +
567 +
568 @incidents_db_operations_router.get("/alert/available-users", response_model=AvailableUsersResponse)
569 async def get_available_users(db: AsyncSession = Depends(get_db)):
570 all_users = await select_all_users()
@@ -1262,6 +1353,32 @@ async def update_case_status_endpoint(
1353 return CaseOutResponse(cases=[updated_case], success=True, message="Case status updated successfully")
1354
1355
1356 +@incidents_db_operations_router.put("/case/escalated", response_model=CaseOutResponse)
1357 +async def update_case_escalated_endpoint(
1358 + escalate_case: EscalateCase,
1359 + current_user: User = Depends(AuthHandler().get_current_user),
1360 + db: AsyncSession = Depends(get_db),
1361 +):
1362 + """Update case escalated status with customer access validation"""
1363 + logger.info(
1364 + f"Updating case {escalate_case.case_id} escalated status for user: {current_user.username} with role_id: {current_user.role_id}",
1365 + )
1366 +
1367 + # Get the case first to check customer access
1368 + case = await get_case_by_id(escalate_case.case_id, db)
1369 +
1370 + # Check if user has access to this case's customer
1371 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
1372 + raise HTTPException(status_code=403, detail=f"Access denied to case {escalate_case.case_id} - insufficient customer permissions")
1373 +
1374 + # Update the case escalated status
1375 + await update_case_escalated(escalate_case.case_id, escalate_case.escalated, db)
1376 +
1377 + # Re-fetch the case with full data structure
1378 + updated_case = await get_case_by_id(escalate_case.case_id, db)
1379 + return CaseOutResponse(cases=[updated_case], success=True, message="Case escalated status updated successfully")
1380 +
1381 +
1382 @incidents_db_operations_router.put("/case/assigned-to", response_model=CaseOutResponse)
1383 async def update_case_assigned_to_endpoint(
1384 assigned_to: AssignedToCase,
backend/app/incidents/schema/db_operations.py
+43
@@ -15,6 +15,7 @@ from app.incidents.models import AlertToIoC
15 from app.incidents.models import Asset
16 from app.incidents.models import Case
17 from app.incidents.models import CaseAlertLink
18 +from app.incidents.models import CaseComment
19 from app.incidents.models import CaseDataStore
20 from app.incidents.models import CaseReportTemplateDataStore
21 from app.incidents.models import Comment
@@ -136,6 +137,12 @@ class CommentResponse(BaseModel):
137 message: str
138
139
140 +class CaseCommentResponse(BaseModel):
141 + comment: CaseComment
142 + success: bool
143 + message: str
144 +
145 +
146 class AlertContextResponse(BaseModel):
147 alert_context: AlertContext
148 success: bool
@@ -242,6 +249,16 @@ class AssignedToCase(BaseModel):
249 assigned_to: str
250
251
252 +class EscalateAlert(BaseModel):
253 + alert_id: int
254 + escalated: bool
255 +
256 +
257 +class EscalateCase(BaseModel):
258 + case_id: int
259 + escalated: bool
260 +
261 +
262 class AlertCreate(BaseModel):
263 alert_name: str
264 alert_description: str
@@ -268,6 +285,21 @@ class CommentEdit(BaseModel):
285 created_at: datetime
286
287
288 +class CaseCommentCreate(BaseModel):
289 + case_id: int
290 + comment: str
291 + user_name: str
292 + created_at: Optional[datetime] = None
293 +
294 +
295 +class CaseCommentEdit(BaseModel):
296 + case_id: int
297 + comment_id: int
298 + comment: str
299 + user_name: str
300 + created_at: datetime
301 +
302 +
303 class AlertContextCreate(BaseModel):
304 source: str
305 context: Dict
@@ -349,6 +381,14 @@ class CommentBase(BaseModel):
381 created_at: datetime
382
383
384 +class CaseCommentBase(BaseModel):
385 + user_name: str
386 + case_id: int
387 + id: int
388 + comment: str
389 + created_at: datetime
390 +
391 +
392 class AssetBase(BaseModel):
393 asset_name: str
394 agent_id: Optional[str] = None
@@ -378,6 +418,7 @@ class AlertOut(BaseModel):
418 customer_code: str
419 source: str
420 assigned_to: Optional[str] = None
421 + escalated: bool = False
422 comments: List[CommentBase] = []
423 assets: List[AssetBase] = []
424 tags: List[AlertTagBase] = []
@@ -406,6 +447,8 @@ class CaseOut(BaseModel):
447 case_creation_time: Optional[datetime] = None
448 customer_code: Optional[str] = None
449 notification_invoked_number: Optional[int] = 0
450 + escalated: bool = False
451 + comments: List[CaseCommentBase] = []
452
453
454 class CaseOutResponse(BaseModel):
backend/app/incidents/services/db_operations.py
+174
@@ -38,6 +38,7 @@ from app.incidents.models import Asset
38 from app.incidents.models import AssetFieldName
39 from app.incidents.models import Case
40 from app.incidents.models import CaseAlertLink
41 +from app.incidents.models import CaseComment
42 from app.incidents.models import CaseDataStore
43 from app.incidents.models import CaseReportTemplateDataStore
44 from app.incidents.models import Comment
@@ -60,6 +61,9 @@ from app.incidents.schema.db_operations import CaseAlertLinkCreate
61 from app.incidents.schema.db_operations import CaseAlertLinksCreate
62 from app.incidents.schema.db_operations import CaseAlertUnLink
63 from app.incidents.schema.db_operations import CaseAlertUnLinkResponse
64 +from app.incidents.schema.db_operations import CaseCommentBase
65 +from app.incidents.schema.db_operations import CaseCommentCreate
66 +from app.incidents.schema.db_operations import CaseCommentEdit
67 from app.incidents.schema.db_operations import CaseCreate
68 from app.incidents.schema.db_operations import CaseOut
69 from app.incidents.schema.db_operations import CaseReportTemplateDataStoreListResponse
@@ -833,6 +837,26 @@ async def update_alert_assigned_to(alert_id: int, assigned_to: str, db: AsyncSes
837 return alert
838
839
840 +async def update_alert_escalated(alert_id: int, escalated: bool, db: AsyncSession) -> Alert:
841 + result = await db.execute(select(Alert).where(Alert.id == alert_id))
842 + alert = result.scalars().first()
843 + if not alert:
844 + raise HTTPException(status_code=404, detail="Alert not found")
845 + alert.escalated = escalated
846 + await db.commit()
847 + return alert
848 +
849 +
850 +async def update_case_escalated(case_id: int, escalated: bool, db: AsyncSession) -> Case:
851 + result = await db.execute(select(Case).where(Case.id == case_id))
852 + case = result.scalars().first()
853 + if not case:
854 + raise HTTPException(status_code=404, detail="Case not found")
855 + case.escalated = escalated
856 + await db.commit()
857 + return case
858 +
859 +
860 async def increment_case_notification_count(case_id: int, db: AsyncSession) -> Case:
861 result = await db.execute(select(Case).where(Case.id == case_id))
862 case = result.scalars().first()
@@ -890,6 +914,48 @@ async def delete_comment(comment_id: int, db: AsyncSession) -> Comment:
914 return comment
915
916
917 +async def create_case_comment(comment: CaseCommentCreate, db: AsyncSession) -> CaseComment:
918 + # Check if the case exists
919 + result = await db.execute(select(Case).options(selectinload(Case.comments)).where(Case.id == comment.case_id))
920 + case = result.scalars().first()
921 + if not case:
922 + raise HTTPException(status_code=404, detail="Case not found")
923 +
924 + # Create comment with automatic timestamp if not provided
925 + comment_data = comment.dict()
926 + if comment_data.get("created_at") is None:
927 + comment_data["created_at"] = datetime.utcnow()
928 +
929 + db_comment = CaseComment(**comment_data)
930 + db.add(db_comment)
931 + try:
932 + await db.commit()
933 + except IntegrityError:
934 + raise HTTPException(status_code=400, detail="Comment already exists")
935 + return db_comment
936 +
937 +
938 +async def edit_case_comment(comment: CaseCommentEdit, db: AsyncSession) -> CaseComment:
939 + result = await db.execute(select(CaseComment).where(CaseComment.id == comment.comment_id))
940 + db_comment = result.scalars().first()
941 + if not db_comment:
942 + raise HTTPException(status_code=404, detail="Comment not found")
943 + db_comment.comment = comment.comment
944 + db_comment.user_name = comment.user_name
945 + await db.commit()
946 + return db_comment
947 +
948 +
949 +async def delete_case_comment(comment_id: int, db: AsyncSession) -> CaseComment:
950 + result = await db.execute(select(CaseComment).where(CaseComment.id == comment_id))
951 + comment = result.scalars().first()
952 + if not comment:
953 + raise HTTPException(status_code=404, detail="Comment not found")
954 + await db.execute(delete(CaseComment).where(CaseComment.id == comment_id))
955 + await db.commit()
956 + return comment
957 +
958 +
959 async def create_asset(asset: AssetCreate, db: AsyncSession) -> Asset:
960 # Check if the alert exists
961 result = await db.execute(select(Alert).options(selectinload(Alert.assets)).where(Alert.id == asset.alert_linked))
@@ -1053,6 +1119,7 @@ async def get_alert_by_id(alert_id: int, db: AsyncSession) -> AlertOut:
1119 customer_code=alert.customer_code,
1120 source=alert.source,
1121 assigned_to=alert.assigned_to,
1122 + escalated=alert.escalated,
1123 comments=comments,
1124 assets=assets,
1125 tags=tags,
@@ -1098,6 +1165,7 @@ async def list_alerts(db: AsyncSession, page: int = 1, page_size: int = 25, orde
1165 customer_code=alert.customer_code,
1166 source=alert.source,
1167 assigned_to=alert.assigned_to,
1168 + escalated=alert.escalated,
1169 comments=comments,
1170 assets=assets,
1171 tags=tags,
@@ -1132,6 +1200,7 @@ async def create_case_from_alert(alert_id: int, db: AsyncSession) -> Case:
1200 case_description=alert.alert_description,
1201 case_status=alert.status,
1202 assigned_to=alert.assigned_to,
1203 + escalated=alert.escalated,
1204 customer_code=alert.customer_code,
1205 )
1206 db.add(case)
@@ -1205,6 +1274,7 @@ async def get_case_by_id(case_id: int, db: AsyncSession) -> CaseOut:
1274 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.tags).selectinload(AlertToTag.tag),
1275 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.cases).selectinload(CaseAlertLink.case),
1276 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.iocs).selectinload(AlertToIoC.ioc),
1277 + selectinload(Case.comments),
1278 ),
1279 )
1280 case = result.scalars().first()
@@ -1228,6 +1298,7 @@ async def get_case_by_id(case_id: int, db: AsyncSession) -> CaseOut:
1298 customer_code=alert.customer_code,
1299 source=alert.source,
1300 assigned_to=alert.assigned_to,
1301 + escalated=alert.escalated,
1302 comments=comments,
1303 assets=assets,
1304 tags=tags,
@@ -1235,6 +1306,10 @@ async def get_case_by_id(case_id: int, db: AsyncSession) -> CaseOut:
1306 iocs=iocs,
1307 )
1308 alerts_out.append(alert_out)
1309 +
1310 + # Extract case comments
1311 + case_comments = [CaseCommentBase(**comment.__dict__) for comment in case.comments]
1312 +
1313 case_out = CaseOut(
1314 id=case.id,
1315 case_name=case.case_name,
@@ -1244,6 +1319,8 @@ async def get_case_by_id(case_id: int, db: AsyncSession) -> CaseOut:
1319 case_creation_time=case.case_creation_time,
1320 customer_code=case.customer_code,
1321 notification_invoked_number=case.notification_invoked_number or 0,
1322 + comments=case_comments,
1323 + escalated=case.escalated,
1324 )
1325 return case_out
1326
@@ -1256,6 +1333,7 @@ async def list_cases(db: AsyncSession) -> List[CaseOut]:
1333 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.tags).selectinload(AlertToTag.tag),
1334 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.cases).selectinload(CaseAlertLink.case),
1335 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.iocs).selectinload(AlertToIoC.ioc),
1336 + selectinload(Case.comments),
1337 ),
1338 )
1339 cases = result.scalars().all()
@@ -1279,6 +1357,7 @@ async def list_cases(db: AsyncSession) -> List[CaseOut]:
1357 customer_code=alert.customer_code,
1358 source=alert.source,
1359 assigned_to=alert.assigned_to,
1360 + escalated=alert.escalated,
1361 comments=comments,
1362 assets=assets,
1363 tags=tags,
@@ -1286,6 +1365,10 @@ async def list_cases(db: AsyncSession) -> List[CaseOut]:
1365 iocs=iocs,
1366 )
1367 alerts_out.append(alert_out)
1368 +
1369 + # Extract case comments
1370 + case_comments = [CaseCommentBase(**comment.__dict__) for comment in case.comments]
1371 +
1372 case_out = CaseOut(
1373 id=case.id,
1374 case_name=case.case_name,
@@ -1296,6 +1379,8 @@ async def list_cases(db: AsyncSession) -> List[CaseOut]:
1379 case_status=case.case_status,
1380 customer_code=case.customer_code,
1381 notification_invoked_number=case.notification_invoked_number or 0,
1382 + comments=case_comments,
1383 + escalated=case.escalated,
1384 )
1385 cases_out.append(case_out)
1386 return cases_out
@@ -1311,6 +1396,7 @@ async def list_cases_by_status(status: str, db: AsyncSession) -> List[CaseOut]:
1396 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.tags).selectinload(AlertToTag.tag),
1397 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.cases).selectinload(CaseAlertLink.case),
1398 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.iocs).selectinload(AlertToIoC.ioc),
1399 + selectinload(Case.comments),
1400 ),
1401 )
1402 cases = result.scalars().all()
@@ -1334,6 +1420,7 @@ async def list_cases_by_status(status: str, db: AsyncSession) -> List[CaseOut]:
1420 customer_code=alert.customer_code,
1421 source=alert.source,
1422 assigned_to=alert.assigned_to,
1423 + escalated=alert.escalated,
1424 comments=comments,
1425 assets=assets,
1426 tags=tags,
@@ -1341,13 +1428,22 @@ async def list_cases_by_status(status: str, db: AsyncSession) -> List[CaseOut]:
1428 iocs=iocs,
1429 )
1430 alerts_out.append(alert_out)
1431 +
1432 + # Extract case comments
1433 + case_comments = [CaseCommentBase(**comment.__dict__) for comment in case.comments]
1434 +
1435 case_out = CaseOut(
1436 id=case.id,
1437 case_name=case.case_name,
1438 case_description=case.case_description,
1439 assigned_to=case.assigned_to,
1440 alerts=alerts_out,
1441 + case_creation_time=case.case_creation_time,
1442 + case_status=case.case_status,
1443 customer_code=case.customer_code,
1444 + notification_invoked_number=case.notification_invoked_number or 0,
1445 + comments=case_comments,
1446 + escalated=case.escalated,
1447 )
1448 cases_out.append(case_out)
1449 return cases_out
@@ -1361,6 +1457,7 @@ async def list_cases_by_assigned_to(assigned_to: str, db: AsyncSession) -> List[
1457 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.comments),
1458 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.assets),
1459 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.tags).selectinload(AlertToTag.tag),
1460 + selectinload(Case.comments),
1461 ),
1462 )
1463 cases = result.scalars().all()
@@ -1382,11 +1479,25 @@ async def list_cases_by_assigned_to(assigned_to: str, db: AsyncSession) -> List[
1479 customer_code=alert.customer_code,
1480 source=alert.source,
1481 assigned_to=alert.assigned_to,
1482 + escalated=alert.escalated,
1483 comments=comments,
1484 assets=assets,
1485 tags=tags,
1486 )
1487 alerts_out.append(alert_out)
1488 +
1489 + # Handle case comments
1490 + case_comments = []
1491 + for comment in case.comments:
1492 + case_comment = CaseCommentBase(
1493 + id=comment.id,
1494 + case_id=comment.case_id,
1495 + user_name=comment.user_name,
1496 + comment=comment.comment,
1497 + created_at=comment.created_at,
1498 + )
1499 + case_comments.append(case_comment)
1500 +
1501 case_out = CaseOut(
1502 id=case.id,
1503 case_name=case.case_name,
@@ -1394,6 +1505,8 @@ async def list_cases_by_assigned_to(assigned_to: str, db: AsyncSession) -> List[
1505 assigned_to=case.assigned_to,
1506 alerts=alerts_out,
1507 customer_code=case.customer_code,
1508 + comments=case_comments,
1509 + escalated=case.escalated,
1510 )
1511 cases_out.append(case_out)
1512 return cases_out
@@ -1410,6 +1523,7 @@ async def list_cases_by_asset_name(asset_name: str, db: AsyncSession) -> List[Ca
1523 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.comments),
1524 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.assets),
1525 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.tags).selectinload(AlertToTag.tag),
1526 + selectinload(Case.comments),
1527 ),
1528 )
1529 cases = result.scalars().all()
@@ -1431,11 +1545,25 @@ async def list_cases_by_asset_name(asset_name: str, db: AsyncSession) -> List[Ca
1545 customer_code=alert.customer_code,
1546 source=alert.source,
1547 assigned_to=alert.assigned_to,
1548 + escalated=alert.escalated,
1549 comments=comments,
1550 assets=assets,
1551 tags=tags,
1552 )
1553 alerts_out.append(alert_out)
1554 +
1555 + # Handle case comments
1556 + case_comments = []
1557 + for comment in case.comments:
1558 + case_comment = CaseCommentBase(
1559 + id=comment.id,
1560 + case_id=comment.case_id,
1561 + user_name=comment.user_name,
1562 + comment=comment.comment,
1563 + created_at=comment.created_at,
1564 + )
1565 + case_comments.append(case_comment)
1566 +
1567 case_out = CaseOut(
1568 id=case.id,
1569 case_name=case.case_name,
@@ -1443,6 +1571,8 @@ async def list_cases_by_asset_name(asset_name: str, db: AsyncSession) -> List[Ca
1571 assigned_to=case.assigned_to,
1572 alerts=alerts_out,
1573 customer_code=case.customer_code,
1574 + comments=case_comments,
1575 + escalated=case.escalated,
1576 )
1577 cases_out.append(case_out)
1578 return cases_out
@@ -1456,6 +1586,7 @@ async def list_cases_by_customer_code(customer_code: str, db: AsyncSession) -> L
1586 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.comments),
1587 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.assets),
1588 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.tags).selectinload(AlertToTag.tag),
1589 + selectinload(Case.comments),
1590 ),
1591 )
1592 cases = result.scalars().all()
@@ -1477,11 +1608,25 @@ async def list_cases_by_customer_code(customer_code: str, db: AsyncSession) -> L
1608 customer_code=alert.customer_code,
1609 source=alert.source,
1610 assigned_to=alert.assigned_to,
1611 + escalated=alert.escalated,
1612 comments=comments,
1613 assets=assets,
1614 tags=tags,
1615 )
1616 alerts_out.append(alert_out)
1617 +
1618 + # Handle case comments
1619 + case_comments = []
1620 + for comment in case.comments:
1621 + case_comment = CaseCommentBase(
1622 + id=comment.id,
1623 + case_id=comment.case_id,
1624 + user_name=comment.user_name,
1625 + comment=comment.comment,
1626 + created_at=comment.created_at,
1627 + )
1628 + case_comments.append(case_comment)
1629 +
1630 case_out = CaseOut(
1631 id=case.id,
1632 case_name=case.case_name,
@@ -1489,6 +1634,8 @@ async def list_cases_by_customer_code(customer_code: str, db: AsyncSession) -> L
1634 assigned_to=case.assigned_to,
1635 alerts=alerts_out,
1636 customer_code=case.customer_code,
1637 + comments=case_comments,
1638 + escalated=case.escalated,
1639 )
1640 cases_out.append(case_out)
1641 return cases_out
@@ -1541,6 +1688,7 @@ async def list_alerts_by_ioc(ioc_value: str, db: AsyncSession, page: int = 1, pa
1688 customer_code=alert.customer_code,
1689 source=alert.source,
1690 assigned_to=alert.assigned_to,
1691 + escalated=alert.escalated,
1692 comments=comments,
1693 assets=assets,
1694 tags=tags,
@@ -1587,6 +1735,7 @@ async def list_alerts_by_tag(tag: str, db: AsyncSession, page: int = 1, page_siz
1735 customer_code=alert.customer_code,
1736 source=alert.source,
1737 assigned_to=alert.assigned_to,
1738 + escalated=alert.escalated,
1739 comments=comments,
1740 assets=assets,
1741 tags=tags,
@@ -1632,6 +1781,7 @@ async def list_alert_by_status(status: str, db: AsyncSession, page: int = 1, pag
1781 customer_code=alert.customer_code,
1782 source=alert.source,
1783 assigned_to=alert.assigned_to,
1784 + escalated=alert.escalated,
1785 comments=comments,
1786 assets=assets,
1787 tags=tags,
@@ -1681,6 +1831,7 @@ async def list_alerts_by_asset_name(
1831 customer_code=alert.customer_code,
1832 source=alert.source,
1833 assigned_to=alert.assigned_to,
1834 + escalated=alert.escalated,
1835 comments=comments,
1836 assets=assets,
1837 tags=tags,
@@ -1728,6 +1879,7 @@ async def list_alert_by_assigned_to(
1879 customer_code=alert.customer_code,
1880 source=alert.source,
1881 assigned_to=alert.assigned_to,
1882 + escalated=alert.escalated,
1883 comments=comments,
1884 assets=assets,
1885 tags=tags,
@@ -1775,6 +1927,7 @@ async def list_alerts_by_title(
1927 customer_code=alert.customer_code,
1928 source=alert.source,
1929 assigned_to=alert.assigned_to,
1930 + escalated=alert.escalated,
1931 comments=comments,
1932 assets=assets,
1933 tags=tags,
@@ -1822,6 +1975,7 @@ async def list_alerts_by_customer_code(
1975 customer_code=alert.customer_code,
1976 source=alert.source,
1977 assigned_to=alert.assigned_to,
1978 + escalated=alert.escalated,
1979 comments=comments,
1980 assets=assets,
1981 tags=tags,
@@ -1869,6 +2023,7 @@ async def list_alerts_by_source(
2023 customer_code=alert.customer_code,
2024 source=alert.source,
2025 assigned_to=alert.assigned_to,
2026 + escalated=alert.escalated,
2027 comments=comments,
2028 assets=assets,
2029 tags=tags,
@@ -1955,6 +2110,7 @@ async def list_alerts_multiple_filters(
2110 customer_code=alert.customer_code,
2111 source=alert.source,
2112 assigned_to=alert.assigned_to,
2113 + escalated=alert.escalated,
2114 comments=comments,
2115 assets=assets,
2116 tags=tags,
@@ -2010,6 +2166,7 @@ async def list_alerts_for_user(
2166 customer_code=alert.customer_code,
2167 source=alert.source,
2168 assigned_to=alert.assigned_to,
2169 + escalated=alert.escalated,
2170 comments=comments,
2171 assets=assets,
2172 tags=tags,
@@ -2032,6 +2189,7 @@ async def list_cases_for_user(
2189 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.tags).selectinload(AlertToTag.tag),
2190 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.cases).selectinload(CaseAlertLink.case),
2191 selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.iocs).selectinload(AlertToIoC.ioc),
2192 + selectinload(Case.comments),
2193 )
2194
2195 # Apply customer filtering
@@ -2061,6 +2219,7 @@ async def list_cases_for_user(
2219 customer_code=alert.customer_code,
2220 source=alert.source,
2221 assigned_to=alert.assigned_to,
2222 + escalated=alert.escalated,
2223 comments=comments,
2224 assets=assets,
2225 tags=tags,
@@ -2068,6 +2227,19 @@ async def list_cases_for_user(
2227 iocs=iocs,
2228 )
2229 alerts_out.append(alert_out)
2230 +
2231 + # Handle case comments
2232 + case_comments = []
2233 + for comment in case.comments:
2234 + case_comment = CaseCommentBase(
2235 + id=comment.id,
2236 + case_id=comment.case_id,
2237 + user_name=comment.user_name,
2238 + comment=comment.comment,
2239 + created_at=comment.created_at,
2240 + )
2241 + case_comments.append(case_comment)
2242 +
2243 case_out = CaseOut(
2244 id=case.id,
2245 case_name=case.case_name,
@@ -2078,6 +2250,8 @@ async def list_cases_for_user(
2250 case_status=case.case_status,
2251 customer_code=case.customer_code,
2252 notification_invoked_number=case.notification_invoked_number or 0,
2253 + comments=case_comments,
2254 + escalated=case.escalated,
2255 )
2256 cases_out.append(case_out)
2257 return cases_out
customer_portal/package.json
+1 -1
@@ -3,7 +3,7 @@
3 "type": "module",
4 "version": "1.0.0",
5 "private": true,
6 - "packageManager": "pnpm@10.13.1",
6 + "packageManager": "pnpm@10.17.0+sha512.fce8a3dd29a4ed2ec566fb53efbb04d8c44a0f05bc6f24a73046910fb9c3ce7afa35a0980500668fa3573345bd644644fa98338fa168235c80f4aa17aa17fbef",
7 "engines": {
8 "node": ">=18.0.0"
9 },
customer_portal/pnpm-workspace.yaml new
+4
@@ -0,0 +1,4 @@
1 +onlyBuiltDependencies:
2 + - '@parcel/watcher'
3 + - '@tailwindcss/oxide'
4 + - esbuild
customer_portal/src/api/cases.ts
+57
@@ -1,5 +1,13 @@
1 import { httpClient } from '@/utils/httpClient'
2
3 +export interface CaseComment {
4 + id: number
5 + case_id: number
6 + user_name: string
7 + comment: string
8 + created_at: string
9 +}
10 +
11 export interface Case {
12 id: number
13 case_creation_time: string
@@ -10,6 +18,7 @@ export interface Case {
18 customer_code: string
19 alert_ids: number[]
20 alerts?: Alert[]
21 + comments?: CaseComment[]
22 }
23
24 export interface Alert {
@@ -48,6 +57,23 @@ export interface CasePayload {
57 assigned_to?: string
58 }
59
60 +export interface CaseCommentCreate {
61 + case_id: number
62 + comment: string
63 +}
64 +
65 +export interface CaseCommentEdit {
66 + id: number
67 + case_id: number
68 + comment: string
69 +}
70 +
71 +export interface CaseCommentResponse {
72 + comment: CaseComment
73 + success: boolean
74 + message: string
75 +}
76 +
77 export class CasesAPI {
78 /**
79 * Get all cases with customer access control
@@ -150,6 +176,37 @@ export class CasesAPI {
176 })
177 return response.data
178 }
179 +
180 + /**
181 + * Create a new case comment
182 + */
183 + static async createCaseComment(caseId: number, comment: string): Promise<CaseCommentResponse> {
184 + const response = await httpClient.post('/incidents/db_operations/case/comment', {
185 + case_id: caseId,
186 + comment
187 + })
188 + return response.data
189 + }
190 +
191 + /**
192 + * Update an existing case comment
193 + */
194 + static async updateCaseComment(id: number, caseId: number, comment: string): Promise<CaseCommentResponse> {
195 + const response = await httpClient.put('/incidents/db_operations/case/comment', {
196 + id,
197 + case_id: caseId,
198 + comment
199 + })
200 + return response.data
201 + }
202 +
203 + /**
204 + * Delete a case comment
205 + */
206 + static async deleteCaseComment(commentId: number): Promise<{ success: boolean; message: string }> {
207 + const response = await httpClient.delete(`/incidents/db_operations/case/comment/${commentId}`)
208 + return response.data
209 + }
210 }
211
212 export default CasesAPI
customer_portal/src/components/CaseComment.vue new
+199
@@ -0,0 +1,199 @@
1 +<template>
2 + <div class="bg-white border border-gray-200 rounded-lg p-4 mb-3">
3 + <div class="flex items-start justify-between">
4 + <div class="flex items-start space-x-3">
5 + <!-- User Avatar -->
6 + <div class="flex-shrink-0">
7 + <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
8 + <span class="text-white text-sm font-medium">
9 + {{ comment.user_name.charAt(0).toUpperCase() }}
10 + </span>
11 + </div>
12 + </div>
13 +
14 + <!-- Comment Content -->
15 + <div class="flex-grow">
16 + <div class="flex items-center space-x-2 mb-1">
17 + <h4 class="text-sm font-medium text-gray-900">{{ comment.user_name }}</h4>
18 + <span class="text-xs text-gray-500">{{ formatDate(comment.created_at) }}</span>
19 + </div>
20 +
21 + <!-- Edit Mode -->
22 + <div v-if="isEditing" class="space-y-2">
23 + <textarea
24 + v-model="editText"
25 + class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
26 + rows="3"
27 + placeholder="Edit your comment..."
28 + ></textarea>
29 + <div class="flex space-x-2">
30 + <button
31 + @click="saveEdit"
32 + :disabled="!editText.trim() || isLoading"
33 + class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
34 + >
35 + <span v-if="isLoading" class="mr-1">
36 + <svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
37 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
38 + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
39 + </svg>
40 + </span>
41 + Save
42 + </button>
43 + <button
44 + @click="cancelEdit"
45 + :disabled="isLoading"
46 + class="inline-flex items-center px-3 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
47 + >
48 + Cancel
49 + </button>
50 + </div>
51 + </div>
52 +
53 + <!-- View Mode -->
54 + <div v-else class="text-sm text-gray-700 whitespace-pre-wrap">{{ comment.comment }}</div>
55 + </div>
56 + </div>
57 +
58 + <!-- Actions -->
59 + <div v-if="canEdit && !isEditing" class="flex items-center space-x-1 ml-2">
60 + <button
61 + @click="startEdit"
62 + class="p-1 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 rounded"
63 + title="Edit comment"
64 + >
65 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
66 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
67 + </svg>
68 + </button>
69 + <button
70 + @click="confirmDelete"
71 + class="p-1 text-gray-400 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 rounded"
72 + title="Delete comment"
73 + >
74 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
75 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="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"></path>
76 + </svg>
77 + </button>
78 + </div>
79 + </div>
80 +
81 + <!-- Error message -->
82 + <div v-if="error" class="mt-2 text-sm text-red-600">{{ error }}</div>
83 + </div>
84 +</template>
85 +
86 +<script setup lang="ts">
87 +import { ref, computed } from 'vue'
88 +import { useAuthStore } from '@/stores/auth'
89 +import type { CaseComment } from '@/api/cases'
90 +import { CasesAPI } from '@/api/cases'
91 +
92 +interface Props {
93 + comment: CaseComment
94 +}
95 +
96 +interface Emits {
97 + (e: 'updated', comment: CaseComment): void
98 + (e: 'deleted', commentId: number): void
99 +}
100 +
101 +const props = defineProps<Props>()
102 +const emit = defineEmits<Emits>()
103 +
104 +const authStore = useAuthStore()
105 +
106 +const isEditing = ref(false)
107 +const editText = ref('')
108 +const isLoading = ref(false)
109 +const error = ref('')
110 +
111 +const canEdit = computed(() => {
112 + return authStore.user?.username === props.comment.user_name
113 +})
114 +
115 +const formatDate = (dateString: string) => {
116 + try {
117 + const date = new Date(dateString)
118 + const now = new Date()
119 + const diff = now.getTime() - date.getTime()
120 +
121 + const minutes = Math.floor(diff / (1000 * 60))
122 + const hours = Math.floor(diff / (1000 * 60 * 60))
123 + const days = Math.floor(diff / (1000 * 60 * 60 * 24))
124 +
125 + if (minutes < 1) return 'Just now'
126 + if (minutes < 60) return `${minutes}m ago`
127 + if (hours < 24) return `${hours}h ago`
128 + if (days < 7) return `${days}d ago`
129 +
130 + return date.toLocaleDateString()
131 + } catch {
132 + return 'Unknown'
133 + }
134 +}
135 +
136 +const startEdit = () => {
137 + editText.value = props.comment.comment
138 + isEditing.value = true
139 + error.value = ''
140 +}
141 +
142 +const cancelEdit = () => {
143 + isEditing.value = false
144 + editText.value = ''
145 + error.value = ''
146 +}
147 +
148 +const saveEdit = async () => {
149 + if (!editText.value.trim()) return
150 +
151 + isLoading.value = true
152 + error.value = ''
153 +
154 + try {
155 + const response = await CasesAPI.updateCaseComment(
156 + props.comment.id,
157 + props.comment.case_id,
158 + editText.value.trim()
159 + )
160 +
161 + if (response.success) {
162 + emit('updated', response.comment)
163 + isEditing.value = false
164 + editText.value = ''
165 + } else {
166 + error.value = response.message || 'Failed to update comment'
167 + }
168 + } catch (err: any) {
169 + error.value = err.response?.data?.detail || 'Failed to update comment'
170 + } finally {
171 + isLoading.value = false
172 + }
173 +}
174 +
175 +const confirmDelete = () => {
176 + if (confirm('Are you sure you want to delete this comment?')) {
177 + deleteComment()
178 + }
179 +}
180 +
181 +const deleteComment = async () => {
182 + isLoading.value = true
183 + error.value = ''
184 +
185 + try {
186 + const response = await CasesAPI.deleteCaseComment(props.comment.id)
187 +
188 + if (response.success) {
189 + emit('deleted', props.comment.id)
190 + } else {
191 + error.value = response.message || 'Failed to delete comment'
192 + }
193 + } catch (err: any) {
194 + error.value = err.response?.data?.detail || 'Failed to delete comment'
195 + } finally {
196 + isLoading.value = false
197 + }
198 +}
199 +</script>
customer_portal/src/components/CaseCommentsList.vue new
+122
@@ -0,0 +1,122 @@
1 +<template>
2 + <div class="space-y-4">
3 + <!-- Header -->
4 + <div class="flex items-center justify-between">
5 + <h3 class="text-lg font-medium text-gray-900">Comments</h3>
6 + <span class="text-sm text-gray-500">{{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}</span>
7 + </div>
8 +
9 + <!-- New Comment Form -->
10 + <div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
11 + <div class="space-y-3">
12 + <textarea
13 + v-model="newComment"
14 + class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
15 + rows="3"
16 + placeholder="Add a comment..."
17 + ></textarea>
18 + <div class="flex justify-end">
19 + <button
20 + @click="addComment"
21 + :disabled="!newComment.trim() || isSubmitting"
22 + class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
23 + >
24 + <span v-if="isSubmitting" class="mr-2">
25 + <svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
26 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
27 + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
28 + </svg>
29 + </span>
30 + Add Comment
31 + </button>
32 + </div>
33 + </div>
34 +
35 + <!-- Error message -->
36 + <div v-if="error" class="mt-2 text-sm text-red-600">{{ error }}</div>
37 + </div>
38 +
39 + <!-- Comments List -->
40 + <div v-if="comments.length === 0" class="text-center py-8 text-gray-500">
41 + <svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
42 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
43 + </svg>
44 + <p>No comments yet</p>
45 + <p class="text-sm">Be the first to add a comment to this case.</p>
46 + </div>
47 +
48 + <div v-else class="space-y-3">
49 + <CaseComment
50 + v-for="comment in sortedComments"
51 + :key="comment.id"
52 + :comment="comment"
53 + @updated="handleCommentUpdated"
54 + @deleted="handleCommentDeleted"
55 + />
56 + </div>
57 + </div>
58 +</template>
59 +
60 +<script setup lang="ts">
61 +import { ref, computed } from 'vue'
62 +import CaseComment from './CaseComment.vue'
63 +import type { CaseComment as CaseCommentType } from '@/api/cases'
64 +import { CasesAPI } from '@/api/cases'
65 +
66 +interface Props {
67 + caseId: number
68 + comments: CaseCommentType[]
69 +}
70 +
71 +interface Emits {
72 + (e: 'commentsUpdated', comments: CaseCommentType[]): void
73 +}
74 +
75 +const props = defineProps<Props>()
76 +const emit = defineEmits<Emits>()
77 +
78 +const newComment = ref('')
79 +const isSubmitting = ref(false)
80 +const error = ref('')
81 +
82 +const sortedComments = computed(() => {
83 + return [...props.comments].sort((a, b) =>
84 + new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
85 + )
86 +})
87 +
88 +const addComment = async () => {
89 + if (!newComment.value.trim()) return
90 +
91 + isSubmitting.value = true
92 + error.value = ''
93 +
94 + try {
95 + const response = await CasesAPI.createCaseComment(props.caseId, newComment.value.trim())
96 +
97 + if (response.success) {
98 + const updatedComments = [...props.comments, response.comment]
99 + emit('commentsUpdated', updatedComments)
100 + newComment.value = ''
101 + } else {
102 + error.value = response.message || 'Failed to add comment'
103 + }
104 + } catch (err: any) {
105 + error.value = err.response?.data?.detail || 'Failed to add comment'
106 + } finally {
107 + isSubmitting.value = false
108 + }
109 +}
110 +
111 +const handleCommentUpdated = (updatedComment: CaseCommentType) => {
112 + const updatedComments = props.comments.map(comment =>
113 + comment.id === updatedComment.id ? updatedComment : comment
114 + )
115 + emit('commentsUpdated', updatedComments)
116 +}
117 +
118 +const handleCommentDeleted = (commentId: number) => {
119 + const updatedComments = props.comments.filter(comment => comment.id !== commentId)
120 + emit('commentsUpdated', updatedComments)
121 +}
122 +</script>
customer_portal/src/router/index.ts
+7
@@ -3,6 +3,7 @@ import LoginPage from '@/components/LoginPage.vue'
3 import OverviewPage from '@/views/OverviewPage.vue'
4 import AlertsPage from '@/views/AlertsPage.vue'
5 import CasesPage from '@/views/CasesPage.vue'
6 +import CaseDetailsView from '@/views/CaseDetailsView.vue'
7 import AgentsPage from '@/views/AgentsPage.vue'
8
9 const NotFound = {
@@ -48,6 +49,12 @@ const routes = [
49 component: CasesPage,
50 meta: { requiresAuth: true }
51 },
52 + {
53 + path: '/cases/:id',
54 + name: 'CaseDetails',
55 + component: CaseDetailsView,
56 + meta: { requiresAuth: true }
57 + },
58 {
59 path: '/agents',
60 name: 'Agents',
customer_portal/src/views/CaseDetailsView.vue new
+233
@@ -0,0 +1,233 @@
1 +<template>
2 + <div class="min-h-screen bg-gray-50">
3 + <!-- Header -->
4 + <header class="bg-white shadow">
5 + <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
6 + <div class="flex justify-between h-16">
7 + <div class="flex items-center">
8 + <router-link
9 + to="/cases"
10 + class="text-indigo-600 hover:text-indigo-500 mr-4"
11 + >
12 + ← Back to Cases
13 + </router-link>
14 + <h1 class="text-xl font-semibold">Case Details</h1>
15 + </div>
16 + <div class="flex items-center space-x-4">
17 + <span class="text-sm text-gray-700">{{ user?.username }}</span>
18 + <button
19 + @click="logout"
20 + class="bg-red-600 hover:bg-red-700 text-white px-3 py-2 rounded-md text-sm font-medium"
21 + >
22 + Logout
23 + </button>
24 + </div>
25 + </div>
26 + </div>
27 + </header>
28 +
29 + <!-- Main Content -->
30 + <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
31 + <div class="px-4 py-6 sm:px-0">
32 + <!-- Loading State -->
33 + <div v-if="loading" class="text-center py-8">
34 + <div class="inline-flex items-center px-4 py-2 font-semibold leading-6 text-sm shadow rounded-md text-white bg-indigo-500">
35 + Loading case details...
36 + </div>
37 + </div>
38 +
39 + <!-- Error State -->
40 + <div v-else-if="error" class="bg-red-50 border border-red-200 rounded-md p-4">
41 + <div class="flex">
42 + <div class="ml-3">
43 + <h3 class="text-sm font-medium text-red-800">
44 + Error loading case
45 + </h3>
46 + <div class="mt-2 text-sm text-red-700">
47 + {{ error }}
48 + </div>
49 + </div>
50 + </div>
51 + </div>
52 +
53 + <!-- Case Details -->
54 + <div v-else-if="caseData" class="space-y-6">
55 + <!-- Case Header -->
56 + <div class="bg-white shadow overflow-hidden sm:rounded-lg">
57 + <div class="px-4 py-5 sm:px-6">
58 + <div class="flex items-center justify-between">
59 + <div>
60 + <h3 class="text-lg leading-6 font-medium text-gray-900">
61 + {{ caseData.case_name || 'Unnamed Case' }}
62 + </h3>
63 + <p class="mt-1 max-w-2xl text-sm text-gray-500">
64 + Case #{{ caseData.id }}
65 + </p>
66 + </div>
67 + <div class="flex items-center space-x-2">
68 + <span
69 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
70 + :class="{
71 + 'bg-red-100 text-red-800': caseData.case_status === 'open',
72 + 'bg-yellow-100 text-yellow-800': caseData.case_status === 'in_progress',
73 + 'bg-green-100 text-green-800': caseData.case_status === 'closed',
74 + 'bg-gray-100 text-gray-800': !caseData.case_status
75 + }"
76 + >
77 + {{ caseData.case_status || 'Unknown' }}
78 + </span>
79 + </div>
80 + </div>
81 + </div>
82 + <div class="border-t border-gray-200 px-4 py-5 sm:p-0">
83 + <dl class="sm:divide-y sm:divide-gray-200">
84 + <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
85 + <dt class="text-sm font-medium text-gray-500">Description</dt>
86 + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
87 + {{ caseData.case_description || 'No description available' }}
88 + </dd>
89 + </div>
90 + <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
91 + <dt class="text-sm font-medium text-gray-500">Created</dt>
92 + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
93 + {{ formatDate(caseData.case_creation_time) }}
94 + </dd>
95 + </div>
96 + <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
97 + <dt class="text-sm font-medium text-gray-500">Assigned to</dt>
98 + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
99 + {{ caseData.assigned_to || 'Unassigned' }}
100 + </dd>
101 + </div>
102 + <div v-if="caseData.customer_code" class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
103 + <dt class="text-sm font-medium text-gray-500">Customer</dt>
104 + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
105 + {{ caseData.customer_code }}
106 + </dd>
107 + </div>
108 + </dl>
109 + </div>
110 + </div>
111 +
112 + <!-- Alerts Section -->
113 + <div v-if="caseData.alerts && caseData.alerts.length > 0" class="bg-white shadow overflow-hidden sm:rounded-lg">
114 + <div class="px-4 py-5 sm:px-6">
115 + <h3 class="text-lg leading-6 font-medium text-gray-900">
116 + Related Alerts ({{ caseData.alerts.length }})
117 + </h3>
118 + </div>
119 + <div class="border-t border-gray-200">
120 + <ul class="divide-y divide-gray-200">
121 + <li v-for="alert in caseData.alerts" :key="alert.id" class="px-4 py-4 sm:px-6">
122 + <div class="flex items-center justify-between">
123 + <div>
124 + <p class="text-sm font-medium text-gray-900">
125 + {{ alert.alert_name || 'Unnamed Alert' }}
126 + </p>
127 + <p class="text-sm text-gray-500">
128 + Alert #{{ alert.id }}
129 + </p>
130 + </div>
131 + <span
132 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
133 + :class="{
134 + 'bg-red-100 text-red-800': alert.status === 'open',
135 + 'bg-yellow-100 text-yellow-800': alert.status === 'in_progress',
136 + 'bg-green-100 text-green-800': alert.status === 'closed',
137 + 'bg-gray-100 text-gray-800': !alert.status
138 + }"
139 + >
140 + {{ alert.status || 'Unknown' }}
141 + </span>
142 + </div>
143 + </li>
144 + </ul>
145 + </div>
146 + </div>
147 +
148 + <!-- Comments Section -->
149 + <div class="bg-white shadow overflow-hidden sm:rounded-lg">
150 + <div class="px-4 py-5 sm:px-6">
151 + <CaseCommentsList
152 + :case-id="caseData.id"
153 + :comments="comments"
154 + @comments-updated="handleCommentsUpdated"
155 + />
156 + </div>
157 + </div>
158 + </div>
159 + </div>
160 + </main>
161 + </div>
162 +</template>
163 +
164 +<script setup lang="ts">
165 +import { ref, onMounted, computed } from 'vue'
166 +import { useRouter, useRoute } from 'vue-router'
167 +import { useAuthStore } from '@/stores/auth'
168 +import { CasesAPI, type Case, type CaseComment } from '@/api/cases'
169 +import CaseCommentsList from '@/components/CaseCommentsList.vue'
170 +
171 +const router = useRouter()
172 +const route = useRoute()
173 +const authStore = useAuthStore()
174 +
175 +const caseData = ref<Case | null>(null)
176 +const comments = ref<CaseComment[]>([])
177 +const loading = ref(false)
178 +const error = ref('')
179 +
180 +const user = computed(() => authStore.user)
181 +
182 +const formatDate = (dateString: string) => {
183 + if (!dateString) return 'Unknown'
184 + try {
185 + return new Date(dateString).toLocaleString()
186 + } catch {
187 + return 'Invalid date'
188 + }
189 +}
190 +
191 +const fetchCaseDetails = async () => {
192 + const caseId = Number(route.params.id)
193 + if (!caseId) {
194 + error.value = 'Invalid case ID'
195 + return
196 + }
197 +
198 + loading.value = true
199 + error.value = ''
200 +
201 + try {
202 + const response = await CasesAPI.getCase(caseId)
203 + if (response.success && response.cases.length > 0) {
204 + caseData.value = response.cases[0]
205 + comments.value = response.cases[0].comments || []
206 + } else {
207 + error.value = 'Case not found'
208 + }
209 + } catch (err: any) {
210 + error.value = err.response?.data?.detail || 'Failed to fetch case details'
211 + console.error('Failed to fetch case details:', err)
212 + } finally {
213 + loading.value = false
214 + }
215 +}
216 +
217 +const handleCommentsUpdated = (updatedComments: CaseComment[]) => {
218 + comments.value = updatedComments
219 + // Also update the case data if it has comments
220 + if (caseData.value) {
221 + caseData.value.comments = updatedComments
222 + }
223 +}
224 +
225 +const logout = () => {
226 + authStore.logout()
227 + router.push('/login')
228 +}
229 +
230 +onMounted(() => {
231 + fetchCaseDetails()
232 +})
233 +</script>
customer_portal/src/views/CasesView.vue
+15 -4
@@ -124,7 +124,7 @@
124 </div>
125
126 <ul v-else class="divide-y divide-gray-200">
127 - <li v-for="case_ in cases" :key="case_.id" class="px-4 py-4 sm:px-6">
127 + <li v-for="case_ in cases" :key="case_.id" class="px-4 py-4 sm:px-6 hover:bg-gray-50 cursor-pointer" @click="viewCaseDetails(case_.id)">
128 <div class="flex items-center justify-between">
129 <div class="flex items-center">
130 <div
@@ -137,7 +137,7 @@
137 }"
138 ></div>
139 <div>
140 - <p class="text-sm font-medium text-gray-900">
140 + <p class="text-sm font-medium text-gray-900 hover:text-indigo-600">
141 {{ case_.case_name || 'Unnamed Case' }}
142 </p>
143 <p class="text-sm text-gray-500">
@@ -146,6 +146,7 @@
146 <p class="text-xs text-gray-400 mt-1">
147 Created: {{ formatDate(case_.case_creation_time) }}
148 <span v-if="case_.assigned_to"> • Assigned to: {{ case_.assigned_to }}</span>
149 + <span v-if="case_.comments && case_.comments.length > 0"> • {{ case_.comments.length }} {{ case_.comments.length === 1 ? 'comment' : 'comments' }}</span>
150 </p>
151 </div>
152 </div>
@@ -208,6 +209,12 @@ interface Case {
209 assigned_to?: string
210 escalation_level?: string
211 customer_code?: string
212 + comments?: Array<{
213 + id: number
214 + comment: string
215 + user_name?: string
216 + created_at: string
217 + }>
218 }
219
220 const router = useRouter()
@@ -237,8 +244,8 @@ const fetchCases = async () => {
244 error.value = ''
245
246 try {
240 - const response = await httpClient.get('/cases/')
241 - cases.value = response.data || []
247 + const response = await httpClient.get('/incidents/db_operations/cases')
248 + cases.value = response.data.cases || []
249 } catch (err: any) {
250 error.value = err.response?.data?.detail || 'Failed to fetch cases'
251 console.error('Failed to fetch cases:', err)
@@ -251,6 +258,10 @@ const refreshCases = () => {
258 fetchCases()
259 }
260
261 +const viewCaseDetails = (caseId: number) => {
262 + router.push(`/cases/${caseId}`)
263 +}
264 +
265 const logout = () => {
266 authStore.logout()
267 router.push('/login')
frontend/package.json
+2 -2
@@ -121,10 +121,10 @@
121 "prettier": "^3.6.2",
122 "prettier-plugin-tailwindcss": "^0.6.14",
123 "sass": "^1.92.1",
124 - "start-server-and-test": "^2.1.1",
124 + "start-server-and-test": "^2.1.2",
125 "tailwindcss": "^4.1.13",
126 "taze": "^19.6.0",
127 - "type-fest": "^5.0.0",
127 + "type-fest": "^5.0.1",
128 "typescript": "~5.9.2",
129 "vite": "^7.1.6",
130 "vite-bundle-visualizer": "^1.2.1",
frontend/src/api/endpoints/incidentManagement/cases.ts
+20
@@ -2,6 +2,7 @@ import type { KeysOfUnion, UnionToIntersection } from "type-fest"
2 import type { FlaskBaseResponse } from "@/types/flask.d"
3 import type {
4 Case,
5 + CaseComment,
6 CaseDataStore,
7 CasePayload,
8 CaseReportTemplateDataStore,
@@ -23,6 +24,10 @@ export interface CaseReportPayload {
24 template_name: string
25 }
26
27 +export type CaseCommentPayload = Omit<CaseComment, "id">
28 +
29 +export type CaseCommentUpdatePayload = Omit<CaseComment, "id"> & { comment_id: number }
30 +
31 export default {
32 getCasesList(filters?: Partial<UnionToIntersection<CasesFilter>>) {
33 let url = `/incidents/db_operations/cases`
@@ -174,5 +179,20 @@ export default {
179 },
180 createCaseNotification(caseId: number) {
181 return HttpClient.post<FlaskBaseResponse>(`/incidents/db_operations/case/notification`, { case_id: caseId })
182 + },
183 + newCaseComment(payload: CaseCommentPayload) {
184 + return HttpClient.post<FlaskBaseResponse & { comment: CaseComment }>(
185 + `/incidents/db_operations/case/comment`,
186 + payload
187 + )
188 + },
189 + updateCaseComment(payload: CaseCommentUpdatePayload) {
190 + return HttpClient.put<FlaskBaseResponse & { comment: CaseComment }>(
191 + `/incidents/db_operations/case/comment`,
192 + payload
193 + )
194 + },
195 + deleteCaseComment(commentId: number) {
196 + return HttpClient.delete<FlaskBaseResponse>(`/incidents/db_operations/case/comment/${commentId}`)
197 }
198 }
frontend/src/app-layouts/common/Navbar/items.tsx
+15 -13
@@ -372,19 +372,21 @@ export default function getItems(): MenuMixedOption[] {
372 ),
373 key: "IncidentManagement-Cases"
374 }
375 - // {
376 - // label: () =>
377 - // h(
378 - // RouterLink,
379 - // {
380 - // to: {
381 - // name: "IncidentManagement-Sigma"
382 - // }
383 - // },
384 - // { default: () => "SIGMA" }
385 - // ),
386 - // key: "IncidentManagement-Sigma"
387 - // }
375 + /*
376 + {
377 + label: () =>
378 + h(
379 + RouterLink,
380 + {
381 + to: {
382 + name: "IncidentManagement-Sigma"
383 + }
384 + },
385 + { default: () => "SIGMA" }
386 + ),
387 + key: "IncidentManagement-Sigma"
388 + }
389 + */
390 ]
391 },
392 {
frontend/src/app-layouts/common/Toolbar/PinnedPagesV2.vue
+2 -1
@@ -110,7 +110,8 @@ function pinPage(page: Page) {
110 }
111
112 function checkRoute(route: RouteLocationNormalized) {
113 - const title = route.meta?.title || _split(route.name?.toString(), "-").at(-1)
113 + const splitName = _split(route.name?.toString(), "-")
114 + const title = route.meta?.title || splitName[splitName.length - 1]
115
116 if (route.name && title && !route.meta?.skipPin) {
117 const page: Page = {
frontend/src/components/auth/SignUp.vue
+34 -4
@@ -62,6 +62,16 @@
62 @keydown.enter="signUp"
63 />
64 </n-form-item>
65 + <n-form-item path="role" label="Role">
66 + <n-select
67 + v-model:value="model.role"
68 + :options="roleOptions"
69 + placeholder="Choose a role"
70 + size="large"
71 + to="body"
72 + @keydown.enter="signUp"
73 + />
74 + </n-form-item>
75
76 <!--
77 <div class="propic flex gap-5 mb-5">
@@ -149,14 +159,16 @@
159
160 <script lang="ts" setup>
161 import type { FormInst, FormItemRule, FormRules, FormValidationError } from "naive-ui"
162 +import type { SelectBaseOption } from "naive-ui/es/select/src/interface"
163 import type { RegisterPayload } from "@/types/auth.d"
164 import _trim from "lodash/trim"
154 -import { NButton, NForm, NFormItem, NInput, NSpin, NStep, NSteps, useMessage } from "naive-ui"
165 +import { NButton, NForm, NFormItem, NInput, NSelect, NSpin, NStep, NSteps, useMessage } from "naive-ui"
166 import PasswordValidator from "password-validator"
167 import isEmail from "validator/es/lib/isEmail"
168 import { computed, ref } from "vue"
169 import Api from "@/api"
170 import Icon from "@/components/common/Icon.vue"
171 +import { AuthUserRole } from "@/types/auth.d"
172 // import ImageCropper, { type ImageCropperResult } from "@/components/common/ImageCropper.vue"
173
174 interface ModelType {
@@ -164,6 +176,7 @@ interface ModelType {
176 password: string | null
177 username: string | null
178 confirmPassword: string | null
179 + role: number | null
180 /*
181 customerCode: string | null
182 firstName: string | null
@@ -193,6 +206,13 @@ const formRef = ref<FormInst | null>(null)
206 const message = useMessage()
207 const model = ref<ModelType>(getModel())
208
209 +const roleOptions: SelectBaseOption[] = Object.values(AuthUserRole)
210 + .filter(o => typeof o === "number" && o !== 0)
211 + .map(o => ({
212 + label: `${Object.entries(AuthUserRole).find(e => e[1] === o)?.[0]}`,
213 + value: o
214 + }))
215 +
216 const accountStepValid = computed(
217 () =>
218 !!_trim(model.value.email || "") &&
@@ -200,7 +220,7 @@ const accountStepValid = computed(
220 !!model.value.confirmPassword &&
221 model.value.password === model.value.confirmPassword
222 )
203 -const detailsStepValid = computed(() => !!_trim(model.value.username || ""))
223 +const detailsStepValid = computed(() => !!_trim(model.value.username || "") && !!model.value.role)
224 // const detailsStepValid = computed(() => !!model.value.customerCode && !!model.value.firstName && !!model.value.lastName)
225
226 const passwordSchema = new PasswordValidator()
@@ -282,6 +302,14 @@ const rules: FormRules = {
302 message: "The Username is already used",
303 trigger: ["blur", "input"]
304 }
305 + ],
306 + role: [
307 + {
308 + required: true,
309 + trigger: ["blur"],
310 + type: "number",
311 + message: "Role is required"
312 + }
313 ]
314 /*
315 customerCode: [
@@ -313,7 +341,8 @@ function getModel(): ModelType {
341 email: null,
342 password: null,
343 confirmPassword: null,
316 - username: null
344 + username: null,
345 + role: null
346 /*
347 customerCode: null,
348 firstName: null,
@@ -332,6 +361,7 @@ function reset(step?: number) {
361 break
362 case 2:
363 model.value.username = null
364 + model.value.role = null
365 break
366 default:
367 model.value = getModel()
@@ -350,7 +380,7 @@ function signUp(e: Event) {
380 password: model.value.password || "",
381 email: _trim(model.value.email || ""),
382 username: _trim(model.value.username || ""),
353 - role_id: 1
383 + role_id: model.value.role || 1
384 /*
385 customerCode: model.value.customerCode,
386 usersFirstName: model.value.firstName,
frontend/src/components/incidentManagement/cases/CaseComment.vue new
+216
@@ -0,0 +1,216 @@
1 +<template>
2 + <div class="case-comment-item flex gap-3" :class="{ embedded }">
3 + <div v-if="userPic" class="user-pic">
4 + <n-avatar round :size="32" :src="userPic" />
5 + </div>
6 + <div class="comment flex grow flex-col gap-1 overflow-hidden">
7 + <div class="user flex items-center gap-3">
8 + <div class="user-name">
9 + {{ comment.user_name }}
10 + </div>
11 + <div class="comment-time">
12 + {{ formatDate(comment.created_at, dFormats.datetime) }}
13 + </div>
14 + </div>
15 + <div v-if="mode === 'view'" class="comment-message">
16 + <Markdown :source="comment.comment" />
17 + </div>
18 +
19 + <n-input
20 + v-if="mode === 'edit'"
21 + v-model:value="commentModel"
22 + type="textarea"
23 + :disabled="saving"
24 + placeholder="Insert the updated comment"
25 + size="large"
26 + :autosize="{
27 + minRows: 3,
28 + maxRows: 18
29 + }"
30 + />
31 +
32 + <div class="comment-actions flex justify-end gap-1">
33 + <template v-if="mode === 'view'">
34 + <n-button size="tiny" secondary :disabled="canceling" @click="editComment()">
35 + <template #icon>
36 + <Icon :name="EditIcon" :size="12"></Icon>
37 + </template>
38 + <span>Edit</span>
39 + </n-button>
40 + <n-popconfirm to="body" @positive-click="deleteCaseComment()">
41 + <template #trigger>
42 + <n-button size="tiny" secondary type="error" :loading="canceling">
43 + <template #icon>
44 + <Icon :name="DeleteIcon" :size="12"></Icon>
45 + </template>
46 + <span>Delete</span>
47 + </n-button>
48 + </template>
49 + Are you sure you want to delete the comment?
50 + </n-popconfirm>
51 + </template>
52 + <template v-if="mode === 'edit'">
53 + <n-button size="tiny" secondary :disabled="saving" @click="setMode('view')">
54 + <template #icon>
55 + <Icon :name="ArrowLeftIcon" :size="12"></Icon>
56 + </template>
57 + <span>Cancel</span>
58 + </n-button>
59 +
60 + <n-button
61 + size="tiny"
62 + secondary
63 + type="success"
64 + :loading="saving"
65 + :disabled="!commentModel"
66 + @click="updateCaseComment()"
67 + >
68 + <template #icon>
69 + <Icon :name="SaveIcon" :size="13"></Icon>
70 + </template>
71 + <span>Save</span>
72 + </n-button>
73 + </template>
74 + </div>
75 + </div>
76 + </div>
77 +</template>
78 +
79 +<script setup lang="ts">
80 +import type { CaseComment } from "@/types/incidentManagement/cases.d"
81 +import { NAvatar, NButton, NInput, NPopconfirm, useMessage } from "naive-ui"
82 +import { defineAsyncComponent, onBeforeMount, ref, toRefs } from "vue"
83 +import Api from "@/api"
84 +import Icon from "@/components/common/Icon.vue"
85 +import { useSettingsStore } from "@/stores/settings"
86 +import { formatDate, getAvatar, getNameInitials } from "@/utils"
87 +
88 +type Mode = "view" | "edit"
89 +
90 +const props = defineProps<{ comment: CaseComment; embedded?: boolean }>()
91 +
92 +const emit = defineEmits<{
93 + (e: "deleted"): void
94 + (e: "updated", value: CaseComment): void
95 +}>()
96 +
97 +const Markdown = defineAsyncComponent(() => import("@/components/common/Markdown.vue"))
98 +
99 +const { comment, embedded } = toRefs(props)
100 +
101 +const ArrowLeftIcon = "carbon:arrow-left"
102 +const SaveIcon = "carbon:save"
103 +const EditIcon = "uil:edit-alt"
104 +const DeleteIcon = "ph:trash"
105 +const mode = ref<Mode>("view")
106 +const canceling = ref(false)
107 +const saving = ref(false)
108 +const dFormats = useSettingsStore().dateFormat
109 +const userPic = ref("")
110 +const commentModel = ref(comment.value.comment)
111 +const message = useMessage()
112 +
113 +function setMode(newMode: Mode) {
114 + mode.value = newMode
115 +}
116 +
117 +function editComment() {
118 + setMode("edit")
119 + commentModel.value = comment.value.comment
120 +}
121 +
122 +function updateCaseComment() {
123 + saving.value = true
124 +
125 + Api.incidentManagement.cases
126 + .updateCaseComment({
127 + case_id: comment.value.case_id,
128 + comment_id: comment.value.id,
129 + comment: commentModel.value,
130 + created_at: new Date(),
131 + user_name: comment.value.user_name
132 + })
133 + .then(res => {
134 + if (res.data.success) {
135 + message.success(res.data?.message || "Comment updated successfully")
136 + setMode("view")
137 + emit("updated", res.data.comment)
138 + } else {
139 + message.warning(res.data?.message || "An error occurred. Please try again later.")
140 + }
141 + })
142 + .catch(err => {
143 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
144 + })
145 + .finally(() => {
146 + saving.value = false
147 + })
148 +}
149 +
150 +function deleteCaseComment() {
151 + canceling.value = true
152 +
153 + Api.incidentManagement.cases
154 + .deleteCaseComment(comment.value.id)
155 + .then(res => {
156 + if (res.data.success) {
157 + message.success(res.data?.message || "Comment deleted successfully")
158 + emit("deleted")
159 + } else {
160 + message.warning(res.data?.message || "An error occurred. Please try again later.")
161 + }
162 + })
163 + .catch(err => {
164 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
165 + })
166 + .finally(() => {
167 + canceling.value = false
168 + })
169 +}
170 +
171 +onBeforeMount(() => {
172 + const initials = getNameInitials(comment.value.user_name)
173 + userPic.value = getAvatar({ seed: initials, text: initials, size: 64 })
174 +})
175 +</script>
176 +
177 +<style lang="scss" scoped>
178 +.case-comment-item {
179 + width: 100%;
180 +
181 + .user-pic {
182 + padding-top: 2px;
183 + }
184 +
185 + .comment {
186 + .user {
187 + margin-left: 2px;
188 +
189 + .user-name {
190 + font-weight: 600;
191 + }
192 +
193 + .comment-time {
194 + font-size: 11px;
195 + color: var(--fg-secondary-color);
196 + font-family: var(--font-family-mono);
197 + }
198 + }
199 +
200 + .comment-message {
201 + border-radius: var(--border-radius);
202 + background-color: var(--bg-default-color);
203 + border: 1px solid var(--border-color);
204 + padding: 6px 10px;
205 + }
206 + }
207 +
208 + &.embedded {
209 + .comment {
210 + .comment-message {
211 + background-color: var(--bg-secondary-color);
212 + }
213 + }
214 + }
215 +}
216 +</style>
frontend/src/components/incidentManagement/cases/CaseCommentsList.vue new
+120
@@ -0,0 +1,120 @@
1 +<template>
2 + <div class="flex flex-col gap-6">
3 + <template v-if="commentsList.length">
4 + <CaseCommentItem
5 + v-for="comment of commentsList"
6 + :key="comment.id"
7 + :comment
8 + embedded
9 + @deleted="removeComment(comment)"
10 + @updated="updateComment($event)"
11 + />
12 + </template>
13 + <template v-else>
14 + <n-empty description="No comments found" class="h-48 justify-center" />
15 + </template>
16 + <n-spin :show="submitting">
17 + <div class="comment-form mt-6 flex flex-col gap-3">
18 + <div class="editor-box">
19 + <n-input
20 + v-model:value="commentMessage"
21 + placeholder="Write a new comment..."
22 + type="textarea"
23 + :autosize="{
24 + minRows: 3,
25 + maxRows: 10
26 + }"
27 + />
28 + </div>
29 + <div class="tool-box flex justify-end gap-2">
30 + <n-button secondary :disabled="submitting" @click="reset()">Reset</n-button>
31 + <n-button
32 + type="primary"
33 + :disabled="!trimmedValue || submitting"
34 + :loading="submitting"
35 + @click="submit()"
36 + >
37 + <template #icon>
38 + <Icon :name="CommentsIcon" />
39 + </template>
40 + Send comment
41 + </n-button>
42 + </div>
43 + </div>
44 + </n-spin>
45 + </div>
46 +</template>
47 +
48 +<script setup lang="ts">
49 +import type { CaseComment } from "@/types/incidentManagement/cases.d"
50 +import _trim from "lodash/trim"
51 +import { NButton, NEmpty, NInput, NSpin, useMessage } from "naive-ui"
52 +import { computed, ref, toRefs } from "vue"
53 +import Api from "@/api"
54 +import Icon from "@/components/common/Icon.vue"
55 +import { useAuthStore } from "@/stores/auth"
56 +import CaseCommentItem from "./CaseComment.vue"
57 +
58 +const props = defineProps<{ comments: CaseComment[]; caseId: number }>()
59 +const emit = defineEmits<{
60 + (e: "updated", value: CaseComment[]): void
61 +}>()
62 +
63 +const { comments, caseId } = toRefs(props)
64 +
65 +const CommentsIcon = "carbon:chat"
66 +const commentsList = ref<CaseComment[]>(comments.value)
67 +const commentMessage = ref<string | null>(null)
68 +const submitting = ref(false)
69 +const message = useMessage()
70 +const authStore = useAuthStore()
71 +const trimmedValue = computed(() => _trim(commentMessage.value || ""))
72 +
73 +function reset() {
74 + commentMessage.value = ""
75 +}
76 +
77 +function updateComment(newComment: CaseComment) {
78 + const comment = commentsList.value.find(o => o.id === newComment.id)
79 + if (comment) {
80 + comment.created_at = newComment.created_at
81 + comment.comment = newComment.comment
82 + }
83 +}
84 +
85 +function removeComment(comment: CaseComment) {
86 + commentsList.value.splice(
87 + commentsList.value.findIndex(o => o.id === comment.id),
88 + 1
89 + )
90 +}
91 +
92 +function submit() {
93 + if (trimmedValue.value) {
94 + submitting.value = true
95 +
96 + Api.incidentManagement.cases
97 + .newCaseComment({
98 + case_id: caseId.value,
99 + comment: trimmedValue.value,
100 + created_at: new Date(),
101 + user_name: authStore.userName
102 + })
103 + .then(res => {
104 + if (res.data.success) {
105 + reset()
106 + commentsList.value.push(res.data.comment)
107 + emit("updated", commentsList.value)
108 + } else {
109 + message.warning(res.data?.message || "An error occurred. Please try again later.")
110 + }
111 + })
112 + .catch(err => {
113 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
114 + })
115 + .finally(() => {
116 + submitting.value = false
117 + })
118 + }
119 +}
120 +</script>
frontend/src/components/incidentManagement/cases/CaseDetails.vue
+18 -1
@@ -30,6 +30,15 @@
30 </template>
31 </div>
32 </n-tab-pane>
33 + <n-tab-pane name="Comments" tab="Comments" display-directive="show:lazy">
34 + <div class="p-7 pt-4">
35 + <CaseCommentsList
36 + :comments="caseEntity.comments || []"
37 + :case-id="caseEntity.id"
38 + @updated="updateCaseComments($event)"
39 + />
40 + </div>
41 + </n-tab-pane>
42 <n-tab-pane name="Data Store" tab="Data Store" display-directive="show:lazy">
43 <div class="p-7 pt-4">
44 <CaseDataStore :case-id="caseEntity.id" />
@@ -40,7 +49,7 @@
49 </template>
50
51 <script setup lang="ts">
43 -import type { Case } from "@/types/incidentManagement/cases.d"
52 +import type { Case, CaseComment } from "@/types/incidentManagement/cases.d"
53 import _clone from "lodash/cloneDeep"
54 import { NEmpty, NSpin, NTabPane, NTabs, useMessage } from "naive-ui"
55 import { defineAsyncComponent, onBeforeMount, ref, toRefs } from "vue"
@@ -56,6 +65,7 @@ const emit = defineEmits<{
65 }>()
66 const CaseOverview = defineAsyncComponent(() => import("./CaseOverview.vue"))
67 const CaseDataStore = defineAsyncComponent(() => import("./CaseDataStore.vue"))
68 +const CaseCommentsList = defineAsyncComponent(() => import("./CaseCommentsList.vue"))
69 const AlertItem = defineAsyncComponent(() => import("../alerts/AlertItem.vue"))
70
71 const { caseData, caseId } = toRefs(props)
@@ -69,6 +79,13 @@ function updateCase(updatedCase: Case) {
79 emit("updated", updatedCase)
80 }
81
82 +function updateCaseComments(updatedComments: CaseComment[]) {
83 + if (caseEntity.value) {
84 + caseEntity.value.comments = updatedComments
85 + emit("updated", caseEntity.value)
86 + }
87 +}
88 +
89 function getCase(caseId: number) {
90 loading.value = true
91
frontend/src/components/users/AssignCustomer.vue
+33 -48
@@ -1,9 +1,5 @@
1 <template>
2 - <n-button
3 - quaternary
4 - class="!w-full !justify-start"
5 - @click="showModal = true"
6 - >
2 + <n-button quaternary class="!w-full !justify-start" @click="showModal = true">
3 <template #icon>
4 <Icon :name="CustomerIcon" :size="14"></Icon>
5 </template>
@@ -22,49 +18,37 @@
18 >
19 <div class="flex flex-col gap-4">
20 <div>
25 - <strong>User:</strong> {{ user?.username }}
21 + <strong>User:</strong>
22 + {{ user?.username }}
23 </div>
24
25 <n-form ref="formRef" :model="formModel">
26 <n-form-item label="Select Customers">
30 - <n-select
31 - v-model:value="formModel.customerCodes"
32 - :options="customerOptions"
33 - placeholder="Choose customers"
34 - multiple
35 - :loading="loadingCustomers"
36 - />
37 - </n-form-item>
38 -
39 - <n-form-item label="Current Access">
40 - <div v-if="currentAccess.length > 0" class="flex flex-wrap gap-2">
41 - <n-tag
42 - v-for="customerCode in currentAccess"
43 - :key="customerCode"
44 - type="info"
45 - size="small"
46 - >
47 - {{ customerCode }}
48 - </n-tag>
49 - </div>
50 - <div v-else class="text-gray-500">
51 - No customer access assigned
52 - </div>
53 - </n-form-item>
54 - </n-form>
55 -
56 - <div class="flex justify-end gap-3">
57 - <n-button @click="showModal = false">Cancel</n-button>
58 - <n-button
59 - type="primary"
60 - :loading="loading"
61 - @click="handleAssignCustomers"
62 - >
63 - Assign Customers
64 - </n-button>
65 - </div>
27 + <n-select
28 + v-model:value="formModel.customerCodes"
29 + :options="customerOptions"
30 + placeholder="Choose customers"
31 + multiple
32 + :loading="loadingCustomers"
33 + />
34 + </n-form-item>
35 +
36 + <n-form-item label="Current Access">
37 + <div v-if="currentAccess.length > 0" class="flex flex-wrap gap-2">
38 + <n-tag v-for="customerCode in currentAccess" :key="customerCode" type="info" size="small">
39 + {{ customerCode }}
40 + </n-tag>
41 + </div>
42 + <div v-else class="text-gray-500">No customer access assigned</div>
43 + </n-form-item>
44 + </n-form>
45 +
46 + <div class="flex justify-end gap-3">
47 + <n-button @click="showModal = false">Cancel</n-button>
48 + <n-button type="primary" :loading="loading" @click="handleAssignCustomers">Assign Customers</n-button>
49 </div>
67 - </n-modal>
50 + </div>
51 + </n-modal>
52 </template>
53
54 <script setup lang="ts">
@@ -128,7 +112,7 @@ async function loadCurrentAccess() {
112 formModel.value.customerCodes = [...currentAccess.value]
113 }
114 } catch (error) {
131 - console.error('Error loading customer access:', error)
115 + console.error("Error loading customer access:", error)
116 message.error("Failed to load current customer access")
117 }
118 }
@@ -138,8 +122,9 @@ function handleAssignCustomers() {
122
123 loading.value = true
124
141 - Api.auth.assignCustomerAccess(props.user.id, formModel.value.customerCodes)
142 - .then((res) => {
125 + Api.auth
126 + .assignCustomerAccess(props.user.id, formModel.value.customerCodes)
127 + .then(res => {
128 if (res.data.success) {
129 message.success(res.data.message || "Customer access assigned successfully")
130 showModal.value = false
@@ -148,7 +133,7 @@ function handleAssignCustomers() {
133 message.error(res.data.message || "Failed to assign customer access")
134 }
135 })
151 - .catch((err) => {
136 + .catch(err => {
137 message.error(err.response?.data?.message || "Failed to assign customer access")
138 })
139 .finally(() => {
@@ -156,7 +141,7 @@ function handleAssignCustomers() {
141 })
142 }
143
159 -watch(showModal, (newVal) => {
144 +watch(showModal, newVal => {
145 if (newVal) {
146 loadCustomers()
147 loadCurrentAccess()
frontend/src/components/users/AssignRole.vue
+21 -29
@@ -1,9 +1,5 @@
1 <template>
2 - <n-button
3 - quaternary
4 - class="!w-full !justify-start"
5 - @click="showModal = true"
6 - >
2 + <n-button quaternary class="!w-full !justify-start" @click="showModal = true">
3 <template #icon>
4 <Icon :name="RoleIcon" :size="14"></Icon>
5 </template>
@@ -22,33 +18,29 @@
18 >
19 <div class="flex flex-col gap-4">
20 <div>
25 - <strong>User:</strong> {{ user?.username }}
21 + <strong>User:</strong>
22 + {{ user?.username }}
23 </div>
24
25 <n-form ref="formRef" :model="formModel" :rules="rules">
26 <n-form-item path="role" label="Select Role">
30 - <n-select
31 - v-model:value="formModel.role"
32 - :options="roleOptions"
33 - placeholder="Choose a role"
34 - :loading="loading"
35 - />
36 - </n-form-item>
37 - </n-form>
38 -
39 - <div class="flex justify-end gap-3">
40 - <n-button @click="showModal = false">Cancel</n-button>
41 - <n-button
42 - type="primary"
27 + <n-select
28 + v-model:value="formModel.role"
29 + :options="roleOptions"
30 + placeholder="Choose a role"
31 :loading="loading"
44 - :disabled="!formModel.role"
45 - @click="handleAssignRole"
46 - >
47 - Assign Role
48 - </n-button>
49 - </div>
32 + />
33 + </n-form-item>
34 + </n-form>
35 +
36 + <div class="flex justify-end gap-3">
37 + <n-button @click="showModal = false">Cancel</n-button>
38 + <n-button type="primary" :loading="loading" :disabled="!formModel.role" @click="handleAssignRole">
39 + Assign Role
40 + </n-button>
41 </div>
51 - </n-modal>
42 + </div>
43 + </n-modal>
44 </template>
45
46 <script setup lang="ts">
@@ -95,12 +87,12 @@ const rules = {
87 function handleAssignRole() {
88 if (!props.user || !formModel.value.role) return
89
98 - formRef.value?.validate(async (errors) => {
99 - if (!errors) {
90 + formRef.value?.validate(async errors => {
91 + if (!errors && props.user?.id && formModel.value.role) {
92 loading.value = true
93
94 try {
103 - const res = await Api.auth.assignRole(props.user!.id, formModel.value.role!)
95 + const res = await Api.auth.assignRole(props.user.id, formModel.value.role)
96 if (res.data.success) {
97 message.success(res.data.message || "Role assigned successfully")
98 showModal.value = false
frontend/src/components/users/UsersList.vue
+23 -22
@@ -16,7 +16,7 @@
16 </div>
17
18 <n-spin :show="loading" content-class="min-h-32">
19 - <n-scrollbar x-scrollable style="width: 100%">
19 + <n-scrollbar x-scrollable class="w-full">
20 <n-table :bordered="false" class="min-w-max">
21 <thead>
22 <tr>
@@ -24,7 +24,7 @@
24 <th>Username</th>
25 <th>Email</th>
26 <th>Role</th>
27 - <th style="max-width: 300px"></th>
27 + <th class="max-w-75"></th>
28 </tr>
29 </thead>
30 <tbody>
@@ -42,15 +42,14 @@
42 </td>
43 <td>
44 <n-tag :type="getRoleTagType(user.role_name)" size="small">
45 - {{ user.role_name || 'No Role' }}
45 + {{ user.role_name || "No Role" }}
46 </n-tag>
47 </td>
48 - <td style="max-width: 300px">
48 + <td class="max-w-75">
49 <div v-if="isAdmin" class="flex justify-end">
50 <n-dropdown
51 trigger="click"
52 :options
53 - to="body"
53 display-directive="show"
54 :keyboard="false"
55 @click="selectedUser = user"
@@ -118,16 +117,16 @@ const emailList = computed(() => usersList.value.map(user => user.email))
117
118 function getRoleTagType(roleName: string | null | undefined) {
119 switch (roleName?.toLowerCase()) {
121 - case 'admin':
122 - return 'error'
123 - case 'analyst':
124 - return 'warning'
125 - case 'scheduler':
126 - return 'info'
127 - case 'customer_user':
128 - return 'success'
120 + case "admin":
121 + return "error"
122 + case "analyst":
123 + return "warning"
124 + case "scheduler":
125 + return "info"
126 + case "customer_user":
127 + return "success"
128 default:
130 - return 'default'
129 + return "default"
130 }
131 }
132
@@ -135,18 +134,20 @@ const options = [
134 {
135 key: "AssignRole",
136 type: "render",
138 - render: () => h(AssignRole, {
139 - user: selectedUser.value || undefined,
140 - onSuccess: getUsers
141 - })
137 + render: () =>
138 + h(AssignRole, {
139 + user: selectedUser.value || undefined,
140 + onSuccess: getUsers
141 + })
142 },
143 {
144 key: "AssignCustomer",
145 type: "render",
146 - render: () => h(AssignCustomer, {
147 - user: selectedUser.value || undefined,
148 - onSuccess: getUsers
149 - })
146 + render: () =>
147 + h(AssignCustomer, {
148 + user: selectedUser.value || undefined,
149 + onSuccess: getUsers
150 + })
151 },
152 {
153 key: "ChangePassword",
frontend/src/components/webVulnerabilityAssessment/ReportsItem.vue
+1 -1
@@ -112,7 +112,7 @@ function checkEvent(event: PointerEvent) {
112
113 function pageBack() {
114 reportNavigation.value.pop()
115 - currentReportPage.value = reportNavigation.value.at(-1) || "index.md"
115 + currentReportPage.value = reportNavigation.value[reportNavigation.value.length - 1] || "index.md"
116 }
117
118 function navigationReset() {
frontend/src/types/auth.d.ts
+3 -1
@@ -12,7 +12,9 @@ export enum RouteRole {
12 export enum AuthUserRole {
13 Unknown = 0,
14 Admin = 1,
15 - Analyst = 2
15 + Analyst = 2,
16 + Scheduler = 3,
17 + CustomerUser = 4
18 }
19
20 export type RouteMetaAuthRole = AuthUserRole | RouteRole
frontend/src/types/incidentManagement/cases.d.ts
+9
@@ -10,6 +10,15 @@ export interface Case {
10 customer_code: null | string
11 notification_invoked_number?: number
12 alerts: Alert[]
13 + comments: CaseComment[]
14 +}
15 +
16 +export interface CaseComment {
17 + id: number
18 + case_id: number
19 + comment: string
20 + created_at: Date
21 + user_name: string
22 }
23
24 export type CaseStatus = AlertStatus