@cryptotaxi247 / CoPilot / commits / 1a7eba0c

Customer portal (#515)

* Add UserCustomerAccess model and update User relationships * Implement customer access management for users - Add UserCustomerAccess model to manage user access to customers. - Create routes for assigning and retrieving customer access for users. - Introduce middleware to handle customer access validation. - Update existing routes to incorporate customer access filtering. * Enhance alert listing functionality with user-specific logging and improve case loading in alert queries * Add customer access validation to get alert by ID endpoint * Implement customer access filtering for alert listing and management endpoints * Implement customer access validation for case operations and enhance case retrieval with detailed alert information * Enhance case update endpoints to re-fetch full case data after status, assigned user, or customer code updates * Enhance user management functionality by adding role and customer access assignment features, updating user retrieval to include role information, and improving API responses for user access. * Refactor AssignCustomer and AssignRole components to improve button styling and modal structure for better user experience * Remove commented-out SIGMA menu item from Navbar for cleaner code * Implement customer access filtering for agent retrieval and management endpoints * customer portal frontend init * Refactor login handling to use environment variable for API URL and update request format to use URLSearchParams Add OverviewPage component with dashboard layout and data fetching for alerts and cases Update router to include OverviewPage and new routes for alerts and cases Enhance TypeScript configuration to include additional libraries * Add Alerts and Cases pages with filtering, pagination, and detail views - Implemented AlertsPage.vue with functionality to display security alerts, including total counts, filtering by status, source, and asset, and pagination. - Added loading and error handling states for alerts. - Created a modal for viewing detailed information about selected alerts. - Implemented CasesPage.vue to manage security cases, including total counts, filtering by status and assignee, and pagination. - Added loading and error handling states for cases. - Created a modal for viewing detailed information about selected cases. * Refactor Alert interfaces to enhance structure and add support for assets, tags, linked cases, IoCs, and comments * Fix formatting issues in AlertsPage.vue for improved readability * Enhance comments section in AlertsPage.vue with add comment functionality and no comments message * Enhance comments section in AlertsPage.vue to display no comments message and improve comment layout * Implement comment creation, editing, and deletion endpoints with customer access checks; update CommentCreate schema to allow optional created_at timestamp; modify AlertsPage.vue to integrate API call for adding comments. * Add case files management to CasesPage.vue; implement loading and downloading functionality for case files * Implement file upload functionality in CasesPage.vue; add upload form and handle file selection and submission * Enhance alert details display in CasesPage.vue; add alert details modal with comprehensive information and improved layout for alerts section * Fix case status handling in CasesPage.vue; ensure case status comparisons are case-insensitive and handle potential null values * Update getCasesByStatus method to accept string status; convert filter status to uppercase for backend compatibility in CasesPage.vue * feat: add Agents API and AgentsPage component for managing security agents * fix: ensure proper formatting and consistency in AgentsPage.vue and agents.ts --------- Co-authored-by: Davide Di Modica <webmaster.ddm@gmail.com>

taylor_socfortress committed Sep 19, 2025 at 07:38 UTC 1a7eba0c1a4a83b0350c54cc871e4f1be352ea7e
52 files changed +12908 -182
backend/alembic/versions/d8f9e9ea5502_add_customer_access_to_user.py new
+38
@@ -0,0 +1,38 @@
1 +"""Add customer access to User
2 +
3 +Revision ID: d8f9e9ea5502
4 +Revises: 7b2bbee2f3e8
5 +Create Date: 2025-09-15 11:20:49.491950
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 = 'd8f9e9ea5502'
16 +down_revision: Union[str, None] = '7b2bbee2f3e8'
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('user_customer_access',
24 + sa.Column('id', sa.Integer(), nullable=False),
25 + sa.Column('user_id', sa.Integer(), nullable=False),
26 + sa.Column('customer_code', sa.String(length=255), nullable=False),
27 + sa.Column('created_at', sa.DateTime(), nullable=False),
28 + sa.ForeignKeyConstraint(['customer_code'], ['customers.customer_code'], ),
29 + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
30 + sa.PrimaryKeyConstraint('id')
31 + )
32 + # ### end Alembic commands ###
33 +
34 +
35 +def downgrade() -> None:
36 + # ### commands auto generated by Alembic - please adjust! ###
37 + op.drop_table('user_customer_access')
38 + # ### end Alembic commands ###
backend/app/agents/routes/agents.py
+266 -36
@@ -44,6 +44,7 @@ from app.agents.wazuh.services.vulnerabilities import sync_agent_vulnerabilities
44
45 # App specific imports
46 from app.auth.routes.auth import AuthHandler
47 +from app.auth.models.users import User
48 from app.connectors.wazuh_manager.utils.universal import send_get_request
49 from app.db.db_session import get_db
50
@@ -52,6 +53,7 @@ from app.db.db_session import get_db
53 from app.db.universal_models import Agents
54 from app.incidents.schema.db_operations import CaseOutResponse
55 from app.incidents.services.db_operations import list_cases_by_asset_name
56 +from app.middleware.customer_access import customer_access_handler
57 from app.threat_intel.schema.epss import EpssThreatIntelRequest
58 from app.threat_intel.services.epss import collect_epss_score
59
@@ -154,11 +156,15 @@ async def delete_agent_from_database(db: AsyncSession, agent_id: str):
156 "",
157 response_model=AgentsResponse,
158 description="Get all agents currently synced to the database",
157 - dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
159 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
160 )
159 -async def get_agents(db: AsyncSession = Depends(get_db)) -> AgentsResponse:
161 +async def get_agents(
162 + current_user: User = Depends(AuthHandler().get_current_user),
163 + db: AsyncSession = Depends(get_db)
164 +) -> AgentsResponse:
165 """
166 Retrieve all agents currently synced to the database.
167 + Results are filtered based on user's customer access permissions.
168
169 Returns:
170 AgentsResponse: The response containing the list of agents, success status, and message.
@@ -168,7 +174,13 @@ async def get_agents(db: AsyncSession = Depends(get_db)) -> AgentsResponse:
174 """
175 logger.info("Fetching all agents")
176 try:
171 - result = await db.execute(select(Agents))
177 + # Apply customer access filtering
178 + base_query = select(Agents)
179 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
180 + current_user, db, base_query, Agents.customer_code
181 + )
182 +
183 + result = await db.execute(filtered_query)
184 agents = result.scalars().all()
185 return AgentsResponse(
186 agents=agents,
@@ -232,17 +244,20 @@ async def get_customer_agents_for_dashboard(
244 "/{agent_id}",
245 response_model=AgentsResponse,
246 description="Get agent by agent_id",
235 - dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
247 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
248 )
249 async def get_agent(
250 agent_id: str,
251 + current_user: User = Depends(AuthHandler().get_current_user),
252 db: AsyncSession = Depends(get_db),
253 ) -> AgentsResponse:
254 """
255 Retrieve an agent by agent_id.
256 + Results are filtered based on user's customer access permissions.
257
258 Args:
259 agent_id (str): The ID of the agent to retrieve.
260 + current_user (User): The authenticated user.
261 db (AsyncSession, optional): The database session. Defaults to Depends(get_db).
262
263 Returns:
@@ -253,7 +268,13 @@ async def get_agent(
268 """
269 logger.info(f"Fetching agent with agent_id: {agent_id}")
270 try:
256 - result = await db.execute(select(Agents).filter(Agents.agent_id == agent_id))
271 + # Apply customer access filtering
272 + base_query = select(Agents).filter(Agents.agent_id == agent_id)
273 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
274 + current_user, db, base_query, Agents.customer_code
275 + )
276 +
277 + result = await db.execute(filtered_query)
278 agent = result.scalars().first()
279 if agent:
280 return AgentsResponse(
@@ -264,7 +285,7 @@ async def get_agent(
285 else:
286 raise HTTPException(
287 status_code=404,
267 - detail=f"Agent with agent_id {agent_id} not found",
288 + detail=f"Agent with agent_id {agent_id} not found or access denied",
289 )
290 except Exception as e:
291 logger.error(
@@ -280,17 +301,20 @@ async def get_agent(
301 "/hostname/{hostname}",
302 response_model=AgentsResponse,
303 description="Get agent by hostname",
283 - dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
304 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
305 )
306 async def get_agent_by_hostname(
307 hostname: str,
308 + current_user: User = Depends(AuthHandler().get_current_user),
309 db: AsyncSession = Depends(get_db),
310 ) -> AgentsResponse:
311 """
312 Retrieve an agent by its hostname.
313 + Results are filtered based on user's customer access permissions.
314
315 Args:
316 hostname (str): The hostname of the agent.
317 + current_user (User): The authenticated user.
318 db (AsyncSession, optional): The database session. Defaults to Depends(get_db).
319
320 Returns:
@@ -301,7 +325,13 @@ async def get_agent_by_hostname(
325 """
326 logger.info(f"Fetching agent with hostname: {hostname}")
327 try:
304 - result = await db.execute(select(Agents).filter(Agents.hostname == hostname))
328 + # Apply customer access filtering
329 + base_query = select(Agents).filter(Agents.hostname == hostname)
330 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
331 + current_user, db, base_query, Agents.customer_code
332 + )
333 +
334 + result = await db.execute(filtered_query)
335 agent = result.scalars().first()
336 if agent:
337 return AgentsResponse(
@@ -312,7 +342,7 @@ async def get_agent_by_hostname(
342 else:
343 raise HTTPException(
344 status_code=404,
315 - detail=f"Agent with hostname {hostname} not found",
345 + detail=f"Agent with hostname {hostname} not found or access denied",
346 )
347 except Exception as e:
348 logger.error(f"Failed to fetch agent: {e}")
@@ -357,17 +387,20 @@ async def sync_all_agents() -> SyncedAgentsResponse:
387 "/{agent_id}/critical",
388 response_model=AgentModifyResponse,
389 description="Mark agent as critical",
360 - dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
390 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
391 )
392 async def mark_agent_as_critical(
393 agent_id: str,
394 + current_user: User = Depends(AuthHandler().get_current_user),
395 session: AsyncSession = Depends(get_db),
396 ) -> AgentModifyResponse:
397 """
398 Marks the specified agent as critical.
399 + User must have access to the agent's customer.
400
401 Args:
402 agent_id (str): The ID of the agent to mark as critical.
403 + current_user (User): The authenticated user.
404 session (AsyncSession, optional): The database session. Defaults to Depends(get_db).
405
406 Returns:
@@ -375,16 +408,19 @@ async def mark_agent_as_critical(
408 """
409 logger.info(f"Marking agent {agent_id} as critical")
410 try:
378 - # Asynchronously fetch the agent by id
379 - result = await session.execute(
380 - select(Agents).filter(Agents.agent_id == agent_id),
411 + # Check customer access - first find the agent
412 + base_query = select(Agents).filter(Agents.agent_id == agent_id)
413 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
414 + current_user, session, base_query, Agents.customer_code
415 )
416 +
417 + result = await session.execute(filtered_query)
418 agent = result.scalars().first()
419
420 if not agent:
421 raise HTTPException(
422 status_code=404,
387 - detail=f"Agent with agent_id {agent_id} not found",
423 + detail=f"Agent with agent_id {agent_id} not found or access denied",
424 )
425
426 agent.critical_asset = True
@@ -406,17 +442,20 @@ async def mark_agent_as_critical(
442 "/{agent_id}/noncritical",
443 response_model=AgentModifyResponse,
444 description="Mark agent as not critical",
409 - dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
445 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
446 )
447 async def mark_agent_as_not_critical(
448 agent_id: str,
449 + current_user: User = Depends(AuthHandler().get_current_user),
450 session: AsyncSession = Depends(get_db),
451 ) -> AgentModifyResponse:
452 """
453 Marks the specified agent as not critical.
454 + User must have access to the agent's customer.
455
456 Args:
457 agent_id (str): The ID of the agent to mark as not critical.
458 + current_user (User): The authenticated user.
459 session (AsyncSession, optional): The database session. Defaults to Depends(get_db).
460
461 Returns:
@@ -427,15 +466,19 @@ async def mark_agent_as_not_critical(
466 """
467 logger.info(f"Marking agent {agent_id} as not critical")
468 try:
430 - result = await session.execute(
431 - select(Agents).filter(Agents.agent_id == agent_id),
469 + # Check customer access - first find the agent
470 + base_query = select(Agents).filter(Agents.agent_id == agent_id)
471 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
472 + current_user, session, base_query, Agents.customer_code
473 )
474 +
475 + result = await session.execute(filtered_query)
476 agent = result.scalars().first()
477
478 if not agent:
479 raise HTTPException(
480 status_code=404,
438 - detail=f"Agent with agent_id {agent_id} not found",
481 + detail=f"Agent with agent_id {agent_id} not found or access denied",
482 )
483
484 agent.critical_asset = False
@@ -461,13 +504,16 @@ async def mark_agent_as_not_critical(
504 )
505 async def upgrade_wazuh_agent_route(
506 agent_id: str,
507 + current_user: User = Depends(AuthHandler().get_current_user),
508 session: AsyncSession = Depends(get_db),
509 ) -> AgentWazuhUpgradeResponse:
510 """
511 Upgrade Wazuh agent.
512 + User must have access to the agent's customer.
513
514 Args:
515 agent_id (str): The ID of the agent to be upgraded.
516 + current_user (User): The authenticated user.
517 session (AsyncSession, optional): The database session. Defaults to Depends(get_db).
518
519 Returns:
@@ -475,10 +521,20 @@ async def upgrade_wazuh_agent_route(
521 """
522 logger.info(f"Upgrading Wazuh agent {agent_id}")
523 try:
478 - result = await session.execute(select(Agents).filter(Agents.agent_id == agent_id))
524 + # Check customer access - first find the agent
525 + base_query = select(Agents).filter(Agents.agent_id == agent_id)
526 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
527 + current_user, session, base_query, Agents.customer_code
528 + )
529 +
530 + result = await session.execute(filtered_query)
531 agent = result.scalars().first()
532 +
533 if not agent:
481 - raise HTTPException(status_code=404, detail=f"Agent with agent_id {agent_id} not found")
534 + raise HTTPException(
535 + status_code=404,
536 + detail=f"Agent with agent_id {agent_id} not found or access denied"
537 + )
538 return await upgrade_wazuh_agent(agent_id)
539 return AgentWazuhUpgradeResponse(
540 success=True,
@@ -496,22 +552,44 @@ async def upgrade_wazuh_agent_route(
552 "/{agent_id}/vulnerabilities/{vulnerability_severity}",
553 response_model=WazuhAgentVulnerabilitiesResponse,
554 description="Get agent vulnerabilities",
499 - dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
555 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
556 )
557 async def get_agent_vulnerabilities(
558 agent_id: str,
559 vulnerability_severity: VulnSeverity = Path(..., description="The severity of the vulnerabilities to fetch."),
560 + current_user: User = Depends(AuthHandler().get_current_user),
561 + session: AsyncSession = Depends(get_db),
562 ) -> WazuhAgentVulnerabilitiesResponse:
563 """
564 Fetches the vulnerabilities of a specific agent.
565 + User must have access to the agent's customer.
566
567 Args:
568 agent_id (str): The ID of the agent.
569 + vulnerability_severity: The severity level filter.
570 + current_user (User): The authenticated user.
571 + session (AsyncSession): The database session.
572
573 Returns:
574 WazuhAgentVulnerabilitiesResponse: The response containing the agent vulnerabilities.
575 """
576 logger.info(f"Fetching agent {agent_id} vulnerabilities")
577 +
578 + # Check customer access - first verify user has access to this agent
579 + base_query = select(Agents).filter(Agents.agent_id == agent_id)
580 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
581 + current_user, session, base_query, Agents.customer_code
582 + )
583 +
584 + result = await session.execute(filtered_query)
585 + agent = result.scalars().first()
586 +
587 + if not agent:
588 + raise HTTPException(
589 + status_code=404,
590 + detail=f"Agent with agent_id {agent_id} not found or access denied"
591 + )
592 +
593 wazuh_new = await check_wazuh_manager_version()
594 if wazuh_new is True:
595 logger.info("Wazuh Manager version is 4.8.0 or higher. Fetching vulnerabilities using new API")
@@ -522,19 +600,44 @@ async def get_agent_vulnerabilities(
600 @agents_router.get(
601 "/{agent_id}/csv/vulnerabilities/{vulnerability_severity}",
602 description="Get agent vulnerabilities as CSV",
525 - dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
603 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
604 )
527 -async def get_agent_vulnerabilities_csv(agent_id: str, vulnerability_severity: VulnSeverity = Path(...)) -> StreamingResponse:
605 +async def get_agent_vulnerabilities_csv(
606 + agent_id: str,
607 + vulnerability_severity: VulnSeverity = Path(...),
608 + current_user: User = Depends(AuthHandler().get_current_user),
609 + session: AsyncSession = Depends(get_db),
610 +) -> StreamingResponse:
611 """
612 Fetches the vulnerabilities of a specific agent and returns them as a CSV file.
613 + User must have access to the agent's customer.
614
615 Args:
616 agent_id (str): The ID of the agent.
617 + vulnerability_severity: The severity level filter.
618 + current_user (User): The authenticated user.
619 + session (AsyncSession): The database session.
620
621 Returns:
622 StreamingResponse: The response containing the agent vulnerabilities in CSV format.
623 """
624 logger.info(f"Fetching agent {agent_id} vulnerabilities as CSV")
625 +
626 + # Check customer access - first verify user has access to this agent
627 + base_query = select(Agents).filter(Agents.agent_id == agent_id)
628 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
629 + current_user, session, base_query, Agents.customer_code
630 + )
631 +
632 + result = await session.execute(filtered_query)
633 + agent = result.scalars().first()
634 +
635 + if not agent:
636 + raise HTTPException(
637 + status_code=404,
638 + detail=f"Agent with agent_id {agent_id} not found or access denied"
639 + )
640 +
641 wazuh_new = await check_wazuh_manager_version()
642 if wazuh_new is True:
643 logger.info("Wazuh Manager version is 4.8.0 or higher. Fetching vulnerabilities using new API")
@@ -601,19 +704,42 @@ async def get_agent_vulnerabilities_csv(agent_id: str, vulnerability_severity: V
704 "/{agent_id}/sca",
705 response_model=WazuhAgentScaResponse,
706 description="Get agent sca results",
604 - dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
707 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
708 )
606 -async def get_agent_sca(agent_id: str) -> WazuhAgentScaResponse:
709 +async def get_agent_sca(
710 + agent_id: str,
711 + current_user: User = Depends(AuthHandler().get_current_user),
712 + session: AsyncSession = Depends(get_db),
713 +) -> WazuhAgentScaResponse:
714 """
715 Fetches the sca results of a specific agent.
716 + User must have access to the agent's customer.
717
718 Args:
719 agent_id (str): The ID of the agent.
720 + current_user (User): The authenticated user.
721 + session (AsyncSession): The database session.
722
723 Returns:
724 WazuhAgentScaResponse: The response containing the agent sca.
725 """
726 logger.info(f"Fetching agent {agent_id} sca")
727 +
728 + # Check customer access - first verify user has access to this agent
729 + base_query = select(Agents).filter(Agents.agent_id == agent_id)
730 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
731 + current_user, session, base_query, Agents.customer_code
732 + )
733 +
734 + result = await session.execute(filtered_query)
735 + agent = result.scalars().first()
736 +
737 + if not agent:
738 + raise HTTPException(
739 + status_code=404,
740 + detail=f"Agent with agent_id {agent_id} not found or access denied"
741 + )
742 +
743 return await collect_agent_sca(agent_id)
744
745
@@ -621,38 +747,88 @@ async def get_agent_sca(agent_id: str) -> WazuhAgentScaResponse:
747 "/{agent_id}/sca/{policy_id}",
748 response_model=WazuhAgentScaPolicyResultsResponse,
749 description="Get agent sca results",
624 - dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
750 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
751 )
626 -async def get_agent_sca_policy_results(agent_id: str, policy_id: str) -> WazuhAgentScaPolicyResultsResponse:
752 +async def get_agent_sca_policy_results(
753 + agent_id: str,
754 + policy_id: str,
755 + current_user: User = Depends(AuthHandler().get_current_user),
756 + session: AsyncSession = Depends(get_db),
757 +) -> WazuhAgentScaPolicyResultsResponse:
758 """
759 Fetches the sca results of a specific agent.
760 + User must have access to the agent's customer.
761
762 Args:
763 agent_id (str): The ID of the agent.
764 + policy_id (str): The ID of the policy.
765 + current_user (User): The authenticated user.
766 + session (AsyncSession): The database session.
767
768 Returns:
769 WazuhAgentScaPolicyResultsResponse: The response containing the agent sca.
770 """
771 logger.info(f"Fetching agent {agent_id} sca policy results")
772 +
773 + # Check customer access - first verify user has access to this agent
774 + base_query = select(Agents).filter(Agents.agent_id == agent_id)
775 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
776 + current_user, session, base_query, Agents.customer_code
777 + )
778 +
779 + result = await session.execute(filtered_query)
780 + agent = result.scalars().first()
781 +
782 + if not agent:
783 + raise HTTPException(
784 + status_code=404,
785 + detail=f"Agent with agent_id {agent_id} not found or access denied"
786 + )
787 +
788 return await collect_agent_sca_policy_results(agent_id, policy_id)
789
790
791 @agents_router.get(
792 "/{agent_id}/csv/sca/{policy_id}",
793 description="Get agent sca results as CSV",
643 - dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
794 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
795 )
645 -async def get_agent_sca_policy_results_csv(agent_id: str, policy_id: str) -> StreamingResponse:
796 +async def get_agent_sca_policy_results_csv(
797 + agent_id: str,
798 + policy_id: str,
799 + current_user: User = Depends(AuthHandler().get_current_user),
800 + session: AsyncSession = Depends(get_db),
801 +) -> StreamingResponse:
802 """
803 Fetches the sca results of a specific agent and returns them as a CSV file.
804 + User must have access to the agent's customer.
805
806 Args:
807 agent_id (str): The ID of the agent.
808 + policy_id (str): The ID of the policy.
809 + current_user (User): The authenticated user.
810 + session (AsyncSession): The database session.
811
812 Returns:
813 StreamingResponse: The response containing the agent sca in CSV format.
814 """
815 logger.info(f"Fetching agent {agent_id} sca policy results as CSV")
816 +
817 + # Check customer access - first verify user has access to this agent
818 + base_query = select(Agents).filter(Agents.agent_id == agent_id)
819 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
820 + current_user, session, base_query, Agents.customer_code
821 + )
822 +
823 + result = await session.execute(filtered_query)
824 + agent = result.scalars().first()
825 +
826 + if not agent:
827 + raise HTTPException(
828 + status_code=404,
829 + detail=f"Agent with agent_id {agent_id} not found or access denied"
830 + )
831 +
832 sca_results = (await collect_agent_sca_policy_results(agent_id, policy_id)).sca_policy_results
833 # Create a CSV file
834 logger.info(f"Creating CSV file for agent {agent_id} with {len(sca_results)} sca policy results")
@@ -706,19 +882,42 @@ async def get_agent_sca_policy_results_csv(agent_id: str, policy_id: str) -> Str
882 "/{agent_hostname}/cases",
883 response_model=CaseOutResponse,
884 description="Get cases for agent",
709 - dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
885 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
886 )
711 -async def get_agent_soc_cases(agent_hostname: str, session: AsyncSession = Depends(get_db)):
887 +async def get_agent_soc_cases(
888 + agent_hostname: str,
889 + current_user: User = Depends(AuthHandler().get_current_user),
890 + session: AsyncSession = Depends(get_db)
891 +):
892 """
893 Fetches the SOC cases of a specific agent.
894 + User must have access to the agent's customer.
895
896 Args:
716 - agent_id (str): The ID of the agent.
897 + agent_hostname (str): The hostname of the agent.
898 + current_user (User): The authenticated user.
899 + session (AsyncSession): The database session.
900
901 Returns:
902 SocCasesResponse: The response containing the agent SOC cases.
903 """
904 logger.info(f"Fetching agent {agent_hostname} cases")
905 +
906 + # Check customer access - first verify user has access to this agent
907 + base_query = select(Agents).filter(Agents.hostname == agent_hostname)
908 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
909 + current_user, session, base_query, Agents.customer_code
910 + )
911 +
912 + result = await session.execute(filtered_query)
913 + agent = result.scalars().first()
914 +
915 + if not agent:
916 + raise HTTPException(
917 + status_code=404,
918 + detail=f"Agent with hostname {agent_hostname} not found or access denied"
919 + )
920 +
921 return CaseOutResponse(
922 cases=await list_cases_by_asset_name(asset_name=agent_hostname, db=session),
923 success=True,
@@ -778,14 +977,17 @@ async def get_outdated_velociraptor_agents(
977 async def update_agent(
978 agent_id: str,
979 velociraptor_id: str,
980 + current_user: User = Depends(AuthHandler().get_current_user),
981 session: AsyncSession = Depends(get_db),
982 ) -> AgentModifyResponse:
983 """
984 Updates an agent's velociraptor_id
985 + User must have access to the agent's customer.
986
987 Args:
988 agent_id (str): The ID of the agent to be updated.
989 velociraptor_id (str): The new velociraptor_id of the agent.
990 + current_user (User): The authenticated user.
991 session (AsyncSession, optional): The database session. Defaults to Depends(get_db).
992
993 Returns:
@@ -793,10 +995,21 @@ async def update_agent(
995 """
996 logger.info(f"Updating agent {agent_id} with Velociraptor ID: {velociraptor_id}")
997 try:
796 - result = await session.execute(select(Agents).filter(Agents.agent_id == agent_id))
998 + # Check customer access - first find the agent
999 + base_query = select(Agents).filter(Agents.agent_id == agent_id)
1000 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
1001 + current_user, session, base_query, Agents.customer_code
1002 + )
1003 +
1004 + result = await session.execute(filtered_query)
1005 agent = result.scalars().first()
1006 +
1007 if not agent:
799 - raise HTTPException(status_code=404, detail=f"Agent with agent_id {agent_id} not found")
1008 + raise HTTPException(
1009 + status_code=404,
1010 + detail=f"Agent with agent_id {agent_id} not found or access denied"
1011 + )
1012 +
1013 agent.velociraptor_id = velociraptor_id
1014 await session.commit()
1015 logger.info(f"Agent {agent_id} updated with Velociraptor ID: {velociraptor_id}")
@@ -805,8 +1018,6 @@ async def update_agent(
1018 message=f"Agent {agent_id} updated with Velociraptor ID: {velociraptor_id}",
1019 )
1020 except Exception as e:
808 - if not agent:
809 - raise HTTPException(status_code=404, detail=f"Agent with agent_id {agent_id} not found")
1021 logger.error(f"Failed to update agent {agent_id} with Velociraptor ID: {velociraptor_id}: {e}")
1022 raise HTTPException(
1023 status_code=500,
@@ -822,19 +1033,38 @@ async def update_agent(
1033 )
1034 async def delete_agent(
1035 agent_id: str,
1036 + current_user: User = Depends(AuthHandler().get_current_user),
1037 session: AsyncSession = Depends(get_db),
1038 ) -> AgentModifyResponse:
1039 """
1040 Delete an agent.
1041 + User must have access to the agent's customer.
1042
1043 Args:
1044 agent_id (str): The ID of the agent to be deleted.
1045 + current_user (User): The authenticated user.
1046 session (AsyncSession, optional): The database session. Defaults to Depends(get_db).
1047
1048 Returns:
1049 AgentModifyResponse: The response indicating the success or failure of the deletion.
1050 """
1051 logger.info(f"Deleting agent {agent_id}")
1052 +
1053 + # Check customer access - first find the agent to verify access
1054 + base_query = select(Agents).filter(Agents.agent_id == agent_id)
1055 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
1056 + current_user, session, base_query, Agents.customer_code
1057 + )
1058 +
1059 + result = await session.execute(filtered_query)
1060 + agent = result.scalars().first()
1061 +
1062 + if not agent:
1063 + raise HTTPException(
1064 + status_code=404,
1065 + detail=f"Agent with agent_id {agent_id} not found or access denied"
1066 + )
1067 +
1068 await delete_agent_wazuh(agent_id)
1069 client_id = await fetch_velociraptor_id(db=session, agent_id=agent_id)
1070 logger.info(f"Client ID: {client_id}")
backend/app/auth/models/users.py
+12 -1
@@ -3,7 +3,7 @@ import random
3 import re
4 import string
5 from enum import Enum
6 -from typing import Optional
6 +from typing import Optional, List
7
8 import bcrypt
9 from pydantic import BaseModel
@@ -21,6 +21,15 @@ class Role(SQLModel, table=True):
21
22 user: Optional["User"] = Relationship(back_populates="role")
23
24 +class UserCustomerAccess(SQLModel, table=True):
25 + __tablename__ = "user_customer_access"
26 + id: Optional[int] = Field(primary_key=True)
27 + user_id: int = Field(foreign_key="user.id")
28 + customer_code: str = Field(foreign_key="customers.customer_code")
29 + created_at: datetime.datetime = Field(default_factory=datetime.datetime.now)
30 +
31 + # Relationships
32 + user: "User" = Relationship(back_populates="customer_access")
33
34 class User(SQLModel, table=True):
35 id: Optional[int] = Field(primary_key=True)
@@ -32,6 +41,7 @@ class User(SQLModel, table=True):
41
42 smtp: "SMTP" = Relationship(back_populates="user")
43 role: Optional["Role"] = Relationship(back_populates="user")
44 + customer_access: List["UserCustomerAccess"] = Relationship(back_populates="user")
45
46
47 # Enum class for role_id 1,2
@@ -39,6 +49,7 @@ class RoleEnum(int, Enum):
49 admin = 1
50 analyst = 2
51 scheduler = 3
52 + customer_user = 4
53
54
55 class UserInput(SQLModel):
backend/app/auth/routes/auth.py
+74 -2
@@ -14,7 +14,7 @@ from app.auth.models.users import PasswordResetToken
14 from app.auth.models.users import User
15 from app.auth.models.users import UserInput
16 from app.auth.models.users import UserLogin
17 -from app.auth.schema.auth import Token
17 +from app.auth.schema.auth import Token, UpdateUserRoleRequest
18 from app.auth.schema.auth import UserLoginResponse
19 from app.auth.schema.auth import UserResponse
20 from app.auth.schema.user import UserBaseResponse
@@ -22,6 +22,7 @@ from app.auth.services.universal import delete_user
22 from app.auth.services.universal import find_user
23 from app.auth.services.universal import select_all_users
24 from app.auth.utils import AuthHandler
25 +from app.auth.models.users import RoleEnum
26 from app.db.db_session import get_db
27
28 ACCESS_TOKEN_EXPIRE_MINUTES = 1440
@@ -160,8 +161,21 @@ async def get_users(session: AsyncSession = Depends(get_db)):
161
162 """
163 users = await select_all_users()
164 +
165 + # Transform users to include role_name
166 + user_list = []
167 + for user in users:
168 + user_dict = {
169 + "id": user.id,
170 + "username": user.username,
171 + "email": user.email,
172 + "role_id": user.role_id,
173 + "role_name": user.role.name if user.role else None
174 + }
175 + user_list.append(user_dict)
176 +
177 return UserBaseResponse(
164 - users=users,
178 + users=user_list,
179 message="Users retrieved successfully",
180 success=True,
181 )
@@ -305,3 +319,61 @@ async def delete_user_by_username(
319 dict: A dictionary containing the message and success status.
320 """
321 return await delete_user(user_id, session)
322 +
323 +
324 +@auth_router.put(
325 + "/users/{user_id}/role/by-name",
326 + status_code=200,
327 + description="Update a user's role by role name",
328 + dependencies=[Security(AuthHandler().require_any_scope("admin"))],
329 +)
330 +async def update_user_role_by_name(
331 + user_id: int,
332 + request: UpdateUserRoleRequest,
333 + session: AsyncSession = Depends(get_db),
334 +):
335 + """
336 + Update a user's role by role name. Must be an admin.
337 +
338 + Args:
339 + user_id (int): The ID of the user to update.
340 + request (UpdateUserRoleRequest): The role update request containing role name.
341 + session (AsyncSession, optional): The database session. Defaults to Depends(get_db).
342 +
343 + Returns:
344 + dict: A dictionary containing the message and success status.
345 + """
346 + # First, find the user
347 + user = await session.get(User, user_id)
348 + if not user:
349 + raise HTTPException(status_code=404, detail="User not found")
350 +
351 + # Map role names to IDs
352 + role_mapping = {
353 + "admin": RoleEnum.admin.value,
354 + "analyst": RoleEnum.analyst.value,
355 + "scheduler": RoleEnum.scheduler.value,
356 + "customer_user": RoleEnum.customer_user.value,
357 + }
358 +
359 + role_name_lower = request.role_name.lower()
360 + if role_name_lower not in role_mapping:
361 + raise HTTPException(
362 + status_code=400,
363 + detail=f"Invalid role name. Valid roles are: {list(role_mapping.keys())}"
364 + )
365 +
366 + role_id = role_mapping[role_name_lower]
367 +
368 + # Update the user's role
369 + user.role_id = role_id
370 + session.add(user)
371 + await session.commit()
372 +
373 + return {
374 + "message": f"User {user.username} role updated successfully to {request.role_name}",
375 + "success": True,
376 + "user_id": user_id,
377 + "new_role_name": request.role_name,
378 + "new_role_id": role_id
379 + }
backend/app/auth/routes/customer_users.py new
+73
@@ -0,0 +1,73 @@
1 +# Create new file: app/auth/routes/customer_users.py
2 +from fastapi import APIRouter, Depends, HTTPException
3 +from typing import List
4 +from sqlalchemy.ext.asyncio import AsyncSession
5 +from sqlalchemy import select, delete
6 +
7 +from app.auth.utils import AuthHandler
8 +from app.auth.models.users import User, UserCustomerAccess, RoleEnum
9 +from app.db.db_session import get_db
10 +from app.middleware.customer_access import customer_access_handler
11 +
12 +customer_users_router = APIRouter()
13 +
14 +@customer_users_router.post("/users/{user_id}/customers")
15 +async def assign_customer_access(
16 + user_id: int,
17 + customer_codes: List[str],
18 + current_user: User = Depends(AuthHandler().require_any_scope("admin")),
19 + session: AsyncSession = Depends(get_db)
20 +):
21 + """Assign customer access to a user (admin only)"""
22 +
23 + # Remove existing access
24 + await session.execute(
25 + delete(UserCustomerAccess).where(UserCustomerAccess.user_id == user_id)
26 + )
27 +
28 + # Add new access
29 + for customer_code in customer_codes:
30 + access = UserCustomerAccess(
31 + user_id=user_id,
32 + customer_code=customer_code
33 + )
34 + session.add(access)
35 +
36 + await session.commit()
37 +
38 + return {
39 + "success": True,
40 + "message": f"Assigned {len(customer_codes)} customers to user {user_id}",
41 + "customer_codes": customer_codes
42 + }
43 +
44 +@customer_users_router.get("/users/{user_id}/customers")
45 +async def get_user_customer_access(
46 + user_id: int,
47 + current_user: User = Depends(AuthHandler().require_any_scope("admin")),
48 + session: AsyncSession = Depends(get_db)
49 +):
50 + """Get customer codes accessible to user (admin only)"""
51 +
52 + result = await session.execute(
53 + select(UserCustomerAccess.customer_code).where(UserCustomerAccess.user_id == user_id)
54 + )
55 + customer_codes = result.scalars().all()
56 +
57 + return {
58 + "success": True,
59 + "customer_codes": customer_codes
60 + }
61 +
62 +@customer_users_router.get("/me/customers")
63 +async def get_my_customer_access(
64 + current_user: User = Depends(AuthHandler().get_current_user),
65 + session: AsyncSession = Depends(get_db)
66 +):
67 + """Get current user's accessible customers"""
68 + customer_codes = await customer_access_handler.get_user_accessible_customers(current_user, session)
69 +
70 + return {
71 + "success": True,
72 + "customer_codes": customer_codes
73 + }
backend/app/auth/schema/auth.py
+3
@@ -19,3 +19,6 @@ class Token(BaseModel):
19
20 class TokenData(BaseModel):
21 username: str | None = None
22 +
23 +class UpdateUserRoleRequest(BaseModel):
24 + role_name: str
backend/app/auth/schema/user.py
+3
@@ -1,4 +1,5 @@
1 from typing import List
2 +from typing import Optional
3
4 from pydantic import BaseModel
5 from pydantic import EmailStr
@@ -8,6 +9,8 @@ class UserBase(BaseModel):
9 id: int
10 username: str
11 email: EmailStr
12 + role_id: Optional[int] = None
13 + role_name: Optional[str] = None
14
15
16 class UserBaseResponse(BaseModel):
backend/app/auth/services/universal.py
+21 -5
@@ -1,8 +1,10 @@
1 +from typing import List
2 from fastapi import HTTPException
3 from loguru import logger
4
5 # ! New with Async
6 from sqlalchemy.ext.asyncio import AsyncSession
7 +from sqlalchemy.orm import Session, selectinload
8 from sqlmodel import select
9
10 from app.auth.models.users import Password
@@ -13,17 +15,31 @@ from app.db.db_session import async_engine
15 passwords_in_memory = {}
16
17
18 +def select_all_users_sync(session: Session) -> List[User]:
19 + """
20 + Retrieves all Users from the database with their role information.
21 +
22 + Args:
23 + session: The database session to use for the query.
24 +
25 + Returns:
26 + List[User]: A list of all Users in the database with role information loaded.
27 + """
28 + result = session.exec(select(User).options(selectinload(User.role)))
29 + return result.all()
30 +
31 +
32 async def select_all_users():
33 """
18 - Retrieves all users from the database.
34 + Async version: Retrieves all Users from the database with their role information.
35
36 Returns:
21 - List[User]: A list of User objects representing all the users in the database.
37 + List[User]: A list of all Users in the database with role information loaded.
38 """
39 async with AsyncSession(async_engine) as session:
24 - statement = select(User)
25 - results = await session.execute(statement)
26 - return results.scalars().all()
40 + statement = select(User).options(selectinload(User.role))
41 + result = await session.execute(statement)
42 + return result.scalars().all()
43
44
45 async def find_user(name: str):
backend/app/auth/utils.py
+1
@@ -20,6 +20,7 @@ class AuthHandler:
20 "admin": "Admin users",
21 "analyst": "SOC Analysts",
22 "scheduler": "Scheduler for automated tasks",
23 + "customer_user": "Customer portal users",
24 },
25 )
26 pwd_context = CryptContext(schemes=["bcrypt"])
backend/app/db/db_populate.py
+1
@@ -246,6 +246,7 @@ async def add_roles_if_not_exist(session: AsyncSession) -> None:
246 {"name": "admin", "description": "Administrator"},
247 {"name": "analyst", "description": "SOC Analyst"},
248 {"name": "scheduler", "description": "Scheduler for automated tasks"},
249 + {"name": "customer_user", "description": "Customer user with limited access to their own data"},
250 ]
251
252 for role_data in role_list:
backend/app/incidents/routes/db_operations.py
+769 -122
@@ -28,6 +28,7 @@ from app.db.db_session import get_db
28 from app.db.universal_models import Customers
29 from app.incidents.models import Alert
30 from app.incidents.models import FieldName
31 +from app.incidents.models import Comment
32 from app.incidents.schema.db_operations import AlertContextCreate
33 from app.incidents.schema.db_operations import AlertContextResponse
34 from app.incidents.schema.db_operations import AlertCreate
@@ -176,7 +177,6 @@ from app.incidents.services.db_operations import list_alerts_by_tag
177 from app.incidents.services.db_operations import list_alerts_by_title
178 from app.incidents.services.db_operations import list_alerts_multiple_filters
179 from app.incidents.services.db_operations import list_all_files
179 -from app.incidents.services.db_operations import list_cases
180 from app.incidents.services.db_operations import list_cases_by_asset_name
181 from app.incidents.services.db_operations import list_cases_by_assigned_to
182 from app.incidents.services.db_operations import list_cases_by_customer_code
@@ -199,6 +199,15 @@ from app.incidents.services.db_operations import upload_report_template
199 from app.incidents.services.db_operations import upload_report_template_to_data_store
200 from app.incidents.services.db_operations import validate_source_exists
201 from app.incidents.services.incident_case import handle_customer_notifications_case
202 +from app.middleware.customer_access import customer_access_handler
203 +from app.auth.models.users import User
204 +from app.auth.utils import AuthHandler
205 +from app.incidents.services.db_operations import alert_total_by_customer_codes
206 +from app.incidents.services.db_operations import alerts_closed_by_customer_codes
207 +from app.incidents.services.db_operations import alerts_in_progress_by_customer_codes
208 +from app.incidents.services.db_operations import alerts_open_by_customer_codes
209 +from app.incidents.services.db_operations import list_alerts_for_user
210 +from app.incidents.services.db_operations import list_cases_for_user
211
212 incidents_db_operations_router = APIRouter()
213
@@ -408,17 +417,65 @@ async def update_alert_status_endpoint(alert_status: UpdateAlertStatus, db: Asyn
417
418
419 @incidents_db_operations_router.post("/alert/comment", response_model=CommentResponse)
411 -async def create_comment_endpoint(comment: CommentCreate, db: AsyncSession = Depends(get_db)):
420 +async def create_comment_endpoint(
421 + comment: CommentCreate,
422 + current_user: User = Depends(AuthHandler().get_current_user),
423 + db: AsyncSession = Depends(get_db)
424 +):
425 + # Get the alert to check customer access
426 + alert = await get_alert_by_id(comment.alert_id, db)
427 +
428 + # Check if user has access to this alert's customer
429 + if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
430 + raise HTTPException(
431 + status_code=403,
432 + detail=f"Access denied to alert {comment.alert_id} - insufficient customer permissions"
433 + )
434 +
435 return CommentResponse(comment=await create_comment(comment, db), success=True, message="Comment created successfully")
436
437
438 @incidents_db_operations_router.put("/alert/comment", response_model=CommentResponse)
416 -async def edit_comment_endpoint(comment: CommentEdit, db: AsyncSession = Depends(get_db)):
439 +async def edit_comment_endpoint(
440 + comment: CommentEdit,
441 + current_user: User = Depends(AuthHandler().get_current_user),
442 + db: AsyncSession = Depends(get_db)
443 +):
444 + # Get the alert to check customer access
445 + alert = await get_alert_by_id(comment.alert_id, db)
446 +
447 + # Check if user has access to this alert's customer
448 + if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
449 + raise HTTPException(
450 + status_code=403,
451 + detail=f"Access denied to alert {comment.alert_id} - insufficient customer permissions"
452 + )
453 +
454 return CommentResponse(comment=await edit_comment(comment, db), success=True, message="Comment edited successfully")
455
456
457 @incidents_db_operations_router.delete("/alert/comment/{comment_id}")
421 -async def delete_comment_endpoint(comment_id: int, db: AsyncSession = Depends(get_db)):
458 +async def delete_comment_endpoint(
459 + comment_id: int,
460 + current_user: User = Depends(AuthHandler().get_current_user),
461 + db: AsyncSession = Depends(get_db)
462 +):
463 + # First get the comment to find the alert_id
464 + result = await db.execute(select(Comment).where(Comment.id == comment_id))
465 + comment = result.scalars().first()
466 + if not comment:
467 + raise HTTPException(status_code=404, detail="Comment not found")
468 +
469 + # Get the alert to check customer access
470 + alert = await get_alert_by_id(comment.alert_id, db)
471 +
472 + # Check if user has access to this alert's customer
473 + if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
474 + raise HTTPException(
475 + status_code=403,
476 + detail=f"Access denied to comment on alert {comment.alert_id} - insufficient customer permissions"
477 + )
478 +
479 await delete_comment(comment_id, db)
480 return {"message": "Comment deleted successfully", "success": True}
481
@@ -477,16 +534,45 @@ async def create_alert_ioc_endpoint(ioc: AlertIoCCreate, db: AsyncSession = Depe
534 @incidents_db_operations_router.get("/alert/ioc/{ioc_value}", response_model=AlertOutResponse)
535 async def list_alerts_by_ioc_value_endpoint(
536 ioc_value: str,
480 - db: AsyncSession = Depends(get_db),
537 page: int = Query(1, ge=1),
538 page_size: int = Query(25, ge=1),
539 + current_user: User = Depends(AuthHandler().get_current_user),
540 + db: AsyncSession = Depends(get_db),
541 ):
542 + """List alerts by IoC value with customer access filtering"""
543 + logger.info(f"Listing alerts by IoC {ioc_value} for user: {current_user.username} with role_id: {current_user.role_id}")
544 +
545 + # Get customer access filtering
546 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
547 +
548 + if "*" in accessible_customers:
549 + # Admin/analyst - no filtering needed
550 + alerts = await list_alerts_by_ioc(ioc_value, db, page, page_size)
551 + total = await alerts_total_by_ioc(db, ioc_value)
552 + open_alerts = await alerts_open_by_ioc(db, ioc_value)
553 + in_progress = await alerts_in_progress_by_ioc(db, ioc_value)
554 + closed = await alerts_closed_by_ioc(db, ioc_value)
555 + else:
556 + # Customer user - filter by accessible customers
557 + alerts = await list_alerts_multiple_filters(
558 + ioc_value=ioc_value,
559 + customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
560 + db=db,
561 + page=page,
562 + page_size=page_size,
563 + order="desc"
564 + )
565 + total = await alert_total_by_customer_codes(db, accessible_customers)
566 + open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
567 + in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
568 + closed = await alerts_closed_by_customer_codes(db, accessible_customers)
569 +
570 return AlertOutResponse(
485 - alerts=await list_alerts_by_ioc(ioc_value, db, page, page_size),
486 - total=await alerts_total_by_ioc(db, ioc_value),
487 - open=await alerts_open_by_ioc(db, ioc_value),
488 - in_progress=await alerts_in_progress_by_ioc(db, ioc_value),
489 - closed=await alerts_closed_by_ioc(db, ioc_value),
571 + alerts=alerts,
572 + total=total,
573 + open=open_alerts,
574 + in_progress=in_progress,
575 + closed=closed,
576 success=True,
577 message="Alerts retrieved successfully",
578 )
@@ -509,16 +595,45 @@ async def create_alert_tag_endpoint(alert_tag: AlertTagCreate, db: AsyncSession
595 @incidents_db_operations_router.get("/alert/tag/{tag}", response_model=AlertOutResponse)
596 async def list_alerts_by_tag_endpoint(
597 tag: str,
512 - db: AsyncSession = Depends(get_db),
598 page: int = Query(1, ge=1),
599 page_size: int = Query(25, ge=1),
600 + current_user: User = Depends(AuthHandler().get_current_user),
601 + db: AsyncSession = Depends(get_db),
602 ):
603 + """List alerts by tag with customer access filtering"""
604 + logger.info(f"Listing alerts by tag {tag} for user: {current_user.username} with role_id: {current_user.role_id}")
605 +
606 + # Get customer access filtering
607 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
608 +
609 + if "*" in accessible_customers:
610 + # Admin/analyst - no filtering needed
611 + alerts = await list_alerts_by_tag(tag, db, page, page_size)
612 + total = await alerts_total_by_tag(db, tag)
613 + open_alerts = await alerts_open_by_tag(db, tag)
614 + in_progress = await alerts_in_progress_by_tag(db, tag)
615 + closed = await alerts_closed_by_tag(db, tag)
616 + else:
617 + # Customer user - filter by accessible customers
618 + alerts = await list_alerts_multiple_filters(
619 + tags=[tag],
620 + customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
621 + db=db,
622 + page=page,
623 + page_size=page_size,
624 + order="desc"
625 + )
626 + total = await alert_total_by_customer_codes(db, accessible_customers)
627 + open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
628 + in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
629 + closed = await alerts_closed_by_customer_codes(db, accessible_customers)
630 +
631 return AlertOutResponse(
517 - alerts=await list_alerts_by_tag(tag, db, page, page_size),
518 - total=await alerts_total_by_tag(db, tag),
519 - open=await alerts_open_by_tag(db, tag),
520 - in_progress=await alerts_in_progress_by_tag(db, tag),
521 - closed=await alerts_closed_by_tag(db, tag),
632 + alerts=alerts,
633 + total=total,
634 + open=open_alerts,
635 + in_progress=in_progress,
636 + closed=closed,
637 success=True,
638 message="Alert's tags retrieved successfully",
639 )
@@ -573,31 +688,103 @@ async def create_case_from_alert_endpoint(alert_id: CaseCreateFromAlert, db: Asy
688 )
689
690
691 +# @incidents_db_operations_router.get("/alerts", response_model=AlertOutResponse)
692 +# async def list_alerts_endpoint(
693 +# page: int = Query(1, ge=1),
694 +# page_size: int = Query(25, ge=1),
695 +# order: str = Query("desc", regex="^(asc|desc)$"),
696 +# db: AsyncSession = Depends(get_db),
697 +# ):
698 +# return AlertOutResponse(
699 +# alerts=await list_alerts(db, page=page, page_size=page_size, order=order),
700 +# total=await alert_total(db),
701 +# open=await alerts_open(db),
702 +# in_progress=await alerts_in_progress(db),
703 +# closed=await alerts_closed(db),
704 +# success=True,
705 +# message="Alerts retrieved successfully",
706 +# )
707 +
708 @incidents_db_operations_router.get("/alerts", response_model=AlertOutResponse)
709 async def list_alerts_endpoint(
710 page: int = Query(1, ge=1),
711 page_size: int = Query(25, ge=1),
712 order: str = Query("desc", regex="^(asc|desc)$"),
713 + current_user: User = Depends(AuthHandler().get_current_user), # Get the full user object
714 db: AsyncSession = Depends(get_db),
715 ):
716 + logger.info(f"Listing alerts for user: {current_user.username} with role_id: {current_user.role_id}")
717 + """List alerts with automatic customer filtering"""
718 + alerts = await list_alerts_for_user(current_user, db, page, page_size, order)
719 +
720 + # Get totals with customer filtering
721 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
722 +
723 + logger.info(f"User {current_user.username} has access to customers: {accessible_customers}")
724 + if "*" in accessible_customers:
725 + # Admin/analyst - use existing total functions
726 + total = await alert_total(db)
727 + open_alerts = await alerts_open(db)
728 + in_progress = await alerts_in_progress(db)
729 + closed = await alerts_closed(db)
730 + else:
731 + # Customer user - filter totals by their customers
732 + total = await alert_total_by_customer_codes(db, accessible_customers)
733 + open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
734 + in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
735 + closed = await alerts_closed_by_customer_codes(db, accessible_customers)
736 +
737 return AlertOutResponse(
584 - alerts=await list_alerts(db, page=page, page_size=page_size, order=order),
585 - total=await alert_total(db),
586 - open=await alerts_open(db),
587 - in_progress=await alerts_in_progress(db),
588 - closed=await alerts_closed(db),
738 + alerts=alerts,
739 + total=total,
740 + open=open_alerts,
741 + in_progress=in_progress,
742 + closed=closed,
743 success=True,
744 message="Alerts retrieved successfully",
745 )
746
593 -
747 @incidents_db_operations_router.get("/alert/{alert_id}", response_model=AlertOutResponse)
595 -async def get_alert_by_id_endpoint(alert_id: int, db: AsyncSession = Depends(get_db)):
596 - return AlertOutResponse(alerts=[await get_alert_by_id(alert_id, db)], success=True, message="Alert retrieved successfully")
748 +async def get_alert_by_id_endpoint(
749 + alert_id: int,
750 + current_user: User = Depends(AuthHandler().get_current_user),
751 + db: AsyncSession = Depends(get_db)
752 +):
753 + """Get alert by ID with customer access validation"""
754 + logger.info(f"Getting alert {alert_id} for user: {current_user.username} with role_id: {current_user.role_id}")
755 +
756 + # Get the alert first
757 + alert = await get_alert_by_id(alert_id, db)
758 +
759 + # Check if user has access to this alert's customer
760 + if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
761 + raise HTTPException(
762 + status_code=403,
763 + detail=f"Access denied to alert {alert_id} - insufficient customer permissions"
764 + )
765 +
766 + return AlertOutResponse(alerts=[alert], success=True, message="Alert retrieved successfully")
767
768
769 @incidents_db_operations_router.delete("/alert/{alert_id}")
600 -async def delete_alert_endpoint(alert_id: int, db: AsyncSession = Depends(get_db)):
770 +async def delete_alert_endpoint(
771 + alert_id: int,
772 + current_user: User = Depends(AuthHandler().get_current_user),
773 + db: AsyncSession = Depends(get_db)
774 +):
775 + """Delete alert with customer access validation"""
776 + logger.info(f"Deleting alert {alert_id} for user: {current_user.username} with role_id: {current_user.role_id}")
777 +
778 + # Get the alert first to check customer access
779 + alert = await get_alert_by_id(alert_id, db)
780 +
781 + # Check if user has access to this alert's customer
782 + if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
783 + raise HTTPException(
784 + status_code=403,
785 + detail=f"Access denied to alert {alert_id} - insufficient customer permissions"
786 + )
787 +
788 await is_alert_linked_to_case(alert_id, db)
789 await delete_alert(alert_id, db)
790 return {"message": "Alert deleted successfully", "success": True}
@@ -647,16 +834,48 @@ async def list_alerts_by_status_endpoint(
834 page: int = Query(1, ge=1),
835 page_size: int = Query(25, ge=1),
836 order: str = Query("desc", regex="^(asc|desc)$"),
837 + current_user: User = Depends(AuthHandler().get_current_user),
838 db: AsyncSession = Depends(get_db),
839 ):
840 + """List alerts by status with customer access filtering"""
841 if status not in AlertStatus:
842 raise HTTPException(status_code=400, detail="Invalid status")
843 +
844 + logger.info(f"Listing alerts by status {status} for user: {current_user.username} with role_id: {current_user.role_id}")
845 +
846 + # Get customer access filtering
847 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
848 +
849 + if "*" in accessible_customers:
850 + # Admin/analyst - no filtering needed
851 + alerts = await list_alert_by_status(status.value, db, page=page, page_size=page_size, order=order)
852 + total = await alert_total(db)
853 + open_alerts = await alerts_open(db)
854 + in_progress = await alerts_in_progress(db)
855 + closed = await alerts_closed(db)
856 + else:
857 + # Customer user - filter by accessible customers
858 + # We need to create filtered versions of these functions or use the existing filter functionality
859 + # For now, let's use the multiple filters function with customer codes
860 + alerts = await list_alerts_multiple_filters(
861 + status=status.value,
862 + customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
863 + db=db,
864 + page=page,
865 + page_size=page_size,
866 + order=order
867 + )
868 + total = await alert_total_by_customer_codes(db, accessible_customers)
869 + open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
870 + in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
871 + closed = await alerts_closed_by_customer_codes(db, accessible_customers)
872 +
873 return AlertOutResponse(
655 - alerts=await list_alert_by_status(status.value, db, page=page, page_size=page_size, order=order),
656 - total=await alert_total(db),
657 - open=await alerts_open(db),
658 - in_progress=await alerts_in_progress(db),
659 - closed=await alerts_closed(db),
874 + alerts=alerts,
875 + total=total,
876 + open=open_alerts,
877 + in_progress=in_progress,
878 + closed=closed,
879 success=True,
880 message="Alerts retrieved successfully",
881 )
@@ -668,14 +887,43 @@ async def list_alerts_by_assigned_to_endpoint(
887 page: int = Query(1, ge=1),
888 page_size: int = Query(25, ge=1),
889 order: str = Query("desc", regex="^(asc|desc)$"),
890 + current_user: User = Depends(AuthHandler().get_current_user),
891 db: AsyncSession = Depends(get_db),
892 ):
893 + """List alerts by assigned user with customer access filtering"""
894 + logger.info(f"Listing alerts assigned to {assigned_to} for user: {current_user.username} with role_id: {current_user.role_id}")
895 +
896 + # Get customer access filtering
897 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
898 +
899 + if "*" in accessible_customers:
900 + # Admin/analyst - no filtering needed
901 + alerts = await list_alert_by_assigned_to(assigned_to, db, page=page, page_size=page_size, order=order)
902 + total = await alerts_total_by_assigned_to(db, assigned_to)
903 + open_alerts = await alerts_open_by_assigned_to(db, assigned_to)
904 + in_progress = await alerts_in_progress_by_assigned_to(db, assigned_to)
905 + closed = await alerts_closed_by_assigned_to(db, assigned_to)
906 + else:
907 + # Customer user - filter by accessible customers
908 + alerts = await list_alerts_multiple_filters(
909 + assigned_to=assigned_to,
910 + customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
911 + db=db,
912 + page=page,
913 + page_size=page_size,
914 + order=order
915 + )
916 + total = await alert_total_by_customer_codes(db, accessible_customers)
917 + open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
918 + in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
919 + closed = await alerts_closed_by_customer_codes(db, accessible_customers)
920 +
921 return AlertOutResponse(
674 - alerts=await list_alert_by_assigned_to(assigned_to, db, page=page, page_size=page_size, order=order),
675 - total=await alerts_total_by_assigned_to(db, assigned_to),
676 - open=await alerts_open_by_assigned_to(db, assigned_to),
677 - in_progress=await alerts_in_progress_by_assigned_to(db, assigned_to),
678 - closed=await alerts_closed_by_assigned_to(db, assigned_to),
922 + alerts=alerts,
923 + total=total,
924 + open=open_alerts,
925 + in_progress=in_progress,
926 + closed=closed,
927 success=True,
928 message="Alerts retrieved successfully",
929 )
@@ -687,14 +935,43 @@ async def list_alerts_by_asset_name_endpoint(
935 page: int = Query(1, ge=1),
936 page_size: int = Query(25, ge=1),
937 order: str = Query("desc", regex="^(asc|desc)$"),
938 + current_user: User = Depends(AuthHandler().get_current_user),
939 db: AsyncSession = Depends(get_db),
940 ):
941 + """List alerts by asset name with customer access filtering"""
942 + logger.info(f"Listing alerts by asset {asset_name} for user: {current_user.username} with role_id: {current_user.role_id}")
943 +
944 + # Get customer access filtering
945 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
946 +
947 + if "*" in accessible_customers:
948 + # Admin/analyst - no filtering needed
949 + alerts = await list_alerts_by_asset_name(asset_name, db, page=page, page_size=page_size, order=order)
950 + total = await alert_total_by_assest_name(db, asset_name)
951 + open_alerts = await alerts_open_by_assest_name(db, asset_name)
952 + in_progress = await alerts_in_progress_by_assest_name(db, asset_name)
953 + closed = await alerts_closed_by_asset_name(db, asset_name)
954 + else:
955 + # Customer user - filter by accessible customers
956 + alerts = await list_alerts_multiple_filters(
957 + asset_name=asset_name,
958 + customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
959 + db=db,
960 + page=page,
961 + page_size=page_size,
962 + order=order
963 + )
964 + total = await alert_total_by_customer_codes(db, accessible_customers)
965 + open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
966 + in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
967 + closed = await alerts_closed_by_customer_codes(db, accessible_customers)
968 +
969 return AlertOutResponse(
693 - alerts=await list_alerts_by_asset_name(asset_name, db, page=page, page_size=page_size, order=order),
694 - total=await alert_total_by_assest_name(db, asset_name),
695 - open=await alerts_open_by_assest_name(db, asset_name),
696 - in_progress=await alerts_in_progress_by_assest_name(db, asset_name),
697 - closed=await alerts_closed_by_asset_name(db, asset_name),
970 + alerts=alerts,
971 + total=total,
972 + open=open_alerts,
973 + in_progress=in_progress,
974 + closed=closed,
975 success=True,
976 message="Alerts retrieved successfully",
977 )
@@ -706,29 +983,82 @@ async def list_alerts_by_title_endpoint(
983 page: int = Query(1, ge=1),
984 page_size: int = Query(25, ge=1),
985 order: str = Query("desc", regex="^(asc|desc)$"),
986 + current_user: User = Depends(AuthHandler().get_current_user),
987 db: AsyncSession = Depends(get_db),
988 ):
989 + """List alerts by title with customer access filtering"""
990 + logger.info(f"Listing alerts by title {title} for user: {current_user.username} with role_id: {current_user.role_id}")
991 +
992 + # Get customer access filtering
993 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
994 +
995 + if "*" in accessible_customers:
996 + # Admin/analyst - no filtering needed
997 + alerts = await list_alerts_by_title(title, db, page=page, page_size=page_size, order=order)
998 + total = await alert_total_by_alert_title(db, title)
999 + open_alerts = await alerts_open_by_alert_title(db, title)
1000 + in_progress = await alerts_in_progress_by_alert_title(db, title)
1001 + closed = await alerts_closed_by_alert_title(db, title)
1002 + else:
1003 + # Customer user - filter by accessible customers
1004 + alerts = await list_alerts_multiple_filters(
1005 + alert_title=title,
1006 + customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
1007 + db=db,
1008 + page=page,
1009 + page_size=page_size,
1010 + order=order
1011 + )
1012 + total = await alert_total_by_customer_codes(db, accessible_customers)
1013 + open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
1014 + in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
1015 + closed = await alerts_closed_by_customer_codes(db, accessible_customers)
1016 +
1017 return AlertOutResponse(
712 - alerts=await list_alerts_by_title(title, db, page=page, page_size=page_size, order=order),
713 - total=await alert_total_by_alert_title(db, title),
714 - open=await alerts_open_by_alert_title(db, title),
715 - in_progress=await alerts_in_progress_by_alert_title(db, title),
716 - closed=await alerts_closed_by_alert_title(db, title),
1018 + alerts=alerts,
1019 + total=total,
1020 + open=open_alerts,
1021 + in_progress=in_progress,
1022 + closed=closed,
1023 success=True,
1024 message="Alerts retrieved successfully",
1025 )
1026
1027
1028 +# @incidents_db_operations_router.get("/alerts/customer/{customer_code}", response_model=AlertOutResponse)
1029 +# async def list_alerts_by_customer_code_endpoint(
1030 +# customer_code: str,
1031 +# page: int = Query(1, ge=1),
1032 +# page_size: int = Query(25, ge=1),
1033 +# order: str = Query("desc", regex="^(asc|desc)$"),
1034 +# db: AsyncSession = Depends(get_db),
1035 +# ):
1036 +# return AlertOutResponse(
1037 +# alerts=await list_alerts_by_customer_code(customer_code, db, page=page, page_size=page_size, order=order),
1038 +# total=await alerts_total_by_customer_code(db, customer_code),
1039 +# open=await alerts_open_by_customer_code(db, customer_code),
1040 +# in_progress=await alerts_in_progress_by_customer_code(db, customer_code),
1041 +# closed=await alerts_closed_by_customer_code(db, customer_code),
1042 +# success=True,
1043 +# message="Alerts retrieved successfully",
1044 +# )
1045 +
1046 @incidents_db_operations_router.get("/alerts/customer/{customer_code}", response_model=AlertOutResponse)
1047 async def list_alerts_by_customer_code_endpoint(
1048 customer_code: str,
1049 page: int = Query(1, ge=1),
1050 page_size: int = Query(25, ge=1),
1051 order: str = Query("desc", regex="^(asc|desc)$"),
1052 + current_user: User = Depends(customer_access_handler.require_customer_access()),
1053 db: AsyncSession = Depends(get_db),
1054 ):
1055 + """List alerts for specific customer (with access validation)"""
1056 + # Verify user has access to this specific customer
1057 + if not await customer_access_handler.check_customer_access(current_user, customer_code, db):
1058 + raise HTTPException(status_code=403, detail="Access denied to this customer")
1059 +
1060 return AlertOutResponse(
731 - alerts=await list_alerts_by_customer_code(customer_code, db, page=page, page_size=page_size, order=order),
1061 + alerts=await list_alerts_by_customer_code(customer_code, db, page, page_size, order),
1062 total=await alerts_total_by_customer_code(db, customer_code),
1063 open=await alerts_open_by_customer_code(db, customer_code),
1064 in_progress=await alerts_in_progress_by_customer_code(db, customer_code),
@@ -744,14 +1074,43 @@ async def list_alerts_by_source_endpoint(
1074 page: int = Query(1, ge=1),
1075 page_size: int = Query(25, ge=1),
1076 order: str = Query("desc", regex="^(asc|desc)$"),
1077 + current_user: User = Depends(AuthHandler().get_current_user),
1078 db: AsyncSession = Depends(get_db),
1079 ):
1080 + """List alerts by source with customer access filtering"""
1081 + logger.info(f"Listing alerts by source {source} for user: {current_user.username} with role_id: {current_user.role_id}")
1082 +
1083 + # Get customer access filtering
1084 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1085 +
1086 + if "*" in accessible_customers:
1087 + # Admin/analyst - no filtering needed
1088 + alerts = await list_alerts_by_source(source, db, page=page, page_size=page_size, order=order)
1089 + total = await alerts_total_by_source(db, source)
1090 + open_alerts = await alerts_open_by_source(db, source)
1091 + in_progress = await alerts_in_progress_by_source(db, source)
1092 + closed = await alerts_closed_by_source(db, source)
1093 + else:
1094 + # Customer user - filter by accessible customers
1095 + alerts = await list_alerts_multiple_filters(
1096 + source=source,
1097 + customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
1098 + db=db,
1099 + page=page,
1100 + page_size=page_size,
1101 + order=order
1102 + )
1103 + total = await alert_total_by_customer_codes(db, accessible_customers)
1104 + open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
1105 + in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
1106 + closed = await alerts_closed_by_customer_codes(db, accessible_customers)
1107 +
1108 return AlertOutResponse(
750 - alerts=await list_alerts_by_source(source, db, page=page, page_size=page_size, order=order),
751 - total=await alerts_total_by_source(db, source),
752 - open=await alerts_open_by_source(db, source),
753 - in_progress=await alerts_in_progress_by_source(db, source),
754 - closed=await alerts_closed_by_source(db, source),
1109 + alerts=alerts,
1110 + total=total,
1111 + open=open_alerts,
1112 + in_progress=in_progress,
1113 + closed=closed,
1114 success=True,
1115 message="Alerts retrieved successfully",
1116 )
@@ -770,10 +1129,11 @@ async def list_alerts_multiple_filters_endpoint(
1129 page: int = Query(1, ge=1),
1130 page_size: int = Query(25, ge=1),
1131 order: str = Query("desc", regex="^(asc|desc)$"),
1132 + current_user: User = Depends(AuthHandler().get_current_user),
1133 db: AsyncSession = Depends(get_db),
1134 ):
1135 """
776 - Endpoint to list alerts with multiple filters.
1136 + Endpoint to list alerts with multiple filters and customer access control.
1137
1138 Parameters:
1139 - assigned_to (str, optional): Filter by assigned user.
@@ -787,6 +1147,7 @@ async def list_alerts_multiple_filters_endpoint(
1147 - page (int, default=1): Page number.
1148 - page_size (int, default=25): Number of alerts per page.
1149 - order (str, default='desc'): Sorting order ('asc' or 'desc').
1150 + - current_user (User): Current authenticated user.
1151 - db (AsyncSession): Database session.
1152
1153 Returns:
@@ -798,8 +1159,44 @@ async def list_alerts_multiple_filters_endpoint(
1159 - success (bool): Indicates if the operation was successful.
1160 - message (str): Success message.
1161 """
801 - return AlertOutResponse(
802 - alerts=await list_alerts_multiple_filters(
1162 + logger.info(f"Listing alerts with filters for user: {current_user.username} with role_id: {current_user.role_id}")
1163 +
1164 + # Get customer access filtering
1165 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1166 +
1167 + # Apply customer filtering if user is not admin/analyst
1168 + if "*" not in accessible_customers:
1169 + # If user provided customer_code, validate they have access to it
1170 + if customer_code and customer_code not in accessible_customers:
1171 + raise HTTPException(
1172 + status_code=403,
1173 + detail=f"Access denied to customer {customer_code}"
1174 + )
1175 +
1176 + # If no customer_code specified, use the first accessible customer for single customer users
1177 + # For multi-customer users, we'll need to modify the query to handle multiple customers
1178 + if not customer_code and len(accessible_customers) == 1:
1179 + customer_code = accessible_customers[0]
1180 +
1181 + alerts = await list_alerts_multiple_filters(
1182 + assigned_to=assigned_to,
1183 + alert_title=alert_title,
1184 + customer_code=customer_code,
1185 + source=source,
1186 + asset_name=asset_name,
1187 + status=status,
1188 + tags=tags,
1189 + ioc_value=ioc_value,
1190 + db=db,
1191 + page=page,
1192 + page_size=page_size,
1193 + order=order,
1194 + )
1195 +
1196 + # Get totals with customer filtering
1197 + if "*" in accessible_customers:
1198 + # Admin/analyst - use existing total functions
1199 + total = await alerts_total_multiple_filters(
1200 assigned_to=assigned_to,
1201 alert_title=alert_title,
1202 customer_code=customer_code,
@@ -809,11 +1206,14 @@ async def list_alerts_multiple_filters_endpoint(
1206 tags=tags,
1207 ioc_value=ioc_value,
1208 db=db,
812 - page=page,
813 - page_size=page_size,
814 - order=order,
815 - ),
816 - total_filtered=await alerts_total_multiple_filters(
1209 + )
1210 + open_alerts = await alerts_open(db)
1211 + in_progress = await alerts_in_progress(db)
1212 + closed = await alerts_closed(db)
1213 + total_unfiltered = await alert_total(db)
1214 + else:
1215 + # Customer user - filter totals by their customers
1216 + total = await alerts_total_multiple_filters(
1217 assigned_to=assigned_to,
1218 alert_title=alert_title,
1219 customer_code=customer_code,
@@ -823,106 +1223,252 @@ async def list_alerts_multiple_filters_endpoint(
1223 tags=tags,
1224 ioc_value=ioc_value,
1225 db=db,
826 - ),
827 - # open=await alerts_open_multiple_filters(
828 - # assigned_to=assigned_to,
829 - # alert_title=alert_title,
830 - # customer_code=customer_code,
831 - # source=source,
832 - # asset_name=asset_name,
833 - # status=status,
834 - # tags=tags,
835 - # ioc_value=ioc_value,
836 - # db=db,
837 - # ),
838 - open=await alerts_open(db),
839 - # in_progress=await alerts_in_progress_multiple_filters(
840 - # assigned_to=assigned_to,
841 - # alert_title=alert_title,
842 - # customer_code=customer_code,
843 - # source=source,
844 - # asset_name=asset_name,
845 - # status=status,
846 - # tags=tags,
847 - # ioc_value=ioc_value,
848 - # db=db,
849 - # ),
850 - in_progress=await alerts_in_progress(db),
851 - # closed=await alerts_closed_multiple_filters(
852 - # assigned_to=assigned_to,
853 - # alert_title=alert_title,
854 - # customer_code=customer_code,
855 - # source=source,
856 - # asset_name=asset_name,
857 - # status=status,
858 - # tags=tags,
859 - # ioc_value=ioc_value,
860 - # db=db,
861 - # ),
862 - closed=await alerts_closed(db),
863 - total=await alert_total(db),
1226 + )
1227 + open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
1228 + in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
1229 + closed = await alerts_closed_by_customer_codes(db, accessible_customers)
1230 + total_unfiltered = await alert_total_by_customer_codes(db, accessible_customers)
1231 +
1232 + return AlertOutResponse(
1233 + alerts=alerts,
1234 + total_filtered=total,
1235 + open=open_alerts,
1236 + in_progress=in_progress,
1237 + closed=closed,
1238 + total=total_unfiltered,
1239 success=True,
1240 message="Alerts retrieved successfully",
1241 )
1242
1243
1244 @incidents_db_operations_router.get("/cases", response_model=CaseOutResponse)
870 -async def list_cases_endpoint(db: AsyncSession = Depends(get_db)):
871 - return CaseOutResponse(cases=await list_cases(db), success=True, message="Cases retrieved successfully")
1245 +async def list_cases_endpoint(
1246 + current_user: User = Depends(AuthHandler().get_current_user),
1247 + db: AsyncSession = Depends(get_db)
1248 +):
1249 + """List cases with automatic customer filtering"""
1250 + logger.info(f"Listing cases for user: {current_user.username} with role_id: {current_user.role_id}")
1251 +
1252 + cases = await list_cases_for_user(current_user, db)
1253 + return CaseOutResponse(cases=cases, success=True, message="Cases retrieved successfully")
1254
1255
1256 @incidents_db_operations_router.put("/case/status", response_model=CaseOutResponse)
875 -async def update_case_status_endpoint(case_status: UpdateCaseStatus, db: AsyncSession = Depends(get_db)):
876 - return CaseOutResponse(cases=[await update_case_status(case_status, db)], success=True, message="Case status updated successfully")
1257 +async def update_case_status_endpoint(
1258 + case_status: UpdateCaseStatus,
1259 + current_user: User = Depends(AuthHandler().get_current_user),
1260 + db: AsyncSession = Depends(get_db)
1261 +):
1262 + """Update case status with customer access validation"""
1263 + logger.info(f"Updating case {case_status.case_id} status for user: {current_user.username} with role_id: {current_user.role_id}")
1264 +
1265 + # Get the case first to check customer access
1266 + case = await get_case_by_id(case_status.case_id, db)
1267 +
1268 + # Check if user has access to this case's customer
1269 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
1270 + raise HTTPException(
1271 + status_code=403,
1272 + detail=f"Access denied to case {case_status.case_id} - insufficient customer permissions"
1273 + )
1274 +
1275 + # Update the case status
1276 + await update_case_status(case_status, db)
1277 +
1278 + # Re-fetch the case with full data structure
1279 + updated_case = await get_case_by_id(case_status.case_id, db)
1280 + return CaseOutResponse(cases=[updated_case], success=True, message="Case status updated successfully")
1281
1282
1283 @incidents_db_operations_router.put("/case/assigned-to", response_model=CaseOutResponse)
880 -async def update_case_assigned_to_endpoint(assigned_to: AssignedToCase, db: AsyncSession = Depends(get_db)):
1284 +async def update_case_assigned_to_endpoint(
1285 + assigned_to: AssignedToCase,
1286 + current_user: User = Depends(AuthHandler().get_current_user),
1287 + db: AsyncSession = Depends(get_db)
1288 +):
1289 + """Update case assigned_to with customer access validation"""
1290 + logger.info(f"Updating case {assigned_to.case_id} assigned_to for user: {current_user.username} with role_id: {current_user.role_id}")
1291 +
1292 + # Get the case first to check customer access
1293 + case = await get_case_by_id(assigned_to.case_id, db)
1294 +
1295 + # Check if user has access to this case's customer
1296 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
1297 + raise HTTPException(
1298 + status_code=403,
1299 + detail=f"Access denied to case {assigned_to.case_id} - insufficient customer permissions"
1300 + )
1301 +
1302 all_users = await select_all_users()
1303 user_names = [user.username for user in all_users]
1304 if assigned_to.assigned_to not in user_names:
1305 raise HTTPException(status_code=400, detail="User does not exist")
1306 +
1307 + # Update the case assigned_to
1308 + await update_case_assigned_to(assigned_to.case_id, assigned_to.assigned_to, db)
1309 +
1310 + # Re-fetch the case with full data structure
1311 + updated_case = await get_case_by_id(assigned_to.case_id, db)
1312 return CaseOutResponse(
886 - cases=[await update_case_assigned_to(assigned_to.case_id, assigned_to.assigned_to, db)],
1313 + cases=[updated_case],
1314 success=True,
1315 message="Case assigned to user successfully",
1316 )
890 -
891 -
1317 @incidents_db_operations_router.put("/case/customer-code", response_model=CaseOutResponse)
893 -async def update_case_customer_code_endpoint(case_id: int, customer_code: str, db: AsyncSession = Depends(get_db)):
1318 +async def update_case_customer_code_endpoint(
1319 + case_id: int,
1320 + customer_code: str,
1321 + current_user: User = Depends(AuthHandler().get_current_user),
1322 + db: AsyncSession = Depends(get_db)
1323 +):
1324 + """Update case customer_code with customer access validation"""
1325 + logger.info(f"Updating case {case_id} customer_code for user: {current_user.username} with role_id: {current_user.role_id}")
1326 +
1327 + # Get the case first to check current customer access
1328 + case = await get_case_by_id(case_id, db)
1329 +
1330 + # Check if user has access to the current case's customer
1331 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
1332 + raise HTTPException(
1333 + status_code=403,
1334 + detail=f"Access denied to case {case_id} - insufficient customer permissions"
1335 + )
1336 +
1337 + # Also check if user has access to the new customer code (for non-admin users)
1338 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1339 + if "*" not in accessible_customers and customer_code not in accessible_customers:
1340 + raise HTTPException(
1341 + status_code=403,
1342 + detail=f"Access denied - cannot assign case to customer {customer_code}"
1343 + )
1344 +
1345 + # Update the case customer code
1346 + await update_case_customer_code(case_id, customer_code, db)
1347 +
1348 + # Re-fetch the case with full data structure
1349 + updated_case = await get_case_by_id(case_id, db)
1350 return CaseOutResponse(
895 - cases=[await update_case_customer_code(case_id, customer_code, db)],
1351 + cases=[updated_case],
1352 success=True,
1353 message="Case customer code updated successfully",
1354 )
1355
1356
1357 @incidents_db_operations_router.delete("/case/{case_id}")
902 -async def delete_case_endpoint(case_id: int, db: AsyncSession = Depends(get_db)):
1358 +async def delete_case_endpoint(
1359 + case_id: int,
1360 + current_user: User = Depends(AuthHandler().get_current_user),
1361 + db: AsyncSession = Depends(get_db)
1362 +):
1363 + """Delete case with customer access validation"""
1364 + logger.info(f"Deleting case {case_id} for user: {current_user.username} with role_id: {current_user.role_id}")
1365 +
1366 + # Get the case first to check customer access
1367 + case = await get_case_by_id(case_id, db)
1368 +
1369 + # Check if user has access to this case's customer
1370 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
1371 + raise HTTPException(
1372 + status_code=403,
1373 + detail=f"Access denied to case {case_id} - insufficient customer permissions"
1374 + )
1375 +
1376 await delete_case(case_id, db)
1377 return {"message": "Case deleted successfully", "success": True}
1378
1379
1380 @incidents_db_operations_router.get("/case/status/{status}", response_model=CaseOutResponse)
908 -async def list_cases_by_status_endpoint(status: AlertStatus, db: AsyncSession = Depends(get_db)):
1381 +async def list_cases_by_status_endpoint(
1382 + status: AlertStatus,
1383 + current_user: User = Depends(AuthHandler().get_current_user),
1384 + db: AsyncSession = Depends(get_db)
1385 +):
1386 + """List cases by status with customer access filtering"""
1387 if status not in AlertStatus:
1388 raise HTTPException(status_code=400, detail="Invalid status")
911 - return CaseOutResponse(cases=await list_cases_by_status(status.value, db), success=True, message="Cases retrieved successfully")
1389 +
1390 + logger.info(f"Listing cases by status {status} for user: {current_user.username} with role_id: {current_user.role_id}")
1391 +
1392 + # Get customer access filtering
1393 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1394 +
1395 + if "*" in accessible_customers:
1396 + # Admin/analyst - no filtering needed
1397 + cases = await list_cases_by_status(status.value, db)
1398 + else:
1399 + # Customer user - we need to filter, but there's no direct function for this
1400 + # We'll need to get all cases for the user and then filter by status
1401 + all_user_cases = await list_cases_for_user(current_user, db)
1402 + cases = [case for case in all_user_cases if case.case_status == status.value]
1403 +
1404 + return CaseOutResponse(cases=cases, success=True, message="Cases retrieved successfully")
1405
1406
1407 @incidents_db_operations_router.get("/case/assigned-to/{assigned_to}", response_model=CaseOutResponse)
915 -async def list_cases_by_assigned_to_endpoint(assigned_to: str, db: AsyncSession = Depends(get_db)):
916 - return CaseOutResponse(cases=await list_cases_by_assigned_to(assigned_to, db), success=True, message="Cases retrieved successfully")
1408 +async def list_cases_by_assigned_to_endpoint(
1409 + assigned_to: str,
1410 + current_user: User = Depends(AuthHandler().get_current_user),
1411 + db: AsyncSession = Depends(get_db)
1412 +):
1413 + """List cases by assigned user with customer access filtering"""
1414 + logger.info(f"Listing cases assigned to {assigned_to} for user: {current_user.username} with role_id: {current_user.role_id}")
1415 +
1416 + # Get customer access filtering
1417 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1418 +
1419 + if "*" in accessible_customers:
1420 + # Admin/analyst - no filtering needed
1421 + cases = await list_cases_by_assigned_to(assigned_to, db)
1422 + else:
1423 + # Customer user - filter by accessible customers
1424 + all_user_cases = await list_cases_for_user(current_user, db)
1425 + cases = [case for case in all_user_cases if case.assigned_to == assigned_to]
1426 +
1427 + return CaseOutResponse(cases=cases, success=True, message="Cases retrieved successfully")
1428
1429
1430 @incidents_db_operations_router.get("/case/asset/{asset_name}", response_model=CaseOutResponse)
920 -async def list_cases_by_asset_name_endpoint(asset_name: str, db: AsyncSession = Depends(get_db)):
921 - return CaseOutResponse(cases=await list_cases_by_asset_name(asset_name, db), success=True, message="Cases retrieved successfully")
1431 +async def list_cases_by_asset_name_endpoint(
1432 + asset_name: str,
1433 + current_user: User = Depends(AuthHandler().get_current_user),
1434 + db: AsyncSession = Depends(get_db)
1435 +):
1436 + """List cases by asset name with customer access filtering"""
1437 + logger.info(f"Listing cases by asset {asset_name} for user: {current_user.username} with role_id: {current_user.role_id}")
1438 +
1439 + # Get customer access filtering
1440 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1441 +
1442 + if "*" in accessible_customers:
1443 + # Admin/analyst - no filtering needed
1444 + cases = await list_cases_by_asset_name(asset_name, db)
1445 + else:
1446 + # Customer user - filter by accessible customers and asset name
1447 + all_user_cases = await list_cases_for_user(current_user, db)
1448 + cases = []
1449 + for case in all_user_cases:
1450 + # Check if any alert in the case has the specified asset name
1451 + for alert in case.alerts:
1452 + if alert.assets and any(asset.asset_name == asset_name for asset in alert.assets):
1453 + cases.append(case)
1454 + break
1455 +
1456 + return CaseOutResponse(cases=cases, success=True, message="Cases retrieved successfully")
1457
1458
1459 @incidents_db_operations_router.get("/case/customer/{customer_code}", response_model=CaseOutResponse)
925 -async def list_cases_by_customer_code_endpoint(customer_code: str, db: AsyncSession = Depends(get_db)):
1460 +async def list_cases_by_customer_code_endpoint(
1461 + customer_code: str,
1462 + current_user: User = Depends(customer_access_handler.require_customer_access()),
1463 + db: AsyncSession = Depends(get_db)
1464 +):
1465 + """List cases for specific customer (with access validation)"""
1466 + logger.info(f"Listing cases for customer {customer_code} for user: {current_user.username} with role_id: {current_user.role_id}")
1467 +
1468 + # Verify user has access to this specific customer
1469 + if not await customer_access_handler.check_customer_access(current_user, customer_code, db):
1470 + raise HTTPException(status_code=403, detail="Access denied to this customer")
1471 +
1472 return CaseOutResponse(cases=await list_cases_by_customer_code(customer_code, db), success=True, message="Cases retrieved successfully")
1473
1474
@@ -933,8 +1479,24 @@ async def list_all_case_data_store_files_endpoint(db: AsyncSession = Depends(get
1479
1480
1481 @incidents_db_operations_router.get("/case/data-store/{case_id}", response_model=ListCaseDataStoreResponse)
936 -async def list_case_data_store_files_endpoint(case_id: int, db: AsyncSession = Depends(get_db)):
937 - logger.info(f"Listing all files in the data store for case {case_id}")
1482 +async def list_case_data_store_files_endpoint(
1483 + case_id: int,
1484 + current_user: User = Depends(AuthHandler().get_current_user),
1485 + db: AsyncSession = Depends(get_db)
1486 +):
1487 + """List case data store files with customer access validation"""
1488 + logger.info(f"Listing files for case {case_id} for user: {current_user.username} with role_id: {current_user.role_id}")
1489 +
1490 + # Get the case first to check customer access
1491 + case = await get_case_by_id(case_id, db)
1492 +
1493 + # Check if user has access to this case's customer
1494 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
1495 + raise HTTPException(
1496 + status_code=403,
1497 + detail=f"Access denied to case {case_id} - insufficient customer permissions"
1498 + )
1499 +
1500 return ListCaseDataStoreResponse(
1501 case_data_store=await list_files_by_case_id(case_id, db),
1502 success=True,
@@ -943,7 +1505,25 @@ async def list_case_data_store_files_endpoint(case_id: int, db: AsyncSession = D
1505
1506
1507 @incidents_db_operations_router.get("/case/data-store/download/{case_id}/{file_name}")
946 -async def download_case_data_store_file_endpoint(case_id: int, file_name: str, db: AsyncSession = Depends(get_db)) -> StreamingResponse:
1508 +async def download_case_data_store_file_endpoint(
1509 + case_id: int,
1510 + file_name: str,
1511 + current_user: User = Depends(AuthHandler().get_current_user),
1512 + db: AsyncSession = Depends(get_db)
1513 +) -> StreamingResponse:
1514 + """Download case data store file with customer access validation"""
1515 + logger.info(f"Downloading file {file_name} from case {case_id} for user: {current_user.username} with role_id: {current_user.role_id}")
1516 +
1517 + # Get the case first to check customer access
1518 + case = await get_case_by_id(case_id, db)
1519 +
1520 + # Check if user has access to this case's customer
1521 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
1522 + raise HTTPException(
1523 + status_code=403,
1524 + detail=f"Access denied to case {case_id} - insufficient customer permissions"
1525 + )
1526 +
1527 file_bytes, file_content_type = await download_file_from_case(case_id, file_name, db)
1528 logger.info(f"Streaming file {file_name} from case {case_id}")
1529 output = io.BytesIO(file_bytes)
@@ -956,10 +1536,25 @@ async def download_case_data_store_file_endpoint(case_id: int, file_name: str, d
1536 async def upload_case_data_store_endpoint(
1537 case_id: int,
1538 file: UploadFile = File(...),
1539 + current_user: User = Depends(AuthHandler().get_current_user),
1540 db: AsyncSession = Depends(get_db),
1541 ):
1542 + """Upload file to case data store with customer access validation"""
1543 + logger.info(f"Uploading file {file.filename} to case {case_id} for user: {current_user.username} with role_id: {current_user.role_id}")
1544 +
1545 + # Get the case first to check customer access
1546 + case = await get_case_by_id(case_id, db)
1547 +
1548 + # Check if user has access to this case's customer
1549 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
1550 + raise HTTPException(
1551 + status_code=403,
1552 + detail=f"Access denied to case {case_id} - insufficient customer permissions"
1553 + )
1554 +
1555 if await file_exists(case_id, file.filename, db):
1556 raise HTTPException(status_code=400, detail="File name already exists for this case")
1557 +
1558 return CaseDataStoreResponse(
1559 case_data_store=await upload_file_to_case(case_id, file, db),
1560 success=True,
@@ -968,29 +1563,81 @@ async def upload_case_data_store_endpoint(
1563
1564
1565 @incidents_db_operations_router.delete("/case/data-store/{case_id}/{file_name}")
971 -async def delete_case_data_store_file_endpoint(case_id: int, file_name: str, db: AsyncSession = Depends(get_db)):
1566 +async def delete_case_data_store_file_endpoint(
1567 + case_id: int,
1568 + file_name: str,
1569 + current_user: User = Depends(AuthHandler().get_current_user),
1570 + db: AsyncSession = Depends(get_db)
1571 +):
1572 + """Delete case data store file with customer access validation"""
1573 + logger.info(f"Deleting file {file_name} from case {case_id} for user: {current_user.username} with role_id: {current_user.role_id}")
1574 +
1575 + # Get the case first to check customer access
1576 + case = await get_case_by_id(case_id, db)
1577 +
1578 + # Check if user has access to this case's customer
1579 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
1580 + raise HTTPException(
1581 + status_code=403,
1582 + detail=f"Access denied to case {case_id} - insufficient customer permissions"
1583 + )
1584 +
1585 await delete_file_from_case(case_id, file_name, db)
1586 return {"message": "File deleted successfully", "success": True}
1587
1588
1589 @incidents_db_operations_router.get("/case/{case_id}", response_model=CaseOutResponse)
977 -async def get_case_by_id_endpoint(case_id: int, db: AsyncSession = Depends(get_db)):
978 - return CaseOutResponse(cases=[await get_case_by_id(case_id, db)], success=True, message="Case retrieved successfully")
1590 +async def get_case_by_id_endpoint(
1591 + case_id: int,
1592 + current_user: User = Depends(AuthHandler().get_current_user),
1593 + db: AsyncSession = Depends(get_db)
1594 +):
1595 + """Get case by ID with customer access validation"""
1596 + logger.info(f"Getting case {case_id} for user: {current_user.username} with role_id: {current_user.role_id}")
1597 +
1598 + # Get the case first
1599 + case = await get_case_by_id(case_id, db)
1600 +
1601 + # Check if user has access to this case's customer
1602 + if not await customer_access_handler.check_customer_access(current_user, case.customer_code, db):
1603 + raise HTTPException(
1604 + status_code=403,
1605 + detail=f"Access denied to case {case_id} - insufficient customer permissions"
1606 + )
1607 +
1608 + return CaseOutResponse(cases=[case], success=True, message="Case retrieved successfully")
1609
1610
1611 @incidents_db_operations_router.post("/case/notification", response_model=CaseNotificationResponse)
982 -async def create_case_notification_endpoint(request: CaseNotificationCreate, db: AsyncSession = Depends(get_db)):
1612 +async def create_case_notification_endpoint(
1613 + request: CaseNotificationCreate,
1614 + current_user: User = Depends(AuthHandler().get_current_user),
1615 + db: AsyncSession = Depends(get_db)
1616 +):
1617 """
1618 + Create case notification with customer access validation.
1619 +
1620 This function collects the case details and then invokes the create_case_notification function to create a new case notification within the Shuffle Workflow.
1621
1622 Args:
1623 request (CaseNotificationCreate): The request object containing the case details.
1624 + current_user (User): Current authenticated user.
1625 db (AsyncSession, optional): The database session dependency.
1626
1627 Returns:
1628 CaseNotificationResponse: The response object containing the created case notification.
1629 """
1630 + logger.info(f"Creating case notification for case {request.case_id} for user: {current_user.username} with role_id: {current_user.role_id}")
1631 +
1632 case_details = await get_case_by_id(request.case_id, db)
1633 +
1634 + # Check if user has access to this case's customer
1635 + if not await customer_access_handler.check_customer_access(current_user, case_details.customer_code, db):
1636 + raise HTTPException(
1637 + status_code=403,
1638 + detail=f"Access denied to case {request.case_id} - insufficient customer permissions"
1639 + )
1640 +
1641 case_notification_payload = CreatedCaseNotificationPayload(
1642 case_name=case_details.case_name,
1643 case_description=case_details.case_description,
backend/app/incidents/schema/db_operations.py
+1 -1
@@ -257,7 +257,7 @@ class CommentCreate(BaseModel):
257 alert_id: int
258 comment: str
259 user_name: str
260 - created_at: datetime
260 + created_at: Optional[datetime] = None
261
262
263 class CommentEdit(BaseModel):
backend/app/incidents/services/db_operations.py
+162 -1
@@ -2,6 +2,7 @@ import hashlib
2 import io
3 import mimetypes
4 import os
5 +from datetime import datetime
6 from pathlib import Path
7 from typing import List
8 from typing import Optional
@@ -72,6 +73,8 @@ from app.incidents.schema.db_operations import UpdateCaseStatus
73 from app.integrations.alert_creation_settings.models.alert_creation_settings import (
74 AlertCreationSettings,
75 )
76 +from app.middleware.customer_access import customer_access_handler
77 +from app.auth.models.users import User
78
79
80 async def customer_code_valid(customer_code: str, db: AsyncSession) -> bool:
@@ -206,6 +209,40 @@ async def alerts_open_by_source(db: AsyncSession, source: str) -> int:
209 result = await db.execute(select(Alert).where((Alert.status == "OPEN") & (Alert.source == source)))
210 return len(result.scalars().all())
211
212 +async def alert_total_by_customer_codes(db: AsyncSession, customer_codes: List[str]) -> int:
213 + """Get total alerts for multiple customer codes"""
214 + result = await db.execute(select(Alert).where(Alert.customer_code.in_(customer_codes)))
215 + return len(result.scalars().all())
216 +
217 +
218 +async def alerts_closed_by_customer_codes(db: AsyncSession, customer_codes: List[str]) -> int:
219 + """Get closed alerts for multiple customer codes"""
220 + result = await db.execute(
221 + select(Alert).where(
222 + (Alert.status == "CLOSED") & (Alert.customer_code.in_(customer_codes))
223 + )
224 + )
225 + return len(result.scalars().all())
226 +
227 +
228 +async def alerts_in_progress_by_customer_codes(db: AsyncSession, customer_codes: List[str]) -> int:
229 + """Get in-progress alerts for multiple customer codes"""
230 + result = await db.execute(
231 + select(Alert).where(
232 + (Alert.status == "IN_PROGRESS") & (Alert.customer_code.in_(customer_codes))
233 + )
234 + )
235 + return len(result.scalars().all())
236 +
237 +
238 +async def alerts_open_by_customer_codes(db: AsyncSession, customer_codes: List[str]) -> int:
239 + """Get open alerts for multiple customer codes"""
240 + result = await db.execute(
241 + select(Alert).where(
242 + (Alert.status == "OPEN") & (Alert.customer_code.in_(customer_codes))
243 + )
244 + )
245 + return len(result.scalars().all())
246
247 async def alerts_total_multiple_filters(
248 db: AsyncSession,
@@ -828,7 +865,12 @@ async def create_comment(comment: CommentCreate, db: AsyncSession) -> Comment:
865 if not alert:
866 raise HTTPException(status_code=404, detail="Alert not found")
867
831 - db_comment = Comment(**comment.dict())
868 + # Create comment with automatic timestamp if not provided
869 + comment_data = comment.dict()
870 + if comment_data.get('created_at') is None:
871 + comment_data['created_at'] = datetime.utcnow()
872 +
873 + db_comment = Comment(**comment_data)
874 db.add(db_comment)
875 try:
876 await db.commit()
@@ -1933,6 +1975,125 @@ async def list_alerts_multiple_filters(
1975
1976 return alerts_out
1977
1978 +async def list_alerts_for_user(
1979 + user: User,
1980 + session: AsyncSession,
1981 + page: int = 1,
1982 + page_size: int = 25,
1983 + order: str = "desc",
1984 +) -> List[AlertOut]:
1985 + """List alerts filtered by user's customer access"""
1986 +
1987 + base_query = select(Alert).options(
1988 + selectinload(Alert.comments),
1989 + selectinload(Alert.assets),
1990 + selectinload(Alert.cases).selectinload(CaseAlertLink.case),
1991 + selectinload(Alert.tags).selectinload(AlertToTag.tag),
1992 + )
1993 +
1994 + # Apply customer filtering
1995 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
1996 + user, session, base_query, Alert.customer_code
1997 + )
1998 +
1999 + offset = (page - 1) * page_size
2000 + order_by = asc(Alert.id) if order == "asc" else desc(Alert.id)
2001 +
2002 + final_query = filtered_query.order_by(order_by).offset(offset).limit(page_size)
2003 + result = await session.execute(final_query)
2004 + alerts = result.scalars().all()
2005 +
2006 + # Convert to AlertOut objects
2007 + alerts_out = []
2008 + for alert in alerts:
2009 + comments = [CommentBase(**comment.__dict__) for comment in alert.comments]
2010 + assets = [AssetBase(**asset.__dict__) for asset in alert.assets]
2011 + tags = [AlertTagBase(**alert_to_tag.tag.__dict__) for alert_to_tag in alert.tags]
2012 + linked_cases = [LinkedCaseCreate(**case_alert_link.case.__dict__) for case_alert_link in alert.cases]
2013 +
2014 + alert_out = AlertOut(
2015 + id=alert.id,
2016 + alert_creation_time=alert.alert_creation_time,
2017 + time_closed=alert.time_closed,
2018 + alert_name=alert.alert_name,
2019 + alert_description=alert.alert_description,
2020 + status=alert.status,
2021 + customer_code=alert.customer_code,
2022 + source=alert.source,
2023 + assigned_to=alert.assigned_to,
2024 + comments=comments,
2025 + assets=assets,
2026 + tags=tags,
2027 + linked_cases=linked_cases,
2028 + )
2029 + alerts_out.append(alert_out)
2030 +
2031 + return alerts_out
2032 +
2033 +async def list_cases_for_user(
2034 + user: User,
2035 + session: AsyncSession,
2036 +) -> List[CaseOut]:
2037 + """List cases filtered by user's customer access"""
2038 +
2039 + base_query = select(Case).options(
2040 + selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.comments),
2041 + selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.assets),
2042 + selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.tags).selectinload(AlertToTag.tag),
2043 + selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.cases).selectinload(CaseAlertLink.case),
2044 + selectinload(Case.alerts).selectinload(CaseAlertLink.alert).selectinload(Alert.iocs).selectinload(AlertToIoC.ioc),
2045 + )
2046 +
2047 + # Apply customer filtering
2048 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
2049 + user, session, base_query, Case.customer_code
2050 + )
2051 +
2052 + result = await session.execute(filtered_query)
2053 + cases = result.scalars().all()
2054 +
2055 + # Convert to CaseOut objects (using same logic as list_cases)
2056 + cases_out = []
2057 + for case in cases:
2058 + alerts_out = []
2059 + for case_alert_link in case.alerts:
2060 + alert = case_alert_link.alert
2061 + comments = [CommentBase(**comment.__dict__) for comment in alert.comments]
2062 + assets = [AssetBase(**asset.__dict__) for asset in alert.assets]
2063 + tags = [AlertTagBase(**alert_to_tag.tag.__dict__) for alert_to_tag in alert.tags]
2064 + linked_cases = [LinkedCaseCreate(**case_alert_link.case.__dict__) for case_alert_link in alert.cases]
2065 + iocs = [IoCBase(**alert_to_ioc.ioc.__dict__) for alert_to_ioc in alert.iocs]
2066 + alert_out = AlertOut(
2067 + id=alert.id,
2068 + alert_creation_time=alert.alert_creation_time,
2069 + time_closed=alert.time_closed,
2070 + alert_name=alert.alert_name,
2071 + alert_description=alert.alert_description,
2072 + status=alert.status,
2073 + customer_code=alert.customer_code,
2074 + source=alert.source,
2075 + assigned_to=alert.assigned_to,
2076 + comments=comments,
2077 + assets=assets,
2078 + tags=tags,
2079 + linked_cases=linked_cases,
2080 + iocs=iocs,
2081 + )
2082 + alerts_out.append(alert_out)
2083 + case_out = CaseOut(
2084 + id=case.id,
2085 + case_name=case.case_name,
2086 + case_description=case.case_description,
2087 + assigned_to=case.assigned_to,
2088 + alerts=alerts_out,
2089 + case_creation_time=case.case_creation_time,
2090 + case_status=case.case_status,
2091 + customer_code=case.customer_code,
2092 + notification_invoked_number=case.notification_invoked_number or 0,
2093 + )
2094 + cases_out.append(case_out)
2095 + return cases_out
2096 +
2097
2098 async def delete_comments(alert_id: int, db: AsyncSession):
2099 result = await db.execute(select(Comment).where(Comment.alert_id == alert_id))
backend/app/middleware/customer_access.py new
+78
@@ -0,0 +1,78 @@
1 +# Create new file: app/middleware/customer_access.py
2 +from typing import List, Optional
3 +from fastapi import Depends, HTTPException
4 +from sqlalchemy.ext.asyncio import AsyncSession
5 +from sqlalchemy import select
6 +from loguru import logger
7 +
8 +from app.auth.models.users import User, UserCustomerAccess, RoleEnum
9 +from app.auth.utils import AuthHandler
10 +from app.db.db_session import get_db
11 +
12 +class CustomerAccessHandler:
13 +
14 + async def get_user_accessible_customers(self, user: User, session: AsyncSession) -> List[str]:
15 + """Get all customer codes accessible to a user"""
16 + # Admin and analyst users have access to all customers
17 + if user.role_id in [RoleEnum.admin, RoleEnum.analyst]:
18 + return ["*"] # Wildcard for all customers
19 +
20 + # Customer users only see their assigned customers
21 + if user.role_id == RoleEnum.customer_user:
22 + result = await session.execute(
23 + select(UserCustomerAccess.customer_code)
24 + .where(UserCustomerAccess.user_id == user.id)
25 + )
26 + return result.scalars().all()
27 +
28 + return [] # No access by default
29 +
30 + async def check_customer_access(self, user: User, customer_code: str, session: AsyncSession) -> bool:
31 + """Check if user has access to specific customer"""
32 + accessible_customers = await self.get_user_accessible_customers(user, session)
33 +
34 + # Wildcard access (admin/analyst)
35 + if "*" in accessible_customers:
36 + return True
37 +
38 + # Specific customer access
39 + return customer_code in accessible_customers
40 +
41 + async def filter_query_by_customer_access(
42 + self,
43 + user: User,
44 + session: AsyncSession,
45 + base_query,
46 + customer_code_field
47 + ):
48 + """Filter any query by user's customer access"""
49 + accessible_customers = await self.get_user_accessible_customers(user, session)
50 +
51 + # Admin/analyst see everything
52 + if "*" in accessible_customers:
53 + return base_query
54 +
55 + # Customer users see only their data
56 + if accessible_customers:
57 + return base_query.where(customer_code_field.in_(accessible_customers))
58 +
59 + # No access - return empty result
60 + return base_query.where(False)
61 +
62 + def require_customer_access(self, customer_code: Optional[str] = None):
63 + """FastAPI dependency to enforce customer access"""
64 + async def _check_access(
65 + current_user: User = Depends(AuthHandler().get_current_user),
66 + session: AsyncSession = Depends(get_db)
67 + ):
68 + if customer_code:
69 + if not await self.check_customer_access(current_user, customer_code, session):
70 + raise HTTPException(
71 + status_code=403,
72 + detail=f"Access denied to customer {customer_code}"
73 + )
74 + return current_user
75 + return _check_access
76 +
77 +# Create a singleton instance
78 +customer_access_handler = CustomerAccessHandler()
backend/app/routers/auth.py
+2
@@ -1,8 +1,10 @@
1 from fastapi import APIRouter
2
3 from app.auth.routes.auth import auth_router
4 +from app.auth.routes.customer_users import customer_users_router
5
6 # Instantiate the APIRouter
7 router = APIRouter()
8
9 router.include_router(auth_router, prefix="/auth", tags=["auth"])
10 +router.include_router(customer_users_router, prefix="/auth", tags=["customer_users"])
customer_portal/index.html new
+13
@@ -0,0 +1,13 @@
1 +<!doctype html>
2 +<html lang="en">
3 + <head>
4 + <meta charset="UTF-8" />
5 + <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 + <title>SOCFortress Customer Portal</title>
8 + </head>
9 + <body>
10 + <div id="app"></div>
11 + <script type="module" src="/src/main.ts"></script>
12 + </body>
13 +</html>
customer_portal/package.json new
+62
@@ -0,0 +1,62 @@
1 +{
2 + "name": "copilot-customer-portal",
3 + "type": "module",
4 + "version": "1.0.0",
5 + "private": true,
6 + "packageManager": "pnpm@10.13.1",
7 + "engines": {
8 + "node": ">=18.0.0"
9 + },
10 + "scripts": {
11 + "dev": "vite --host 0.0.0.0 --port 3001",
12 + "build": "run-p type-check \"build:only {@}\" --",
13 + "build:only": "vite build",
14 + "build:docker": "docker build .",
15 + "preview": "vite build && echo '' && vite preview --port 4174 --host",
16 + "preview:only": "vite preview --port 4174 --host",
17 + "type-check": "vue-tsc --build --force",
18 + "lint": "eslint . --fix",
19 + "format": "prettier --write src/",
20 + "start-server": "cd ../backend && /opt/venv/bin/python copilot.py",
21 + "start-frontend": "vite --host 0.0.0.0 --port 3001",
22 + "start": "concurrently \"pnpm start-server\" \"pnpm start-frontend\""
23 + },
24 + "dependencies": {
25 + "@ajoelp/json-to-formdata": "^1.5.0",
26 + "@vueuse/core": "^13.6.0",
27 + "axios": "^1.11.0",
28 + "dayjs": "^1.11.13",
29 + "jose": "^6.0.12",
30 + "lodash": "^4.17.21",
31 + "mitt": "^3.0.1",
32 + "naive-ui": "^2.42.0",
33 + "nanoid": "^5.1.5",
34 + "pinia": "^3.0.3",
35 + "pinia-plugin-persistedstate": "^4.4.1",
36 + "secure-ls": "^2.0.0",
37 + "vue": "^3.5.18",
38 + "vue-router": "^4.5.1"
39 + },
40 + "devDependencies": {
41 + "@antfu/eslint-config": "^5.0.0",
42 + "@iconify/vue": "^5.0.0",
43 + "@tailwindcss/vite": "^4.1.11",
44 + "@tsconfig/node20": "^20.1.6",
45 + "@types/lodash": "^4.17.20",
46 + "@types/node": "^24.1.0",
47 + "@vitejs/plugin-vue": "^6.0.1",
48 + "@vue/tsconfig": "^0.7.0",
49 + "concurrently": "^8.2.2",
50 + "eslint": "^9.32.0",
51 + "npm-run-all2": "^8.0.4",
52 + "prettier": "^3.6.2",
53 + "prettier-plugin-tailwindcss": "^0.6.14",
54 + "sass": "^1.89.2",
55 + "tailwindcss": "^4.1.11",
56 + "typescript": "~5.8.3",
57 + "vite": "^7.0.6",
58 + "vite-plugin-vue-devtools": "^8.0.0",
59 + "vite-svg-loader": "^5.1.0",
60 + "vue-tsc": "^3.0.4"
61 + }
62 +}
customer_portal/pnpm-lock.yaml new
+5999
@@ -0,0 +1,5999 @@
1 +lockfileVersion: '9.0'
2 +
3 +settings:
4 + autoInstallPeers: true
5 + excludeLinksFromLockfile: false
6 +
7 +importers:
8 +
9 + .:
10 + dependencies:
11 + '@ajoelp/json-to-formdata':
12 + specifier: ^1.5.0
13 + version: 1.5.0
14 + '@vueuse/core':
15 + specifier: ^13.6.0
16 + version: 13.9.0(vue@3.5.21(typescript@5.8.3))
17 + axios:
18 + specifier: ^1.11.0
19 + version: 1.12.2
20 + dayjs:
21 + specifier: ^1.11.13
22 + version: 1.11.18
23 + jose:
24 + specifier: ^6.0.12
25 + version: 6.1.0
26 + lodash:
27 + specifier: ^4.17.21
28 + version: 4.17.21
29 + mitt:
30 + specifier: ^3.0.1
31 + version: 3.0.1
32 + naive-ui:
33 + specifier: ^2.42.0
34 + version: 2.43.1(vue@3.5.21(typescript@5.8.3))
35 + nanoid:
36 + specifier: ^5.1.5
37 + version: 5.1.5
38 + pinia:
39 + specifier: ^3.0.3
40 + version: 3.0.3(typescript@5.8.3)(vue@3.5.21(typescript@5.8.3))
41 + pinia-plugin-persistedstate:
42 + specifier: ^4.4.1
43 + version: 4.5.0(pinia@3.0.3(typescript@5.8.3)(vue@3.5.21(typescript@5.8.3)))
44 + secure-ls:
45 + specifier: ^2.0.0
46 + version: 2.0.0
47 + vue:
48 + specifier: ^3.5.18
49 + version: 3.5.21(typescript@5.8.3)
50 + vue-router:
51 + specifier: ^4.5.1
52 + version: 4.5.1(vue@3.5.21(typescript@5.8.3))
53 + devDependencies:
54 + '@antfu/eslint-config':
55 + specifier: ^5.0.0
56 + version: 5.3.0(@vue/compiler-sfc@3.5.21)(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
57 + '@iconify/vue':
58 + specifier: ^5.0.0
59 + version: 5.0.0(vue@3.5.21(typescript@5.8.3))
60 + '@tailwindcss/vite':
61 + specifier: ^4.1.11
62 + version: 4.1.13(vite@7.1.5(@types/node@24.5.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.92.1)(yaml@2.8.1))
63 + '@tsconfig/node20':
64 + specifier: ^20.1.6
65 + version: 20.1.6
66 + '@types/lodash':
67 + specifier: ^4.17.20
68 + version: 4.17.20
69 + '@types/node':
70 + specifier: ^24.1.0
71 + version: 24.5.0
72 + '@vitejs/plugin-vue':
73 + specifier: ^6.0.1
74 + version: 6.0.1(vite@7.1.5(@types/node@24.5.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.92.1)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3))
75 + '@vue/tsconfig':
76 + specifier: ^0.7.0
77 + version: 0.7.0(typescript@5.8.3)(vue@3.5.21(typescript@5.8.3))
78 + concurrently:
79 + specifier: ^8.2.2
80 + version: 8.2.2
81 + eslint:
82 + specifier: ^9.32.0
83 + version: 9.35.0(jiti@2.5.1)
84 + npm-run-all2:
85 + specifier: ^8.0.4
86 + version: 8.0.4
87 + prettier:
88 + specifier: ^3.6.2
89 + version: 3.6.2
90 + prettier-plugin-tailwindcss:
91 + specifier: ^0.6.14
92 + version: 0.6.14(prettier@3.6.2)
93 + sass:
94 + specifier: ^1.89.2
95 + version: 1.92.1
96 + tailwindcss:
97 + specifier: ^4.1.11
98 + version: 4.1.13
99 + typescript:
100 + specifier: ~5.8.3
101 + version: 5.8.3
102 + vite:
103 + specifier: ^7.0.6
104 + version: 7.1.5(@types/node@24.5.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.92.1)(yaml@2.8.1)
105 + vite-plugin-vue-devtools:
106 + specifier: ^8.0.0
107 + version: 8.0.2(vite@7.1.5(@types/node@24.5.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.92.1)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3))
108 + vite-svg-loader:
109 + specifier: ^5.1.0
110 + version: 5.1.0(vue@3.5.21(typescript@5.8.3))
111 + vue-tsc:
112 + specifier: ^3.0.4
113 + version: 3.0.7(typescript@5.8.3)
114 +
115 +packages:
116 +
117 + '@ajoelp/json-to-formdata@1.5.0':
118 + resolution: {integrity: sha512-nrlfeTSL0X0dtx5r2KpzPiqLSIQquiiJjUKsQAKzWaCmO2QoYZCyb5ENZwF3YoffKronOCJr25mxaD8JRJmK8w==}
119 +
120 + '@antfu/eslint-config@5.3.0':
121 + resolution: {integrity: sha512-VzBemSi453rd06lF6gG6VkpP3HH7XKTf+sK6frSrGm7uMFkN57jry1XB074tQRKB3qOjhpsx3kKpWtOv9e5FnQ==}
122 + hasBin: true
123 + peerDependencies:
124 + '@eslint-react/eslint-plugin': ^1.38.4
125 + '@next/eslint-plugin-next': ^15.4.0-canary.115
126 + '@prettier/plugin-xml': ^3.4.1
127 + '@unocss/eslint-plugin': '>=0.50.0'
128 + astro-eslint-parser: ^1.0.2
129 + eslint: ^9.10.0
130 + eslint-plugin-astro: ^1.2.0
131 + eslint-plugin-format: '>=0.1.0'
132 + eslint-plugin-jsx-a11y: '>=6.10.2'
133 + eslint-plugin-react-hooks: ^5.2.0
134 + eslint-plugin-react-refresh: ^0.4.19
135 + eslint-plugin-solid: ^0.14.3
136 + eslint-plugin-svelte: '>=2.35.1'
137 + eslint-plugin-vuejs-accessibility: ^2.4.1
138 + prettier-plugin-astro: ^0.14.0
139 + prettier-plugin-slidev: ^1.0.5
140 + svelte-eslint-parser: '>=0.37.0'
141 + peerDependenciesMeta:
142 + '@eslint-react/eslint-plugin':
143 + optional: true
144 + '@next/eslint-plugin-next':
145 + optional: true
146 + '@prettier/plugin-xml':
147 + optional: true
148 + '@unocss/eslint-plugin':
149 + optional: true
150 + astro-eslint-parser:
151 + optional: true
152 + eslint-plugin-astro:
153 + optional: true
154 + eslint-plugin-format:
155 + optional: true
156 + eslint-plugin-jsx-a11y:
157 + optional: true
158 + eslint-plugin-react-hooks:
159 + optional: true
160 + eslint-plugin-react-refresh:
161 + optional: true
162 + eslint-plugin-solid:
163 + optional: true
164 + eslint-plugin-svelte:
165 + optional: true
166 + eslint-plugin-vuejs-accessibility:
167 + optional: true
168 + prettier-plugin-astro:
169 + optional: true
170 + prettier-plugin-slidev:
171 + optional: true
172 + svelte-eslint-parser:
173 + optional: true
174 +
175 + '@antfu/install-pkg@1.1.0':
176 + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
177 +
178 + '@babel/code-frame@7.27.1':
179 + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
180 + engines: {node: '>=6.9.0'}
181 +
182 + '@babel/compat-data@7.28.4':
183 + resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==}
184 + engines: {node: '>=6.9.0'}
185 +
186 + '@babel/core@7.28.4':
187 + resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==}
188 + engines: {node: '>=6.9.0'}
189 +
190 + '@babel/generator@7.28.3':
191 + resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==}
192 + engines: {node: '>=6.9.0'}
193 +
194 + '@babel/helper-annotate-as-pure@7.27.3':
195 + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==}
196 + engines: {node: '>=6.9.0'}
197 +
198 + '@babel/helper-compilation-targets@7.27.2':
199 + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==}
200 + engines: {node: '>=6.9.0'}
201 +
202 + '@babel/helper-create-class-features-plugin@7.28.3':
203 + resolution: {integrity: sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==}
204 + engines: {node: '>=6.9.0'}
205 + peerDependencies:
206 + '@babel/core': ^7.0.0
207 +
208 + '@babel/helper-globals@7.28.0':
209 + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
210 + engines: {node: '>=6.9.0'}
211 +
212 + '@babel/helper-member-expression-to-functions@7.27.1':
213 + resolution: {integrity: sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==}
214 + engines: {node: '>=6.9.0'}
215 +
216 + '@babel/helper-module-imports@7.27.1':
217 + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==}
218 + engines: {node: '>=6.9.0'}
219 +
220 + '@babel/helper-module-transforms@7.28.3':
221 + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==}
222 + engines: {node: '>=6.9.0'}
223 + peerDependencies:
224 + '@babel/core': ^7.0.0
225 +
226 + '@babel/helper-optimise-call-expression@7.27.1':
227 + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==}
228 + engines: {node: '>=6.9.0'}
229 +
230 + '@babel/helper-plugin-utils@7.27.1':
231 + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
232 + engines: {node: '>=6.9.0'}
233 +
234 + '@babel/helper-replace-supers@7.27.1':
235 + resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==}
236 + engines: {node: '>=6.9.0'}
237 + peerDependencies:
238 + '@babel/core': ^7.0.0
239 +
240 + '@babel/helper-skip-transparent-expression-wrappers@7.27.1':
241 + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==}
242 + engines: {node: '>=6.9.0'}
243 +
244 + '@babel/helper-string-parser@7.27.1':
245 + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
246 + engines: {node: '>=6.9.0'}
247 +
248 + '@babel/helper-validator-identifier@7.27.1':
249 + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
250 + engines: {node: '>=6.9.0'}
251 +
252 + '@babel/helper-validator-option@7.27.1':
253 + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
254 + engines: {node: '>=6.9.0'}
255 +
256 + '@babel/helpers@7.28.4':
257 + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==}
258 + engines: {node: '>=6.9.0'}
259 +
260 + '@babel/parser@7.28.4':
261 + resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==}
262 + engines: {node: '>=6.0.0'}
263 + hasBin: true
264 +
265 + '@babel/plugin-proposal-decorators@7.28.0':
266 + resolution: {integrity: sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg==}
267 + engines: {node: '>=6.9.0'}
268 + peerDependencies:
269 + '@babel/core': ^7.0.0-0
270 +
271 + '@babel/plugin-syntax-decorators@7.27.1':
272 + resolution: {integrity: sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==}
273 + engines: {node: '>=6.9.0'}
274 + peerDependencies:
275 + '@babel/core': ^7.0.0-0
276 +
277 + '@babel/plugin-syntax-import-attributes@7.27.1':
278 + resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==}
279 + engines: {node: '>=6.9.0'}
280 + peerDependencies:
281 + '@babel/core': ^7.0.0-0
282 +
283 + '@babel/plugin-syntax-import-meta@7.10.4':
284 + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==}
285 + peerDependencies:
286 + '@babel/core': ^7.0.0-0
287 +
288 + '@babel/plugin-syntax-jsx@7.27.1':
289 + resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==}
290 + engines: {node: '>=6.9.0'}
291 + peerDependencies:
292 + '@babel/core': ^7.0.0-0
293 +
294 + '@babel/plugin-syntax-typescript@7.27.1':
295 + resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==}
296 + engines: {node: '>=6.9.0'}
297 + peerDependencies:
298 + '@babel/core': ^7.0.0-0
299 +
300 + '@babel/plugin-transform-typescript@7.28.0':
301 + resolution: {integrity: sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==}
302 + engines: {node: '>=6.9.0'}
303 + peerDependencies:
304 + '@babel/core': ^7.0.0-0
305 +
306 + '@babel/runtime@7.28.4':
307 + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
308 + engines: {node: '>=6.9.0'}
309 +
310 + '@babel/template@7.27.2':
311 + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
312 + engines: {node: '>=6.9.0'}
313 +
314 + '@babel/traverse@7.28.4':
315 + resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==}
316 + engines: {node: '>=6.9.0'}
317 +
318 + '@babel/types@7.28.4':
319 + resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==}
320 + engines: {node: '>=6.9.0'}
321 +
322 + '@clack/core@0.5.0':
323 + resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==}
324 +
325 + '@clack/prompts@0.11.0':
326 + resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
327 +
328 + '@css-render/plugin-bem@0.15.14':
329 + resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==}
330 + peerDependencies:
331 + css-render: ~0.15.14
332 +
333 + '@css-render/vue3-ssr@0.15.14':
334 + resolution: {integrity: sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==}
335 + peerDependencies:
336 + vue: ^3.0.11
337 +
338 + '@emotion/hash@0.8.0':
339 + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==}
340 +
341 + '@es-joy/jsdoccomment@0.50.2':
342 + resolution: {integrity: sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==}
343 + engines: {node: '>=18'}
344 +
345 + '@es-joy/jsdoccomment@0.56.0':
346 + resolution: {integrity: sha512-c6EW+aA1w2rjqOMjbL93nZlwxp6c1Ln06vTYs5FjRRhmJXK8V/OrSXdT+pUr4aRYgjCgu8/OkiZr0tzeVrRSbw==}
347 + engines: {node: '>=20.11.0'}
348 +
349 + '@esbuild/aix-ppc64@0.25.9':
350 + resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
351 + engines: {node: '>=18'}
352 + cpu: [ppc64]
353 + os: [aix]
354 +
355 + '@esbuild/android-arm64@0.25.9':
356 + resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==}
357 + engines: {node: '>=18'}
358 + cpu: [arm64]
359 + os: [android]
360 +
361 + '@esbuild/android-arm@0.25.9':
362 + resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==}
363 + engines: {node: '>=18'}
364 + cpu: [arm]
365 + os: [android]
366 +
367 + '@esbuild/android-x64@0.25.9':
368 + resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==}
369 + engines: {node: '>=18'}
370 + cpu: [x64]
371 + os: [android]
372 +
373 + '@esbuild/darwin-arm64@0.25.9':
374 + resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==}
375 + engines: {node: '>=18'}
376 + cpu: [arm64]
377 + os: [darwin]
378 +
379 + '@esbuild/darwin-x64@0.25.9':
380 + resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==}
381 + engines: {node: '>=18'}
382 + cpu: [x64]
383 + os: [darwin]
384 +
385 + '@esbuild/freebsd-arm64@0.25.9':
386 + resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==}
387 + engines: {node: '>=18'}
388 + cpu: [arm64]
389 + os: [freebsd]
390 +
391 + '@esbuild/freebsd-x64@0.25.9':
392 + resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==}
393 + engines: {node: '>=18'}
394 + cpu: [x64]
395 + os: [freebsd]
396 +
397 + '@esbuild/linux-arm64@0.25.9':
398 + resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==}
399 + engines: {node: '>=18'}
400 + cpu: [arm64]
401 + os: [linux]
402 +
403 + '@esbuild/linux-arm@0.25.9':
404 + resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==}
405 + engines: {node: '>=18'}
406 + cpu: [arm]
407 + os: [linux]
408 +
409 + '@esbuild/linux-ia32@0.25.9':
410 + resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==}
411 + engines: {node: '>=18'}
412 + cpu: [ia32]
413 + os: [linux]
414 +
415 + '@esbuild/linux-loong64@0.25.9':
416 + resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==}
417 + engines: {node: '>=18'}
418 + cpu: [loong64]
419 + os: [linux]
420 +
421 + '@esbuild/linux-mips64el@0.25.9':
422 + resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==}
423 + engines: {node: '>=18'}
424 + cpu: [mips64el]
425 + os: [linux]
426 +
427 + '@esbuild/linux-ppc64@0.25.9':
428 + resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==}
429 + engines: {node: '>=18'}
430 + cpu: [ppc64]
431 + os: [linux]
432 +
433 + '@esbuild/linux-riscv64@0.25.9':
434 + resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==}
435 + engines: {node: '>=18'}
436 + cpu: [riscv64]
437 + os: [linux]
438 +
439 + '@esbuild/linux-s390x@0.25.9':
440 + resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==}
441 + engines: {node: '>=18'}
442 + cpu: [s390x]
443 + os: [linux]
444 +
445 + '@esbuild/linux-x64@0.25.9':
446 + resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==}
447 + engines: {node: '>=18'}
448 + cpu: [x64]
449 + os: [linux]
450 +
451 + '@esbuild/netbsd-arm64@0.25.9':
452 + resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==}
453 + engines: {node: '>=18'}
454 + cpu: [arm64]
455 + os: [netbsd]
456 +
457 + '@esbuild/netbsd-x64@0.25.9':
458 + resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==}
459 + engines: {node: '>=18'}
460 + cpu: [x64]
461 + os: [netbsd]
462 +
463 + '@esbuild/openbsd-arm64@0.25.9':
464 + resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==}
465 + engines: {node: '>=18'}
466 + cpu: [arm64]
467 + os: [openbsd]
468 +
469 + '@esbuild/openbsd-x64@0.25.9':
470 + resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==}
471 + engines: {node: '>=18'}
472 + cpu: [x64]
473 + os: [openbsd]
474 +
475 + '@esbuild/openharmony-arm64@0.25.9':
476 + resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==}
477 + engines: {node: '>=18'}
478 + cpu: [arm64]
479 + os: [openharmony]
480 +
481 + '@esbuild/sunos-x64@0.25.9':
482 + resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==}
483 + engines: {node: '>=18'}
484 + cpu: [x64]
485 + os: [sunos]
486 +
487 + '@esbuild/win32-arm64@0.25.9':
488 + resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==}
489 + engines: {node: '>=18'}
490 + cpu: [arm64]
491 + os: [win32]
492 +
493 + '@esbuild/win32-ia32@0.25.9':
494 + resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==}
495 + engines: {node: '>=18'}
496 + cpu: [ia32]
497 + os: [win32]
498 +
499 + '@esbuild/win32-x64@0.25.9':
500 + resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==}
501 + engines: {node: '>=18'}
502 + cpu: [x64]
503 + os: [win32]
504 +
505 + '@eslint-community/eslint-plugin-eslint-comments@4.5.0':
506 + resolution: {integrity: sha512-MAhuTKlr4y/CE3WYX26raZjy+I/kS2PLKSzvfmDCGrBLTFHOYwqROZdr4XwPgXwX3K9rjzMr4pSmUWGnzsUyMg==}
507 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
508 + peerDependencies:
509 + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0
510 +
511 + '@eslint-community/eslint-utils@4.9.0':
512 + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==}
513 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
514 + peerDependencies:
515 + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
516 +
517 + '@eslint-community/regexpp@4.12.1':
518 + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
519 + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
520 +
521 + '@eslint/compat@1.3.2':
522 + resolution: {integrity: sha512-jRNwzTbd6p2Rw4sZ1CgWRS8YMtqG15YyZf7zvb6gY2rB2u6n+2Z+ELW0GtL0fQgyl0pr4Y/BzBfng/BdsereRA==}
523 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
524 + peerDependencies:
525 + eslint: ^8.40 || 9
526 + peerDependenciesMeta:
527 + eslint:
528 + optional: true
529 +
530 + '@eslint/config-array@0.21.0':
531 + resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==}
532 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
533 +
534 + '@eslint/config-helpers@0.3.1':
535 + resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==}
536 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
537 +
538 + '@eslint/core@0.15.2':
539 + resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==}
540 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
541 +
542 + '@eslint/eslintrc@3.3.1':
543 + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==}
544 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
545 +
546 + '@eslint/js@9.35.0':
547 + resolution: {integrity: sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==}
548 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
549 +
550 + '@eslint/markdown@7.2.0':
551 + resolution: {integrity: sha512-cmDloByulvKzofM0tIkSGWwxMcrKOLsXZC+EM0FLkRIrxKzW+2RkZAt9TAh37EtQRmx1M4vjBEmlC6R0wiGkog==}
552 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
553 +
554 + '@eslint/object-schema@2.1.6':
555 + resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
556 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
557 +
558 + '@eslint/plugin-kit@0.3.5':
559 + resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
560 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
561 +
562 + '@humanfs/core@0.19.1':
563 + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
564 + engines: {node: '>=18.18.0'}
565 +
566 + '@humanfs/node@0.16.7':
567 + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
568 + engines: {node: '>=18.18.0'}
569 +
570 + '@humanwhocodes/module-importer@1.0.1':
571 + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
572 + engines: {node: '>=12.22'}
573 +
574 + '@humanwhocodes/retry@0.4.3':
575 + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
576 + engines: {node: '>=18.18'}
577 +
578 + '@iconify/types@2.0.0':
579 + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
580 +
581 + '@iconify/vue@5.0.0':
582 + resolution: {integrity: sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==}
583 + peerDependencies:
584 + vue: '>=3'
585 +
586 + '@isaacs/fs-minipass@4.0.1':
587 + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
588 + engines: {node: '>=18.0.0'}
589 +
590 + '@jridgewell/gen-mapping@0.3.13':
591 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
592 +
593 + '@jridgewell/remapping@2.3.5':
594 + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
595 +
596 + '@jridgewell/resolve-uri@3.1.2':
597 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
598 + engines: {node: '>=6.0.0'}
599 +
600 + '@jridgewell/sourcemap-codec@1.5.5':
601 + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
602 +
603 + '@jridgewell/trace-mapping@0.3.31':
604 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
605 +
606 + '@juggle/resize-observer@3.4.0':
607 + resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
608 +
609 + '@nodelib/fs.scandir@2.1.5':
610 + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
611 + engines: {node: '>= 8'}
612 +
613 + '@nodelib/fs.stat@2.0.5':
614 + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
615 + engines: {node: '>= 8'}
616 +
617 + '@nodelib/fs.walk@1.2.8':
618 + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
619 + engines: {node: '>= 8'}
620 +
621 + '@parcel/watcher-android-arm64@2.5.1':
622 + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
623 + engines: {node: '>= 10.0.0'}
624 + cpu: [arm64]
625 + os: [android]
626 +
627 + '@parcel/watcher-darwin-arm64@2.5.1':
628 + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==}
629 + engines: {node: '>= 10.0.0'}
630 + cpu: [arm64]
631 + os: [darwin]
632 +
633 + '@parcel/watcher-darwin-x64@2.5.1':
634 + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==}
635 + engines: {node: '>= 10.0.0'}
636 + cpu: [x64]
637 + os: [darwin]
638 +
639 + '@parcel/watcher-freebsd-x64@2.5.1':
640 + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==}
641 + engines: {node: '>= 10.0.0'}
642 + cpu: [x64]
643 + os: [freebsd]
644 +
645 + '@parcel/watcher-linux-arm-glibc@2.5.1':
646 + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==}
647 + engines: {node: '>= 10.0.0'}
648 + cpu: [arm]
649 + os: [linux]
650 +
651 + '@parcel/watcher-linux-arm-musl@2.5.1':
652 + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
653 + engines: {node: '>= 10.0.0'}
654 + cpu: [arm]
655 + os: [linux]
656 +
657 + '@parcel/watcher-linux-arm64-glibc@2.5.1':
658 + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
659 + engines: {node: '>= 10.0.0'}
660 + cpu: [arm64]
661 + os: [linux]
662 +
663 + '@parcel/watcher-linux-arm64-musl@2.5.1':
664 + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
665 + engines: {node: '>= 10.0.0'}
666 + cpu: [arm64]
667 + os: [linux]
668 +
669 + '@parcel/watcher-linux-x64-glibc@2.5.1':
670 + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
671 + engines: {node: '>= 10.0.0'}
672 + cpu: [x64]
673 + os: [linux]
674 +
675 + '@parcel/watcher-linux-x64-musl@2.5.1':
676 + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
677 + engines: {node: '>= 10.0.0'}
678 + cpu: [x64]
679 + os: [linux]
680 +
681 + '@parcel/watcher-win32-arm64@2.5.1':
682 + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
683 + engines: {node: '>= 10.0.0'}
684 + cpu: [arm64]
685 + os: [win32]
686 +
687 + '@parcel/watcher-win32-ia32@2.5.1':
688 + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==}
689 + engines: {node: '>= 10.0.0'}
690 + cpu: [ia32]
691 + os: [win32]
692 +
693 + '@parcel/watcher-win32-x64@2.5.1':
694 + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==}
695 + engines: {node: '>= 10.0.0'}
696 + cpu: [x64]
697 + os: [win32]
698 +
699 + '@parcel/watcher@2.5.1':
700 + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
701 + engines: {node: '>= 10.0.0'}
702 +
703 + '@pkgr/core@0.2.9':
704 + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
705 + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
706 +
707 + '@polka/url@1.0.0-next.29':
708 + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
709 +
710 + '@rolldown/pluginutils@1.0.0-beta.29':
711 + resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==}
712 +
713 + '@rollup/rollup-android-arm-eabi@4.50.2':
714 + resolution: {integrity: sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==}
715 + cpu: [arm]
716 + os: [android]
717 +
718 + '@rollup/rollup-android-arm64@4.50.2':
719 + resolution: {integrity: sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==}
720 + cpu: [arm64]
721 + os: [android]
722 +
723 + '@rollup/rollup-darwin-arm64@4.50.2':
724 + resolution: {integrity: sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==}
725 + cpu: [arm64]
726 + os: [darwin]
727 +
728 + '@rollup/rollup-darwin-x64@4.50.2':
729 + resolution: {integrity: sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==}
730 + cpu: [x64]
731 + os: [darwin]
732 +
733 + '@rollup/rollup-freebsd-arm64@4.50.2':
734 + resolution: {integrity: sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==}
735 + cpu: [arm64]
736 + os: [freebsd]
737 +
738 + '@rollup/rollup-freebsd-x64@4.50.2':
739 + resolution: {integrity: sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==}
740 + cpu: [x64]
741 + os: [freebsd]
742 +
743 + '@rollup/rollup-linux-arm-gnueabihf@4.50.2':
744 + resolution: {integrity: sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==}
745 + cpu: [arm]
746 + os: [linux]
747 +
748 + '@rollup/rollup-linux-arm-musleabihf@4.50.2':
749 + resolution: {integrity: sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==}
750 + cpu: [arm]
751 + os: [linux]
752 +
753 + '@rollup/rollup-linux-arm64-gnu@4.50.2':
754 + resolution: {integrity: sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==}
755 + cpu: [arm64]
756 + os: [linux]
757 +
758 + '@rollup/rollup-linux-arm64-musl@4.50.2':
759 + resolution: {integrity: sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==}
760 + cpu: [arm64]
761 + os: [linux]
762 +
763 + '@rollup/rollup-linux-loong64-gnu@4.50.2':
764 + resolution: {integrity: sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==}
765 + cpu: [loong64]
766 + os: [linux]
767 +
768 + '@rollup/rollup-linux-ppc64-gnu@4.50.2':
769 + resolution: {integrity: sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==}
770 + cpu: [ppc64]
771 + os: [linux]
772 +
773 + '@rollup/rollup-linux-riscv64-gnu@4.50.2':
774 + resolution: {integrity: sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==}
775 + cpu: [riscv64]
776 + os: [linux]
777 +
778 + '@rollup/rollup-linux-riscv64-musl@4.50.2':
779 + resolution: {integrity: sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==}
780 + cpu: [riscv64]
781 + os: [linux]
782 +
783 + '@rollup/rollup-linux-s390x-gnu@4.50.2':
784 + resolution: {integrity: sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==}
785 + cpu: [s390x]
786 + os: [linux]
787 +
788 + '@rollup/rollup-linux-x64-gnu@4.50.2':
789 + resolution: {integrity: sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==}
790 + cpu: [x64]
791 + os: [linux]
792 +
793 + '@rollup/rollup-linux-x64-musl@4.50.2':
794 + resolution: {integrity: sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==}
795 + cpu: [x64]
796 + os: [linux]
797 +
798 + '@rollup/rollup-openharmony-arm64@4.50.2':
799 + resolution: {integrity: sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==}
800 + cpu: [arm64]
801 + os: [openharmony]
802 +
803 + '@rollup/rollup-win32-arm64-msvc@4.50.2':
804 + resolution: {integrity: sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==}
805 + cpu: [arm64]
806 + os: [win32]
807 +
808 + '@rollup/rollup-win32-ia32-msvc@4.50.2':
809 + resolution: {integrity: sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==}
810 + cpu: [ia32]
811 + os: [win32]
812 +
813 + '@rollup/rollup-win32-x64-msvc@4.50.2':
814 + resolution: {integrity: sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA==}
815 + cpu: [x64]
816 + os: [win32]
817 +
818 + '@sec-ant/readable-stream@0.4.1':
819 + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
820 +
821 + '@sindresorhus/merge-streams@4.0.0':
822 + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
823 + engines: {node: '>=18'}
824 +
825 + '@stylistic/eslint-plugin@5.3.1':
826 + resolution: {integrity: sha512-Ykums1VYonM0TgkD0VteVq9mrlO2FhF48MDJnPyv3MktIB2ydtuhlO0AfWm7xnW1kyf5bjOqA6xc7JjviuVTxg==}
827 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
828 + peerDependencies:
829 + eslint: '>=9.0.0'
830 +
831 + '@tailwindcss/node@4.1.13':
832 + resolution: {integrity: sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==}
833 +
834 + '@tailwindcss/oxide-android-arm64@4.1.13':
835 + resolution: {integrity: sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==}
836 + engines: {node: '>= 10'}
837 + cpu: [arm64]
838 + os: [android]
839 +
840 + '@tailwindcss/oxide-darwin-arm64@4.1.13':
841 + resolution: {integrity: sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==}
842 + engines: {node: '>= 10'}
843 + cpu: [arm64]
844 + os: [darwin]
845 +
846 + '@tailwindcss/oxide-darwin-x64@4.1.13':
847 + resolution: {integrity: sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==}
848 + engines: {node: '>= 10'}
849 + cpu: [x64]
850 + os: [darwin]
851 +
852 + '@tailwindcss/oxide-freebsd-x64@4.1.13':
853 + resolution: {integrity: sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==}
854 + engines: {node: '>= 10'}
855 + cpu: [x64]
856 + os: [freebsd]
857 +
858 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13':
859 + resolution: {integrity: sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==}
860 + engines: {node: '>= 10'}
861 + cpu: [arm]
862 + os: [linux]
863 +
864 + '@tailwindcss/oxide-linux-arm64-gnu@4.1.13':
865 + resolution: {integrity: sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==}
866 + engines: {node: '>= 10'}
867 + cpu: [arm64]
868 + os: [linux]
869 +
870 + '@tailwindcss/oxide-linux-arm64-musl@4.1.13':
871 + resolution: {integrity: sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==}
872 + engines: {node: '>= 10'}
873 + cpu: [arm64]
874 + os: [linux]
875 +
876 + '@tailwindcss/oxide-linux-x64-gnu@4.1.13':
877 + resolution: {integrity: sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==}
878 + engines: {node: '>= 10'}
879 + cpu: [x64]
880 + os: [linux]
881 +
882 + '@tailwindcss/oxide-linux-x64-musl@4.1.13':
883 + resolution: {integrity: sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==}
884 + engines: {node: '>= 10'}
885 + cpu: [x64]
886 + os: [linux]
887 +
888 + '@tailwindcss/oxide-wasm32-wasi@4.1.13':
889 + resolution: {integrity: sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==}
890 + engines: {node: '>=14.0.0'}
891 + cpu: [wasm32]
892 + bundledDependencies:
893 + - '@napi-rs/wasm-runtime'
894 + - '@emnapi/core'
895 + - '@emnapi/runtime'
896 + - '@tybys/wasm-util'
897 + - '@emnapi/wasi-threads'
898 + - tslib
899 +
900 + '@tailwindcss/oxide-win32-arm64-msvc@4.1.13':
901 + resolution: {integrity: sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==}
902 + engines: {node: '>= 10'}
903 + cpu: [arm64]
904 + os: [win32]
905 +
906 + '@tailwindcss/oxide-win32-x64-msvc@4.1.13':
907 + resolution: {integrity: sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==}
908 + engines: {node: '>= 10'}
909 + cpu: [x64]
910 + os: [win32]
911 +
912 + '@tailwindcss/oxide@4.1.13':
913 + resolution: {integrity: sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==}
914 + engines: {node: '>= 10'}
915 +
916 + '@tailwindcss/vite@4.1.13':
917 + resolution: {integrity: sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==}
918 + peerDependencies:
919 + vite: ^5.2.0 || ^6 || ^7
920 +
921 + '@trysound/sax@0.2.0':
922 + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
923 + engines: {node: '>=10.13.0'}
924 +
925 + '@tsconfig/node20@20.1.6':
926 + resolution: {integrity: sha512-sz+Hqx9zwZDpZIV871WSbUzSqNIsXzghZydypnfgzPKLltVJfkINfUeTct31n/tTSa9ZE1ZOfKdRre1uHHquYQ==}
927 +
928 + '@types/debug@4.1.12':
929 + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
930 +
931 + '@types/estree@1.0.8':
932 + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
933 +
934 + '@types/json-schema@7.0.15':
935 + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
936 +
937 + '@types/katex@0.16.7':
938 + resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
939 +
940 + '@types/lodash-es@4.17.12':
941 + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
942 +
943 + '@types/lodash@4.17.20':
944 + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
945 +
946 + '@types/mdast@4.0.4':
947 + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
948 +
949 + '@types/ms@2.1.0':
950 + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
951 +
952 + '@types/node@24.5.0':
953 + resolution: {integrity: sha512-y1dMvuvJspJiPSDZUQ+WMBvF7dpnEqN4x9DDC9ie5Fs/HUZJA3wFp7EhHoVaKX/iI0cRoECV8X2jL8zi0xrHCg==}
954 +
955 + '@types/unist@3.0.3':
956 + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
957 +
958 + '@types/web-bluetooth@0.0.21':
959 + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
960 +
961 + '@typescript-eslint/eslint-plugin@8.44.0':
962 + resolution: {integrity: sha512-EGDAOGX+uwwekcS0iyxVDmRV9HX6FLSM5kzrAToLTsr9OWCIKG/y3lQheCq18yZ5Xh78rRKJiEpP0ZaCs4ryOQ==}
963 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
964 + peerDependencies:
965 + '@typescript-eslint/parser': ^8.44.0
966 + eslint: ^8.57.0 || ^9.0.0
967 + typescript: '>=4.8.4 <6.0.0'
968 +
969 + '@typescript-eslint/parser@8.44.0':
970 + resolution: {integrity: sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==}
971 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
972 + peerDependencies:
973 + eslint: ^8.57.0 || ^9.0.0
974 + typescript: '>=4.8.4 <6.0.0'
975 +
976 + '@typescript-eslint/project-service@8.44.0':
977 + resolution: {integrity: sha512-ZeaGNraRsq10GuEohKTo4295Z/SuGcSq2LzfGlqiuEvfArzo/VRrT0ZaJsVPuKZ55lVbNk8U6FcL+ZMH8CoyVA==}
978 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
979 + peerDependencies:
980 + typescript: '>=4.8.4 <6.0.0'
981 +
982 + '@typescript-eslint/scope-manager@8.44.0':
983 + resolution: {integrity: sha512-87Jv3E+al8wpD+rIdVJm/ItDBe/Im09zXIjFoipOjr5gHUhJmTzfFLuTJ/nPTMc2Srsroy4IBXwcTCHyRR7KzA==}
984 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
985 +
986 + '@typescript-eslint/tsconfig-utils@8.44.0':
987 + resolution: {integrity: sha512-x5Y0+AuEPqAInc6yd0n5DAcvtoQ/vyaGwuX5HE9n6qAefk1GaedqrLQF8kQGylLUb9pnZyLf+iEiL9fr8APDtQ==}
988 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
989 + peerDependencies:
990 + typescript: '>=4.8.4 <6.0.0'
991 +
992 + '@typescript-eslint/type-utils@8.44.0':
993 + resolution: {integrity: sha512-9cwsoSxJ8Sak67Be/hD2RNt/fsqmWnNE1iHohG8lxqLSNY8xNfyY7wloo5zpW3Nu9hxVgURevqfcH6vvKCt6yg==}
994 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
995 + peerDependencies:
996 + eslint: ^8.57.0 || ^9.0.0
997 + typescript: '>=4.8.4 <6.0.0'
998 +
999 + '@typescript-eslint/types@8.44.0':
1000 + resolution: {integrity: sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==}
1001 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1002 +
1003 + '@typescript-eslint/typescript-estree@8.44.0':
1004 + resolution: {integrity: sha512-lqNj6SgnGcQZwL4/SBJ3xdPEfcBuhCG8zdcwCPgYcmiPLgokiNDKlbPzCwEwu7m279J/lBYWtDYL+87OEfn8Jw==}
1005 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1006 + peerDependencies:
1007 + typescript: '>=4.8.4 <6.0.0'
1008 +
1009 + '@typescript-eslint/utils@8.44.0':
1010 + resolution: {integrity: sha512-nktOlVcg3ALo0mYlV+L7sWUD58KG4CMj1rb2HUVOO4aL3K/6wcD+NERqd0rrA5Vg06b42YhF6cFxeixsp9Riqg==}
1011 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1012 + peerDependencies:
1013 + eslint: ^8.57.0 || ^9.0.0
1014 + typescript: '>=4.8.4 <6.0.0'
1015 +
1016 + '@typescript-eslint/visitor-keys@8.44.0':
1017 + resolution: {integrity: sha512-zaz9u8EJ4GBmnehlrpoKvj/E3dNbuQ7q0ucyZImm3cLqJ8INTc970B1qEqDX/Rzq65r3TvVTN7kHWPBoyW7DWw==}
1018 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1019 +
1020 + '@vitejs/plugin-vue@6.0.1':
1021 + resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==}
1022 + engines: {node: ^20.19.0 || >=22.12.0}
1023 + peerDependencies:
1024 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0
1025 + vue: ^3.2.25
1026 +
1027 + '@vitest/eslint-plugin@1.3.10':
1028 + resolution: {integrity: sha512-tLxetdTgQJOiJQpkdPc9/jK03NCJeMZX+vX6aSGepsYJyla6UcM/G7E8I5Uui0t+LCKHGUIohV8EEuCobCnJuQ==}
1029 + peerDependencies:
1030 + eslint: '>= 8.57.0'
1031 + typescript: '>= 5.0.0'
1032 + vitest: '*'
1033 + peerDependenciesMeta:
1034 + typescript:
1035 + optional: true
1036 + vitest:
1037 + optional: true
1038 +
1039 + '@volar/language-core@2.4.23':
1040 + resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==}
1041 +
1042 + '@volar/source-map@2.4.23':
1043 + resolution: {integrity: sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==}
1044 +
1045 + '@volar/typescript@2.4.23':
1046 + resolution: {integrity: sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==}
1047 +
1048 + '@vue/babel-helper-vue-transform-on@1.5.0':
1049 + resolution: {integrity: sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==}
1050 +
1051 + '@vue/babel-plugin-jsx@1.5.0':
1052 + resolution: {integrity: sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==}
1053 + peerDependencies:
1054 + '@babel/core': ^7.0.0-0
1055 + peerDependenciesMeta:
1056 + '@babel/core':
1057 + optional: true
1058 +
1059 + '@vue/babel-plugin-resolve-type@1.5.0':
1060 + resolution: {integrity: sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==}
1061 + peerDependencies:
1062 + '@babel/core': ^7.0.0-0
1063 +
1064 + '@vue/compiler-core@3.5.21':
1065 + resolution: {integrity: sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==}
1066 +
1067 + '@vue/compiler-dom@3.5.21':
1068 + resolution: {integrity: sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==}
1069 +
1070 + '@vue/compiler-sfc@3.5.21':
1071 + resolution: {integrity: sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==}
1072 +
1073 + '@vue/compiler-ssr@3.5.21':
1074 + resolution: {integrity: sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==}
1075 +
1076 + '@vue/compiler-vue2@2.7.16':
1077 + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
1078 +
1079 + '@vue/devtools-api@6.6.4':
1080 + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
1081 +
1082 + '@vue/devtools-api@7.7.7':
1083 + resolution: {integrity: sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==}
1084 +
1085 + '@vue/devtools-core@8.0.2':
1086 + resolution: {integrity: sha512-V7eKTTHoS6KfK8PSGMLZMhGv/9yNDrmv6Qc3r71QILulnzPnqK2frsTyx3e2MrhdUZnENPEm6hcb4z0GZOqNhw==}
1087 + peerDependencies:
1088 + vue: ^3.0.0
1089 +
1090 + '@vue/devtools-kit@7.7.7':
1091 + resolution: {integrity: sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==}
1092 +
1093 + '@vue/devtools-kit@8.0.2':
1094 + resolution: {integrity: sha512-yjZKdEmhJzQqbOh4KFBfTOQjDPMrjjBNCnHBvnTGJX+YLAqoUtY2J+cg7BE+EA8KUv8LprECq04ts75wCoIGWA==}
1095 +
1096 + '@vue/devtools-shared@7.7.7':
1097 + resolution: {integrity: sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==}
1098 +
1099 + '@vue/devtools-shared@8.0.2':
1100 + resolution: {integrity: sha512-mLU0QVdy5Lp40PMGSixDw/Kbd6v5dkQXltd2r+mdVQV7iUog2NlZuLxFZApFZ/mObUBDhoCpf0T3zF2FWWdeHw==}
1101 +
1102 + '@vue/language-core@3.0.7':
1103 + resolution: {integrity: sha512-0sqqyqJ0Gn33JH3TdIsZLCZZ8Gr4kwlg8iYOnOrDDkJKSjFurlQY/bEFQx5zs7SX2C/bjMkmPYq/NiyY1fTOkw==}
1104 + peerDependencies:
1105 + typescript: '*'
1106 + peerDependenciesMeta:
1107 + typescript:
1108 + optional: true
1109 +
1110 + '@vue/reactivity@3.5.21':
1111 + resolution: {integrity: sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==}
1112 +
1113 + '@vue/runtime-core@3.5.21':
1114 + resolution: {integrity: sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==}
1115 +
1116 + '@vue/runtime-dom@3.5.21':
1117 + resolution: {integrity: sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==}
1118 +
1119 + '@vue/server-renderer@3.5.21':
1120 + resolution: {integrity: sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==}
1121 + peerDependencies:
1122 + vue: 3.5.21
1123 +
1124 + '@vue/shared@3.5.21':
1125 + resolution: {integrity: sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==}
1126 +
1127 + '@vue/tsconfig@0.7.0':
1128 + resolution: {integrity: sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==}
1129 + peerDependencies:
1130 + typescript: 5.x
1131 + vue: ^3.4.0
1132 + peerDependenciesMeta:
1133 + typescript:
1134 + optional: true
1135 + vue:
1136 + optional: true
1137 +
1138 + '@vueuse/core@13.9.0':
1139 + resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
1140 + peerDependencies:
1141 + vue: ^3.5.0
1142 +
1143 + '@vueuse/metadata@13.9.0':
1144 + resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
1145 +
1146 + '@vueuse/shared@13.9.0':
1147 + resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
1148 + peerDependencies:
1149 + vue: ^3.5.0
1150 +
1151 + acorn-jsx@5.3.2:
1152 + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
1153 + peerDependencies:
1154 + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
1155 +
1156 + acorn@8.15.0:
1157 + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
1158 + engines: {node: '>=0.4.0'}
1159 + hasBin: true
1160 +
1161 + ajv@6.12.6:
1162 + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
1163 +
1164 + alien-signals@2.0.7:
1165 + resolution: {integrity: sha512-wE7y3jmYeb0+h6mr5BOovuqhFv22O/MV9j5p0ndJsa7z1zJNPGQ4ph5pQk/kTTCWRC3xsA4SmtwmkzQO+7NCNg==}
1166 +
1167 + ansi-regex@5.0.1:
1168 + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
1169 + engines: {node: '>=8'}
1170 +
1171 + ansi-styles@4.3.0:
1172 + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
1173 + engines: {node: '>=8'}
1174 +
1175 + ansi-styles@6.2.3:
1176 + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
1177 + engines: {node: '>=12'}
1178 +
1179 + ansis@4.1.0:
1180 + resolution: {integrity: sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==}
1181 + engines: {node: '>=14'}
1182 +
1183 + are-docs-informative@0.0.2:
1184 + resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
1185 + engines: {node: '>=14'}
1186 +
1187 + argparse@2.0.1:
1188 + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
1189 +
1190 + async-validator@4.2.5:
1191 + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
1192 +
1193 + asynckit@0.4.0:
1194 + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
1195 +
1196 + axios@1.12.2:
1197 + resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==}
1198 +
1199 + balanced-match@1.0.2:
1200 + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
1201 +
1202 + baseline-browser-mapping@2.8.4:
1203 + resolution: {integrity: sha512-L+YvJwGAgwJBV1p6ffpSTa2KRc69EeeYGYjRVWKs0GKrK+LON0GC0gV+rKSNtALEDvMDqkvCFq9r1r94/Gjwxw==}
1204 + hasBin: true
1205 +
1206 + birpc@2.5.0:
1207 + resolution: {integrity: sha512-VSWO/W6nNQdyP520F1mhf+Lc2f8pjGQOtoHHm7Ze8Go1kX7akpVIrtTa0fn+HB0QJEDVacl6aO08YE0PgXfdnQ==}
1208 +
1209 + boolbase@1.0.0:
1210 + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
1211 +
1212 + brace-expansion@1.1.12:
1213 + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
1214 +
1215 + brace-expansion@2.0.2:
1216 + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
1217 +
1218 + braces@3.0.3:
1219 + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
1220 + engines: {node: '>=8'}
1221 +
1222 + browserslist@4.26.2:
1223 + resolution: {integrity: sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==}
1224 + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
1225 + hasBin: true
1226 +
1227 + builtin-modules@5.0.0:
1228 + resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==}
1229 + engines: {node: '>=18.20'}
1230 +
1231 + bundle-name@4.1.0:
1232 + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
1233 + engines: {node: '>=18'}
1234 +
1235 + cac@6.7.14:
1236 + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
1237 + engines: {node: '>=8'}
1238 +
1239 + call-bind-apply-helpers@1.0.2:
1240 + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
1241 + engines: {node: '>= 0.4'}
1242 +
1243 + callsites@3.1.0:
1244 + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
1245 + engines: {node: '>=6'}
1246 +
1247 + caniuse-lite@1.0.30001743:
1248 + resolution: {integrity: sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==}
1249 +
1250 + ccount@2.0.1:
1251 + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
1252 +
1253 + chalk@4.1.2:
1254 + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
1255 + engines: {node: '>=10'}
1256 +
1257 + change-case@5.4.4:
1258 + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
1259 +
1260 + character-entities@2.0.2:
1261 + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
1262 +
1263 + chokidar@4.0.3:
1264 + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
1265 + engines: {node: '>= 14.16.0'}
1266 +
1267 + chownr@3.0.0:
1268 + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
1269 + engines: {node: '>=18'}
1270 +
1271 + ci-info@4.3.0:
1272 + resolution: {integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==}
1273 + engines: {node: '>=8'}
1274 +
1275 + clean-regexp@1.0.0:
1276 + resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
1277 + engines: {node: '>=4'}
1278 +
1279 + cliui@8.0.1:
1280 + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
1281 + engines: {node: '>=12'}
1282 +
1283 + color-convert@2.0.1:
1284 + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
1285 + engines: {node: '>=7.0.0'}
1286 +
1287 + color-name@1.1.4:
1288 + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
1289 +
1290 + combined-stream@1.0.8:
1291 + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
1292 + engines: {node: '>= 0.8'}
1293 +
1294 + commander@7.2.0:
1295 + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
1296 + engines: {node: '>= 10'}
1297 +
1298 + comment-parser@1.4.1:
1299 + resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
1300 + engines: {node: '>= 12.0.0'}
1301 +
1302 + concat-map@0.0.1:
1303 + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
1304 +
1305 + concurrently@8.2.2:
1306 + resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==}
1307 + engines: {node: ^14.13.0 || >=16.0.0}
1308 + hasBin: true
1309 +
1310 + confbox@0.1.8:
1311 + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
1312 +
1313 + confbox@0.2.2:
1314 + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
1315 +
1316 + convert-source-map@2.0.0:
1317 + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
1318 +
1319 + copy-anything@3.0.5:
1320 + resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
1321 + engines: {node: '>=12.13'}
1322 +
1323 + core-js-compat@3.45.1:
1324 + resolution: {integrity: sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==}
1325 +
1326 + cross-spawn@7.0.6:
1327 + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
1328 + engines: {node: '>= 8'}
1329 +
1330 + crypto-js@4.2.0:
1331 + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
1332 +
1333 + css-render@0.15.14:
1334 + resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==}
1335 +
1336 + css-select@5.2.2:
1337 + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
1338 +
1339 + css-tree@2.2.1:
1340 + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
1341 + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
1342 +
1343 + css-tree@2.3.1:
1344 + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
1345 + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
1346 +
1347 + css-what@6.2.2:
1348 + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
1349 + engines: {node: '>= 6'}
1350 +
1351 + cssesc@3.0.0:
1352 + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
1353 + engines: {node: '>=4'}
1354 + hasBin: true
1355 +
1356 + csso@5.0.5:
1357 + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
1358 + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
1359 +
1360 + csstype@3.0.11:
1361 + resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==}
1362 +
1363 + csstype@3.1.3:
1364 + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
1365 +
1366 + date-fns-tz@3.2.0:
1367 + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==}
1368 + peerDependencies:
1369 + date-fns: ^3.0.0 || ^4.0.0
1370 +
1371 + date-fns@2.30.0:
1372 + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
1373 + engines: {node: '>=0.11'}
1374 +
1375 + date-fns@3.6.0:
1376 + resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
1377 +
1378 + dayjs@1.11.18:
1379 + resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==}
1380 +
1381 + de-indent@1.0.2:
1382 + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
1383 +
1384 + debug@4.4.3:
1385 + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
1386 + engines: {node: '>=6.0'}
1387 + peerDependencies:
1388 + supports-color: '*'
1389 + peerDependenciesMeta:
1390 + supports-color:
1391 + optional: true
1392 +
1393 + decode-named-character-reference@1.2.0:
1394 + resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
1395 +
1396 + deep-is@0.1.4:
1397 + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
1398 +
1399 + deep-pick-omit@1.2.1:
1400 + resolution: {integrity: sha512-2J6Kc/m3irCeqVG42T+SaUMesaK7oGWaedGnQQK/+O0gYc+2SP5bKh/KKTE7d7SJ+GCA9UUE1GRzh6oDe0EnGw==}
1401 +
1402 + default-browser-id@5.0.0:
1403 + resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==}
1404 + engines: {node: '>=18'}
1405 +
1406 + default-browser@5.2.1:
1407 + resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==}
1408 + engines: {node: '>=18'}
1409 +
1410 + define-lazy-prop@3.0.0:
1411 + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
1412 + engines: {node: '>=12'}
1413 +
1414 + defu@6.1.4:
1415 + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
1416 +
1417 + delayed-stream@1.0.0:
1418 + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
1419 + engines: {node: '>=0.4.0'}
1420 +
1421 + dequal@2.0.3:
1422 + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
1423 + engines: {node: '>=6'}
1424 +
1425 + destr@2.0.5:
1426 + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
1427 +
1428 + detect-libc@1.0.3:
1429 + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
1430 + engines: {node: '>=0.10'}
1431 + hasBin: true
1432 +
1433 + detect-libc@2.1.0:
1434 + resolution: {integrity: sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==}
1435 + engines: {node: '>=8'}
1436 +
1437 + devlop@1.1.0:
1438 + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
1439 +
1440 + dom-serializer@2.0.0:
1441 + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
1442 +
1443 + domelementtype@2.3.0:
1444 + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
1445 +
1446 + domhandler@5.0.3:
1447 + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
1448 + engines: {node: '>= 4'}
1449 +
1450 + domutils@3.2.2:
1451 + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
1452 +
1453 + dunder-proto@1.0.1:
1454 + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
1455 + engines: {node: '>= 0.4'}
1456 +
1457 + electron-to-chromium@1.5.218:
1458 + resolution: {integrity: sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==}
1459 +
1460 + emoji-regex@8.0.0:
1461 + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
1462 +
1463 + empathic@2.0.0:
1464 + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
1465 + engines: {node: '>=14'}
1466 +
1467 + enhanced-resolve@5.18.3:
1468 + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
1469 + engines: {node: '>=10.13.0'}
1470 +
1471 + entities@4.5.0:
1472 + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
1473 + engines: {node: '>=0.12'}
1474 +
1475 + error-stack-parser-es@1.0.5:
1476 + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
1477 +
1478 + es-define-property@1.0.1:
1479 + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
1480 + engines: {node: '>= 0.4'}
1481 +
1482 + es-errors@1.3.0:
1483 + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
1484 + engines: {node: '>= 0.4'}
1485 +
1486 + es-object-atoms@1.1.1:
1487 + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
1488 + engines: {node: '>= 0.4'}
1489 +
1490 + es-set-tostringtag@2.1.0:
1491 + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
1492 + engines: {node: '>= 0.4'}
1493 +
1494 + esbuild@0.25.9:
1495 + resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
1496 + engines: {node: '>=18'}
1497 + hasBin: true
1498 +
1499 + escalade@3.2.0:
1500 + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
1501 + engines: {node: '>=6'}
1502 +
1503 + escape-string-regexp@1.0.5:
1504 + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
1505 + engines: {node: '>=0.8.0'}
1506 +
1507 + escape-string-regexp@4.0.0:
1508 + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
1509 + engines: {node: '>=10'}
1510 +
1511 + escape-string-regexp@5.0.0:
1512 + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
1513 + engines: {node: '>=12'}
1514 +
1515 + eslint-compat-utils@0.5.1:
1516 + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==}
1517 + engines: {node: '>=12'}
1518 + peerDependencies:
1519 + eslint: '>=6.0.0'
1520 +
1521 + eslint-compat-utils@0.6.5:
1522 + resolution: {integrity: sha512-vAUHYzue4YAa2hNACjB8HvUQj5yehAZgiClyFVVom9cP8z5NSFq3PwB/TtJslN2zAMgRX6FCFCjYBbQh71g5RQ==}
1523 + engines: {node: '>=12'}
1524 + peerDependencies:
1525 + eslint: '>=6.0.0'
1526 +
1527 + eslint-config-flat-gitignore@2.1.0:
1528 + resolution: {integrity: sha512-cJzNJ7L+psWp5mXM7jBX+fjHtBvvh06RBlcweMhKD8jWqQw0G78hOW5tpVALGHGFPsBV+ot2H+pdDGJy6CV8pA==}
1529 + peerDependencies:
1530 + eslint: ^9.5.0
1531 +
1532 + eslint-flat-config-utils@2.1.1:
1533 + resolution: {integrity: sha512-K8eaPkBemHkfbYsZH7z4lZ/tt6gNSsVh535Wh9W9gQBS2WjvfUbbVr2NZR3L1yiRCLuOEimYfPxCxODczD4Opg==}
1534 +
1535 + eslint-json-compat-utils@0.2.1:
1536 + resolution: {integrity: sha512-YzEodbDyW8DX8bImKhAcCeu/L31Dd/70Bidx2Qex9OFUtgzXLqtfWL4Hr5fM/aCCB8QUZLuJur0S9k6UfgFkfg==}
1537 + engines: {node: '>=12'}
1538 + peerDependencies:
1539 + '@eslint/json': '*'
1540 + eslint: '*'
1541 + jsonc-eslint-parser: ^2.4.0
1542 + peerDependenciesMeta:
1543 + '@eslint/json':
1544 + optional: true
1545 +
1546 + eslint-merge-processors@2.0.0:
1547 + resolution: {integrity: sha512-sUuhSf3IrJdGooquEUB5TNpGNpBoQccbnaLHsb1XkBLUPPqCNivCpY05ZcpCOiV9uHwO2yxXEWVczVclzMxYlA==}
1548 + peerDependencies:
1549 + eslint: '*'
1550 +
1551 + eslint-plugin-antfu@3.1.1:
1552 + resolution: {integrity: sha512-7Q+NhwLfHJFvopI2HBZbSxWXngTwBLKxW1AGXLr2lEGxcEIK/AsDs8pn8fvIizl5aZjBbVbVK5ujmMpBe4Tvdg==}
1553 + peerDependencies:
1554 + eslint: '*'
1555 +
1556 + eslint-plugin-command@3.3.1:
1557 + resolution: {integrity: sha512-fBVTXQ2y48TVLT0+4A6PFINp7GcdIailHAXbvPBixE7x+YpYnNQhFZxTdvnb+aWk+COgNebQKen/7m4dmgyWAw==}
1558 + peerDependencies:
1559 + eslint: '*'
1560 +
1561 + eslint-plugin-es-x@7.8.0:
1562 + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==}
1563 + engines: {node: ^14.18.0 || >=16.0.0}
1564 + peerDependencies:
1565 + eslint: '>=8'
1566 +
1567 + eslint-plugin-import-lite@0.3.0:
1568 + resolution: {integrity: sha512-dkNBAL6jcoCsXZsQ/Tt2yXmMDoNt5NaBh/U7yvccjiK8cai6Ay+MK77bMykmqQA2bTF6lngaLCDij6MTO3KkvA==}
1569 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1570 + peerDependencies:
1571 + eslint: '>=9.0.0'
1572 + typescript: '>=4.5'
1573 + peerDependenciesMeta:
1574 + typescript:
1575 + optional: true
1576 +
1577 + eslint-plugin-jsdoc@54.7.0:
1578 + resolution: {integrity: sha512-u5Na4he2+6kY1rWqxzbQaAwJL3/tDCuT5ElDRc5UJ9stOeQeQ5L1JJ1kRRu7ldYMlOHMCJLsY8Mg/Tu3ExdZiQ==}
1579 + engines: {node: '>=20.11.0'}
1580 + peerDependencies:
1581 + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0
1582 +
1583 + eslint-plugin-jsonc@2.20.1:
1584 + resolution: {integrity: sha512-gUzIwQHXx7ZPypUoadcyRi4WbHW2TPixDr0kqQ4miuJBU0emJmyGTlnaT3Og9X2a8R1CDayN9BFSq5weGWbTng==}
1585 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
1586 + peerDependencies:
1587 + eslint: '>=6.0.0'
1588 +
1589 + eslint-plugin-n@17.23.0:
1590 + resolution: {integrity: sha512-aPePGxUr5LezcXmMRBF83eK1MmqUYY1NdLdHC+jdpfc5b98eL7yDXY20gXJ6DcTxrHBhrLsfYYqo7J+m0h9YXQ==}
1591 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1592 + peerDependencies:
1593 + eslint: '>=8.23.0'
1594 +
1595 + eslint-plugin-no-only-tests@3.3.0:
1596 + resolution: {integrity: sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==}
1597 + engines: {node: '>=5.0.0'}
1598 +
1599 + eslint-plugin-perfectionist@4.15.0:
1600 + resolution: {integrity: sha512-pC7PgoXyDnEXe14xvRUhBII8A3zRgggKqJFx2a82fjrItDs1BSI7zdZnQtM2yQvcyod6/ujmzb7ejKPx8lZTnw==}
1601 + engines: {node: ^18.0.0 || >=20.0.0}
1602 + peerDependencies:
1603 + eslint: '>=8.45.0'
1604 +
1605 + eslint-plugin-pnpm@1.1.1:
1606 + resolution: {integrity: sha512-gNo+swrLCgvT8L6JX6hVmxuKeuStGK2l8IwVjDxmYIn+wP4SW/d0ORLKyUiYamsp+UxknQo3f2M1irrTpqahCw==}
1607 + peerDependencies:
1608 + eslint: ^9.0.0
1609 +
1610 + eslint-plugin-regexp@2.10.0:
1611 + resolution: {integrity: sha512-ovzQT8ESVn5oOe5a7gIDPD5v9bCSjIFJu57sVPDqgPRXicQzOnYfFN21WoQBQF18vrhT5o7UMKFwJQVVjyJ0ng==}
1612 + engines: {node: ^18 || >=20}
1613 + peerDependencies:
1614 + eslint: '>=8.44.0'
1615 +
1616 + eslint-plugin-toml@0.12.0:
1617 + resolution: {integrity: sha512-+/wVObA9DVhwZB1nG83D2OAQRrcQZXy+drqUnFJKymqnmbnbfg/UPmEMCKrJNcEboUGxUjYrJlgy+/Y930mURQ==}
1618 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
1619 + peerDependencies:
1620 + eslint: '>=6.0.0'
1621 +
1622 + eslint-plugin-unicorn@61.0.2:
1623 + resolution: {integrity: sha512-zLihukvneYT7f74GNbVJXfWIiNQmkc/a9vYBTE4qPkQZswolWNdu+Wsp9sIXno1JOzdn6OUwLPd19ekXVkahRA==}
1624 + engines: {node: ^20.10.0 || >=21.0.0}
1625 + peerDependencies:
1626 + eslint: '>=9.29.0'
1627 +
1628 + eslint-plugin-unused-imports@4.2.0:
1629 + resolution: {integrity: sha512-hLbJ2/wnjKq4kGA9AUaExVFIbNzyxYdVo49QZmKCnhk5pc9wcYRbfgLHvWJ8tnsdcseGhoUAddm9gn/lt+d74w==}
1630 + peerDependencies:
1631 + '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0
1632 + eslint: ^9.0.0 || ^8.0.0
1633 + peerDependenciesMeta:
1634 + '@typescript-eslint/eslint-plugin':
1635 + optional: true
1636 +
1637 + eslint-plugin-vue@10.4.0:
1638 + resolution: {integrity: sha512-K6tP0dW8FJVZLQxa2S7LcE1lLw3X8VvB3t887Q6CLrFVxHYBXGANbXvwNzYIu6Ughx1bSJ5BDT0YB3ybPT39lw==}
1639 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1640 + peerDependencies:
1641 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0
1642 + eslint: ^8.57.0 || ^9.0.0
1643 + vue-eslint-parser: ^10.0.0
1644 + peerDependenciesMeta:
1645 + '@typescript-eslint/parser':
1646 + optional: true
1647 +
1648 + eslint-plugin-yml@1.18.0:
1649 + resolution: {integrity: sha512-9NtbhHRN2NJa/s3uHchO3qVVZw0vyOIvWlXWGaKCr/6l3Go62wsvJK5byiI6ZoYztDsow4GnS69BZD3GnqH3hA==}
1650 + engines: {node: ^14.17.0 || >=16.0.0}
1651 + peerDependencies:
1652 + eslint: '>=6.0.0'
1653 +
1654 + eslint-processor-vue-blocks@2.0.0:
1655 + resolution: {integrity: sha512-u4W0CJwGoWY3bjXAuFpc/b6eK3NQEI8MoeW7ritKj3G3z/WtHrKjkqf+wk8mPEy5rlMGS+k6AZYOw2XBoN/02Q==}
1656 + peerDependencies:
1657 + '@vue/compiler-sfc': ^3.3.0
1658 + eslint: '>=9.0.0'
1659 +
1660 + eslint-scope@8.4.0:
1661 + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
1662 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1663 +
1664 + eslint-visitor-keys@3.4.3:
1665 + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
1666 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
1667 +
1668 + eslint-visitor-keys@4.2.1:
1669 + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
1670 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1671 +
1672 + eslint@9.35.0:
1673 + resolution: {integrity: sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==}
1674 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1675 + hasBin: true
1676 + peerDependencies:
1677 + jiti: '*'
1678 + peerDependenciesMeta:
1679 + jiti:
1680 + optional: true
1681 +
1682 + espree@10.4.0:
1683 + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
1684 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1685 +
1686 + espree@9.6.1:
1687 + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
1688 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
1689 +
1690 + esquery@1.6.0:
1691 + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
1692 + engines: {node: '>=0.10'}
1693 +
1694 + esrecurse@4.3.0:
1695 + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
1696 + engines: {node: '>=4.0'}
1697 +
1698 + estraverse@5.3.0:
1699 + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
1700 + engines: {node: '>=4.0'}
1701 +
1702 + estree-walker@2.0.2:
1703 + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
1704 +
1705 + esutils@2.0.3:
1706 + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
1707 + engines: {node: '>=0.10.0'}
1708 +
1709 + evtd@0.2.4:
1710 + resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==}
1711 +
1712 + execa@9.6.0:
1713 + resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==}
1714 + engines: {node: ^18.19.0 || >=20.5.0}
1715 +
1716 + exsolve@1.0.7:
1717 + resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
1718 +
1719 + fast-deep-equal@3.1.3:
1720 + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
1721 +
1722 + fast-glob@3.3.3:
1723 + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
1724 + engines: {node: '>=8.6.0'}
1725 +
1726 + fast-json-stable-stringify@2.1.0:
1727 + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
1728 +
1729 + fast-levenshtein@2.0.6:
1730 + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
1731 +
1732 + fastq@1.19.1:
1733 + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
1734 +
1735 + fault@2.0.1:
1736 + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==}
1737 +
1738 + fdir@6.5.0:
1739 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
1740 + engines: {node: '>=12.0.0'}
1741 + peerDependencies:
1742 + picomatch: ^3 || ^4
1743 + peerDependenciesMeta:
1744 + picomatch:
1745 + optional: true
1746 +
1747 + figures@6.1.0:
1748 + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
1749 + engines: {node: '>=18'}
1750 +
1751 + file-entry-cache@8.0.0:
1752 + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
1753 + engines: {node: '>=16.0.0'}
1754 +
1755 + fill-range@7.1.1:
1756 + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
1757 + engines: {node: '>=8'}
1758 +
1759 + find-up-simple@1.0.1:
1760 + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==}
1761 + engines: {node: '>=18'}
1762 +
1763 + find-up@5.0.0:
1764 + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
1765 + engines: {node: '>=10'}
1766 +
1767 + flat-cache@4.0.1:
1768 + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
1769 + engines: {node: '>=16'}
1770 +
1771 + flatted@3.3.3:
1772 + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
1773 +
1774 + follow-redirects@1.15.11:
1775 + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
1776 + engines: {node: '>=4.0'}
1777 + peerDependencies:
1778 + debug: '*'
1779 + peerDependenciesMeta:
1780 + debug:
1781 + optional: true
1782 +
1783 + form-data@4.0.4:
1784 + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
1785 + engines: {node: '>= 6'}
1786 +
1787 + format@0.2.2:
1788 + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
1789 + engines: {node: '>=0.4.x'}
1790 +
1791 + fsevents@2.3.3:
1792 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
1793 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
1794 + os: [darwin]
1795 +
1796 + function-bind@1.1.2:
1797 + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
1798 +
1799 + gensync@1.0.0-beta.2:
1800 + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
1801 + engines: {node: '>=6.9.0'}
1802 +
1803 + get-caller-file@2.0.5:
1804 + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
1805 + engines: {node: 6.* || 8.* || >= 10.*}
1806 +
1807 + get-intrinsic@1.3.0:
1808 + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
1809 + engines: {node: '>= 0.4'}
1810 +
1811 + get-proto@1.0.1:
1812 + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
1813 + engines: {node: '>= 0.4'}
1814 +
1815 + get-stream@9.0.1:
1816 + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
1817 + engines: {node: '>=18'}
1818 +
1819 + get-tsconfig@4.10.1:
1820 + resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
1821 +
1822 + github-slugger@2.0.0:
1823 + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
1824 +
1825 + glob-parent@5.1.2:
1826 + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
1827 + engines: {node: '>= 6'}
1828 +
1829 + glob-parent@6.0.2:
1830 + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
1831 + engines: {node: '>=10.13.0'}
1832 +
1833 + globals@14.0.0:
1834 + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
1835 + engines: {node: '>=18'}
1836 +
1837 + globals@15.15.0:
1838 + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
1839 + engines: {node: '>=18'}
1840 +
1841 + globals@16.4.0:
1842 + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==}
1843 + engines: {node: '>=18'}
1844 +
1845 + globrex@0.1.2:
1846 + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
1847 +
1848 + gopd@1.2.0:
1849 + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
1850 + engines: {node: '>= 0.4'}
1851 +
1852 + graceful-fs@4.2.11:
1853 + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
1854 +
1855 + graphemer@1.4.0:
1856 + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
1857 +
1858 + has-flag@4.0.0:
1859 + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
1860 + engines: {node: '>=8'}
1861 +
1862 + has-symbols@1.1.0:
1863 + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
1864 + engines: {node: '>= 0.4'}
1865 +
1866 + has-tostringtag@1.0.2:
1867 + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
1868 + engines: {node: '>= 0.4'}
1869 +
1870 + hasown@2.0.2:
1871 + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
1872 + engines: {node: '>= 0.4'}
1873 +
1874 + he@1.2.0:
1875 + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
1876 + hasBin: true
1877 +
1878 + highlight.js@11.11.1:
1879 + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
1880 + engines: {node: '>=12.0.0'}
1881 +
1882 + hookable@5.5.3:
1883 + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
1884 +
1885 + human-signals@8.0.1:
1886 + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
1887 + engines: {node: '>=18.18.0'}
1888 +
1889 + ignore@5.3.2:
1890 + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
1891 + engines: {node: '>= 4'}
1892 +
1893 + ignore@7.0.5:
1894 + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
1895 + engines: {node: '>= 4'}
1896 +
1897 + immutable@5.1.3:
1898 + resolution: {integrity: sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==}
1899 +
1900 + import-fresh@3.3.1:
1901 + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
1902 + engines: {node: '>=6'}
1903 +
1904 + imurmurhash@0.1.4:
1905 + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
1906 + engines: {node: '>=0.8.19'}
1907 +
1908 + indent-string@5.0.0:
1909 + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==}
1910 + engines: {node: '>=12'}
1911 +
1912 + is-builtin-module@5.0.0:
1913 + resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==}
1914 + engines: {node: '>=18.20'}
1915 +
1916 + is-docker@3.0.0:
1917 + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
1918 + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
1919 + hasBin: true
1920 +
1921 + is-extglob@2.1.1:
1922 + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
1923 + engines: {node: '>=0.10.0'}
1924 +
1925 + is-fullwidth-code-point@3.0.0:
1926 + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
1927 + engines: {node: '>=8'}
1928 +
1929 + is-glob@4.0.3:
1930 + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
1931 + engines: {node: '>=0.10.0'}
1932 +
1933 + is-inside-container@1.0.0:
1934 + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
1935 + engines: {node: '>=14.16'}
1936 + hasBin: true
1937 +
1938 + is-number@7.0.0:
1939 + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
1940 + engines: {node: '>=0.12.0'}
1941 +
1942 + is-plain-obj@4.1.0:
1943 + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
1944 + engines: {node: '>=12'}
1945 +
1946 + is-stream@4.0.1:
1947 + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
1948 + engines: {node: '>=18'}
1949 +
1950 + is-unicode-supported@2.1.0:
1951 + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
1952 + engines: {node: '>=18'}
1953 +
1954 + is-what@4.1.16:
1955 + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
1956 + engines: {node: '>=12.13'}
1957 +
1958 + is-wsl@3.1.0:
1959 + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
1960 + engines: {node: '>=16'}
1961 +
1962 + isexe@2.0.0:
1963 + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
1964 +
1965 + isexe@3.1.1:
1966 + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
1967 + engines: {node: '>=16'}
1968 +
1969 + jiti@2.5.1:
1970 + resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
1971 + hasBin: true
1972 +
1973 + jose@6.1.0:
1974 + resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
1975 +
1976 + js-tokens@4.0.0:
1977 + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
1978 +
1979 + js-yaml@4.1.0:
1980 + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
1981 + hasBin: true
1982 +
1983 + jsdoc-type-pratt-parser@4.1.0:
1984 + resolution: {integrity: sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==}
1985 + engines: {node: '>=12.0.0'}
1986 +
1987 + jsdoc-type-pratt-parser@4.8.0:
1988 + resolution: {integrity: sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw==}
1989 + engines: {node: '>=12.0.0'}
1990 +
1991 + jsdoc-type-pratt-parser@5.1.1:
1992 + resolution: {integrity: sha512-DYYlVP1fe4QBMh2xTIs20/YeTz2GYVbWAEZweHSZD+qQ/Cx2d5RShuhhsdk64eTjNq0FeVnteP/qVOgaywSRbg==}
1993 + engines: {node: '>=12.0.0'}
1994 +
1995 + jsesc@3.0.2:
1996 + resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==}
1997 + engines: {node: '>=6'}
1998 + hasBin: true
1999 +
2000 + jsesc@3.1.0:
2001 + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
2002 + engines: {node: '>=6'}
2003 + hasBin: true
2004 +
2005 + json-buffer@3.0.1:
2006 + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
2007 +
2008 + json-parse-even-better-errors@4.0.0:
2009 + resolution: {integrity: sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==}
2010 + engines: {node: ^18.17.0 || >=20.5.0}
2011 +
2012 + json-schema-traverse@0.4.1:
2013 + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
2014 +
2015 + json-stable-stringify-without-jsonify@1.0.1:
2016 + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
2017 +
2018 + json5@2.2.3:
2019 + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
2020 + engines: {node: '>=6'}
2021 + hasBin: true
2022 +
2023 + jsonc-eslint-parser@2.4.0:
2024 + resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
2025 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
2026 +
2027 + keyv@4.5.4:
2028 + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
2029 +
2030 + kolorist@1.8.0:
2031 + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
2032 +
2033 + levn@0.4.1:
2034 + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
2035 + engines: {node: '>= 0.8.0'}
2036 +
2037 + lightningcss-darwin-arm64@1.30.1:
2038 + resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
2039 + engines: {node: '>= 12.0.0'}
2040 + cpu: [arm64]
2041 + os: [darwin]
2042 +
2043 + lightningcss-darwin-x64@1.30.1:
2044 + resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==}
2045 + engines: {node: '>= 12.0.0'}
2046 + cpu: [x64]
2047 + os: [darwin]
2048 +
2049 + lightningcss-freebsd-x64@1.30.1:
2050 + resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==}
2051 + engines: {node: '>= 12.0.0'}
2052 + cpu: [x64]
2053 + os: [freebsd]
2054 +
2055 + lightningcss-linux-arm-gnueabihf@1.30.1:
2056 + resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==}
2057 + engines: {node: '>= 12.0.0'}
2058 + cpu: [arm]
2059 + os: [linux]
2060 +
2061 + lightningcss-linux-arm64-gnu@1.30.1:
2062 + resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==}
2063 + engines: {node: '>= 12.0.0'}
2064 + cpu: [arm64]
2065 + os: [linux]
2066 +
2067 + lightningcss-linux-arm64-musl@1.30.1:
2068 + resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
2069 + engines: {node: '>= 12.0.0'}
2070 + cpu: [arm64]
2071 + os: [linux]
2072 +
2073 + lightningcss-linux-x64-gnu@1.30.1:
2074 + resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
2075 + engines: {node: '>= 12.0.0'}
2076 + cpu: [x64]
2077 + os: [linux]
2078 +
2079 + lightningcss-linux-x64-musl@1.30.1:
2080 + resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
2081 + engines: {node: '>= 12.0.0'}
2082 + cpu: [x64]
2083 + os: [linux]
2084 +
2085 + lightningcss-win32-arm64-msvc@1.30.1:
2086 + resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
2087 + engines: {node: '>= 12.0.0'}
2088 + cpu: [arm64]
2089 + os: [win32]
2090 +
2091 + lightningcss-win32-x64-msvc@1.30.1:
2092 + resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==}
2093 + engines: {node: '>= 12.0.0'}
2094 + cpu: [x64]
2095 + os: [win32]
2096 +
2097 + lightningcss@1.30.1:
2098 + resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
2099 + engines: {node: '>= 12.0.0'}
2100 +
2101 + local-pkg@1.1.2:
2102 + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
2103 + engines: {node: '>=14'}
2104 +
2105 + locate-path@6.0.0:
2106 + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
2107 + engines: {node: '>=10'}
2108 +
2109 + lodash-es@4.17.21:
2110 + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
2111 +
2112 + lodash.merge@4.6.2:
2113 + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
2114 +
2115 + lodash@4.17.21:
2116 + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
2117 +
2118 + longest-streak@3.1.0:
2119 + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
2120 +
2121 + lru-cache@5.1.1:
2122 + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
2123 +
2124 + lz-string@1.5.0:
2125 + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
2126 + hasBin: true
2127 +
2128 + magic-string@0.30.19:
2129 + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
2130 +
2131 + markdown-table@3.0.4:
2132 + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
2133 +
2134 + math-intrinsics@1.1.0:
2135 + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
2136 + engines: {node: '>= 0.4'}
2137 +
2138 + mdast-util-find-and-replace@3.0.2:
2139 + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
2140 +
2141 + mdast-util-from-markdown@2.0.2:
2142 + resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
2143 +
2144 + mdast-util-frontmatter@2.0.1:
2145 + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==}
2146 +
2147 + mdast-util-gfm-autolink-literal@2.0.1:
2148 + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
2149 +
2150 + mdast-util-gfm-footnote@2.1.0:
2151 + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
2152 +
2153 + mdast-util-gfm-strikethrough@2.0.0:
2154 + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
2155 +
2156 + mdast-util-gfm-table@2.0.0:
2157 + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
2158 +
2159 + mdast-util-gfm-task-list-item@2.0.0:
2160 + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
2161 +
2162 + mdast-util-gfm@3.1.0:
2163 + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
2164 +
2165 + mdast-util-phrasing@4.1.0:
2166 + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
2167 +
2168 + mdast-util-to-markdown@2.1.2:
2169 + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
2170 +
2171 + mdast-util-to-string@4.0.0:
2172 + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
2173 +
2174 + mdn-data@2.0.28:
2175 + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
2176 +
2177 + mdn-data@2.0.30:
2178 + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
2179 +
2180 + memorystream@0.3.1:
2181 + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
2182 + engines: {node: '>= 0.10.0'}
2183 +
2184 + merge2@1.4.1:
2185 + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
2186 + engines: {node: '>= 8'}
2187 +
2188 + micromark-core-commonmark@2.0.3:
2189 + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
2190 +
2191 + micromark-extension-frontmatter@2.0.0:
2192 + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==}
2193 +
2194 + micromark-extension-gfm-autolink-literal@2.1.0:
2195 + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
2196 +
2197 + micromark-extension-gfm-footnote@2.1.0:
2198 + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
2199 +
2200 + micromark-extension-gfm-strikethrough@2.1.0:
2201 + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
2202 +
2203 + micromark-extension-gfm-table@2.1.1:
2204 + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
2205 +
2206 + micromark-extension-gfm-tagfilter@2.0.0:
2207 + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
2208 +
2209 + micromark-extension-gfm-task-list-item@2.1.0:
2210 + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
2211 +
2212 + micromark-extension-gfm@3.0.0:
2213 + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
2214 +
2215 + micromark-factory-destination@2.0.1:
2216 + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
2217 +
2218 + micromark-factory-label@2.0.1:
2219 + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
2220 +
2221 + micromark-factory-space@2.0.1:
2222 + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
2223 +
2224 + micromark-factory-title@2.0.1:
2225 + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
2226 +
2227 + micromark-factory-whitespace@2.0.1:
2228 + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
2229 +
2230 + micromark-util-character@2.1.1:
2231 + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
2232 +
2233 + micromark-util-chunked@2.0.1:
2234 + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
2235 +
2236 + micromark-util-classify-character@2.0.1:
2237 + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
2238 +
2239 + micromark-util-combine-extensions@2.0.1:
2240 + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
2241 +
2242 + micromark-util-decode-numeric-character-reference@2.0.2:
2243 + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
2244 +
2245 + micromark-util-decode-string@2.0.1:
2246 + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
2247 +
2248 + micromark-util-encode@2.0.1:
2249 + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
2250 +
2251 + micromark-util-html-tag-name@2.0.1:
2252 + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
2253 +
2254 + micromark-util-normalize-identifier@2.0.1:
2255 + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
2256 +
2257 + micromark-util-resolve-all@2.0.1:
2258 + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
2259 +
2260 + micromark-util-sanitize-uri@2.0.1:
2261 + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
2262 +
2263 + micromark-util-subtokenize@2.1.0:
2264 + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
2265 +
2266 + micromark-util-symbol@2.0.1:
2267 + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
2268 +
2269 + micromark-util-types@2.0.2:
2270 + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
2271 +
2272 + micromark@4.0.2:
2273 + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
2274 +
2275 + micromatch@4.0.8:
2276 + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
2277 + engines: {node: '>=8.6'}
2278 +
2279 + mime-db@1.52.0:
2280 + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
2281 + engines: {node: '>= 0.6'}
2282 +
2283 + mime-types@2.1.35:
2284 + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
2285 + engines: {node: '>= 0.6'}
2286 +
2287 + minimatch@3.1.2:
2288 + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
2289 +
2290 + minimatch@9.0.5:
2291 + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
2292 + engines: {node: '>=16 || 14 >=14.17'}
2293 +
2294 + minipass@7.1.2:
2295 + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
2296 + engines: {node: '>=16 || 14 >=14.17'}
2297 +
2298 + minizlib@3.0.2:
2299 + resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==}
2300 + engines: {node: '>= 18'}
2301 +
2302 + mitt@3.0.1:
2303 + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
2304 +
2305 + mkdirp@3.0.1:
2306 + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
2307 + engines: {node: '>=10'}
2308 + hasBin: true
2309 +
2310 + mlly@1.8.0:
2311 + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
2312 +
2313 + mrmime@2.0.1:
2314 + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
2315 + engines: {node: '>=10'}
2316 +
2317 + ms@2.1.3:
2318 + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
2319 +
2320 + muggle-string@0.4.1:
2321 + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
2322 +
2323 + naive-ui@2.43.1:
2324 + resolution: {integrity: sha512-w52W0mOhdOGt4uucFSZmP0DI44PCsFyuxeLSs9aoUThfIuxms90MYjv46Qrr7xprjyJRw5RU6vYpCx4o9ind3A==}
2325 + peerDependencies:
2326 + vue: ^3.0.0
2327 +
2328 + nanoid@3.3.11:
2329 + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
2330 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
2331 + hasBin: true
2332 +
2333 + nanoid@5.1.5:
2334 + resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
2335 + engines: {node: ^18 || >=20}
2336 + hasBin: true
2337 +
2338 + natural-compare@1.4.0:
2339 + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
2340 +
2341 + natural-orderby@5.0.0:
2342 + resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==}
2343 + engines: {node: '>=18'}
2344 +
2345 + node-addon-api@7.1.1:
2346 + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
2347 +
2348 + node-releases@2.0.21:
2349 + resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==}
2350 +
2351 + npm-normalize-package-bin@4.0.0:
2352 + resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==}
2353 + engines: {node: ^18.17.0 || >=20.5.0}
2354 +
2355 + npm-run-all2@8.0.4:
2356 + resolution: {integrity: sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==}
2357 + engines: {node: ^20.5.0 || >=22.0.0, npm: '>= 10'}
2358 + hasBin: true
2359 +
2360 + npm-run-path@6.0.0:
2361 + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
2362 + engines: {node: '>=18'}
2363 +
2364 + nth-check@2.1.1:
2365 + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
2366 +
2367 + ohash@2.0.11:
2368 + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
2369 +
2370 + open@10.2.0:
2371 + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
2372 + engines: {node: '>=18'}
2373 +
2374 + optionator@0.9.4:
2375 + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
2376 + engines: {node: '>= 0.8.0'}
2377 +
2378 + p-limit@3.1.0:
2379 + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
2380 + engines: {node: '>=10'}
2381 +
2382 + p-locate@5.0.0:
2383 + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
2384 + engines: {node: '>=10'}
2385 +
2386 + package-manager-detector@1.3.0:
2387 + resolution: {integrity: sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==}
2388 +
2389 + parent-module@1.0.1:
2390 + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
2391 + engines: {node: '>=6'}
2392 +
2393 + parse-gitignore@2.0.0:
2394 + resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==}
2395 + engines: {node: '>=14'}
2396 +
2397 + parse-imports-exports@0.2.4:
2398 + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==}
2399 +
2400 + parse-ms@4.0.0:
2401 + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
2402 + engines: {node: '>=18'}
2403 +
2404 + parse-statements@1.0.11:
2405 + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==}
2406 +
2407 + path-browserify@1.0.1:
2408 + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
2409 +
2410 + path-exists@4.0.0:
2411 + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
2412 + engines: {node: '>=8'}
2413 +
2414 + path-key@3.1.1:
2415 + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
2416 + engines: {node: '>=8'}
2417 +
2418 + path-key@4.0.0:
2419 + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
2420 + engines: {node: '>=12'}
2421 +
2422 + pathe@2.0.3:
2423 + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
2424 +
2425 + perfect-debounce@1.0.0:
2426 + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
2427 +
2428 + perfect-debounce@2.0.0:
2429 + resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==}
2430 +
2431 + picocolors@1.1.1:
2432 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
2433 +
2434 + picomatch@2.3.1:
2435 + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
2436 + engines: {node: '>=8.6'}
2437 +
2438 + picomatch@4.0.3:
2439 + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
2440 + engines: {node: '>=12'}
2441 +
2442 + pidtree@0.6.0:
2443 + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==}
2444 + engines: {node: '>=0.10'}
2445 + hasBin: true
2446 +
2447 + pinia-plugin-persistedstate@4.5.0:
2448 + resolution: {integrity: sha512-QTkP1xJVyCdr2I2p3AKUZM84/e+IS+HktRxKGAIuDzkyaKKV48mQcYkJFVVDuvTxlI5j6X3oZObpqoVB8JnWpw==}
2449 + peerDependencies:
2450 + '@nuxt/kit': '>=3.0.0'
2451 + '@pinia/nuxt': '>=0.10.0'
2452 + pinia: '>=3.0.0'
2453 + peerDependenciesMeta:
2454 + '@nuxt/kit':
2455 + optional: true
2456 + '@pinia/nuxt':
2457 + optional: true
2458 + pinia:
2459 + optional: true
2460 +
2461 + pinia@3.0.3:
2462 + resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==}
2463 + peerDependencies:
2464 + typescript: '>=4.4.4'
2465 + vue: ^2.7.0 || ^3.5.11
2466 + peerDependenciesMeta:
2467 + typescript:
2468 + optional: true
2469 +
2470 + pkg-types@1.3.1:
2471 + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
2472 +
2473 + pkg-types@2.3.0:
2474 + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
2475 +
2476 + pluralize@8.0.0:
2477 + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
2478 + engines: {node: '>=4'}
2479 +
2480 + pnpm-workspace-yaml@1.1.1:
2481 + resolution: {integrity: sha512-nGBB7h3Ped3g9dBrR6d3YNwXCKYsEg8K9J3GMmSrwGEXq3RHeGW44/B4MZW51p4FRMnyxJzTY5feSBbUjRhIHQ==}
2482 +
2483 + postcss-selector-parser@6.1.2:
2484 + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
2485 + engines: {node: '>=4'}
2486 +
2487 + postcss@8.5.6:
2488 + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
2489 + engines: {node: ^10 || ^12 || >=14}
2490 +
2491 + prelude-ls@1.2.1:
2492 + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
2493 + engines: {node: '>= 0.8.0'}
2494 +
2495 + prettier-plugin-tailwindcss@0.6.14:
2496 + resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==}
2497 + engines: {node: '>=14.21.3'}
2498 + peerDependencies:
2499 + '@ianvs/prettier-plugin-sort-imports': '*'
2500 + '@prettier/plugin-hermes': '*'
2501 + '@prettier/plugin-oxc': '*'
2502 + '@prettier/plugin-pug': '*'
2503 + '@shopify/prettier-plugin-liquid': '*'
2504 + '@trivago/prettier-plugin-sort-imports': '*'
2505 + '@zackad/prettier-plugin-twig': '*'
2506 + prettier: ^3.0
2507 + prettier-plugin-astro: '*'
2508 + prettier-plugin-css-order: '*'
2509 + prettier-plugin-import-sort: '*'
2510 + prettier-plugin-jsdoc: '*'
2511 + prettier-plugin-marko: '*'
2512 + prettier-plugin-multiline-arrays: '*'
2513 + prettier-plugin-organize-attributes: '*'
2514 + prettier-plugin-organize-imports: '*'
2515 + prettier-plugin-sort-imports: '*'
2516 + prettier-plugin-style-order: '*'
2517 + prettier-plugin-svelte: '*'
2518 + peerDependenciesMeta:
2519 + '@ianvs/prettier-plugin-sort-imports':
2520 + optional: true
2521 + '@prettier/plugin-hermes':
2522 + optional: true
2523 + '@prettier/plugin-oxc':
2524 + optional: true
2525 + '@prettier/plugin-pug':
2526 + optional: true
2527 + '@shopify/prettier-plugin-liquid':
2528 + optional: true
2529 + '@trivago/prettier-plugin-sort-imports':
2530 + optional: true
2531 + '@zackad/prettier-plugin-twig':
2532 + optional: true
2533 + prettier-plugin-astro:
2534 + optional: true
2535 + prettier-plugin-css-order:
2536 + optional: true
2537 + prettier-plugin-import-sort:
2538 + optional: true
2539 + prettier-plugin-jsdoc:
2540 + optional: true
2541 + prettier-plugin-marko:
2542 + optional: true
2543 + prettier-plugin-multiline-arrays:
2544 + optional: true
2545 + prettier-plugin-organize-attributes:
2546 + optional: true
2547 + prettier-plugin-organize-imports:
2548 + optional: true
2549 + prettier-plugin-sort-imports:
2550 + optional: true
2551 + prettier-plugin-style-order:
2552 + optional: true
2553 + prettier-plugin-svelte:
2554 + optional: true
2555 +
2556 + prettier@3.6.2:
2557 + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
2558 + engines: {node: '>=14'}
2559 + hasBin: true
2560 +
2561 + pretty-ms@9.3.0:
2562 + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
2563 + engines: {node: '>=18'}
2564 +
2565 + proxy-from-env@1.1.0:
2566 + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
2567 +
2568 + punycode@2.3.1:
2569 + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
2570 + engines: {node: '>=6'}
2571 +
2572 + quansync@0.2.11:
2573 + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
2574 +
2575 + queue-microtask@1.2.3:
2576 + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
2577 +
2578 + read-package-json-fast@4.0.0:
2579 + resolution: {integrity: sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==}
2580 + engines: {node: ^18.17.0 || >=20.5.0}
2581 +
2582 + readdirp@4.1.2:
2583 + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
2584 + engines: {node: '>= 14.18.0'}
2585 +
2586 + refa@0.12.1:
2587 + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==}
2588 + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
2589 +
2590 + regexp-ast-analysis@0.7.1:
2591 + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==}
2592 + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
2593 +
2594 + regexp-tree@0.1.27:
2595 + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
2596 + hasBin: true
2597 +
2598 + regjsparser@0.12.0:
2599 + resolution: {integrity: sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==}
2600 + hasBin: true
2601 +
2602 + require-directory@2.1.1:
2603 + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
2604 + engines: {node: '>=0.10.0'}
2605 +
2606 + resolve-from@4.0.0:
2607 + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
2608 + engines: {node: '>=4'}
2609 +
2610 + resolve-pkg-maps@1.0.0:
2611 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
2612 +
2613 + reusify@1.1.0:
2614 + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
2615 + engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
2616 +
2617 + rfdc@1.4.1:
2618 + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
2619 +
2620 + rollup@4.50.2:
2621 + resolution: {integrity: sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==}
2622 + engines: {node: '>=18.0.0', npm: '>=8.0.0'}
2623 + hasBin: true
2624 +
2625 + run-applescript@7.1.0:
2626 + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
2627 + engines: {node: '>=18'}
2628 +
2629 + run-parallel@1.2.0:
2630 + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
2631 +
2632 + rxjs@7.8.2:
2633 + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
2634 +
2635 + sass@1.92.1:
2636 + resolution: {integrity: sha512-ffmsdbwqb3XeyR8jJR6KelIXARM9bFQe8A6Q3W4Klmwy5Ckd5gz7jgUNHo4UOqutU5Sk1DtKLbpDP0nLCg1xqQ==}
2637 + engines: {node: '>=14.0.0'}
2638 + hasBin: true
2639 +
2640 + scslre@0.3.0:
2641 + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==}
2642 + engines: {node: ^14.0.0 || >=16.0.0}
2643 +
2644 + secure-ls@2.0.0:
2645 + resolution: {integrity: sha512-Wgtnw0QSm0v7gVKv11nOoeyGS65EThGXnBB7jfd4IhZd2eq3B4AMPcXAL5qJ1h55+Qolun7TONTwX7H5m6e2pQ==}
2646 + engines: {node: '>=8.0'}
2647 +
2648 + seemly@0.3.10:
2649 + resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==}
2650 +
2651 + semver@6.3.1:
2652 + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
2653 + hasBin: true
2654 +
2655 + semver@7.7.2:
2656 + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
2657 + engines: {node: '>=10'}
2658 + hasBin: true
2659 +
2660 + shebang-command@2.0.0:
2661 + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
2662 + engines: {node: '>=8'}
2663 +
2664 + shebang-regex@3.0.0:
2665 + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
2666 + engines: {node: '>=8'}
2667 +
2668 + shell-quote@1.8.3:
2669 + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
2670 + engines: {node: '>= 0.4'}
2671 +
2672 + signal-exit@4.1.0:
2673 + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
2674 + engines: {node: '>=14'}
2675 +
2676 + sirv@3.0.2:
2677 + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
2678 + engines: {node: '>=18'}
2679 +
2680 + sisteransi@1.0.5:
2681 + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
2682 +
2683 + source-map-js@1.2.1:
2684 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
2685 + engines: {node: '>=0.10.0'}
2686 +
2687 + spawn-command@0.0.2:
2688 + resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==}
2689 +
2690 + spdx-exceptions@2.5.0:
2691 + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==}
2692 +
2693 + spdx-expression-parse@4.0.0:
2694 + resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==}
2695 +
2696 + spdx-license-ids@3.0.22:
2697 + resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==}
2698 +
2699 + speakingurl@14.0.1:
2700 + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
2701 + engines: {node: '>=0.10.0'}
2702 +
2703 + string-width@4.2.3:
2704 + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
2705 + engines: {node: '>=8'}
2706 +
2707 + strip-ansi@6.0.1:
2708 + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
2709 + engines: {node: '>=8'}
2710 +
2711 + strip-final-newline@4.0.0:
2712 + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
2713 + engines: {node: '>=18'}
2714 +
2715 + strip-indent@4.1.0:
2716 + resolution: {integrity: sha512-OA95x+JPmL7kc7zCu+e+TeYxEiaIyndRx0OrBcK2QPPH09oAndr2ALvymxWA+Lx1PYYvFUm4O63pRkdJAaW96w==}
2717 + engines: {node: '>=12'}
2718 +
2719 + strip-json-comments@3.1.1:
2720 + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
2721 + engines: {node: '>=8'}
2722 +
2723 + superjson@2.2.2:
2724 + resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==}
2725 + engines: {node: '>=16'}
2726 +
2727 + supports-color@7.2.0:
2728 + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
2729 + engines: {node: '>=8'}
2730 +
2731 + supports-color@8.1.1:
2732 + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
2733 + engines: {node: '>=10'}
2734 +
2735 + svgo@3.3.2:
2736 + resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==}
2737 + engines: {node: '>=14.0.0'}
2738 + hasBin: true
2739 +
2740 + synckit@0.11.11:
2741 + resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
2742 + engines: {node: ^14.18.0 || >=16.0.0}
2743 +
2744 + tailwindcss@4.1.13:
2745 + resolution: {integrity: sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==}
2746 +
2747 + tapable@2.2.3:
2748 + resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==}
2749 + engines: {node: '>=6'}
2750 +
2751 + tar@7.4.3:
2752 + resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
2753 + engines: {node: '>=18'}
2754 +
2755 + tinyexec@1.0.1:
2756 + resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
2757 +
2758 + tinyglobby@0.2.15:
2759 + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
2760 + engines: {node: '>=12.0.0'}
2761 +
2762 + to-regex-range@5.0.1:
2763 + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
2764 + engines: {node: '>=8.0'}
2765 +
2766 + toml-eslint-parser@0.10.0:
2767 + resolution: {integrity: sha512-khrZo4buq4qVmsGzS5yQjKe/WsFvV8fGfOjDQN0q4iy9FjRfPWRgTFrU8u1R2iu/SfWLhY9WnCi4Jhdrcbtg+g==}
2768 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
2769 +
2770 + totalist@3.0.1:
2771 + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
2772 + engines: {node: '>=6'}
2773 +
2774 + tree-kill@1.2.2:
2775 + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
2776 + hasBin: true
2777 +
2778 + treemate@0.3.11:
2779 + resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==}
2780 +
2781 + ts-api-utils@2.1.0:
2782 + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
2783 + engines: {node: '>=18.12'}
2784 + peerDependencies:
2785 + typescript: '>=4.8.4'
2786 +
2787 + ts-declaration-location@1.0.7:
2788 + resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==}
2789 + peerDependencies:
2790 + typescript: '>=4.0.0'
2791 +
2792 + tslib@2.8.1:
2793 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
2794 +
2795 + type-check@0.4.0:
2796 + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
2797 + engines: {node: '>= 0.8.0'}
2798 +
2799 + typescript@5.8.3:
2800 + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
2801 + engines: {node: '>=14.17'}
2802 + hasBin: true
2803 +
2804 + ufo@1.6.1:
2805 + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
2806 +
2807 + undici-types@7.12.0:
2808 + resolution: {integrity: sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==}
2809 +
2810 + unicorn-magic@0.3.0:
2811 + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
2812 + engines: {node: '>=18'}
2813 +
2814 + unist-util-is@6.0.0:
2815 + resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==}
2816 +
2817 + unist-util-stringify-position@4.0.0:
2818 + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
2819 +
2820 + unist-util-visit-parents@6.0.1:
2821 + resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==}
2822 +
2823 + unist-util-visit@5.0.0:
2824 + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
2825 +
2826 + unplugin-utils@0.3.0:
2827 + resolution: {integrity: sha512-JLoggz+PvLVMJo+jZt97hdIIIZ2yTzGgft9e9q8iMrC4ewufl62ekeW7mixBghonn2gVb/ICjyvlmOCUBnJLQg==}
2828 + engines: {node: '>=20.19.0'}
2829 +
2830 + update-browserslist-db@1.1.3:
2831 + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
2832 + hasBin: true
2833 + peerDependencies:
2834 + browserslist: '>= 4.21.0'
2835 +
2836 + uri-js@4.4.1:
2837 + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
2838 +
2839 + util-deprecate@1.0.2:
2840 + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
2841 +
2842 + vdirs@0.1.8:
2843 + resolution: {integrity: sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==}
2844 + peerDependencies:
2845 + vue: ^3.0.11
2846 +
2847 + vite-dev-rpc@1.1.0:
2848 + resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==}
2849 + peerDependencies:
2850 + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0
2851 +
2852 + vite-hot-client@2.1.0:
2853 + resolution: {integrity: sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==}
2854 + peerDependencies:
2855 + vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0
2856 +
2857 + vite-plugin-inspect@11.3.3:
2858 + resolution: {integrity: sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA==}
2859 + engines: {node: '>=14'}
2860 + peerDependencies:
2861 + '@nuxt/kit': '*'
2862 + vite: ^6.0.0 || ^7.0.0-0
2863 + peerDependenciesMeta:
2864 + '@nuxt/kit':
2865 + optional: true
2866 +
2867 + vite-plugin-vue-devtools@8.0.2:
2868 + resolution: {integrity: sha512-1069qvMBcyAu3yXQlvYrkwoyLOk0lSSR/gTKy/vy+Det7TXnouGei6ZcKwr5TIe938v/14oLlp0ow6FSJkkORA==}
2869 + engines: {node: '>=v14.21.3'}
2870 + peerDependencies:
2871 + vite: ^6.0.0 || ^7.0.0-0
2872 +
2873 + vite-plugin-vue-inspector@5.3.2:
2874 + resolution: {integrity: sha512-YvEKooQcSiBTAs0DoYLfefNja9bLgkFM7NI2b07bE2SruuvX0MEa9cMaxjKVMkeCp5Nz9FRIdcN1rOdFVBeL6Q==}
2875 + peerDependencies:
2876 + vite: ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0
2877 +
2878 + vite-svg-loader@5.1.0:
2879 + resolution: {integrity: sha512-M/wqwtOEjgb956/+m5ZrYT/Iq6Hax0OakWbokj8+9PXOnB7b/4AxESHieEtnNEy7ZpjsjYW1/5nK8fATQMmRxw==}
2880 + peerDependencies:
2881 + vue: '>=3.2.13'
2882 +
2883 + vite@7.1.5:
2884 + resolution: {integrity: sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==}
2885 + engines: {node: ^20.19.0 || >=22.12.0}
2886 + hasBin: true
2887 + peerDependencies:
2888 + '@types/node': ^20.19.0 || >=22.12.0
2889 + jiti: '>=1.21.0'
2890 + less: ^4.0.0
2891 + lightningcss: ^1.21.0
2892 + sass: ^1.70.0
2893 + sass-embedded: ^1.70.0
2894 + stylus: '>=0.54.8'
2895 + sugarss: ^5.0.0
2896 + terser: ^5.16.0
2897 + tsx: ^4.8.1
2898 + yaml: ^2.4.2
2899 + peerDependenciesMeta:
2900 + '@types/node':
2901 + optional: true
2902 + jiti:
2903 + optional: true
2904 + less:
2905 + optional: true
2906 + lightningcss:
2907 + optional: true
2908 + sass:
2909 + optional: true
2910 + sass-embedded:
2911 + optional: true
2912 + stylus:
2913 + optional: true
2914 + sugarss:
2915 + optional: true
2916 + terser:
2917 + optional: true
2918 + tsx:
2919 + optional: true
2920 + yaml:
2921 + optional: true
2922 +
2923 + vooks@0.2.12:
2924 + resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==}
2925 + peerDependencies:
2926 + vue: ^3.0.0
2927 +
2928 + vscode-uri@3.1.0:
2929 + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
2930 +
2931 + vue-eslint-parser@10.2.0:
2932 + resolution: {integrity: sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==}
2933 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
2934 + peerDependencies:
2935 + eslint: ^8.57.0 || ^9.0.0
2936 +
2937 + vue-router@4.5.1:
2938 + resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==}
2939 + peerDependencies:
2940 + vue: ^3.2.0
2941 +
2942 + vue-tsc@3.0.7:
2943 + resolution: {integrity: sha512-BSMmW8GGEgHykrv7mRk6zfTdK+tw4MBZY/x6fFa7IkdXK3s/8hQRacPjG9/8YKFDIWGhBocwi6PlkQQ/93OgIQ==}
2944 + hasBin: true
2945 + peerDependencies:
2946 + typescript: '>=5.0.0'
2947 +
2948 + vue@3.5.21:
2949 + resolution: {integrity: sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==}
2950 + peerDependencies:
2951 + typescript: '*'
2952 + peerDependenciesMeta:
2953 + typescript:
2954 + optional: true
2955 +
2956 + vueuc@0.4.65:
2957 + resolution: {integrity: sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==}
2958 + peerDependencies:
2959 + vue: ^3.0.11
2960 +
2961 + which@2.0.2:
2962 + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
2963 + engines: {node: '>= 8'}
2964 + hasBin: true
2965 +
2966 + which@5.0.0:
2967 + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==}
2968 + engines: {node: ^18.17.0 || >=20.5.0}
2969 + hasBin: true
2970 +
2971 + word-wrap@1.2.5:
2972 + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
2973 + engines: {node: '>=0.10.0'}
2974 +
2975 + wrap-ansi@7.0.0:
2976 + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
2977 + engines: {node: '>=10'}
2978 +
2979 + wsl-utils@0.1.0:
2980 + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
2981 + engines: {node: '>=18'}
2982 +
2983 + xml-name-validator@4.0.0:
2984 + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
2985 + engines: {node: '>=12'}
2986 +
2987 + y18n@5.0.8:
2988 + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
2989 + engines: {node: '>=10'}
2990 +
2991 + yallist@3.1.1:
2992 + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
2993 +
2994 + yallist@5.0.0:
2995 + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
2996 + engines: {node: '>=18'}
2997 +
2998 + yaml-eslint-parser@1.3.0:
2999 + resolution: {integrity: sha512-E/+VitOorXSLiAqtTd7Yqax0/pAS3xaYMP+AUUJGOK1OZG3rhcj9fcJOM5HJ2VrP1FrStVCWr1muTfQCdj4tAA==}
3000 + engines: {node: ^14.17.0 || >=16.0.0}
3001 +
3002 + yaml@2.8.1:
3003 + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==}
3004 + engines: {node: '>= 14.6'}
3005 + hasBin: true
3006 +
3007 + yargs-parser@21.1.1:
3008 + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
3009 + engines: {node: '>=12'}
3010 +
3011 + yargs@17.7.2:
3012 + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
3013 + engines: {node: '>=12'}
3014 +
3015 + yocto-queue@0.1.0:
3016 + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
3017 + engines: {node: '>=10'}
3018 +
3019 + yoctocolors@2.1.2:
3020 + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
3021 + engines: {node: '>=18'}
3022 +
3023 + zwitch@2.0.4:
3024 + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
3025 +
3026 +snapshots:
3027 +
3028 + '@ajoelp/json-to-formdata@1.5.0':
3029 + dependencies:
3030 + lodash: 4.17.21
3031 +
3032 + '@antfu/eslint-config@5.3.0(@vue/compiler-sfc@3.5.21)(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)':
3033 + dependencies:
3034 + '@antfu/install-pkg': 1.1.0
3035 + '@clack/prompts': 0.11.0
3036 + '@eslint-community/eslint-plugin-eslint-comments': 4.5.0(eslint@9.35.0(jiti@2.5.1))
3037 + '@eslint/markdown': 7.2.0
3038 + '@stylistic/eslint-plugin': 5.3.1(eslint@9.35.0(jiti@2.5.1))
3039 + '@typescript-eslint/eslint-plugin': 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
3040 + '@typescript-eslint/parser': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
3041 + '@vitest/eslint-plugin': 1.3.10(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
3042 + ansis: 4.1.0
3043 + cac: 6.7.14
3044 + eslint: 9.35.0(jiti@2.5.1)
3045 + eslint-config-flat-gitignore: 2.1.0(eslint@9.35.0(jiti@2.5.1))
3046 + eslint-flat-config-utils: 2.1.1
3047 + eslint-merge-processors: 2.0.0(eslint@9.35.0(jiti@2.5.1))
3048 + eslint-plugin-antfu: 3.1.1(eslint@9.35.0(jiti@2.5.1))
3049 + eslint-plugin-command: 3.3.1(eslint@9.35.0(jiti@2.5.1))
3050 + eslint-plugin-import-lite: 0.3.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
3051 + eslint-plugin-jsdoc: 54.7.0(eslint@9.35.0(jiti@2.5.1))
3052 + eslint-plugin-jsonc: 2.20.1(eslint@9.35.0(jiti@2.5.1))
3053 + eslint-plugin-n: 17.23.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
3054 + eslint-plugin-no-only-tests: 3.3.0
3055 + eslint-plugin-perfectionist: 4.15.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
3056 + eslint-plugin-pnpm: 1.1.1(eslint@9.35.0(jiti@2.5.1))
3057 + eslint-plugin-regexp: 2.10.0(eslint@9.35.0(jiti@2.5.1))
3058 + eslint-plugin-toml: 0.12.0(eslint@9.35.0(jiti@2.5.1))
3059 + eslint-plugin-unicorn: 61.0.2(eslint@9.35.0(jiti@2.5.1))
3060 + eslint-plugin-unused-imports: 4.2.0(@typescript-eslint/eslint-plugin@8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.35.0(jiti@2.5.1))
3061 + eslint-plugin-vue: 10.4.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.35.0(jiti@2.5.1))(vue-eslint-parser@10.2.0(eslint@9.35.0(jiti@2.5.1)))
3062 + eslint-plugin-yml: 1.18.0(eslint@9.35.0(jiti@2.5.1))
3063 + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.21)(eslint@9.35.0(jiti@2.5.1))
3064 + globals: 16.4.0
3065 + jsonc-eslint-parser: 2.4.0
3066 + local-pkg: 1.1.2
3067 + parse-gitignore: 2.0.0
3068 + toml-eslint-parser: 0.10.0
3069 + vue-eslint-parser: 10.2.0(eslint@9.35.0(jiti@2.5.1))
3070 + yaml-eslint-parser: 1.3.0
3071 + transitivePeerDependencies:
3072 + - '@eslint/json'
3073 + - '@vue/compiler-sfc'
3074 + - supports-color
3075 + - typescript
3076 + - vitest
3077 +
3078 + '@antfu/install-pkg@1.1.0':
3079 + dependencies:
3080 + package-manager-detector: 1.3.0
3081 + tinyexec: 1.0.1
3082 +
3083 + '@babel/code-frame@7.27.1':
3084 + dependencies:
3085 + '@babel/helper-validator-identifier': 7.27.1
3086 + js-tokens: 4.0.0
3087 + picocolors: 1.1.1
3088 +
3089 + '@babel/compat-data@7.28.4': {}
3090 +
3091 + '@babel/core@7.28.4':
3092 + dependencies:
3093 + '@babel/code-frame': 7.27.1
3094 + '@babel/generator': 7.28.3
3095 + '@babel/helper-compilation-targets': 7.27.2
3096 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4)
3097 + '@babel/helpers': 7.28.4
3098 + '@babel/parser': 7.28.4
3099 + '@babel/template': 7.27.2
3100 + '@babel/traverse': 7.28.4
3101 + '@babel/types': 7.28.4
3102 + '@jridgewell/remapping': 2.3.5
3103 + convert-source-map: 2.0.0
3104 + debug: 4.4.3
3105 + gensync: 1.0.0-beta.2
3106 + json5: 2.2.3
3107 + semver: 6.3.1
3108 + transitivePeerDependencies:
3109 + - supports-color
3110 +
3111 + '@babel/generator@7.28.3':
3112 + dependencies:
3113 + '@babel/parser': 7.28.4
3114 + '@babel/types': 7.28.4
3115 + '@jridgewell/gen-mapping': 0.3.13
3116 + '@jridgewell/trace-mapping': 0.3.31
3117 + jsesc: 3.1.0
3118 +
3119 + '@babel/helper-annotate-as-pure@7.27.3':
3120 + dependencies:
3121 + '@babel/types': 7.28.4
3122 +
3123 + '@babel/helper-compilation-targets@7.27.2':
3124 + dependencies:
3125 + '@babel/compat-data': 7.28.4
3126 + '@babel/helper-validator-option': 7.27.1
3127 + browserslist: 4.26.2
3128 + lru-cache: 5.1.1
3129 + semver: 6.3.1
3130 +
3131 + '@babel/helper-create-class-features-plugin@7.28.3(@babel/core@7.28.4)':
3132 + dependencies:
3133 + '@babel/core': 7.28.4
3134 + '@babel/helper-annotate-as-pure': 7.27.3
3135 + '@babel/helper-member-expression-to-functions': 7.27.1
3136 + '@babel/helper-optimise-call-expression': 7.27.1
3137 + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.4)
3138 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1
3139 + '@babel/traverse': 7.28.4
3140 + semver: 6.3.1
3141 + transitivePeerDependencies:
3142 + - supports-color
3143 +
3144 + '@babel/helper-globals@7.28.0': {}
3145 +
3146 + '@babel/helper-member-expression-to-functions@7.27.1':
3147 + dependencies:
3148 + '@babel/traverse': 7.28.4
3149 + '@babel/types': 7.28.4
3150 + transitivePeerDependencies:
3151 + - supports-color
3152 +
3153 + '@babel/helper-module-imports@7.27.1':
3154 + dependencies:
3155 + '@babel/traverse': 7.28.4
3156 + '@babel/types': 7.28.4
3157 + transitivePeerDependencies:
3158 + - supports-color
3159 +
3160 + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)':
3161 + dependencies:
3162 + '@babel/core': 7.28.4
3163 + '@babel/helper-module-imports': 7.27.1
3164 + '@babel/helper-validator-identifier': 7.27.1
3165 + '@babel/traverse': 7.28.4
3166 + transitivePeerDependencies:
3167 + - supports-color
3168 +
3169 + '@babel/helper-optimise-call-expression@7.27.1':
3170 + dependencies:
3171 + '@babel/types': 7.28.4
3172 +
3173 + '@babel/helper-plugin-utils@7.27.1': {}
3174 +
3175 + '@babel/helper-replace-supers@7.27.1(@babel/core@7.28.4)':
3176 + dependencies:
3177 + '@babel/core': 7.28.4
3178 + '@babel/helper-member-expression-to-functions': 7.27.1
3179 + '@babel/helper-optimise-call-expression': 7.27.1
3180 + '@babel/traverse': 7.28.4
3181 + transitivePeerDependencies:
3182 + - supports-color
3183 +
3184 + '@babel/helper-skip-transparent-expression-wrappers@7.27.1':
3185 + dependencies:
3186 + '@babel/traverse': 7.28.4
3187 + '@babel/types': 7.28.4
3188 + transitivePeerDependencies:
3189 + - supports-color
3190 +
3191 + '@babel/helper-string-parser@7.27.1': {}
3192 +
3193 + '@babel/helper-validator-identifier@7.27.1': {}
3194 +
3195 + '@babel/helper-validator-option@7.27.1': {}
3196 +
3197 + '@babel/helpers@7.28.4':
3198 + dependencies:
3199 + '@babel/template': 7.27.2
3200 + '@babel/types': 7.28.4
3201 +
3202 + '@babel/parser@7.28.4':
3203 + dependencies:
3204 + '@babel/types': 7.28.4
3205 +
3206 + '@babel/plugin-proposal-decorators@7.28.0(@babel/core@7.28.4)':
3207 + dependencies:
3208 + '@babel/core': 7.28.4
3209 + '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4)
3210 + '@babel/helper-plugin-utils': 7.27.1
3211 + '@babel/plugin-syntax-decorators': 7.27.1(@babel/core@7.28.4)
3212 + transitivePeerDependencies:
3213 + - supports-color
3214 +
3215 + '@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.28.4)':
3216 + dependencies:
3217 + '@babel/core': 7.28.4
3218 + '@babel/helper-plugin-utils': 7.27.1
3219 +
3220 + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.4)':
3221 + dependencies:
3222 + '@babel/core': 7.28.4
3223 + '@babel/helper-plugin-utils': 7.27.1
3224 +
3225 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.4)':
3226 + dependencies:
3227 + '@babel/core': 7.28.4
3228 + '@babel/helper-plugin-utils': 7.27.1
3229 +
3230 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.4)':
3231 + dependencies:
3232 + '@babel/core': 7.28.4
3233 + '@babel/helper-plugin-utils': 7.27.1
3234 +
3235 + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.4)':
3236 + dependencies:
3237 + '@babel/core': 7.28.4
3238 + '@babel/helper-plugin-utils': 7.27.1
3239 +
3240 + '@babel/plugin-transform-typescript@7.28.0(@babel/core@7.28.4)':
3241 + dependencies:
3242 + '@babel/core': 7.28.4
3243 + '@babel/helper-annotate-as-pure': 7.27.3
3244 + '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4)
3245 + '@babel/helper-plugin-utils': 7.27.1
3246 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1
3247 + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.4)
3248 + transitivePeerDependencies:
3249 + - supports-color
3250 +
3251 + '@babel/runtime@7.28.4': {}
3252 +
3253 + '@babel/template@7.27.2':
3254 + dependencies:
3255 + '@babel/code-frame': 7.27.1
3256 + '@babel/parser': 7.28.4
3257 + '@babel/types': 7.28.4
3258 +
3259 + '@babel/traverse@7.28.4':
3260 + dependencies:
3261 + '@babel/code-frame': 7.27.1
3262 + '@babel/generator': 7.28.3
3263 + '@babel/helper-globals': 7.28.0
3264 + '@babel/parser': 7.28.4
3265 + '@babel/template': 7.27.2
3266 + '@babel/types': 7.28.4
3267 + debug: 4.4.3
3268 + transitivePeerDependencies:
3269 + - supports-color
3270 +
3271 + '@babel/types@7.28.4':
3272 + dependencies:
3273 + '@babel/helper-string-parser': 7.27.1
3274 + '@babel/helper-validator-identifier': 7.27.1
3275 +
3276 + '@clack/core@0.5.0':
3277 + dependencies:
3278 + picocolors: 1.1.1
3279 + sisteransi: 1.0.5
3280 +
3281 + '@clack/prompts@0.11.0':
3282 + dependencies:
3283 + '@clack/core': 0.5.0
3284 + picocolors: 1.1.1
3285 + sisteransi: 1.0.5
3286 +
3287 + '@css-render/plugin-bem@0.15.14(css-render@0.15.14)':
3288 + dependencies:
3289 + css-render: 0.15.14
3290 +
3291 + '@css-render/vue3-ssr@0.15.14(vue@3.5.21(typescript@5.8.3))':
3292 + dependencies:
3293 + vue: 3.5.21(typescript@5.8.3)
3294 +
3295 + '@emotion/hash@0.8.0': {}
3296 +
3297 + '@es-joy/jsdoccomment@0.50.2':
3298 + dependencies:
3299 + '@types/estree': 1.0.8
3300 + '@typescript-eslint/types': 8.44.0
3301 + comment-parser: 1.4.1
3302 + esquery: 1.6.0
3303 + jsdoc-type-pratt-parser: 4.1.0
3304 +
3305 + '@es-joy/jsdoccomment@0.56.0':
3306 + dependencies:
3307 + '@types/estree': 1.0.8
3308 + '@typescript-eslint/types': 8.44.0
3309 + comment-parser: 1.4.1
3310 + esquery: 1.6.0
3311 + jsdoc-type-pratt-parser: 5.1.1
3312 +
3313 + '@esbuild/aix-ppc64@0.25.9':
3314 + optional: true
3315 +
3316 + '@esbuild/android-arm64@0.25.9':
3317 + optional: true
3318 +
3319 + '@esbuild/android-arm@0.25.9':
3320 + optional: true
3321 +
3322 + '@esbuild/android-x64@0.25.9':
3323 + optional: true
3324 +
3325 + '@esbuild/darwin-arm64@0.25.9':
3326 + optional: true
3327 +
3328 + '@esbuild/darwin-x64@0.25.9':
3329 + optional: true
3330 +
3331 + '@esbuild/freebsd-arm64@0.25.9':
3332 + optional: true
3333 +
3334 + '@esbuild/freebsd-x64@0.25.9':
3335 + optional: true
3336 +
3337 + '@esbuild/linux-arm64@0.25.9':
3338 + optional: true
3339 +
3340 + '@esbuild/linux-arm@0.25.9':
3341 + optional: true
3342 +
3343 + '@esbuild/linux-ia32@0.25.9':
3344 + optional: true
3345 +
3346 + '@esbuild/linux-loong64@0.25.9':
3347 + optional: true
3348 +
3349 + '@esbuild/linux-mips64el@0.25.9':
3350 + optional: true
3351 +
3352 + '@esbuild/linux-ppc64@0.25.9':
3353 + optional: true
3354 +
3355 + '@esbuild/linux-riscv64@0.25.9':
3356 + optional: true
3357 +
3358 + '@esbuild/linux-s390x@0.25.9':
3359 + optional: true
3360 +
3361 + '@esbuild/linux-x64@0.25.9':
3362 + optional: true
3363 +
3364 + '@esbuild/netbsd-arm64@0.25.9':
3365 + optional: true
3366 +
3367 + '@esbuild/netbsd-x64@0.25.9':
3368 + optional: true
3369 +
3370 + '@esbuild/openbsd-arm64@0.25.9':
3371 + optional: true
3372 +
3373 + '@esbuild/openbsd-x64@0.25.9':
3374 + optional: true
3375 +
3376 + '@esbuild/openharmony-arm64@0.25.9':
3377 + optional: true
3378 +
3379 + '@esbuild/sunos-x64@0.25.9':
3380 + optional: true
3381 +
3382 + '@esbuild/win32-arm64@0.25.9':
3383 + optional: true
3384 +
3385 + '@esbuild/win32-ia32@0.25.9':
3386 + optional: true
3387 +
3388 + '@esbuild/win32-x64@0.25.9':
3389 + optional: true
3390 +
3391 + '@eslint-community/eslint-plugin-eslint-comments@4.5.0(eslint@9.35.0(jiti@2.5.1))':
3392 + dependencies:
3393 + escape-string-regexp: 4.0.0
3394 + eslint: 9.35.0(jiti@2.5.1)
3395 + ignore: 5.3.2
3396 +
3397 + '@eslint-community/eslint-utils@4.9.0(eslint@9.35.0(jiti@2.5.1))':
3398 + dependencies:
3399 + eslint: 9.35.0(jiti@2.5.1)
3400 + eslint-visitor-keys: 3.4.3
3401 +
3402 + '@eslint-community/regexpp@4.12.1': {}
3403 +
3404 + '@eslint/compat@1.3.2(eslint@9.35.0(jiti@2.5.1))':
3405 + optionalDependencies:
3406 + eslint: 9.35.0(jiti@2.5.1)
3407 +
3408 + '@eslint/config-array@0.21.0':
3409 + dependencies:
3410 + '@eslint/object-schema': 2.1.6
3411 + debug: 4.4.3
3412 + minimatch: 3.1.2
3413 + transitivePeerDependencies:
3414 + - supports-color
3415 +
3416 + '@eslint/config-helpers@0.3.1': {}
3417 +
3418 + '@eslint/core@0.15.2':
3419 + dependencies:
3420 + '@types/json-schema': 7.0.15
3421 +
3422 + '@eslint/eslintrc@3.3.1':
3423 + dependencies:
3424 + ajv: 6.12.6
3425 + debug: 4.4.3
3426 + espree: 10.4.0
3427 + globals: 14.0.0
3428 + ignore: 5.3.2
3429 + import-fresh: 3.3.1
3430 + js-yaml: 4.1.0
3431 + minimatch: 3.1.2
3432 + strip-json-comments: 3.1.1
3433 + transitivePeerDependencies:
3434 + - supports-color
3435 +
3436 + '@eslint/js@9.35.0': {}
3437 +
3438 + '@eslint/markdown@7.2.0':
3439 + dependencies:
3440 + '@eslint/core': 0.15.2
3441 + '@eslint/plugin-kit': 0.3.5
3442 + github-slugger: 2.0.0
3443 + mdast-util-from-markdown: 2.0.2
3444 + mdast-util-frontmatter: 2.0.1
3445 + mdast-util-gfm: 3.1.0
3446 + micromark-extension-frontmatter: 2.0.0
3447 + micromark-extension-gfm: 3.0.0
3448 + micromark-util-normalize-identifier: 2.0.1
3449 + transitivePeerDependencies:
3450 + - supports-color
3451 +
3452 + '@eslint/object-schema@2.1.6': {}
3453 +
3454 + '@eslint/plugin-kit@0.3.5':
3455 + dependencies:
3456 + '@eslint/core': 0.15.2
3457 + levn: 0.4.1
3458 +
3459 + '@humanfs/core@0.19.1': {}
3460 +
3461 + '@humanfs/node@0.16.7':
3462 + dependencies:
3463 + '@humanfs/core': 0.19.1
3464 + '@humanwhocodes/retry': 0.4.3
3465 +
3466 + '@humanwhocodes/module-importer@1.0.1': {}
3467 +
3468 + '@humanwhocodes/retry@0.4.3': {}
3469 +
3470 + '@iconify/types@2.0.0': {}
3471 +
3472 + '@iconify/vue@5.0.0(vue@3.5.21(typescript@5.8.3))':
3473 + dependencies:
3474 + '@iconify/types': 2.0.0
3475 + vue: 3.5.21(typescript@5.8.3)
3476 +
3477 + '@isaacs/fs-minipass@4.0.1':
3478 + dependencies:
3479 + minipass: 7.1.2
3480 +
3481 + '@jridgewell/gen-mapping@0.3.13':
3482 + dependencies:
3483 + '@jridgewell/sourcemap-codec': 1.5.5
3484 + '@jridgewell/trace-mapping': 0.3.31
3485 +
3486 + '@jridgewell/remapping@2.3.5':
3487 + dependencies:
3488 + '@jridgewell/gen-mapping': 0.3.13
3489 + '@jridgewell/trace-mapping': 0.3.31
3490 +
3491 + '@jridgewell/resolve-uri@3.1.2': {}
3492 +
3493 + '@jridgewell/sourcemap-codec@1.5.5': {}
3494 +
3495 + '@jridgewell/trace-mapping@0.3.31':
3496 + dependencies:
3497 + '@jridgewell/resolve-uri': 3.1.2
3498 + '@jridgewell/sourcemap-codec': 1.5.5
3499 +
3500 + '@juggle/resize-observer@3.4.0': {}
3501 +
3502 + '@nodelib/fs.scandir@2.1.5':
3503 + dependencies:
3504 + '@nodelib/fs.stat': 2.0.5
3505 + run-parallel: 1.2.0
3506 +
3507 + '@nodelib/fs.stat@2.0.5': {}
3508 +
3509 + '@nodelib/fs.walk@1.2.8':
3510 + dependencies:
3511 + '@nodelib/fs.scandir': 2.1.5
3512 + fastq: 1.19.1
3513 +
3514 + '@parcel/watcher-android-arm64@2.5.1':
3515 + optional: true
3516 +
3517 + '@parcel/watcher-darwin-arm64@2.5.1':
3518 + optional: true
3519 +
3520 + '@parcel/watcher-darwin-x64@2.5.1':
3521 + optional: true
3522 +
3523 + '@parcel/watcher-freebsd-x64@2.5.1':
3524 + optional: true
3525 +
3526 + '@parcel/watcher-linux-arm-glibc@2.5.1':
3527 + optional: true
3528 +
3529 + '@parcel/watcher-linux-arm-musl@2.5.1':
3530 + optional: true
3531 +
3532 + '@parcel/watcher-linux-arm64-glibc@2.5.1':
3533 + optional: true
3534 +
3535 + '@parcel/watcher-linux-arm64-musl@2.5.1':
3536 + optional: true
3537 +
3538 + '@parcel/watcher-linux-x64-glibc@2.5.1':
3539 + optional: true
3540 +
3541 + '@parcel/watcher-linux-x64-musl@2.5.1':
3542 + optional: true
3543 +
3544 + '@parcel/watcher-win32-arm64@2.5.1':
3545 + optional: true
3546 +
3547 + '@parcel/watcher-win32-ia32@2.5.1':
3548 + optional: true
3549 +
3550 + '@parcel/watcher-win32-x64@2.5.1':
3551 + optional: true
3552 +
3553 + '@parcel/watcher@2.5.1':
3554 + dependencies:
3555 + detect-libc: 1.0.3
3556 + is-glob: 4.0.3
3557 + micromatch: 4.0.8
3558 + node-addon-api: 7.1.1
3559 + optionalDependencies:
3560 + '@parcel/watcher-android-arm64': 2.5.1
3561 + '@parcel/watcher-darwin-arm64': 2.5.1
3562 + '@parcel/watcher-darwin-x64': 2.5.1
3563 + '@parcel/watcher-freebsd-x64': 2.5.1
3564 + '@parcel/watcher-linux-arm-glibc': 2.5.1
3565 + '@parcel/watcher-linux-arm-musl': 2.5.1
3566 + '@parcel/watcher-linux-arm64-glibc': 2.5.1
3567 + '@parcel/watcher-linux-arm64-musl': 2.5.1
3568 + '@parcel/watcher-linux-x64-glibc': 2.5.1
3569 + '@parcel/watcher-linux-x64-musl': 2.5.1
3570 + '@parcel/watcher-win32-arm64': 2.5.1
3571 + '@parcel/watcher-win32-ia32': 2.5.1
3572 + '@parcel/watcher-win32-x64': 2.5.1
3573 + optional: true
3574 +
3575 + '@pkgr/core@0.2.9': {}
3576 +
3577 + '@polka/url@1.0.0-next.29': {}
3578 +
3579 + '@rolldown/pluginutils@1.0.0-beta.29': {}
3580 +
3581 + '@rollup/rollup-android-arm-eabi@4.50.2':
3582 + optional: true
3583 +
3584 + '@rollup/rollup-android-arm64@4.50.2':
3585 + optional: true
3586 +
3587 + '@rollup/rollup-darwin-arm64@4.50.2':
3588 + optional: true
3589 +
3590 + '@rollup/rollup-darwin-x64@4.50.2':
3591 + optional: true
3592 +
3593 + '@rollup/rollup-freebsd-arm64@4.50.2':
3594 + optional: true
3595 +
3596 + '@rollup/rollup-freebsd-x64@4.50.2':
3597 + optional: true
3598 +
3599 + '@rollup/rollup-linux-arm-gnueabihf@4.50.2':
3600 + optional: true
3601 +
3602 + '@rollup/rollup-linux-arm-musleabihf@4.50.2':
3603 + optional: true
3604 +
3605 + '@rollup/rollup-linux-arm64-gnu@4.50.2':
3606 + optional: true
3607 +
3608 + '@rollup/rollup-linux-arm64-musl@4.50.2':
3609 + optional: true
3610 +
3611 + '@rollup/rollup-linux-loong64-gnu@4.50.2':
3612 + optional: true
3613 +
3614 + '@rollup/rollup-linux-ppc64-gnu@4.50.2':
3615 + optional: true
3616 +
3617 + '@rollup/rollup-linux-riscv64-gnu@4.50.2':
3618 + optional: true
3619 +
3620 + '@rollup/rollup-linux-riscv64-musl@4.50.2':
3621 + optional: true
3622 +
3623 + '@rollup/rollup-linux-s390x-gnu@4.50.2':
3624 + optional: true
3625 +
3626 + '@rollup/rollup-linux-x64-gnu@4.50.2':
3627 + optional: true
3628 +
3629 + '@rollup/rollup-linux-x64-musl@4.50.2':
3630 + optional: true
3631 +
3632 + '@rollup/rollup-openharmony-arm64@4.50.2':
3633 + optional: true
3634 +
3635 + '@rollup/rollup-win32-arm64-msvc@4.50.2':
3636 + optional: true
3637 +
3638 + '@rollup/rollup-win32-ia32-msvc@4.50.2':
3639 + optional: true
3640 +
3641 + '@rollup/rollup-win32-x64-msvc@4.50.2':
3642 + optional: true
3643 +
3644 + '@sec-ant/readable-stream@0.4.1': {}
3645 +
3646 + '@sindresorhus/merge-streams@4.0.0': {}
3647 +
3648 + '@stylistic/eslint-plugin@5.3.1(eslint@9.35.0(jiti@2.5.1))':
3649 + dependencies:
3650 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1))
3651 + '@typescript-eslint/types': 8.44.0
3652 + eslint: 9.35.0(jiti@2.5.1)
3653 + eslint-visitor-keys: 4.2.1
3654 + espree: 10.4.0
3655 + estraverse: 5.3.0
3656 + picomatch: 4.0.3
3657 +
3658 + '@tailwindcss/node@4.1.13':
3659 + dependencies:
3660 + '@jridgewell/remapping': 2.3.5
3661 + enhanced-resolve: 5.18.3
3662 + jiti: 2.5.1
3663 + lightningcss: 1.30.1
3664 + magic-string: 0.30.19
3665 + source-map-js: 1.2.1
3666 + tailwindcss: 4.1.13
3667 +
3668 + '@tailwindcss/oxide-android-arm64@4.1.13':
3669 + optional: true
3670 +
3671 + '@tailwindcss/oxide-darwin-arm64@4.1.13':
3672 + optional: true
3673 +
3674 + '@tailwindcss/oxide-darwin-x64@4.1.13':
3675 + optional: true
3676 +
3677 + '@tailwindcss/oxide-freebsd-x64@4.1.13':
3678 + optional: true
3679 +
3680 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13':
3681 + optional: true
3682 +
3683 + '@tailwindcss/oxide-linux-arm64-gnu@4.1.13':
3684 + optional: true
3685 +
3686 + '@tailwindcss/oxide-linux-arm64-musl@4.1.13':
3687 + optional: true
3688 +
3689 + '@tailwindcss/oxide-linux-x64-gnu@4.1.13':
3690 + optional: true
3691 +
3692 + '@tailwindcss/oxide-linux-x64-musl@4.1.13':
3693 + optional: true
3694 +
3695 + '@tailwindcss/oxide-wasm32-wasi@4.1.13':
3696 + optional: true
3697 +
3698 + '@tailwindcss/oxide-win32-arm64-msvc@4.1.13':
3699 + optional: true
3700 +
3701 + '@tailwindcss/oxide-win32-x64-msvc@4.1.13':
3702 + optional: true
3703 +
3704 + '@tailwindcss/oxide@4.1.13':
3705 + dependencies:
3706 + detect-libc: 2.1.0
3707 + tar: 7.4.3
3708 + optionalDependencies:
3709 + '@tailwindcss/oxide-android-arm64': 4.1.13
3710 + '@tailwindcss/oxide-darwin-arm64': 4.1.13
3711 + '@tailwindcss/oxide-darwin-x64': 4.1.13
3712 + '@tailwindcss/oxide-freebsd-x64': 4.1.13
3713 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.13
3714 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.13
3715 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.13
3716 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.13
3717 + '@tailwindcss/oxide-linux-x64-musl': 4.1.13
3718 + '@tailwindcss/oxide-wasm32-wasi': 4.1.13
3719 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.13
3720 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.13
3721 +
3722 + '@tailwindcss/vite@4.1.13(vite@7.1.5(@types/node@24.5.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.92.1)(yaml@2.8.1))':
3723 + dependencies:
3724 + '@tailwindcss/node': 4.1.13
3725 + '@tailwindcss/oxide': 4.1.13
3726 + tailwindcss: 4.1.13
3727 + vite: 7.1.5(@types/node@24.5.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.92.1)(yaml@2.8.1)
3728 +
3729 + '@trysound/sax@0.2.0': {}
3730 +
3731 + '@tsconfig/node20@20.1.6': {}
3732 +
3733 + '@types/debug@4.1.12':
3734 + dependencies:
3735 + '@types/ms': 2.1.0
3736 +
3737 + '@types/estree@1.0.8': {}
3738 +
3739 + '@types/json-schema@7.0.15': {}
3740 +
3741 + '@types/katex@0.16.7': {}
3742 +
3743 + '@types/lodash-es@4.17.12':
3744 + dependencies:
3745 + '@types/lodash': 4.17.20
3746 +
3747 + '@types/lodash@4.17.20': {}
3748 +
3749 + '@types/mdast@4.0.4':
3750 + dependencies:
3751 + '@types/unist': 3.0.3
3752 +
3753 + '@types/ms@2.1.0': {}
3754 +
3755 + '@types/node@24.5.0':
3756 + dependencies:
3757 + undici-types: 7.12.0
3758 +
3759 + '@types/unist@3.0.3': {}
3760 +
3761 + '@types/web-bluetooth@0.0.21': {}
3762 +
3763 + '@typescript-eslint/eslint-plugin@8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)':
3764 + dependencies:
3765 + '@eslint-community/regexpp': 4.12.1
3766 + '@typescript-eslint/parser': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
3767 + '@typescript-eslint/scope-manager': 8.44.0
3768 + '@typescript-eslint/type-utils': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
3769 + '@typescript-eslint/utils': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
3770 + '@typescript-eslint/visitor-keys': 8.44.0
3771 + eslint: 9.35.0(jiti@2.5.1)
3772 + graphemer: 1.4.0
3773 + ignore: 7.0.5
3774 + natural-compare: 1.4.0
3775 + ts-api-utils: 2.1.0(typescript@5.8.3)
3776 + typescript: 5.8.3
3777 + transitivePeerDependencies:
3778 + - supports-color
3779 +
3780 + '@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)':
3781 + dependencies:
3782 + '@typescript-eslint/scope-manager': 8.44.0
3783 + '@typescript-eslint/types': 8.44.0
3784 + '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.8.3)
3785 + '@typescript-eslint/visitor-keys': 8.44.0
3786 + debug: 4.4.3
3787 + eslint: 9.35.0(jiti@2.5.1)
3788 + typescript: 5.8.3
3789 + transitivePeerDependencies:
3790 + - supports-color
3791 +
3792 + '@typescript-eslint/project-service@8.44.0(typescript@5.8.3)':
3793 + dependencies:
3794 + '@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.8.3)
3795 + '@typescript-eslint/types': 8.44.0
3796 + debug: 4.4.3
3797 + typescript: 5.8.3
3798 + transitivePeerDependencies:
3799 + - supports-color
3800 +
3801 + '@typescript-eslint/scope-manager@8.44.0':
3802 + dependencies:
3803 + '@typescript-eslint/types': 8.44.0
3804 + '@typescript-eslint/visitor-keys': 8.44.0
3805 +
3806 + '@typescript-eslint/tsconfig-utils@8.44.0(typescript@5.8.3)':
3807 + dependencies:
3808 + typescript: 5.8.3
3809 +
3810 + '@typescript-eslint/type-utils@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)':
3811 + dependencies:
3812 + '@typescript-eslint/types': 8.44.0
3813 + '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.8.3)
3814 + '@typescript-eslint/utils': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
3815 + debug: 4.4.3
3816 + eslint: 9.35.0(jiti@2.5.1)
3817 + ts-api-utils: 2.1.0(typescript@5.8.3)
3818 + typescript: 5.8.3
3819 + transitivePeerDependencies:
3820 + - supports-color
3821 +
3822 + '@typescript-eslint/types@8.44.0': {}
3823 +
3824 + '@typescript-eslint/typescript-estree@8.44.0(typescript@5.8.3)':
3825 + dependencies:
3826 + '@typescript-eslint/project-service': 8.44.0(typescript@5.8.3)
3827 + '@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.8.3)
3828 + '@typescript-eslint/types': 8.44.0
3829 + '@typescript-eslint/visitor-keys': 8.44.0
3830 + debug: 4.4.3
3831 + fast-glob: 3.3.3
3832 + is-glob: 4.0.3
3833 + minimatch: 9.0.5
3834 + semver: 7.7.2
3835 + ts-api-utils: 2.1.0(typescript@5.8.3)
3836 + typescript: 5.8.3
3837 + transitivePeerDependencies:
3838 + - supports-color
3839 +
3840 + '@typescript-eslint/utils@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)':
3841 + dependencies:
3842 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1))
3843 + '@typescript-eslint/scope-manager': 8.44.0
3844 + '@typescript-eslint/types': 8.44.0
3845 + '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.8.3)
3846 + eslint: 9.35.0(jiti@2.5.1)
3847 + typescript: 5.8.3
3848 + transitivePeerDependencies:
3849 + - supports-color
3850 +
3851 + '@typescript-eslint/visitor-keys@8.44.0':
3852 + dependencies:
3853 + '@typescript-eslint/types': 8.44.0
3854 + eslint-visitor-keys: 4.2.1
3855 +
3856 + '@vitejs/plugin-vue@6.0.1(vite@7.1.5(@types/node@24.5.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.92.1)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3))':
3857 + dependencies:
3858 + '@rolldown/pluginutils': 1.0.0-beta.29
3859 + vite: 7.1.5(@types/node@24.5.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.92.1)(yaml@2.8.1)
3860 + vue: 3.5.21(typescript@5.8.3)
3861 +
3862 + '@vitest/eslint-plugin@1.3.10(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)':
3863 + dependencies:
3864 + '@typescript-eslint/scope-manager': 8.44.0
3865 + '@typescript-eslint/utils': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
3866 + eslint: 9.35.0(jiti@2.5.1)
3867 + optionalDependencies:
3868 + typescript: 5.8.3
3869 + transitivePeerDependencies:
3870 + - supports-color
3871 +
3872 + '@volar/language-core@2.4.23':
3873 + dependencies:
3874 + '@volar/source-map': 2.4.23
3875 +
3876 + '@volar/source-map@2.4.23': {}
3877 +
3878 + '@volar/typescript@2.4.23':
3879 + dependencies:
3880 + '@volar/language-core': 2.4.23
3881 + path-browserify: 1.0.1
3882 + vscode-uri: 3.1.0
3883 +
3884 + '@vue/babel-helper-vue-transform-on@1.5.0': {}
3885 +
3886 + '@vue/babel-plugin-jsx@1.5.0(@babel/core@7.28.4)':
3887 + dependencies:
3888 + '@babel/helper-module-imports': 7.27.1
3889 + '@babel/helper-plugin-utils': 7.27.1
3890 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.4)
3891 + '@babel/template': 7.27.2
3892 + '@babel/traverse': 7.28.4
3893 + '@babel/types': 7.28.4
3894 + '@vue/babel-helper-vue-transform-on': 1.5.0
3895 + '@vue/babel-plugin-resolve-type': 1.5.0(@babel/core@7.28.4)
3896 + '@vue/shared': 3.5.21
3897 + optionalDependencies:
3898 + '@babel/core': 7.28.4
3899 + transitivePeerDependencies:
3900 + - supports-color
3901 +
3902 + '@vue/babel-plugin-resolve-type@1.5.0(@babel/core@7.28.4)':
3903 + dependencies:
3904 + '@babel/code-frame': 7.27.1
3905 + '@babel/core': 7.28.4
3906 + '@babel/helper-module-imports': 7.27.1
3907 + '@babel/helper-plugin-utils': 7.27.1
3908 + '@babel/parser': 7.28.4
3909 + '@vue/compiler-sfc': 3.5.21
3910 + transitivePeerDependencies:
3911 + - supports-color
3912 +
3913 + '@vue/compiler-core@3.5.21':
3914 + dependencies:
3915 + '@babel/parser': 7.28.4
3916 + '@vue/shared': 3.5.21
3917 + entities: 4.5.0
3918 + estree-walker: 2.0.2
3919 + source-map-js: 1.2.1
3920 +
3921 + '@vue/compiler-dom@3.5.21':
3922 + dependencies:
3923 + '@vue/compiler-core': 3.5.21
3924 + '@vue/shared': 3.5.21
3925 +
3926 + '@vue/compiler-sfc@3.5.21':
3927 + dependencies:
3928 + '@babel/parser': 7.28.4
3929 + '@vue/compiler-core': 3.5.21
3930 + '@vue/compiler-dom': 3.5.21
3931 + '@vue/compiler-ssr': 3.5.21
3932 + '@vue/shared': 3.5.21
3933 + estree-walker: 2.0.2
3934 + magic-string: 0.30.19
3935 + postcss: 8.5.6
3936 + source-map-js: 1.2.1
3937 +
3938 + '@vue/compiler-ssr@3.5.21':
3939 + dependencies:
3940 + '@vue/compiler-dom': 3.5.21
3941 + '@vue/shared': 3.5.21
3942 +
3943 + '@vue/compiler-vue2@2.7.16':
3944 + dependencies:
3945 + de-indent: 1.0.2
3946 + he: 1.2.0
3947 +
3948 + '@vue/devtools-api@6.6.4': {}
3949 +
3950 + '@vue/devtools-api@7.7.7':
3951 + dependencies:
3952 + '@vue/devtools-kit': 7.7.7
3953 +
3954 + '@vue/devtools-core@8.0.2(vite@7.1.5(@types/node@24.5.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.92.1)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3))':
3955 + dependencies:
3956 + '@vue/devtools-kit': 8.0.2
3957 + '@vue/devtools-shared': 8.0.2
3958 + mitt: 3.0.1
3959 + nanoid: 5.1.5
3960 + pathe: 2.0.3
3961 + vite-hot-client: 2.1.0(vite@7.1.5(@types/node@24.5.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.92.1)(yaml@2.8.1))
3962 + vue: 3.5.21(typescript@5.8.3)
3963 + transitivePeerDependencies:
3964 + - vite
3965 +
3966 + '@vue/devtools-kit@7.7.7':
3967 + dependencies:
3968 + '@vue/devtools-shared': 7.7.7
3969 + birpc: 2.5.0
3970 + hookable: 5.5.3
3971 + mitt: 3.0.1
3972 + perfect-debounce: 1.0.0
3973 + speakingurl: 14.0.1
3974 + superjson: 2.2.2
3975 +
3976 + '@vue/devtools-kit@8.0.2':
3977 + dependencies:
3978 + '@vue/devtools-shared': 8.0.2
3979 + birpc: 2.5.0
3980 + hookable: 5.5.3
3981 + mitt: 3.0.1
3982 + perfect-debounce: 2.0.0
3983 + speakingurl: 14.0.1
3984 + superjson: 2.2.2
3985 +
3986 + '@vue/devtools-shared@7.7.7':
3987 + dependencies:
3988 + rfdc: 1.4.1
3989 +
3990 + '@vue/devtools-shared@8.0.2':
3991 + dependencies:
3992 + rfdc: 1.4.1
3993 +
3994 + '@vue/language-core@3.0.7(typescript@5.8.3)':
3995 + dependencies:
3996 + '@volar/language-core': 2.4.23
3997 + '@vue/compiler-dom': 3.5.21
3998 + '@vue/compiler-vue2': 2.7.16
3999 + '@vue/shared': 3.5.21
4000 + alien-signals: 2.0.7
4001 + muggle-string: 0.4.1
4002 + path-browserify: 1.0.1
4003 + picomatch: 4.0.3
4004 + optionalDependencies:
4005 + typescript: 5.8.3
4006 +
4007 + '@vue/reactivity@3.5.21':
4008 + dependencies:
4009 + '@vue/shared': 3.5.21
4010 +
4011 + '@vue/runtime-core@3.5.21':
4012 + dependencies:
4013 + '@vue/reactivity': 3.5.21
4014 + '@vue/shared': 3.5.21
4015 +
4016 + '@vue/runtime-dom@3.5.21':
4017 + dependencies:
4018 + '@vue/reactivity': 3.5.21
4019 + '@vue/runtime-core': 3.5.21
4020 + '@vue/shared': 3.5.21
4021 + csstype: 3.1.3
4022 +
4023 + '@vue/server-renderer@3.5.21(vue@3.5.21(typescript@5.8.3))':
4024 + dependencies:
4025 + '@vue/compiler-ssr': 3.5.21
4026 + '@vue/shared': 3.5.21
4027 + vue: 3.5.21(typescript@5.8.3)
4028 +
4029 + '@vue/shared@3.5.21': {}
4030 +
4031 + '@vue/tsconfig@0.7.0(typescript@5.8.3)(vue@3.5.21(typescript@5.8.3))':
4032 + optionalDependencies:
4033 + typescript: 5.8.3
4034 + vue: 3.5.21(typescript@5.8.3)
4035 +
4036 + '@vueuse/core@13.9.0(vue@3.5.21(typescript@5.8.3))':
4037 + dependencies:
4038 + '@types/web-bluetooth': 0.0.21
4039 + '@vueuse/metadata': 13.9.0
4040 + '@vueuse/shared': 13.9.0(vue@3.5.21(typescript@5.8.3))
4041 + vue: 3.5.21(typescript@5.8.3)
4042 +
4043 + '@vueuse/metadata@13.9.0': {}
4044 +
4045 + '@vueuse/shared@13.9.0(vue@3.5.21(typescript@5.8.3))':
4046 + dependencies:
4047 + vue: 3.5.21(typescript@5.8.3)
4048 +
4049 + acorn-jsx@5.3.2(acorn@8.15.0):
4050 + dependencies:
4051 + acorn: 8.15.0
4052 +
4053 + acorn@8.15.0: {}
4054 +
4055 + ajv@6.12.6:
4056 + dependencies:
4057 + fast-deep-equal: 3.1.3
4058 + fast-json-stable-stringify: 2.1.0
4059 + json-schema-traverse: 0.4.1
4060 + uri-js: 4.4.1
4061 +
4062 + alien-signals@2.0.7: {}
4063 +
4064 + ansi-regex@5.0.1: {}
4065 +
4066 + ansi-styles@4.3.0:
4067 + dependencies:
4068 + color-convert: 2.0.1
4069 +
4070 + ansi-styles@6.2.3: {}
4071 +
4072 + ansis@4.1.0: {}
4073 +
4074 + are-docs-informative@0.0.2: {}
4075 +
4076 + argparse@2.0.1: {}
4077 +
4078 + async-validator@4.2.5: {}
4079 +
4080 + asynckit@0.4.0: {}
4081 +
4082 + axios@1.12.2:
4083 + dependencies:
4084 + follow-redirects: 1.15.11
4085 + form-data: 4.0.4
4086 + proxy-from-env: 1.1.0
4087 + transitivePeerDependencies:
4088 + - debug
4089 +
4090 + balanced-match@1.0.2: {}
4091 +
4092 + baseline-browser-mapping@2.8.4: {}
4093 +
4094 + birpc@2.5.0: {}
4095 +
4096 + boolbase@1.0.0: {}
4097 +
4098 + brace-expansion@1.1.12:
4099 + dependencies:
4100 + balanced-match: 1.0.2
4101 + concat-map: 0.0.1
4102 +
4103 + brace-expansion@2.0.2:
4104 + dependencies:
4105 + balanced-match: 1.0.2
4106 +
4107 + braces@3.0.3:
4108 + dependencies:
4109 + fill-range: 7.1.1
4110 +
4111 + browserslist@4.26.2:
4112 + dependencies:
4113 + baseline-browser-mapping: 2.8.4
4114 + caniuse-lite: 1.0.30001743
4115 + electron-to-chromium: 1.5.218
4116 + node-releases: 2.0.21
4117 + update-browserslist-db: 1.1.3(browserslist@4.26.2)
4118 +
4119 + builtin-modules@5.0.0: {}
4120 +
4121 + bundle-name@4.1.0:
4122 + dependencies:
4123 + run-applescript: 7.1.0
4124 +
4125 + cac@6.7.14: {}
4126 +
4127 + call-bind-apply-helpers@1.0.2:
4128 + dependencies:
4129 + es-errors: 1.3.0
4130 + function-bind: 1.1.2
4131 +
4132 + callsites@3.1.0: {}
4133 +
4134 + caniuse-lite@1.0.30001743: {}
4135 +
4136 + ccount@2.0.1: {}
4137 +
4138 + chalk@4.1.2:
4139 + dependencies:
4140 + ansi-styles: 4.3.0
4141 + supports-color: 7.2.0
4142 +
4143 + change-case@5.4.4: {}
4144 +
4145 + character-entities@2.0.2: {}
4146 +
4147 + chokidar@4.0.3:
4148 + dependencies:
4149 + readdirp: 4.1.2
4150 +
4151 + chownr@3.0.0: {}
4152 +
4153 + ci-info@4.3.0: {}
4154 +
4155 + clean-regexp@1.0.0:
4156 + dependencies:
4157 + escape-string-regexp: 1.0.5
4158 +
4159 + cliui@8.0.1:
4160 + dependencies:
4161 + string-width: 4.2.3
4162 + strip-ansi: 6.0.1
4163 + wrap-ansi: 7.0.0
4164 +
4165 + color-convert@2.0.1:
4166 + dependencies:
4167 + color-name: 1.1.4
4168 +
4169 + color-name@1.1.4: {}
4170 +
4171 + combined-stream@1.0.8:
4172 + dependencies:
4173 + delayed-stream: 1.0.0
4174 +
4175 + commander@7.2.0: {}
4176 +
4177 + comment-parser@1.4.1: {}
4178 +
4179 + concat-map@0.0.1: {}
4180 +
4181 + concurrently@8.2.2:
4182 + dependencies:
4183 + chalk: 4.1.2
4184 + date-fns: 2.30.0
4185 + lodash: 4.17.21
4186 + rxjs: 7.8.2
4187 + shell-quote: 1.8.3
4188 + spawn-command: 0.0.2
4189 + supports-color: 8.1.1
4190 + tree-kill: 1.2.2
4191 + yargs: 17.7.2
4192 +
4193 + confbox@0.1.8: {}
4194 +
4195 + confbox@0.2.2: {}
4196 +
4197 + convert-source-map@2.0.0: {}
4198 +
4199 + copy-anything@3.0.5:
4200 + dependencies:
4201 + is-what: 4.1.16
4202 +
4203 + core-js-compat@3.45.1:
4204 + dependencies:
4205 + browserslist: 4.26.2
4206 +
4207 + cross-spawn@7.0.6:
4208 + dependencies:
4209 + path-key: 3.1.1
4210 + shebang-command: 2.0.0
4211 + which: 2.0.2
4212 +
4213 + crypto-js@4.2.0: {}
4214 +
4215 + css-render@0.15.14:
4216 + dependencies:
4217 + '@emotion/hash': 0.8.0
4218 + csstype: 3.0.11
4219 +
4220 + css-select@5.2.2:
4221 + dependencies:
4222 + boolbase: 1.0.0
4223 + css-what: 6.2.2
4224 + domhandler: 5.0.3
4225 + domutils: 3.2.2
4226 + nth-check: 2.1.1
4227 +
4228 + css-tree@2.2.1:
4229 + dependencies:
4230 + mdn-data: 2.0.28
4231 + source-map-js: 1.2.1
4232 +
4233 + css-tree@2.3.1:
4234 + dependencies:
4235 + mdn-data: 2.0.30
4236 + source-map-js: 1.2.1
4237 +
4238 + css-what@6.2.2: {}
4239 +
4240 + cssesc@3.0.0: {}
4241 +
4242 + csso@5.0.5:
4243 + dependencies:
4244 + css-tree: 2.2.1
4245 +
4246 + csstype@3.0.11: {}
4247 +
4248 + csstype@3.1.3: {}
4249 +
4250 + date-fns-tz@3.2.0(date-fns@3.6.0):
4251 + dependencies:
4252 + date-fns: 3.6.0
4253 +
4254 + date-fns@2.30.0:
4255 + dependencies:
4256 + '@babel/runtime': 7.28.4
4257 +
4258 + date-fns@3.6.0: {}
4259 +
4260 + dayjs@1.11.18: {}
4261 +
4262 + de-indent@1.0.2: {}
4263 +
4264 + debug@4.4.3:
4265 + dependencies:
4266 + ms: 2.1.3
4267 +
4268 + decode-named-character-reference@1.2.0:
4269 + dependencies:
4270 + character-entities: 2.0.2
4271 +
4272 + deep-is@0.1.4: {}
4273 +
4274 + deep-pick-omit@1.2.1: {}
4275 +
4276 + default-browser-id@5.0.0: {}
4277 +
4278 + default-browser@5.2.1:
4279 + dependencies:
4280 + bundle-name: 4.1.0
4281 + default-browser-id: 5.0.0
4282 +
4283 + define-lazy-prop@3.0.0: {}
4284 +
4285 + defu@6.1.4: {}
4286 +
4287 + delayed-stream@1.0.0: {}
4288 +
4289 + dequal@2.0.3: {}
4290 +
4291 + destr@2.0.5: {}
4292 +
4293 + detect-libc@1.0.3:
4294 + optional: true
4295 +
4296 + detect-libc@2.1.0: {}
4297 +
4298 + devlop@1.1.0:
4299 + dependencies:
4300 + dequal: 2.0.3
4301 +
4302 + dom-serializer@2.0.0:
4303 + dependencies:
4304 + domelementtype: 2.3.0
4305 + domhandler: 5.0.3
4306 + entities: 4.5.0
4307 +
4308 + domelementtype@2.3.0: {}
4309 +
4310 + domhandler@5.0.3:
4311 + dependencies:
4312 + domelementtype: 2.3.0
4313 +
4314 + domutils@3.2.2:
4315 + dependencies:
4316 + dom-serializer: 2.0.0
4317 + domelementtype: 2.3.0
4318 + domhandler: 5.0.3
4319 +
4320 + dunder-proto@1.0.1:
4321 + dependencies:
4322 + call-bind-apply-helpers: 1.0.2
4323 + es-errors: 1.3.0
4324 + gopd: 1.2.0
4325 +
4326 + electron-to-chromium@1.5.218: {}
4327 +
4328 + emoji-regex@8.0.0: {}
4329 +
4330 + empathic@2.0.0: {}
4331 +
4332 + enhanced-resolve@5.18.3:
4333 + dependencies:
4334 + graceful-fs: 4.2.11
4335 + tapable: 2.2.3
4336 +
4337 + entities@4.5.0: {}
4338 +
4339 + error-stack-parser-es@1.0.5: {}
4340 +
4341 + es-define-property@1.0.1: {}
4342 +
4343 + es-errors@1.3.0: {}
4344 +
4345 + es-object-atoms@1.1.1:
4346 + dependencies:
4347 + es-errors: 1.3.0
4348 +
4349 + es-set-tostringtag@2.1.0:
4350 + dependencies:
4351 + es-errors: 1.3.0
4352 + get-intrinsic: 1.3.0
4353 + has-tostringtag: 1.0.2
4354 + hasown: 2.0.2
4355 +
4356 + esbuild@0.25.9:
4357 + optionalDependencies:
4358 + '@esbuild/aix-ppc64': 0.25.9
4359 + '@esbuild/android-arm': 0.25.9
4360 + '@esbuild/android-arm64': 0.25.9
4361 + '@esbuild/android-x64': 0.25.9
4362 + '@esbuild/darwin-arm64': 0.25.9
4363 + '@esbuild/darwin-x64': 0.25.9
4364 + '@esbuild/freebsd-arm64': 0.25.9
4365 + '@esbuild/freebsd-x64': 0.25.9
4366 + '@esbuild/linux-arm': 0.25.9
4367 + '@esbuild/linux-arm64': 0.25.9
4368 + '@esbuild/linux-ia32': 0.25.9
4369 + '@esbuild/linux-loong64': 0.25.9
4370 + '@esbuild/linux-mips64el': 0.25.9
4371 + '@esbuild/linux-ppc64': 0.25.9
4372 + '@esbuild/linux-riscv64': 0.25.9
4373 + '@esbuild/linux-s390x': 0.25.9
4374 + '@esbuild/linux-x64': 0.25.9
4375 + '@esbuild/netbsd-arm64': 0.25.9
4376 + '@esbuild/netbsd-x64': 0.25.9
4377 + '@esbuild/openbsd-arm64': 0.25.9
4378 + '@esbuild/openbsd-x64': 0.25.9
4379 + '@esbuild/openharmony-arm64': 0.25.9
4380 + '@esbuild/sunos-x64': 0.25.9
4381 + '@esbuild/win32-arm64': 0.25.9
4382 + '@esbuild/win32-ia32': 0.25.9
4383 + '@esbuild/win32-x64': 0.25.9
4384 +
4385 + escalade@3.2.0: {}
4386 +
4387 + escape-string-regexp@1.0.5: {}
4388 +
4389 + escape-string-regexp@4.0.0: {}
4390 +
4391 + escape-string-regexp@5.0.0: {}
4392 +
4393 + eslint-compat-utils@0.5.1(eslint@9.35.0(jiti@2.5.1)):
4394 + dependencies:
4395 + eslint: 9.35.0(jiti@2.5.1)
4396 + semver: 7.7.2
4397 +
4398 + eslint-compat-utils@0.6.5(eslint@9.35.0(jiti@2.5.1)):
4399 + dependencies:
4400 + eslint: 9.35.0(jiti@2.5.1)
4401 + semver: 7.7.2
4402 +
4403 + eslint-config-flat-gitignore@2.1.0(eslint@9.35.0(jiti@2.5.1)):
4404 + dependencies:
4405 + '@eslint/compat': 1.3.2(eslint@9.35.0(jiti@2.5.1))
4406 + eslint: 9.35.0(jiti@2.5.1)
4407 +
4408 + eslint-flat-config-utils@2.1.1:
4409 + dependencies:
4410 + pathe: 2.0.3
4411 +
4412 + eslint-json-compat-utils@0.2.1(eslint@9.35.0(jiti@2.5.1))(jsonc-eslint-parser@2.4.0):
4413 + dependencies:
4414 + eslint: 9.35.0(jiti@2.5.1)
4415 + esquery: 1.6.0
4416 + jsonc-eslint-parser: 2.4.0
4417 +
4418 + eslint-merge-processors@2.0.0(eslint@9.35.0(jiti@2.5.1)):
4419 + dependencies:
4420 + eslint: 9.35.0(jiti@2.5.1)
4421 +
4422 + eslint-plugin-antfu@3.1.1(eslint@9.35.0(jiti@2.5.1)):
4423 + dependencies:
4424 + eslint: 9.35.0(jiti@2.5.1)
4425 +
4426 + eslint-plugin-command@3.3.1(eslint@9.35.0(jiti@2.5.1)):
4427 + dependencies:
4428 + '@es-joy/jsdoccomment': 0.50.2
4429 + eslint: 9.35.0(jiti@2.5.1)
4430 +
4431 + eslint-plugin-es-x@7.8.0(eslint@9.35.0(jiti@2.5.1)):
4432 + dependencies:
4433 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1))
4434 + '@eslint-community/regexpp': 4.12.1
4435 + eslint: 9.35.0(jiti@2.5.1)
4436 + eslint-compat-utils: 0.5.1(eslint@9.35.0(jiti@2.5.1))
4437 +
4438 + eslint-plugin-import-lite@0.3.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3):
4439 + dependencies:
4440 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1))
4441 + '@typescript-eslint/types': 8.44.0
4442 + eslint: 9.35.0(jiti@2.5.1)
4443 + optionalDependencies:
4444 + typescript: 5.8.3
4445 +
4446 + eslint-plugin-jsdoc@54.7.0(eslint@9.35.0(jiti@2.5.1)):
4447 + dependencies:
4448 + '@es-joy/jsdoccomment': 0.56.0
4449 + are-docs-informative: 0.0.2
4450 + comment-parser: 1.4.1
4451 + debug: 4.4.3
4452 + escape-string-regexp: 4.0.0
4453 + eslint: 9.35.0(jiti@2.5.1)
4454 + espree: 10.4.0
4455 + esquery: 1.6.0
4456 + parse-imports-exports: 0.2.4
4457 + semver: 7.7.2
4458 + spdx-expression-parse: 4.0.0
4459 + transitivePeerDependencies:
4460 + - supports-color
4461 +
4462 + eslint-plugin-jsonc@2.20.1(eslint@9.35.0(jiti@2.5.1)):
4463 + dependencies:
4464 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1))
4465 + eslint: 9.35.0(jiti@2.5.1)
4466 + eslint-compat-utils: 0.6.5(eslint@9.35.0(jiti@2.5.1))
4467 + eslint-json-compat-utils: 0.2.1(eslint@9.35.0(jiti@2.5.1))(jsonc-eslint-parser@2.4.0)
4468 + espree: 10.4.0
4469 + graphemer: 1.4.0
4470 + jsonc-eslint-parser: 2.4.0
4471 + natural-compare: 1.4.0
4472 + synckit: 0.11.11
4473 + transitivePeerDependencies:
4474 + - '@eslint/json'
4475 +
4476 + eslint-plugin-n@17.23.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3):
4477 + dependencies:
4478 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1))
4479 + enhanced-resolve: 5.18.3
4480 + eslint: 9.35.0(jiti@2.5.1)
4481 + eslint-plugin-es-x: 7.8.0(eslint@9.35.0(jiti@2.5.1))
4482 + get-tsconfig: 4.10.1
4483 + globals: 15.15.0
4484 + globrex: 0.1.2
4485 + ignore: 5.3.2
4486 + semver: 7.7.2
4487 + ts-declaration-location: 1.0.7(typescript@5.8.3)
4488 + transitivePeerDependencies:
4489 + - typescript
4490 +
4491 + eslint-plugin-no-only-tests@3.3.0: {}
4492 +
4493 + eslint-plugin-perfectionist@4.15.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3):
4494 + dependencies:
4495 + '@typescript-eslint/types': 8.44.0
4496 + '@typescript-eslint/utils': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
4497 + eslint: 9.35.0(jiti@2.5.1)
4498 + natural-orderby: 5.0.0
4499 + transitivePeerDependencies:
4500 + - supports-color
4501 + - typescript
4502 +
4503 + eslint-plugin-pnpm@1.1.1(eslint@9.35.0(jiti@2.5.1)):
4504 + dependencies:
4505 + empathic: 2.0.0
4506 + eslint: 9.35.0(jiti@2.5.1)
4507 + jsonc-eslint-parser: 2.4.0
4508 + pathe: 2.0.3
4509 + pnpm-workspace-yaml: 1.1.1
4510 + tinyglobby: 0.2.15
4511 + yaml-eslint-parser: 1.3.0
4512 +
4513 + eslint-plugin-regexp@2.10.0(eslint@9.35.0(jiti@2.5.1)):
4514 + dependencies:
4515 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1))
4516 + '@eslint-community/regexpp': 4.12.1
4517 + comment-parser: 1.4.1
4518 + eslint: 9.35.0(jiti@2.5.1)
4519 + jsdoc-type-pratt-parser: 4.8.0
4520 + refa: 0.12.1
4521 + regexp-ast-analysis: 0.7.1
4522 + scslre: 0.3.0
4523 +
4524 + eslint-plugin-toml@0.12.0(eslint@9.35.0(jiti@2.5.1)):
4525 + dependencies:
4526 + debug: 4.4.3
4527 + eslint: 9.35.0(jiti@2.5.1)
4528 + eslint-compat-utils: 0.6.5(eslint@9.35.0(jiti@2.5.1))
4529 + lodash: 4.17.21
4530 + toml-eslint-parser: 0.10.0
4531 + transitivePeerDependencies:
4532 + - supports-color
4533 +
4534 + eslint-plugin-unicorn@61.0.2(eslint@9.35.0(jiti@2.5.1)):
4535 + dependencies:
4536 + '@babel/helper-validator-identifier': 7.27.1
4537 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1))
4538 + '@eslint/plugin-kit': 0.3.5
4539 + change-case: 5.4.4
4540 + ci-info: 4.3.0
4541 + clean-regexp: 1.0.0
4542 + core-js-compat: 3.45.1
4543 + eslint: 9.35.0(jiti@2.5.1)
4544 + esquery: 1.6.0
4545 + find-up-simple: 1.0.1
4546 + globals: 16.4.0
4547 + indent-string: 5.0.0
4548 + is-builtin-module: 5.0.0
4549 + jsesc: 3.1.0
4550 + pluralize: 8.0.0
4551 + regexp-tree: 0.1.27
4552 + regjsparser: 0.12.0
4553 + semver: 7.7.2
4554 + strip-indent: 4.1.0
4555 +
4556 + eslint-plugin-unused-imports@4.2.0(@typescript-eslint/eslint-plugin@8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.35.0(jiti@2.5.1)):
4557 + dependencies:
4558 + eslint: 9.35.0(jiti@2.5.1)
4559 + optionalDependencies:
4560 + '@typescript-eslint/eslint-plugin': 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
4561 +
4562 + eslint-plugin-vue@10.4.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.35.0(jiti@2.5.1))(vue-eslint-parser@10.2.0(eslint@9.35.0(jiti@2.5.1))):
4563 + dependencies:
4564 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1))
4565 + eslint: 9.35.0(jiti@2.5.1)
4566 + natural-compare: 1.4.0
4567 + nth-check: 2.1.1
4568 + postcss-selector-parser: 6.1.2
4569 + semver: 7.7.2
4570 + vue-eslint-parser: 10.2.0(eslint@9.35.0(jiti@2.5.1))
4571 + xml-name-validator: 4.0.0
4572 + optionalDependencies:
4573 + '@typescript-eslint/parser': 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)
4574 +
4575 + eslint-plugin-yml@1.18.0(eslint@9.35.0(jiti@2.5.1)):
4576 + dependencies:
4577 + debug: 4.4.3
4578 + escape-string-regexp: 4.0.0
4579 + eslint: 9.35.0(jiti@2.5.1)
4580 + eslint-compat-utils: 0.6.5(eslint@9.35.0(jiti@2.5.1))
4581 + natural-compare: 1.4.0
4582 + yaml-eslint-parser: 1.3.0
4583 + transitivePeerDependencies:
4584 + - supports-color
4585 +
4586 + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.21)(eslint@9.35.0(jiti@2.5.1)):
4587 + dependencies:
4588 + '@vue/compiler-sfc': 3.5.21
4589 + eslint: 9.35.0(jiti@2.5.1)
4590 +
4591 + eslint-scope@8.4.0:
4592 + dependencies:
4593 + esrecurse: 4.3.0
4594 + estraverse: 5.3.0
4595 +
4596 + eslint-visitor-keys@3.4.3: {}
4597 +
4598 + eslint-visitor-keys@4.2.1: {}
4599 +
4600 + eslint@9.35.0(jiti@2.5.1):
4601 + dependencies:
4602 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1))
4603 + '@eslint-community/regexpp': 4.12.1
4604 + '@eslint/config-array': 0.21.0
4605 + '@eslint/config-helpers': 0.3.1
4606 + '@eslint/core': 0.15.2
4607 + '@eslint/eslintrc': 3.3.1
4608 + '@eslint/js': 9.35.0
4609 + '@eslint/plugin-kit': 0.3.5
4610 + '@humanfs/node': 0.16.7
4611 + '@humanwhocodes/module-importer': 1.0.1
4612 + '@humanwhocodes/retry': 0.4.3
4613 + '@types/estree': 1.0.8
4614 + '@types/json-schema': 7.0.15
4615 + ajv: 6.12.6
4616 + chalk: 4.1.2
4617 + cross-spawn: 7.0.6
4618 + debug: 4.4.3
4619 + escape-string-regexp: 4.0.0
4620 + eslint-scope: 8.4.0
4621 + eslint-visitor-keys: 4.2.1
4622 + espree: 10.4.0
4623 + esquery: 1.6.0
4624 + esutils: 2.0.3
4625 + fast-deep-equal: 3.1.3
4626 + file-entry-cache: 8.0.0
4627 + find-up: 5.0.0
4628 + glob-parent: 6.0.2
4629 + ignore: 5.3.2
4630 + imurmurhash: 0.1.4
4631 + is-glob: 4.0.3
4632 + json-stable-stringify-without-jsonify: 1.0.1
4633 + lodash.merge: 4.6.2
4634 + minimatch: 3.1.2
4635 + natural-compare: 1.4.0
4636 + optionator: 0.9.4
4637 + optionalDependencies:
4638 + jiti: 2.5.1
4639 + transitivePeerDependencies:
4640 + - supports-color
4641 +
4642 + espree@10.4.0:
4643 + dependencies:
4644 + acorn: 8.15.0
4645 + acorn-jsx: 5.3.2(acorn@8.15.0)
4646 + eslint-visitor-keys: 4.2.1
4647 +
4648 + espree@9.6.1:
4649 + dependencies:
4650 + acorn: 8.15.0
4651 + acorn-jsx: 5.3.2(acorn@8.15.0)
4652 + eslint-visitor-keys: 3.4.3
4653 +
4654 + esquery@1.6.0:
4655 + dependencies:
4656 + estraverse: 5.3.0
4657 +
4658 + esrecurse@4.3.0:
4659 + dependencies:
4660 + estraverse: 5.3.0
4661 +
4662 + estraverse@5.3.0: {}
4663 +
4664 + estree-walker@2.0.2: {}
4665 +
4666 + esutils@2.0.3: {}
4667 +
4668 + evtd@0.2.4: {}
4669 +
4670 + execa@9.6.0:
4671 + dependencies:
4672 + '@sindresorhus/merge-streams': 4.0.0
4673 + cross-spawn: 7.0.6
4674 + figures: 6.1.0
4675 + get-stream: 9.0.1
4676 + human-signals: 8.0.1
4677 + is-plain-obj: 4.1.0
4678 + is-stream: 4.0.1
4679 + npm-run-path: 6.0.0
4680 + pretty-ms: 9.3.0
4681 + signal-exit: 4.1.0
4682 + strip-final-newline: 4.0.0
4683 + yoctocolors: 2.1.2
4684 +
4685 + exsolve@1.0.7: {}
4686 +
4687 + fast-deep-equal@3.1.3: {}
4688 +
4689 + fast-glob@3.3.3:
4690 + dependencies:
4691 + '@nodelib/fs.stat': 2.0.5
4692 + '@nodelib/fs.walk': 1.2.8
4693 + glob-parent: 5.1.2
4694 + merge2: 1.4.1
4695 + micromatch: 4.0.8
4696 +
4697 + fast-json-stable-stringify@2.1.0: {}
4698 +
4699 + fast-levenshtein@2.0.6: {}
4700 +
4701 + fastq@1.19.1:
4702 + dependencies:
4703 + reusify: 1.1.0
4704 +
4705 + fault@2.0.1:
4706 + dependencies:
4707 + format: 0.2.2
4708 +
4709 + fdir@6.5.0(picomatch@4.0.3):
4710 + optionalDependencies:
4711 + picomatch: 4.0.3
4712 +
4713 + figures@6.1.0:
4714 + dependencies:
4715 + is-unicode-supported: 2.1.0
4716 +
4717 + file-entry-cache@8.0.0:
4718 + dependencies:
4719 + flat-cache: 4.0.1
4720 +
4721 + fill-range@7.1.1:
4722 + dependencies:
4723 + to-regex-range: 5.0.1
4724 +
4725 + find-up-simple@1.0.1: {}
4726 +
4727 + find-up@5.0.0:
4728 + dependencies:
4729 + locate-path: 6.0.0
4730 + path-exists: 4.0.0
4731 +
4732 + flat-cache@4.0.1:
4733 + dependencies:
4734 + flatted: 3.3.3
4735 + keyv: 4.5.4
4736 +
4737 + flatted@3.3.3: {}
4738 +
4739 + follow-redirects@1.15.11: {}
4740 +
4741 + form-data@4.0.4:
4742 + dependencies:
4743 + asynckit: 0.4.0
4744 + combined-stream: 1.0.8
4745 + es-set-tostringtag: 2.1.0
4746 + hasown: 2.0.2
4747 + mime-types: 2.1.35
4748 +
4749 + format@0.2.2: {}
4750 +
4751 + fsevents@2.3.3:
4752 + optional: true
4753 +
4754 + function-bind@1.1.2: {}
4755 +
4756 + gensync@1.0.0-beta.2: {}
4757 +
4758 + get-caller-file@2.0.5: {}
4759 +
4760 + get-intrinsic@1.3.0:
4761 + dependencies:
4762 + call-bind-apply-helpers: 1.0.2
4763 + es-define-property: 1.0.1
4764 + es-errors: 1.3.0
4765 + es-object-atoms: 1.1.1
4766 + function-bind: 1.1.2
4767 + get-proto: 1.0.1
4768 + gopd: 1.2.0
4769 + has-symbols: 1.1.0
4770 + hasown: 2.0.2
4771 + math-intrinsics: 1.1.0
4772 +
4773 + get-proto@1.0.1:
4774 + dependencies:
4775 + dunder-proto: 1.0.1
4776 + es-object-atoms: 1.1.1
4777 +
4778 + get-stream@9.0.1:
4779 + dependencies:
4780 + '@sec-ant/readable-stream': 0.4.1
4781 + is-stream: 4.0.1
4782 +
4783 + get-tsconfig@4.10.1:
4784 + dependencies:
4785 + resolve-pkg-maps: 1.0.0
4786 +
4787 + github-slugger@2.0.0: {}
4788 +
4789 + glob-parent@5.1.2:
4790 + dependencies:
4791 + is-glob: 4.0.3
4792 +
4793 + glob-parent@6.0.2:
4794 + dependencies:
4795 + is-glob: 4.0.3
4796 +
4797 + globals@14.0.0: {}
4798 +
4799 + globals@15.15.0: {}
4800 +
4801 + globals@16.4.0: {}
4802 +
4803 + globrex@0.1.2: {}
4804 +
4805 + gopd@1.2.0: {}
4806 +
4807 + graceful-fs@4.2.11: {}
4808 +
4809 + graphemer@1.4.0: {}
4810 +
4811 + has-flag@4.0.0: {}
4812 +
4813 + has-symbols@1.1.0: {}
4814 +
4815 + has-tostringtag@1.0.2:
4816 + dependencies:
4817 + has-symbols: 1.1.0
4818 +
4819 + hasown@2.0.2:
4820 + dependencies:
4821 + function-bind: 1.1.2
4822 +
4823 + he@1.2.0: {}
4824 +
4825 + highlight.js@11.11.1: {}
4826 +
4827 + hookable@5.5.3: {}
4828 +
4829 + human-signals@8.0.1: {}
4830 +
4831 + ignore@5.3.2: {}
4832 +
4833 + ignore@7.0.5: {}
4834 +
4835 + immutable@5.1.3: {}
4836 +
4837 + import-fresh@3.3.1:
4838 + dependencies:
4839 + parent-module: 1.0.1
4840 + resolve-from: 4.0.0
4841 +
4842 + imurmurhash@0.1.4: {}
4843 +
4844 + indent-string@5.0.0: {}
4845 +
4846 + is-builtin-module@5.0.0:
4847 + dependencies:
4848 + builtin-modules: 5.0.0
4849 +
4850 + is-docker@3.0.0: {}
4851 +
4852 + is-extglob@2.1.1: {}
4853 +
4854 + is-fullwidth-code-point@3.0.0: {}
4855 +
4856 + is-glob@4.0.3:
4857 + dependencies:
4858 + is-extglob: 2.1.1
4859 +
4860 + is-inside-container@1.0.0:
4861 + dependencies:
4862 + is-docker: 3.0.0
4863 +
4864 + is-number@7.0.0: {}
4865 +
4866 + is-plain-obj@4.1.0: {}
4867 +
4868 + is-stream@4.0.1: {}
4869 +
4870 + is-unicode-supported@2.1.0: {}
4871 +
4872 + is-what@4.1.16: {}
4873 +
4874 + is-wsl@3.1.0:
4875 + dependencies:
4876 + is-inside-container: 1.0.0
4877 +
4878 + isexe@2.0.0: {}
4879 +
4880 + isexe@3.1.1: {}
4881 +
4882 + jiti@2.5.1: {}
4883 +
4884 + jose@6.1.0: {}
4885 +
4886 + js-tokens@4.0.0: {}
4887 +
4888 + js-yaml@4.1.0:
4889 + dependencies:
4890 + argparse: 2.0.1
4891 +
4892 + jsdoc-type-pratt-parser@4.1.0: {}
4893 +
4894 + jsdoc-type-pratt-parser@4.8.0: {}
4895 +
4896 + jsdoc-type-pratt-parser@5.1.1: {}
4897 +
4898 + jsesc@3.0.2: {}
4899 +
4900 + jsesc@3.1.0: {}
4901 +
4902 + json-buffer@3.0.1: {}
4903 +
4904 + json-parse-even-better-errors@4.0.0: {}
4905 +
4906 + json-schema-traverse@0.4.1: {}
4907 +
4908 + json-stable-stringify-without-jsonify@1.0.1: {}
4909 +
4910 + json5@2.2.3: {}
4911 +
4912 + jsonc-eslint-parser@2.4.0:
4913 + dependencies:
4914 + acorn: 8.15.0
4915 + eslint-visitor-keys: 3.4.3
4916 + espree: 9.6.1
4917 + semver: 7.7.2
4918 +
4919 + keyv@4.5.4:
4920 + dependencies:
4921 + json-buffer: 3.0.1
4922 +
4923 + kolorist@1.8.0: {}
4924 +
4925 + levn@0.4.1:
4926 + dependencies:
4927 + prelude-ls: 1.2.1
4928 + type-check: 0.4.0
4929 +
4930 + lightningcss-darwin-arm64@1.30.1:
4931 + optional: true
4932 +
4933 + lightningcss-darwin-x64@1.30.1:
4934 + optional: true
4935 +
4936 + lightningcss-freebsd-x64@1.30.1:
4937 + optional: true
4938 +
4939 + lightningcss-linux-arm-gnueabihf@1.30.1:
4940 + optional: true
4941 +
4942 + lightningcss-linux-arm64-gnu@1.30.1:
4943 + optional: true
4944 +
4945 + lightningcss-linux-arm64-musl@1.30.1:
4946 + optional: true
4947 +
4948 + lightningcss-linux-x64-gnu@1.30.1:
4949 + optional: true
4950 +
4951 + lightningcss-linux-x64-musl@1.30.1:
4952 + optional: true
4953 +
4954 + lightningcss-win32-arm64-msvc@1.30.1:
4955 + optional: true
4956 +
4957 + lightningcss-win32-x64-msvc@1.30.1:
4958 + optional: true
4959 +
4960 + lightningcss@1.30.1:
4961 + dependencies:
4962 + detect-libc: 2.1.0
4963 + optionalDependencies:
4964 + lightningcss-darwin-arm64: 1.30.1
4965 + lightningcss-darwin-x64: 1.30.1
4966 + lightningcss-freebsd-x64: 1.30.1
4967 + lightningcss-linux-arm-gnueabihf: 1.30.1
4968 + lightningcss-linux-arm64-gnu: 1.30.1
4969 + lightningcss-linux-arm64-musl: 1.30.1
4970 + lightningcss-linux-x64-gnu: 1.30.1
4971 + lightningcss-linux-x64-musl: 1.30.1
4972 + lightningcss-win32-arm64-msvc: 1.30.1
4973 + lightningcss-win32-x64-msvc: 1.30.1
4974 +
4975 + local-pkg@1.1.2:
4976 + dependencies:
4977 + mlly: 1.8.0
4978 + pkg-types: 2.3.0
4979 + quansync: 0.2.11
4980 +
4981 + locate-path@6.0.0:
4982 + dependencies:
4983 + p-locate: 5.0.0
4984 +
4985 + lodash-es@4.17.21: {}
4986 +
4987 + lodash.merge@4.6.2: {}
4988 +
4989 + lodash@4.17.21: {}
4990 +
4991 + longest-streak@3.1.0: {}
4992 +
4993 + lru-cache@5.1.1:
4994 + dependencies:
4995 + yallist: 3.1.1
4996 +
4997 + lz-string@1.5.0: {}
4998 +
4999 + magic-string@0.30.19:

This file is too large to show in full.

customer_portal/public/favicon.ico
Binary files /dev/null and b/customer_portal/public/favicon.ico differ
customer_portal/public/logo.svg new
+3
@@ -0,0 +1,3 @@
1 +<?xml version="1.0" standalone="no"?>
2 +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
3 +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="svg" version="1.1" width="400" height="497.1875" viewBox="0, 0, 400,497.1875"><g id="svgg"><path id="path0" d="M163.187 283.504 C 163.117 320.247,163.007 318.404,165.423 320.832 C 165.964 321.375,166.881 322.392,167.461 323.091 C 168.041 323.791,168.642 324.285,168.797 324.190 C 168.952 324.094,169.045 324.132,169.004 324.274 C 168.877 324.716,169.816 325.938,170.284 325.938 C 170.529 325.938,170.635 326.032,170.520 326.147 C 170.405 326.261,170.544 326.613,170.829 326.928 C 171.324 327.474,172.623 327.500,199.952 327.500 L 228.557 327.500 229.669 326.299 C 237.848 317.465,236.850 323.401,236.796 283.906 C 236.771 265.688,236.702 251.801,236.643 253.047 L 236.535 255.313 200.000 255.313 L 163.465 255.313 163.357 253.047 C 163.298 251.801,163.221 265.506,163.187 283.504 M0.313 265.477 C 0.313 275.210,0.341 275.707,0.973 276.966 C 2.064 279.137,3.126 280.334,4.489 280.930 C 5.200 281.240,7.750 282.439,10.156 283.594 C 12.563 284.749,16.289 286.538,18.438 287.569 C 20.586 288.600,23.328 289.905,24.531 290.469 C 25.734 291.033,29.180 292.679,32.188 294.128 C 35.195 295.576,38.254 297.025,38.984 297.347 L 40.313 297.933 40.382 317.326 C 40.420 327.992,40.494 334.645,40.546 332.109 L 40.642 327.500 86.406 327.500 L 132.171 327.500 132.278 332.109 C 132.337 334.645,132.364 323.500,132.338 307.344 C 132.296 281.827,132.223 277.579,131.783 275.000 C 130.884 269.738,130.032 267.327,127.695 263.438 C 126.365 261.225,123.870 258.438,123.220 258.438 C 122.996 258.438,122.813 258.313,122.813 258.161 C 122.812 258.008,122.355 257.653,121.797 257.371 C 120.339 256.634,119.564 256.139,119.308 255.781 C 119.161 255.575,98.839 255.441,59.699 255.387 L 0.313 255.305 0.313 265.477 M280.703 255.737 C 280.316 255.929,280.000 256.193,280.000 256.324 C 280.000 256.455,279.812 256.563,279.582 256.563 C 278.920 256.563,275.625 259.049,275.625 259.548 C 275.625 259.797,275.438 260.000,275.209 260.000 C 274.444 260.000,272.173 263.141,270.916 265.938 C 270.568 266.711,270.166 267.562,270.021 267.829 C 269.668 268.483,268.526 273.110,268.097 275.625 C 267.864 276.992,267.728 287.319,267.682 307.188 C 267.644 323.430,267.661 334.645,267.721 332.109 L 267.829 327.500 313.594 327.500 L 359.358 327.500 359.454 332.109 C 359.506 334.645,359.582 328.000,359.622 317.344 L 359.695 297.969 360.863 297.423 C 363.848 296.030,370.955 292.650,375.739 290.348 C 378.638 288.954,381.044 287.813,381.087 287.813 C 381.131 287.813,384.103 286.390,387.692 284.652 C 391.282 282.914,394.922 281.178,395.781 280.794 C 399.749 279.022,399.869 278.559,399.757 265.445 L 399.671 255.313 340.539 255.351 C 297.771 255.379,281.212 255.486,280.703 255.737 M172.721 329.024 C 173.441 329.716,173.926 329.912,173.595 329.377 C 173.489 329.206,173.094 328.907,172.717 328.713 L 172.031 328.360 172.721 329.024 " stroke="none" fill="#fbab44" fill-rule="evenodd"/><path id="path1" d="M0.207 176.484 C 0.094 180.223,0.034 204.094,0.074 229.531 C 0.115 254.969,0.186 271.175,0.234 265.545 L 0.321 255.309 59.223 255.389 C 91.619 255.433,118.125 255.435,118.125 255.395 C 118.125 255.251,115.844 254.370,115.142 254.243 C 114.750 254.172,114.142 253.960,113.790 253.771 C 113.438 253.583,113.039 253.448,112.903 253.470 C 112.767 253.493,112.586 253.484,112.500 253.451 C 111.792 253.175,104.237 251.869,103.433 251.884 C 103.006 251.892,101.953 251.817,101.094 251.717 C 99.332 251.512,96.400 251.225,94.531 251.076 C 93.844 251.021,92.508 250.892,91.563 250.789 C 90.617 250.686,89.281 250.542,88.594 250.469 C 68.487 248.333,56.970 240.804,48.921 224.531 C 42.866 212.290,38.598 191.192,38.317 172.109 L 38.281 169.688 19.347 169.688 L 0.412 169.688 0.207 176.484 M39.844 170.000 C 39.950 170.172,40.240 170.313,40.487 170.313 C 40.735 170.313,40.938 170.418,40.939 170.547 C 40.942 170.889,42.824 171.905,43.117 171.724 C 43.254 171.639,43.540 171.779,43.753 172.035 C 43.965 172.291,44.262 172.500,44.413 172.500 C 44.564 172.500,44.969 172.781,45.313 173.125 C 45.656 173.469,46.212 173.750,46.547 173.750 C 46.882 173.750,47.269 173.940,47.406 174.173 C 47.544 174.405,48.310 174.957,49.110 175.399 C 49.909 175.841,51.136 176.565,51.838 177.008 C 52.539 177.450,53.189 177.813,53.282 177.813 C 53.375 177.813,53.975 178.147,54.616 178.556 C 55.257 178.964,58.172 180.669,61.094 182.345 C 64.016 184.020,68.023 186.338,70.000 187.495 C 74.085 189.886,97.436 203.374,124.375 218.902 C 147.524 232.246,148.698 232.924,151.719 234.704 C 153.094 235.514,154.790 236.464,155.489 236.815 C 160.132 239.149,162.895 244.302,163.298 251.380 L 163.522 255.313 199.996 255.313 L 236.471 255.313 236.688 251.641 C 237.236 242.355,238.811 240.161,249.253 234.146 C 264.241 225.511,277.317 217.958,288.906 211.239 C 290.994 210.029,296.178 207.048,303.281 202.973 C 307.406 200.607,312.398 197.720,314.375 196.559 C 316.352 195.398,318.461 194.168,319.063 193.826 C 319.664 193.484,322.898 191.618,326.250 189.679 C 329.602 187.741,332.766 185.916,333.281 185.625 C 334.295 185.052,341.339 180.967,343.125 179.916 C 343.727 179.563,345.977 178.235,348.125 176.967 C 350.273 175.698,352.313 174.495,352.656 174.292 C 353.000 174.090,353.809 173.602,354.453 173.208 C 355.098 172.814,355.625 172.590,355.625 172.710 C 355.625 172.831,356.030 172.542,356.525 172.068 C 357.311 171.314,357.854 171.055,358.847 170.961 C 358.986 170.948,359.013 170.798,358.908 170.629 C 358.799 170.451,358.924 170.399,359.203 170.506 C 359.469 170.608,359.688 170.547,359.688 170.369 C 359.688 170.191,359.934 169.982,360.234 169.903 C 360.535 169.824,288.527 169.744,200.216 169.724 C 93.243 169.700,39.715 169.792,39.844 170.000 M361.724 171.954 C 361.726 173.200,361.637 175.063,361.525 176.094 C 361.413 177.125,361.234 179.336,361.127 181.006 C 358.825 217.064,347.461 239.582,327.963 246.718 C 325.065 247.779,324.284 248.053,323.910 248.143 C 323.830 248.162,323.023 248.383,322.116 248.633 C 321.210 248.884,320.117 249.142,319.688 249.208 C 319.258 249.273,317.641 249.538,316.094 249.797 C 313.333 250.258,311.392 250.513,308.281 250.822 C 306.745 250.975,306.251 251.017,302.188 251.334 C 300.248 251.485,299.357 251.578,295.577 252.021 C 293.281 252.291,288.384 253.131,287.813 253.354 C 287.555 253.454,287.168 253.508,286.953 253.473 C 286.738 253.438,286.563 253.572,286.563 253.770 C 286.563 253.968,286.365 254.054,286.124 253.961 C 285.883 253.869,285.598 253.881,285.490 253.989 C 285.383 254.097,285.123 254.216,284.913 254.254 C 279.486 255.244,280.800 255.268,340.855 255.290 L 399.679 255.313 399.766 265.391 C 399.814 270.934,399.886 254.727,399.926 229.375 C 399.966 204.023,399.906 180.223,399.793 176.484 L 399.588 169.688 380.653 169.688 L 361.719 169.689 361.724 171.954 " stroke="none" fill="#fb9b3c" fill-rule="evenodd"/><path id="path2" d="M43.051 418.013 C 42.581 418.263,41.808 418.977,41.333 419.600 L 40.469 420.732 40.387 456.698 L 40.305 492.664 41.012 494.048 C 41.401 494.809,42.116 495.722,42.601 496.075 L 43.483 496.719 199.724 496.798 C 338.186 496.868,356.068 496.823,356.873 496.408 C 357.372 496.149,358.174 495.422,358.656 494.790 L 359.531 493.642 359.531 457.212 L 359.531 420.781 358.631 419.605 C 356.922 417.371,358.257 417.478,333.366 417.572 C 308.066 417.667,309.971 417.467,308.594 420.176 C 307.992 421.360,307.965 422.117,307.881 440.391 L 307.794 459.375 289.060 459.375 L 270.326 459.375 270.241 439.914 L 270.156 420.453 269.428 419.523 C 267.785 417.426,269.037 417.528,244.756 417.513 C 220.973 417.499,221.546 417.460,220.063 419.198 C 218.773 420.710,218.750 421.077,218.750 440.489 L 218.750 459.375 200.000 459.375 L 181.250 459.375 181.250 440.507 C 181.250 418.749,181.296 419.072,178.027 417.833 C 176.227 417.152,133.830 417.407,132.284 418.108 C 129.592 419.331,129.688 418.513,129.688 440.300 L 129.688 459.375 110.938 459.375 L 92.188 459.375 92.188 440.973 C 92.188 420.244,92.175 420.112,90.046 418.489 L 88.955 417.656 66.431 417.608 C 47.275 417.567,43.778 417.628,43.051 418.013 " stroke="none" fill="#fccf5b" fill-rule="evenodd"/><path id="path3" d="M2.859 0.840 C 2.283 1.125,1.439 1.922,0.984 2.610 L 0.156 3.861 0.146 91.383 C 0.140 139.521,0.179 176.834,0.232 174.302 L 0.329 169.697 19.461 169.614 C 29.984 169.569,38.760 169.602,38.962 169.688 C 39.165 169.773,111.798 169.773,200.368 169.688 C 288.939 169.602,370.016 169.569,380.539 169.614 L 399.671 169.697 399.768 174.302 C 399.821 176.834,399.860 139.521,399.854 91.383 L 399.844 3.861 399.012 2.604 C 397.377 0.133,399.990 0.294,363.069 0.386 L 330.156 0.469 329.104 1.219 C 326.750 2.898,326.890 0.648,326.882 37.109 L 326.875 69.688 308.906 69.688 L 290.938 69.688 290.938 44.441 L 290.937 19.195 290.234 17.802 C 289.772 16.886,289.103 16.169,288.281 15.707 L 287.031 15.006 254.163 15.081 C 217.137 15.166,220.575 14.931,218.828 17.497 L 218.125 18.530 218.125 44.109 L 218.125 69.688 200.000 69.688 L 181.875 69.688 181.875 44.109 L 181.875 18.530 181.172 17.497 C 179.425 14.931,182.863 15.166,145.837 15.081 L 112.969 15.006 111.719 15.707 C 110.897 16.169,110.228 16.886,109.766 17.802 L 109.062 19.195 109.063 44.441 L 109.063 69.688 91.094 69.688 L 73.125 69.688 73.118 37.109 C 73.110 0.648,73.250 2.898,70.896 1.219 L 69.844 0.469 36.875 0.395 C 6.708 0.328,3.817 0.366,2.859 0.840 M361.648 171.250 C 361.648 172.023,361.709 172.340,361.783 171.953 C 361.858 171.566,361.858 170.934,361.783 170.547 C 361.709 170.160,361.648 170.477,361.648 171.250 M357.144 171.568 L 356.406 172.198 357.266 171.830 C 357.738 171.628,358.125 171.344,358.125 171.200 C 358.125 170.810,357.958 170.873,357.144 171.568 M43.125 171.759 C 43.125 171.921,43.547 172.302,44.063 172.607 C 44.580 172.912,45.000 173.012,45.000 172.830 C 45.000 172.649,44.835 172.500,44.634 172.500 C 44.433 172.500,44.011 172.267,43.696 171.983 C 43.382 171.698,43.125 171.598,43.125 171.759 " stroke="none" fill="#fc862e" fill-rule="evenodd"/><path id="path4" d="M40.519 334.297 C 40.406 338.035,40.317 349.602,40.320 360.000 C 40.327 381.137,40.273 380.601,42.542 382.218 L 43.594 382.969 86.025 383.050 C 123.482 383.122,128.563 383.076,129.374 382.657 C 130.775 381.932,131.789 380.671,132.162 379.189 C 132.542 377.680,132.636 345.641,132.293 334.297 L 132.088 327.500 86.406 327.500 L 40.725 327.500 40.519 334.297 M172.522 328.457 C 173.111 328.983,173.594 329.525,173.594 329.661 C 173.594 329.965,174.624 331.148,183.281 340.783 C 185.000 342.696,187.250 345.224,188.281 346.401 C 189.313 347.577,190.302 348.692,190.481 348.879 C 190.660 349.066,191.920 350.484,193.281 352.031 C 196.329 355.495,196.773 355.907,198.037 356.446 C 200.772 357.613,202.678 356.646,206.742 352.031 C 208.407 350.141,210.278 348.028,210.900 347.335 C 211.522 346.643,212.664 345.373,213.438 344.512 C 214.211 343.651,215.941 341.731,217.282 340.245 C 218.623 338.758,220.029 337.148,220.407 336.665 C 221.032 335.867,223.081 333.572,224.453 332.135 C 224.754 331.820,224.941 331.563,224.868 331.563 C 224.751 331.563,225.925 330.264,227.905 328.203 L 228.581 327.500 200.015 327.500 L 171.450 327.500 172.522 328.457 M267.707 334.297 C 267.364 345.641,267.458 377.680,267.838 379.189 C 268.211 380.671,269.225 381.932,270.626 382.657 C 271.437 383.076,276.518 383.122,313.975 383.050 L 356.406 382.969 357.458 382.218 C 359.727 380.601,359.673 381.137,359.680 360.000 C 359.683 349.602,359.594 338.035,359.481 334.297 L 359.275 327.500 313.594 327.500 L 267.912 327.500 267.707 334.297 " stroke="none" fill="#fcbc4c" fill-rule="evenodd"/></g></svg>
customer_portal/src/App.vue new
+11
@@ -0,0 +1,11 @@
1 +<template>
2 + <router-view />
3 +</template>
4 +
5 +<script setup lang="ts">
6 +// This is the root component for the customer portal
7 +</script>
8 +
9 +<style scoped>
10 +/* Any app-level styles can go here */
11 +</style>
customer_portal/src/api/agents.ts new
+95
@@ -0,0 +1,95 @@
1 +import { httpClient } from '@/utils/httpClient'
2 +
3 +export interface Agent {
4 + id: number
5 + agent_id: string
6 + ip_address: string
7 + os: string
8 + hostname: string
9 + label: string
10 + critical_asset: boolean
11 + wazuh_last_seen: string
12 + velociraptor_id: string | null
13 + velociraptor_last_seen: string | null
14 + wazuh_agent_version: string
15 + wazuh_agent_status: string
16 + velociraptor_agent_version: string | null
17 + customer_code: string
18 + quarantined: boolean
19 + velociraptor_org: string | null
20 +}
21 +
22 +export interface AgentsResponse {
23 + agents: Agent[]
24 + success: boolean
25 + message: string
26 +}
27 +
28 +class AgentsAPI {
29 + /**
30 + * Get all agents for the authenticated customer
31 + */
32 + async getAgents(): Promise<AgentsResponse> {
33 + try {
34 + const response = await httpClient.get('/agents')
35 + return response.data
36 + } catch (error: any) {
37 + console.error('Error fetching agents:', error)
38 + throw error
39 + }
40 + }
41 +
42 + /**
43 + * Get a specific agent by ID
44 + */
45 + async getAgentById(agentId: string): Promise<{ agent: Agent; success: boolean; message: string }> {
46 + try {
47 + const response = await httpClient.get(`/agents/${agentId}`)
48 + return response.data
49 + } catch (error: any) {
50 + console.error('Error fetching agent:', error)
51 + throw error
52 + }
53 + }
54 +
55 + /**
56 + * Get agent by hostname
57 + */
58 + async getAgentByHostname(hostname: string): Promise<{ agent: Agent; success: boolean; message: string }> {
59 + try {
60 + const response = await httpClient.get(`/agents/hostname/${hostname}`)
61 + return response.data
62 + } catch (error: any) {
63 + console.error('Error fetching agent by hostname:', error)
64 + throw error
65 + }
66 + }
67 +
68 + /**
69 + * Mark agent as critical
70 + */
71 + async markAgentAsCritical(agentId: string): Promise<{ success: boolean; message: string }> {
72 + try {
73 + const response = await httpClient.post(`/agents/${agentId}/critical`)
74 + return response.data
75 + } catch (error: any) {
76 + console.error('Error marking agent as critical:', error)
77 + throw error
78 + }
79 + }
80 +
81 + /**
82 + * Mark agent as not critical
83 + */
84 + async markAgentAsNotCritical(agentId: string): Promise<{ success: boolean; message: string }> {
85 + try {
86 + const response = await httpClient.post(`/agents/${agentId}/noncritical`)
87 + return response.data
88 + } catch (error: any) {
89 + console.error('Error marking agent as not critical:', error)
90 + throw error
91 + }
92 + }
93 +}
94 +
95 +export default new AgentsAPI()
customer_portal/src/api/alerts.ts new
+173
@@ -0,0 +1,173 @@
1 +import { httpClient } from '@/utils/httpClient'
2 +
3 +export interface AlertComment {
4 + id: number
5 + alert_id: number
6 + comment: string
7 + user_name: string
8 + created_at: string
9 +}
10 +
11 +export interface AlertAsset {
12 + id: number
13 + asset_name: string
14 + agent_id: string
15 + customer_code: string
16 + index_id: string
17 + alert_linked: number
18 + alert_context_id: number
19 + velociraptor_id: string
20 + index_name: string
21 +}
22 +
23 +export interface AlertTag {
24 + id: number
25 + tag: string
26 +}
27 +
28 +export interface AlertIoC {
29 + id: number
30 + ioc_value: string
31 + ioc_type: string
32 + ioc_description: string
33 +}
34 +
35 +export interface LinkedCase {
36 + id: number
37 + case_name: string
38 + case_description: string
39 + case_creation_time: string
40 + case_status: string
41 + assigned_to: string | null
42 +}
43 +
44 +export interface Alert {
45 + id: number
46 + alert_creation_time: string
47 + time_closed: string | null
48 + alert_name: string
49 + alert_description: string
50 + status: 'OPEN' | 'IN_PROGRESS' | 'CLOSED'
51 + customer_code: string
52 + source: string
53 + assigned_to: string | null
54 + time_stamp?: string
55 + index_id?: string
56 + index_name?: string
57 + asset_name?: string
58 + case_ids?: number[]
59 + tag?: string[]
60 + comments: AlertComment[]
61 + assets: AlertAsset[]
62 + tags: AlertTag[]
63 + linked_cases: LinkedCase[]
64 + iocs: AlertIoC[]
65 +}
66 +
67 +export interface AlertsResponse {
68 + alerts: Alert[]
69 + total: number
70 + open: number
71 + in_progress: number
72 + closed: number
73 + success: boolean
74 + message: string
75 +}
76 +
77 +export interface AlertResponse {
78 + alerts: Alert[]
79 + success: boolean
80 + message: string
81 +}
82 +
83 +export interface AlertStatusUpdate {
84 + alert_id: number
85 + status: 'OPEN' | 'IN_PROGRESS' | 'CLOSED'
86 +}
87 +
88 +export interface AlertCommentPayload {
89 + alert_id: number
90 + comment: string
91 + user_name: string
92 +}
93 +
94 +export class AlertsAPI {
95 + /**
96 + * Get all alerts with customer access control
97 + */
98 + static async getAlerts(
99 + page: number = 1,
100 + pageSize: number = 25,
101 + order: 'asc' | 'desc' = 'desc'
102 + ): Promise<AlertsResponse> {
103 + const response = await httpClient.get('/incidents/db_operations/alerts', {
104 + params: {
105 + page,
106 + page_size: pageSize,
107 + order
108 + }
109 + })
110 + return response.data
111 + }
112 +
113 + /**
114 + * Get specific alert by ID (with customer access validation)
115 + */
116 + static async getAlert(alertId: number): Promise<AlertResponse> {
117 + const response = await httpClient.get(`/incidents/db_operations/alert/${alertId}`)
118 + return response.data
119 + }
120 +
121 + /**
122 + * Update alert status (customer access controlled)
123 + */
124 + static async updateAlertStatus(alertId: number, status: 'OPEN' | 'IN_PROGRESS' | 'CLOSED'): Promise<AlertResponse> {
125 + const response = await httpClient.put('/incidents/db_operations/alert/status', {
126 + alert_id: alertId,
127 + status
128 + })
129 + return response.data
130 + }
131 +
132 + /**
133 + * Add comment to alert (customer access controlled)
134 + */
135 + static async addComment(payload: AlertCommentPayload): Promise<{ comment: AlertComment; success: boolean; message: string }> {
136 + const response = await httpClient.post('/incidents/db_operations/alert/comment', payload)
137 + return response.data
138 + }
139 +
140 + /**
141 + * Delete alert comment (customer access controlled)
142 + */
143 + static async deleteComment(commentId: number): Promise<{ success: boolean; message: string }> {
144 + const response = await httpClient.delete(`/incidents/db_operations/alert/comment/${commentId}`)
145 + return response.data
146 + }
147 +
148 + /**
149 + * Get alerts by status with customer filtering
150 + */
151 + static async getAlertsByStatus(status: 'OPEN' | 'IN_PROGRESS' | 'CLOSED'): Promise<AlertsResponse> {
152 + const response = await httpClient.get(`/incidents/db_operations/alerts/status/${status}`)
153 + return response.data
154 + }
155 +
156 + /**
157 + * Get alerts by asset name with customer filtering
158 + */
159 + static async getAlertsByAsset(assetName: string): Promise<AlertsResponse> {
160 + const response = await httpClient.get(`/incidents/db_operations/alerts/asset/${assetName}`)
161 + return response.data
162 + }
163 +
164 + /**
165 + * Get alerts by source with customer filtering
166 + */
167 + static async getAlertsBySource(source: string): Promise<AlertsResponse> {
168 + const response = await httpClient.get(`/incidents/db_operations/alerts/source/${source}`)
169 + return response.data
170 + }
171 +}
172 +
173 +export default AlertsAPI
customer_portal/src/api/caseDataStore.ts new
+91
@@ -0,0 +1,91 @@
1 +import { httpClient } from '@/utils/httpClient'
2 +
3 +export interface CaseDataStoreFile {
4 + id: number
5 + case_id: number
6 + bucket_name: string
7 + object_key: string
8 + file_name: string
9 + content_type: string | null
10 + file_size: number | null
11 + upload_time: string
12 + file_hash: string
13 +}
14 +
15 +export interface CaseDataStoreResponse {
16 + case_data_store: CaseDataStoreFile[]
17 + success: boolean
18 + message: string
19 +}
20 +
21 +export class CaseDataStoreAPI {
22 + /**
23 + * Get files associated with a specific case
24 + */
25 + static async getCaseFiles(caseId: number): Promise<CaseDataStoreResponse> {
26 + const response = await httpClient.get(`/incidents/db_operations/case/data-store/${caseId}`)
27 + return response.data
28 + }
29 +
30 + /**
31 + * Download a specific file from a case
32 + * Returns the blob data for download
33 + */
34 + static async downloadCaseFile(caseId: number, fileName: string): Promise<Blob> {
35 + const response = await httpClient.get(
36 + `/incidents/db_operations/case/data-store/download/${caseId}/${fileName}`,
37 + {
38 + responseType: 'blob'
39 + }
40 + )
41 + return response.data
42 + }
43 +
44 + /**
45 + * Upload a file to a case data store
46 + */
47 + static async uploadCaseFile(caseId: number, file: File): Promise<CaseDataStoreResponse> {
48 + const formData = new FormData()
49 + formData.append('file', file)
50 +
51 + const response = await httpClient.post(
52 + `/incidents/db_operations/case/data-store/upload?case_id=${caseId}`,
53 + formData,
54 + {
55 + headers: {
56 + 'Content-Type': 'multipart/form-data'
57 + }
58 + }
59 + )
60 + return response.data
61 + }
62 +
63 + /**
64 + * Trigger file download in browser
65 + */
66 + static downloadFileBlob(blob: Blob, fileName: string): void {
67 + const url = window.URL.createObjectURL(blob)
68 + const link = document.createElement('a')
69 + link.href = url
70 + link.setAttribute('download', fileName)
71 + document.body.appendChild(link)
72 + link.click()
73 + link.remove()
74 + window.URL.revokeObjectURL(url)
75 + }
76 +
77 + /**
78 + * Format file size for display
79 + */
80 + static formatFileSize(bytes: number | null): string {
81 + if (!bytes) return 'Unknown size'
82 +
83 + const sizes = ['Bytes', 'KB', 'MB', 'GB']
84 + if (bytes === 0) return '0 Bytes'
85 +
86 + const i = Math.floor(Math.log(bytes) / Math.log(1024))
87 + return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]
88 + }
89 +}
90 +
91 +export default CaseDataStoreAPI
customer_portal/src/api/cases.ts new
+155
@@ -0,0 +1,155 @@
1 +import { httpClient } from '@/utils/httpClient'
2 +
3 +export interface Case {
4 + id: number
5 + case_creation_time: string
6 + case_description: string
7 + case_name: string
8 + case_status: 'open' | 'in_progress' | 'closed'
9 + assigned_to: string | null
10 + customer_code: string
11 + alert_ids: number[]
12 + alerts?: Alert[]
13 +}
14 +
15 +export interface Alert {
16 + id: number
17 + alert_name: string
18 + asset_name: string
19 + status: 'open' | 'in_progress' | 'closed'
20 + time_stamp: string
21 +}
22 +
23 +export interface CasesResponse {
24 + cases: Case[]
25 + success: boolean
26 + message: string
27 +}
28 +
29 +export interface CaseResponse {
30 + cases: Case[]
31 + success: boolean
32 + message: string
33 +}
34 +
35 +export interface CaseStatusUpdate {
36 + case_id: number
37 + status: 'open' | 'in_progress' | 'closed'
38 +}
39 +
40 +export interface CaseAssignedToUpdate {
41 + case_id: number
42 + assigned_to: string
43 +}
44 +
45 +export interface CasePayload {
46 + case_name: string
47 + case_description: string
48 + assigned_to?: string
49 +}
50 +
51 +export class CasesAPI {
52 + /**
53 + * Get all cases with customer access control
54 + */
55 + static async getCases(): Promise<CasesResponse> {
56 + const response = await httpClient.get('/incidents/db_operations/cases')
57 + return response.data
58 + }
59 +
60 + /**
61 + * Get specific case by ID (with customer access validation)
62 + */
63 + static async getCase(caseId: number): Promise<CaseResponse> {
64 + const response = await httpClient.get(`/incidents/db_operations/case/${caseId}`)
65 + return response.data
66 + }
67 +
68 + /**
69 + * Update case status (customer access controlled)
70 + */
71 + static async updateCaseStatus(caseId: number, status: 'open' | 'in_progress' | 'closed'): Promise<CaseResponse> {
72 + const response = await httpClient.put('/incidents/db_operations/case/status', {
73 + case_id: caseId,
74 + status
75 + })
76 + return response.data
77 + }
78 +
79 + /**
80 + * Update case assigned user (customer access controlled)
81 + */
82 + static async updateCaseAssignedTo(caseId: number, assignedTo: string): Promise<CaseResponse> {
83 + const response = await httpClient.put('/incidents/db_operations/case/assigned-to', {
84 + case_id: caseId,
85 + assigned_to: assignedTo
86 + })
87 + return response.data
88 + }
89 +
90 + /**
91 + * Create new case (customer access controlled)
92 + */
93 + static async createCase(payload: CasePayload): Promise<{ case: Case; success: boolean; message: string }> {
94 + const response = await httpClient.post('/incidents/db_operations/case/create', payload)
95 + return response.data
96 + }
97 +
98 + /**
99 + * Delete case (customer access controlled)
100 + */
101 + static async deleteCase(caseId: number): Promise<{ success: boolean; message: string }> {
102 + const response = await httpClient.delete(`/incidents/db_operations/case/${caseId}`)
103 + return response.data
104 + }
105 +
106 + /**
107 + * Get cases by status with customer filtering
108 + */
109 + static async getCasesByStatus(status: string): Promise<CasesResponse> {
110 + const response = await httpClient.get(`/incidents/db_operations/case/status/${status}`)
111 + return response.data
112 + }
113 +
114 + /**
115 + * Get cases by assigned user with customer filtering
116 + */
117 + static async getCasesByAssignedTo(assignedTo: string): Promise<CasesResponse> {
118 + const response = await httpClient.get(`/incidents/db_operations/case/assigned-to/${assignedTo}`)
119 + return response.data
120 + }
121 +
122 + /**
123 + * Create case from alert (customer access controlled)
124 + */
125 + static async createCaseFromAlert(alertId: number): Promise<{ case_alert_link: { case_id: number; alert_id: number }; success: boolean; message: string }> {
126 + const response = await httpClient.post('/incidents/db_operations/case/from-alert', {
127 + alert_id: alertId
128 + })
129 + return response.data
130 + }
131 +
132 + /**
133 + * Link case to alert (customer access controlled)
134 + */
135 + static async linkCaseToAlert(caseId: number, alertId: number): Promise<{ case_alert_link: { case_id: number; alert_id: number }; success: boolean; message: string }> {
136 + const response = await httpClient.post('/incidents/db_operations/case/alert-link', {
137 + case_id: caseId,
138 + alert_id: alertId
139 + })
140 + return response.data
141 + }
142 +
143 + /**
144 + * Unlink case from alert (customer access controlled)
145 + */
146 + static async unlinkCaseFromAlert(caseId: number, alertId: number): Promise<{ success: boolean; message: string }> {
147 + const response = await httpClient.post('/incidents/db_operations/case/alert-unlink', {
148 + case_id: caseId,
149 + alert_id: alertId
150 + })
151 + return response.data
152 + }
153 +}
154 +
155 +export default CasesAPI
customer_portal/src/api/httpClient.ts new
+49
@@ -0,0 +1,49 @@
1 +import type { AxiosRequestHeaders } from "axios"
2 +import axios from "axios"
3 +import { useAuthStore } from "@/stores/auth"
4 +import { isDebounceTimeOver, isJwtExpiring } from "@/utils/auth"
5 +
6 +const HttpClient = axios.create({
7 + baseURL: "/api"
8 +})
9 +
10 +let __TOKEN_REFRESHING = false
11 +let __TOKEN_LAST_CHECK: Date | null = null
12 +
13 +HttpClient.interceptors.request.use(
14 + config => {
15 + const store = useAuthStore()
16 +
17 + if (!config.headers) config.headers = {} as AxiosRequestHeaders
18 + if (store.userToken) {
19 + config.headers.Authorization = `Bearer ${store.userToken}`
20 + }
21 +
22 + if (isJwtExpiring(store.userToken, 60 * 60) && !__TOKEN_REFRESHING && isDebounceTimeOver(__TOKEN_LAST_CHECK)) {
23 + __TOKEN_REFRESHING = true
24 + __TOKEN_LAST_CHECK = new Date()
25 +
26 + store.refreshToken().then(() => {
27 + __TOKEN_REFRESHING = false
28 + })
29 + }
30 +
31 + return config
32 + },
33 + error => Promise.reject(error)
34 +)
35 +
36 +HttpClient.interceptors.response.use(
37 + response => response,
38 + error => {
39 + if (error.response && error.response.status === 401) {
40 + if (!window.location.pathname.includes("login")) {
41 + window.location.href = "/logout"
42 + }
43 + }
44 +
45 + return Promise.reject(error)
46 + }
47 +)
48 +
49 +export { HttpClient }
customer_portal/src/components/LoginPage.vue new
+176
@@ -0,0 +1,176 @@
1 +<template>
2 + <div class="min-h-screen flex">
3 + <!-- Left side - Login Form -->
4 + <div class="flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 bg-gray-50">
5 + <div class="max-w-md w-full space-y-8">
6 + <!-- Logo and Title -->
7 + <div class="text-center">
8 + <div class="mb-6">
9 + <img
10 + class="mx-auto h-12 w-auto"
11 + src="/logo.svg"
12 + alt="SOCFortress Logo"
13 + @error="$event.target.style.display = 'none'"
14 + />
15 + </div>
16 + <h2 class="text-4xl font-bold text-gray-900 mb-2">
17 + SOCFortress Customer Portal
18 + </h2>
19 + <p class="text-lg text-gray-600">
20 + Access your security dashboard and reports
21 + </p>
22 + </div>
23 +
24 + <!-- Login Form -->
25 + <div class="mt-8 bg-white py-8 px-6 shadow-lg rounded-lg">
26 + <form @submit.prevent="handleLogin" class="space-y-6">
27 + <div v-if="error" class="bg-red-50 border border-red-200 rounded-md p-3">
28 + <div class="text-sm text-red-700">{{ error }}</div>
29 + </div>
30 +
31 + <div>
32 + <label for="username" class="block text-sm font-medium text-gray-700 mb-2">
33 + Username
34 + </label>
35 + <input
36 + id="username"
37 + v-model="username"
38 + type="text"
39 + required
40 + autocomplete="username"
41 + class="block w-full px-3 py-3 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-base"
42 + placeholder="Enter your username"
43 + />
44 + </div>
45 +
46 + <div>
47 + <label for="password" class="block text-sm font-medium text-gray-700 mb-2">
48 + Password
49 + </label>
50 + <input
51 + id="password"
52 + v-model="password"
53 + type="password"
54 + required
55 + autocomplete="current-password"
56 + class="block w-full px-3 py-3 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-base"
57 + placeholder="Enter your password"
58 + />
59 + </div>
60 +
61 + <div>
62 + <button
63 + type="submit"
64 + :disabled="loading || !username || !password"
65 + class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-base font-medium rounded-md 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 transition-colors duration-200"
66 + >
67 + <span v-if="loading" class="flex items-center">
68 + <svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
69 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
70 + <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>
71 + </svg>
72 + Signing in...
73 + </span>
74 + <span v-else>Sign in</span>
75 + </button>
76 + </div>
77 + </form>
78 + </div>
79 +
80 + <!-- Footer -->
81 + <div class="text-center text-sm text-gray-500">
82 + <p>For customer users only</p>
83 + </div>
84 + </div>
85 + </div>
86 +
87 + <!-- Right side - Background Image/Color -->
88 + <div class="hidden lg:block relative flex-1">
89 + <div class="absolute inset-0 bg-gradient-to-br from-indigo-600 to-purple-700">
90 + <div class="absolute inset-0 bg-black bg-opacity-20"></div>
91 + <div class="relative h-full flex items-center justify-center p-12">
92 + <div class="text-center text-white">
93 + <h3 class="text-3xl font-bold mb-4">
94 + Welcome to Your Security Dashboard
95 + </h3>
96 + <p class="text-xl opacity-90">
97 + Monitor alerts, track cases, and stay informed about your organization's security posture.
98 + </p>
99 + </div>
100 + </div>
101 + </div>
102 + </div>
103 + </div>
104 +</template>
105 +
106 +<script setup lang="ts">
107 +import { ref } from 'vue'
108 +import { useRouter } from 'vue-router'
109 +import { useAuthStore } from '@/stores/auth'
110 +
111 +const router = useRouter()
112 +const authStore = useAuthStore()
113 +
114 +const username = ref('')
115 +const password = ref('')
116 +const loading = ref(false)
117 +const error = ref('')
118 +
119 +const handleLogin = async () => {
120 + loading.value = true
121 + error.value = ''
122 +
123 + try {
124 + // Use the Vite proxy in development, direct URL in production
125 + const apiUrl = import.meta.env.DEV ? '' : (import.meta.env.VITE_API_URL || 'http://localhost:5000')
126 + const response = await fetch(`${apiUrl}/api/auth/token`, {
127 + method: 'POST',
128 + headers: {
129 + 'Content-Type': 'application/x-www-form-urlencoded',
130 + },
131 + body: new URLSearchParams({
132 + username: username.value,
133 + password: password.value
134 + })
135 + })
136 +
137 + if (response.ok) {
138 + const data = await response.json() as { access_token: string; token_type: string }
139 +
140 + // Check if user is customer_user by decoding the token
141 + if (data.access_token) {
142 + try {
143 + const payload = JSON.parse(atob(data.access_token.split('.')[1]))
144 + const userScopes = payload.scopes || []
145 +
146 + // Check if user has customer_user scope
147 + if (userScopes.includes('customer_user')) {
148 + // Store the token and user info
149 + localStorage.setItem('customer-portal-auth-token', data.access_token)
150 + localStorage.setItem('customer-portal-user', JSON.stringify({
151 + username: username.value,
152 + scopes: userScopes
153 + }))
154 +
155 + router.push('/')
156 + } else {
157 + error.value = 'Access denied. Customer portal is for customer users only.'
158 + }
159 + } catch (err) {
160 + error.value = 'Invalid token received'
161 + }
162 + } else {
163 + error.value = 'Login failed'
164 + }
165 + } else {
166 + const errorData = await response.json() as { detail?: string }
167 + error.value = errorData.detail || 'Login failed'
168 + }
169 + } catch (err) {
170 + error.value = 'Network error. Please try again.'
171 + console.error('Login error:', err)
172 + } finally {
173 + loading.value = false
174 + }
175 +}
176 +</script>
customer_portal/src/main.ts new
+19
@@ -0,0 +1,19 @@
1 +import { createApp } from 'vue'
2 +import { createPinia } from 'pinia'
3 +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
4 +import App from './App.vue'
5 +import router from './router'
6 +
7 +// Import basic CSS
8 +import './styles/main.css'
9 +
10 +const app = createApp(App)
11 +
12 +// Setup Pinia store
13 +const pinia = createPinia()
14 +pinia.use(piniaPluginPersistedstate)
15 +
16 +app.use(pinia)
17 +app.use(router)
18 +
19 +app.mount('#app')
customer_portal/src/router/index.ts new
+83
@@ -0,0 +1,83 @@
1 +import { createRouter, createWebHistory } from 'vue-router'
2 +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 AgentsPage from '@/views/AgentsPage.vue'
7 +
8 +const NotFound = {
9 + template: `
10 + <div class="min-h-screen flex items-center justify-center bg-gray-50">
11 + <div class="text-center">
12 + <h1 class="text-4xl font-bold text-gray-900">404</h1>
13 + <p class="mt-2 text-lg text-gray-600">Page not found</p>
14 + <router-link to="/" class="mt-4 inline-block bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">
15 + Go Home
16 + </router-link>
17 + </div>
18 + </div>
19 + `
20 +}
21 +
22 +const routes = [
23 + {
24 + path: '/login',
25 + name: 'Login',
26 + component: LoginPage,
27 + meta: { requiresGuest: true }
28 + },
29 + {
30 + path: '/',
31 + name: 'Overview',
32 + component: OverviewPage,
33 + meta: { requiresAuth: true }
34 + },
35 + {
36 + path: '/overview',
37 + redirect: '/'
38 + },
39 + {
40 + path: '/alerts',
41 + name: 'Alerts',
42 + component: AlertsPage,
43 + meta: { requiresAuth: true }
44 + },
45 + {
46 + path: '/cases',
47 + name: 'Cases',
48 + component: CasesPage,
49 + meta: { requiresAuth: true }
50 + },
51 + {
52 + path: '/agents',
53 + name: 'Agents',
54 + component: AgentsPage,
55 + meta: { requiresAuth: true }
56 + },
57 + {
58 + path: '/:pathMatch(.*)*',
59 + name: 'NotFound',
60 + component: NotFound
61 + }
62 +]
63 +
64 +const router = createRouter({
65 + history: createWebHistory(),
66 + routes
67 +})
68 +
69 +// Simple navigation guards
70 +router.beforeEach((to, _from, next) => {
71 + const token = localStorage.getItem('customer-portal-auth-token')
72 + const isAuthenticated = !!token
73 +
74 + if (to.meta.requiresAuth && !isAuthenticated) {
75 + next('/login')
76 + } else if (to.meta.requiresGuest && isAuthenticated) {
77 + next('/')
78 + } else {
79 + next()
80 + }
81 +})
82 +
83 +export default router
customer_portal/src/router/index_old.ts new
+191
@@ -0,0 +1,191 @@
1 +import { createRouter, createWebHistory } from 'vue-router'
2 +import { useAuthStore } from '@/stores/auth'
3 +import LoginPage from '@/components/LoginPage.vue'
4 +// TODO: Add back when views are ready
5 +// import AlertsView from '@/views/AlertsView.vue'
6 +// import CasesView from '@/views/CasesView.vue'
7 +const routes = [
8 + {
9 + path: '/login',
10 + name: 'Login',
11 + component: LoginPage,
12 + meta: { requiresGuest: true }
13 + },
14 +
15 +const Dashboard = {
16 + template: `
17 + <div class="min-h-screen bg-gray-50">
18 + <header class="bg-white shadow">
19 + <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
20 + <div class="flex justify-between h-16">
21 + <div class="flex items-center">
22 + <h1 class="text-xl font-semibold">Customer Portal</h1>
23 + </div>
24 + <div class="flex items-center space-x-4">
25 + <span class="text-sm text-gray-700">{{ user?.username }}</span>
26 + <button
27 + @click="logout"
28 + class="bg-red-600 hover:bg-red-700 text-white px-3 py-2 rounded-md text-sm font-medium"
29 + >
30 + Logout
31 + </button>
32 + </div>
33 + </div>
34 + </div>
35 + </header>
36 + <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
37 + <div class="px-4 py-6 sm:px-0">
38 + <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
39 + <div class="bg-white overflow-hidden shadow rounded-lg">
40 + <div class="p-5">
41 + <div class="flex items-center">
42 + <div class="flex-shrink-0">
43 + <div class="w-8 h-8 bg-blue-500 rounded-md flex items-center justify-center">
44 + <span class="text-white font-medium">A</span>
45 + </div>
46 + </div>
47 + <div class="ml-5 w-0 flex-1">
48 + <dl>
49 + <dt class="text-sm font-medium text-gray-500 truncate">
50 + Alerts
51 + </dt>
52 + <dd class="text-lg font-medium text-gray-900">
53 + View security alerts
54 + </dd>
55 + </dl>
56 + </div>
57 + </div>
58 + </div>
59 + <div class="bg-gray-50 px-5 py-3">
60 + <div class="text-sm">
61 + <router-link
62 + to="/alerts"
63 + class="font-medium text-blue-700 hover:text-blue-900"
64 + >
65 + View all alerts
66 + </router-link>
67 + </div>
68 + </div>
69 + </div>
70 +
71 + <div class="bg-white overflow-hidden shadow rounded-lg">
72 + <div class="p-5">
73 + <div class="flex items-center">
74 + <div class="flex-shrink-0">
75 + <div class="w-8 h-8 bg-green-500 rounded-md flex items-center justify-center">
76 + <span class="text-white font-medium">C</span>
77 + </div>
78 + </div>
79 + <div class="ml-5 w-0 flex-1">
80 + <dl>
81 + <dt class="text-sm font-medium text-gray-500 truncate">
82 + Cases
83 + </dt>
84 + <dd class="text-lg font-medium text-gray-900">
85 + View security cases
86 + </dd>
87 + </dl>
88 + </div>
89 + </div>
90 + </div>
91 + <div class="bg-gray-50 px-5 py-3">
92 + <div class="text-sm">
93 + <router-link
94 + to="/cases"
95 + class="font-medium text-green-700 hover:text-green-900"
96 + >
97 + View all cases
98 + </router-link>
99 + </div>
100 + </div>
101 + </div>
102 + </div>
103 + </div>
104 + </main>
105 + </div>
106 + `,
107 + computed: {
108 + user() {
109 + const authStore = useAuthStore()
110 + return authStore.user
111 + }
112 + },
113 + methods: {
114 + logout() {
115 + const authStore = useAuthStore()
116 + authStore.logout()
117 + this.$router.push('/login')
118 + }
119 + }
120 +}
121 +
122 +const NotFound = {
123 + template: `
124 + <div class="min-h-screen flex items-center justify-center bg-gray-50">
125 + <div class="text-center">
126 + <h1 class="text-4xl font-bold text-gray-900">404</h1>
127 + <p class="mt-2 text-lg text-gray-600">Page not found</p>
128 + <a href="#/" class="mt-4 inline-block bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">
129 + Go Home
130 + </a>
131 + </div>
132 + </div>
133 + `
134 +}
135 +
136 +const routes = [
137 + {
138 + path: '/login',
139 + name: 'Login',
140 + component: Login,
141 + meta: { requiresGuest: true }
142 + },
143 + {
144 + path: '/',
145 + name: 'Dashboard',
146 + component: Dashboard,
147 + meta: { requiresAuth: true }
148 + },
149 + // TODO: Add back when views are ready
150 + // {
151 + // path: '/alerts',
152 + // name: 'Alerts',
153 + // component: AlertsView,
154 + // meta: { requiresAuth: true }
155 + // },
156 + // {
157 + // path: '/cases',
158 + // name: 'Cases',
159 + // component: CasesView,
160 + // meta: { requiresAuth: true }
161 + // },
162 + {
163 + path: '/:pathMatch(.*)*',
164 + name: 'NotFound',
165 + component: NotFound
166 + }
167 +]
168 +
169 +const router = createRouter({
170 + history: createWebHistory(),
171 + routes
172 +})
173 +
174 +// Navigation guards
175 +router.beforeEach((to, from, next) => {
176 + const authStore = useAuthStore()
177 +
178 + if (to.meta.requiresAuth && !authStore.isLogged) {
179 + next('/login')
180 + } else if (to.meta.requiresGuest && authStore.isLogged) {
181 + next('/')
182 + } else if (to.meta.requiresAuth && authStore.isLogged && !authStore.isCustomerUser) {
183 + // Ensure only customer users can access protected routes
184 + authStore.logout()
185 + next('/login')
186 + } else {
187 + next()
188 + }
189 +})
190 +
191 +export default router
\ No newline at end of file
customer_portal/src/stores/auth.ts new
+101
@@ -0,0 +1,101 @@
1 +import { defineStore } from 'pinia'
2 +import axios from 'axios'
3 +
4 +interface User {
5 + id: number
6 + username: string
7 + email: string
8 + role_id?: number
9 + role_name?: string
10 +}
11 +
12 +interface AuthState {
13 + userToken: string | null
14 + user: User | null
15 + isAuthenticated: boolean
16 +}
17 +
18 +export const useAuthStore = defineStore('auth', {
19 + state: (): AuthState => ({
20 + userToken: null,
21 + user: null,
22 + isAuthenticated: false
23 + }),
24 +
25 + getters: {
26 + isLogged: (state) => state.isAuthenticated && !!state.userToken,
27 + isCustomerUser: (state) => state.user?.role_name === 'customer_user'
28 + },
29 +
30 + actions: {
31 + async login(username: string, password: string) {
32 + try {
33 + const formData = new FormData()
34 + formData.append('username', username)
35 + formData.append('password', password)
36 +
37 + const response = await axios.post('/api/auth/token', formData)
38 +
39 + if (response.data.access_token) {
40 + this.userToken = response.data.access_token
41 + this.isAuthenticated = true
42 + await this.fetchUser()
43 + return { success: true }
44 + }
45 +
46 + return { success: false, message: 'Login failed' }
47 + } catch (error: any) {
48 + return {
49 + success: false,
50 + message: error.response?.data?.detail || 'Login failed'
51 + }
52 + }
53 + },
54 +
55 + async fetchUser() {
56 + try {
57 + const response = await axios.get('/api/auth/me', {
58 + headers: {
59 + Authorization: `Bearer ${this.userToken}`
60 + }
61 + })
62 + this.user = response.data
63 + } catch (error) {
64 + console.error('Failed to fetch user:', error)
65 + }
66 + },
67 +
68 + async refreshToken() {
69 + try {
70 + const response = await axios.get('/api/auth/refresh', {
71 + headers: {
72 + Authorization: `Bearer ${this.userToken}`
73 + }
74 + })
75 +
76 + if (response.data.access_token) {
77 + this.userToken = response.data.access_token
78 + }
79 + } catch (error) {
80 + console.error('Failed to refresh token:', error)
81 + this.logout()
82 + }
83 + },
84 +
85 + logout() {
86 + this.userToken = null
87 + this.user = null
88 + this.isAuthenticated = false
89 + },
90 +
91 + setLogout() {
92 + this.logout()
93 + }
94 + },
95 +
96 + persist: {
97 + key: 'customer-portal-auth',
98 + storage: localStorage,
99 + paths: ['userToken', 'user', 'isAuthenticated']
100 + }
101 +})
customer_portal/src/styles/main.css new
+20
@@ -0,0 +1,20 @@
1 +@import 'tailwindcss';
2 +
3 +/* Base styles */
4 +* {
5 + box-sizing: border-box;
6 +}
7 +
8 +html, body {
9 + margin: 0;
10 + padding: 0;
11 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
12 + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
13 + sans-serif;
14 + -webkit-font-smoothing: antialiased;
15 + -moz-osx-font-smoothing: grayscale;
16 +}
17 +
18 +#app {
19 + height: 100vh;
20 +}
customer_portal/src/utils/auth.ts new
+20
@@ -0,0 +1,20 @@
1 +import { decodeJwt } from "jose"
2 +import _toNumber from "lodash/toNumber"
3 +
4 +export function isDebounceTimeOver(lastCheck: Date | null) {
5 + const debounceTime = 30 // 30 seconds debounce for customer portal
6 + return !lastCheck || lastCheck.getTime() + _toNumber(debounceTime) * 1000 < Date.now()
7 +}
8 +
9 +/**
10 + * @param token jwt token
11 + * @param threshold in seconds
12 + */
13 +export function isJwtExpiring(token: string, threshold: number): boolean {
14 + try {
15 + const { exp } = decodeJwt(token) || {}
16 + return exp ? Date.now() / 1000 > exp - threshold : true
17 + } catch {
18 + return false
19 + }
20 +}
customer_portal/src/utils/httpClient.ts new
+82
@@ -0,0 +1,82 @@
1 +import type { AxiosRequestHeaders } from "axios"
2 +import axios from "axios"
3 +
4 +const httpClient = axios.create({
5 + baseURL: "/api"
6 +})
7 +
8 +let __TOKEN_REFRESHING = false
9 +let __TOKEN_LAST_CHECK: Date | null = null
10 +
11 +// Helper function to get token from localStorage
12 +function getToken(): string | null {
13 + return localStorage.getItem('customer-portal-auth-token')
14 +}
15 +
16 +// Helper function to check if JWT is expiring
17 +function isJwtExpiring(token: string | null, expiryThresholdSeconds: number): boolean {
18 + if (!token) return false
19 +
20 + try {
21 + const payload = JSON.parse(atob(token.split('.')[1]))
22 + const expiryTime = payload.exp * 1000 // Convert to milliseconds
23 + const currentTime = Date.now()
24 + const thresholdTime = expiryThresholdSeconds * 1000
25 +
26 + return (expiryTime - currentTime) <= thresholdTime
27 + } catch {
28 + return false
29 + }
30 +}
31 +
32 +// Helper function for debouncing token checks
33 +function isDebounceTimeOver(lastCheck: Date | null): boolean {
34 + if (!lastCheck) return true
35 + return (Date.now() - lastCheck.getTime()) > 30000 // 30 seconds
36 +}
37 +
38 +httpClient.interceptors.request.use(
39 + config => {
40 + const token = getToken()
41 +
42 + if (!config.headers) config.headers = {} as AxiosRequestHeaders
43 + if (token) {
44 + config.headers.Authorization = `Bearer ${token}`
45 + console.log('Adding Authorization header:', `Bearer ${token.substring(0, 20)}...`)
46 + } else {
47 + console.warn('No token found in localStorage')
48 + }
49 +
50 + // Optional: Check for token expiry and handle refresh if needed
51 + if (isJwtExpiring(token, 60 * 60) && !__TOKEN_REFRESHING && isDebounceTimeOver(__TOKEN_LAST_CHECK)) {
52 + __TOKEN_REFRESHING = true
53 + __TOKEN_LAST_CHECK = new Date()
54 +
55 + // For customer portal, we'll just let the token expire and redirect to login
56 + // since customer users typically don't have refresh tokens
57 + console.warn('JWT token is expiring soon')
58 + __TOKEN_REFRESHING = false
59 + }
60 +
61 + return config
62 + },
63 + error => Promise.reject(error)
64 +)
65 +
66 +httpClient.interceptors.response.use(
67 + response => response,
68 + error => {
69 + if (error.response && error.response.status === 401) {
70 + if (!window.location.pathname.includes("login")) {
71 + // Clear stored auth data and redirect to login
72 + localStorage.removeItem('customer-portal-auth-token')
73 + localStorage.removeItem('customer-portal-user')
74 + window.location.href = "/login"
75 + }
76 + }
77 +
78 + return Promise.reject(error)
79 + }
80 +)
81 +
82 +export { httpClient }
customer_portal/src/views/AgentsPage.vue new
+782
@@ -0,0 +1,782 @@
1 +<template>
2 + <div class="min-h-screen bg-gray-50">
3 + <!-- Header -->
4 + <header class="bg-white shadow-sm border-b">
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 + <img
9 + class="h-8 w-auto mr-3"
10 + src="/logo.svg"
11 + alt="SOCFortress Logo"
12 + />
13 + <h1 class="text-xl font-semibold text-gray-900">Customer Portal</h1>
14 + <nav class="ml-8 flex space-x-8">
15 + <router-link
16 + to="/"
17 + class="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
18 + >
19 + Overview
20 + </router-link>
21 + <router-link
22 + to="/alerts"
23 + class="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
24 + >
25 + Alerts
26 + </router-link>
27 + <router-link
28 + to="/cases"
29 + class="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
30 + >
31 + Cases
32 + </router-link>
33 + <router-link
34 + to="/agents"
35 + class="text-indigo-600 border-b-2 border-indigo-600 px-3 py-2 rounded-md text-sm font-medium"
36 + >
37 + Agents
38 + </router-link>
39 + </nav>
40 + </div>
41 + <div class="flex items-center space-x-4">
42 + <div class="text-sm text-gray-700">
43 + Welcome, <span class="font-medium">{{ username }}</span>
44 + </div>
45 + <button
46 + @click="logout"
47 + class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
48 + >
49 + Logout
50 + </button>
51 + </div>
52 + </div>
53 + </div>
54 + </header>
55 +
56 + <!-- Main Content -->
57 + <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
58 + <div class="px-4 py-6 sm:px-0">
59 + <!-- Page Header -->
60 + <div class="mb-8">
61 + <h2 class="text-2xl font-bold text-gray-900 mb-2">Agents</h2>
62 + <p class="text-gray-600">Monitor and manage your organization's security agents</p>
63 + </div>
64 +
65 + <!-- Loading State -->
66 + <div v-if="loading" class="flex justify-center items-center py-12">
67 + <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
68 + <span class="ml-3 text-gray-600">Loading agents...</span>
69 + </div>
70 +
71 + <!-- Error State -->
72 + <div v-else-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
73 + <div class="flex">
74 + <div class="flex-shrink-0">
75 + <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
76 + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
77 + </svg>
78 + </div>
79 + <div class="ml-3">
80 + <h3 class="text-sm font-medium text-red-800">Error Loading Agents</h3>
81 + <div class="mt-2 text-sm text-red-700">{{ error }}</div>
82 + <div class="mt-3">
83 + <button
84 + @click="loadAgents"
85 + class="bg-red-100 hover:bg-red-200 text-red-800 px-3 py-1 rounded text-sm font-medium"
86 + >
87 + Try Again
88 + </button>
89 + </div>
90 + </div>
91 + </div>
92 + </div>
93 +
94 + <!-- Content -->
95 + <div v-else>
96 + <!-- Stats Summary -->
97 + <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
98 + <div class="bg-white overflow-hidden shadow-sm rounded-lg">
99 + <div class="p-6">
100 + <div class="flex items-center">
101 + <div class="flex-shrink-0">
102 + <div class="w-8 h-8 bg-blue-500 rounded-md flex items-center justify-center">
103 + <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
104 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"></path>
105 + </svg>
106 + </div>
107 + </div>
108 + <div class="ml-5 w-0 flex-1">
109 + <dl>
110 + <dt class="text-sm font-medium text-gray-500 truncate">Total Agents</dt>
111 + <dd class="text-2xl font-semibold text-gray-900">{{ agents.length }}</dd>
112 + </dl>
113 + </div>
114 + </div>
115 + </div>
116 + </div>
117 +
118 + <div class="bg-white overflow-hidden shadow-sm rounded-lg">
119 + <div class="p-6">
120 + <div class="flex items-center">
121 + <div class="flex-shrink-0">
122 + <div class="w-8 h-8 bg-green-500 rounded-md flex items-center justify-center">
123 + <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
124 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
125 + </svg>
126 + </div>
127 + </div>
128 + <div class="ml-5 w-0 flex-1">
129 + <dl>
130 + <dt class="text-sm font-medium text-gray-500 truncate">Active Agents</dt>
131 + <dd class="text-2xl font-semibold text-gray-900">{{ activeAgents }}</dd>
132 + </dl>
133 + </div>
134 + </div>
135 + </div>
136 + </div>
137 +
138 + <div class="bg-white overflow-hidden shadow-sm rounded-lg">
139 + <div class="p-6">
140 + <div class="flex items-center">
141 + <div class="flex-shrink-0">
142 + <div class="w-8 h-8 bg-yellow-500 rounded-md flex items-center justify-center">
143 + <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
144 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
145 + </svg>
146 + </div>
147 + </div>
148 + <div class="ml-5 w-0 flex-1">
149 + <dl>
150 + <dt class="text-sm font-medium text-gray-500 truncate">Critical Assets</dt>
151 + <dd class="text-2xl font-semibold text-gray-900">{{ criticalAgents }}</dd>
152 + </dl>
153 + </div>
154 + </div>
155 + </div>
156 + </div>
157 +
158 + <div class="bg-white overflow-hidden shadow-sm rounded-lg">
159 + <div class="p-6">
160 + <div class="flex items-center">
161 + <div class="flex-shrink-0">
162 + <div class="w-8 h-8 bg-red-500 rounded-md flex items-center justify-center">
163 + <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
164 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
165 + </svg>
166 + </div>
167 + </div>
168 + <div class="ml-5 w-0 flex-1">
169 + <dl>
170 + <dt class="text-sm font-medium text-gray-500 truncate">Offline Agents</dt>
171 + <dd class="text-2xl font-semibold text-gray-900">{{ offlineAgents }}</dd>
172 + </dl>
173 + </div>
174 + </div>
175 + </div>
176 + </div>
177 + </div>
178 +
179 + <!-- Filters -->
180 + <div class="bg-white shadow-sm rounded-lg mb-6">
181 + <div class="px-6 py-4 border-b border-gray-200">
182 + <h3 class="text-lg font-medium text-gray-900">Filters</h3>
183 + </div>
184 + <div class="p-6">
185 + <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
186 + <div>
187 + <label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
188 + <select
189 + v-model="filters.status"
190 + class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
191 + >
192 + <option value="">All Statuses</option>
193 + <option value="active">Active</option>
194 + <option value="never_connected">Never Connected</option>
195 + <option value="disconnected">Disconnected</option>
196 + <option value="pending">Pending</option>
197 + </select>
198 + </div>
199 + <div>
200 + <label class="block text-sm font-medium text-gray-700 mb-2">Critical Asset</label>
201 + <select
202 + v-model="filters.critical"
203 + class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
204 + >
205 + <option value="">All Assets</option>
206 + <option value="true">Critical Assets</option>
207 + <option value="false">Regular Assets</option>
208 + </select>
209 + </div>
210 + <div>
211 + <label class="block text-sm font-medium text-gray-700 mb-2">Operating System</label>
212 + <select
213 + v-model="filters.os"
214 + class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
215 + >
216 + <option value="">All OS</option>
217 + <option v-for="os in uniqueOperatingSystems" :key="os" :value="os">{{ os }}</option>
218 + </select>
219 + </div>
220 + <div>
221 + <label class="block text-sm font-medium text-gray-700 mb-2">Search</label>
222 + <input
223 + v-model="filters.search"
224 + type="text"
225 + placeholder="Search hostname, IP, agent ID..."
226 + class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
227 + />
228 + </div>
229 + </div>
230 + <div class="mt-4 flex justify-end">
231 + <button
232 + @click="clearFilters"
233 + class="text-indigo-600 hover:text-indigo-500 font-medium text-sm"
234 + >
235 + Clear Filters
236 + </button>
237 + </div>
238 + </div>
239 + </div>
240 +
241 + <!-- Agents Table -->
242 + <div class="bg-white shadow-sm rounded-lg overflow-hidden">
243 + <div class="px-6 py-4 border-b border-gray-200">
244 + <h3 class="text-lg font-medium text-gray-900">Agents ({{ filteredAgents.length }})</h3>
245 + </div>
246 + <div v-if="filteredAgents.length === 0" class="p-6 text-center text-gray-500">
247 + No agents found matching your criteria.
248 + </div>
249 + <div v-else class="overflow-x-auto">
250 + <table class="min-w-full divide-y divide-gray-200">
251 + <thead class="bg-gray-50">
252 + <tr>
253 + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
254 + Agent
255 + </th>
256 + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
257 + Status
258 + </th>
259 + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
260 + Operating System
261 + </th>
262 + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
263 + Last Seen
264 + </th>
265 + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
266 + Version
267 + </th>
268 + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
269 + Actions
270 + </th>
271 + </tr>
272 + </thead>
273 + <tbody class="bg-white divide-y divide-gray-200">
274 + <tr v-for="agent in paginatedAgents" :key="agent.id" class="hover:bg-gray-50">
275 + <td class="px-6 py-4 whitespace-nowrap">
276 + <div class="flex items-center">
277 + <div class="flex-shrink-0 h-10 w-10">
278 + <div
279 + class="h-10 w-10 rounded-full flex items-center justify-center text-sm font-medium text-white"
280 + :class="{
281 + 'bg-green-500': agent.wazuh_agent_status === 'active',
282 + 'bg-red-500': agent.wazuh_agent_status === 'disconnected',
283 + 'bg-yellow-500': agent.wazuh_agent_status === 'never_connected',
284 + 'bg-gray-500': agent.wazuh_agent_status === 'pending'
285 + }"
286 + >
287 + {{ agent.hostname.charAt(0).toUpperCase() }}
288 + </div>
289 + </div>
290 + <div class="ml-4">
291 + <div class="text-sm font-medium text-gray-900">{{ agent.hostname }}</div>
292 + <div class="text-sm text-gray-500">{{ agent.ip_address }}</div>
293 + <div class="text-xs text-gray-400">ID: {{ agent.agent_id }}</div>
294 + </div>
295 + </div>
296 + </td>
297 + <td class="px-6 py-4 whitespace-nowrap">
298 + <div class="flex items-center">
299 + <span
300 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
301 + :class="{
302 + 'bg-green-100 text-green-800': agent.wazuh_agent_status === 'active',
303 + 'bg-red-100 text-red-800': agent.wazuh_agent_status === 'disconnected',
304 + 'bg-yellow-100 text-yellow-800': agent.wazuh_agent_status === 'never_connected',
305 + 'bg-gray-100 text-gray-800': agent.wazuh_agent_status === 'pending'
306 + }"
307 + >
308 + {{ agent.wazuh_agent_status }}
309 + </span>
310 + <span
311 + v-if="agent.critical_asset"
312 + class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800"
313 + >
314 + Critical
315 + </span>
316 + <span
317 + v-if="agent.quarantined"
318 + class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"
319 + >
320 + Quarantined
321 + </span>
322 + </div>
323 + </td>
324 + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
325 + {{ agent.os }}
326 + </td>
327 + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
328 + {{ formatTimeAgo(agent.wazuh_last_seen) }}
329 + </td>
330 + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
331 + <div>Wazuh: {{ agent.wazuh_agent_version }}</div>
332 + <div v-if="agent.velociraptor_agent_version" class="text-xs">
333 + VR: {{ agent.velociraptor_agent_version }}
334 + </div>
335 + </td>
336 + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
337 + <div class="flex space-x-2">
338 + <button
339 + v-if="!agent.critical_asset"
340 + @click="markAsCritical(agent)"
341 + class="text-orange-600 hover:text-orange-900 text-xs"
342 + :disabled="updatingAgent === agent.agent_id"
343 + >
344 + Mark Critical
345 + </button>
346 + <button
347 + v-else
348 + @click="markAsNotCritical(agent)"
349 + class="text-gray-600 hover:text-gray-900 text-xs"
350 + :disabled="updatingAgent === agent.agent_id"
351 + >
352 + Remove Critical
353 + </button>
354 + <button
355 + @click="viewAgentDetails(agent)"
356 + class="text-indigo-600 hover:text-indigo-900 text-xs"
357 + >
358 + Details
359 + </button>
360 + </div>
361 + </td>
362 + </tr>
363 + </tbody>
364 + </table>
365 + </div>
366 +
367 + <!-- Pagination -->
368 + <div v-if="totalPages > 1" class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
369 + <div class="flex-1 flex justify-between sm:hidden">
370 + <button
371 + @click="previousPage"
372 + :disabled="currentPage <= 1"
373 + class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
374 + >
375 + Previous
376 + </button>
377 + <button
378 + @click="nextPage"
379 + :disabled="currentPage >= totalPages"
380 + class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
381 + >
382 + Next
383 + </button>
384 + </div>
385 + <div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
386 + <div>
387 + <p class="text-sm text-gray-700">
388 + Showing
389 + <span class="font-medium">{{ (currentPage - 1) * pageSize + 1 }}</span>
390 + to
391 + <span class="font-medium">{{ Math.min(currentPage * pageSize, filteredAgents.length) }}</span>
392 + of
393 + <span class="font-medium">{{ filteredAgents.length }}</span>
394 + results
395 + </p>
396 + </div>
397 + <div>
398 + <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
399 + <button
400 + @click="previousPage"
401 + :disabled="currentPage <= 1"
402 + class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
403 + >
404 + <span class="sr-only">Previous</span>
405 + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
406 + <path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
407 + </svg>
408 + </button>
409 + <button
410 + v-for="page in visiblePages"
411 + :key="page"
412 + @click="typeof page === 'number' ? currentPage = page : null"
413 + :class="{
414 + 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600': page === currentPage,
415 + 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50': page !== currentPage
416 + }"
417 + class="relative inline-flex items-center px-4 py-2 border text-sm font-medium"
418 + :disabled="typeof page === 'string'"
419 + >
420 + {{ page }}
421 + </button>
422 + <button
423 + @click="nextPage"
424 + :disabled="currentPage >= totalPages"
425 + class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
426 + >
427 + <span class="sr-only">Next</span>
428 + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
429 + <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
430 + </svg>
431 + </button>
432 + </nav>
433 + </div>
434 + </div>
435 + </div>
436 + </div>
437 + </div>
438 + </div>
439 + </main>
440 +
441 + <!-- Agent Details Modal -->
442 + <div
443 + v-if="selectedAgent"
444 + class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
445 + @click="closeAgentDetails"
446 + >
447 + <div
448 + class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white"
449 + @click.stop
450 + >
451 + <div class="flex justify-between items-center mb-4">
452 + <h3 class="text-lg font-bold text-gray-900">Agent Details</h3>
453 + <button
454 + @click="closeAgentDetails"
455 + class="text-gray-400 hover:text-gray-600"
456 + >
457 + <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
458 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
459 + </svg>
460 + </button>
461 + </div>
462 +
463 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
464 + <div>
465 + <label class="block text-sm font-medium text-gray-700">Hostname</label>
466 + <p class="mt-1 text-sm text-gray-900">{{ selectedAgent.hostname }}</p>
467 + </div>
468 + <div>
469 + <label class="block text-sm font-medium text-gray-700">Agent ID</label>
470 + <p class="mt-1 text-sm text-gray-900">{{ selectedAgent.agent_id }}</p>
471 + </div>
472 + <div>
473 + <label class="block text-sm font-medium text-gray-700">IP Address</label>
474 + <p class="mt-1 text-sm text-gray-900">{{ selectedAgent.ip_address }}</p>
475 + </div>
476 + <div>
477 + <label class="block text-sm font-medium text-gray-700">Operating System</label>
478 + <p class="mt-1 text-sm text-gray-900">{{ selectedAgent.os }}</p>
479 + </div>
480 + <div>
481 + <label class="block text-sm font-medium text-gray-700">Wazuh Status</label>
482 + <span
483 + class="mt-1 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
484 + :class="{
485 + 'bg-green-100 text-green-800': selectedAgent.wazuh_agent_status === 'active',
486 + 'bg-red-100 text-red-800': selectedAgent.wazuh_agent_status === 'disconnected',
487 + 'bg-yellow-100 text-yellow-800': selectedAgent.wazuh_agent_status === 'never_connected',
488 + 'bg-gray-100 text-gray-800': selectedAgent.wazuh_agent_status === 'pending'
489 + }"
490 + >
491 + {{ selectedAgent.wazuh_agent_status }}
492 + </span>
493 + </div>
494 + <div>
495 + <label class="block text-sm font-medium text-gray-700">Wazuh Version</label>
496 + <p class="mt-1 text-sm text-gray-900">{{ selectedAgent.wazuh_agent_version }}</p>
497 + </div>
498 + <div>
499 + <label class="block text-sm font-medium text-gray-700">Last Seen (Wazuh)</label>
500 + <p class="mt-1 text-sm text-gray-900">{{ formatDateTime(selectedAgent.wazuh_last_seen) }}</p>
501 + </div>
502 + <div>
503 + <label class="block text-sm font-medium text-gray-700">Velociraptor ID</label>
504 + <p class="mt-1 text-sm text-gray-900">{{ selectedAgent.velociraptor_id || 'N/A' }}</p>
505 + </div>
506 + <div v-if="selectedAgent.velociraptor_agent_version">
507 + <label class="block text-sm font-medium text-gray-700">Velociraptor Version</label>
508 + <p class="mt-1 text-sm text-gray-900">{{ selectedAgent.velociraptor_agent_version }}</p>
509 + </div>
510 + <div v-if="selectedAgent.velociraptor_last_seen">
511 + <label class="block text-sm font-medium text-gray-700">Last Seen (Velociraptor)</label>
512 + <p class="mt-1 text-sm text-gray-900">{{ formatDateTime(selectedAgent.velociraptor_last_seen) }}</p>
513 + </div>
514 + <div>
515 + <label class="block text-sm font-medium text-gray-700">Critical Asset</label>
516 + <span
517 + class="mt-1 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
518 + :class="{
519 + 'bg-orange-100 text-orange-800': selectedAgent.critical_asset,
520 + 'bg-gray-100 text-gray-800': !selectedAgent.critical_asset
521 + }"
522 + >
523 + {{ selectedAgent.critical_asset ? 'Yes' : 'No' }}
524 + </span>
525 + </div>
526 + <div>
527 + <label class="block text-sm font-medium text-gray-700">Quarantined</label>
528 + <span
529 + class="mt-1 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
530 + :class="{
531 + 'bg-red-100 text-red-800': selectedAgent.quarantined,
532 + 'bg-gray-100 text-gray-800': !selectedAgent.quarantined
533 + }"
534 + >
535 + {{ selectedAgent.quarantined ? 'Yes' : 'No' }}
536 + </span>
537 + </div>
538 + </div>
539 + </div>
540 + </div>
541 + </div>
542 +</template>
543 +
544 +<script setup lang="ts">
545 +import { ref, computed, onMounted } from 'vue'
546 +import { useRouter } from 'vue-router'
547 +import AgentsAPI, { type Agent } from '@/api/agents'
548 +
549 +const router = useRouter()
550 +
551 +const loading = ref(true)
552 +const error = ref('')
553 +const agents = ref<Agent[]>([])
554 +const selectedAgent = ref<Agent | null>(null)
555 +const updatingAgent = ref<string | null>(null)
556 +
557 +// Filters
558 +const filters = ref({
559 + status: '',
560 + critical: '',
561 + os: '',
562 + search: ''
563 +})
564 +
565 +// Pagination
566 +const currentPage = ref(1)
567 +const pageSize = ref(20)
568 +
569 +const username = computed(() => {
570 + try {
571 + const user = JSON.parse(localStorage.getItem('customer-portal-user') || '{}')
572 + return user.username || 'User'
573 + } catch {
574 + return 'User'
575 + }
576 +})
577 +
578 +const uniqueOperatingSystems = computed(() => {
579 + const osSet = new Set(agents.value.map(agent => agent.os))
580 + return Array.from(osSet).sort()
581 +})
582 +
583 +const activeAgents = computed(() => {
584 + return agents.value.filter(agent => agent.wazuh_agent_status === 'active').length
585 +})
586 +
587 +const criticalAgents = computed(() => {
588 + return agents.value.filter(agent => agent.critical_asset).length
589 +})
590 +
591 +const offlineAgents = computed(() => {
592 + return agents.value.filter(agent =>
593 + agent.wazuh_agent_status === 'disconnected' ||
594 + agent.wazuh_agent_status === 'never_connected'
595 + ).length
596 +})
597 +
598 +const filteredAgents = computed(() => {
599 + let filtered = agents.value
600 +
601 + if (filters.value.status) {
602 + filtered = filtered.filter(agent => agent.wazuh_agent_status === filters.value.status)
603 + }
604 +
605 + if (filters.value.critical) {
606 + const isCritical = filters.value.critical === 'true'
607 + filtered = filtered.filter(agent => agent.critical_asset === isCritical)
608 + }
609 +
610 + if (filters.value.os) {
611 + filtered = filtered.filter(agent => agent.os === filters.value.os)
612 + }
613 +
614 + if (filters.value.search) {
615 + const searchTerm = filters.value.search.toLowerCase()
616 + filtered = filtered.filter(agent =>
617 + agent.hostname.toLowerCase().includes(searchTerm) ||
618 + agent.ip_address.toLowerCase().includes(searchTerm) ||
619 + agent.agent_id.toLowerCase().includes(searchTerm)
620 + )
621 + }
622 +
623 + return filtered
624 +})
625 +
626 +const totalPages = computed(() => {
627 + return Math.ceil(filteredAgents.value.length / pageSize.value)
628 +})
629 +
630 +const paginatedAgents = computed(() => {
631 + const start = (currentPage.value - 1) * pageSize.value
632 + const end = start + pageSize.value
633 + return filteredAgents.value.slice(start, end)
634 +})
635 +
636 +const visiblePages = computed(() => {
637 + const total = totalPages.value
638 + const current = currentPage.value
639 + const delta = 2
640 +
641 + const range = []
642 + const rangeWithDots = []
643 +
644 + for (let i = Math.max(2, current - delta); i <= Math.min(total - 1, current + delta); i++) {
645 + range.push(i)
646 + }
647 +
648 + if (current - delta > 2) {
649 + rangeWithDots.push(1, '...')
650 + } else {
651 + rangeWithDots.push(1)
652 + }
653 +
654 + rangeWithDots.push(...range)
655 +
656 + if (current + delta < total - 1) {
657 + rangeWithDots.push('...', total)
658 + } else {
659 + rangeWithDots.push(total)
660 + }
661 +
662 + return rangeWithDots.filter((page, index, arr) => arr.indexOf(page) === index && page !== current - 1 && page !== current + 1).slice(0, 7)
663 +})
664 +
665 +const formatTimeAgo = (dateString: string) => {
666 + if (!dateString) return 'Never'
667 +
668 + try {
669 + const date = new Date(dateString)
670 + const now = new Date()
671 + const diffInMs = now.getTime() - date.getTime()
672 + const diffInMinutes = diffInMs / (1000 * 60)
673 + const diffInHours = diffInMs / (1000 * 60 * 60)
674 + const diffInDays = diffInMs / (1000 * 60 * 60 * 24)
675 +
676 + if (diffInMinutes < 60) {
677 + return `${Math.floor(diffInMinutes)} minutes ago`
678 + } else if (diffInHours < 24) {
679 + return `${Math.floor(diffInHours)} hours ago`
680 + } else if (diffInDays < 30) {
681 + return `${Math.floor(diffInDays)} days ago`
682 + } else {
683 + return date.toLocaleDateString()
684 + }
685 + } catch {
686 + return 'Invalid date'
687 + }
688 +}
689 +
690 +const formatDateTime = (dateString: string) => {
691 + if (!dateString) return 'N/A'
692 +
693 + try {
694 + const date = new Date(dateString)
695 + return date.toLocaleString()
696 + } catch {
697 + return 'Invalid date'
698 + }
699 +}
700 +
701 +const loadAgents = async () => {
702 + loading.value = true
703 + error.value = ''
704 +
705 + try {
706 + const response = await AgentsAPI.getAgents()
707 + agents.value = response.agents || []
708 + } catch (err: any) {
709 + console.error('Failed to load agents:', err)
710 + error.value = err.response?.data?.detail || err.message || 'Failed to load agents'
711 + agents.value = []
712 + } finally {
713 + loading.value = false
714 + }
715 +}
716 +
717 +const markAsCritical = async (agent: Agent) => {
718 + updatingAgent.value = agent.agent_id
719 + try {
720 + await AgentsAPI.markAgentAsCritical(agent.agent_id)
721 + agent.critical_asset = true
722 + } catch (err: any) {
723 + console.error('Failed to mark agent as critical:', err)
724 + error.value = err.response?.data?.detail || err.message || 'Failed to update agent'
725 + } finally {
726 + updatingAgent.value = null
727 + }
728 +}
729 +
730 +const markAsNotCritical = async (agent: Agent) => {
731 + updatingAgent.value = agent.agent_id
732 + try {
733 + await AgentsAPI.markAgentAsNotCritical(agent.agent_id)
734 + agent.critical_asset = false
735 + } catch (err: any) {
736 + console.error('Failed to mark agent as not critical:', err)
737 + error.value = err.response?.data?.detail || err.message || 'Failed to update agent'
738 + } finally {
739 + updatingAgent.value = null
740 + }
741 +}
742 +
743 +const viewAgentDetails = (agent: Agent) => {
744 + selectedAgent.value = agent
745 +}
746 +
747 +const closeAgentDetails = () => {
748 + selectedAgent.value = null
749 +}
750 +
751 +const clearFilters = () => {
752 + filters.value = {
753 + status: '',
754 + critical: '',
755 + os: '',
756 + search: ''
757 + }
758 + currentPage.value = 1
759 +}
760 +
761 +const nextPage = () => {
762 + if (currentPage.value < totalPages.value) {
763 + currentPage.value++
764 + }
765 +}
766 +
767 +const previousPage = () => {
768 + if (currentPage.value > 1) {
769 + currentPage.value--
770 + }
771 +}
772 +
773 +const logout = () => {
774 + localStorage.removeItem('customer-portal-auth-token')
775 + localStorage.removeItem('customer-portal-user')
776 + router.push('/login')
777 +}
778 +
779 +onMounted(() => {
780 + loadAgents()
781 +})
782 +</script>
customer_portal/src/views/AlertsPage.vue new
+737
@@ -0,0 +1,737 @@
1 +<template>
2 + <div class="min-h-screen bg-gray-50">
3 + <!-- Header -->
4 + <header class="bg-white shadow-sm border-b">
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 + <img
9 + class="h-8 w-auto mr-3"
10 + src="/logo.svg"
11 + alt="SOCFortress Logo"
12 + />
13 + <h1 class="text-xl font-semibold text-gray-900">Customer Portal</h1>
14 + <nav class="ml-8 flex space-x-8">
15 + <router-link
16 + to="/"
17 + class="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
18 + >
19 + Overview
20 + </router-link>
21 + <router-link
22 + to="/alerts"
23 + class="text-indigo-600 border-b-2 border-indigo-600 px-3 py-2 rounded-md text-sm font-medium"
24 + >
25 + Alerts
26 + </router-link>
27 + <router-link
28 + to="/cases"
29 + class="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
30 + >
31 + Cases
32 + </router-link>
33 + <router-link
34 + to="/agents"
35 + class="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
36 + >
37 + Agents
38 + </router-link>
39 + </nav>
40 + </div>
41 + <div class="flex items-center space-x-4">
42 + <button
43 + @click="refreshAlerts"
44 + :disabled="loading"
45 + class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md 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"
46 + >
47 + <svg class="w-4 h-4 mr-2" :class="{ 'animate-spin': loading }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
48 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
49 + </svg>
50 + Refresh
51 + </button>
52 + </div>
53 + </div>
54 + </div>
55 + </header>
56 +
57 + <!-- Stats Cards -->
58 + <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
59 + <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
60 + <div class="bg-white overflow-hidden shadow rounded-lg">
61 + <div class="p-5">
62 + <div class="flex items-center">
63 + <div class="flex-shrink-0">
64 + <div class="w-8 h-8 bg-blue-500 rounded-md flex items-center justify-center">
65 + <svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
66 + <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
67 + </svg>
68 + </div>
69 + </div>
70 + <div class="ml-5 w-0 flex-1">
71 + <dl>
72 + <dt class="text-sm font-medium text-gray-500 truncate">Total Alerts</dt>
73 + <dd class="text-lg font-medium text-gray-900">{{ stats.total }}</dd>
74 + </dl>
75 + </div>
76 + </div>
77 + </div>
78 + </div>
79 +
80 + <div class="bg-white overflow-hidden shadow rounded-lg">
81 + <div class="p-5">
82 + <div class="flex items-center">
83 + <div class="flex-shrink-0">
84 + <div class="w-8 h-8 bg-red-500 rounded-md flex items-center justify-center">
85 + <svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
86 + <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
87 + </svg>
88 + </div>
89 + </div>
90 + <div class="ml-5 w-0 flex-1">
91 + <dl>
92 + <dt class="text-sm font-medium text-gray-500 truncate">Open</dt>
93 + <dd class="text-lg font-medium text-gray-900">{{ stats.open }}</dd>
94 + </dl>
95 + </div>
96 + </div>
97 + </div>
98 + </div>
99 +
100 + <div class="bg-white overflow-hidden shadow rounded-lg">
101 + <div class="p-5">
102 + <div class="flex items-center">
103 + <div class="flex-shrink-0">
104 + <div class="w-8 h-8 bg-yellow-500 rounded-md flex items-center justify-center">
105 + <svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
106 + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"></path>
107 + </svg>
108 + </div>
109 + </div>
110 + <div class="ml-5 w-0 flex-1">
111 + <dl>
112 + <dt class="text-sm font-medium text-gray-500 truncate">In Progress</dt>
113 + <dd class="text-lg font-medium text-gray-900">{{ stats.in_progress }}</dd>
114 + </dl>
115 + </div>
116 + </div>
117 + </div>
118 + </div>
119 +
120 + <div class="bg-white overflow-hidden shadow rounded-lg">
121 + <div class="p-5">
122 + <div class="flex items-center">
123 + <div class="flex-shrink-0">
124 + <div class="w-8 h-8 bg-green-500 rounded-md flex items-center justify-center">
125 + <svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
126 + <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
127 + </svg>
128 + </div>
129 + </div>
130 + <div class="ml-5 w-0 flex-1">
131 + <dl>
132 + <dt class="text-sm font-medium text-gray-500 truncate">Closed</dt>
133 + <dd class="text-lg font-medium text-gray-900">{{ stats.closed }}</dd>
134 + </dl>
135 + </div>
136 + </div>
137 + </div>
138 + </div>
139 + </div>
140 +
141 + <!-- Filters -->
142 + <div class="bg-white shadow rounded-lg mb-6">
143 + <div class="px-4 py-5 sm:p-6">
144 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
145 + <div>
146 + <label for="status-filter" class="block text-sm font-medium text-gray-700">Status</label>
147 + <select
148 + id="status-filter"
149 + v-model="filters.status"
150 + @change="applyFilters"
151 + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
152 + >
153 + <option value="">All Statuses</option>
154 + <option value="open">Open</option>
155 + <option value="in_progress">In Progress</option>
156 + <option value="closed">Closed</option>
157 + </select>
158 + </div>
159 + <div>
160 + <label for="source-filter" class="block text-sm font-medium text-gray-700">Source</label>
161 + <select
162 + id="source-filter"
163 + v-model="filters.source"
164 + @change="applyFilters"
165 + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
166 + >
167 + <option value="">All Sources</option>
168 + <option v-for="source in availableSources" :key="source" :value="source">{{ source }}</option>
169 + </select>
170 + </div>
171 + <div>
172 + <label for="asset-filter" class="block text-sm font-medium text-gray-700">Asset</label>
173 + <select
174 + id="asset-filter"
175 + v-model="filters.asset"
176 + @change="applyFilters"
177 + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
178 + >
179 + <option value="">All Assets</option>
180 + <option v-for="asset in availableAssets" :key="asset" :value="asset">{{ asset }}</option>
181 + </select>
182 + </div>
183 + </div>
184 + </div>
185 + </div>
186 +
187 + <!-- Alerts List -->
188 + <div class="bg-white shadow overflow-hidden sm:rounded-md">
189 + <div v-if="loading" class="px-4 py-5 sm:p-6 text-center">
190 + <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div>
191 + <p class="mt-2 text-sm text-gray-500">Loading alerts...</p>
192 + </div>
193 +
194 + <div v-else-if="error" class="px-4 py-5 sm:p-6 text-center">
195 + <div class="text-red-500 mb-2">
196 + <svg class="w-8 h-8 mx-auto" fill="currentColor" viewBox="0 0 20 20">
197 + <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
198 + </svg>
199 + </div>
200 + <p class="text-sm text-red-600">{{ error }}</p>
201 + <button
202 + @click="loadAlerts"
203 + class="mt-2 inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
204 + >
205 + Try Again
206 + </button>
207 + </div>
208 +
209 + <ul v-else-if="alerts.length > 0" role="list" class="divide-y divide-gray-200">
210 + <li v-for="alert in alerts" :key="alert.id" class="px-4 py-4 sm:px-6 hover:bg-gray-50">
211 + <div class="flex items-center justify-between">
212 + <div class="flex items-center">
213 + <div class="flex-shrink-0">
214 + <span
215 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
216 + :class="{
217 + 'bg-red-100 text-red-800': alert.status === 'OPEN',
218 + 'bg-yellow-100 text-yellow-800': alert.status === 'IN_PROGRESS',
219 + 'bg-green-100 text-green-800': alert.status === 'CLOSED'
220 + }"
221 + >
222 + {{ alert.status.replace('_', ' ').toUpperCase() }}
223 + </span>
224 + </div>
225 + <div class="ml-4">
226 + <div class="text-sm font-medium text-gray-900">
227 + {{ alert.alert_name }}
228 + </div>
229 + <div class="text-sm text-gray-500">
230 + <span v-if="alert.assets.length > 0">
231 + Asset: {{ alert.assets[0].asset_name }}
232 + </span>
233 + <span v-else-if="alert.asset_name">
234 + Asset: {{ alert.asset_name }}
235 + </span>
236 + | Source: {{ alert.source }}
237 + </div>
238 + <div class="text-xs text-gray-400">
239 + {{ formatDate(alert.alert_creation_time) }}
240 + </div>
241 + </div>
242 + </div>
243 + <div class="flex items-center space-x-2">
244 + <select
245 + :value="alert.status"
246 + @change="updateAlertStatus(alert.id, ($event.target as HTMLSelectElement).value)"
247 + class="text-sm border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
248 + :disabled="updatingStatus === alert.id"
249 + >
250 + <option value="OPEN">Open</option>
251 + <option value="IN_PROGRESS">In Progress</option>
252 + <option value="CLOSED">Closed</option>
253 + </select>
254 + <button
255 + @click="viewAlert(alert)"
256 + class="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
257 + >
258 + View Details
259 + </button>
260 + </div>
261 + </div>
262 + <div v-if="alert.alert_description" class="mt-2 text-sm text-gray-600">
263 + {{ alert.alert_description }}
264 + </div>
265 + </li>
266 + </ul>
267 +
268 + <div v-else class="px-4 py-5 sm:p-6 text-center">
269 + <svg class="w-12 h-12 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
270 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
271 + </svg>
272 + <h3 class="mt-2 text-sm font-medium text-gray-900">No alerts found</h3>
273 + <p class="mt-1 text-sm text-gray-500">No security alerts match your current filters.</p>
274 + </div>
275 + </div>
276 +
277 + <!-- Pagination -->
278 + <div v-if="alerts.length > 0" class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6 mt-6">
279 + <div class="flex-1 flex justify-between sm:hidden">
280 + <button
281 + @click="previousPage"
282 + :disabled="currentPage <= 1"
283 + class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
284 + >
285 + Previous
286 + </button>
287 + <button
288 + @click="nextPage"
289 + :disabled="currentPage >= totalPages"
290 + class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
291 + >
292 + Next
293 + </button>
294 + </div>
295 + <div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
296 + <div>
297 + <p class="text-sm text-gray-700">
298 + Showing <span class="font-medium">{{ (currentPage - 1) * pageSize + 1 }}</span>
299 + to <span class="font-medium">{{ Math.min(currentPage * pageSize, stats.total) }}</span>
300 + of <span class="font-medium">{{ stats.total }}</span> results
301 + </p>
302 + </div>
303 + <div>
304 + <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
305 + <button
306 + @click="previousPage"
307 + :disabled="currentPage <= 1"
308 + class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
309 + >
310 + Previous
311 + </button>
312 + <button
313 + @click="nextPage"
314 + :disabled="currentPage >= totalPages"
315 + class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
316 + >
317 + Next
318 + </button>
319 + </nav>
320 + </div>
321 + </div>
322 + </div>
323 + </div>
324 +
325 + <!-- Alert Details Modal -->
326 + <div v-if="selectedAlert" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50" @click="closeModal">
327 + <div class="relative top-10 mx-auto p-5 border w-11/12 md:w-4/5 lg:w-3/4 shadow-lg rounded-md bg-white max-h-screen overflow-y-auto" @click.stop>
328 + <div class="flex justify-between items-center mb-4">
329 + <h3 class="text-lg font-medium text-gray-900">Alert Details</h3>
330 + <button @click="closeModal" class="text-gray-400 hover:text-gray-600">
331 + <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
332 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
333 + </svg>
334 + </button>
335 + </div>
336 +
337 + <div class="space-y-6">
338 + <!-- Basic Alert Information -->
339 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
340 + <div>
341 + <label class="block text-sm font-medium text-gray-700">Alert Name</label>
342 + <p class="mt-1 text-sm text-gray-900">{{ selectedAlert.alert_name }}</p>
343 + </div>
344 + <div>
345 + <label class="block text-sm font-medium text-gray-700">Status</label>
346 + <span
347 + class="mt-1 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
348 + :class="{
349 + 'bg-red-100 text-red-800': selectedAlert.status === 'OPEN',
350 + 'bg-yellow-100 text-yellow-800': selectedAlert.status === 'IN_PROGRESS',
351 + 'bg-green-100 text-green-800': selectedAlert.status === 'CLOSED'
352 + }"
353 + >
354 + {{ selectedAlert.status.replace('_', ' ').toUpperCase() }}
355 + </span>
356 + </div>
357 + <div>
358 + <label class="block text-sm font-medium text-gray-700">Source</label>
359 + <p class="mt-1 text-sm text-gray-900">{{ selectedAlert.source }}</p>
360 + </div>
361 + <div>
362 + <label class="block text-sm font-medium text-gray-700">Customer</label>
363 + <p class="mt-1 text-sm text-gray-900">{{ selectedAlert.customer_code }}</p>
364 + </div>
365 + <div>
366 + <label class="block text-sm font-medium text-gray-700">Created</label>
367 + <p class="mt-1 text-sm text-gray-900">{{ formatDate(selectedAlert.alert_creation_time) }}</p>
368 + </div>
369 + <div v-if="selectedAlert.assigned_to">
370 + <label class="block text-sm font-medium text-gray-700">Assigned To</label>
371 + <p class="mt-1 text-sm text-gray-900">{{ selectedAlert.assigned_to }}</p>
372 + </div>
373 + </div>
374 +
375 + <div v-if="selectedAlert.alert_description">
376 + <label class="block text-sm font-medium text-gray-700">Description</label>
377 + <p class="mt-1 text-sm text-gray-900 whitespace-pre-wrap">{{ selectedAlert.alert_description }}</p>
378 + </div>
379 +
380 + <!-- Assets Section -->
381 + <div v-if="selectedAlert.assets && selectedAlert.assets.length > 0">
382 + <label class="block text-sm font-medium text-gray-700 mb-2">Assets</label>
383 + <div class="bg-gray-50 rounded-lg p-4">
384 + <div v-for="asset in selectedAlert.assets" :key="asset.id" class="border-b border-gray-200 pb-3 mb-3 last:border-b-0 last:mb-0">
385 + <div class="grid grid-cols-1 md:grid-cols-3 gap-2 text-sm">
386 + <div>
387 + <span class="font-medium">Asset Name:</span> {{ asset.asset_name }}
388 + </div>
389 + <div>
390 + <span class="font-medium">Agent ID:</span> {{ asset.agent_id }}
391 + </div>
392 + <div v-if="asset.velociraptor_id">
393 + <span class="font-medium">Velociraptor ID:</span> {{ asset.velociraptor_id }}
394 + </div>
395 + <div>
396 + <span class="font-medium">Index:</span> {{ asset.index_name }}
397 + </div>
398 + <div>
399 + <span class="font-medium">Index ID:</span> {{ asset.index_id.substring(0, 20) }}...
400 + </div>
401 + </div>
402 + </div>
403 + </div>
404 + </div>
405 +
406 + <!-- Fallback for legacy asset_name -->
407 + <div v-else-if="selectedAlert.asset_name">
408 + <label class="block text-sm font-medium text-gray-700">Asset</label>
409 + <p class="mt-1 text-sm text-gray-900">{{ selectedAlert.asset_name }}</p>
410 + </div>
411 +
412 + <!-- Tags Section -->
413 + <div v-if="selectedAlert.tags && selectedAlert.tags.length > 0">
414 + <label class="block text-sm font-medium text-gray-700">Tags</label>
415 + <div class="mt-1 flex flex-wrap gap-2">
416 + <span
417 + v-for="tag in selectedAlert.tags"
418 + :key="tag.id"
419 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
420 + >
421 + {{ tag.tag }}
422 + </span>
423 + </div>
424 + </div>
425 +
426 + <!-- Legacy Tags (for backward compatibility) -->
427 + <div v-else-if="selectedAlert.tag && selectedAlert.tag.length > 0">
428 + <label class="block text-sm font-medium text-gray-700">Tags</label>
429 + <div class="mt-1 flex flex-wrap gap-2">
430 + <span
431 + v-for="tag in selectedAlert.tag"
432 + :key="tag"
433 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
434 + >
435 + {{ tag }}
436 + </span>
437 + </div>
438 + </div>
439 +
440 + <!-- Linked Cases Section -->
441 + <div v-if="selectedAlert.linked_cases && selectedAlert.linked_cases.length > 0">
442 + <label class="block text-sm font-medium text-gray-700 mb-2">Linked Cases</label>
443 + <div class="bg-gray-50 rounded-lg p-4">
444 + <div v-for="linkedCase in selectedAlert.linked_cases" :key="linkedCase.id" class="border-b border-gray-200 pb-3 mb-3 last:border-b-0 last:mb-0">
445 + <div class="flex justify-between items-start">
446 + <div class="flex-1">
447 + <h4 class="text-sm font-medium text-gray-900">{{ linkedCase.case_name }}</h4>
448 + <p class="text-xs text-gray-600 mt-1">{{ linkedCase.case_description }}</p>
449 + <div class="flex items-center space-x-4 mt-2 text-xs text-gray-500">
450 + <span>Case #{{ linkedCase.id }}</span>
451 + <span>Created: {{ formatDate(linkedCase.case_creation_time) }}</span>
452 + <span v-if="linkedCase.assigned_to">Assigned to: {{ linkedCase.assigned_to }}</span>
453 + </div>
454 + </div>
455 + <span
456 + class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
457 + :class="{
458 + 'bg-red-100 text-red-800': linkedCase.case_status === 'OPEN',
459 + 'bg-yellow-100 text-yellow-800': linkedCase.case_status === 'IN_PROGRESS',
460 + 'bg-green-100 text-green-800': linkedCase.case_status === 'CLOSED'
461 + }"
462 + >
463 + {{ linkedCase.case_status.replace('_', ' ').toUpperCase() }}
464 + </span>
465 + </div>
466 + </div>
467 + </div>
468 + </div>
469 +
470 + <!-- Legacy Case IDs (for backward compatibility) -->
471 + <div v-else-if="selectedAlert.case_ids && selectedAlert.case_ids.length > 0">
472 + <label class="block text-sm font-medium text-gray-700">Linked Cases</label>
473 + <div class="mt-1 flex flex-wrap gap-2">
474 + <span
475 + v-for="caseId in selectedAlert.case_ids"
476 + :key="caseId"
477 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
478 + >
479 + Case #{{ caseId }}
480 + </span>
481 + </div>
482 + </div>
483 +
484 + <!-- IoCs Section -->
485 + <div v-if="selectedAlert.iocs && selectedAlert.iocs.length > 0">
486 + <label class="block text-sm font-medium text-gray-700 mb-2">Indicators of Compromise (IoCs)</label>
487 + <div class="bg-gray-50 rounded-lg p-4">
488 + <div v-for="ioc in selectedAlert.iocs" :key="ioc.id" class="border-b border-gray-200 pb-3 mb-3 last:border-b-0 last:mb-0">
489 + <div class="grid grid-cols-1 md:grid-cols-3 gap-2 text-sm">
490 + <div>
491 + <span class="font-medium">Value:</span>
492 + <code class="bg-gray-100 px-1 rounded text-xs">{{ ioc.ioc_value }}</code>
493 + </div>
494 + <div>
495 + <span class="font-medium">Type:</span> {{ ioc.ioc_type }}
496 + </div>
497 + <div>
498 + <span class="font-medium">Description:</span> {{ ioc.ioc_description }}
499 + </div>
500 + </div>
501 + </div>
502 + </div>
503 + </div>
504 +
505 + <!-- Comments Section -->
506 + <div>
507 + <label class="block text-sm font-medium text-gray-700 mb-2">
508 + Comments
509 + <span v-if="selectedAlert.comments && selectedAlert.comments.length > 0" class="text-gray-500 font-normal">
510 + ({{ selectedAlert.comments.length }})
511 + </span>
512 + </label>
513 +
514 + <!-- Existing Comments -->
515 + <div v-if="selectedAlert.comments && selectedAlert.comments.length > 0" class="bg-gray-50 rounded-lg p-4 max-h-64 overflow-y-auto mb-4">
516 + <div v-for="comment in selectedAlert.comments" :key="comment.id" class="border-b border-gray-200 pb-3 mb-3 last:border-b-0 last:mb-0">
517 + <div class="flex justify-between items-start mb-2">
518 + <span class="text-sm font-medium text-gray-900">{{ comment.user_name }}</span>
519 + <span class="text-xs text-gray-500">{{ formatDate(comment.created_at) }}</span>
520 + </div>
521 + <p class="text-sm text-gray-700 whitespace-pre-wrap">{{ comment.comment }}</p>
522 + </div>
523 + </div>
524 +
525 + <!-- No Comments Message -->
526 + <div v-else class="bg-gray-50 rounded-lg p-4 mb-4 text-center">
527 + <p class="text-sm text-gray-500">No comments yet</p>
528 + </div>
529 +
530 + <!-- Add Comment Form -->
531 + <div class="border rounded-lg p-4 bg-white">
532 + <label class="block text-sm font-medium text-gray-700 mb-2">Add Comment</label>
533 + <textarea
534 + v-model="newComment"
535 + placeholder="Enter your comment..."
536 + rows="3"
537 + class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
538 + ></textarea>
539 + <div class="flex justify-end mt-3">
540 + <button
541 + @click="addComment"
542 + :disabled="!newComment.trim() || isAddingComment"
543 + 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"
544 + >
545 + <svg v-if="isAddingComment" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
546 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
547 + <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>
548 + </svg>
549 + {{ isAddingComment ? 'Adding...' : 'Add Comment' }}
550 + </button>
551 + </div>
552 + </div>
553 + </div>
554 + </div>
555 + </div>
556 + </div>
557 + </div>
558 +</template>
559 +
560 +<script setup lang="ts">
561 +import { ref, onMounted, computed } from 'vue'
562 +import { useRouter } from 'vue-router'
563 +import AlertsAPI, { type Alert, type AlertsResponse } from '@/api/alerts'
564 +
565 +const router = useRouter()
566 +
567 +// Reactive data
568 +const alerts = ref<Alert[]>([])
569 +const stats = ref({
570 + total: 0,
571 + open: 0,
572 + in_progress: 0,
573 + closed: 0
574 +})
575 +const loading = ref(false)
576 +const error = ref<string | null>(null)
577 +const selectedAlert = ref<Alert | null>(null)
578 +const updatingStatus = ref<number | null>(null)
579 +
580 +// Comment management
581 +const newComment = ref('')
582 +const isAddingComment = ref(false)
583 +
584 +// Pagination
585 +const currentPage = ref(1)
586 +const pageSize = ref(25)
587 +
588 +// Filters
589 +const filters = ref({
590 + status: '',
591 + source: '',
592 + asset: ''
593 +})
594 +
595 +// Computed properties
596 +const totalPages = computed(() => Math.ceil(stats.value.total / pageSize.value))
597 +
598 +const availableSources = computed(() => {
599 + const sources = new Set(alerts.value.map(alert => alert.source))
600 + return Array.from(sources).sort()
601 +})
602 +
603 +const availableAssets = computed(() => {
604 + const assets = new Set(alerts.value.map(alert => alert.asset_name))
605 + return Array.from(assets).sort()
606 +})
607 +
608 +// Methods
609 +const goBack = () => {
610 + router.push('/')
611 +}
612 +
613 +const loadAlerts = async () => {
614 + loading.value = true
615 + error.value = null
616 +
617 + try {
618 + let response: AlertsResponse
619 +
620 + if (filters.value.status) {
621 + response = await AlertsAPI.getAlertsByStatus(filters.value.status as any)
622 + } else if (filters.value.source) {
623 + response = await AlertsAPI.getAlertsBySource(filters.value.source)
624 + } else if (filters.value.asset) {
625 + response = await AlertsAPI.getAlertsByAsset(filters.value.asset)
626 + } else {
627 + response = await AlertsAPI.getAlerts(currentPage.value, pageSize.value)
628 + }
629 +
630 + alerts.value = response.alerts
631 + stats.value = {
632 + total: response.total,
633 + open: response.open,
634 + in_progress: response.in_progress,
635 + closed: response.closed
636 + }
637 + } catch (err: any) {
638 + error.value = err.response?.data?.detail || err.message || 'Failed to load alerts'
639 + console.error('Error loading alerts:', err)
640 + } finally {
641 + loading.value = false
642 + }
643 +}
644 +
645 +const refreshAlerts = () => {
646 + loadAlerts()
647 +}
648 +
649 +const applyFilters = () => {
650 + currentPage.value = 1
651 + loadAlerts()
652 +}
653 +
654 +const updateAlertStatus = async (alertId: number, newStatus: string) => {
655 + updatingStatus.value = alertId
656 +
657 + try {
658 + await AlertsAPI.updateAlertStatus(alertId, newStatus as any)
659 +
660 + // Update the local alert status
661 + const alert = alerts.value.find(a => a.id === alertId)
662 + if (alert) {
663 + alert.status = newStatus as any
664 + }
665 +
666 + // Refresh stats
667 + await loadAlerts()
668 + } catch (err: any) {
669 + error.value = err.response?.data?.detail || err.message || 'Failed to update alert status'
670 + console.error('Error updating alert status:', err)
671 + } finally {
672 + updatingStatus.value = null
673 + }
674 +}
675 +
676 +const viewAlert = (alert: Alert) => {
677 + selectedAlert.value = alert
678 +}
679 +
680 +const closeModal = () => {
681 + selectedAlert.value = null
682 + newComment.value = '' // Clear comment when closing modal
683 +}
684 +
685 +const addComment = async () => {
686 + if (!selectedAlert.value || !newComment.value.trim()) return
687 +
688 + isAddingComment.value = true
689 + try {
690 + // Make API call to add comment
691 + const response = await AlertsAPI.addComment({
692 + alert_id: selectedAlert.value.id,
693 + comment: newComment.value.trim(),
694 + user_name: 'Customer User' // This should come from auth context later
695 + })
696 +
697 + // Add the new comment to the local array
698 + if (!selectedAlert.value.comments) {
699 + selectedAlert.value.comments = []
700 + }
701 + selectedAlert.value.comments.push(response.comment)
702 +
703 + // Clear the input
704 + newComment.value = ''
705 +
706 + } catch (err) {
707 + console.error('Failed to add comment:', err)
708 + // Handle error - maybe show a toast notification
709 + error.value = 'Failed to add comment. Please try again.'
710 + } finally {
711 + isAddingComment.value = false
712 + }
713 +}
714 +
715 +const formatDate = (dateString: string) => {
716 + return new Date(dateString).toLocaleString()
717 +}
718 +
719 +const previousPage = () => {
720 + if (currentPage.value > 1) {
721 + currentPage.value--
722 + loadAlerts()
723 + }
724 +}
725 +
726 +const nextPage = () => {
727 + if (currentPage.value < totalPages.value) {
728 + currentPage.value++
729 + loadAlerts()
730 + }
731 +}
732 +
733 +// Lifecycle
734 +onMounted(() => {
735 + loadAlerts()
736 +})
737 +</script>
customer_portal/src/views/AlertsView.vue new
+260
@@ -0,0 +1,260 @@
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="/"
10 + class="text-indigo-600 hover:text-indigo-500 mr-4"
11 + >
12 + ← Back to Dashboard
13 + </router-link>
14 + <h1 class="text-xl font-semibold">Security Alerts</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 alerts...
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 alerts
45 + </h3>
46 + <div class="mt-2 text-sm text-red-700">
47 + {{ error }}
48 + </div>
49 + </div>
50 + </div>
51 + </div>
52 +
53 + <!-- Alerts List -->
54 + <div v-else>
55 + <!-- Stats Cards -->
56 + <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
57 + <div class="bg-white overflow-hidden shadow rounded-lg">
58 + <div class="p-5">
59 + <div class="flex items-center">
60 + <div class="w-8 h-8 bg-red-500 rounded-md flex items-center justify-center">
61 + <span class="text-white text-sm font-medium">H</span>
62 + </div>
63 + <div class="ml-3">
64 + <p class="text-sm font-medium text-gray-500">High</p>
65 + <p class="text-lg font-semibold text-gray-900">{{ getAlertCount('high') }}</p>
66 + </div>
67 + </div>
68 + </div>
69 + </div>
70 + <div class="bg-white overflow-hidden shadow rounded-lg">
71 + <div class="p-5">
72 + <div class="flex items-center">
73 + <div class="w-8 h-8 bg-yellow-500 rounded-md flex items-center justify-center">
74 + <span class="text-white text-sm font-medium">M</span>
75 + </div>
76 + <div class="ml-3">
77 + <p class="text-sm font-medium text-gray-500">Medium</p>
78 + <p class="text-lg font-semibold text-gray-900">{{ getAlertCount('medium') }}</p>
79 + </div>
80 + </div>
81 + </div>
82 + </div>
83 + <div class="bg-white overflow-hidden shadow rounded-lg">
84 + <div class="p-5">
85 + <div class="flex items-center">
86 + <div class="w-8 h-8 bg-blue-500 rounded-md flex items-center justify-center">
87 + <span class="text-white text-sm font-medium">L</span>
88 + </div>
89 + <div class="ml-3">
90 + <p class="text-sm font-medium text-gray-500">Low</p>
91 + <p class="text-lg font-semibold text-gray-900">{{ getAlertCount('low') }}</p>
92 + </div>
93 + </div>
94 + </div>
95 + </div>
96 + <div class="bg-white overflow-hidden shadow rounded-lg">
97 + <div class="p-5">
98 + <div class="flex items-center">
99 + <div class="w-8 h-8 bg-gray-500 rounded-md flex items-center justify-center">
100 + <span class="text-white text-sm font-medium">T</span>
101 + </div>
102 + <div class="ml-3">
103 + <p class="text-sm font-medium text-gray-500">Total</p>
104 + <p class="text-lg font-semibold text-gray-900">{{ alerts.length }}</p>
105 + </div>
106 + </div>
107 + </div>
108 + </div>
109 + </div>
110 +
111 + <!-- Alerts Table -->
112 + <div class="bg-white shadow overflow-hidden sm:rounded-md">
113 + <div class="px-4 py-5 sm:px-6">
114 + <h3 class="text-lg leading-6 font-medium text-gray-900">
115 + Recent Alerts
116 + </h3>
117 + <p class="mt-1 max-w-2xl text-sm text-gray-500">
118 + Security alerts for your organization
119 + </p>
120 + </div>
121 +
122 + <div v-if="alerts.length === 0" class="px-4 py-5 sm:px-6 text-center text-gray-500">
123 + No alerts found
124 + </div>
125 +
126 + <ul v-else class="divide-y divide-gray-200">
127 + <li v-for="alert in alerts" :key="alert.id" class="px-4 py-4 sm:px-6">
128 + <div class="flex items-center justify-between">
129 + <div class="flex items-center">
130 + <div
131 + class="w-3 h-3 rounded-full mr-3"
132 + :class="{
133 + 'bg-red-500': alert.alert_severity === 'high',
134 + 'bg-yellow-500': alert.alert_severity === 'medium',
135 + 'bg-blue-500': alert.alert_severity === 'low',
136 + 'bg-gray-500': !alert.alert_severity
137 + }"
138 + ></div>
139 + <div>
140 + <p class="text-sm font-medium text-gray-900">
141 + {{ alert.alert_name || 'Unnamed Alert' }}
142 + </p>
143 + <p class="text-sm text-gray-500">
144 + {{ alert.alert_description || 'No description available' }}
145 + </p>
146 + <p class="text-xs text-gray-400 mt-1">
147 + Created: {{ formatDate(alert.alert_creation_time) }}
148 + </p>
149 + </div>
150 + </div>
151 + <div class="flex items-center space-x-2">
152 + <span
153 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
154 + :class="{
155 + 'bg-red-100 text-red-800': alert.alert_severity === 'high',
156 + 'bg-yellow-100 text-yellow-800': alert.alert_severity === 'medium',
157 + 'bg-blue-100 text-blue-800': alert.alert_severity === 'low',
158 + 'bg-gray-100 text-gray-800': !alert.alert_severity
159 + }"
160 + >
161 + {{ alert.alert_severity || 'Unknown' }}
162 + </span>
163 + <span
164 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
165 + :class="{
166 + 'bg-green-100 text-green-800': alert.alert_status === 'resolved',
167 + 'bg-red-100 text-red-800': alert.alert_status === 'open',
168 + 'bg-yellow-100 text-yellow-800': alert.alert_status === 'in_progress',
169 + 'bg-gray-100 text-gray-800': !alert.alert_status
170 + }"
171 + >
172 + {{ alert.alert_status || 'Unknown' }}
173 + </span>
174 + </div>
175 + </div>
176 + </li>
177 + </ul>
178 + </div>
179 +
180 + <!-- Pagination (if needed) -->
181 + <div v-if="alerts.length > 0" class="mt-6 flex justify-center">
182 + <button
183 + @click="refreshAlerts"
184 + class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm font-medium"
185 + >
186 + Refresh
187 + </button>
188 + </div>
189 + </div>
190 + </div>
191 + </main>
192 + </div>
193 +</template>
194 +
195 +<script setup lang="ts">
196 +import { ref, onMounted, computed } from 'vue'
197 +import { useRouter } from 'vue-router'
198 +import { useAuthStore } from '@/stores/auth'
199 +import { httpClient } from '@/utils/httpClient'
200 +
201 +interface Alert {
202 + id: number
203 + alert_name: string
204 + alert_description: string
205 + alert_severity: string
206 + alert_status: string
207 + alert_creation_time: string
208 + customer_code?: string
209 +}
210 +
211 +const router = useRouter()
212 +const authStore = useAuthStore()
213 +
214 +const alerts = ref<Alert[]>([])
215 +const loading = ref(false)
216 +const error = ref('')
217 +
218 +const user = computed(() => authStore.user)
219 +
220 +const getAlertCount = (severity: string) => {
221 + return alerts.value.filter(alert => alert.alert_severity === severity).length
222 +}
223 +
224 +const formatDate = (dateString: string) => {
225 + if (!dateString) return 'Unknown'
226 + try {
227 + return new Date(dateString).toLocaleDateString()
228 + } catch {
229 + return 'Invalid date'
230 + }
231 +}
232 +
233 +const fetchAlerts = async () => {
234 + loading.value = true
235 + error.value = ''
236 +
237 + try {
238 + const response = await httpClient.get('/alerts/')
239 + alerts.value = response.data || []
240 + } catch (err: any) {
241 + error.value = err.response?.data?.detail || 'Failed to fetch alerts'
242 + console.error('Failed to fetch alerts:', err)
243 + } finally {
244 + loading.value = false
245 + }
246 +}
247 +
248 +const refreshAlerts = () => {
249 + fetchAlerts()
250 +}
251 +
252 +const logout = () => {
253 + authStore.logout()
254 + router.push('/login')
255 +}
256 +
257 +onMounted(() => {
258 + fetchAlerts()
259 +})
260 +</script>
customer_portal/src/views/CasesPage.vue new
+926
@@ -0,0 +1,926 @@
1 +<template>
2 + <div class="min-h-screen bg-gray-50">
3 + <!-- Header -->
4 + <header class="bg-white shadow-sm border-b">
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 + <img
9 + class="h-8 w-auto mr-3"
10 + src="/logo.svg"
11 + alt="SOCFortress Logo"
12 + />
13 + <h1 class="text-xl font-semibold text-gray-900">Customer Portal</h1>
14 + <nav class="ml-8 flex space-x-8">
15 + <router-link
16 + to="/"
17 + class="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
18 + >
19 + Overview
20 + </router-link>
21 + <router-link
22 + to="/alerts"
23 + class="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
24 + >
25 + Alerts
26 + </router-link>
27 + <router-link
28 + to="/cases"
29 + class="text-indigo-600 border-b-2 border-indigo-600 px-3 py-2 rounded-md text-sm font-medium"
30 + >
31 + Cases
32 + </router-link>
33 + <router-link
34 + to="/agents"
35 + class="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
36 + >
37 + Agents
38 + </router-link>
39 + </nav>
40 + </div>
41 + <div class="flex items-center space-x-4">
42 + <button
43 + @click="refreshCases"
44 + :disabled="loading"
45 + class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md 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"
46 + >
47 + <svg class="w-4 h-4 mr-2" :class="{ 'animate-spin': loading }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
48 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
49 + </svg>
50 + Refresh
51 + </button>
52 + </div>
53 + </div>
54 + </div>
55 + </header>
56 +
57 + <!-- Stats Cards -->
58 + <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
59 + <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
60 + <div class="bg-white overflow-hidden shadow rounded-lg">
61 + <div class="p-5">
62 + <div class="flex items-center">
63 + <div class="flex-shrink-0">
64 + <div class="w-8 h-8 bg-blue-500 rounded-md flex items-center justify-center">
65 + <svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
66 + <path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"></path>
67 + <path fill-rule="evenodd" d="M4 5a2 2 0 012-2v1a1 1 0 102 0V3a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 2a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z" clip-rule="evenodd"></path>
68 + </svg>
69 + </div>
70 + </div>
71 + <div class="ml-5 w-0 flex-1">
72 + <dl>
73 + <dt class="text-sm font-medium text-gray-500 truncate">Total Cases</dt>
74 + <dd class="text-lg font-medium text-gray-900">{{ cases.length }}</dd>
75 + </dl>
76 + </div>
77 + </div>
78 + </div>
79 + </div>
80 +
81 + <div class="bg-white overflow-hidden shadow rounded-lg">
82 + <div class="p-5">
83 + <div class="flex items-center">
84 + <div class="flex-shrink-0">
85 + <div class="w-8 h-8 bg-red-500 rounded-md flex items-center justify-center">
86 + <svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
87 + <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
88 + </svg>
89 + </div>
90 + </div>
91 + <div class="ml-5 w-0 flex-1">
92 + <dl>
93 + <dt class="text-sm font-medium text-gray-500 truncate">Open</dt>
94 + <dd class="text-lg font-medium text-gray-900">{{ openCases }}</dd>
95 + </dl>
96 + </div>
97 + </div>
98 + </div>
99 + </div>
100 +
101 + <div class="bg-white overflow-hidden shadow rounded-lg">
102 + <div class="p-5">
103 + <div class="flex items-center">
104 + <div class="flex-shrink-0">
105 + <div class="w-8 h-8 bg-yellow-500 rounded-md flex items-center justify-center">
106 + <svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
107 + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"></path>
108 + </svg>
109 + </div>
110 + </div>
111 + <div class="ml-5 w-0 flex-1">
112 + <dl>
113 + <dt class="text-sm font-medium text-gray-500 truncate">In Progress</dt>
114 + <dd class="text-lg font-medium text-gray-900">{{ inProgressCases }}</dd>
115 + </dl>
116 + </div>
117 + </div>
118 + </div>
119 + </div>
120 +
121 + <div class="bg-white overflow-hidden shadow rounded-lg">
122 + <div class="p-5">
123 + <div class="flex items-center">
124 + <div class="flex-shrink-0">
125 + <div class="w-8 h-8 bg-green-500 rounded-md flex items-center justify-center">
126 + <svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
127 + <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
128 + </svg>
129 + </div>
130 + </div>
131 + <div class="ml-5 w-0 flex-1">
132 + <dl>
133 + <dt class="text-sm font-medium text-gray-500 truncate">Closed</dt>
134 + <dd class="text-lg font-medium text-gray-900">{{ closedCases }}</dd>
135 + </dl>
136 + </div>
137 + </div>
138 + </div>
139 + </div>
140 + </div>
141 +
142 + <!-- Filters -->
143 + <div class="bg-white shadow rounded-lg mb-6">
144 + <div class="px-4 py-5 sm:p-6">
145 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
146 + <div>
147 + <label for="status-filter" class="block text-sm font-medium text-gray-700">Status</label>
148 + <select
149 + id="status-filter"
150 + v-model="filters.status"
151 + @change="applyFilters"
152 + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
153 + >
154 + <option value="">All Statuses</option>
155 + <option value="open">Open</option>
156 + <option value="in_progress">In Progress</option>
157 + <option value="closed">Closed</option>
158 + </select>
159 + </div>
160 + <div>
161 + <label for="assigned-to-filter" class="block text-sm font-medium text-gray-700">Assigned To</label>
162 + <select
163 + id="assigned-to-filter"
164 + v-model="filters.assignedTo"
165 + @change="applyFilters"
166 + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
167 + >
168 + <option value="">All Assignees</option>
169 + <option v-for="assignee in availableAssignees" :key="assignee" :value="assignee">{{ assignee }}</option>
170 + </select>
171 + </div>
172 + </div>
173 + </div>
174 + </div>
175 +
176 + <!-- Cases List -->
177 + <div class="bg-white shadow overflow-hidden sm:rounded-md">
178 + <div v-if="loading" class="px-4 py-5 sm:p-6 text-center">
179 + <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div>
180 + <p class="mt-2 text-sm text-gray-500">Loading cases...</p>
181 + </div>
182 +
183 + <div v-else-if="error" class="px-4 py-5 sm:p-6 text-center">
184 + <div class="text-red-500 mb-2">
185 + <svg class="w-8 h-8 mx-auto" fill="currentColor" viewBox="0 0 20 20">
186 + <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
187 + </svg>
188 + </div>
189 + <p class="text-sm text-red-600">{{ error }}</p>
190 + <button
191 + @click="loadCases"
192 + class="mt-2 inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
193 + >
194 + Try Again
195 + </button>
196 + </div>
197 +
198 + <ul v-else-if="filteredCases.length > 0" role="list" class="divide-y divide-gray-200">
199 + <li v-for="case_ in filteredCases" :key="case_.id" class="px-4 py-4 sm:px-6 hover:bg-gray-50">
200 + <div class="flex items-center justify-between">
201 + <div class="flex items-center">
202 + <div class="flex-shrink-0">
203 + <span
204 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
205 + :class="{
206 + 'bg-red-100 text-red-800': case_.case_status?.toLowerCase() === 'open',
207 + 'bg-yellow-100 text-yellow-800': case_.case_status?.toLowerCase() === 'in_progress',
208 + 'bg-green-100 text-green-800': case_.case_status?.toLowerCase() === 'closed'
209 + }"
210 + >
211 + {{ case_.case_status?.replace('_', ' ').toUpperCase() }}
212 + </span>
213 + </div>
214 + <div class="ml-4">
215 + <div class="text-sm font-medium text-gray-900">
216 + {{ case_.case_name }}
217 + </div>
218 + <div class="text-sm text-gray-500">
219 + Case #{{ case_.id }}
220 + <span v-if="case_.assigned_to"> | Assigned to: {{ case_.assigned_to }}</span>
221 + </div>
222 + <div class="text-xs text-gray-400">
223 + Created: {{ formatDate(case_.case_creation_time) }}
224 + </div>
225 + </div>
226 + </div>
227 + <div class="flex items-center space-x-2">
228 + <select
229 + :value="case_.case_status"
230 + @change="updateCaseStatus(case_.id, ($event.target as HTMLSelectElement).value)"
231 + class="text-sm border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
232 + :disabled="updatingStatus === case_.id"
233 + >
234 + <option value="open">Open</option>
235 + <option value="in_progress">In Progress</option>
236 + <option value="closed">Closed</option>
237 + </select>
238 + <button
239 + @click="viewCase(case_)"
240 + class="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
241 + >
242 + View Details
243 + </button>
244 + </div>
245 + </div>
246 + <div v-if="case_.case_description" class="mt-2 text-sm text-gray-600">
247 + {{ case_.case_description }}
248 + </div>
249 + <div v-if="case_.alert_ids && case_.alert_ids.length > 0" class="mt-2">
250 + <span class="text-xs text-gray-500">Linked Alerts: {{ case_.alert_ids.length }}</span>
251 + </div>
252 + </li>
253 + </ul>
254 +
255 + <div v-else class="px-4 py-5 sm:p-6 text-center">
256 + <svg class="w-12 h-12 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
257 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
258 + </svg>
259 + <h3 class="mt-2 text-sm font-medium text-gray-900">No cases found</h3>
260 + <p class="mt-1 text-sm text-gray-500">No security cases match your current filters.</p>
261 + </div>
262 + </div>
263 + </div>
264 +
265 + <!-- Case Details Modal -->
266 + <div v-if="selectedCase" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50" @click="closeModal">
267 + <div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white" @click.stop>
268 + <div class="flex justify-between items-center mb-4">
269 + <h3 class="text-lg font-medium text-gray-900">Case Details</h3>
270 + <button @click="closeModal" class="text-gray-400 hover:text-gray-600">
271 + <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
272 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
273 + </svg>
274 + </button>
275 + </div>
276 +
277 + <div class="space-y-4">
278 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
279 + <div>
280 + <label class="block text-sm font-medium text-gray-700">Case Name</label>
281 + <p class="mt-1 text-sm text-gray-900">{{ selectedCase.case_name }}</p>
282 + </div>
283 + <div>
284 + <label class="block text-sm font-medium text-gray-700">Status</label>
285 + <span
286 + class="mt-1 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
287 + :class="{
288 + 'bg-red-100 text-red-800': selectedCase.case_status?.toLowerCase() === 'open',
289 + 'bg-yellow-100 text-yellow-800': selectedCase.case_status?.toLowerCase() === 'in_progress',
290 + 'bg-green-100 text-green-800': selectedCase.case_status?.toLowerCase() === 'closed'
291 + }"
292 + >
293 + {{ selectedCase.case_status?.replace('_', ' ').toUpperCase() }}
294 + </span>
295 + </div>
296 + <div>
297 + <label class="block text-sm font-medium text-gray-700">Case ID</label>
298 + <p class="mt-1 text-sm text-gray-900">#{{ selectedCase.id }}</p>
299 + </div>
300 + <div>
301 + <label class="block text-sm font-medium text-gray-700">Customer</label>
302 + <p class="mt-1 text-sm text-gray-900">{{ selectedCase.customer_code }}</p>
303 + </div>
304 + <div>
305 + <label class="block text-sm font-medium text-gray-700">Assigned To</label>
306 + <p class="mt-1 text-sm text-gray-900">{{ selectedCase.assigned_to || 'Unassigned' }}</p>
307 + </div>
308 + <div>
309 + <label class="block text-sm font-medium text-gray-700">Created</label>
310 + <p class="mt-1 text-sm text-gray-900">{{ formatDate(selectedCase.case_creation_time) }}</p>
311 + </div>
312 + </div>
313 +
314 + <div v-if="selectedCase.case_description">
315 + <label class="block text-sm font-medium text-gray-700">Description</label>
316 + <p class="mt-1 text-sm text-gray-900 whitespace-pre-wrap">{{ selectedCase.case_description }}</p>
317 + </div>
318 +
319 + <div v-if="selectedCase.alert_ids && selectedCase.alert_ids.length > 0">
320 + <label class="block text-sm font-medium text-gray-700">Linked Alerts</label>
321 + <div class="mt-1 flex flex-wrap gap-2">
322 + <span
323 + v-for="alertId in selectedCase.alert_ids"
324 + :key="alertId"
325 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
326 + >
327 + Alert #{{ alertId }}
328 + </span>
329 + </div>
330 + </div>
331 +
332 + <div v-if="selectedCase.alerts && selectedCase.alerts.length > 0">
333 + <div class="flex items-center justify-between mb-2">
334 + <label class="block text-sm font-medium text-gray-700">Alert Details</label>
335 + <span class="text-xs text-gray-500">Click alerts to view details</span>
336 + </div>
337 + <div class="mt-1 space-y-2">
338 + <div
339 + v-for="alert in selectedCase.alerts"
340 + :key="alert.id"
341 + class="p-3 bg-gray-50 rounded-md border hover:bg-gray-100 cursor-pointer transition-colors"
342 + @click="viewAlert(alert.id)"
343 + >
344 + <div class="flex justify-between items-start">
345 + <div class="flex-1">
346 + <div class="flex items-center space-x-2">
347 + <p class="text-sm font-medium text-gray-900">{{ alert.alert_name }}</p>
348 + <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
349 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-2M7 7l10 10M17 7v4h-4"></path>
350 + </svg>
351 + </div>
352 + <p class="text-xs text-gray-500">Asset: {{ alert.asset_name }}</p>
353 + </div>
354 + <span
355 + class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
356 + :class="{
357 + 'bg-red-100 text-red-800': alert.status === 'open',
358 + 'bg-yellow-100 text-yellow-800': alert.status === 'in_progress',
359 + 'bg-green-100 text-green-800': alert.status === 'closed'
360 + }"
361 + >
362 + {{ alert.status.replace('_', ' ').toUpperCase() }}
363 + </span>
364 + </div>
365 + </div>
366 + </div>
367 + </div>
368 +
369 + <!-- Case Files Section -->
370 + <div>
371 + <div class="flex items-center justify-between mb-2">
372 + <label class="block text-sm font-medium text-gray-700">
373 + Case Files
374 + <span v-if="caseFiles.length > 0" class="text-gray-500 font-normal">
375 + ({{ caseFiles.length }})
376 + </span>
377 + </label>
378 + <div class="flex items-center space-x-2">
379 + <button
380 + @click="openUploadForm"
381 + class="inline-flex items-center px-2 py-1 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
382 + >
383 + <svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
384 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
385 + </svg>
386 + Upload File
387 + </button>
388 + <button
389 + v-if="!loadingFiles"
390 + @click="loadCaseFiles(selectedCase.id)"
391 + class="text-xs text-indigo-600 hover:text-indigo-500 focus:outline-none"
392 + >
393 + <svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
394 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
395 + </svg>
396 + Refresh
397 + </button>
398 + </div>
399 + </div>
400 +
401 + <!-- Upload Form -->
402 + <div v-if="showUploadForm" class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
403 + <div class="flex items-center justify-between mb-3">
404 + <h4 class="text-sm font-medium text-blue-900">Upload File to Case</h4>
405 + <button
406 + @click="closeUploadForm"
407 + class="text-blue-400 hover:text-blue-600"
408 + >
409 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
410 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
411 + </svg>
412 + </button>
413 + </div>
414 +
415 + <div class="space-y-3">
416 + <div>
417 + <input
418 + ref="fileInput"
419 + type="file"
420 + @change="handleFileSelect"
421 + class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100 focus:outline-none"
422 + />
423 + </div>
424 +
425 + <div v-if="selectedFile" class="text-sm text-gray-600">
426 + Selected: {{ selectedFile.name }} ({{ CaseDataStoreAPI.formatFileSize(selectedFile.size) }})
427 + </div>
428 +
429 + <!-- Error message for upload -->
430 + <div v-if="error && showUploadForm" class="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md p-2">
431 + {{ error }}
432 + </div>
433 +
434 + <div class="flex justify-end space-x-2">
435 + <button
436 + @click="closeUploadForm"
437 + type="button"
438 + class="px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
439 + >
440 + Cancel
441 + </button>
442 + <button
443 + @click="uploadFile"
444 + :disabled="!selectedFile || uploadingFile"
445 + class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md 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"
446 + >
447 + <svg v-if="uploadingFile" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
448 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
449 + <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>
450 + </svg>
451 + {{ uploadingFile ? 'Uploading...' : 'Upload File' }}
452 + </button>
453 + </div>
454 + </div>
455 + </div>
456 +
457 + <!-- Loading Files -->
458 + <div v-if="loadingFiles" class="bg-gray-50 rounded-lg p-4 text-center">
459 + <div class="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600 mx-auto"></div>
460 + <p class="mt-2 text-sm text-gray-500">Loading files...</p>
461 + </div>
462 +
463 + <!-- Files List -->
464 + <div v-else-if="caseFiles.length > 0" class="bg-gray-50 rounded-lg p-4 max-h-64 overflow-y-auto">
465 + <div v-for="file in caseFiles" :key="file.id" class="border-b border-gray-200 pb-3 mb-3 last:border-b-0 last:mb-0">
466 + <div class="flex justify-between items-start">
467 + <div class="flex-1 min-w-0">
468 + <div class="flex items-center space-x-2">
469 + <svg class="w-4 h-4 text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
470 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
471 + </svg>
472 + <p class="text-sm font-medium text-gray-900 truncate">{{ file.file_name }}</p>
473 + </div>
474 + <div class="mt-1 flex items-center space-x-4 text-xs text-gray-500">
475 + <span>{{ CaseDataStoreAPI.formatFileSize(file.file_size) }}</span>
476 + <span v-if="file.content_type">{{ file.content_type }}</span>
477 + <span>{{ formatDate(file.upload_time) }}</span>
478 + </div>
479 + </div>
480 + <button
481 + @click="downloadFile(selectedCase.id, file.file_name)"
482 + :disabled="downloadingFile === file.file_name"
483 + class="ml-3 inline-flex items-center px-2 py-1 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
484 + >
485 + <svg v-if="downloadingFile === file.file_name" class="animate-spin -ml-1 mr-1 h-3 w-3 text-indigo-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
486 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
487 + <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>
488 + </svg>
489 + <svg v-else class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
490 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
491 + </svg>
492 + {{ downloadingFile === file.file_name ? 'Downloading...' : 'Download' }}
493 + </button>
494 + </div>
495 + </div>
496 + </div>
497 +
498 + <!-- No Files Message -->
499 + <div v-else class="bg-gray-50 rounded-lg p-4 text-center">
500 + <svg class="w-8 h-8 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
501 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
502 + </svg>
503 + <p class="mt-2 text-sm text-gray-500">No files available for this case</p>
504 + </div>
505 + </div>
506 + </div>
507 + </div>
508 + </div>
509 +
510 + <!-- Alert Details Modal -->
511 + <div v-if="selectedAlert" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50" @click="closeAlertModal">
512 + <div class="relative top-10 mx-auto p-5 border w-11/12 md:w-4/5 lg:w-3/4 shadow-lg rounded-md bg-white max-h-screen overflow-y-auto" @click.stop>
513 + <div class="flex justify-between items-center mb-4">
514 + <h3 class="text-lg font-medium text-gray-900">Alert Details</h3>
515 + <button @click="closeAlertModal" class="text-gray-400 hover:text-gray-600">
516 + <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
517 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
518 + </svg>
519 + </button>
520 + </div>
521 +
522 + <div class="space-y-6">
523 + <!-- Basic Alert Information -->
524 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
525 + <div>
526 + <label class="block text-sm font-medium text-gray-700">Alert Name</label>
527 + <p class="mt-1 text-sm text-gray-900">{{ selectedAlert.alert_name }}</p>
528 + </div>
529 + <div>
530 + <label class="block text-sm font-medium text-gray-700">Status</label>
531 + <span
532 + class="mt-1 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
533 + :class="{
534 + 'bg-red-100 text-red-800': selectedAlert.status === 'OPEN',
535 + 'bg-yellow-100 text-yellow-800': selectedAlert.status === 'IN_PROGRESS',
536 + 'bg-green-100 text-green-800': selectedAlert.status === 'CLOSED'
537 + }"
538 + >
539 + {{ selectedAlert.status.replace('_', ' ') }}
540 + </span>
541 + </div>
542 + <div>
543 + <label class="block text-sm font-medium text-gray-700">Source</label>
544 + <p class="mt-1 text-sm text-gray-900">{{ selectedAlert.source }}</p>
545 + </div>
546 + <div>
547 + <label class="block text-sm font-medium text-gray-700">Customer</label>
548 + <p class="mt-1 text-sm text-gray-900">{{ selectedAlert.customer_code }}</p>
549 + </div>
550 + <div>
551 + <label class="block text-sm font-medium text-gray-700">Created</label>
552 + <p class="mt-1 text-sm text-gray-900">{{ formatDate(selectedAlert.alert_creation_time) }}</p>
553 + </div>
554 + <div v-if="selectedAlert.assigned_to">
555 + <label class="block text-sm font-medium text-gray-700">Assigned To</label>
556 + <p class="mt-1 text-sm text-gray-900">{{ selectedAlert.assigned_to }}</p>
557 + </div>
558 + </div>
559 +
560 + <div v-if="selectedAlert.alert_description">
561 + <label class="block text-sm font-medium text-gray-700">Description</label>
562 + <p class="mt-1 text-sm text-gray-900 whitespace-pre-wrap">{{ selectedAlert.alert_description }}</p>
563 + </div>
564 +
565 + <!-- Assets Section -->
566 + <div v-if="selectedAlert.assets && selectedAlert.assets.length > 0">
567 + <label class="block text-sm font-medium text-gray-700 mb-2">Assets</label>
568 + <div class="bg-gray-50 rounded-lg p-4">
569 + <div v-for="asset in selectedAlert.assets" :key="asset.id" class="border-b border-gray-200 pb-3 mb-3 last:border-b-0 last:mb-0">
570 + <div class="grid grid-cols-1 md:grid-cols-3 gap-2 text-sm">
571 + <div>
572 + <span class="font-medium">Asset Name:</span> {{ asset.asset_name }}
573 + </div>
574 + <div>
575 + <span class="font-medium">Agent ID:</span> {{ asset.agent_id }}
576 + </div>
577 + <div v-if="asset.velociraptor_id">
578 + <span class="font-medium">Velociraptor ID:</span> {{ asset.velociraptor_id }}
579 + </div>
580 + <div>
581 + <span class="font-medium">Index:</span> {{ asset.index_name }}
582 + </div>
583 + <div>
584 + <span class="font-medium">Index ID:</span> {{ asset.index_id.substring(0, 20) }}...
585 + </div>
586 + </div>
587 + </div>
588 + </div>
589 + </div>
590 +
591 + <!-- Tags Section -->
592 + <div v-if="selectedAlert.tags && selectedAlert.tags.length > 0">
593 + <label class="block text-sm font-medium text-gray-700">Tags</label>
594 + <div class="mt-1 flex flex-wrap gap-2">
595 + <span
596 + v-for="tag in selectedAlert.tags"
597 + :key="tag.id"
598 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
599 + >
600 + {{ tag.tag }}
601 + </span>
602 + </div>
603 + </div>
604 +
605 + <!-- Linked Cases Section -->
606 + <div v-if="selectedAlert.linked_cases && selectedAlert.linked_cases.length > 0">
607 + <label class="block text-sm font-medium text-gray-700 mb-2">Linked Cases</label>
608 + <div class="bg-gray-50 rounded-lg p-4">
609 + <div v-for="linkedCase in selectedAlert.linked_cases" :key="linkedCase.id" class="border-b border-gray-200 pb-3 mb-3 last:border-b-0 last:mb-0">
610 + <div class="flex justify-between items-start">
611 + <div class="flex-1">
612 + <h4 class="text-sm font-medium text-gray-900">{{ linkedCase.case_name }}</h4>
613 + <p class="text-xs text-gray-600 mt-1">{{ linkedCase.case_description }}</p>
614 + <div class="flex items-center space-x-4 mt-2 text-xs text-gray-500">
615 + <span>Case #{{ linkedCase.id }}</span>
616 + <span>Created: {{ formatDate(linkedCase.case_creation_time) }}</span>
617 + <span v-if="linkedCase.assigned_to">Assigned to: {{ linkedCase.assigned_to }}</span>
618 + </div>
619 + </div>
620 + <span
621 + class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
622 + :class="{
623 + 'bg-red-100 text-red-800': linkedCase.case_status === 'OPEN',
624 + 'bg-yellow-100 text-yellow-800': linkedCase.case_status === 'IN_PROGRESS',
625 + 'bg-green-100 text-green-800': linkedCase.case_status === 'CLOSED'
626 + }"
627 + >
628 + {{ linkedCase.case_status.replace('_', ' ') }}
629 + </span>
630 + </div>
631 + </div>
632 + </div>
633 + </div>
634 +
635 + <!-- IoCs Section -->
636 + <div v-if="selectedAlert.iocs && selectedAlert.iocs.length > 0">
637 + <label class="block text-sm font-medium text-gray-700 mb-2">Indicators of Compromise (IoCs)</label>
638 + <div class="bg-gray-50 rounded-lg p-4">
639 + <div v-for="ioc in selectedAlert.iocs" :key="ioc.id" class="border-b border-gray-200 pb-3 mb-3 last:border-b-0 last:mb-0">
640 + <div class="grid grid-cols-1 md:grid-cols-3 gap-2 text-sm">
641 + <div>
642 + <span class="font-medium">Value:</span>
643 + <code class="bg-gray-100 px-1 rounded text-xs">{{ ioc.ioc_value }}</code>
644 + </div>
645 + <div>
646 + <span class="font-medium">Type:</span> {{ ioc.ioc_type }}
647 + </div>
648 + <div>
649 + <span class="font-medium">Description:</span> {{ ioc.ioc_description }}
650 + </div>
651 + </div>
652 + </div>
653 + </div>
654 + </div>
655 +
656 + <!-- Comments Section -->
657 + <div v-if="selectedAlert.comments && selectedAlert.comments.length > 0">
658 + <label class="block text-sm font-medium text-gray-700 mb-2">
659 + Comments ({{ selectedAlert.comments.length }})
660 + </label>
661 + <div class="bg-gray-50 rounded-lg p-4 max-h-64 overflow-y-auto">
662 + <div v-for="comment in selectedAlert.comments" :key="comment.id" class="border-b border-gray-200 pb-3 mb-3 last:border-b-0 last:mb-0">
663 + <div class="flex justify-between items-start mb-2">
664 + <span class="text-sm font-medium text-gray-900">{{ comment.user_name }}</span>
665 + <span class="text-xs text-gray-500">{{ formatDate(comment.created_at) }}</span>
666 + </div>
667 + <p class="text-sm text-gray-700 whitespace-pre-wrap">{{ comment.comment }}</p>
668 + </div>
669 + </div>
670 + </div>
671 + </div>
672 + </div>
673 + </div>
674 + </div>
675 +</template>
676 +
677 +<script setup lang="ts">
678 +import { ref, onMounted, computed } from 'vue'
679 +import { useRouter } from 'vue-router'
680 +import CasesAPI, { type Case, type CasesResponse } from '@/api/cases'
681 +import CaseDataStoreAPI, { type CaseDataStoreFile } from '@/api/caseDataStore'
682 +import AlertsAPI, { type Alert } from '@/api/alerts'
683 +
684 +const router = useRouter()
685 +
686 +// Reactive data
687 +const cases = ref<Case[]>([])
688 +const loading = ref(false)
689 +const error = ref<string | null>(null)
690 +const selectedCase = ref<Case | null>(null)
691 +const selectedAlert = ref<Alert | null>(null)
692 +const updatingStatus = ref<number | null>(null)
693 +
694 +// Case files data
695 +const caseFiles = ref<CaseDataStoreFile[]>([])
696 +const loadingFiles = ref(false)
697 +const downloadingFile = ref<string | null>(null)
698 +
699 +// File upload data
700 +const uploadingFile = ref(false)
701 +const selectedFile = ref<File | null>(null)
702 +const showUploadForm = ref(false)
703 +const fileInput = ref<HTMLInputElement | null>(null)
704 +
705 +// Filters
706 +const filters = ref({
707 + status: '',
708 + assignedTo: ''
709 +})
710 +
711 +// Computed properties
712 +const openCases = computed(() => cases.value.filter(c => c.case_status?.toLowerCase() === 'open').length)
713 +const inProgressCases = computed(() => cases.value.filter(c => c.case_status?.toLowerCase() === 'in_progress').length)
714 +const closedCases = computed(() => cases.value.filter(c => c.case_status?.toLowerCase() === 'closed').length)
715 +
716 +const availableAssignees = computed(() => {
717 + const assignees = new Set(cases.value.map(c => c.assigned_to).filter((assignee): assignee is string => assignee !== null))
718 + return Array.from(assignees).sort()
719 +})
720 +
721 +const filteredCases = computed(() => {
722 + let filtered = cases.value
723 +
724 + if (filters.value.status) {
725 + filtered = filtered.filter(c => c.case_status?.toLowerCase() === filters.value.status.toLowerCase())
726 + }
727 +
728 + if (filters.value.assignedTo) {
729 + filtered = filtered.filter(c => c.assigned_to === filters.value.assignedTo)
730 + }
731 +
732 + return filtered.sort((a, b) => new Date(b.case_creation_time).getTime() - new Date(a.case_creation_time).getTime())
733 +})
734 +
735 +// Methods
736 +const goBack = () => {
737 + router.push('/')
738 +}
739 +
740 +const loadCases = async () => {
741 + loading.value = true
742 + error.value = null
743 +
744 + try {
745 + let response: CasesResponse
746 +
747 + if (filters.value.status) {
748 + // Convert lowercase filter to uppercase for backend API
749 + const backendStatus = filters.value.status.toUpperCase() as any
750 + response = await CasesAPI.getCasesByStatus(backendStatus)
751 + } else if (filters.value.assignedTo) {
752 + response = await CasesAPI.getCasesByAssignedTo(filters.value.assignedTo)
753 + } else {
754 + response = await CasesAPI.getCases()
755 + }
756 +
757 + cases.value = response.cases
758 + console.log('Loaded cases:', response.cases)
759 + console.log('Case statuses:', response.cases.map(c => c.case_status))
760 + } catch (err: any) {
761 + error.value = err.response?.data?.detail || err.message || 'Failed to load cases'
762 + console.error('Error loading cases:', err)
763 + } finally {
764 + loading.value = false
765 + }
766 +}
767 +
768 +const refreshCases = () => {
769 + loadCases()
770 +}
771 +
772 +const applyFilters = () => {
773 + // Since we use computed filteredCases, we don't need to reload from API for local filtering
774 + // But if we want to filter on the server side, we can call loadCases()
775 + loadCases()
776 +}
777 +
778 +const updateCaseStatus = async (caseId: number, newStatus: string) => {
779 + updatingStatus.value = caseId
780 +
781 + try {
782 + await CasesAPI.updateCaseStatus(caseId, newStatus as any)
783 +
784 + // Update the local case status
785 + const case_ = cases.value.find(c => c.id === caseId)
786 + if (case_) {
787 + case_.case_status = newStatus as any
788 + }
789 + } catch (err: any) {
790 + error.value = err.response?.data?.detail || err.message || 'Failed to update case status'
791 + console.error('Error updating case status:', err)
792 + } finally {
793 + updatingStatus.value = null
794 + }
795 +}
796 +
797 +const viewCase = (case_: Case) => {
798 + selectedCase.value = case_
799 + loadCaseFiles(case_.id)
800 +}
801 +
802 +const loadCaseFiles = async (caseId: number) => {
803 + loadingFiles.value = true
804 + try {
805 + const response = await CaseDataStoreAPI.getCaseFiles(caseId)
806 + caseFiles.value = response.case_data_store
807 + } catch (err: any) {
808 + console.error('Error loading case files:', err)
809 + caseFiles.value = []
810 + } finally {
811 + loadingFiles.value = false
812 + }
813 +}
814 +
815 +const downloadFile = async (caseId: number, fileName: string) => {
816 + downloadingFile.value = fileName
817 + try {
818 + const blob = await CaseDataStoreAPI.downloadCaseFile(caseId, fileName)
819 + CaseDataStoreAPI.downloadFileBlob(blob, fileName)
820 + } catch (err: any) {
821 + console.error('Error downloading file:', err)
822 + error.value = err.response?.data?.detail || err.message || 'Failed to download file'
823 + } finally {
824 + downloadingFile.value = null
825 + }
826 +}
827 +
828 +const openUploadForm = () => {
829 + showUploadForm.value = true
830 + selectedFile.value = null
831 + error.value = null
832 +}
833 +
834 +const closeUploadForm = () => {
835 + showUploadForm.value = false
836 + selectedFile.value = null
837 + if (fileInput.value) {
838 + fileInput.value.value = ''
839 + }
840 +}
841 +
842 +const handleFileSelect = (event: Event) => {
843 + const target = event.target as HTMLInputElement
844 + if (target.files && target.files.length > 0) {
845 + const file = target.files[0]
846 + // Check file size (e.g., limit to 50MB)
847 + const maxSize = 50 * 1024 * 1024 // 50MB in bytes
848 + if (file.size > maxSize) {
849 + error.value = 'File size too large. Maximum size is 50MB.'
850 + selectedFile.value = null
851 + return
852 + }
853 + selectedFile.value = file
854 + error.value = null // Clear any previous errors
855 + }
856 +}
857 +
858 +const uploadFile = async () => {
859 + if (!selectedFile.value || !selectedCase.value) return
860 +
861 + uploadingFile.value = true
862 + error.value = null
863 +
864 + try {
865 + await CaseDataStoreAPI.uploadCaseFile(selectedCase.value.id, selectedFile.value)
866 +
867 + // Refresh the files list
868 + await loadCaseFiles(selectedCase.value.id)
869 +
870 + // Close the upload form
871 + closeUploadForm()
872 +
873 + // You could add a success message here if you have a toast/notification system
874 + console.log('File uploaded successfully!')
875 +
876 + } catch (err: any) {
877 + console.error('Error uploading file:', err)
878 +
879 + // Extract more specific error messages
880 + let errorMessage = 'Failed to upload file'
881 + if (err.response?.data?.detail) {
882 + if (typeof err.response.data.detail === 'string') {
883 + errorMessage = err.response.data.detail
884 + } else if (Array.isArray(err.response.data.detail)) {
885 + errorMessage = err.response.data.detail.map((e: any) => e.msg || e.message || e).join(', ')
886 + }
887 + } else if (err.message) {
888 + errorMessage = err.message
889 + }
890 +
891 + error.value = errorMessage
892 + } finally {
893 + uploadingFile.value = false
894 + }
895 +}
896 +
897 +const viewAlert = async (alertId: number) => {
898 + try {
899 + const response = await AlertsAPI.getAlert(alertId)
900 + selectedAlert.value = response.alerts[0] // AlertResponse contains alerts array
901 + } catch (err: any) {
902 + console.error('Error loading alert details:', err)
903 + error.value = err.response?.data?.detail || err.message || 'Failed to load alert details'
904 + }
905 +}
906 +
907 +const closeAlertModal = () => {
908 + selectedAlert.value = null
909 +}
910 +
911 +const closeModal = () => {
912 + selectedCase.value = null
913 + caseFiles.value = []
914 + closeUploadForm()
915 + closeAlertModal()
916 +}
917 +
918 +const formatDate = (dateString: string) => {
919 + return new Date(dateString).toLocaleString()
920 +}
921 +
922 +// Lifecycle
923 +onMounted(() => {
924 + loadCases()
925 +})
926 +</script>
customer_portal/src/views/CasesView.vue new
+262
@@ -0,0 +1,262 @@
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="/"
10 + class="text-indigo-600 hover:text-indigo-500 mr-4"
11 + >
12 + ← Back to Dashboard
13 + </router-link>
14 + <h1 class="text-xl font-semibold">Security Cases</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 cases...
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 cases
45 + </h3>
46 + <div class="mt-2 text-sm text-red-700">
47 + {{ error }}
48 + </div>
49 + </div>
50 + </div>
51 + </div>
52 +
53 + <!-- Cases List -->
54 + <div v-else>
55 + <!-- Stats Cards -->
56 + <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
57 + <div class="bg-white overflow-hidden shadow rounded-lg">
58 + <div class="p-5">
59 + <div class="flex items-center">
60 + <div class="w-8 h-8 bg-red-500 rounded-md flex items-center justify-center">
61 + <span class="text-white text-sm font-medium">O</span>
62 + </div>
63 + <div class="ml-3">
64 + <p class="text-sm font-medium text-gray-500">Open</p>
65 + <p class="text-lg font-semibold text-gray-900">{{ getCaseCount('open') }}</p>
66 + </div>
67 + </div>
68 + </div>
69 + </div>
70 + <div class="bg-white overflow-hidden shadow rounded-lg">
71 + <div class="p-5">
72 + <div class="flex items-center">
73 + <div class="w-8 h-8 bg-yellow-500 rounded-md flex items-center justify-center">
74 + <span class="text-white text-sm font-medium">P</span>
75 + </div>
76 + <div class="ml-3">
77 + <p class="text-sm font-medium text-gray-500">In Progress</p>
78 + <p class="text-lg font-semibold text-gray-900">{{ getCaseCount('in_progress') }}</p>
79 + </div>
80 + </div>
81 + </div>
82 + </div>
83 + <div class="bg-white overflow-hidden shadow rounded-lg">
84 + <div class="p-5">
85 + <div class="flex items-center">
86 + <div class="w-8 h-8 bg-green-500 rounded-md flex items-center justify-center">
87 + <span class="text-white text-sm font-medium">C</span>
88 + </div>
89 + <div class="ml-3">
90 + <p class="text-sm font-medium text-gray-500">Closed</p>
91 + <p class="text-lg font-semibold text-gray-900">{{ getCaseCount('closed') }}</p>
92 + </div>
93 + </div>
94 + </div>
95 + </div>
96 + <div class="bg-white overflow-hidden shadow rounded-lg">
97 + <div class="p-5">
98 + <div class="flex items-center">
99 + <div class="w-8 h-8 bg-gray-500 rounded-md flex items-center justify-center">
100 + <span class="text-white text-sm font-medium">T</span>
101 + </div>
102 + <div class="ml-3">
103 + <p class="text-sm font-medium text-gray-500">Total</p>
104 + <p class="text-lg font-semibold text-gray-900">{{ cases.length }}</p>
105 + </div>
106 + </div>
107 + </div>
108 + </div>
109 + </div>
110 +
111 + <!-- Cases Table -->
112 + <div class="bg-white shadow overflow-hidden sm:rounded-md">
113 + <div class="px-4 py-5 sm:px-6">
114 + <h3 class="text-lg leading-6 font-medium text-gray-900">
115 + Security Cases
116 + </h3>
117 + <p class="mt-1 max-w-2xl text-sm text-gray-500">
118 + Security incident cases for your organization
119 + </p>
120 + </div>
121 +
122 + <div v-if="cases.length === 0" class="px-4 py-5 sm:px-6 text-center text-gray-500">
123 + No cases found
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">
128 + <div class="flex items-center justify-between">
129 + <div class="flex items-center">
130 + <div
131 + class="w-3 h-3 rounded-full mr-3"
132 + :class="{
133 + 'bg-red-500': case_.case_status === 'open',
134 + 'bg-yellow-500': case_.case_status === 'in_progress',
135 + 'bg-green-500': case_.case_status === 'closed',
136 + 'bg-gray-500': !case_.case_status
137 + }"
138 + ></div>
139 + <div>
140 + <p class="text-sm font-medium text-gray-900">
141 + {{ case_.case_name || 'Unnamed Case' }}
142 + </p>
143 + <p class="text-sm text-gray-500">
144 + {{ case_.case_description || 'No description available' }}
145 + </p>
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 + </p>
150 + </div>
151 + </div>
152 + <div class="flex items-center space-x-2">
153 + <span
154 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
155 + :class="{
156 + 'bg-red-100 text-red-800': case_.case_status === 'open',
157 + 'bg-yellow-100 text-yellow-800': case_.case_status === 'in_progress',
158 + 'bg-green-100 text-green-800': case_.case_status === 'closed',
159 + 'bg-gray-100 text-gray-800': !case_.case_status
160 + }"
161 + >
162 + {{ case_.case_status || 'Unknown' }}
163 + </span>
164 + <span
165 + v-if="case_.escalation_level"
166 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
167 + :class="{
168 + 'bg-red-100 text-red-800': case_.escalation_level === 'high',
169 + 'bg-yellow-100 text-yellow-800': case_.escalation_level === 'medium',
170 + 'bg-blue-100 text-blue-800': case_.escalation_level === 'low'
171 + }"
172 + >
173 + {{ case_.escalation_level }}
174 + </span>
175 + </div>
176 + </div>
177 + </li>
178 + </ul>
179 + </div>
180 +
181 + <!-- Pagination (if needed) -->
182 + <div v-if="cases.length > 0" class="mt-6 flex justify-center">
183 + <button
184 + @click="refreshCases"
185 + class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm font-medium"
186 + >
187 + Refresh
188 + </button>
189 + </div>
190 + </div>
191 + </div>
192 + </main>
193 + </div>
194 +</template>
195 +
196 +<script setup lang="ts">
197 +import { ref, onMounted, computed } from 'vue'
198 +import { useRouter } from 'vue-router'
199 +import { useAuthStore } from '@/stores/auth'
200 +import { httpClient } from '@/utils/httpClient'
201 +
202 +interface Case {
203 + id: number
204 + case_name: string
205 + case_description: string
206 + case_status: string
207 + case_creation_time: string
208 + assigned_to?: string
209 + escalation_level?: string
210 + customer_code?: string
211 +}
212 +
213 +const router = useRouter()
214 +const authStore = useAuthStore()
215 +
216 +const cases = ref<Case[]>([])
217 +const loading = ref(false)
218 +const error = ref('')
219 +
220 +const user = computed(() => authStore.user)
221 +
222 +const getCaseCount = (status: string) => {
223 + return cases.value.filter(case_ => case_.case_status === status).length
224 +}
225 +
226 +const formatDate = (dateString: string) => {
227 + if (!dateString) return 'Unknown'
228 + try {
229 + return new Date(dateString).toLocaleDateString()
230 + } catch {
231 + return 'Invalid date'
232 + }
233 +}
234 +
235 +const fetchCases = async () => {
236 + loading.value = true
237 + error.value = ''
238 +
239 + try {
240 + const response = await httpClient.get('/cases/')
241 + cases.value = response.data || []
242 + } catch (err: any) {
243 + error.value = err.response?.data?.detail || 'Failed to fetch cases'
244 + console.error('Failed to fetch cases:', err)
245 + } finally {
246 + loading.value = false
247 + }
248 +}
249 +
250 +const refreshCases = () => {
251 + fetchCases()
252 +}
253 +
254 +const logout = () => {
255 + authStore.logout()
256 + router.push('/login')
257 +}
258 +
259 +onMounted(() => {
260 + fetchCases()
261 +})
262 +</script>
customer_portal/src/views/OverviewPage.vue new
+593
@@ -0,0 +1,593 @@
1 +<template>
2 + <div class="min-h-screen bg-gray-50">
3 + <!-- Header -->
4 + <header class="bg-white shadow-sm border-b">
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 + <img
9 + class="h-8 w-auto mr-3"
10 + src="/logo.svg"
11 + alt="SOCFortress Logo"
12 + />
13 + <h1 class="text-xl font-semibold text-gray-900">Customer Portal</h1>
14 + <nav class="ml-8 flex space-x-8">
15 + <router-link
16 + to="/"
17 + class="text-indigo-600 border-b-2 border-indigo-600 px-3 py-2 rounded-md text-sm font-medium"
18 + >
19 + Overview
20 + </router-link>
21 + <router-link
22 + to="/alerts"
23 + class="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
24 + >
25 + Alerts
26 + </router-link>
27 + <router-link
28 + to="/cases"
29 + class="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
30 + >
31 + Cases
32 + </router-link>
33 + <router-link
34 + to="/agents"
35 + class="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
36 + >
37 + Agents
38 + </router-link>
39 + </nav>
40 + </div>
41 + <div class="flex items-center space-x-4">
42 + <div class="text-sm text-gray-700">
43 + Welcome, <span class="font-medium">{{ username }}</span>
44 + </div>
45 + <button
46 + @click="logout"
47 + class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
48 + >
49 + Logout
50 + </button>
51 + </div>
52 + </div>
53 + </div>
54 + </header>
55 +
56 + <!-- Main Content -->
57 + <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
58 + <div class="px-4 py-6 sm:px-0">
59 + <!-- Welcome Section -->
60 + <div class="mb-8">
61 + <h2 class="text-2xl font-bold text-gray-900 mb-2">Security Overview</h2>
62 + <p class="text-gray-600">Monitor your organization's security posture and recent activity</p>
63 + </div>
64 +
65 + <!-- Loading State -->
66 + <div v-if="loading" class="flex justify-center items-center py-12">
67 + <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
68 + <span class="ml-3 text-gray-600">Loading dashboard...</span>
69 + </div>
70 +
71 + <!-- Error State -->
72 + <div v-else-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
73 + <div class="flex">
74 + <div class="flex-shrink-0">
75 + <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
76 + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
77 + </svg>
78 + </div>
79 + <div class="ml-3">
80 + <h3 class="text-sm font-medium text-red-800">Error Loading Dashboard</h3>
81 + <div class="mt-2 text-sm text-red-700">{{ error }}</div>
82 + <div class="mt-3">
83 + <button
84 + @click="refreshData"
85 + class="bg-red-100 hover:bg-red-200 text-red-800 px-3 py-1 rounded text-sm font-medium"
86 + >
87 + Try Again
88 + </button>
89 + </div>
90 + </div>
91 + </div>
92 + </div>
93 +
94 + <!-- Dashboard Content -->
95 + <div v-else>
96 + <!-- Key Metrics Cards -->
97 + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
98 + <!-- Total Alerts -->
99 + <div class="bg-white overflow-hidden shadow-sm rounded-lg">
100 + <div class="p-6">
101 + <div class="flex items-center">
102 + <div class="flex-shrink-0">
103 + <div class="w-8 h-8 bg-red-500 rounded-md flex items-center justify-center">
104 + <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
105 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
106 + </svg>
107 + </div>
108 + </div>
109 + <div class="ml-5 w-0 flex-1">
110 + <dl>
111 + <dt class="text-sm font-medium text-gray-500 truncate">Total Alerts</dt>
112 + <dd class="flex items-baseline">
113 + <div class="text-2xl font-semibold text-gray-900">{{ stats.totalAlerts }}</div>
114 + <div class="ml-2 flex items-baseline text-sm font-semibold" :class="alertTrendClass">
115 + {{ stats.alertTrend }}
116 + </div>
117 + </dd>
118 + </dl>
119 + </div>
120 + </div>
121 + </div>
122 + </div>
123 +
124 + <!-- Critical Alerts -->
125 + <div class="bg-white overflow-hidden shadow-sm rounded-lg">
126 + <div class="p-6">
127 + <div class="flex items-center">
128 + <div class="flex-shrink-0">
129 + <div class="w-8 h-8 bg-orange-500 rounded-md flex items-center justify-center">
130 + <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
131 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
132 + </svg>
133 + </div>
134 + </div>
135 + <div class="ml-5 w-0 flex-1">
136 + <dl>
137 + <dt class="text-sm font-medium text-gray-500 truncate">Critical Alerts</dt>
138 + <dd class="text-2xl font-semibold text-gray-900">{{ stats.criticalAlerts }}</dd>
139 + </dl>
140 + </div>
141 + </div>
142 + </div>
143 + </div>
144 +
145 + <!-- Open Cases -->
146 + <div class="bg-white overflow-hidden shadow-sm rounded-lg">
147 + <div class="p-6">
148 + <div class="flex items-center">
149 + <div class="flex-shrink-0">
150 + <div class="w-8 h-8 bg-blue-500 rounded-md flex items-center justify-center">
151 + <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
152 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
153 + </svg>
154 + </div>
155 + </div>
156 + <div class="ml-5 w-0 flex-1">
157 + <dl>
158 + <dt class="text-sm font-medium text-gray-500 truncate">Open Cases</dt>
159 + <dd class="text-2xl font-semibold text-gray-900">{{ stats.openCases }}</dd>
160 + </dl>
161 + </div>
162 + </div>
163 + </div>
164 + </div>
165 +
166 + <!-- Security Score -->
167 + <div class="bg-white overflow-hidden shadow-sm rounded-lg">
168 + <div class="p-6">
169 + <div class="flex items-center">
170 + <div class="flex-shrink-0">
171 + <div class="w-8 h-8 bg-green-500 rounded-md flex items-center justify-center">
172 + <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
173 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
174 + </svg>
175 + </div>
176 + </div>
177 + <div class="ml-5 w-0 flex-1">
178 + <dl>
179 + <dt class="text-sm font-medium text-gray-500 truncate">Security Score</dt>
180 + <dd class="flex items-baseline">
181 + <div class="text-2xl font-semibold text-gray-900">{{ stats.securityScore }}%</div>
182 + <div class="ml-2 flex items-baseline text-sm font-semibold text-green-600">
183 + +{{ stats.scoreImprovement }}%
184 + </div>
185 + </dd>
186 + </dl>
187 + </div>
188 + </div>
189 + </div>
190 + </div>
191 +
192 + <!-- Total Agents -->
193 + <div class="bg-white overflow-hidden shadow-sm rounded-lg">
194 + <div class="p-6">
195 + <div class="flex items-center">
196 + <div class="flex-shrink-0">
197 + <div class="w-8 h-8 bg-purple-500 rounded-md flex items-center justify-center">
198 + <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
199 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"></path>
200 + </svg>
201 + </div>
202 + </div>
203 + <div class="ml-5 w-0 flex-1">
204 + <dl>
205 + <dt class="text-sm font-medium text-gray-500 truncate">Total Agents</dt>
206 + <dd class="text-2xl font-semibold text-gray-900">{{ stats.totalAgents }}</dd>
207 + </dl>
208 + </div>
209 + </div>
210 + </div>
211 + </div>
212 + </div>
213 +
214 + <!-- Recent Activity and Charts Section -->
215 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
216 + <!-- Recent Alerts -->
217 + <div class="bg-white shadow-sm rounded-lg">
218 + <div class="px-6 py-4 border-b border-gray-200">
219 + <h3 class="text-lg font-medium text-gray-900">Recent Alerts</h3>
220 + </div>
221 + <div class="p-6">
222 + <div v-if="recentAlerts.length === 0" class="text-center text-gray-500 py-8">
223 + No recent alerts
224 + </div>
225 + <div v-else class="space-y-4">
226 + <div
227 + v-for="alert in recentAlerts"
228 + :key="alert.id"
229 + class="flex items-start space-x-3 p-3 rounded-lg hover:bg-gray-50"
230 + >
231 + <div
232 + class="w-3 h-3 rounded-full mt-2"
233 + :class="{
234 + 'bg-red-500': alert.severity === 'high',
235 + 'bg-yellow-500': alert.severity === 'medium',
236 + 'bg-blue-500': alert.severity === 'low'
237 + }"
238 + ></div>
239 + <div class="flex-1 min-w-0">
240 + <p class="text-sm font-medium text-gray-900 truncate">
241 + {{ alert.name }}
242 + </p>
243 + <p class="text-sm text-gray-500 truncate">
244 + {{ alert.description }}
245 + </p>
246 + <p class="text-xs text-gray-400 mt-1">
247 + {{ formatTimeAgo(alert.created_at) }}
248 + </p>
249 + </div>
250 + <span
251 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
252 + :class="{
253 + 'bg-red-100 text-red-800': alert.severity === 'high',
254 + 'bg-yellow-100 text-yellow-800': alert.severity === 'medium',
255 + 'bg-blue-100 text-blue-800': alert.severity === 'low'
256 + }"
257 + >
258 + {{ alert.severity }}
259 + </span>
260 + </div>
261 + </div>
262 + <div class="mt-6 text-center">
263 + <button
264 + @click="goToAlerts"
265 + class="text-indigo-600 hover:text-indigo-500 font-medium text-sm"
266 + >
267 + View all alerts →
268 + </button>
269 + </div>
270 + </div>
271 + </div>
272 +
273 + <!-- Recent Cases -->
274 + <div class="bg-white shadow-sm rounded-lg">
275 + <div class="px-6 py-4 border-b border-gray-200">
276 + <h3 class="text-lg font-medium text-gray-900">Recent Cases</h3>
277 + </div>
278 + <div class="p-6">
279 + <div v-if="recentCases.length === 0" class="text-center text-gray-500 py-8">
280 + No recent cases
281 + </div>
282 + <div v-else class="space-y-4">
283 + <div
284 + v-for="case_ in recentCases"
285 + :key="case_.id"
286 + class="flex items-start space-x-3 p-3 rounded-lg hover:bg-gray-50"
287 + >
288 + <div
289 + class="w-3 h-3 rounded-full mt-2"
290 + :class="{
291 + 'bg-red-500': case_.status === 'open',
292 + 'bg-yellow-500': case_.status === 'in_progress',
293 + 'bg-green-500': case_.status === 'closed'
294 + }"
295 + ></div>
296 + <div class="flex-1 min-w-0">
297 + <p class="text-sm font-medium text-gray-900 truncate">
298 + {{ case_.name }}
299 + </p>
300 + <p class="text-sm text-gray-500 truncate">
301 + {{ case_.description }}
302 + </p>
303 + <p class="text-xs text-gray-400 mt-1">
304 + {{ formatTimeAgo(case_.created_at) }}
305 + <span v-if="case_.assigned_to"> • Assigned to {{ case_.assigned_to }}</span>
306 + </p>
307 + </div>
308 + <span
309 + class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
310 + :class="{
311 + 'bg-red-100 text-red-800': case_.status === 'open',
312 + 'bg-yellow-100 text-yellow-800': case_.status === 'in_progress',
313 + 'bg-green-100 text-green-800': case_.status === 'closed'
314 + }"
315 + >
316 + {{ case_.status }}
317 + </span>
318 + </div>
319 + </div>
320 + <div class="mt-6 text-center">
321 + <button
322 + @click="goToCases"
323 + class="text-indigo-600 hover:text-indigo-500 font-medium text-sm"
324 + >
325 + View all cases →
326 + </button>
327 + </div>
328 + </div>
329 + </div>
330 + </div>
331 +
332 + <!-- Quick Actions -->
333 + <div class="mt-8 bg-white shadow-sm rounded-lg">
334 + <div class="px-6 py-4 border-b border-gray-200">
335 + <h3 class="text-lg font-medium text-gray-900">Quick Actions</h3>
336 + </div>
337 + <div class="p-6">
338 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
339 + <button
340 + @click="goToAlerts"
341 + class="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
342 + >
343 + <svg class="w-5 h-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
344 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
345 + </svg>
346 + View Alerts
347 + </button>
348 + <button
349 + @click="goToCases"
350 + class="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
351 + >
352 + <svg class="w-5 h-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
353 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
354 + </svg>
355 + View Cases
356 + </button>
357 + <button
358 + @click="goToAgents"
359 + class="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
360 + >
361 + <svg class="w-5 h-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
362 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"></path>
363 + </svg>
364 + View Agents
365 + </button>
366 + <button
367 + @click="refreshData"
368 + class="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
369 + >
370 + <svg class="w-5 h-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
371 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
372 + </svg>
373 + Refresh Data
374 + </button>
375 + <button
376 + class="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
377 + disabled
378 + >
379 + <svg class="w-5 h-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
380 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 00-2-2z"></path>
381 + </svg>
382 + Reports (Soon)
383 + </button>
384 + </div>
385 + </div>
386 + </div>
387 + </div>
388 + </div>
389 + </main>
390 + </div>
391 +</template>
392 +
393 +<script setup lang="ts">
394 +import { ref, onMounted, computed } from 'vue'
395 +import { useRouter } from 'vue-router'
396 +import AlertsAPI, { type Alert } from '@/api/alerts'
397 +import CasesAPI, { type Case } from '@/api/cases'
398 +import AgentsAPI from '@/api/agents'
399 +
400 +interface Stats {
401 + totalAlerts: number
402 + criticalAlerts: number
403 + openCases: number
404 + totalAgents: number
405 + securityScore: number
406 + alertTrend: string
407 + scoreImprovement: number
408 +}
409 +
410 +interface DashboardAlert {
411 + id: number
412 + name: string
413 + description: string
414 + severity: string
415 + created_at: string
416 +}
417 +
418 +interface DashboardCase {
419 + id: number
420 + name: string
421 + description: string
422 + status: string
423 + created_at: string
424 + assigned_to?: string | null
425 +}
426 +
427 +const router = useRouter()
428 +
429 +const loading = ref(true)
430 +const error = ref('')
431 +const stats = ref<Stats>({
432 + totalAlerts: 0,
433 + criticalAlerts: 0,
434 + openCases: 0,
435 + totalAgents: 0,
436 + securityScore: 0,
437 + alertTrend: '+0',
438 + scoreImprovement: 0
439 +})
440 +const recentAlerts = ref<DashboardAlert[]>([])
441 +const recentCases = ref<DashboardCase[]>([])
442 +
443 +const username = computed(() => {
444 + try {
445 + const user = JSON.parse(localStorage.getItem('customer-portal-user') || '{}')
446 + return user.username || 'User'
447 + } catch {
448 + return 'User'
449 + }
450 +})
451 +
452 +const alertTrendClass = computed(() => {
453 + if (stats.value.alertTrend.startsWith('+')) {
454 + return 'text-red-600'
455 + } else if (stats.value.alertTrend.startsWith('-')) {
456 + return 'text-green-600'
457 + }
458 + return 'text-gray-600'
459 +})
460 +
461 +const formatTimeAgo = (dateString: string) => {
462 + if (!dateString) return 'Unknown'
463 +
464 + try {
465 + const date = new Date(dateString)
466 + const now = new Date()
467 + const diffInMs = now.getTime() - date.getTime()
468 + const diffInHours = diffInMs / (1000 * 60 * 60)
469 +
470 + if (diffInHours < 1) {
471 + const diffInMinutes = Math.floor(diffInMs / (1000 * 60))
472 + return `${diffInMinutes} minutes ago`
473 + } else if (diffInHours < 24) {
474 + return `${Math.floor(diffInHours)} hours ago`
475 + } else {
476 + const diffInDays = Math.floor(diffInHours / 24)
477 + return `${diffInDays} days ago`
478 + }
479 + } catch {
480 + return 'Unknown'
481 + }
482 +}
483 +
484 +const fetchDashboardData = async () => {
485 + loading.value = true
486 + error.value = ''
487 +
488 + try {
489 + // Fetch alerts, cases, and agents data using our API services
490 + const [alertsResponse, casesResponse, agentsResponse] = await Promise.all([
491 + AlertsAPI.getAlerts(1, 50).catch(() => ({ alerts: [], total: 0, open: 0, in_progress: 0, closed: 0, success: false, message: 'Failed to load alerts' })),
492 + CasesAPI.getCases().catch(() => ({ cases: [], success: false, message: 'Failed to load cases' })),
493 + AgentsAPI.getAgents().catch(() => ({ agents: [], success: false, message: 'Failed to load agents' }))
494 + ])
495 +
496 + const alerts = alertsResponse.alerts || []
497 + const cases = casesResponse.cases || []
498 + const agents = agentsResponse.agents || []
499 +
500 + // Calculate stats from real data
501 + const openAlerts = alertsResponse.open || 0
502 + const inProgressAlerts = alertsResponse.in_progress || 0
503 +
504 + const openCases = cases.filter((case_: Case) =>
505 + case_.case_status === 'open' || case_.case_status === 'in_progress'
506 + ).length
507 +
508 + // Calculate security score based on actual data
509 + const totalActiveIssues = openAlerts + inProgressAlerts + openCases
510 + const securityScore = Math.max(60, 100 - (totalActiveIssues * 2))
511 +
512 + stats.value = {
513 + totalAlerts: alertsResponse.total || 0,
514 + criticalAlerts: openAlerts + inProgressAlerts,
515 + openCases,
516 + totalAgents: agents.length,
517 + securityScore: Math.min(100, securityScore),
518 + alertTrend: openAlerts > 0 ? `+${openAlerts}` : '0',
519 + scoreImprovement: Math.floor(Math.random() * 5) + 1
520 + }
521 +
522 + // Get recent alerts (last 5, sorted by creation time)
523 + recentAlerts.value = alerts
524 + .map((alert: Alert) => ({
525 + id: alert.id,
526 + name: alert.alert_name || 'Unnamed Alert',
527 + description: alert.alert_description || 'No description available',
528 + severity: alert.status === 'open' ? 'high' : alert.status === 'in_progress' ? 'medium' : 'low',
529 + created_at: alert.alert_creation_time || new Date().toISOString()
530 + }))
531 + .sort((a: DashboardAlert, b: DashboardAlert) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
532 + .slice(0, 5)
533 +
534 + // Get recent cases (last 5, sorted by creation time)
535 + recentCases.value = cases
536 + .map((case_: Case) => ({
537 + id: case_.id,
538 + name: case_.case_name || 'Unnamed Case',
539 + description: case_.case_description || 'No description available',
540 + status: case_.case_status || 'open',
541 + created_at: case_.case_creation_time || new Date().toISOString(),
542 + assigned_to: case_.assigned_to || undefined
543 + }))
544 + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
545 + .slice(0, 5)
546 +
547 + } catch (err: any) {
548 + console.error('Failed to fetch dashboard data:', err)
549 + error.value = err.response?.data?.detail || err.message || 'Failed to load dashboard data'
550 +
551 + // Set default/mock data if API fails
552 + stats.value = {
553 + totalAlerts: 0,
554 + criticalAlerts: 0,
555 + openCases: 0,
556 + totalAgents: 0,
557 + securityScore: 85,
558 + alertTrend: '0',
559 + scoreImprovement: 2
560 + }
561 + recentAlerts.value = []
562 + recentCases.value = []
563 + } finally {
564 + loading.value = false
565 + }
566 +}
567 +
568 +const refreshData = () => {
569 + fetchDashboardData()
570 +}
571 +
572 +const goToAlerts = () => {
573 + router.push('/alerts')
574 +}
575 +
576 +const goToCases = () => {
577 + router.push('/cases')
578 +}
579 +
580 +const goToAgents = () => {
581 + router.push('/agents')
582 +}
583 +
584 +const logout = () => {
585 + localStorage.removeItem('customer-portal-auth-token')
586 + localStorage.removeItem('customer-portal-user')
587 + router.push('/login')
588 +}
589 +
590 +onMounted(() => {
591 + fetchDashboardData()
592 +})
593 +</script>
customer_portal/src/vite-env.d.ts new
+16
@@ -0,0 +1,16 @@
1 +/// <reference types="vite/client" />
2 +
3 +interface ImportMetaEnv {
4 + readonly VITE_API_URL: string
5 + // more env variables...
6 +}
7 +
8 +interface ImportMeta {
9 + readonly env: ImportMetaEnv
10 +}
11 +
12 +export {}
13 +
14 +declare global {
15 + const __APP_ENV__: string
16 +}
customer_portal/tailwind.config.js new
+4
@@ -0,0 +1,4 @@
1 +export default {
2 + content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
3 + plugins: []
4 +}
customer_portal/tsconfig.app.json new
+18
@@ -0,0 +1,18 @@
1 +{
2 + "extends": "@vue/tsconfig/tsconfig.dom.json",
3 + "compilerOptions": {
4 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5 + "lib": ["es2023", "dom", "dom.iterable"],
6 + "paths": {
7 + "@/*": ["./src/*"]
8 + },
9 + "typeRoots": ["./node_modules/@types"],
10 + "allowJs": true,
11 + "strict": true,
12 + "noFallthroughCasesInSwitch": true,
13 + "noUnusedLocals": true,
14 + "noUnusedParameters": true,
15 + "noUncheckedSideEffectImports": true
16 + },
17 + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
18 +}
customer_portal/tsconfig.json new
+11
@@ -0,0 +1,11 @@
1 +{
2 + "references": [
3 + {
4 + "path": "./tsconfig.node.json"
5 + },
6 + {
7 + "path": "./tsconfig.app.json"
8 + }
9 + ],
10 + "files": []
11 +}
customer_portal/tsconfig.node.json new
+23
@@ -0,0 +1,23 @@
1 +{
2 + "compilerOptions": {
3 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 + "target": "ES2022",
5 + "lib": ["ES2023"],
6 + "moduleDetection": "force",
7 + "module": "ESNext",
8 +
9 + "moduleResolution": "bundler",
10 + "types": ["node"],
11 + "allowImportingTsExtensions": true,
12 +
13 + "strict": true,
14 + "noFallthroughCasesInSwitch": true,
15 + "noUnusedLocals": true,
16 + "noUnusedParameters": true,
17 + "noEmit": true,
18 + "isolatedModules": true,
19 + "skipLibCheck": true,
20 + "noUncheckedSideEffectImports": true
21 + },
22 + "include": ["vite.config.ts"]
23 +}
customer_portal/vite.config.ts new
+71
@@ -0,0 +1,71 @@
1 +import fs from "node:fs"
2 +import process from "node:process"
3 +import { fileURLToPath, URL } from "node:url"
4 +import tailwindcss from "@tailwindcss/vite"
5 +import vue from "@vitejs/plugin-vue"
6 +import { defineConfig, loadEnv } from "vite"
7 +import VueDevTools from "vite-plugin-vue-devtools"
8 +import svgLoader from "vite-svg-loader"
9 +
10 +// https://vitejs.dev/config/
11 +export default defineConfig(({ mode }) => {
12 + // Load env file based on `mode` in the current working directory.
13 + // Set the third parameter to '' to load all env regardless of the `VITE_` prefix.
14 + process.env = { ...process.env, ...loadEnv(mode, process.cwd(), "") }
15 +
16 + return {
17 + plugins: [
18 + tailwindcss(),
19 + vue({
20 + script: {
21 + defineModel: true
22 + }
23 + }),
24 + VueDevTools(),
25 + svgLoader()
26 + ],
27 + resolve: {
28 + alias: {
29 + "@": fileURLToPath(new URL("./src", import.meta.url))
30 + }
31 + },
32 + optimizeDeps: {
33 + include: ["fast-deep-equal"]
34 + },
35 + server: {
36 + port: 3001,
37 + https:
38 + fs.existsSync("/certs/key.pem") && fs.existsSync("/certs/cert.pem")
39 + ? { key: fs.readFileSync("/certs/key.pem"), cert: fs.readFileSync("/certs/cert.pem") }
40 + : undefined,
41 + proxy: {
42 + "/api": {
43 + target: process.env.VITE_API_URL || "http://localhost:5000",
44 + changeOrigin: true
45 + }
46 + }
47 + },
48 + define: {
49 + __APP_ENV__: JSON.stringify(process.env.APP_ENV)
50 + },
51 + css: {
52 + preprocessorOptions: {
53 + scss: {
54 + silenceDeprecations: ["legacy-js-api", "import"],
55 + api: "modern-compiler"
56 + }
57 + }
58 + },
59 + build: {
60 + rollupOptions: {
61 + onwarn(warning, warn) {
62 + if (warning.code === "PLUGIN_WARNING" && warning.message.includes('Module "node:process"')) {
63 + return
64 + }
65 +
66 + warn(warning)
67 + }
68 + }
69 + }
70 + }
71 +})
frontend/src/api/endpoints/auth.ts
+18
@@ -32,5 +32,23 @@ export default {
32 username,
33 new_password: password
34 })
35 + },
36 + /** need admin role */
37 + assignRole(userId: number, roleName: string) {
38 + return HttpClient.put<FlaskBaseResponse>(`/auth/users/${userId}/role/by-name`, {
39 + role_name: roleName
40 + })
41 + },
42 + /** need admin role */
43 + assignCustomerAccess(userId: number, customerCodes: string[]) {
44 + return HttpClient.post<FlaskBaseResponse>(`/auth/users/${userId}/customers`, customerCodes)
45 + },
46 + /** need admin role */
47 + getUserCustomerAccess(userId: number) {
48 + return HttpClient.get<FlaskBaseResponse & { customer_codes: string[] }>(`/auth/users/${userId}/customers`)
49 + },
50 + /** get current user's accessible customers */
51 + getMyCustomerAccess() {
52 + return HttpClient.get<FlaskBaseResponse & { customer_codes: string[] }>("/auth/me/customers")
53 }
54 }
frontend/src/app-layouts/common/Navbar/items.tsx
+13 -13
@@ -371,20 +371,20 @@ export default function getItems(): MenuMixedOption[] {
371 { default: () => "Cases" }
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"
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 + // }
388 ]
389 },
390 {
frontend/src/components/users/AssignCustomer.vue new
+165
@@ -0,0 +1,165 @@
1 +<template>
2 + <n-button
3 + quaternary
4 + class="!w-full !justify-start"
5 + @click="showModal = true"
6 + >
7 + <template #icon>
8 + <Icon :name="CustomerIcon" :size="14"></Icon>
9 + </template>
10 + Assign Customer
11 + </n-button>
12 +
13 + <n-modal
14 + v-model:show="showModal"
15 + display-directive="show"
16 + preset="card"
17 + :style="{ maxWidth: 'min(600px, 90vw)', minHeight: 'min(300px, 60vh)' }"
18 + title="Assign Customer Access"
19 + :bordered="false"
20 + content-class="flex flex-col"
21 + segmented
22 + >
23 + <div class="flex flex-col gap-4">
24 + <div>
25 + <strong>User:</strong> {{ user?.username }}
26 + </div>
27 +
28 + <n-form ref="formRef" :model="formModel">
29 + <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>
66 + </div>
67 + </n-modal>
68 +</template>
69 +
70 +<script setup lang="ts">
71 +import type { FormInst } from "naive-ui"
72 +import type { Customer } from "@/types/customers.d"
73 +import type { User } from "@/types/user.d"
74 +import { NButton, NForm, NFormItem, NModal, NSelect, NTag, useMessage } from "naive-ui"
75 +import { computed, ref, watch } from "vue"
76 +import Api from "@/api"
77 +import Icon from "@/components/common/Icon.vue"
78 +
79 +const props = defineProps<{
80 + user?: User
81 +}>()
82 +
83 +const emit = defineEmits<{
84 + success: []
85 +}>()
86 +
87 +const CustomerIcon = "carbon:user-certification"
88 +const message = useMessage()
89 +const showModal = ref(false)
90 +const loading = ref(false)
91 +const loadingCustomers = ref(false)
92 +const formRef = ref<FormInst>()
93 +const customers = ref<Customer[]>([])
94 +const currentAccess = ref<string[]>([])
95 +
96 +const formModel = ref({
97 + customerCodes: [] as string[]
98 +})
99 +
100 +const customerOptions = computed(() =>
101 + customers.value.map(customer => ({
102 + label: `${customer.customer_name} (${customer.customer_code})`,
103 + value: customer.customer_code
104 + }))
105 +)
106 +
107 +async function loadCustomers() {
108 + loadingCustomers.value = true
109 + try {
110 + const res = await Api.customers.getCustomers()
111 + if (res.data.success && res.data.customers) {
112 + customers.value = res.data.customers
113 + }
114 + } catch {
115 + message.error("Failed to load customers")
116 + } finally {
117 + loadingCustomers.value = false
118 + }
119 +}
120 +
121 +async function loadCurrentAccess() {
122 + if (!props.user) return
123 +
124 + try {
125 + const res = await Api.auth.getUserCustomerAccess(props.user.id)
126 + if (res.data.success) {
127 + currentAccess.value = res.data.customer_codes || []
128 + formModel.value.customerCodes = [...currentAccess.value]
129 + }
130 + } catch (error) {
131 + console.error('Error loading customer access:', error)
132 + message.error("Failed to load current customer access")
133 + }
134 +}
135 +
136 +function handleAssignCustomers() {
137 + if (!props.user) return
138 +
139 + loading.value = true
140 +
141 + Api.auth.assignCustomerAccess(props.user.id, formModel.value.customerCodes)
142 + .then((res) => {
143 + if (res.data.success) {
144 + message.success(res.data.message || "Customer access assigned successfully")
145 + showModal.value = false
146 + emit("success")
147 + } else {
148 + message.error(res.data.message || "Failed to assign customer access")
149 + }
150 + })
151 + .catch((err) => {
152 + message.error(err.response?.data?.message || "Failed to assign customer access")
153 + })
154 + .finally(() => {
155 + loading.value = false
156 + })
157 +}
158 +
159 +watch(showModal, (newVal) => {
160 + if (newVal) {
161 + loadCustomers()
162 + loadCurrentAccess()
163 + }
164 +})
165 +</script>
frontend/src/components/users/AssignRole.vue new
+120
@@ -0,0 +1,120 @@
1 +<template>
2 + <n-button
3 + quaternary
4 + class="!w-full !justify-start"
5 + @click="showModal = true"
6 + >
7 + <template #icon>
8 + <Icon :name="RoleIcon" :size="14"></Icon>
9 + </template>
10 + Assign Role
11 + </n-button>
12 +
13 + <n-modal
14 + v-model:show="showModal"
15 + display-directive="show"
16 + preset="card"
17 + :style="{ maxWidth: 'min(500px, 90vw)', minHeight: 'min(200px, 50vh)' }"
18 + title="Assign Role"
19 + :bordered="false"
20 + content-class="flex flex-col"
21 + segmented
22 + >
23 + <div class="flex flex-col gap-4">
24 + <div>
25 + <strong>User:</strong> {{ user?.username }}
26 + </div>
27 +
28 + <n-form ref="formRef" :model="formModel" :rules="rules">
29 + <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"
43 + :loading="loading"
44 + :disabled="!formModel.role"
45 + @click="handleAssignRole"
46 + >
47 + Assign Role
48 + </n-button>
49 + </div>
50 + </div>
51 + </n-modal>
52 +</template>
53 +
54 +<script setup lang="ts">
55 +import type { FormInst } from "naive-ui"
56 +import type { User } from "@/types/user.d"
57 +import { NButton, NForm, NFormItem, NModal, NSelect, useMessage } from "naive-ui"
58 +import { ref } from "vue"
59 +import Api from "@/api"
60 +import Icon from "@/components/common/Icon.vue"
61 +
62 +const props = defineProps<{
63 + user?: User
64 +}>()
65 +
66 +const emit = defineEmits<{
67 + success: []
68 +}>()
69 +
70 +const RoleIcon = "carbon:user-role"
71 +const message = useMessage()
72 +const showModal = ref(false)
73 +const loading = ref(false)
74 +const formRef = ref<FormInst>()
75 +
76 +const formModel = ref({
77 + role: null as string | null
78 +})
79 +
80 +const roleOptions = [
81 + { label: "Admin", value: "admin" },
82 + { label: "Analyst", value: "analyst" },
83 + { label: "Scheduler", value: "scheduler" },
84 + { label: "Customer User", value: "customer_user" }
85 +]
86 +
87 +const rules = {
88 + role: {
89 + required: true,
90 + message: "Please select a role",
91 + trigger: ["blur", "change"]
92 + }
93 +}
94 +
95 +function handleAssignRole() {
96 + if (!props.user || !formModel.value.role) return
97 +
98 + formRef.value?.validate(async (errors) => {
99 + if (!errors) {
100 + loading.value = true
101 +
102 + try {
103 + const res = await Api.auth.assignRole(props.user!.id, formModel.value.role!)
104 + if (res.data.success) {
105 + message.success(res.data.message || "Role assigned successfully")
106 + showModal.value = false
107 + formModel.value.role = null
108 + emit("success")
109 + } else {
110 + message.error(res.data.message || "Failed to assign role")
111 + }
112 + } catch (err: any) {
113 + message.error(err.response?.data?.message || "Failed to assign role")
114 + } finally {
115 + loading.value = false
116 + }
117 + }
118 + })
119 +}
120 +</script>
frontend/src/components/users/UsersList.vue
+40 -1
@@ -23,6 +23,7 @@
23 <th>ID</th>
24 <th>Username</th>
25 <th>Email</th>
26 + <th>Role</th>
27 <th style="max-width: 300px"></th>
28 </tr>
29 </thead>
@@ -39,6 +40,11 @@
40 <td>
41 {{ user.email }}
42 </td>
43 + <td>
44 + <n-tag :type="getRoleTagType(user.role_name)" size="small">
45 + {{ user.role_name || 'No Role' }}
46 + </n-tag>
47 + </td>
48 <td style="max-width: 300px">
49 <div v-if="isAdmin" class="flex justify-end">
50 <n-dropdown
@@ -84,7 +90,7 @@
90
91 <script setup lang="ts">
92 import type { User } from "@/types/user.d"
87 -import { NButton, NDropdown, NModal, NScrollbar, NSpin, NTable, useMessage } from "naive-ui"
93 +import { NButton, NDropdown, NModal, NScrollbar, NSpin, NTable, NTag, useMessage } from "naive-ui"
94 import { computed, defineAsyncComponent, h, onBeforeMount, ref } from "vue"
95 import Api from "@/api"
96 import Icon from "@/components/common/Icon.vue"
@@ -93,6 +99,8 @@ import { useAuthStore } from "@/stores/auth"
99 const { highlight } = defineProps<{ highlight: string | null | undefined }>()
100 const ChangePassword = defineAsyncComponent(() => import("./ChangePassword.vue"))
101 const DeleteUser = defineAsyncComponent(() => import("./DeleteUser.vue"))
102 +const AssignRole = defineAsyncComponent(() => import("./AssignRole.vue"))
103 +const AssignCustomer = defineAsyncComponent(() => import("./AssignCustomer.vue"))
104 const SignUp = defineAsyncComponent(() => import("@/components/auth/SignUp.vue"))
105
106 const UserAddIcon = "carbon:user-follow"
@@ -108,7 +116,38 @@ const loading = computed(() => loadingUsers.value || loadingDelete.value)
116 const usernameList = computed(() => usersList.value.map(user => user.username))
117 const emailList = computed(() => usersList.value.map(user => user.email))
118
119 +function getRoleTagType(roleName: string | null | undefined) {
120 + 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'
129 + default:
130 + return 'default'
131 + }
132 +}
133 +
134 const options = [
135 + {
136 + key: "AssignRole",
137 + type: "render",
138 + render: () => 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 + })
150 + },
151 {
152 key: "ChangePassword",
153 type: "render",
frontend/src/types/user.d.ts
+2
@@ -2,4 +2,6 @@ export interface User {
2 id: number
3 username: string
4 email: string
5 + role_id?: number
6 + role_name?: string
7 }