Merge branch 'cross-provider-compliance-view-ui-side' of github.com:prowler-cloud/prowler into cross-provider-compliance-view-ui-side

This commit is contained in:
pedrooot committed 2026-07-07 11:01:09 +02:00
commit 86ced80205
178 files changed
+11349 -685

No files matched your search

+10 -1
View File
@@ -2,11 +2,20 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.33.1] (Prowler UNRELEASED)
## [1.34.0] (Prowler UNRELEASED)
### 🚀 Added
- Compliance PDF reports no longer require provider credentials: findings are enriched from the provider metadata stored in the database, so reports generate even after the provider secret is deleted or its credentials become invalid [(#11845)](https://github.com/prowler-cloud/prowler/pull/11845)
---
## [1.33.1] (Prowler v5.32.1)
### 🐞 Fixed
- Attack Paths: Scan rows now have database defaults for `is_migrated` and `sink_backend` so `scan-perform-scheduled` inserts survive deploy skew [(#11826)](https://github.com/prowler-cloud/prowler/pull/11826)
- Invited users now keep their invitation context when completing authentication with Google, GitHub, or SAML, so the invitation is accepted during login [(#11752)](https://github.com/prowler-cloud/prowler/pull/11752)
### 🔐 Security
+47 -21
View File
@@ -9,6 +9,7 @@ from api.models import (
User,
UserRoleRelationship,
)
from api.utils import accept_invitation_for_user
from django.db import transaction
@@ -20,6 +21,22 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
except User.DoesNotExist:
return None
@staticmethod
def _get_invitation_token(request):
for source_name in ("data", "POST"):
data = getattr(request, source_name, None) or {}
if not hasattr(data, "get"):
continue
invitation_token = data.get("invitation_token")
if invitation_token:
return invitation_token
wrapped_request = getattr(request, "_request", None)
if wrapped_request and wrapped_request is not request:
return ProwlerSocialAccountAdapter._get_invitation_token(wrapped_request)
return None
def pre_social_login(self, request, sociallogin):
# Link existing accounts with the same email address
email = sociallogin.account.extra_data.get("email")
@@ -83,29 +100,38 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
user.name = social_account_name
user.save(using=MainRouter.admin_db)
tenant = Tenant.objects.using(MainRouter.admin_db).create(
name=f"{user.email.split('@')[0]} default tenant"
)
with rls_transaction(str(tenant.id)):
Membership.objects.using(MainRouter.admin_db).create(
user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
)
role = Role.objects.using(MainRouter.admin_db).create(
name="admin",
tenant_id=tenant.id,
manage_users=True,
manage_account=True,
manage_billing=True,
manage_providers=True,
manage_integrations=True,
manage_scans=True,
unlimited_visibility=True,
)
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
invitation_token = self._get_invitation_token(request)
if invitation_token:
invitation, _ = accept_invitation_for_user(
user=user,
role=role,
tenant_id=tenant.id,
invitation_token=invitation_token,
)
request.prowler_invitation_token = invitation_token
request.prowler_invitation_tenant_id = str(invitation.tenant_id)
else:
tenant = Tenant.objects.using(MainRouter.admin_db).create(
name=f"{user.email.split('@')[0]} default tenant"
)
with rls_transaction(str(tenant.id)):
Membership.objects.using(MainRouter.admin_db).create(
user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
)
role = Role.objects.using(MainRouter.admin_db).create(
name="admin",
tenant_id=tenant.id,
manage_users=True,
manage_account=True,
manage_billing=True,
manage_providers=True,
manage_integrations=True,
manage_scans=True,
unlimited_visibility=True,
)
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user,
role=role,
tenant_id=tenant.id,
)
else:
request.session["saml_user_created"] = str(user.id)
+39 -1
View File
@@ -5,7 +5,7 @@ import pytest
from allauth.socialaccount.models import SocialLogin
from api.adapters import ProwlerSocialAccountAdapter
from api.db_router import MainRouter
from api.models import SAMLConfiguration
from api.models import Invitation, Membership, SAMLConfiguration, Tenant
from django.contrib.auth import get_user_model
User = get_user_model()
@@ -188,6 +188,44 @@ class TestProwlerSocialAccountAdapter:
_, called_user = call_args[0]
assert called_user.email == create_test_user.email
def test_save_user_social_with_invitation_joins_invited_tenant(
self, rf, create_test_user, tenants_fixture
):
adapter = ProwlerSocialAccountAdapter()
invited_tenant = tenants_fixture[2]
invited_email = "frank-invited@example.com"
invitation = Invitation.objects.create(
tenant=invited_tenant,
email=invited_email,
inviter=create_test_user,
)
request = rf.post("/", data={"invitation_token": invitation.token})
request.session = {}
sociallogin = MagicMock(spec=SocialLogin)
sociallogin.provider = MagicMock()
sociallogin.provider.id = "google"
sociallogin.account = MagicMock()
sociallogin.account.extra_data = {"name": "Frank"}
real_user = User.objects.create_user(
name="Frank", email=invited_email, password="Secret123!"
)
tenants_before = Tenant.objects.count()
with patch("api.adapters.super") as mock_super:
mock_super.return_value.save_user.return_value = real_user
adapter.save_user(request, sociallogin)
invitation.refresh_from_db()
assert invitation.state == Invitation.State.ACCEPTED
assert Tenant.objects.count() == tenants_before
assert Membership.objects.filter(
user=real_user,
tenant=invited_tenant,
role=Membership.RoleChoices.MEMBER,
).exists()
def test_save_user_saml_sets_session_flag(self, rf):
adapter = ProwlerSocialAccountAdapter()
request = rf.get("/")
+107 -25
View File
@@ -59,7 +59,11 @@ from api.models import (
from api.rls import Tenant
from api.uuid_utils import datetime_to_uuid7
from api.v1.serializers import TokenSerializer
from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView
from api.v1.views import (
ComplianceOverviewViewSet,
CustomSAMLLoginView,
TenantFinishACSView,
)
from botocore.exceptions import ClientError, NoCredentialsError
from conftest import (
API_JSON_CONTENT_TYPE,
@@ -8598,16 +8602,14 @@ class TestInvitationViewSet:
expires_at=self.TOMORROW,
)
data = {
"invitation_token": invitation.token,
}
data = {"invitation_token": invitation.token}
assert not Membership.objects.filter(
user__email__iexact=user.email, tenant=tenant
).exists()
response = authenticated_client.post(
reverse("invitation-accept"), data=data, format="json"
reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_201_CREATED
invitation.refresh_from_db()
@@ -8616,13 +8618,46 @@ class TestInvitationViewSet:
).exists()
assert invitation.state == Invitation.State.ACCEPTED.value
def test_invitations_accept_invitation_invalid_token(self, authenticated_client):
data = {
"invitation_token": "invalid_token",
}
def test_invitations_accept_invitation_existing_membership(
self,
authenticated_client,
create_test_user,
tenants_fixture,
):
*_, tenant = tenants_fixture
user = create_test_user
invitation = Invitation.objects.create(
tenant=tenant,
email=TEST_USER,
inviter=user,
expires_at=self.TOMORROW,
)
Membership.objects.create(user=user, tenant=tenant)
data = {"invitation_token": invitation.token}
response = authenticated_client.post(
reverse("invitation-accept"), data=data, format="json"
reverse("invitation-accept"),
data=data,
format="vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
invitation.refresh_from_db()
assert invitation.state == Invitation.State.ACCEPTED.value
assert (
Membership.objects.filter(
user__email__iexact=user.email, tenant=tenant
).count()
== 1
)
def test_invitations_accept_invitation_invalid_token(self, authenticated_client):
data = {"invitation_token": "invalid_token"}
response = authenticated_client.post(
reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -8636,12 +8671,10 @@ class TestInvitationViewSet:
invitation.email = TEST_USER
invitation.save()
data = {
"invitation_token": invitation.token,
}
data = {"invitation_token": invitation.token}
response = authenticated_client.post(
reverse("invitation-accept"), data=data, format="json"
reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_410_GONE
@@ -8677,12 +8710,10 @@ class TestInvitationViewSet:
invitation.email = TEST_USER
invitation.save()
data = {
"invitation_token": invitation.token,
}
data = {"invitation_token": invitation.token}
response = authenticated_client.post(
reverse("invitation-accept"), data=data, format="json"
reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -8700,12 +8731,10 @@ class TestInvitationViewSet:
invitation.email = TEST_USER
invitation.save()
data = {
"invitation_token": invitation.token,
}
data = {"invitation_token": invitation.token}
response = authenticated_client.post(
reverse("invitation-accept"), data=data, format="json"
reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -13697,6 +13726,26 @@ class TestSAMLTokenValidation:
assert response2.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
class TestCustomSAMLLoginView:
def test_dispatch_clears_stale_callback_url_when_request_has_none(self):
request = RequestFactory().get("/api/v1/saml/login/testtenant/")
request.session = {
"saml_callback_url": "/invitation/accept?invitation_token=old-token"
}
with patch(
"allauth.socialaccount.providers.saml.views.LoginView.dispatch",
return_value=JsonResponse({}),
):
response = CustomSAMLLoginView.as_view()(
request, organization_slug="testtenant"
)
assert response.status_code == status.HTTP_200_OK
assert "saml_callback_url" not in request.session
@pytest.mark.django_db
class TestSAMLInitiateAPIView:
def test_valid_email_domain_and_certificates(
@@ -13708,7 +13757,7 @@ class TestSAMLInitiateAPIView:
url = reverse("api_saml_initiate")
payload = {"email_domain": saml_setup["email"]}
response = authenticated_client.post(url, data=payload, format="json")
response = authenticated_client.post(url, data=payload, format="vnd.api+json")
assert response.status_code == status.HTTP_302_FOUND
assert (
@@ -13717,11 +13766,42 @@ class TestSAMLInitiateAPIView:
)
assert "SAMLRequest" not in response.url
def test_valid_email_domain_preserves_safe_callback_url(
self, authenticated_client, saml_setup
):
url = reverse("api_saml_initiate")
callback_url = "/invitation/accept?invitation_token=test-token"
payload = {
"email_domain": saml_setup["email"],
"callback_url": callback_url,
}
response = authenticated_client.post(url, data=payload, format="vnd.api+json")
assert response.status_code == status.HTTP_302_FOUND
query_params = parse_qs(urlparse(response.url).query)
assert query_params["callback_url"] == [callback_url]
def test_valid_email_domain_rejects_external_callback_url(
self, authenticated_client, saml_setup
):
url = reverse("api_saml_initiate")
payload = {
"email_domain": saml_setup["email"],
"callback_url": "https://attacker.example/invitation",
}
response = authenticated_client.post(url, data=payload, format="vnd.api+json")
assert response.status_code == status.HTTP_302_FOUND
query_params = parse_qs(urlparse(response.url).query)
assert "callback_url" not in query_params
def test_invalid_email_domain(self, authenticated_client):
url = reverse("api_saml_initiate")
payload = {"email_domain": "user@unauthorized.com"}
response = authenticated_client.post(url, data=payload, format="json")
response = authenticated_client.post(url, data=payload, format="vnd.api+json")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["errors"]["detail"] == "Unauthorized domain."
@@ -13904,7 +13984,8 @@ class TestTenantFinishACSView:
)
)
request.user = user
request.session = {}
callback_url = "/invitation/accept?invitation_token=test-token"
request.session = {"saml_callback_url": callback_url}
with (
patch(
@@ -13946,6 +14027,7 @@ class TestTenantFinishACSView:
assert parsed_url.netloc == expected_callback_host
query_params = parse_qs(parsed_url.query)
assert "id" in query_params
assert query_params["callbackUrl"] == [callback_url]
token_id = query_params["id"][0]
token_obj = SAMLToken.objects.get(id=token_id)
+40 -1
View File
@@ -7,9 +7,19 @@ from allauth.socialaccount.providers.oauth2.client import OAuth2Client
from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import InvitationTokenExpiredException
from api.models import Integration, Invitation, Processor, Provider, Resource
from api.models import (
Integration,
Invitation,
Membership,
Processor,
Provider,
Resource,
Role,
UserRoleRelationship,
)
from api.v1.serializers import FindingMetadataSerializer
from django.contrib.postgres.aggregates import ArrayAgg
from django.db import transaction
from django.db.models import Subquery
from prowler.lib.outputs.jira.jira import Jira, JiraBasicAuthError
from prowler.providers.aws.lib.s3.s3 import S3
@@ -538,6 +548,35 @@ def validate_invitation(
return invitation
def accept_invitation_for_user(
*, user, invitation_token: str, raise_not_found: bool = False
):
with transaction.atomic(using=MainRouter.admin_db):
invitation = validate_invitation(
invitation_token, user.email, raise_not_found=raise_not_found
)
with rls_transaction(str(invitation.tenant_id), using=MainRouter.admin_db):
membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create(
user=user,
tenant=invitation.tenant,
defaults={"role": Membership.RoleChoices.MEMBER},
)
invitation_roles = Role.objects.using(MainRouter.admin_db).filter(
invitations=invitation
)
for role in invitation_roles:
UserRoleRelationship.objects.using(MainRouter.admin_db).get_or_create(
user=user,
role=role,
defaults={"tenant": invitation.tenant},
)
invitation.state = Invitation.State.ACCEPTED
invitation.save(using=MainRouter.admin_db)
return invitation, membership
# ToRemove after removing the fallback mechanism in /findings/metadata
def get_findings_metadata_no_aggregations(tenant_id: str, filtered_queryset):
filtered_ids = filtered_queryset.order_by().values("id")
+3
View File
@@ -3147,6 +3147,9 @@ class ProcessorUpdateSerializer(BaseWriteSerializer):
class SamlInitiateSerializer(BaseSerializerV1):
email_domain = serializers.CharField()
callback_url = serializers.CharField(
required=False, allow_blank=True, max_length=2048
)
class JSONAPIMeta:
resource_name = "saml-initiate"
+71 -20
View File
@@ -9,7 +9,7 @@ from collections import defaultdict
from copy import deepcopy
from datetime import UTC, datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from urllib.parse import urljoin
from urllib.parse import urlencode, urljoin
import sentry_sdk
from allauth.socialaccount.models import SocialAccount, SocialApp
@@ -129,6 +129,7 @@ from api.renderers import APIJSONRenderer, PlainTextRenderer
from api.rls import Tenant
from api.utils import (
CustomOAuth2Client,
accept_invitation_for_user,
get_findings_metadata_no_aggregations,
initialize_prowler_integration,
initialize_prowler_provider,
@@ -542,6 +543,46 @@ class SchemaView(SpectacularAPIView):
return super().get(request, *args, **kwargs)
SAML_CALLBACK_SESSION_KEY = "saml_callback_url"
def _safe_callback_path(value):
if not value or not isinstance(value, str):
return None
if not value.startswith("/") or value.startswith("//"):
return None
return value
def _get_request_invitation_token(request):
for source_name in ("data", "POST"):
data = getattr(request, source_name, None) or {}
if not hasattr(data, "get"):
continue
invitation_token = data.get("invitation_token")
if invitation_token:
return invitation_token
wrapped_request = getattr(request, "_request", None)
if wrapped_request and wrapped_request is not request:
return _get_request_invitation_token(wrapped_request)
return None
def _accept_social_invitation(request, user):
invitation_token = _get_request_invitation_token(request)
tenant_id = getattr(request, "prowler_invitation_tenant_id", None)
if invitation_token and not tenant_id:
invitation, _ = accept_invitation_for_user(
user=user,
invitation_token=invitation_token,
raise_not_found=True,
)
tenant_id = str(invitation.tenant_id)
return tenant_id
@extend_schema(exclude=True)
class GoogleSocialLoginView(SocialLoginView):
adapter_class = GoogleOAuth2Adapter
@@ -552,7 +593,11 @@ class GoogleSocialLoginView(SocialLoginView):
original_response = super().get_response()
if self.user and self.user.is_authenticated:
serializer = TokenSocialLoginSerializer(data={"email": self.user.email})
tenant_id = _accept_social_invitation(self.request, self.user)
serializer_data = {"email": self.user.email}
if tenant_id:
serializer_data["tenant_id"] = tenant_id
serializer = TokenSocialLoginSerializer(data=serializer_data)
try:
serializer.is_valid(raise_exception=True)
except TokenError as e:
@@ -577,7 +622,11 @@ class GithubSocialLoginView(SocialLoginView):
original_response = super().get_response()
if self.user and self.user.is_authenticated:
serializer = TokenSocialLoginSerializer(data={"email": self.user.email})
tenant_id = _accept_social_invitation(self.request, self.user)
serializer_data = {"email": self.user.email}
if tenant_id:
serializer_data["tenant_id"] = tenant_id
serializer = TokenSocialLoginSerializer(data=serializer_data)
try:
serializer.is_valid(raise_exception=True)
@@ -637,6 +686,10 @@ class CustomSAMLLoginView(LoginView):
This approach maintains security while providing better UX.
"""
callback_url = _safe_callback_path(request.GET.get("callback_url"))
request.session.pop(SAML_CALLBACK_SESSION_KEY, None)
if callback_url:
request.session[SAML_CALLBACK_SESSION_KEY] = callback_url
if request.method == "GET":
# Convert GET to POST while preserving parameters
request.method = "POST"
@@ -681,6 +734,11 @@ class SAMLInitiateAPIView(GenericAPIView):
"saml_login", kwargs={"organization_slug": config.email_domain}
)
login_url = urljoin(api_host, login_path)
callback_url = _safe_callback_path(
serializer.validated_data.get("callback_url")
)
if callback_url:
login_url = f"{login_url}?{urlencode({'callback_url': callback_url})}"
return redirect(login_url)
@@ -896,7 +954,13 @@ class TenantFinishACSView(FinishACSView):
token=token_data, user=user
)
callback_url = env.str("SAML_SSO_CALLBACK_URL")
redirect_url = f"{callback_url}?id={saml_token.id}"
redirect_params = {"id": str(saml_token.id)}
saml_callback_url = _safe_callback_path(
request.session.pop(SAML_CALLBACK_SESSION_KEY, None)
)
if saml_callback_url:
redirect_params["callbackUrl"] = saml_callback_url
redirect_url = f"{callback_url}?{urlencode(redirect_params)}"
request.session.pop("saml_user_created", None)
return redirect(redirect_url)
@@ -4389,25 +4453,12 @@ class InvitationAcceptViewSet(BaseRLSViewSet):
invitation_token = serializer.validated_data["invitation_token"]
user_email = request.user.email
invitation = validate_invitation(
invitation_token, user_email, raise_not_found=True
)
# Proceed with accepting the invitation
user = User.objects.using(MainRouter.admin_db).get(email=user_email)
membership = Membership.objects.using(MainRouter.admin_db).create(
invitation, membership = accept_invitation_for_user(
user=user,
tenant=invitation.tenant,
invitation_token=invitation_token,
raise_not_found=True,
)
user_role = []
for role in invitation.roles.all():
user_role.append(
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user, role=role, tenant=invitation.tenant
)
)
invitation.state = Invitation.State.ACCEPTED
invitation.save(using=MainRouter.admin_db)
self.response_serializer_class = MembershipSerializer
membership_serializer = self.get_serializer(membership)
+7 -19
View File
@@ -11,7 +11,6 @@ from uuid import UUID
from api.db_router import READ_REPLICA_ALIAS, MainRouter
from api.db_utils import rls_transaction
from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot
from api.utils import initialize_prowler_provider
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY
from prowler.lib.check.compliance_models import (
@@ -27,6 +26,7 @@ from tasks.jobs.reports import (
ENSReportGenerator,
NIS2ReportGenerator,
ThreatScoreReportGenerator,
build_provider_metadata,
)
from tasks.jobs.threatscore import compute_threatscore_metrics
from tasks.jobs.threatscore_utils import (
@@ -841,24 +841,12 @@ def generate_compliance_reports(
tenant_id, scan_id
)
# Initialize the Prowler provider once for the whole report batch. Each
# generator used to re-init this in _load_compliance_data, paying the
# boto3/Azure-SDK construction cost 5 times per scan. The instance is
# only used by FindingOutput.transform_api_finding to enrich findings,
# so a single shared instance is correct.
logger.info("Initializing prowler_provider once for all reports (scan %s)", scan_id)
try:
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
prowler_provider = initialize_prowler_provider(provider_obj)
except Exception as init_error:
# If init fails the generators will fall back to lazy init in
# _load_compliance_data; we just log and continue.
logger.warning(
"Could not pre-initialize prowler_provider for scan %s: %s",
scan_id,
init_error,
)
prowler_provider = None
# Build a credential-free provider metadata stub once for the whole
# report batch. FindingOutput.transform_api_finding only reads static
# attributes (type plus a few identity fields), so reports never decrypt
# the ProviderSecret nor construct a cloud SDK session — generation keeps
# working after credentials are deleted or invalidated (PROWLER-2145).
prowler_provider = build_provider_metadata(provider_obj)
# Create shared findings cache up front so the eviction closure below
# can reference it. Defined BEFORE the closure to avoid the UnboundLocalError
@@ -98,6 +98,7 @@ from .config import (
from .csa import CSAReportGenerator
from .ens import ENSReportGenerator
from .nis2 import NIS2ReportGenerator
from .provider_metadata import build_provider_metadata
from .threatscore import ThreatScoreReportGenerator
__all__ = [
@@ -105,6 +106,7 @@ __all__ = [
"BaseComplianceReportGenerator",
"ComplianceData",
"RequirementData",
"build_provider_metadata",
"create_pdf_styles",
"get_requirement_metadata",
# Framework-specific generators
+11 -10
View File
@@ -11,7 +11,6 @@ from typing import Any
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import rls_transaction
from api.models import Provider, StatusChoices
from api.utils import initialize_prowler_provider
from celery.utils.log import get_task_logger
from prowler.lib.check.compliance_models import (
Compliance,
@@ -52,6 +51,7 @@ from .config import (
PADDING_SMALL,
FrameworkConfig,
)
from .provider_metadata import build_provider_metadata
logger = get_task_logger(__name__)
@@ -178,7 +178,8 @@ class ComplianceData:
attributes_by_requirement_id: Mapping of requirement IDs to their attributes
findings_by_check_id: Mapping of check IDs to their findings
provider_obj: Provider model object
prowler_provider: Initialized Prowler provider
prowler_provider: Credential-free provider metadata stub (see
``build_provider_metadata``)
"""
tenant_id: str
@@ -439,10 +440,10 @@ class BaseComplianceReportGenerator(ABC):
provider_obj: Optional pre-fetched Provider object
requirement_statistics: Optional pre-aggregated statistics
findings_cache: Optional pre-loaded findings cache
prowler_provider: Optional pre-initialized Prowler provider. When
generating multiple reports for the same scan the master
function initializes this once and passes it in to avoid
re-running boto3/Azure-SDK setup per framework.
prowler_provider: Optional provider metadata stub (see
``build_provider_metadata``). When generating multiple
reports for the same scan the master function builds it
once and passes it in.
**kwargs: Additional framework-specific arguments
"""
framework = self.config.display_name
@@ -896,9 +897,9 @@ class BaseComplianceReportGenerator(ABC):
provider_obj: Optional pre-fetched Provider
requirement_statistics: Optional pre-aggregated statistics
findings_cache: Optional pre-loaded findings
prowler_provider: Optional pre-initialized Prowler provider. When
the master function initializes it once and passes it in,
we skip the per-report ``initialize_prowler_provider`` call.
prowler_provider: Optional provider metadata stub. When the
master function builds it once and passes it in, we skip
the per-report ``build_provider_metadata`` call.
Returns:
Aggregated ComplianceData object
@@ -909,7 +910,7 @@ class BaseComplianceReportGenerator(ABC):
provider_obj = Provider.objects.get(id=provider_id)
if prowler_provider is None:
prowler_provider = initialize_prowler_provider(provider_obj)
prowler_provider = build_provider_metadata(provider_obj)
provider_type = provider_obj.provider
# Load compliance framework — fall back to the universal loader
@@ -0,0 +1,124 @@
from types import SimpleNamespace
from prowler.providers.github.models import GithubIdentityInfo
def build_provider_metadata(provider) -> SimpleNamespace:
"""Build a credential-free stand-in for the Prowler SDK provider.
``FindingOutput.transform_api_finding`` only reads static attributes
from the provider (``type`` plus a few identity/metadata fields used to
label accounts), so compliance reports never need the decrypted
``ProviderSecret`` nor a live cloud SDK session. This builds an object
exposing exactly those attributes from the ``Provider`` DB row, which
keeps report generation working when the provider secret has been
deleted or its credentials are no longer valid (PROWLER-2145).
Args:
provider: The API ``Provider`` model instance (only ``provider``,
``uid`` and ``alias`` are read).
Returns:
A ``SimpleNamespace`` mimicking the SDK provider attributes consumed
by ``FindingOutput.transform_api_finding`` / ``generate_output``.
"""
provider_type = provider.provider
uid = provider.uid
display_name = provider.alias or uid
# Defaults cover every attribute read unconditionally in
# FindingOutput.generate_output (``provider.auth_method`` is accessed
# directly for several provider types); identity lookups go through
# get_nested_attribute/getattr, which tolerate missing attributes.
stub = SimpleNamespace(
type=provider_type,
auth_method="",
identity=SimpleNamespace(),
)
if provider_type == "aws":
stub.identity = SimpleNamespace(account=uid)
elif provider_type == "azure":
stub.identity = SimpleNamespace(
identity_type="",
identity_id="",
tenant_ids=[""],
tenant_domain="",
subscriptions={uid: display_name},
)
elif provider_type == "gcp":
stub.identity = SimpleNamespace(profile="")
stub.projects = {
uid: SimpleNamespace(
id=uid,
name=display_name,
labels={},
organization=None,
)
}
elif provider_type == "kubernetes":
stub.identity = SimpleNamespace(context=uid, cluster=uid)
elif provider_type == "m365":
stub.identity = SimpleNamespace(
identity_type="",
identity_id="",
tenant_domain=uid,
tenant_id="",
)
elif provider_type == "github":
# generate_output assigns account fields only inside
# isinstance(identity, Github*IdentityInfo) branches, so the stub
# must carry a real GithubIdentityInfo instance.
stub.identity = GithubIdentityInfo(
account_id=uid,
account_name=display_name,
account_url="",
)
elif provider_type == "mongodbatlas":
stub.identity = SimpleNamespace(
organization_id=uid,
organization_name=display_name,
)
elif provider_type == "iac":
stub.provider_uid = uid
elif provider_type == "oraclecloud":
stub.identity = SimpleNamespace(
tenancy_id=uid,
tenancy_name=display_name,
)
elif provider_type == "alibabacloud":
stub.identity = SimpleNamespace(
identity_arn="",
account_id=uid,
account_name=display_name,
)
elif provider_type == "cloudflare":
stub.identity = SimpleNamespace(
audited_accounts=[uid],
accounts=[],
)
elif provider_type == "openstack":
stub.identity = SimpleNamespace(
username="",
project_id=uid,
project_name=display_name,
)
elif provider_type == "googleworkspace":
stub.identity = SimpleNamespace(
delegated_user="",
customer_id=uid,
domain=display_name,
)
elif provider_type == "vercel":
stub.identity = SimpleNamespace(
team=None,
user_id=uid,
username=display_name,
)
elif provider_type == "okta":
stub.identity = SimpleNamespace(
org_domain=uid,
client_id="",
)
return stub
@@ -178,7 +178,9 @@ def _load_findings_for_requirement_checks(
tenant_id (str): The tenant ID for Row-Level Security context.
scan_id (str): The ID of the scan to retrieve findings for.
check_ids (list[str]): List of check IDs to load findings for.
prowler_provider: The initialized Prowler provider instance.
prowler_provider: Credential-free provider metadata stub (see
``tasks.jobs.reports.build_provider_metadata``) consumed by
``FindingOutput.transform_api_finding``.
findings_cache (dict, optional): Cache of already loaded findings.
If provided, checks are first looked up in cache before querying database.
total_counts_out (dict, optional): If provided, populated with
+14 -17
View File
@@ -1044,10 +1044,10 @@ class TestStaleCleanupProtectionHelpers:
class TestGenerateThreatscoreReportFunction:
"""Test suite for generate_threatscore_report function."""
@patch("tasks.jobs.reports.base.initialize_prowler_provider")
@patch("tasks.jobs.reports.base.build_provider_metadata")
def test_generate_threatscore_report_exception_handling(
self,
mock_initialize_provider,
mock_build_provider_metadata,
tenants_fixture,
scans_fixture,
providers_fixture,
@@ -1057,7 +1057,7 @@ class TestGenerateThreatscoreReportFunction:
scan = scans_fixture[0]
provider = providers_fixture[0]
mock_initialize_provider.side_effect = Exception("Test exception")
mock_build_provider_metadata.side_effect = Exception("Test exception")
with pytest.raises(Exception) as exc_info:
generate_threatscore_report(
@@ -1167,7 +1167,6 @@ class TestGenerateComplianceReportsOptimized:
assert result["cis"] == {"upload": False, "path": ""}
mock_cis.assert_not_called()
@patch("api.utils.initialize_prowler_provider")
@patch("tasks.jobs.report.rmtree")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_cis_report")
@@ -1194,7 +1193,6 @@ class TestGenerateComplianceReportsOptimized:
mock_cis,
mock_upload_to_s3,
mock_rmtree,
mock_init_provider,
):
"""After each framework finishes, exclusive entries are evicted.
@@ -1223,7 +1221,6 @@ class TestGenerateComplianceReportsOptimized:
mock_aggregate_stats.return_value = {}
mock_generate_output_dir.return_value = "/tmp/tenant/scan/x/prowler-out"
mock_upload_to_s3.return_value = "s3://bucket/tenant/scan/x/report.pdf"
mock_init_provider.return_value = Mock(name="prowler_provider")
# Seed the cache as if both frameworks had already loaded their
# findings. We mutate it indirectly: each generator wrapper is a
@@ -1266,7 +1263,7 @@ class TestGenerateComplianceReportsOptimized:
"shared must remain in cache because ENS still needs it"
)
@patch("tasks.jobs.report.initialize_prowler_provider")
@patch("tasks.jobs.report.build_provider_metadata")
@patch("tasks.jobs.report.rmtree")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_cis_report")
@@ -1279,7 +1276,7 @@ class TestGenerateComplianceReportsOptimized:
@patch("tasks.jobs.report.Compliance.get_bulk")
@patch("tasks.jobs.report.Provider.objects.get")
@patch("tasks.jobs.report.ScanSummary.objects.filter")
def test_prowler_provider_initialized_once(
def test_provider_metadata_built_once(
self,
mock_scan_summary_filter,
mock_provider_get,
@@ -1293,11 +1290,11 @@ class TestGenerateComplianceReportsOptimized:
mock_cis,
mock_upload_to_s3,
mock_rmtree,
mock_init_provider,
mock_build_metadata,
):
"""``initialize_prowler_provider`` must be called exactly once for
the whole batch (PROWLER-1733). Previously each generator re-init'd
the SDK provider in ``_load_compliance_data`` → 5 inits per scan.
"""``build_provider_metadata`` must be called exactly once for the
whole batch and its result shared across all 5 reports
(PROWLER-1733 / PROWLER-2145).
"""
mock_scan_summary_filter.return_value.exists.return_value = True
mock_provider_get.return_value = Mock(uid="provider-uid", provider="aws")
@@ -1306,7 +1303,7 @@ class TestGenerateComplianceReportsOptimized:
mock_aggregate_stats.return_value = {}
mock_generate_output_dir.return_value = "/tmp/tenant/scan/x/prowler-out"
mock_upload_to_s3.return_value = "s3://bucket/tenant/scan/x/report.pdf"
mock_init_provider.return_value = Mock(name="prowler_provider")
mock_build_metadata.return_value = Mock(name="prowler_provider")
generate_compliance_reports(
tenant_id=str(uuid.uuid4()),
@@ -1325,14 +1322,14 @@ class TestGenerateComplianceReportsOptimized:
mock_nis2.assert_called_once()
mock_csa.assert_called_once()
mock_cis.assert_called_once()
# …but the SDK provider was initialized only once.
assert mock_init_provider.call_count == 1, (
f"expected 1 init, got {mock_init_provider.call_count} "
# …but the provider metadata stub was built only once.
assert mock_build_metadata.call_count == 1, (
f"expected 1 build, got {mock_build_metadata.call_count} "
f"(prowler_provider must be shared across reports)"
)
# The shared instance must reach every wrapper as kwargs.
shared = mock_init_provider.return_value
shared = mock_build_metadata.return_value
for mock_wrapper in (
mock_threatscore,
mock_ens,
@@ -0,0 +1,193 @@
"""Tests for the credential-free provider metadata stub (PROWLER-2145).
Every provider object used here is a plain ``SimpleNamespace`` WITHOUT a
``secret`` attribute: any code path trying to read ``provider.secret`` (the
coupling these tests guard against) would raise ``AttributeError`` and fail
the test. No database is required.
"""
from types import SimpleNamespace
import pytest
from api.models import Provider
from prowler.lib.outputs.finding import Finding as FindingOutput
from prowler.providers.github.models import GithubIdentityInfo
from tasks.jobs.reports import build_provider_metadata
PROVIDER_UID = "provider-uid-123"
PROVIDER_ALIAS = "my-provider-alias"
def _provider_row(provider_type: str, alias: str | None = PROVIDER_ALIAS):
"""Mimic the Provider DB row attributes read by build_provider_metadata."""
return SimpleNamespace(provider=provider_type, uid=PROVIDER_UID, alias=alias)
class TestBuildProviderMetadata:
@pytest.mark.parametrize("provider_type", Provider.ProviderChoices.values)
def test_every_provider_type_gets_safe_defaults(self, provider_type):
stub = build_provider_metadata(_provider_row(provider_type))
assert stub.type == provider_type
assert isinstance(stub.auth_method, str)
assert hasattr(stub, "identity")
def test_aws_identity_account_is_uid(self):
stub = build_provider_metadata(_provider_row("aws"))
assert stub.identity.account == PROVIDER_UID
def test_azure_identity_covers_generate_output_accesses(self):
stub = build_provider_metadata(_provider_row("azure"))
# generate_output indexes tenant_ids[0] and reads these directly.
assert stub.identity.tenant_ids
assert stub.identity.identity_type == ""
assert stub.identity.identity_id == ""
assert stub.identity.subscriptions == {PROVIDER_UID: PROVIDER_ALIAS}
def test_gcp_projects_keyed_by_uid(self):
stub = build_provider_metadata(_provider_row("gcp"))
project = stub.projects[PROVIDER_UID]
assert project.id == PROVIDER_UID
assert project.name == PROVIDER_ALIAS
assert project.labels == {}
# generate_output calls getattr(project, "organization") without a
# default, so the attribute must exist (None skips the org branch).
assert project.organization is None
def test_kubernetes_identity_context_and_cluster(self):
stub = build_provider_metadata(_provider_row("kubernetes"))
assert stub.identity.context == PROVIDER_UID
assert stub.identity.cluster == PROVIDER_UID
def test_github_identity_is_real_identity_info(self):
# generate_output only assigns account fields inside
# isinstance(identity, Github*IdentityInfo) branches.
stub = build_provider_metadata(_provider_row("github"))
assert isinstance(stub.identity, GithubIdentityInfo)
assert stub.identity.account_id == PROVIDER_UID
assert stub.identity.account_name == PROVIDER_ALIAS
def test_iac_provider_uid(self):
stub = build_provider_metadata(_provider_row("iac"))
assert stub.provider_uid == PROVIDER_UID
def test_alias_falls_back_to_uid(self):
stub = build_provider_metadata(_provider_row("azure", alias=None))
assert stub.identity.subscriptions == {PROVIDER_UID: PROVIDER_UID}
def _check_metadata_dict(provider_type: str, check_id: str) -> dict:
return {
"provider": provider_type,
"checkid": check_id,
"checktitle": "Test check title",
"checktype": [],
# CheckMetadata validates ServiceName == check_id.split("_")[0]
"servicename": check_id.split("_")[0],
"subservicename": "",
"severity": "high",
"resourcetype": "resource-type",
"description": "",
"risk": "",
"relatedurl": "",
"remediation": {
"recommendation": {"text": "", "url": ""},
"code": {"nativeiac": "", "terraform": "", "cli": "", "other": ""},
},
"resourceidtemplate": "",
"categories": [],
"dependson": [],
"relatedto": [],
"notes": "",
}
class _FakeFinding:
"""Attribute-faithful Finding stand-in.
A plain object instead of ``Mock``: only the attributes the Django model
exposes exist, so any new provider-attribute read in generate_output
(e.g. cloudflare's ``getattr(finding, "account_id", ...)``) hits the
same missing-attribute path it would hit in production instead of being
masked by Mock auto-created attributes.
"""
def _finding_model(provider_type: str, check_id: str, region: str):
"""Mimic the Django Finding row attributes read by transform_api_finding."""
resource = SimpleNamespace(
uid="resource-uid",
name="resource-name",
metadata="{}",
details="",
region=region,
tags=SimpleNamespace(all=lambda: []),
)
finding = _FakeFinding()
finding.resources = SimpleNamespace(first=lambda: resource)
finding.check_metadata = _check_metadata_dict(provider_type, check_id)
finding.status = "FAIL"
finding.status_extended = "failed for testing"
finding.muted = False
return finding
_FINDING_REGION = "region-x"
# Expected (account_uid, region) of the transformed finding per provider
# type, with resource.region = _FINDING_REGION. Keyed by every
# Provider.ProviderChoices value so that adding a new provider type without
# extending build_provider_metadata (and this table) fails the test below
# instead of breaking PDF generation at runtime.
_EXPECTED_TRANSFORM = {
"aws": (PROVIDER_UID, _FINDING_REGION),
"azure": (PROVIDER_UID, _FINDING_REGION),
"gcp": (PROVIDER_UID, _FINDING_REGION),
# transform_api_finding strips the "namespace: " prefix and
# generate_output re-adds it.
"kubernetes": (PROVIDER_UID, f"namespace: {_FINDING_REGION}"),
"m365": (PROVIDER_UID, _FINDING_REGION),
# For GitHub the owner comes from resource.region.
"github": (_FINDING_REGION, _FINDING_REGION),
"mongodbatlas": (PROVIDER_UID, _FINDING_REGION),
"iac": (PROVIDER_UID, _FINDING_REGION),
"oraclecloud": (PROVIDER_UID, _FINDING_REGION),
"alibabacloud": (PROVIDER_UID, _FINDING_REGION),
# Cloudflare uses the zone name (falls back to resource.name) as region.
"cloudflare": (PROVIDER_UID, "resource-name"),
"openstack": (PROVIDER_UID, _FINDING_REGION),
"image": ("image", _FINDING_REGION),
"googleworkspace": (PROVIDER_UID, _FINDING_REGION),
"vercel": (PROVIDER_UID, "global"),
"okta": (PROVIDER_UID, "global"),
}
class TestTransformApiFindingWithMetadataStub:
"""transform_api_finding must work end-to-end with the stub — i.e.
without a credentialed SDK provider — for EVERY API provider type."""
@pytest.mark.parametrize("provider_type", Provider.ProviderChoices.values)
def test_transform_with_stub(self, provider_type):
assert provider_type in _EXPECTED_TRANSFORM, (
f"New provider type {provider_type!r}: add a branch to "
f"build_provider_metadata covering the attributes read by "
f"FindingOutput.generate_output, then add its expected "
f"(account_uid, region) here."
)
expected_account_uid, expected_region = _EXPECTED_TRANSFORM[provider_type]
stub = build_provider_metadata(_provider_row(provider_type))
check_id = f"{provider_type}_test_check"
finding_model = _finding_model(provider_type, check_id, _FINDING_REGION)
output = FindingOutput.transform_api_finding(finding_model, stub)
assert output.check_id == check_id
assert output.status == "FAIL"
assert output.account_uid == expected_account_uid
assert output.region == expected_region
assert output.resource_name
assert output.resource_uid
+9 -1
View File
@@ -2,7 +2,15 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.32.1] (Prowler UNRELEASED)
## [5.32.2] (Prowler UNRELEASED)
### 🐞 Fixed
- Azure `postgresql_flexible_server_log_retention_days_greater_3` check now queries the `logfiles.retention_days` configuration parameter instead of `log_retention_days` (which only exists on the retired Single Server), fixing false `FAIL` results on every Flexible Server regardless of the actual retention value [(#11761)](https://github.com/prowler-cloud/prowler/pull/11761)
---
## [5.32.1] (Prowler v5.32.1)
### 🐞 Fixed
@@ -223,7 +223,7 @@ class PostgreSQL(AzureService):
client = self.clients[subscription]
try:
log_retention_days = client.configurations.get(
resouce_group_name, server_name, "log_retention_days"
resouce_group_name, server_name, "logfiles.retention_days"
)
log_retention_days = log_retention_days.value
except Exception:
@@ -402,6 +402,42 @@ class Test_PostgreSQL_Service_Resilience:
dev_server = next(s for s in servers[AZURE_SUBSCRIPTION_ID] if s.name == "dev")
assert dev_server.connection_throttling == "ON"
def test_log_retention_reads_flexible_server_parameter_name(self):
# Azure Flexible Server exposes log retention under the parameter
# "logfiles.retention_days". The legacy Single Server name
# "log_retention_days" does not exist on Flexible Server (Azure raises
# ConfigurationNotExists), which previously left log_retention_days=None
# and made postgresql_flexible_server_log_retention_days_greater_3 always
# FAIL. Regression test for #11757.
dev = _make_server("dev")
mock_client = MagicMock()
mock_client.servers.list.return_value = [dev]
server_details = MagicMock()
server_details.location = "westeurope"
mock_client.servers.get.return_value = server_details
mock_client.administrators.list_by_server.return_value = []
mock_client.firewall_rules.list_by_server.return_value = []
def configurations_get(resource_group, server_name, key):
if key == "log_retention_days":
raise ResourceNotFoundError(
"(ConfigurationNotExists) The configuration "
"'log_retention_days' does not exist for dev server "
"version 18."
)
if key == "logfiles.retention_days":
return MagicMock(value="5")
return MagicMock(value="ON")
mock_client.configurations.get.side_effect = configurations_get
postgresql = self._build_service_with_client(mock_client)
servers = postgresql._get_flexible_servers()
dev_server = servers[AZURE_SUBSCRIPTION_ID][0]
assert dev_server.log_retention_days == "5"
def test_unexpected_throttling_error_is_not_silently_collected(self):
# An unexpected failure reading "connection_throttle.enable" (e.g. a
# permission, throttling, or transient SDK error) must NOT be turned
+15 -1
View File
@@ -2,12 +2,26 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.33.0] (Prowler UNRELEASED)
## [1.33.0] (Prowler v5.33.0)
### 🚀 Added
- Owners can delete their last organization from the profile page [(#11864)](https://github.com/prowler-cloud/prowler/pull/11864)
- Cross-provider compliance view for universal frameworks, with a per-provider breakdown, provider/account/group filters, and combined PDF report generation [(#11745)](https://github.com/prowler-cloud/prowler/pull/11745)
### 🔄 Changed
- Organization row actions in the profile page are aligned in fixed columns and the Active indicator now sits next to the organization name [(#11864)](https://github.com/prowler-cloud/prowler/pull/11864)
---
## [1.32.1] (Prowler v5.32.1)
### 🐞 Fixed
- Invitation callback paths are now preserved when invited users continue with Google, GitHub, or SAML authentication [(#11752)](https://github.com/prowler-cloud/prowler/pull/11752)
---
## [1.32.0] (Prowler v5.32.0)
+7 -4
View File
@@ -166,8 +166,13 @@ export const deleteSamlConfig = async (id: string) => {
}
};
export const initiateSamlAuth = async (email: string) => {
export const initiateSamlAuth = async (email: string, callbackUrl = "/") => {
try {
const attributes = {
email_domain: email,
...(callbackUrl !== "/" && { callback_url: callbackUrl }),
};
const response = await fetch(`${apiBaseUrl}/auth/saml/initiate/`, {
method: "POST",
headers: {
@@ -176,9 +181,7 @@ export const initiateSamlAuth = async (email: string) => {
body: JSON.stringify({
data: {
type: "saml-initiate",
attributes: {
email_domain: email,
},
attributes,
},
}),
redirect: "manual",
File renamed without changes.
@@ -4,17 +4,17 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import {
validateBaseUrl,
validateCredentials,
} from "@/lib/lighthouse/validation";
} from "@/lib/lighthouse-v1/validation";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
type LighthouseProvider,
PROVIDER_DISPLAY_NAMES,
} from "@/types/lighthouse";
} from "@/types/lighthouse-v1";
import type {
BedrockCredentials,
OpenAICompatibleCredentials,
OpenAICredentials,
} from "@/types/lighthouse/credentials";
} from "@/types/lighthouse-v1/credentials";
// API Response Types
type ProviderCredentials =
+42
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { signOut } from "@/auth.config";
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
@@ -287,6 +288,47 @@ export async function deleteTenant(
}
}
export async function deleteTenantThenSignOut(
_prevState: DeleteTenantState | null,
formData: FormData,
): Promise<DeleteTenantState> {
const formDataObject = Object.fromEntries(formData);
const validatedData = deleteTenantSchema.safeParse(formDataObject);
if (!validatedData.success) {
return { error: "Invalid tenant ID" };
}
const { tenantId } = validatedData.data;
const headers = await getAuthHeaders({ contentType: false });
try {
const url = new URL(`${apiBaseUrl}/tenants/${tenantId}`);
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const errorDetail =
errorData?.errors?.[0]?.detail ||
`Failed to delete tenant: ${response.statusText}`;
throw new Error(errorDetail);
}
} catch (error) {
return handleApiError(error);
}
// Deleting the last tenant also removes the user account server-side, so
// there is no profile left to revalidate; close the session instead.
// signOut redirects by throwing, so it must stay outside the try/catch.
await signOut({ redirectTo: "/sign-in" });
// Unreachable: signOut always redirects. Present to satisfy the return type.
return { success: true };
}
interface SwitchThenDeleteSuccess {
success: true;
accessToken: string;
@@ -13,6 +13,7 @@ const SignUp = async ({
typeof resolvedSearchParams?.invitation_token === "string"
? resolvedSearchParams.invitation_token
: null;
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const GOOGLE_AUTH_URL = getAuthUrl("google");
const GITHUB_AUTH_URL = getAuthUrl("github");
@@ -21,6 +22,7 @@ const SignUp = async ({
<AuthForm
type="sign-up"
invitationToken={invitationToken}
isCloudEnv={isCloudEnv}
googleAuthUrl={GOOGLE_AUTH_URL}
githubAuthUrl={GITHUB_AUTH_URL}
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
@@ -0,0 +1,33 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LighthouseOverviewBanner } from "./lighthouse-overview-banner";
describe("LighthouseOverviewBanner", () => {
it("renders Toni copy and links to Lighthouse when connected", () => {
// Given / When
render(<LighthouseOverviewBanner href="/lighthouse" />);
// Then
const link = screen.getByRole("link", {
name: /Find and remediate which actually matters\./,
});
expect(link).toHaveAttribute("href", "/lighthouse");
expect(link).toHaveTextContent("Lighthouse AI");
expect(link).toHaveTextContent(
"Find and remediate which actually matters.",
);
});
it("links to Lighthouse settings when no connected configuration exists", () => {
// Given / When
render(<LighthouseOverviewBanner href="/lighthouse/settings" />);
// Then
expect(
screen.getByRole("link", {
name: /Find and remediate which actually matters\./,
}),
).toHaveAttribute("href", "/lighthouse/settings");
});
});
@@ -0,0 +1,128 @@
"use client";
import { ArrowRight } from "lucide-react";
import Link from "next/link";
import { useRef, useState } from "react";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Card, CardContent } from "@/components/shadcn";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { cn } from "@/lib/utils";
import type { LighthouseOverviewBannerHref } from "../_lib/lighthouse-banner";
interface LighthouseOverviewBannerProps {
href: LighthouseOverviewBannerHref;
}
export function LighthouseOverviewBanner({
href,
}: LighthouseOverviewBannerProps) {
const interactiveRef = useRef<HTMLDivElement>(null);
const curXRef = useRef(0);
const curYRef = useRef(0);
const tgXRef = useRef(0);
const tgYRef = useRef(0);
const [isSafari, setIsSafari] = useState(false);
useMountEffect(() => {
setIsSafari(/^((?!chrome|android).)*safari/i.test(navigator.userAgent));
});
useMountEffect(() => {
let animationFrameId: number;
const move = () => {
if (!interactiveRef.current) return;
curXRef.current += (tgXRef.current - curXRef.current) / 20;
curYRef.current += (tgYRef.current - curYRef.current) / 20;
interactiveRef.current.style.transform = `translate(${Math.round(curXRef.current)}px, ${Math.round(curYRef.current)}px)`;
animationFrameId = requestAnimationFrame(move);
};
animationFrameId = requestAnimationFrame(move);
return () => {
cancelAnimationFrame(animationFrameId);
};
});
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
if (interactiveRef.current) {
const rect = interactiveRef.current.getBoundingClientRect();
tgXRef.current = event.clientX - rect.left;
tgYRef.current = event.clientY - rect.top;
}
};
return (
<Link
href={href}
className="group focus-visible:ring-border-input-primary block rounded-xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Card
variant="base"
padding="none"
className="group-hover:border-border-input-primary relative overflow-hidden transition-colors"
onMouseMove={handleMouseMove}
>
<svg className="hidden">
<defs>
<filter id="blurMe">
<feGaussianBlur
in="SourceGraphic"
stdDeviation="10"
result="blur"
/>
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -8"
result="goo"
/>
<feBlend in="SourceGraphic" in2="goo" />
</filter>
</defs>
</svg>
<div
className={cn(
"pointer-events-none absolute inset-0 blur-lg",
isSafari ? "blur-2xl" : "[filter:url(#blurMe)_blur(40px)]",
)}
>
<div className="animate-first lighthouse-banner-gradient-neutral absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:center_center] opacity-100 [mix-blend-mode:hard-light]" />
<div className="animate-second lighthouse-banner-gradient-primary absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%-200px)] opacity-80 [mix-blend-mode:hard-light]" />
<div className="animate-third lighthouse-banner-gradient-primary-hover absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%+200px)] opacity-70 [mix-blend-mode:hard-light]" />
<div
ref={interactiveRef}
className="lighthouse-banner-gradient-primary-press absolute -top-1/2 -left-1/2 h-full w-full opacity-60 [mix-blend-mode:hard-light]"
/>
</div>
<CardContent className="relative z-10 flex min-w-0 items-center justify-between gap-4 px-4 py-3 sm:px-5">
<div className="flex min-w-0 items-center gap-3">
<span className="border-border-neutral-tertiary bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center rounded-md border">
<LighthouseIcon className="size-5" />
</span>
<div className="min-w-0">
<p className="text-text-neutral-primary text-sm font-medium">
Lighthouse AI
</p>
<p className="text-text-neutral-secondary text-sm">
Find and remediate which actually matters.
</p>
</div>
</div>
<ArrowRight className="text-text-neutral-tertiary size-4 shrink-0 transition-transform group-hover:translate-x-0.5" />
</CardContent>
</Card>
</Link>
);
}
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from "vitest";
import type {
LighthouseV2Configuration,
LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types";
import {
getLighthouseOverviewBannerHref,
resolveLighthouseOverviewBannerHref,
} from "./lighthouse-banner";
describe("resolveLighthouseOverviewBannerHref", () => {
it("routes to Lighthouse chat when any v2 configuration is connected", () => {
// Given / When
const href = resolveLighthouseOverviewBannerHref([
configuration("openai", false),
configuration("bedrock", true),
]);
// Then
expect(href).toBe("/lighthouse");
});
it("routes to Lighthouse settings when no v2 configuration is connected", () => {
// Given / When
const href = resolveLighthouseOverviewBannerHref([
configuration("openai", false),
configuration("openai-compatible", null),
]);
// Then
expect(href).toBe("/lighthouse/settings");
});
});
describe("getLighthouseOverviewBannerHref", () => {
it("hides the banner outside cloud without loading configurations", async () => {
// Given
const loadConfigurations = vi.fn(async () => ({
data: [configuration("openai", true)],
}));
// When
const href = await getLighthouseOverviewBannerHref(
false,
loadConfigurations,
);
// Then
expect(href).toBeNull();
expect(loadConfigurations).not.toHaveBeenCalled();
});
it("hides the banner when configurations fail to load", async () => {
// Given
const loadConfigurations = vi.fn(async () => ({
error: "Unauthorized",
status: 401,
}));
// When
const href = await getLighthouseOverviewBannerHref(
true,
loadConfigurations,
);
// Then
expect(href).toBeNull();
});
it("resolves the banner href from loaded configurations in cloud", async () => {
// Given
const loadConfigurations = vi.fn(async () => ({
data: [configuration("bedrock", true)],
}));
// When
const href = await getLighthouseOverviewBannerHref(
true,
loadConfigurations,
);
// Then
expect(href).toBe("/lighthouse");
});
});
function configuration(
providerType: LighthouseV2ProviderType,
connected: LighthouseV2Configuration["connected"],
): LighthouseV2Configuration {
return {
id: `config-${providerType}`,
providerType,
baseUrl:
providerType === "openai-compatible" ? "https://example.com" : null,
defaultModel: null,
businessContext: "Production account",
connected,
connectionLastCheckedAt: null,
insertedAt: "2026-06-24T09:00:00Z",
updatedAt: "2026-06-24T10:00:00Z",
};
}
@@ -0,0 +1,38 @@
import type { LighthouseV2Configuration } from "@/app/(prowler)/lighthouse/_types";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import type { ServerActionResult } from "@/types/server-actions";
export const LIGHTHOUSE_OVERVIEW_BANNER_HREF = LIGHTHOUSE_ROUTE;
export type LighthouseOverviewBannerHref =
(typeof LIGHTHOUSE_OVERVIEW_BANNER_HREF)[keyof typeof LIGHTHOUSE_OVERVIEW_BANNER_HREF];
type LoadLighthouseV2Configurations = () => Promise<
ServerActionResult<LighthouseV2Configuration[]>
>;
export function resolveLighthouseOverviewBannerHref(
configurations: LighthouseV2Configuration[],
): LighthouseOverviewBannerHref {
return configurations.some(
(configuration) => configuration.connected === true,
)
? LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT
: LIGHTHOUSE_OVERVIEW_BANNER_HREF.SETTINGS;
}
export async function getLighthouseOverviewBannerHref(
cloud: boolean,
loadConfigurations: LoadLighthouseV2Configurations,
): Promise<LighthouseOverviewBannerHref | null> {
if (!cloud) {
return null;
}
const result = await loadConfigurations();
if (!("data" in result)) {
return null;
}
return resolveLighthouseOverviewBannerHref(result.data);
}
@@ -1,7 +1,6 @@
"use server";
import { getLatestFindings } from "@/actions/findings/findings";
import { LighthouseBanner } from "@/components/lighthouse/banner";
import { LinkToFindings } from "@/components/overview";
import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table";
import { CardTitle } from "@/components/shadcn";
@@ -62,7 +61,6 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
return (
<div className="flex w-full flex-col">
<LighthouseBanner />
<DataTable
key={`dashboard-findings-${Date.now()}`}
columns={ColumnLatestFindings}
@@ -0,0 +1 @@
export * from "./lighthouse-v2";
@@ -0,0 +1,276 @@
import { describe, expect, it } from "vitest";
import {
buildLighthouseV2ConfigurationPayload,
buildLighthouseV2ConfigurationUpdatePayload,
buildLighthouseV2MessagePayload,
mapLighthouseV2Configuration,
mapLighthouseV2Message,
mapLighthouseV2Model,
mapLighthouseV2Provider,
validateLighthouseV2ConfigurationInput,
} from "./lighthouse-v2.adapter";
describe("lighthouse-v2.adapter", () => {
describe("when mapping Cloud JSON:API resources", () => {
it("should map configuration attributes to UI fields", () => {
// Given
const resource: Parameters<typeof mapLighthouseV2Configuration>[0] = {
id: "config-1",
type: "lighthouse-ai-configurations",
attributes: {
provider_type: "bedrock",
base_url: null,
default_model: "anthropic.claude",
business_context: "Production tenant",
connected: true,
connection_last_checked_at: "2026-06-24T10:00:00Z",
inserted_at: "2026-06-24T09:00:00Z",
updated_at: "2026-06-24T10:00:00Z",
},
};
// When
const config = mapLighthouseV2Configuration(resource);
// Then
expect(config).toEqual({
id: "config-1",
providerType: "bedrock",
baseUrl: null,
defaultModel: "anthropic.claude",
businessContext: "Production tenant",
connected: true,
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
insertedAt: "2026-06-24T09:00:00Z",
updatedAt: "2026-06-24T10:00:00Z",
});
});
it("should map supported provider and model payloads", () => {
// Given
const provider = {
id: "openai_compatible",
type: "lighthouse-supported-providers",
attributes: { name: "OpenAI Compatible" },
};
const model = {
id: "gpt-5.5",
type: "lighthouse-supported-models",
attributes: {
model_name: "GPT 5.5",
max_input_tokens: 100000,
max_output_tokens: 8192,
supports_function_calling: true,
supports_vision: false,
supports_reasoning: true,
},
};
// When / Then
expect(mapLighthouseV2Provider(provider)).toEqual({
id: "openai-compatible",
name: "OpenAI Compatible",
});
expect(mapLighthouseV2Model(model)).toEqual({
id: "gpt-5.5",
name: "GPT 5.5",
maxInputTokens: 100000,
maxOutputTokens: 8192,
supportsFunctionCalling: true,
supportsVision: false,
supportsReasoning: true,
});
});
it("should map message parts from backend names", () => {
// Given
const resource: Parameters<typeof mapLighthouseV2Message>[0] = {
id: "message-1",
type: "lighthouse-messages",
attributes: {
role: "assistant",
model: "gpt-5.5",
token_usage: { input: 10 },
inserted_at: "2026-06-24T10:01:00Z",
parts: [
{
id: "part-1",
type: "lighthouse-parts",
attributes: {
part_type: "text",
content: { text: "Done" },
tool_call_outcome: null,
inserted_at: "2026-06-24T10:01:00Z",
updated_at: "2026-06-24T10:01:00Z",
},
},
],
},
};
// When
const message = mapLighthouseV2Message(resource);
// Then
expect(message.parts[0]).toMatchObject({
id: "part-1",
type: "text",
content: { text: "Done" },
});
});
it("should give id-less parts stable fallback keys instead of empty strings", () => {
// Given
const resource: Parameters<typeof mapLighthouseV2Message>[0] = {
id: "message-2",
type: "lighthouse-messages",
attributes: {
role: "assistant",
model: null,
token_usage: null,
inserted_at: "2026-06-24T10:02:00Z",
parts: [
{ part_type: "text", content: { text: "one" } },
{ part_type: "text", content: { text: "two" } },
],
},
};
// When
const message = mapLighthouseV2Message(resource);
// Then
expect(message.parts.map((part) => part.id)).toEqual([
"part-0",
"part-1",
]);
});
it("should reject unknown provider ids at the adapter boundary", () => {
// Given / When / Then
expect(() =>
mapLighthouseV2Provider({
id: "totally-unknown-provider",
type: "lighthouse-supported-providers",
attributes: { name: "Mystery" },
}),
).toThrow(/Unsupported Lighthouse v2 provider/);
});
});
describe("when building Cloud payloads", () => {
it("should use Cloud Bedrock credential keys", () => {
// Given
const input = {
providerType: "bedrock" as const,
credentials: {
aws_access_key_id: "test-bedrock-access-key",
aws_secret_access_key: "a".repeat(40),
aws_region_name: "us-east-1",
},
};
// When
const payload = buildLighthouseV2ConfigurationPayload(input);
// Then
expect(payload.data).toMatchObject({
type: "lighthouse-ai-configurations",
attributes: {
provider_type: "bedrock",
credentials: {
aws_access_key_id: "test-bedrock-access-key",
aws_secret_access_key: "a".repeat(40),
aws_region_name: "us-east-1",
},
},
});
expect(payload.data.attributes).not.toHaveProperty("default_model");
expect(payload.data.attributes).not.toHaveProperty("business_context");
});
it("should serialize OpenAI-compatible configuration provider ids for the Cloud API", () => {
// Given
const input = {
providerType: "openai-compatible" as const,
credentials: { api_key: "provider-key" },
baseUrl: "https://openrouter.ai/api/v1",
};
// When
const payload = buildLighthouseV2ConfigurationPayload(input);
// Then
expect(payload.data.attributes).toMatchObject({
provider_type: "openai_compatible",
credentials: { api_key: "provider-key" },
base_url: "https://openrouter.ai/api/v1",
});
});
it("should serialize OpenAI-compatible message provider ids for the Cloud API", () => {
// Given
const input = {
text: "Summarize critical findings",
provider: "openai-compatible" as const,
model: "openrouter/auto",
};
// When
const payload = buildLighthouseV2MessagePayload(input);
// Then
expect(payload.data.attributes.provider).toBe("openai_compatible");
});
it("should build per-provider update payloads with default_model and business_context", () => {
// When
const payload = buildLighthouseV2ConfigurationUpdatePayload("config-1", {
defaultModel: "anthropic.claude-4",
businessContext: "Production tenant",
});
// Then
expect(payload).toEqual({
data: {
type: "lighthouse-ai-configurations",
id: "config-1",
attributes: {
default_model: "anthropic.claude-4",
business_context: "Production tenant",
},
},
});
});
it("should omit untouched fields from the update payload", () => {
// When
const payload = buildLighthouseV2ConfigurationUpdatePayload("config-1", {
defaultModel: "gpt-5.1",
});
// Then
expect(payload.data.attributes).toEqual({ default_model: "gpt-5.1" });
expect(payload.data.attributes).not.toHaveProperty("business_context");
expect(payload.data.attributes).not.toHaveProperty("credentials");
});
it("should require base_url for OpenAI-compatible configurations", () => {
// Given
const input = {
providerType: "openai-compatible" as const,
credentials: { api_key: "provider-key" },
};
// When
const result = validateLighthouseV2ConfigurationInput(input);
// Then
expect(result).toEqual({
success: false,
error: "Base URL is required for OpenAI-compatible providers.",
});
});
});
});
@@ -0,0 +1,380 @@
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2Configuration,
type LighthouseV2ConfigurationInput,
type LighthouseV2ConfigurationUpdateInput,
type LighthouseV2Credentials,
type LighthouseV2Message,
type LighthouseV2MessageRole,
type LighthouseV2Part,
type LighthouseV2PartType,
type LighthouseV2ProviderType,
type LighthouseV2Session,
type LighthouseV2SupportedModel,
type LighthouseV2SupportedProvider,
type LighthouseV2Task,
} from "@/app/(prowler)/lighthouse/_types";
import type { JsonApiDocument, JsonApiResource } from "@/types/jsonapi";
import type {
TaskAttributes as ApiTaskAttributes,
TaskState,
} from "@/types/tasks";
interface ConfigurationAttributes {
provider_type: string;
base_url: string | null;
default_model?: string | null;
business_context?: string | null;
connected: boolean | null;
connection_last_checked_at: string | null;
inserted_at: string;
updated_at: string;
}
interface SupportedProviderAttributes {
name: string;
}
interface SupportedModelAttributes {
model_name?: string | null;
name?: string | null;
max_input_tokens: number | null;
max_output_tokens: number | null;
supports_function_calling: boolean | null;
supports_vision: boolean | null;
supports_reasoning: boolean | null;
}
interface SessionAttributes {
title: string | null;
is_archived: boolean;
inserted_at: string;
updated_at: string;
}
interface MessageAttributes {
role: LighthouseV2MessageRole;
model: string | null;
token_usage: unknown;
inserted_at: string;
parts?: UnknownPartResource[];
}
interface PartAttributes {
id?: string;
part_type: LighthouseV2PartType;
content: unknown;
tool_call_outcome?: string | null;
inserted_at?: string | null;
updated_at?: string | null;
}
type UnknownPartResource =
| JsonApiResource<PartAttributes>
| (PartAttributes & { id?: string });
// Extends the shared task attributes: the Lighthouse task resource always
// carries `state` plus scheduling metadata.
interface TaskAttributes extends ApiTaskAttributes {
state: TaskState;
inserted_at?: string;
completed_at?: string | null;
name?: string | null;
metadata?: unknown;
}
interface ValidationSuccess {
success: true;
}
interface ValidationFailure {
success: false;
error: string;
}
type ValidationResult = ValidationSuccess | ValidationFailure;
const LIGHTHOUSE_V2_API_PROVIDER_TYPE = {
OPENAI: "openai",
BEDROCK: "bedrock",
OPENAI_COMPATIBLE: "openai_compatible",
} as const;
type LighthouseV2ApiProviderType =
(typeof LIGHTHOUSE_V2_API_PROVIDER_TYPE)[keyof typeof LIGHTHOUSE_V2_API_PROVIDER_TYPE];
export function getJsonApiArray<TResource>(
document: JsonApiDocument<TResource[]>,
): TResource[] {
return document.data ?? [];
}
export function mapLighthouseV2Configuration(
resource: JsonApiResource<ConfigurationAttributes>,
): LighthouseV2Configuration {
return {
id: resource.id,
providerType: normalizeLighthouseV2ProviderType(
resource.attributes.provider_type,
),
baseUrl: resource.attributes.base_url,
defaultModel: resource.attributes.default_model ?? null,
businessContext: resource.attributes.business_context ?? "",
connected: resource.attributes.connected,
connectionLastCheckedAt: resource.attributes.connection_last_checked_at,
insertedAt: resource.attributes.inserted_at,
updatedAt: resource.attributes.updated_at,
};
}
export function mapLighthouseV2Provider(
resource: JsonApiResource<SupportedProviderAttributes>,
): LighthouseV2SupportedProvider {
return {
id: normalizeLighthouseV2ProviderType(resource.id),
name: resource.attributes.name,
};
}
export function mapLighthouseV2Model(
resource: JsonApiResource<SupportedModelAttributes>,
): LighthouseV2SupportedModel {
return {
id: resource.id,
name:
resource.attributes.model_name ?? resource.attributes.name ?? resource.id,
maxInputTokens: resource.attributes.max_input_tokens,
maxOutputTokens: resource.attributes.max_output_tokens,
supportsFunctionCalling: resource.attributes.supports_function_calling,
supportsVision: resource.attributes.supports_vision,
supportsReasoning: resource.attributes.supports_reasoning,
};
}
export function mapLighthouseV2Session(
resource: JsonApiResource<SessionAttributes>,
): LighthouseV2Session {
return {
id: resource.id,
title: resource.attributes.title,
isArchived: resource.attributes.is_archived,
insertedAt: resource.attributes.inserted_at,
updatedAt: resource.attributes.updated_at,
};
}
export function mapLighthouseV2Message(
resource: JsonApiResource<MessageAttributes>,
): LighthouseV2Message {
return {
id: resource.id,
role: resource.attributes.role,
model: resource.attributes.model,
tokenUsage: resource.attributes.token_usage,
insertedAt: resource.attributes.inserted_at,
parts: (resource.attributes.parts ?? []).map((part, index) =>
mapLighthouseV2Part(part, index),
),
};
}
export function mapLighthouseV2Task(
resource: JsonApiResource<TaskAttributes>,
): LighthouseV2Task {
return {
id: resource.id,
name: resource.attributes.name ?? null,
state: resource.attributes.state,
insertedAt: resource.attributes.inserted_at,
completedAt: resource.attributes.completed_at,
metadata: resource.attributes.metadata,
result: resource.attributes.result,
};
}
export function buildLighthouseV2ConfigurationPayload(
input: LighthouseV2ConfigurationInput,
) {
return {
data: {
type: "lighthouse-ai-configurations",
attributes: filterUndefinedAttributes({
provider_type: toLighthouseV2ApiProviderType(input.providerType),
credentials: input.credentials,
base_url: input.baseUrl ?? null,
}),
},
};
}
export function buildLighthouseV2ConfigurationUpdatePayload(
configId: string,
input: LighthouseV2ConfigurationUpdateInput,
) {
return {
data: {
type: "lighthouse-ai-configurations",
id: configId,
attributes: filterUndefinedAttributes({
credentials: input.credentials,
base_url: input.baseUrl,
default_model: input.defaultModel,
business_context: input.businessContext,
}),
},
};
}
export function buildLighthouseV2SessionCreatePayload(title?: string | null) {
return {
data: {
type: "lighthouse-sessions",
attributes: { title: title || null },
},
};
}
export function buildLighthouseV2SessionUpdatePayload(
sessionId: string,
attributes: { title?: string | null; isArchived?: boolean },
) {
return {
data: {
type: "lighthouse-sessions",
id: sessionId,
attributes: filterUndefinedAttributes({
title: attributes.title,
is_archived: attributes.isArchived,
}),
},
};
}
export function buildLighthouseV2MessagePayload(input: {
text: string;
provider: LighthouseV2ProviderType;
model?: string | null;
}) {
return {
data: {
type: "lighthouse-messages",
attributes: filterUndefinedAttributes({
parts: [
{
part_type: "text",
content: { text: input.text },
},
],
provider: toLighthouseV2ApiProviderType(input.provider),
model: input.model || undefined,
}),
},
};
}
export function validateLighthouseV2ConfigurationInput(input: {
providerType: LighthouseV2ProviderType;
credentials?: LighthouseV2Credentials;
baseUrl?: string | null;
}): ValidationResult {
if (!input.credentials) {
return { success: false, error: "Credentials are required." };
}
if (
input.providerType === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE &&
!input.baseUrl
) {
return {
success: false,
error: "Base URL is required for OpenAI-compatible providers.",
};
}
if (
input.providerType !== LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE &&
input.baseUrl
) {
return {
success: false,
error: "Base URL is only supported for OpenAI-compatible providers.",
};
}
if (
input.providerType === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK &&
!hasBedrockRegion(input.credentials)
) {
return {
success: false,
error: "AWS region is required for Bedrock providers.",
};
}
return { success: true };
}
export function toLighthouseV2ApiProviderType(
providerType: LighthouseV2ProviderType,
): LighthouseV2ApiProviderType {
switch (providerType) {
case LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI:
return LIGHTHOUSE_V2_API_PROVIDER_TYPE.OPENAI;
case LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK:
return LIGHTHOUSE_V2_API_PROVIDER_TYPE.BEDROCK;
case LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE:
return LIGHTHOUSE_V2_API_PROVIDER_TYPE.OPENAI_COMPATIBLE;
}
}
function mapLighthouseV2Part(
resource: UnknownPartResource,
index: number,
): LighthouseV2Part {
const attributes = "attributes" in resource ? resource.attributes : resource;
// Persisted parts carry an id; streamed/id-less parts fall back to a stable
// per-message index so multiple id-less parts never collide on "" as a key.
const id =
("id" in resource ? resource.id : attributes.id) ?? `part-${index}`;
return {
id,
type: attributes.part_type,
content: attributes.content,
toolCallOutcome: attributes.tool_call_outcome ?? null,
insertedAt: attributes.inserted_at ?? null,
updatedAt: attributes.updated_at ?? null,
};
}
function filterUndefinedAttributes<T extends Record<string, unknown>>(
attributes: T,
) {
return Object.fromEntries(
Object.entries(attributes).filter(([, value]) => value !== undefined),
) as Partial<T>;
}
function hasBedrockRegion(credentials: LighthouseV2Credentials): boolean {
return (
"aws_region_name" in credentials && Boolean(credentials.aws_region_name)
);
}
function normalizeLighthouseV2ProviderType(
providerType: string,
): LighthouseV2ProviderType {
const normalized =
providerType === "openai_compatible"
? LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE
: providerType;
// Validate at the adapter boundary so an unexpected backend id fails fast
// here instead of crossing into the UI as a bogus "valid" provider.
const allowed: readonly string[] = Object.values(LIGHTHOUSE_V2_PROVIDER_TYPE);
if (!allowed.includes(normalized)) {
throw new Error(`Unsupported Lighthouse v2 provider: ${providerType}`);
}
return normalized as LighthouseV2ProviderType;
}
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { authMock, revalidatePathMock } = vi.hoisted(() => ({
authMock: vi.fn(),
revalidatePathMock: vi.fn(),
}));
vi.mock("@/auth.config", () => ({ auth: authMock }));
vi.mock("next/cache", () => ({ revalidatePath: revalidatePathMock }));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
captureMessage: vi.fn(),
}));
// Provide the primitives the action AND the real handleApiResponse need, while
// keeping the revalidate gate (the behavior under test) running for real.
vi.mock("@/lib/helper", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: vi
.fn()
.mockResolvedValue({ Authorization: "Bearer token-123" }),
parseStringify: (value: unknown) => JSON.parse(JSON.stringify(value)),
getErrorMessage: (error: unknown) => String(error),
sanitizeErrorMessage: (message: string) => message,
GENERIC_SERVER_ERROR_MESSAGE: "Server error",
}));
import {
createLighthouseV2Session,
getLighthouseV2SupportedModels,
updateLighthouseV2Configuration,
updateLighthouseV2Session,
} from "./lighthouse-v2";
function sessionResponse(id = "session-1") {
return Response.json(
{
data: {
id,
type: "lighthouse-sessions",
attributes: {
title: "Summarize findings",
is_archived: false,
inserted_at: "2026-06-25T10:00:00Z",
updated_at: "2026-06-25T10:00:00Z",
active_celery_task_id: null,
},
},
},
{ status: 201 },
);
}
function configurationResponse(id = "config-1") {
return Response.json(
{
data: {
id,
type: "lighthouse-ai-configurations",
attributes: {
provider_type: "bedrock",
base_url: null,
default_model: "anthropic.claude-4",
business_context: "Production tenant",
connected: true,
connection_last_checked_at: "2026-06-25T10:00:00Z",
inserted_at: "2026-06-25T09:00:00Z",
updated_at: "2026-06-25T10:00:00Z",
},
},
},
{ status: 200 },
);
}
function modelsResponse() {
return Response.json({ data: [] }, { status: 200 });
}
describe("Lighthouse v2 session write actions", () => {
beforeEach(() => {
authMock.mockResolvedValue({ accessToken: "token-123" });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("does NOT revalidate /lighthouse when creating a session (avoids chat remount)", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(sessionResponse()));
const result = await createLighthouseV2Session("Summarize findings");
expect("data" in result && result.data.id).toBe("session-1");
// Revalidating the active force-dynamic route would remount the chat and
// kill the live EventSource — so it must stay off for session creation.
expect(revalidatePathMock).not.toHaveBeenCalled();
});
it("still revalidates /lighthouse for other session writes (regression contrast)", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(sessionResponse()));
await updateLighthouseV2Session("session-1", { title: "Renamed" });
// Proves the test harness would catch a revalidate being (re)introduced.
expect(revalidatePathMock).toHaveBeenCalledWith("/lighthouse");
});
it("persists the chosen model as the provider default without remounting the active chat", async () => {
// Given
const fetchMock = vi.fn().mockResolvedValue(configurationResponse());
vi.stubGlobal("fetch", fetchMock);
// When
const result = await updateLighthouseV2Configuration("config-1", {
defaultModel: "anthropic.claude-4",
});
// Then
expect("data" in result && result.data.defaultModel).toBe(
"anthropic.claude-4",
);
expect(fetchMock).toHaveBeenCalledWith(
new URL("https://api.example.com/api/v1/lighthouse/config/config-1"),
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
data: {
type: "lighthouse-ai-configurations",
id: "config-1",
attributes: { default_model: "anthropic.claude-4" },
},
}),
}),
);
// Revalidating the active force-dynamic chat route would remount it and kill
// the live EventSource — only the settings route may be revalidated.
expect(revalidatePathMock).not.toHaveBeenCalledWith("/lighthouse");
expect(revalidatePathMock).toHaveBeenCalledWith("/lighthouse/settings");
});
it("loads OpenAI-compatible models using the Cloud provider id", async () => {
// Given
const fetchMock = vi.fn().mockResolvedValue(modelsResponse());
vi.stubGlobal("fetch", fetchMock);
// When
const result = await getLighthouseV2SupportedModels("openai-compatible");
// Then
expect("data" in result && result.data).toEqual([]);
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/v1/lighthouse/supported-providers/openai_compatible/models",
expect.objectContaining({
method: "GET",
}),
);
});
});
@@ -0,0 +1,372 @@
"use server";
import { pollTaskUntilSettled } from "@/actions/task/poll";
import type {
LighthouseV2Configuration,
LighthouseV2ConfigurationInput,
LighthouseV2ConfigurationUpdateInput,
LighthouseV2Message,
LighthouseV2ProviderType,
LighthouseV2SendMessageInput,
LighthouseV2SendMessageResult,
LighthouseV2Session,
LighthouseV2SupportedModel,
LighthouseV2SupportedProvider,
} from "@/app/(prowler)/lighthouse/_types";
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import type { JsonApiDocument } from "@/types/jsonapi";
import type { ServerActionResult } from "@/types/server-actions";
import {
buildLighthouseV2ConfigurationPayload,
buildLighthouseV2ConfigurationUpdatePayload,
buildLighthouseV2MessagePayload,
buildLighthouseV2SessionCreatePayload,
buildLighthouseV2SessionUpdatePayload,
getJsonApiArray,
mapLighthouseV2Configuration,
mapLighthouseV2Message,
mapLighthouseV2Model,
mapLighthouseV2Provider,
mapLighthouseV2Session,
mapLighthouseV2Task,
toLighthouseV2ApiProviderType,
validateLighthouseV2ConfigurationInput,
} from "./lighthouse-v2.adapter";
type TaskResource = Parameters<typeof mapLighthouseV2Task>[0];
export type LighthouseV2ActionResult<T> = ServerActionResult<T>;
const CONFIG_ENDPOINT = "/lighthouse/config";
const SESSIONS_ENDPOINT = "/lighthouse/sessions";
const SUPPORTED_PROVIDERS_ENDPOINT = "/lighthouse/supported-providers";
// 20 attempts x 3s: ~60s ceiling for the provider connection-check task.
const CONNECTION_TEST_MAX_ATTEMPTS = 20;
const CONNECTION_TEST_DELAY_MS = 3000;
export async function getLighthouseV2Configurations(): Promise<
LighthouseV2ActionResult<LighthouseV2Configuration[]>
> {
return getCollection(CONFIG_ENDPOINT, mapLighthouseV2Configuration);
}
export async function createLighthouseV2Configuration(
input: LighthouseV2ConfigurationInput,
): Promise<LighthouseV2ActionResult<LighthouseV2Configuration>> {
const validation = validateLighthouseV2ConfigurationInput(input);
if (!validation.success) {
return { error: validation.error, status: 400 };
}
return mutateSingle(
CONFIG_ENDPOINT,
{
method: "POST",
body: JSON.stringify(buildLighthouseV2ConfigurationPayload(input)),
},
mapLighthouseV2Configuration,
LIGHTHOUSE_ROUTE.SETTINGS,
);
}
export async function updateLighthouseV2Configuration(
configId: string,
input: LighthouseV2ConfigurationUpdateInput,
): Promise<LighthouseV2ActionResult<LighthouseV2Configuration>> {
return mutateSingle(
`${CONFIG_ENDPOINT}/${encodeURIComponent(configId)}`,
{
method: "PATCH",
body: JSON.stringify(
buildLighthouseV2ConfigurationUpdatePayload(configId, input),
),
},
mapLighthouseV2Configuration,
LIGHTHOUSE_ROUTE.SETTINGS,
);
}
export async function deleteLighthouseV2Configuration(
configId: string,
): Promise<LighthouseV2ActionResult<true>> {
return mutateEmpty(
`${CONFIG_ENDPOINT}/${encodeURIComponent(configId)}`,
{ method: "DELETE" },
LIGHTHOUSE_ROUTE.SETTINGS,
);
}
// Starts the backend connection-check task, polls it to completion (reusing the
// shared task poller), then returns the re-fetched configuration so the caller
// can render the authoritative `connected` / `connectionLastCheckedAt` status.
export async function testLighthouseV2ConfigurationConnection(
configId: string,
): Promise<LighthouseV2ActionResult<LighthouseV2Configuration>> {
try {
const response = await fetch(
buildApiUrl(
`${CONFIG_ENDPOINT}/${encodeURIComponent(configId)}/connection`,
),
{ method: "POST", headers: await getAuthHeaders({ contentType: false }) },
);
const document = (await handleApiResponse(
response,
)) as JsonApiDocument<TaskResource>;
if (isErrorDocument(document) || !document.data) {
return toErrorResult(document);
}
const settled = await pollTaskUntilSettled(
mapLighthouseV2Task(document.data).id,
{
maxAttempts: CONNECTION_TEST_MAX_ATTEMPTS,
delayMs: CONNECTION_TEST_DELAY_MS,
},
);
if (!settled.ok) {
return { error: settled.error || "Connection test timed out." };
}
const configurations = await getLighthouseV2Configurations();
if ("error" in configurations) {
return configurations;
}
const updated = configurations.data.find(
(config) => config.id === configId,
);
if (!updated) {
return { error: "Configuration not found after connection test." };
}
return { data: updated };
} catch (error) {
return handleApiError(error);
}
}
export async function getLighthouseV2SupportedProviders(): Promise<
LighthouseV2ActionResult<LighthouseV2SupportedProvider[]>
> {
return getCollection(SUPPORTED_PROVIDERS_ENDPOINT, mapLighthouseV2Provider);
}
export async function getLighthouseV2SupportedModels(
provider: LighthouseV2ProviderType,
): Promise<LighthouseV2ActionResult<LighthouseV2SupportedModel[]>> {
return getCollection(
`${SUPPORTED_PROVIDERS_ENDPOINT}/${encodeURIComponent(toLighthouseV2ApiProviderType(provider))}/models`,
mapLighthouseV2Model,
);
}
export async function getLighthouseV2Sessions(): Promise<
LighthouseV2ActionResult<LighthouseV2Session[]>
> {
return getCollection(SESSIONS_ENDPOINT, mapLighthouseV2Session);
}
export async function createLighthouseV2Session(
title?: string | null,
): Promise<LighthouseV2ActionResult<LighthouseV2Session>> {
// Intentionally NOT revalidating "/lighthouse": the page is force-dynamic
// (nothing to revalidate) and revalidating the active route mid-submit would
// re-run the server component and remount the chat, killing the live stream.
// The sidebar refreshes client-side via notifyLighthouseV2SessionsChanged().
return mutateSingle(
SESSIONS_ENDPOINT,
{
method: "POST",
body: JSON.stringify(buildLighthouseV2SessionCreatePayload(title)),
},
mapLighthouseV2Session,
"",
);
}
export async function updateLighthouseV2Session(
sessionId: string,
attributes: { title?: string | null; isArchived?: boolean },
): Promise<LighthouseV2ActionResult<LighthouseV2Session>> {
return mutateSingle(
`${SESSIONS_ENDPOINT}/${encodeURIComponent(sessionId)}`,
{
method: "PATCH",
body: JSON.stringify(
buildLighthouseV2SessionUpdatePayload(sessionId, attributes),
),
},
mapLighthouseV2Session,
LIGHTHOUSE_ROUTE.CHAT,
);
}
export async function archiveLighthouseV2Session(
sessionId: string,
): Promise<LighthouseV2ActionResult<LighthouseV2Session>> {
return updateLighthouseV2Session(sessionId, { isArchived: true });
}
export async function getLighthouseV2Messages(
sessionId: string,
): Promise<LighthouseV2ActionResult<LighthouseV2Message[]>> {
return getCollection(
`${SESSIONS_ENDPOINT}/${encodeURIComponent(sessionId)}/messages`,
mapLighthouseV2Message,
);
}
export async function sendLighthouseV2Message(
input: LighthouseV2SendMessageInput,
): Promise<LighthouseV2ActionResult<LighthouseV2SendMessageResult>> {
try {
const response = await fetch(
buildApiUrl(
`${SESSIONS_ENDPOINT}/${encodeURIComponent(input.sessionId)}/messages`,
),
{
method: "POST",
headers: await getAuthHeaders({ contentType: true }),
body: JSON.stringify(buildLighthouseV2MessagePayload(input)),
},
);
const document = (await handleApiResponse(
response,
)) as JsonApiDocument<TaskResource>;
if (isErrorDocument(document) || !document.data) {
return toErrorResult(document);
}
return {
data: {
task: mapLighthouseV2Task(document.data),
},
meta: document.meta,
};
} catch (error) {
return handleApiError(error);
}
}
async function getCollection<TResource, TOutput>(
path: string,
mapper: (resource: TResource) => TOutput,
): Promise<LighthouseV2ActionResult<TOutput[]>> {
return getCollectionFromUrl(buildApiUrl(path), mapper);
}
async function getCollectionFromUrl<TResource, TOutput>(
url: URL,
mapper: (resource: TResource) => TOutput,
): Promise<LighthouseV2ActionResult<TOutput[]>> {
try {
const headers = await getAuthHeaders({ contentType: false });
const first = (await handleApiResponse(
await fetch(url.toString(), {
method: "GET",
headers,
cache: "no-store",
}),
)) as JsonApiDocument<TResource[]>;
if (isErrorDocument(first)) {
return toErrorResult(first);
}
const resources = [...getJsonApiArray(first)];
let nextUrl: string | undefined = first.links?.next ?? undefined;
while (nextUrl) {
const page = (await handleApiResponse(
await fetch(nextUrl, { method: "GET", headers, cache: "no-store" }),
)) as JsonApiDocument<TResource[]>;
if (isErrorDocument(page)) {
return toErrorResult(page);
}
resources.push(...getJsonApiArray(page));
nextUrl = page.links?.next ?? undefined;
}
return {
data: resources.map(mapper),
meta: first.meta,
links: first.links,
};
} catch (error) {
return handleApiError(error);
}
}
async function mutateSingle<TResource, TOutput>(
path: string,
init: RequestInit,
mapper: (resource: TResource) => TOutput,
pathToRevalidate: string,
includeContentType = true,
): Promise<LighthouseV2ActionResult<TOutput>> {
try {
const response = await fetch(buildApiUrl(path), {
...init,
headers: await getAuthHeaders({ contentType: includeContentType }),
});
const document = (await handleApiResponse(
response,
pathToRevalidate,
)) as JsonApiDocument<TResource>;
if (isErrorDocument(document) || !document.data) {
return toErrorResult(document);
}
return { data: mapper(document.data), meta: document.meta };
} catch (error) {
return handleApiError(error);
}
}
async function mutateEmpty(
path: string,
init: RequestInit,
pathToRevalidate: string,
): Promise<LighthouseV2ActionResult<true>> {
try {
const response = await fetch(buildApiUrl(path), {
...init,
headers: await getAuthHeaders({ contentType: false }),
});
const document = await handleApiResponse(response, pathToRevalidate);
if (isErrorDocument(document)) {
return toErrorResult(document);
}
return { data: true, status: document.status };
} catch (error) {
return handleApiError(error);
}
}
function buildApiUrl(path: string): URL {
return new URL(`${getRequiredApiBaseUrl()}${path}`);
}
function getRequiredApiBaseUrl(): string {
if (!apiBaseUrl) {
throw new Error("API base URL is not configured.");
}
return apiBaseUrl;
}
function isErrorDocument<TData>(
document: JsonApiDocument<TData> | { error?: unknown },
): document is JsonApiDocument<TData> & { error: string } {
return typeof document.error === "string";
}
function toErrorResult<TData>(
document: JsonApiDocument<TData>,
): Extract<LighthouseV2ActionResult<never>, { error: string }> {
return {
error: document.error ?? "Unexpected Lighthouse AI response.",
errors: document.errors,
status: document.status,
};
}
@@ -0,0 +1,7 @@
# AI Elements
Components vendored from Vercel's AI Elements registry
(https://ai-sdk.dev/elements), adapted to this repo's shadcn imports and
lint rules. They live in their own folder — same idea as `components/shadcn/`
— so they can be diffed against upstream when updating instead of being mixed
with hand-written chat UI, which lives in `../chat/`.
@@ -0,0 +1,239 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
BrainIcon,
ChevronDownIcon,
DotIcon,
type LucideIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, useContext } from "react";
import { Badge } from "@/components/shadcn/badge/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/shadcn/collapsible";
import { cn } from "@/lib/utils";
type ChainOfThoughtContextValue = {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
};
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
null,
);
const useChainOfThought = () => {
const context = useContext(ChainOfThoughtContext);
if (!context) {
throw new Error(
"ChainOfThought components must be used within ChainOfThought",
);
}
return context;
};
export type ChainOfThoughtProps = ComponentProps<"div"> & {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
export function ChainOfThought({
className,
open,
defaultOpen = false,
onOpenChange,
children,
...props
}: ChainOfThoughtProps) {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const chainOfThoughtContext = { isOpen, setIsOpen };
// One Collapsible root wraps both header and content so CollapsibleTrigger and
// CollapsibleContent share Radix's ARIA/id wiring; the context only carries
// `isOpen` for presentational bits like the header chevron.
return (
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className={cn("not-prose w-full space-y-4", className)}
{...props}
>
{children}
</Collapsible>
</ChainOfThoughtContext.Provider>
);
}
export type ChainOfThoughtHeaderProps = ComponentProps<
typeof CollapsibleTrigger
>;
export function ChainOfThoughtHeader({
className,
children,
...props
}: ChainOfThoughtHeaderProps) {
const { isOpen } = useChainOfThought();
return (
<CollapsibleTrigger
className={cn(
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
className,
)}
{...props}
>
<BrainIcon className="size-4" />
<span className="flex-1 text-left">{children ?? "Chain of Thought"}</span>
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0",
)}
/>
</CollapsibleTrigger>
);
}
export const CHAIN_OF_THOUGHT_STATUS = {
COMPLETE: "complete",
ACTIVE: "active",
PENDING: "pending",
} as const;
export type ChainOfThoughtStatus =
(typeof CHAIN_OF_THOUGHT_STATUS)[keyof typeof CHAIN_OF_THOUGHT_STATUS];
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
icon?: LucideIcon;
label: ReactNode;
description?: ReactNode;
status?: ChainOfThoughtStatus;
};
export function ChainOfThoughtStep({
className,
icon: Icon = DotIcon,
label,
description,
status = CHAIN_OF_THOUGHT_STATUS.COMPLETE,
children,
...props
}: ChainOfThoughtStepProps) {
const statusStyles = {
complete: "text-muted-foreground",
active: "text-foreground",
pending: "text-muted-foreground/50",
};
return (
<div
className={cn(
"flex gap-2 text-sm",
statusStyles[status],
"fade-in-0 slide-in-from-top-2 animate-in",
className,
)}
{...props}
>
<div className="relative mt-0.5">
<Icon className="size-4" />
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
</div>
<div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div>
{description && (
<div className="text-muted-foreground text-xs">{description}</div>
)}
{children}
</div>
</div>
);
}
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
export function ChainOfThoughtSearchResults({
className,
...props
}: ChainOfThoughtSearchResultsProps) {
return (
<div
className={cn("flex flex-wrap items-center gap-2", className)}
{...props}
/>
);
}
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
export function ChainOfThoughtSearchResult({
className,
children,
...props
}: ChainOfThoughtSearchResultProps) {
return (
<Badge
className={cn("gap-1 px-2 py-0.5 text-xs font-normal", className)}
variant="secondary"
{...props}
>
{children}
</Badge>
);
}
export type ChainOfThoughtContentProps = ComponentProps<
typeof CollapsibleContent
>;
export function ChainOfThoughtContent({
className,
children,
...props
}: ChainOfThoughtContentProps) {
return (
<CollapsibleContent
className={cn(
"mt-2 space-y-3",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
className,
)}
{...props}
>
{children}
</CollapsibleContent>
);
}
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
caption?: string;
};
export function ChainOfThoughtImage({
className,
children,
caption,
...props
}: ChainOfThoughtImageProps) {
return (
<div className={cn("mt-2 space-y-2", className)} {...props}>
<div className="bg-muted relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg p-3">
{children}
</div>
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
</div>
);
}
@@ -19,19 +19,42 @@ export const Conversation = ({ className, ...props }: ConversationProps) => (
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
type ConversationContentChildren =
| ReactNode
| ((context: ReturnType<typeof useStickToBottomContext>) => ReactNode);
export type ConversationContentProps = Omit<
ComponentProps<"div">,
"children" | "ref"
> & {
children?: ConversationContentChildren;
scrollClassName?: string;
};
export const ConversationContent = ({
children,
className,
scrollClassName,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
}: ConversationContentProps) => {
const context = useStickToBottomContext();
const { contentRef, scrollRef } = context;
return (
<div
ref={scrollRef}
className={cn("h-full min-h-0 w-full overflow-y-auto", scrollClassName)}
>
<div
ref={contentRef}
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
>
{typeof children === "function" ? children(context) : children}
</div>
</div>
);
};
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
@@ -0,0 +1,196 @@
"use client";
import { CornerDownLeft, Settings, TriangleAlert } from "lucide-react";
import Link from "next/link";
import { type ReactNode, type SubmitEvent, useRef } from "react";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Button } from "@/components/shadcn/button/button";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
interface ChatComposerPanelProps {
feedback: string | null;
canRetry: boolean;
onRetry: () => void;
onDismissFeedback: () => void;
canSend: boolean;
input: string;
isStreaming: boolean;
modelSelector: ReactNode;
selectedConfigurationConnected: boolean;
onInputChange: (value: string) => void;
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
onSubmitText: (text: string) => Promise<void>;
}
// Feedback banner + input, shared by the empty and active chat layouts so the
// two branches can't drift apart.
export function ChatComposerPanel({
feedback,
canRetry,
onRetry,
onDismissFeedback,
...composerProps
}: ChatComposerPanelProps) {
return (
<>
<ChatFeedbackBar
feedback={feedback}
canRetry={canRetry}
onRetry={onRetry}
onDismiss={onDismissFeedback}
/>
<ChatComposer {...composerProps} />
</>
);
}
function ChatFeedbackBar({
feedback,
canRetry,
onRetry,
onDismiss,
}: {
feedback: string | null;
canRetry: boolean;
onRetry: () => void;
onDismiss: () => void;
}) {
if (!feedback) return null;
return (
<Alert variant="error" onClose={onDismiss} className="mb-3 pr-10">
<TriangleAlert />
<AlertDescription className="flex items-center justify-between gap-3">
<span>{feedback}</span>
{canRetry && (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
)}
</AlertDescription>
</Alert>
);
}
interface ChatComposerProps {
canSend: boolean;
input: string;
isStreaming: boolean;
modelSelector: ReactNode;
selectedConfigurationConnected: boolean;
onInputChange: (value: string) => void;
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
onSubmitText: (text: string) => Promise<void>;
}
function ChatComposer({
canSend,
input,
isStreaming,
selectedConfigurationConnected,
onInputChange,
modelSelector,
onSubmit,
onSubmitText,
}: ChatComposerProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useMountEffect(() => {
textareaRef.current?.focus();
});
return (
<form
className="border-border-neutral-secondary bg-bg-neutral-tertiary has-[textarea:focus]:border-border-input-primary-press flex min-h-[150px] w-full flex-col overflow-hidden rounded-[8px] border shadow-xs transition-all"
onSubmit={onSubmit}
>
<Textarea
ref={textareaRef}
aria-label="Message"
value={input}
onChange={(event) => onInputChange(event.target.value)}
disabled={!canSend}
placeholder={
selectedConfigurationConnected
? "Ask a question"
: "Connect a provider first"
}
variant="soft"
textareaSize="lg"
className="min-h-[104px] flex-1"
onKeyDown={(event) => {
// Ignore Enter while an IME composition is active so confirming an
// East Asian candidate doesn't submit the message prematurely.
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void onSubmitText(input);
}
}}
/>
<div className="flex items-center justify-between gap-3 px-3 pb-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Button type="button" variant="outline" size="icon-sm" asChild>
<Link
href={LIGHTHOUSE_ROUTE.SETTINGS}
aria-label="Lighthouse AI settings"
>
<Settings className="size-4" />
</Link>
</Button>
{modelSelector}
</div>
{isStreaming ? (
<div
className="flex size-8 items-center justify-center"
role="status"
aria-label="Generating response"
>
<Spinner className="size-4" />
</div>
) : (
<ChatSendButton canSend={canSend} hasText={input.trim().length > 0} />
)}
</div>
</form>
);
}
function ChatSendButton({
canSend,
hasText,
}: {
canSend: boolean;
hasText: boolean;
}) {
const sendButton = (
<Button type="submit" size="icon-sm" disabled={!canSend || !hasText}>
<CornerDownLeft className="size-4" />
</Button>
);
if (canSend && !hasText) {
return (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<span className="inline-flex cursor-not-allowed">{sendButton}</span>
</TooltipTrigger>
<TooltipContent side="top">Type something</TooltipContent>
</Tooltip>
);
}
return sendButton;
}
@@ -0,0 +1,374 @@
import { useEffect, useRef, useState } from "react";
export const REVEAL_DIRECTION = {
START: "start",
END: "end",
CENTER: "center",
} as const;
export const DECRYPTED_TEXT_ANIMATE_ON = {
VIEW: "view",
HOVER: "hover",
IN_VIEW_HOVER: "inViewHover",
CLICK: "click",
} as const;
export const DECRYPTED_TEXT_CLICK_MODE = {
ONCE: "once",
TOGGLE: "toggle",
} as const;
const DIRECTION = {
FORWARD: "forward",
REVERSE: "reverse",
} as const;
export type RevealDirection =
(typeof REVEAL_DIRECTION)[keyof typeof REVEAL_DIRECTION];
export type DecryptedTextAnimateOn =
(typeof DECRYPTED_TEXT_ANIMATE_ON)[keyof typeof DECRYPTED_TEXT_ANIMATE_ON];
export type DecryptedTextClickMode =
(typeof DECRYPTED_TEXT_CLICK_MODE)[keyof typeof DECRYPTED_TEXT_CLICK_MODE];
export const DEFAULT_CHARACTERS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+";
interface DecryptedTextControllerOptions {
text: string;
speed: number;
maxIterations: number;
sequential: boolean;
revealDirection: RevealDirection;
useOriginalCharsOnly: boolean;
characters: string;
animateOn: DecryptedTextAnimateOn;
clickMode: DecryptedTextClickMode;
}
type Direction = (typeof DIRECTION)[keyof typeof DIRECTION];
function buildPool(
text: string,
characters: string,
useOriginalCharsOnly: boolean,
): string[] {
return useOriginalCharsOnly
? Array.from(new Set(text.split(""))).filter((char) => char !== " ")
: characters.split("");
}
function shuffleText(
originalText: string,
revealed: Set<number>,
pool: string[],
): string {
return originalText
.split("")
.map((char, i) => {
if (char === " ") return " ";
if (revealed.has(i)) return originalText[i];
return pool[Math.floor(Math.random() * pool.length)];
})
.join("");
}
function computeOrder(len: number, revealDirection: RevealDirection): number[] {
const order: number[] = [];
if (len <= 0) return order;
if (revealDirection === "start") {
for (let i = 0; i < len; i++) order.push(i);
return order;
}
if (revealDirection === "end") {
for (let i = len - 1; i >= 0; i--) order.push(i);
return order;
}
const middle = Math.floor(len / 2);
let offset = 0;
while (order.length < len) {
if (offset % 2 === 0) {
const idx = middle + offset / 2;
if (idx >= 0 && idx < len) order.push(idx);
} else {
const idx = middle - Math.ceil(offset / 2);
if (idx >= 0 && idx < len) order.push(idx);
}
offset++;
}
return order.slice(0, len);
}
function fillAllIndices(len: number): Set<number> {
const s = new Set<number>();
for (let i = 0; i < len; i++) s.add(i);
return s;
}
function removeRandomIndices(set: Set<number>, count: number): Set<number> {
const arr = Array.from(set);
for (let i = 0; i < count && arr.length > 0; i++) {
const idx = Math.floor(Math.random() * arr.length);
arr.splice(idx, 1);
}
return new Set(arr);
}
export function useDecryptedTextController({
text,
speed,
maxIterations,
sequential,
revealDirection,
useOriginalCharsOnly,
characters,
animateOn,
clickMode,
}: DecryptedTextControllerOptions) {
const [displayText, setDisplayText] = useState<string>(text);
const [isAnimating, setIsAnimating] = useState<boolean>(false);
const [revealedIndices, setRevealedIndices] = useState<Set<number>>(
new Set(),
);
const [hasAnimated, setHasAnimated] = useState<boolean>(false);
const [isDecrypted, setIsDecrypted] = useState<boolean>(
animateOn !== "click",
);
const [direction, setDirection] = useState<Direction>(DIRECTION.FORWARD);
const containerRef = useRef<HTMLSpanElement>(null);
const orderRef = useRef<number[]>([]);
const pointerRef = useRef<number>(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pool = buildPool(text, characters, useOriginalCharsOnly);
const triggerDecrypt = () => {
if (sequential) {
orderRef.current = computeOrder(text.length, revealDirection);
pointerRef.current = 0;
}
setRevealedIndices(new Set());
setDirection(DIRECTION.FORWARD);
setIsAnimating(true);
};
const triggerReverse = () => {
if (sequential) {
orderRef.current = computeOrder(text.length, revealDirection)
.slice()
.reverse();
pointerRef.current = 0;
}
setRevealedIndices(fillAllIndices(text.length));
setDisplayText(shuffleText(text, fillAllIndices(text.length), pool));
setDirection(DIRECTION.REVERSE);
setIsAnimating(true);
};
useEffect(() => {
if (!isAnimating) return;
let currentIteration = 0;
const effectPool = buildPool(text, characters, useOriginalCharsOnly);
const getNextIndex = (revealedSet: Set<number>): number => {
const textLength = text.length;
switch (revealDirection) {
case "start":
return revealedSet.size;
case "end":
return textLength - 1 - revealedSet.size;
case "center": {
const middle = Math.floor(textLength / 2);
const offset = Math.floor(revealedSet.size / 2);
const nextIndex =
revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1;
if (
nextIndex >= 0 &&
nextIndex < textLength &&
!revealedSet.has(nextIndex)
) {
return nextIndex;
}
for (let i = 0; i < textLength; i++) {
if (!revealedSet.has(i)) return i;
}
return 0;
}
default:
return revealedSet.size;
}
};
intervalRef.current = setInterval(() => {
setRevealedIndices((prevRevealed) => {
if (sequential) {
if (direction === DIRECTION.FORWARD) {
if (prevRevealed.size < text.length) {
const nextIndex = getNextIndex(prevRevealed);
const newRevealed = new Set(prevRevealed);
newRevealed.add(nextIndex);
setDisplayText(shuffleText(text, newRevealed, effectPool));
return newRevealed;
}
clearInterval(intervalRef.current ?? undefined);
setIsAnimating(false);
setIsDecrypted(true);
return prevRevealed;
}
if (pointerRef.current < orderRef.current.length) {
const idxToRemove = orderRef.current[pointerRef.current++];
const newRevealed = new Set(prevRevealed);
newRevealed.delete(idxToRemove);
setDisplayText(shuffleText(text, newRevealed, effectPool));
if (newRevealed.size === 0) {
clearInterval(intervalRef.current ?? undefined);
setIsAnimating(false);
setIsDecrypted(false);
}
return newRevealed;
}
clearInterval(intervalRef.current ?? undefined);
setIsAnimating(false);
setIsDecrypted(false);
return prevRevealed;
}
if (direction === DIRECTION.FORWARD) {
setDisplayText(shuffleText(text, prevRevealed, effectPool));
currentIteration++;
if (currentIteration >= maxIterations) {
clearInterval(intervalRef.current ?? undefined);
setIsAnimating(false);
setDisplayText(text);
setIsDecrypted(true);
}
return prevRevealed;
}
const currentSet =
prevRevealed.size === 0 ? fillAllIndices(text.length) : prevRevealed;
const removeCount = Math.max(
1,
Math.ceil(text.length / Math.max(1, maxIterations)),
);
const nextSet = removeRandomIndices(currentSet, removeCount);
setDisplayText(shuffleText(text, nextSet, effectPool));
currentIteration++;
if (nextSet.size === 0 || currentIteration >= maxIterations) {
clearInterval(intervalRef.current ?? undefined);
setIsAnimating(false);
setIsDecrypted(false);
setDisplayText(shuffleText(text, new Set(), effectPool));
return new Set();
}
return nextSet;
});
}, speed);
return () => clearInterval(intervalRef.current ?? undefined);
}, [
isAnimating,
text,
speed,
maxIterations,
sequential,
revealDirection,
direction,
characters,
useOriginalCharsOnly,
]);
const handleClick = () => {
if (animateOn !== "click") return;
if (clickMode === "once") {
if (isDecrypted) return;
triggerDecrypt();
}
if (clickMode === "toggle") {
if (isDecrypted) {
triggerReverse();
} else {
triggerDecrypt();
}
}
};
const triggerHoverDecrypt = () => {
if (isAnimating) return;
setRevealedIndices(new Set());
setIsDecrypted(false);
setDisplayText(text);
setDirection(DIRECTION.FORWARD);
setIsAnimating(true);
};
const resetToPlainText = () => {
clearInterval(intervalRef.current ?? undefined);
setIsAnimating(false);
setRevealedIndices(new Set());
setDisplayText(text);
setIsDecrypted(true);
setDirection(DIRECTION.FORWARD);
};
useEffect(() => {
if (animateOn !== "view" && animateOn !== "inViewHover") return;
if (typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !hasAnimated) {
if (sequential) {
orderRef.current = computeOrder(text.length, revealDirection);
pointerRef.current = 0;
}
setRevealedIndices(new Set());
setDirection(DIRECTION.FORWARD);
setIsAnimating(true);
setHasAnimated(true);
}
});
},
{ root: null, rootMargin: "0px", threshold: 0.1 },
);
const currentRef = containerRef.current;
if (currentRef) observer.observe(currentRef);
return () => {
if (currentRef) observer.unobserve(currentRef);
};
}, [animateOn, hasAnimated, sequential, text, revealDirection]);
useEffect(() => {
if (animateOn === "click") {
const emptySet = new Set<number>();
setRevealedIndices(emptySet);
setDisplayText(
shuffleText(text, emptySet, buildPool(text, characters, false)),
);
setIsDecrypted(false);
} else {
setRevealedIndices(new Set());
setDisplayText(text);
setIsDecrypted(true);
}
setDirection(DIRECTION.FORWARD);
}, [animateOn, text, characters]);
return {
containerRef,
displayText,
handleClick,
isAnimating,
isDecrypted,
resetToPlainText,
revealedIndices,
triggerHoverDecrypt,
};
}
@@ -0,0 +1,23 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DecryptedText } from "./decrypted-text";
describe("DecryptedText", () => {
it("keeps the accessible text stable while the visible text is encrypted", async () => {
// Given / When
const { container } = render(
<DecryptedText text="Secret Text" animateOn="click" characters="X" />,
);
// Then
await waitFor(() =>
expect(container.querySelector('[aria-hidden="true"]')).toHaveTextContent(
"XXXXXX XXXX",
),
);
expect(
screen.getByText("Secret Text", { selector: ".sr-only" }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,102 @@
"use client";
import { type HTMLMotionProps, motion } from "framer-motion";
import { cn } from "@/lib/utils";
import {
type DecryptedTextAnimateOn,
type DecryptedTextClickMode,
DEFAULT_CHARACTERS,
type RevealDirection,
useDecryptedTextController,
} from "./decrypted-text-hooks";
interface DecryptedTextProps extends HTMLMotionProps<"span"> {
text: string;
speed?: number;
maxIterations?: number;
sequential?: boolean;
revealDirection?: RevealDirection;
useOriginalCharsOnly?: boolean;
characters?: string;
className?: string;
encryptedClassName?: string;
parentClassName?: string;
animateOn?: DecryptedTextAnimateOn;
clickMode?: DecryptedTextClickMode;
}
// Ported from reactbits DecryptedText, adapted to Prowler conventions:
// framer-motion import, no useMemo/useCallback (React Compiler), pure helpers
// hoisted to module scope, and IntersectionObserver guarded for SSR/test safety.
export function DecryptedText({
text,
speed = 50,
maxIterations = 10,
sequential = false,
revealDirection = "start",
useOriginalCharsOnly = false,
characters = DEFAULT_CHARACTERS,
className = "",
parentClassName = "",
encryptedClassName = "",
animateOn = "hover",
clickMode = "once",
...props
}: DecryptedTextProps) {
const {
containerRef,
displayText,
handleClick,
isAnimating,
isDecrypted,
resetToPlainText,
revealedIndices,
triggerHoverDecrypt,
} = useDecryptedTextController({
text,
speed,
maxIterations,
sequential,
revealDirection,
useOriginalCharsOnly,
characters,
animateOn,
clickMode,
});
const animateProps =
animateOn === "hover" || animateOn === "inViewHover"
? { onMouseEnter: triggerHoverDecrypt, onMouseLeave: resetToPlainText }
: animateOn === "click"
? { onClick: handleClick }
: {};
return (
<motion.span
ref={containerRef}
className={cn("inline-block whitespace-pre-wrap", parentClassName)}
{...animateProps}
{...props}
>
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{displayText.split("").map((char, index) => {
const isRevealedOrDone =
revealedIndices.has(index) || (!isAnimating && isDecrypted);
return (
<span
key={index}
className={isRevealedOrDone ? className : encryptedClassName}
>
{char}
</span>
);
})}
</span>
</motion.span>
);
}
@@ -0,0 +1,107 @@
"use client";
import { Cloud, FileCheck2, Network, ShieldAlert } from "lucide-react";
import { type ReactNode, type SubmitEvent } from "react";
import { LighthouseIconWithAura } from "@/components/icons";
import { Button } from "@/components/shadcn/button/button";
import { ChatComposerPanel } from "./composer";
import { DecryptedText } from "./decrypted-text";
const LIGHTHOUSE_V2_SUGGESTIONS = [
{
label: "Critical findings",
prompt: "Summarize my most critical open findings and what to fix first.",
icon: ShieldAlert,
},
{
label: "Compliance gaps",
prompt: "What are my highest-impact compliance gaps right now?",
icon: FileCheck2,
},
{
label: "Attack paths",
prompt: "Find risky attack paths and explain the exposure.",
icon: Network,
},
{
label: "How can I onboard to my AWS account?",
prompt: "How can I onboard to my AWS account?",
icon: Cloud,
},
] as const;
interface ChatEmptyStateProps {
feedback: string | null;
canRetry: boolean;
onRetry: () => void;
onDismissFeedback: () => void;
canSend: boolean;
input: string;
isStreaming: boolean;
modelSelector: ReactNode;
selectedConfigurationConnected: boolean;
onInputChange: (value: string) => void;
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
onSubmitText: (text: string) => Promise<void>;
}
export function ChatEmptyState({
onInputChange,
...composerPanelProps
}: ChatEmptyStateProps) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center px-4 py-10 md:px-8">
<div className="mx-auto flex w-full max-w-5xl flex-col items-center gap-5">
<LighthouseIconWithAura className="size-20" />
<div className="space-y-2 text-center">
<h1 className="text-text-neutral-primary text-3xl font-semibold">
<DecryptedText
text="Find and remediate which actually matters."
animateOn="view"
sequential
speed={40}
encryptedClassName="text-text-neutral-tertiary"
/>
</h1>
<p className="text-text-neutral-secondary text-base italic">
<DecryptedText
text="What do you want to know today?"
animateOn="view"
sequential
speed={40}
encryptedClassName="text-text-neutral-tertiary"
/>
</p>
</div>
<div className="w-full max-w-4xl">
<ChatComposerPanel
{...composerPanelProps}
onInputChange={onInputChange}
/>
</div>
<div className="flex max-w-4xl flex-wrap items-center justify-center gap-2">
<span className="text-text-neutral-secondary basis-full text-center text-sm font-medium">
Try Lighthouse AI for...
</span>
{LIGHTHOUSE_V2_SUGGESTIONS.map((suggestion) => {
const Icon = suggestion.icon;
return (
<Button
key={suggestion.label}
type="button"
variant="outline"
size="sm"
onClick={() => onInputChange(suggestion.prompt)}
>
<Icon className="size-4" />
{suggestion.label}
</Button>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1 @@
export { LighthouseV2ChatPage } from "./lighthouse-v2-chat-page";
@@ -0,0 +1,744 @@
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT,
notifyLighthouseV2SessionArchived,
} from "@/app/(prowler)/lighthouse/_lib/session-events";
import type {
LighthouseV2Configuration,
LighthouseV2Message,
LighthouseV2SupportedModel,
LighthouseV2SupportedProvider,
} from "@/app/(prowler)/lighthouse/_types";
import { LighthouseV2ChatPage } from "./lighthouse-v2-chat-page";
// Controllable EventSource mock: records each instance so tests can drive
// named SSE events and connection failures, while still being a vi.fn so
// `expect(EventSource).toHaveBeenCalledWith(...)` keeps working.
interface MockEventSource {
url: string;
readyState: number;
onerror: ((event: Event) => void) | null;
listeners: Map<string, Set<EventListener>>;
addEventListener: (type: string, cb: EventListener) => void;
close: ReturnType<typeof vi.fn>;
emit: (type: string, data: unknown) => void;
fail: (readyState: number) => void;
}
let eventSources: MockEventSource[] = [];
function stubEventSource() {
eventSources = [];
const EventSourceMock = vi.fn(function (this: MockEventSource, url: string) {
this.url = url;
this.readyState = 0;
this.onerror = null;
this.listeners = new Map();
this.addEventListener = (type: string, cb: EventListener) => {
const set = this.listeners.get(type) ?? new Set<EventListener>();
set.add(cb);
this.listeners.set(type, set);
};
this.close = vi.fn(() => {
this.readyState = 2;
});
this.emit = (type: string, data: unknown) => {
const event = new MessageEvent(type, { data: JSON.stringify(data) });
this.listeners.get(type)?.forEach((cb) => cb(event));
};
this.fail = (readyState: number) => {
this.readyState = readyState;
this.onerror?.(new Event("error"));
};
eventSources.push(this);
});
Object.assign(EventSourceMock, { CONNECTING: 0, OPEN: 1, CLOSED: 2 });
vi.stubGlobal("EventSource", EventSourceMock);
}
const {
createSessionMock,
getMessagesMock,
sendMessageMock,
updateConfigurationMock,
} = vi.hoisted(() => ({
createSessionMock: vi.fn(),
getMessagesMock: vi.fn(),
sendMessageMock: vi.fn(),
updateConfigurationMock: vi.fn(),
}));
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
createLighthouseV2Session: createSessionMock,
getLighthouseV2Messages: getMessagesMock,
sendLighthouseV2Message: sendMessageMock,
updateLighthouseV2Configuration: updateConfigurationMock,
}));
// Streamdown pulls in shiki/wasm syntax highlighting that doesn't run under
// jsdom; render its text passthrough so message bodies are still assertable.
vi.mock("streamdown", () => ({
Streamdown: ({ children }: { children: ReactNode }) => <>{children}</>,
defaultRehypePlugins: { katex: undefined, harden: undefined },
}));
const configurations: LighthouseV2Configuration[] = [
{
id: "config-openai",
providerType: "openai",
baseUrl: null,
defaultModel: "gpt-5.1",
businessContext: "Production account",
connected: true,
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
insertedAt: "2026-06-24T09:00:00Z",
updatedAt: "2026-06-24T10:00:00Z",
},
{
id: "config-bedrock",
providerType: "bedrock",
baseUrl: null,
defaultModel: "anthropic.claude-4",
businessContext: "Production account",
connected: true,
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
insertedAt: "2026-06-23T09:00:00Z",
updatedAt: "2026-06-23T10:00:00Z",
},
];
const modelsByProvider = {
openai: [model("gpt-5.1"), model("gpt-4.1")],
bedrock: [model("anthropic.claude-4")],
"openai-compatible": [model("llama-3.3")],
};
const supportedProviders: LighthouseV2SupportedProvider[] = [
{ id: "openai", name: "OpenAI" },
{ id: "bedrock", name: "AWS Bedrock" },
{ id: "openai-compatible", name: "OpenAI Compatible" },
];
describe("LighthouseV2ChatPage", () => {
beforeEach(() => {
vi.stubGlobal(
"ResizeObserver",
class ResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
},
);
Object.defineProperty(Element.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
createSessionMock.mockReset();
getMessagesMock.mockReset();
sendMessageMock.mockReset();
updateConfigurationMock.mockReset();
// The mock never fires "open": the client must POST the message without
// waiting for it (the backend sends no bytes until the worker emits, which
// only happens after the POST). This is the regression guard for the
// open-gate deadlock.
stubEventSource();
createSessionMock.mockResolvedValue({
data: {
id: "session-1",
title: "Summarize findings",
isArchived: false,
insertedAt: "2026-06-24T10:00:00Z",
updatedAt: "2026-06-24T10:00:00Z",
},
});
getMessagesMock.mockResolvedValue({ data: [] });
sendMessageMock.mockResolvedValue({
data: {
task: {
id: "task-1",
name: "lighthouse-run",
state: "executing",
},
},
});
updateConfigurationMock.mockResolvedValue({ data: configurations[1] });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("renders the searchable model selector and settings shortcut", () => {
// Given / When
renderPage();
// Then
expect(screen.getByRole("combobox", { name: "Model" })).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Lighthouse AI settings" }),
).toHaveAttribute("href", "/lighthouse/settings");
});
it("shows the current OpenAI model without a selector when OpenAI is the only connected provider", () => {
// Given / When
renderPage({
configurations: [
{ ...configurations[0], defaultModel: "gpt-5.1", connected: true },
{ ...configurations[1], connected: false },
],
modelsByProvider: {
openai: [model("gpt-5.1", "GPT-5.1")],
bedrock: [model("anthropic.claude-4")],
"openai-compatible": [model("llama-3.3")],
},
});
// Then
expect(
screen.queryByRole("combobox", { name: "Model" }),
).not.toBeInTheDocument();
const currentModel = screen.getByLabelText("Current model: OpenAI GPT-5.1");
expect(within(currentModel).getByText("OpenAI")).toBeInTheDocument();
expect(within(currentModel).getByText("GPT-5.1")).toBeInTheDocument();
});
it("defaults to gpt-5.5 when OpenAI has no remembered model", () => {
// Given / When
renderPage({
configurations: [
{ ...configurations[0], defaultModel: null, connected: true },
{ ...configurations[1], connected: false },
],
modelsByProvider: {
openai: [model("gpt-4.1", "GPT-4.1"), model("gpt-5.5", "GPT-5.5")],
bedrock: [model("anthropic.claude-4")],
"openai-compatible": [model("llama-3.3")],
},
});
// Then
const currentModel = screen.getByLabelText("Current model: OpenAI GPT-5.5");
expect(within(currentModel).getByText("GPT-5.5")).toBeInTheDocument();
});
it("uses the AWS onboarding quick prompt instead of the docs prompt", async () => {
// Given
const user = userEvent.setup();
renderPage();
// When
await user.click(
screen.getByRole("button", {
name: "How can I onboard to my AWS account?",
}),
);
// Then
expect(
screen.queryByRole("button", { name: "Docs" }),
).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Message" })).toHaveValue(
"How can I onboard to my AWS account?",
);
});
it("shows model names in the selector while keeping model ids for persistence", async () => {
// Given
const user = userEvent.setup();
renderPage({
configurations: [
{ ...configurations[0], defaultModel: "gpt-5.1" },
{
...configurations[1],
defaultModel: "us.anthropic.claude-sonnet-4-20250514-v1:0",
},
],
modelsByProvider: {
openai: [model("gpt-5.1", "GPT-5.1")],
bedrock: [
model(
"us.anthropic.claude-sonnet-4-20250514-v1:0",
"Claude Sonnet 4",
),
],
"openai-compatible": [],
},
});
// When
const modelSelector = screen.getByRole("combobox", { name: "Model" });
await user.click(modelSelector);
// Then
expect(modelSelector).toHaveTextContent("GPT-5.1");
expect(
await screen.findByRole("option", { name: "Claude Sonnet 4" }),
).toBeInTheDocument();
expect(
screen.queryByText("us.anthropic.claude-sonnet-4-20250514-v1:0"),
).not.toBeInTheDocument();
// When
await user.click(screen.getByRole("option", { name: "Claude Sonnet 4" }));
// Then
await waitFor(() =>
expect(updateConfigurationMock).toHaveBeenCalledWith("config-bedrock", {
defaultModel: "us.anthropic.claude-sonnet-4-20250514-v1:0",
}),
);
expect(modelSelector).toHaveTextContent("Claude Sonnet 4");
});
it("uses supported provider names as model selector section headings", async () => {
// Given
const user = userEvent.setup();
renderPage({
configurations: [
...configurations,
{
id: "config-openai-compatible",
providerType: "openai-compatible",
baseUrl: "https://example.com/v1",
defaultModel: "llama-3.3",
businessContext: "Production account",
connected: true,
connectionLastCheckedAt: "2026-06-22T10:00:00Z",
insertedAt: "2026-06-22T09:00:00Z",
updatedAt: "2026-06-22T10:00:00Z",
},
],
supportedProviders,
});
// When
await user.click(screen.getByRole("combobox", { name: "Model" }));
// Then
expect(await screen.findByText("AWS Bedrock")).toBeInTheDocument();
expect(screen.getByText("OpenAI Compatible")).toBeInTheDocument();
expect(screen.queryByText("Amazon Bedrock")).not.toBeInTheDocument();
});
it("uses the tuned scrollbar and bottom fade without a composer separator", () => {
// Given / When
const { container } = renderPage({
initialMessages: [message("message-1", "assistant", "Existing answer")],
});
// Then
const conversation = screen.getByRole("log");
const scrollViewport = conversation.firstElementChild as HTMLElement;
const content = scrollViewport.firstElementChild as HTMLElement;
const scrollFade = container.querySelector(
'[data-slot="lighthouse-v2-chat-scroll-fade"]',
);
expect(conversation).toHaveClass("h-full", "min-h-0");
expect(conversation.parentElement).toHaveClass("flex", "overflow-hidden");
expect(scrollViewport).toHaveClass(
"minimal-scrollbar",
"overflow-x-hidden",
"overflow-y-auto",
);
expect(content).toHaveClass("pb-20");
expect(scrollFade).toHaveClass(
"pointer-events-none",
"absolute",
"bottom-0",
"right-2",
"h-16",
"bg-gradient-to-t",
"from-bg-neutral-secondary",
"to-transparent",
);
expect(
container.querySelector(
'[data-slot="lighthouse-v2-chat-composer-panel"]',
),
).not.toHaveClass("border-t");
});
it("opens the highest-priority connected provider with its remembered model", async () => {
// Given: both OpenAI and Bedrock are connected; OpenAI outranks Bedrock
const user = userEvent.setup();
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
renderPage();
// When
await user.type(
screen.getByRole("textbox", { name: "Message" }),
["Summarize findings", "{Enter}"].join(""),
);
// Then: the message is sent with OpenAI and its remembered defaultModel,
// even though EventSource never fires "open"
await waitFor(() =>
expect(sendMessageMock).toHaveBeenCalledWith({
sessionId: "session-1",
text: "Summarize findings",
provider: "openai",
model: "gpt-5.1",
}),
);
expect(createSessionMock).toHaveBeenCalledWith("Summarize findings");
// The session URL is set in place (no router navigation / remount)
expect(replaceStateSpy).toHaveBeenCalledWith(
null,
"",
"/lighthouse?session=session-1",
);
// The stream is opened against our same-origin SSE proxy (not the
// cross-origin API host), so the browser EventSource can actually connect.
expect(EventSource).toHaveBeenCalledWith(
"/api/lighthouse/v2/sessions/session-1/event-stream",
);
replaceStateSpy.mockRestore();
});
it("opens a lower-priority provider when the higher-priority one is disconnected", async () => {
// Given: only Bedrock is connected
const user = userEvent.setup();
renderPage({
configurations: [
{ ...configurations[0], connected: false },
configurations[1],
],
});
// When
await user.type(
screen.getByRole("textbox", { name: "Message" }),
["Summarize findings", "{Enter}"].join(""),
);
// Then
await waitFor(() =>
expect(sendMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "bedrock",
model: "anthropic.claude-4",
}),
),
);
});
it("falls back to the first supported model when the remembered model is unsupported", async () => {
// Given: OpenAI's remembered default model is no longer offered
const user = userEvent.setup();
renderPage({
configurations: [
{ ...configurations[0], defaultModel: "missing-model" },
configurations[1],
],
});
// When
await user.type(
screen.getByRole("textbox", { name: "Message" }),
["Summarize findings", "{Enter}"].join(""),
);
// Then: OpenAI stays selected (highest priority) but on its first model
await waitFor(() =>
expect(sendMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
model: "gpt-5.1",
}),
),
);
});
it("persists the selected chat model as that provider's default", async () => {
// Given
const user = userEvent.setup();
renderPage();
// When
await user.click(screen.getByRole("combobox", { name: "Model" }));
await user.click(
await screen.findByRole("option", { name: "anthropic.claude-4" }),
);
// Then: only the chosen provider's config is updated, by id
await waitFor(() =>
expect(updateConfigurationMock).toHaveBeenCalledWith("config-bedrock", {
defaultModel: "anthropic.claude-4",
}),
);
});
it("keeps the chosen model applied and surfaces the backend reason when saving the default fails", async () => {
// Given
const user = userEvent.setup();
updateConfigurationMock.mockResolvedValue({
error: "Invalid model 'anthropic.claude-4' for provider 'bedrock'.",
status: 400,
});
renderPage();
// When
await user.click(screen.getByRole("combobox", { name: "Model" }));
await user.click(
await screen.findByRole("option", { name: "anthropic.claude-4" }),
);
// Then: the failed save shows the real backend message as an error alert,
// and the selection stays applied so the connected provider remains usable.
expect(await screen.findByRole("alert")).toHaveTextContent(
"Invalid model 'anthropic.claude-4' for provider 'bedrock'.",
);
expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent(
"anthropic.claude-4",
);
});
it("updates the URL before notifying session history listeners", async () => {
// Given
const user = userEvent.setup();
const notifiedUrls: string[] = [];
const recordCurrentUrl = () => {
notifiedUrls.push(`${window.location.pathname}${window.location.search}`);
};
try {
// Register inside the try so a throw in renderPage() can't leak the
// listener into later tests (the finally only runs if we entered the try).
window.addEventListener(
LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT,
recordCurrentUrl,
);
renderPage();
// When
await user.type(
screen.getByRole("textbox", { name: "Message" }),
["Summarize findings", "{Enter}"].join(""),
);
// Then
await waitFor(() => expect(notifiedUrls.length).toBeGreaterThan(0));
expect(notifiedUrls[0]).toBe("/lighthouse?session=session-1");
} finally {
window.removeEventListener(
LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT,
recordCurrentUrl,
);
}
});
it("subscribes to the stream before sending the message (no replay buffer)", async () => {
// Given
const user = userEvent.setup();
renderPage();
// When
await user.type(
screen.getByRole("textbox", { name: "Message" }),
["Summarize findings", "{Enter}"].join(""),
);
// Then: the EventSource must be constructed before the POST, otherwise
// early tokens emitted by the worker would be lost (backend has no replay).
await waitFor(() => expect(sendMessageMock).toHaveBeenCalled());
const eventSourceOrder = vi.mocked(EventSource).mock.invocationCallOrder[0];
const sendOrder = sendMessageMock.mock.invocationCallOrder[0];
expect(eventSourceOrder).toBeLessThan(sendOrder);
});
it("renders a copy button (copies text) and an ISO timestamp under each message", async () => {
// Given
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
const { container } = renderPage({
initialMessages: [message("message-1", "assistant", "Existing answer")],
});
// When
await user.click(screen.getByRole("button", { name: "Copy message" }));
// Then: copies the message text, and the timestamp carries the raw ISO
expect(writeText).toHaveBeenCalledWith("Existing answer");
expect(container.querySelector("time")).toHaveAttribute(
"datetime",
"2026-06-25T10:00:00Z",
);
});
it("renders streamed deltas and reloads persisted messages on message.end", async () => {
// Given
const user = userEvent.setup();
renderPage();
await user.type(
screen.getByRole("textbox", { name: "Message" }),
["Summarize findings", "{Enter}"].join(""),
);
await waitFor(() => expect(eventSources).toHaveLength(1));
const source = eventSources[0];
// When a delta arrives, it renders live
act(() => source.emit("message.delta", { content: "Hello there" }));
expect(await screen.findByText("Hello there")).toBeInTheDocument();
// When the run ends, the full persisted message is reloaded from the DB
act(() => source.emit("message.end", { message_id: "message-1" }));
await waitFor(() =>
expect(getMessagesMock).toHaveBeenCalledWith("session-1"),
);
expect(source.close).toHaveBeenCalled();
});
it("resets to a new chat when the live-created session is archived from the sidebar", async () => {
// Given: a session created in this chat (its URL was set via replaceState,
// so the sidebar cannot see it in Next's search params)
const user = userEvent.setup();
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
renderPage();
await user.type(
screen.getByRole("textbox", { name: "Message" }),
["Summarize findings", "{Enter}"].join(""),
);
await waitFor(() => expect(sendMessageMock).toHaveBeenCalled());
// When: the sidebar archives that same session
act(() => notifyLighthouseV2SessionArchived("session-1"));
// Then: the chat resets in place and the URL leaves the dead session
await waitFor(() =>
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "/lighthouse"),
);
expect(screen.queryByText("Summarize findings")).not.toBeInTheDocument();
expect(eventSources[0].close).toHaveBeenCalled();
replaceStateSpy.mockRestore();
});
it("drops a stale message reload that resolves after the session is archived", async () => {
// Given: a live session whose message.end reload is still in flight
const user = userEvent.setup();
let resolveReload: (value: unknown) => void = () => {};
getMessagesMock.mockReturnValueOnce(
new Promise((resolve) => {
resolveReload = resolve;
}),
);
renderPage();
await user.type(
screen.getByRole("textbox", { name: "Message" }),
["Summarize findings", "{Enter}"].join(""),
);
await waitFor(() => expect(eventSources).toHaveLength(1));
// When: the run ends (starting the async reload) and, before it resolves,
// the open session is archived and the chat resets
act(() => eventSources[0].emit("message.end", { message_id: "message-1" }));
await waitFor(() =>
expect(getMessagesMock).toHaveBeenCalledWith("session-1"),
);
act(() => notifyLighthouseV2SessionArchived("session-1"));
// The reload finally resolves with the (now archived) session's messages
await act(async () => {
resolveReload({
data: [message("message-1", "assistant", "Archived answer")],
});
});
// Then: the stale reload is ignored so the reset chat is not repopulated
expect(screen.queryByText("Archived answer")).not.toBeInTheDocument();
});
it("keeps the conversation when a different session is archived", async () => {
// Given
renderPage({
initialSessionId: "session-2",
initialMessages: [message("message-1", "assistant", "Existing answer")],
});
// When
act(() => notifyLighthouseV2SessionArchived("session-other"));
// Then
expect(screen.getByText("Existing answer")).toBeInTheDocument();
});
it("surfaces a connection error when the stream closes without retrying", async () => {
// Given
const user = userEvent.setup();
renderPage();
await user.type(
screen.getByRole("textbox", { name: "Message" }),
["Summarize findings", "{Enter}"].join(""),
);
await waitFor(() => expect(eventSources).toHaveLength(1));
// When the EventSource fails terminally (e.g. 401/404 on the SSE GET)
act(() => eventSources[0].fail(2 /* EventSource.CLOSED */));
// Then a clear error is shown instead of an endless "Reconnecting" spinner
expect(
await screen.findByText("Unable to connect to the response stream."),
).toBeInTheDocument();
});
});
type RenderPageProps = Partial<Parameters<typeof LighthouseV2ChatPage>[0]>;
function renderPage(props?: RenderPageProps) {
const componentProps = {
configurations: props?.configurations ?? configurations,
modelsByProvider: props?.modelsByProvider ?? modelsByProvider,
supportedProviders: props?.supportedProviders ?? supportedProviders,
initialSessionId: props?.initialSessionId,
initialMessages: props?.initialMessages ?? [],
initialPrompt: props?.initialPrompt,
} satisfies Parameters<typeof LighthouseV2ChatPage>[0];
return render(<LighthouseV2ChatPage {...componentProps} />);
}
function model(id: string, name = id): LighthouseV2SupportedModel {
return {
id,
name,
maxInputTokens: null,
maxOutputTokens: null,
supportsFunctionCalling: null,
supportsVision: null,
supportsReasoning: null,
};
}
function message(
id: string,
role: LighthouseV2Message["role"],
content: string,
): LighthouseV2Message {
return {
id,
role,
model: null,
tokenUsage: null,
insertedAt: "2026-06-25T10:00:00Z",
parts: [
{
id: `${id}-part`,
type: "text",
content,
toolCallOutcome: null,
insertedAt: "2026-06-25T10:00:00Z",
updatedAt: "2026-06-25T10:00:00Z",
},
],
};
}
@@ -0,0 +1,658 @@
"use client";
import { type SubmitEvent, useRef, useState } from "react";
import {
createLighthouseV2Session,
getLighthouseV2Messages,
sendLighthouseV2Message,
updateLighthouseV2Configuration,
} from "@/app/(prowler)/lighthouse/_actions";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/app/(prowler)/lighthouse/_components/ai-elements/conversation";
import {
createInitialLighthouseV2StreamState,
type LighthouseV2StreamState,
reduceLighthouseV2Event,
} from "@/app/(prowler)/lighthouse/_lib/event-reducer";
import {
buildOptimisticMessage,
buildSessionTitle,
} from "@/app/(prowler)/lighthouse/_lib/messages";
import {
buildLighthouseV2ModelSelectionValue,
type LighthouseV2ModelSelection,
parseLighthouseV2ModelSelectionValue,
} from "@/app/(prowler)/lighthouse/_lib/model-selection";
import {
LIGHTHOUSE_V2_NEW_CHAT_EVENT,
LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT,
notifyLighthouseV2SessionsChanged,
} from "@/app/(prowler)/lighthouse/_lib/session-events";
import { parseStreamEvent } from "@/app/(prowler)/lighthouse/_lib/stream-event-parser";
import { buildLighthouseV2StreamUrl } from "@/app/(prowler)/lighthouse/_lib/stream-url";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
LIGHTHOUSE_V2_SSE_EVENT,
type LighthouseV2Configuration,
type LighthouseV2Message,
type LighthouseV2ProviderType,
type LighthouseV2SSEEvent,
type LighthouseV2SupportedModel,
type LighthouseV2SupportedProvider,
} from "@/app/(prowler)/lighthouse/_types";
import { Card } from "@/components/shadcn";
import {
Combobox,
type ComboboxGroup,
} from "@/components/shadcn/combobox/combobox";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { ProviderIcon } from "../config/provider-icon";
import { ChatComposerPanel } from "./composer";
import { ChatEmptyState } from "./empty-state";
import { MessageBubble } from "./message-bubble";
import { StreamingAssistantMessage } from "./streaming-message";
interface LighthouseV2ChatPageProps {
configurations: LighthouseV2Configuration[];
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>;
supportedProviders: LighthouseV2SupportedProvider[];
initialSessionId?: string;
initialMessages: LighthouseV2Message[];
initialPrompt?: string;
initialError?: string;
}
export function LighthouseV2ChatPage({
configurations,
modelsByProvider,
supportedProviders,
initialSessionId,
initialMessages,
initialPrompt,
initialError,
}: LighthouseV2ChatPageProps) {
const eventSourceRef = useRef<EventSource | null>(null);
const initialPromptSentRef = useRef(false);
const connectedConfigurations = configurations.filter(
(configuration) => configuration.connected === true,
);
const initialModelSelection = resolveInitialModelSelection(
connectedConfigurations,
modelsByProvider,
);
const [selectedModelSelection, setSelectedModelSelection] =
useState<LighthouseV2ModelSelection | null>(initialModelSelection);
const [modelPreferenceSaving, setModelPreferenceSaving] = useState(false);
const [activeSessionId, setActiveSessionId] = useState<string | null>(
initialSessionId ?? null,
);
// Mirror for window listeners registered on mount, whose closures would
// otherwise keep the first render's activeSessionId.
const activeSessionIdRef = useRef<string | null>(initialSessionId ?? null);
const [messages, setMessages] = useState(initialMessages);
const [input, setInput] = useState("");
const [feedback, setFeedback] = useState<string | null>(initialError ?? null);
const [blockedByConflict, setBlockedByConflict] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [lastSubmittedText, setLastSubmittedText] = useState<string | null>(
null,
);
const [streamState, setStreamState] = useState<LighthouseV2StreamState>(() =>
createInitialLighthouseV2StreamState(),
);
const selectedConfiguration = selectedModelSelection
? connectedConfigurations.find(
(configuration) =>
configuration.providerType === selectedModelSelection.providerType,
)
: undefined;
const modelSelectorGroups = buildModelSelectorGroups(
connectedConfigurations,
modelsByProvider,
supportedProviders,
);
const showStaticOpenAIModel =
isOnlyConnectedProvider(
connectedConfigurations,
LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI,
) &&
selectedModelSelection?.providerType === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI;
const selectedModelLabel = selectedModelSelection
? getModelSelectionLabel(selectedModelSelection, modelsByProvider)
: "No model selected";
const selectedProviderName = selectedModelSelection
? getProviderDisplayName(
selectedModelSelection.providerType,
supportedProviders,
)
: "OpenAI";
const selectedModelValue = selectedModelSelection
? buildLighthouseV2ModelSelectionValue(
selectedModelSelection.providerType,
selectedModelSelection.modelId,
)
: "";
const canSend =
selectedConfiguration?.connected === true &&
Boolean(selectedModelSelection?.modelId) &&
!streamState.activeTaskId &&
!blockedByConflict &&
!isSubmitting;
const refreshMessages = async (sessionId: string): Promise<boolean> => {
const result = await getLighthouseV2Messages(sessionId);
// The fetch is async, so a reset (new chat, or archiving this session) can
// land while it is in flight. Drop the stale result instead of repopulating
// a chat that no longer points at this session.
if (sessionId !== activeSessionIdRef.current) return false;
if ("data" in result) {
setMessages(result.data);
return true;
}
return false;
};
const closeStream = () => {
eventSourceRef.current?.close();
eventSourceRef.current = null;
};
const handleTerminalEvent = async (
sessionId: string,
event: LighthouseV2SSEEvent,
) => {
if (
event.type === LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_END ||
event.type === LIGHTHOUSE_V2_SSE_EVENT.ERROR
) {
closeStream();
setBlockedByConflict(false);
if (event.type === LIGHTHOUSE_V2_SSE_EVENT.ERROR) {
setFeedback(event.detail || "Agent run failed.");
}
const refreshed = await refreshMessages(sessionId);
if (refreshed) {
setStreamState(createInitialLighthouseV2StreamState());
}
notifyLighthouseV2SessionsChanged();
}
};
const startStream = (streamUrl: string, sessionId: string) => {
closeStream();
const source = new EventSource(streamUrl);
eventSourceRef.current = source;
const applyEvent = (event: LighthouseV2SSEEvent) => {
setStreamState((current) => reduceLighthouseV2Event(current, event));
void handleTerminalEvent(sessionId, event);
};
source.addEventListener("message.delta", (event) =>
applyEvent(
parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_DELTA),
),
);
source.addEventListener("tool_call.start", (event) =>
applyEvent(
parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_START),
),
);
source.addEventListener("tool_call.end", (event) =>
applyEvent(
parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_END),
),
);
source.addEventListener("message.end", (event) =>
applyEvent(parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_END)),
);
source.addEventListener("error", (event) => {
if (event instanceof MessageEvent) {
applyEvent(parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.ERROR));
}
});
// The browser fires `onerror` both on a transient drop (it auto-reconnects)
// and on a non-retryable failure such as a 401/404 on the SSE GET. Only the
// latter leaves the source CLOSED, so surface a connection error there and
// treat everything else as a reconnect.
source.onerror = () => {
if (eventSourceRef.current !== source) return;
if (source.readyState === EventSource.CLOSED) {
closeStream();
setFeedback("Unable to connect to the response stream.");
}
setStreamState((current) =>
reduceLighthouseV2Event(current, { type: "disconnect" }),
);
};
};
const ensureSession = async (text: string) => {
if (activeSessionId) {
return activeSessionId;
}
const title = buildSessionTitle(text);
const result = await createLighthouseV2Session(title);
if ("error" in result) {
setFeedback(result.error);
return null;
}
// Update the URL in place (not router.push) so the force-dynamic server
// component is NOT re-run mid-submit. A re-run would change `key` in
// page.tsx and remount this component, tearing down the open EventSource.
replaceLighthouseV2SessionUrl(result.data.id);
setActiveSessionId(result.data.id);
activeSessionIdRef.current = result.data.id;
notifyLighthouseV2SessionsChanged();
return result.data.id;
};
const submitMessage = async (text: string) => {
const trimmedText = text.trim();
if (!trimmedText) return;
if (!selectedModelSelection) {
setFeedback("Select a model before sending a message.");
return;
}
if (!canSend) return;
setIsSubmitting(true);
try {
const sessionId = await ensureSession(trimmedText);
if (!sessionId) return;
const provisionalTaskId = `pending-${Date.now()}`;
setFeedback(null);
setBlockedByConflict(false);
setLastSubmittedText(trimmedText);
setInput("");
setMessages((current) => [
...current,
buildOptimisticMessage("user", trimmedText),
]);
setStreamState(createInitialLighthouseV2StreamState(provisionalTaskId));
// Subscribe to the same-origin SSE proxy BEFORE sending the message: the
// backend has no replay buffer, so the listener must be attached before
// the worker starts emitting.
startStream(buildLighthouseV2StreamUrl(sessionId), sessionId);
const result = await sendLighthouseV2Message({
sessionId,
text: trimmedText,
provider: selectedModelSelection.providerType,
model: selectedModelSelection.modelId,
});
if ("error" in result) {
closeStream();
setStreamState(createInitialLighthouseV2StreamState());
setFeedback(result.error);
if (result.status === 409) {
setBlockedByConflict(true);
await refreshMessages(sessionId);
}
return;
}
setStreamState((current) =>
current.activeTaskId === provisionalTaskId
? { ...current, activeTaskId: result.data.task.id }
: current,
);
notifyLighthouseV2SessionsChanged();
} finally {
setIsSubmitting(false);
}
};
const handleModelValueChange = (value: string) => {
const selection = parseLighthouseV2ModelSelectionValue(value);
if (!selection) return;
void handleModelSelectionChange(selection);
};
const handleModelSelectionChange = async (
selection: LighthouseV2ModelSelection,
) => {
// The selection drives the model used for the next message, so it stays
// applied even if persisting it as the provider's default model fails —
// reverting it would make a connected provider unusable when the save 4xxs.
setSelectedModelSelection(selection);
setFeedback(null);
const configId = connectedConfigurations.find(
(configuration) => configuration.providerType === selection.providerType,
)?.id;
if (!configId) return;
setModelPreferenceSaving(true);
const result = await updateLighthouseV2Configuration(configId, {
defaultModel: selection.modelId,
});
setModelPreferenceSaving(false);
if ("error" in result) {
setFeedback(result.error);
}
};
const handleSubmit = (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
void submitMessage(input);
};
// Close any open EventSource when the chat unmounts (e.g. route/session change).
useMountEffect(() => {
return () => closeStream();
});
useMountEffect(() => {
if (initialPrompt && !initialPromptSentRef.current) {
initialPromptSentRef.current = true;
void submitMessage(initialPrompt);
}
});
const resetToNewChat = () => {
closeStream();
setActiveSessionId(null);
activeSessionIdRef.current = null;
setMessages([]);
setInput("");
setFeedback(null);
setBlockedByConflict(false);
setIsSubmitting(false);
setLastSubmittedText(null);
setStreamState(createInitialLighthouseV2StreamState());
replaceLighthouseV2SessionUrl(null);
};
// The sidebar "+" can't rely on routing to reset the latest conversation (its
// URL was set via replaceState, invisible to Next's router), so reset in place.
useMountEffect(() => {
window.addEventListener(LIGHTHOUSE_V2_NEW_CHAT_EVENT, resetToNewChat);
return () =>
window.removeEventListener(LIGHTHOUSE_V2_NEW_CHAT_EVENT, resetToNewChat);
});
// Archiving deletes the session; when it's the open one, fall back to a new
// chat instead of leaving a dead conversation and its URL on screen.
useMountEffect(() => {
const handleSessionArchived = (event: Event) => {
const archivedId = (event as CustomEvent<{ sessionId: string }>).detail
?.sessionId;
if (archivedId && archivedId === activeSessionIdRef.current) {
resetToNewChat();
}
};
window.addEventListener(
LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT,
handleSessionArchived,
);
return () =>
window.removeEventListener(
LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT,
handleSessionArchived,
);
});
const hasLiveAssistantActivity =
Boolean(streamState.activeTaskId) ||
Boolean(streamState.assistantText) ||
streamState.toolCalls.length > 0;
const hasConversation = messages.length > 0 || hasLiveAssistantActivity;
const composerPanelProps = {
feedback,
canRetry:
streamState.status === "disconnected" && lastSubmittedText !== null,
onRetry: () =>
lastSubmittedText ? void submitMessage(lastSubmittedText) : undefined,
onDismissFeedback: () => setFeedback(null),
canSend,
input,
isStreaming: Boolean(streamState.activeTaskId),
modelSelector: showStaticOpenAIModel ? (
<CurrentModelDisplay
provider={selectedModelSelection.providerType}
providerName={selectedProviderName}
modelName={selectedModelLabel}
/>
) : (
<div className="min-w-0 flex-1 sm:max-w-48">
<Combobox
aria-label="Model"
value={selectedModelValue}
onValueChange={handleModelValueChange}
groups={modelSelectorGroups}
disabled={modelSelectorGroups.length === 0 || modelPreferenceSaving}
placeholder="Select model"
searchPlaceholder="Search models..."
emptyMessage="No models found."
triggerClassName="h-8 text-sm"
/>
</div>
),
selectedConfigurationConnected: selectedConfiguration?.connected === true,
onInputChange: setInput,
onSubmit: handleSubmit,
onSubmitText: submitMessage,
};
return (
<Card
variant="base"
className="flex h-full min-h-0 flex-col overflow-hidden"
>
{hasConversation ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<Conversation className="h-full min-h-0">
<ConversationContent
className="mx-auto w-full max-w-4xl gap-5 px-4 pt-8 pb-20 md:px-8"
scrollClassName="minimal-scrollbar overflow-x-hidden overflow-y-auto"
>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{hasLiveAssistantActivity && (
<StreamingAssistantMessage streamState={streamState} />
)}
</ConversationContent>
<ConversationScrollButton className="z-20" />
</Conversation>
<div
data-slot="lighthouse-v2-chat-scroll-fade"
className="from-bg-neutral-secondary pointer-events-none absolute right-2 bottom-0 left-0 z-10 h-16 bg-gradient-to-t to-transparent"
/>
</div>
<div
data-slot="lighthouse-v2-chat-composer-panel"
className="bg-bg-neutral-secondary px-4 pb-5 md:px-8"
>
<div className="mx-auto w-full max-w-4xl">
<ChatComposerPanel {...composerPanelProps} />
</div>
</div>
</div>
) : (
<ChatEmptyState {...composerPanelProps} />
)}
</Card>
);
}
function replaceLighthouseV2SessionUrl(sessionId: string | null) {
const url = sessionId
? `/lighthouse?session=${encodeURIComponent(sessionId)}`
: "/lighthouse";
window.history.replaceState(window.history.state, "", url);
}
function CurrentModelDisplay({
provider,
providerName,
modelName,
}: {
provider: LighthouseV2ProviderType;
providerName: string;
modelName: string;
}) {
return (
<div
aria-label={`Current model: ${providerName} ${modelName}`}
className="border-border-neutral-secondary bg-bg-neutral-secondary flex h-8 max-w-48 min-w-0 items-center gap-2 rounded-lg border px-2.5 text-sm"
>
<ProviderIcon
provider={provider}
className="text-text-neutral-secondary size-3.5 shrink-0"
/>
<span className="text-text-neutral-tertiary shrink-0">
{providerName}
</span>
<span className="text-text-neutral-primary min-w-0 truncate font-medium">
{modelName}
</span>
</div>
);
}
// Fixed precedence used to pick which connected provider opens the chat. Any
// provider outside this list keeps its relative order behind these.
const LIGHTHOUSE_V2_PROVIDER_PRIORITY = [
LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI,
LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK,
LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE,
] as const;
// Fallback model per provider when the configuration has no remembered model.
const LIGHTHOUSE_V2_PREFERRED_DEFAULT_MODEL: Partial<
Record<LighthouseV2ProviderType, string>
> = {
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI]: "gpt-5.5",
};
function resolveInitialModelSelection(
connectedConfigurations: LighthouseV2Configuration[],
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>,
): LighthouseV2ModelSelection | null {
const priorityIndex = (providerType: LighthouseV2ProviderType) => {
const index = LIGHTHOUSE_V2_PROVIDER_PRIORITY.indexOf(providerType);
return index === -1 ? LIGHTHOUSE_V2_PROVIDER_PRIORITY.length : index;
};
// Stable sort keeps providers outside the priority list in their original order.
const orderedConfigurations = [...connectedConfigurations].sort(
(a, b) => priorityIndex(a.providerType) - priorityIndex(b.providerType),
);
for (const configuration of orderedConfigurations) {
const providerModels = modelsByProvider[configuration.providerType] ?? [];
if (providerModels.length === 0) continue;
// Prefer the provider's remembered model when it is still supported, then
// the provider's preferred default, then the first supported model.
const rememberedModel = providerModels.find(
(model) => model.id === configuration.defaultModel,
);
const preferredModel = providerModels.find(
(model) =>
model.id ===
LIGHTHOUSE_V2_PREFERRED_DEFAULT_MODEL[configuration.providerType],
);
return {
providerType: configuration.providerType,
modelId: (rememberedModel ?? preferredModel ?? providerModels[0]).id,
};
}
return null;
}
function buildModelSelectorGroups(
connectedConfigurations: LighthouseV2Configuration[],
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>,
supportedProviders: LighthouseV2SupportedProvider[],
): ComboboxGroup[] {
const groups: ComboboxGroup[] = [];
for (const provider of supportedProviders) {
const configuration = connectedConfigurations.find(
(item) => item.providerType === provider.id,
);
if (!configuration) continue;
const options = (modelsByProvider[configuration.providerType] ?? []).map(
(model) => ({
value: buildLighthouseV2ModelSelectionValue(
configuration.providerType,
model.id,
),
label: model.name,
}),
);
if (options.length === 0) continue;
groups.push({
heading: provider.name,
options,
});
}
return groups;
}
function isOnlyConnectedProvider(
connectedConfigurations: LighthouseV2Configuration[],
providerType: LighthouseV2ProviderType,
) {
return (
connectedConfigurations.length === 1 &&
connectedConfigurations[0]?.providerType === providerType
);
}
function getModelSelectionLabel(
selection: LighthouseV2ModelSelection,
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>,
) {
return (
modelsByProvider[selection.providerType]?.find(
(model) => model.id === selection.modelId,
)?.name ?? selection.modelId
);
}
function getProviderDisplayName(
providerType: LighthouseV2ProviderType,
supportedProviders: LighthouseV2SupportedProvider[],
) {
return (
supportedProviders.find((provider) => provider.id === providerType)?.name ??
providerType
);
}
@@ -0,0 +1,182 @@
import { render, screen } from "@testing-library/react";
import { type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import {
LIGHTHOUSE_V2_MESSAGE_ROLE,
LIGHTHOUSE_V2_PART_TYPE,
type LighthouseV2Message,
} from "@/app/(prowler)/lighthouse/_types";
import { MessageBubble } from "./message-bubble";
vi.mock("streamdown", () => ({
Streamdown: ({ children }: { children: ReactNode }) => {
const text = String(children);
if (text.includes("very-wide-header")) {
return (
<table>
<caption>Wide markdown table</caption>
<tbody>
<tr>
<td>{text}</td>
</tr>
</tbody>
</table>
);
}
if (text.includes("graph TD")) {
// Mirrors streamdown's real mermaid DOM: pan/zoom wrapper + inline max-width on the svg
return (
<div data-streamdown="mermaid-block">
<div className="my-4 overflow-hidden">
<div role="application">
<div aria-label="Mermaid chart" role="img">
<svg aria-hidden="true" style={{ maxWidth: "1024px" }} />
</div>
</div>
</div>
</div>
);
}
return <>{children}</>;
},
defaultRehypePlugins: { katex: undefined, harden: undefined },
}));
describe("MessageBubble", () => {
it("should render assistant text and tool calls in persisted part order", () => {
// Given
const orderedMessage = buildAssistantMessage([
textPart("part-1", "Voy a buscar los findings por severidad"),
toolCallPart("part-2", "prowler_app_search_security_findings"),
textPart("part-3", "Ahora voy a buscar en los criticos"),
]);
// When
render(<MessageBubble message={orderedMessage} />);
// Then
const firstText = screen.getByText(
"Voy a buscar los findings por severidad",
);
const toolCall = screen.getByRole("button", {
name: /Used Search security findings/,
});
const secondText = screen.getByText("Ahora voy a buscar en los criticos");
expect(isBefore(firstText, toolCall)).toBe(true);
expect(isBefore(toolCall, secondText)).toBe(true);
});
it("should keep wide assistant tables inside the message width", () => {
// Given
const wideTableMessage = buildAssistantMessage([
textPart(
"part-1",
"| very-wide-header | another-wide-header |\n| --- | --- |\n| very-long-cell-value-that-should-not-resize-the-message | value |",
),
]);
// When
render(<MessageBubble message={wideTableMessage} />);
// Then
const table = screen.getByRole("table", {
name: "Wide markdown table",
});
const markdown = table.closest(".lighthouse-markdown");
if (!(markdown instanceof HTMLElement)) {
throw new Error("Expected markdown wrapper around assistant table");
}
expect(markdown).toHaveClass("min-w-0", "max-w-full", "overflow-x-auto");
expect(markdown.parentElement).toHaveClass("min-w-0");
expect(markdown.parentElement?.parentElement).toHaveClass(
"min-w-0",
"max-w-full",
);
expect(markdown.parentElement?.parentElement?.parentElement).toHaveClass(
"min-w-0",
);
});
it("keeps Mermaid diagrams inside the constrained markdown wrapper", () => {
// Given
const mermaidMessage = buildAssistantMessage([
textPart("part-1", "```mermaid\ngraph TD\n A --> B\n```"),
]);
// When
render(<MessageBubble message={mermaidMessage} />);
// Then
const mermaid = screen.getByRole("img", { name: "Mermaid chart" });
const markdown = mermaid.closest(".lighthouse-markdown");
if (!(markdown instanceof HTMLElement)) {
throw new Error("Expected markdown wrapper around Mermaid diagram");
}
expect(markdown).toHaveClass("min-w-0", "max-w-full", "overflow-x-auto");
expect(markdown.parentElement).toHaveClass("min-w-0");
expect(markdown.parentElement?.parentElement).toHaveClass(
"min-w-0",
"max-w-full",
);
});
});
function isBefore(first: HTMLElement, second: HTMLElement): boolean {
return Boolean(
first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING,
);
}
function buildAssistantMessage(
parts: LighthouseV2Message["parts"],
): LighthouseV2Message {
return {
id: "message-1",
role: LIGHTHOUSE_V2_MESSAGE_ROLE.ASSISTANT,
model: null,
tokenUsage: null,
insertedAt: "2026-06-25T10:00:00Z",
parts,
};
}
function textPart(
id: string,
text: string,
): LighthouseV2Message["parts"][number] {
return {
id,
type: LIGHTHOUSE_V2_PART_TYPE.TEXT,
content: { text },
toolCallOutcome: null,
insertedAt: "2026-06-25T10:00:00Z",
updatedAt: "2026-06-25T10:00:00Z",
};
}
function toolCallPart(
id: string,
toolName: string,
): LighthouseV2Message["parts"][number] {
return {
id,
type: LIGHTHOUSE_V2_PART_TYPE.TOOL_CALL,
content: {
tool_call_id: id,
tool_name: toolName,
arguments: null,
result: null,
outcome: "success",
},
toolCallOutcome: "success",
insertedAt: "2026-06-25T10:00:01Z",
updatedAt: "2026-06-25T10:00:01Z",
};
}
@@ -0,0 +1,206 @@
"use client";
import { Bot, Check, Copy, UserRound } from "lucide-react";
import { useState } from "react";
import { formatMessageTimestamp } from "@/app/(prowler)/lighthouse/_lib/format";
import { getTextContent } from "@/app/(prowler)/lighthouse/_lib/messages";
import {
LIGHTHOUSE_V2_MESSAGE_ROLE,
LIGHTHOUSE_V2_PART_TYPE,
type LighthouseV2Message,
type LighthouseV2Part,
} from "@/app/(prowler)/lighthouse/_types";
import { Button } from "@/components/shadcn/button/button";
import { cn } from "@/lib/utils";
import { MessageMarkdown } from "./message-markdown";
import { ToolCalls } from "./tool-call-part";
const ASSISTANT_PART_GROUP_TYPE = {
TEXT: "text",
TOOL_CALL: "tool_call",
} as const;
type AssistantPartGroupType =
(typeof ASSISTANT_PART_GROUP_TYPE)[keyof typeof ASSISTANT_PART_GROUP_TYPE];
interface AssistantPartGroup {
id: string;
type: AssistantPartGroupType;
parts: LighthouseV2Part[];
}
export function MessageBubble({ message }: { message: LighthouseV2Message }) {
const isUser = message.role === LIGHTHOUSE_V2_MESSAGE_ROLE.USER;
// Text-only join feeds the copy button; tool calls are rendered separately.
const messageText = message.parts
.filter((part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT)
.map((part) => getTextContent(part.content))
.filter(Boolean)
.join("\n\n");
return (
<article
className={cn(
"group flex min-w-0 gap-3",
isUser ? "justify-end" : "justify-start",
)}
>
{!isUser && <Bot className="text-text-neutral-tertiary mt-1 size-5" />}
<div
className={cn(
"flex max-w-[min(760px,85%)] min-w-0 flex-col gap-1",
isUser ? "items-end" : "items-start",
)}
>
<div
className={cn(
"max-w-full min-w-0 rounded-[8px] px-4 py-3 text-sm",
isUser
? "bg-button-primary text-slate-950"
: "bg-bg-neutral-tertiary text-text-neutral-primary",
)}
>
{/* User text stays plain to preserve HTML-like tags; assistant
renders parts in order so tool calls sit between text blocks. */}
{isUser ? (
<p className="whitespace-pre-wrap">{messageText}</p>
) : (
<AssistantParts parts={message.parts} />
)}
</div>
<MessageMeta
isUser={isUser}
text={messageText}
insertedAt={message.insertedAt}
/>
</div>
{isUser && (
<UserRound className="text-text-neutral-tertiary mt-1 size-5" />
)}
</article>
);
}
function AssistantParts({ parts }: { parts: LighthouseV2Part[] }) {
const groups = groupAssistantParts(parts);
return (
<div className="min-w-0 space-y-3">
{groups.map((group) =>
group.type === ASSISTANT_PART_GROUP_TYPE.TOOL_CALL ? (
<ToolCalls key={group.id} parts={group.parts} />
) : (
<AssistantTextParts key={group.id} parts={group.parts} />
),
)}
</div>
);
}
function AssistantTextParts({ parts }: { parts: LighthouseV2Part[] }) {
return parts.map((part, index) => {
const text = getTextContent(part.content);
return text ? (
<MessageMarkdown key={part.id || `text-${index}`} text={text} />
) : null;
});
}
function groupAssistantParts(parts: LighthouseV2Part[]): AssistantPartGroup[] {
return parts.reduce<AssistantPartGroup[]>((groups, part, index) => {
const groupType = getAssistantPartGroupType(part);
if (!groupType) {
return groups;
}
const lastGroup = groups.at(-1);
if (lastGroup?.type === groupType) {
return [
...groups.slice(0, -1),
{
...lastGroup,
parts: [...lastGroup.parts, part],
},
];
}
return [
...groups,
{
id: `${groupType}-${part.id || index}`,
type: groupType,
parts: [part],
},
];
}, []);
}
function getAssistantPartGroupType(
part: LighthouseV2Part,
): AssistantPartGroupType | null {
if (part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT) {
return ASSISTANT_PART_GROUP_TYPE.TEXT;
}
if (part.type === LIGHTHOUSE_V2_PART_TYPE.TOOL_CALL) {
return ASSISTANT_PART_GROUP_TYPE.TOOL_CALL;
}
return null;
}
function MessageMeta({
isUser,
text,
insertedAt,
}: {
isUser: boolean;
text: string;
insertedAt: string;
}) {
// Copy is always shown; the timestamp only reveals on hover over the message.
// Agent footer reads left-to-right ([copy] [time]); user footer mirrors it.
return (
<div
className={cn(
"flex items-center gap-1 px-1",
isUser && "flex-row-reverse",
)}
>
<CopyMessageButton text={text} />
<time
dateTime={insertedAt}
className="text-text-neutral-tertiary text-xs opacity-0 transition-opacity group-hover:opacity-100"
>
{formatMessageTimestamp(insertedAt)}
</time>
</div>
);
}
function CopyMessageButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard can reject (e.g. permissions); nothing to recover.
}
};
return (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Copy message"
onClick={handleCopy}
className="text-text-neutral-tertiary hover:text-text-neutral-primary size-6"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</Button>
);
}
@@ -0,0 +1,32 @@
import { defaultRehypePlugins, Streamdown } from "streamdown";
import { escapeAngleBracketPlaceholders } from "@/lib/markdown";
// Renders assistant message text as markdown (code blocks, tables, lists),
// matching the Lighthouse v1 chat. `isStreaming` animates partial output.
export function MessageMarkdown({
text,
isStreaming = false,
}: {
text: string;
isStreaming?: boolean;
}) {
return (
<div className="lighthouse-markdown max-w-full min-w-0 overflow-x-auto">
<Streamdown
parseIncompleteMarkdown
shikiTheme={["github-light", "github-dark"]}
controls={{ code: true, table: true, mermaid: true }}
// Omit defaultRehypePlugins.raw so HTML-like tokens (e.g. <bucket_name>)
// are escaped rather than parsed as elements.
rehypePlugins={[
defaultRehypePlugins.katex,
defaultRehypePlugins.harden,
]}
isAnimating={isStreaming}
>
{escapeAngleBracketPlaceholders(text)}
</Streamdown>
</div>
);
}
@@ -0,0 +1,244 @@
"use client";
import { Bot, Loader2 } from "lucide-react";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtHeader,
ChainOfThoughtStep,
} from "@/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought";
import {
LIGHTHOUSE_V2_STREAM_ACTIVITY_ITEM_TYPE,
LIGHTHOUSE_V2_STREAM_STATUS,
LIGHTHOUSE_V2_TOOL_CALL_STATUS,
type LighthouseV2StreamActivityItem,
type LighthouseV2StreamState,
type LighthouseV2StreamToolCallActivityItem,
} from "@/app/(prowler)/lighthouse/_lib/event-reducer";
import { formatToolName } from "@/app/(prowler)/lighthouse/_lib/tool-calls";
import { cn } from "@/lib/utils";
import { MessageMarkdown } from "./message-markdown";
const STREAMING_ACTIVITY_GROUP_TYPE = {
TEXT: "text",
TOOL_CALL: "tool_call",
} as const;
interface StreamingTextActivityGroup {
id: string;
type: typeof STREAMING_ACTIVITY_GROUP_TYPE.TEXT;
text: string;
}
interface StreamingToolCallActivityGroup {
id: string;
type: typeof STREAMING_ACTIVITY_GROUP_TYPE.TOOL_CALL;
toolCalls: LighthouseV2StreamToolCallActivityItem[];
}
type StreamingActivityGroup =
| StreamingTextActivityGroup
| StreamingToolCallActivityGroup;
export function StreamingAssistantMessage({
streamState,
}: {
streamState: LighthouseV2StreamState;
}) {
const hasActivity =
Boolean(streamState.activeTaskId) || streamState.toolCalls.length > 0;
return (
<article className="flex min-w-0 justify-start gap-3">
<Bot className="text-text-neutral-tertiary mt-1 size-5" />
<div className="bg-bg-neutral-tertiary text-text-neutral-primary max-w-[min(760px,85%)] min-w-0 rounded-[8px] px-4 py-3 text-sm">
{streamState.activityItems.length > 0 ? (
<StreamingActivityGroups streamState={streamState} />
) : (
hasActivity && <StreamingPendingActivity streamState={streamState} />
)}
</div>
</article>
);
}
function StreamingActivityGroups({
streamState,
}: {
streamState: LighthouseV2StreamState;
}) {
const groups = groupStreamingActivityItems(streamState.activityItems);
const isDisconnected =
streamState.status === LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED;
return (
<div className="min-w-0 space-y-3">
{groups.map((group) =>
group.type === STREAMING_ACTIVITY_GROUP_TYPE.TOOL_CALL ? (
<StreamingToolCallGroup key={group.id} toolCalls={group.toolCalls} />
) : (
<MessageMarkdown key={group.id} text={group.text} isStreaming />
),
)}
{isDisconnected && <StreamingReconnectActivity />}
</div>
);
}
function StreamingPendingActivity({
streamState,
}: {
streamState: LighthouseV2StreamState;
}) {
const isDisconnected =
streamState.status === LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED;
return (
<ChainOfThought className="max-w-none space-y-0">
<ChainOfThoughtHeader className="text-text-neutral-secondary">
<span className={cn(!isDisconnected && "animate-pulse")}>
{getActivityHeader(streamState)}
</span>
</ChainOfThoughtHeader>
<ChainOfThoughtContent className="mt-2 space-y-2">
<ChainOfThoughtStep label="Preparing response" status="active" />
{isDisconnected && (
<ChainOfThoughtStep label="Reconnecting stream" status="active" />
)}
</ChainOfThoughtContent>
</ChainOfThought>
);
}
function StreamingToolCallGroup({
toolCalls,
}: {
toolCalls: LighthouseV2StreamToolCallActivityItem[];
}) {
return (
<ChainOfThought className="max-w-none space-y-0">
<ChainOfThoughtHeader className="text-text-neutral-secondary">
<span className={cn(hasRunningToolCall(toolCalls) && "animate-pulse")}>
{getToolActivityHeader(toolCalls)}
</span>
</ChainOfThoughtHeader>
<ChainOfThoughtContent className="mt-2 space-y-2">
{toolCalls.map((toolCall) => (
<ChainOfThoughtStep
key={toolCall.id}
description={
toolCall.outcome && toolCall.outcome.toLowerCase() !== "success"
? toolCall.outcome
: undefined
}
icon={
toolCall.status === LIGHTHOUSE_V2_TOOL_CALL_STATUS.RUNNING
? Loader2
: undefined
}
label={getToolCallLabel(toolCall)}
status={
toolCall.status === LIGHTHOUSE_V2_TOOL_CALL_STATUS.RUNNING
? "active"
: "complete"
}
/>
))}
</ChainOfThoughtContent>
</ChainOfThought>
);
}
function StreamingReconnectActivity() {
return (
<ChainOfThought className="max-w-none space-y-0">
<ChainOfThoughtHeader className="text-text-neutral-secondary">
<span className="animate-pulse">Reconnecting</span>
</ChainOfThoughtHeader>
<ChainOfThoughtContent className="mt-2 space-y-2">
<ChainOfThoughtStep label="Reconnecting stream" status="active" />
</ChainOfThoughtContent>
</ChainOfThought>
);
}
function getActivityHeader(streamState: LighthouseV2StreamState): string {
if (streamState.status === LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED) {
return "Reconnecting";
}
return "Thinking";
}
function getToolCallLabel(
toolCall: LighthouseV2StreamToolCallActivityItem,
): string {
return `${toolCall.status === LIGHTHOUSE_V2_TOOL_CALL_STATUS.RUNNING ? "Calling" : "Called"} ${formatToolName(toolCall.name)}`;
}
function getToolActivityHeader(
toolCalls: LighthouseV2StreamToolCallActivityItem[],
): string {
if (toolCalls.length === 1) {
return getToolCallLabel(toolCalls[0]);
}
return hasRunningToolCall(toolCalls) ? "Using tools" : "Used tools";
}
function hasRunningToolCall(
toolCalls: LighthouseV2StreamToolCallActivityItem[],
): boolean {
return toolCalls.some(
(toolCall) => toolCall.status === LIGHTHOUSE_V2_TOOL_CALL_STATUS.RUNNING,
);
}
function groupStreamingActivityItems(
activityItems: LighthouseV2StreamActivityItem[],
): StreamingActivityGroup[] {
return activityItems.reduce<StreamingActivityGroup[]>((groups, item) => {
const lastGroup = groups.at(-1);
if (item.type === LIGHTHOUSE_V2_STREAM_ACTIVITY_ITEM_TYPE.TEXT) {
if (lastGroup?.type === STREAMING_ACTIVITY_GROUP_TYPE.TEXT) {
return [
...groups.slice(0, -1),
{
...lastGroup,
text: `${lastGroup.text}${item.text}`,
},
];
}
return [
...groups,
{
id: item.id,
type: STREAMING_ACTIVITY_GROUP_TYPE.TEXT,
text: item.text,
},
];
}
if (lastGroup?.type === STREAMING_ACTIVITY_GROUP_TYPE.TOOL_CALL) {
return [
...groups.slice(0, -1),
{
...lastGroup,
toolCalls: [...lastGroup.toolCalls, item],
},
];
}
return [
...groups,
{
id: item.id,
type: STREAMING_ACTIVITY_GROUP_TYPE.TOOL_CALL,
toolCalls: [item],
},
];
}, []);
}
@@ -0,0 +1,124 @@
"use client";
import { Check, ChevronDown, TriangleAlert, Wrench } from "lucide-react";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtHeader,
} from "@/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought";
import {
formatToolName,
getToolCallContent,
isToolCallError,
} from "@/app/(prowler)/lighthouse/_lib/tool-calls";
import type { LighthouseV2Part } from "@/app/(prowler)/lighthouse/_types";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/shadcn/collapsible";
import {
QUERY_EDITOR_LANGUAGE,
QueryCodeEditor,
} from "@/components/shared/query-code-editor";
// Groups consecutive finished tool calls under one collapsed disclosure so the
// "work" stays compact while surrounding text can render in chronological order.
export function ToolCalls({ parts }: { parts: LighthouseV2Part[] }) {
if (parts.length === 0) {
return null;
}
const label = getToolCallsLabel(parts);
return (
<ChainOfThought className="space-y-0">
<ChainOfThoughtHeader className="text-text-neutral-secondary">
{label}
</ChainOfThoughtHeader>
<ChainOfThoughtContent className="mt-2 space-y-1.5">
{parts.map((part, index) => (
<ToolCallPart key={part.id || `tool-${index}`} part={part} />
))}
</ChainOfThoughtContent>
</ChainOfThought>
);
}
function getToolCallsLabel(parts: LighthouseV2Part[]): string {
if (parts.length === 1) {
const toolCall = getToolCallContent(parts[0].content);
if (toolCall) {
return `Used ${formatToolName(toolCall.toolName)}`;
}
}
return `Used ${parts.length} ${parts.length === 1 ? "tool" : "tools"}`;
}
// One tool call as a light, collapsed row: status + humanized name, expanding to
// the arguments the agent sent and the result it got back.
function ToolCallPart({ part }: { part: LighthouseV2Part }) {
const toolCall = getToolCallContent(part.content);
if (!toolCall) {
return null;
}
// `content.outcome` is authoritative; fall back to the part-level column.
const outcome = toolCall.outcome ?? part.toolCallOutcome;
const isError = isToolCallError(outcome);
return (
<Collapsible className="border-border-neutral-secondary rounded-[6px] border">
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-2.5 py-1.5 text-xs">
{isError ? (
<TriangleAlert className="text-text-error-primary size-3.5 shrink-0" />
) : (
<Check className="text-text-success-primary size-3.5 shrink-0" />
)}
<Wrench className="text-text-neutral-tertiary size-3.5 shrink-0" />
<span className="text-text-neutral-primary flex-1 truncate text-left">
{formatToolName(toolCall.toolName)}
</span>
{isError && outcome && (
<span className="text-text-error-primary truncate">{outcome}</span>
)}
<ChevronDown className="text-text-neutral-tertiary size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 px-2.5 pb-2.5">
<ToolCallSection label="Arguments" value={toolCall.arguments} />
<ToolCallSection label="Result" value={toolCall.result} />
</CollapsibleContent>
</Collapsible>
);
}
function ToolCallSection({ label, value }: { label: string; value: unknown }) {
if (value === null || value === undefined || value === "") {
return null;
}
// String results (often markdown) render as plain text so real newlines show
// instead of escaped "\n"; structured values render as highlighted JSON.
const isText = typeof value === "string";
const text = isText ? value : JSON.stringify(value, null, 2);
if (!text) {
return null;
}
return (
<QueryCodeEditor
ariaLabel={label}
visibleLabel={label}
language={
isText ? QUERY_EDITOR_LANGUAGE.PLAIN_TEXT : QUERY_EDITOR_LANGUAGE.JSON
}
value={text}
copyValue={text}
editable={false}
minHeight={0}
showCopyButton
showLineNumbers={false}
onChange={() => {}}
/>
);
}
@@ -0,0 +1,137 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LighthouseV2BusinessContextForm } from "./business-context-form";
const { updateConfigurationMock } = vi.hoisted(() => ({
updateConfigurationMock: vi.fn(),
}));
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
updateLighthouseV2Configuration: updateConfigurationMock,
}));
function configuration(businessContext: string) {
return {
id: "config-1",
providerType: "bedrock" as const,
baseUrl: null,
defaultModel: null,
businessContext,
connected: true,
connectionLastCheckedAt: null,
insertedAt: "2026-06-25T09:00:00Z",
updatedAt: "2026-06-25T10:00:00Z",
};
}
describe("LighthouseV2BusinessContextForm", () => {
beforeEach(() => {
updateConfigurationMock.mockReset();
updateConfigurationMock.mockResolvedValue({
data: configuration("Production context"),
});
});
it("seeds the textarea with the business context and disables save until edited", () => {
// Given / When
render(
<LighthouseV2BusinessContextForm
configurationId="config-1"
initialBusinessContext="Production context"
/>,
);
// Then
expect(
screen.getByRole("textbox", { name: /Business context/i }),
).toHaveValue("Production context");
expect(
screen.getByRole("button", { name: "Save business context" }),
).toBeDisabled();
});
it("saves the shared business context against the provider configuration", async () => {
// Given
const user = userEvent.setup();
render(
<LighthouseV2BusinessContextForm
configurationId="config-1"
initialBusinessContext=""
/>,
);
// When
await user.type(
screen.getByRole("textbox", { name: /Business context/i }),
"Production context",
);
await user.click(
screen.getByRole("button", { name: "Save business context" }),
);
// Then
await waitFor(() =>
expect(updateConfigurationMock).toHaveBeenCalledWith("config-1", {
businessContext: "Production context",
}),
);
});
it("shows the counter and blocks saving over the character limit", async () => {
// Given
const user = userEvent.setup();
render(
<LighthouseV2BusinessContextForm
configurationId="config-1"
initialBusinessContext=""
/>,
);
// When: paste in one event instead of 5001 keystrokes
await user.click(
screen.getByRole("textbox", { name: /Business context/i }),
);
await user.paste("a".repeat(5001));
// Then
expect(screen.getByText("5001/5000")).toBeInTheDocument();
expect(
screen.getByText("Business context cannot exceed 5000 characters."),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Save business context" }),
).toBeDisabled();
expect(updateConfigurationMock).not.toHaveBeenCalled();
});
it("surfaces the backend reason when the save fails", async () => {
// Given
const user = userEvent.setup();
updateConfigurationMock.mockResolvedValue({
error: "No active configuration found for 'bedrock'.",
status: 400,
});
render(
<LighthouseV2BusinessContextForm
configurationId="config-1"
initialBusinessContext=""
/>,
);
// When
await user.type(
screen.getByRole("textbox", { name: /Business context/i }),
"Production context",
);
await user.click(
screen.getByRole("button", { name: "Save business context" }),
);
// Then
expect(
await screen.findByText("No active configuration found for 'bedrock'."),
).toBeInTheDocument();
});
});
@@ -0,0 +1,128 @@
"use client";
import { Bot, Loader2, Save } from "lucide-react";
import { useState } from "react";
import { updateLighthouseV2Configuration } from "@/app/(prowler)/lighthouse/_actions";
import { BUSINESS_CONTEXT_LIMIT } from "@/app/(prowler)/lighthouse/_lib/config";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { cn } from "@/lib/utils";
// Shared business context. The backend syncs it across every provider config, so
// it is edited once here against any single configuration rather than per provider.
export function LighthouseV2BusinessContextForm({
configurationId,
initialBusinessContext,
}: {
configurationId: string;
initialBusinessContext: string;
}) {
const [businessContext, setBusinessContext] = useState(
initialBusinessContext,
);
const [savedContext, setSavedContext] = useState(initialBusinessContext);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const overLimit = businessContext.length > BUSINESS_CONTEXT_LIMIT;
const isDirty = businessContext !== savedContext;
const canSave = isDirty && !overLimit && !saving;
const handleSave = async () => {
if (!canSave) return;
setSaving(true);
setError(null);
try {
const result = await updateLighthouseV2Configuration(configurationId, {
businessContext,
});
if ("error" in result) {
setError(result.error);
return;
}
setSavedContext(result.data.businessContext);
setBusinessContext(result.data.businessContext);
} catch {
setError("Something went wrong while saving. Please try again.");
} finally {
setSaving(false);
}
};
return (
<Card
variant="inner"
padding="none"
data-lighthouse-v2-business-context=""
className="gap-4 p-4 md:p-5"
>
<div className="flex items-start gap-3">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-12 shrink-0 items-center justify-center rounded-[10px] border">
<Bot className="text-text-neutral-secondary size-6" />
</div>
<div className="min-w-0">
<h3 className="text-text-neutral-primary text-xl font-semibold">
Business context
</h3>
<p className="text-text-neutral-secondary mt-1 max-w-2xl text-sm">
Shared context Lighthouse AI considers for every provider and chat.
</p>
</div>
</div>
<div className="flex flex-col gap-4">
<Field>
<div className="flex items-center justify-between gap-3">
<FieldLabel htmlFor="lighthouse-v2-business-context">
Business context
</FieldLabel>
<span
className={cn(
"text-xs",
overLimit
? "text-text-error-primary"
: "text-text-neutral-tertiary",
)}
>
{businessContext.length}/{BUSINESS_CONTEXT_LIMIT}
</span>
</div>
<Textarea
id="lighthouse-v2-business-context"
textareaSize="lg"
aria-invalid={overLimit}
value={businessContext}
onChange={(event) => setBusinessContext(event.target.value)}
placeholder="Example: production AWS accounts, PCI workloads, EU data residency, critical internet-facing services..."
/>
{overLimit && (
<FieldError>
Business context cannot exceed {BUSINESS_CONTEXT_LIMIT}{" "}
characters.
</FieldError>
)}
{error && <FieldError>{error}</FieldError>}
</Field>
<div className="flex justify-end">
<Button
type="button"
aria-label="Save business context"
onClick={handleSave}
disabled={!canSave}
>
{saving ? <Loader2 className="animate-spin" /> : <Save />}
{saving ? "Saving…" : "Save"}
</Button>
</div>
</div>
</Card>
);
}
@@ -0,0 +1,322 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRound, Loader2, PlugZap, Save, Trash2 } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import {
createLighthouseV2Configuration,
deleteLighthouseV2Configuration,
testLighthouseV2ConfigurationConnection,
updateLighthouseV2Configuration,
} from "@/app/(prowler)/lighthouse/_actions";
import {
buildCredentialPayload,
buildLighthouseV2ConfigFormSchema,
buildLighthouseV2ConfigurationInput,
EMPTY_FORM_VALUES,
FEEDBACK_VARIANT,
type FeedbackState,
getConnectionStatus,
getFormDefaults,
type LighthouseV2ConfigFormValues,
trimToNullable,
} from "@/app/(prowler)/lighthouse/_lib/config";
import { formatLastChecked } from "@/app/(prowler)/lighthouse/_lib/format";
import {
type LighthouseV2Configuration,
type LighthouseV2ConfigurationUpdateInput,
type LighthouseV2Credentials,
type LighthouseV2SupportedProvider,
} from "@/app/(prowler)/lighthouse/_types";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { Modal } from "@/components/shadcn/modal";
import { ConfigurationSection } from "./configuration-section";
import { CredentialFields } from "./credential-fields";
import { ProviderIcon } from "./provider-icon";
import { StatusBadge } from "./status-badge";
export function LighthouseV2ConfigurationForm({
configuration,
onConfigurationDeleted,
onConfigurationSaved,
onConfigurationTested,
onFeedback,
provider,
}: {
configuration?: LighthouseV2Configuration;
onConfigurationDeleted: (configurationId: string) => void;
onConfigurationSaved: (configuration: LighthouseV2Configuration) => void;
onConfigurationTested: (configuration: LighthouseV2Configuration) => void;
onFeedback: (feedback: FeedbackState | null) => void;
provider: LighthouseV2SupportedProvider;
}) {
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const providerType = provider.id;
const hasConfiguration = Boolean(configuration);
const form = useForm<LighthouseV2ConfigFormValues>({
resolver: zodResolver(
buildLighthouseV2ConfigFormSchema(providerType, hasConfiguration),
),
defaultValues: getFormDefaults(configuration),
mode: "onSubmit",
});
const status = getConnectionStatus(configuration);
const runConnectionTest = async (configurationId: string) => {
setTesting(true);
onFeedback(null);
try {
const result =
await testLighthouseV2ConfigurationConnection(configurationId);
if ("error" in result) {
onFeedback({
title: "Connection check failed",
description: result.error,
variant: FEEDBACK_VARIANT.ERROR,
});
return;
}
onConfigurationTested(result.data);
} catch {
onFeedback({
title: "Connection check failed",
description: "Something went wrong while testing. Please try again.",
variant: FEEDBACK_VARIANT.ERROR,
});
} finally {
setTesting(false);
}
};
const handleSave = async (values: LighthouseV2ConfigFormValues) => {
setSaving(true);
onFeedback(null);
const credentials = buildCredentialPayload(
providerType,
values,
hasConfiguration,
);
const baseUrl = trimToNullable(values.baseUrl);
const shouldTestAfterSave =
!configuration ||
Boolean(credentials) ||
baseUrl !== (configuration.baseUrl ?? null);
try {
const result = configuration
? await updateLighthouseV2Configuration(configuration.id, {
baseUrl,
...(credentials ? { credentials } : {}),
} satisfies LighthouseV2ConfigurationUpdateInput)
: await createLighthouseV2Configuration(
buildLighthouseV2ConfigurationInput(
providerType,
credentials as LighthouseV2Credentials,
baseUrl,
),
);
if ("error" in result) {
onFeedback({
title: "Configuration not saved",
description: result.error,
variant: FEEDBACK_VARIANT.ERROR,
});
return;
}
form.reset(getFormDefaults(result.data));
onConfigurationSaved(result.data);
if (shouldTestAfterSave) {
await runConnectionTest(result.data.id);
}
} catch {
onFeedback({
title: "Configuration not saved",
description: "Something went wrong while saving. Please try again.",
variant: FEEDBACK_VARIANT.ERROR,
});
} finally {
setSaving(false);
}
};
const handleTestConnection = async () => {
if (!configuration) return;
await runConnectionTest(configuration.id);
};
const handleDelete = async () => {
if (!configuration) return;
setDeleting(true);
try {
const result = await deleteLighthouseV2Configuration(configuration.id);
if ("error" in result) {
onFeedback({
title: "Configuration not removed",
description: result.error,
variant: FEEDBACK_VARIANT.ERROR,
});
return;
}
setDeleteOpen(false);
form.reset(EMPTY_FORM_VALUES);
onConfigurationDeleted(configuration.id);
} catch {
onFeedback({
title: "Configuration not removed",
description: "Something went wrong while deleting. Please try again.",
variant: FEEDBACK_VARIANT.ERROR,
});
} finally {
setDeleting(false);
}
};
return (
<>
<Card
variant="inner"
padding="none"
className="h-full min-w-0 p-4 md:p-5"
>
<section className="flex h-full w-full min-w-0 flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 gap-3">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-12 shrink-0 items-center justify-center rounded-[10px] border">
<ProviderIcon
provider={providerType}
className="text-text-neutral-secondary size-6"
/>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-text-neutral-primary text-xl font-semibold">
{provider.name}
</h3>
<StatusBadge status={status} />
<span className="text-text-neutral-tertiary text-xs">
{formatLastChecked(configuration?.connectionLastCheckedAt)}
</span>
</div>
<p className="text-text-neutral-secondary mt-1 max-w-2xl text-sm">
{configuration
? "Stored provider configuration. Rotate credentials only when needed."
: "Create provider configuration before Lighthouse AI can use this model family."}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={!configuration || testing}
>
{testing ? <Loader2 className="animate-spin" /> : <PlugZap />}
{testing ? "Testing connection…" : "Test connection"}
</Button>
</div>
</div>
<div
aria-hidden="true"
className="border-border-neutral-secondary border-t"
/>
<form
className="flex min-h-0 w-full flex-1 flex-col gap-4"
onSubmit={form.handleSubmit(handleSave)}
noValidate
>
<div className="min-h-0 overflow-y-auto">
<ConfigurationSection
icon={<KeyRound className="size-4" />}
title="Credentials"
description={
configuration
? "Leave blank to keep existing credentials."
: "Credentials are required for new configurations."
}
>
<CredentialFields
errors={form.formState.errors}
hasStoredCredentials={hasConfiguration}
provider={providerType}
register={form.register}
/>
</ConfigurationSection>
</div>
<div className="mt-auto flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-text-neutral-secondary text-sm">
{configuration
? "Saving updates may change chat behavior immediately."
: "Save provider before testing the connection."}
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={saving}>
{saving ? <Loader2 className="animate-spin" /> : <Save />}
Save
</Button>
<Button
type="button"
variant="destructive"
onClick={() => setDeleteOpen(true)}
disabled={!configuration || deleting}
>
{deleting ? <Loader2 className="animate-spin" /> : <Trash2 />}
Delete
</Button>
</div>
</div>
</form>
</section>
</Card>
<Modal
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Lighthouse AI configuration?"
description={`This removes ${provider.name} from Lighthouse AI. Existing chat history stays available, but this provider cannot be used until configured again.`}
size="md"
>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setDeleteOpen(false)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? <Loader2 className="animate-spin" /> : <Trash2 />}
Delete configuration
</Button>
</div>
</Modal>
</>
);
}
@@ -0,0 +1,32 @@
import { type ReactNode } from "react";
export function ConfigurationSection({
children,
description,
icon,
title,
}: {
children: ReactNode;
description: string;
icon: ReactNode;
title: string;
}) {
return (
<section className="grid gap-6 md:grid-cols-[220px_minmax(0,1fr)]">
<div className="flex gap-3">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-8 shrink-0 items-center justify-center rounded-[8px] border">
{icon}
</div>
<div>
<h4 className="text-text-neutral-primary text-sm font-semibold">
{title}
</h4>
<p className="text-text-neutral-secondary mt-1 text-sm">
{description}
</p>
</div>
</div>
<div className="min-w-0">{children}</div>
</section>
);
}
@@ -0,0 +1,120 @@
import { type useForm } from "react-hook-form";
import { type LighthouseV2ConfigFormValues } from "@/app/(prowler)/lighthouse/_lib/config";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Input } from "@/components/shadcn/input/input";
// Stored secrets are never sent back by the API, so the fields would look
// empty even when a key exists; the masked placeholder stands in for it.
const STORED_SECRET_PLACEHOLDER = "•".repeat(36);
export function CredentialFields({
errors,
hasStoredCredentials = false,
provider,
register,
}: {
errors: ReturnType<
typeof useForm<LighthouseV2ConfigFormValues>
>["formState"]["errors"];
hasStoredCredentials?: boolean;
provider: LighthouseV2ProviderType;
register: ReturnType<
typeof useForm<LighthouseV2ConfigFormValues>
>["register"];
}) {
const secretPlaceholder = hasStoredCredentials
? STORED_SECRET_PLACEHOLDER
: undefined;
return (
<div className="grid gap-4">
{(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE) && (
<Field>
<FieldLabel htmlFor="lighthouse-v2-api-key">API key</FieldLabel>
<Input
id="lighthouse-v2-api-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.apiKey)}
{...register("apiKey")}
/>
{errors.apiKey?.message && (
<FieldError>{errors.apiKey.message}</FieldError>
)}
</Field>
)}
{provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE && (
<Field>
<FieldLabel htmlFor="lighthouse-v2-base-url">Base URL</FieldLabel>
<Input
id="lighthouse-v2-base-url"
aria-invalid={Boolean(errors.baseUrl)}
placeholder="https://llm.example.com/v1"
{...register("baseUrl")}
/>
{errors.baseUrl?.message && (
<FieldError>{errors.baseUrl.message}</FieldError>
)}
</Field>
)}
{provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK && (
<div className="grid gap-4 md:grid-cols-2">
<Field>
<FieldLabel htmlFor="lighthouse-v2-access-key">
AWS access key ID
</FieldLabel>
<Input
id="lighthouse-v2-access-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.awsAccessKeyId)}
{...register("awsAccessKeyId")}
/>
{errors.awsAccessKeyId?.message && (
<FieldError>{errors.awsAccessKeyId.message}</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor="lighthouse-v2-secret-key">
AWS secret access key
</FieldLabel>
<Input
id="lighthouse-v2-secret-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.awsSecretAccessKey)}
{...register("awsSecretAccessKey")}
/>
{errors.awsSecretAccessKey?.message && (
<FieldError>{errors.awsSecretAccessKey.message}</FieldError>
)}
</Field>
<Field className="md:col-span-2">
<FieldLabel htmlFor="lighthouse-v2-region">AWS region</FieldLabel>
<Input
id="lighthouse-v2-region"
placeholder="us-east-1"
aria-invalid={Boolean(errors.awsRegionName)}
{...register("awsRegionName")}
/>
{errors.awsRegionName?.message && (
<FieldError>{errors.awsRegionName.message}</FieldError>
)}
</Field>
</div>
)}
</div>
);
}
@@ -0,0 +1,32 @@
import { AlertCircle, DatabaseZap } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import { Card, CardContent } from "@/components/shadcn/card/card";
export function LighthouseV2EmptyState({ error }: { error?: string }) {
return (
<Card variant="base" padding="lg" className="mx-auto max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 py-8 text-center">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-14 items-center justify-center rounded-[14px] border">
<DatabaseZap className="text-text-neutral-secondary size-7" />
</div>
<div>
<h2 className="text-text-neutral-primary text-xl font-semibold">
No Lighthouse AI providers available
</h2>
<p className="text-text-neutral-secondary mt-2 text-sm">
Cloud did not return supported providers for Lighthouse AI
configuration.
</p>
</div>
{error && (
<Alert variant="error" className="text-left">
<AlertCircle className="size-4" />
<AlertTitle>Configuration unavailable</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1 @@
export { LighthouseV2ConfigPage } from "./lighthouse-v2-config-page";
@@ -0,0 +1,403 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
LighthouseV2Configuration,
LighthouseV2SupportedProvider,
} from "@/app/(prowler)/lighthouse/_types";
import { LighthouseV2ConfigPage } from "./lighthouse-v2-config-page";
const {
createConfigurationMock,
deleteConfigurationMock,
testConnectionMock,
updateConfigurationMock,
toastMock,
} = vi.hoisted(() => ({
createConfigurationMock: vi.fn(),
deleteConfigurationMock: vi.fn(),
testConnectionMock: vi.fn(),
updateConfigurationMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
}));
// Action feedback is delivered through toasts (rendered by the layout Toaster),
// so we assert the dispatched toast rather than in-page banner text.
vi.mock("@/components/ui", () => ({
useToast: () => ({ toast: toastMock, dismiss: vi.fn() }),
}));
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
createLighthouseV2Configuration: createConfigurationMock,
deleteLighthouseV2Configuration: deleteConfigurationMock,
testLighthouseV2ConfigurationConnection: testConnectionMock,
updateLighthouseV2Configuration: updateConfigurationMock,
}));
const providers: LighthouseV2SupportedProvider[] = [
{ id: "openai", name: "OpenAI" },
{ id: "bedrock", name: "Amazon Bedrock" },
{ id: "openai-compatible", name: "OpenAI-compatible" },
];
const configurations: LighthouseV2Configuration[] = [
{
id: "config-openai",
providerType: "openai",
baseUrl: null,
defaultModel: "gpt-5.1",
businessContext: "Production context",
connected: true,
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
insertedAt: "2026-06-24T09:00:00Z",
updatedAt: "2026-06-24T10:00:00Z",
},
{
id: "config-bedrock",
providerType: "bedrock",
baseUrl: null,
defaultModel: "anthropic.claude-4",
businessContext: "Production context",
connected: false,
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
insertedAt: "2026-06-23T09:00:00Z",
updatedAt: "2026-06-23T10:00:00Z",
},
];
describe("LighthouseV2ConfigPage", () => {
beforeEach(() => {
createConfigurationMock.mockReset();
deleteConfigurationMock.mockReset();
testConnectionMock.mockReset();
updateConfigurationMock.mockReset();
toastMock.mockReset();
createConfigurationMock.mockResolvedValue({ data: configurations[0] });
deleteConfigurationMock.mockResolvedValue({ data: true });
// The action polls the task internally and resolves with the re-fetched
// configuration carrying the authoritative connection status.
testConnectionMock.mockResolvedValue({
data: { ...configurations[0], connected: true },
});
updateConfigurationMock.mockResolvedValue({ data: configurations[0] });
});
it("renders provider statuses and the active provider without the readiness summary card", () => {
// Given / When
const { container } = renderPage();
// Then
expect(
screen.queryByRole("heading", { name: "Lighthouse readiness" }),
).not.toBeInTheDocument();
expect(screen.queryByText("1 connected")).not.toBeInTheDocument();
expect(screen.queryByText("1 failed")).not.toBeInTheDocument();
expect(screen.queryByText("1 not tested")).not.toBeInTheDocument();
const openAIProvider = screen.getByRole("button", { name: "OpenAI" });
const settingsCard = screen.getByRole("region", {
name: "Lighthouse AI settings",
});
const settingsSeparator = container.querySelector(
'[data-slot="settings-separator"]',
);
const innerCards = settingsCard.querySelectorAll('[data-slot="card"]');
expect(settingsCard).toHaveAttribute("data-slot", "card");
expect(settingsCard).toHaveClass("w-full", "gap-4", "p-4", "md:p-5");
expect(settingsCard).not.toHaveClass(
"gap-0",
"overflow-hidden",
"mx-auto",
"max-w-7xl",
);
expect(settingsSeparator).toBeNull();
expect(innerCards).toHaveLength(3);
innerCards.forEach((card) =>
expect(card).toHaveClass(
"border-border-neutral-tertiary",
"bg-bg-neutral-tertiary",
),
);
expect(settingsCard).toContainElement(openAIProvider);
expect(openAIProvider).toHaveAttribute("aria-pressed", "true");
expect(within(openAIProvider).getByText("Connected")).toBeInTheDocument();
const bedrockProvider = screen.getByRole("button", {
name: /Amazon Bedrock/i,
});
expect(within(bedrockProvider).getByText("Failed")).toBeInTheDocument();
const compatibleProvider = screen.getByRole("button", {
name: /OpenAI-compatible/i,
});
expect(
within(compatibleProvider).getByText("Not tested"),
).toBeInTheDocument();
});
it("renders a single shared business context, not a per-provider default model", async () => {
// Given
const user = userEvent.setup();
renderPage();
// Then: business context is tenant-wide, so there is exactly one editor
expect(
screen.getAllByRole("textbox", { name: /Business context/i }),
).toHaveLength(1);
// When switching providers, no per-provider model/context field appears
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
// Then
expect(
screen.queryByRole("combobox", { name: "Default model" }),
).not.toBeInTheDocument();
expect(screen.queryByText("Default model")).not.toBeInTheDocument();
expect(
screen.getAllByRole("textbox", { name: /Business context/i }),
).toHaveLength(1);
});
it("hides the business context editor until a provider is configured", () => {
// Given / When: no configurations exist yet
renderPage({ configurations: [] });
// Then: the editor is replaced by a hint and the textarea is absent
expect(
screen.queryByRole("textbox", { name: /Business context/i }),
).not.toBeInTheDocument();
expect(screen.getByText(/Configure a provider first/i)).toBeInTheDocument();
});
it("updates an existing configuration without sending blank credentials or model defaults", async () => {
// Given
const user = userEvent.setup();
updateConfigurationMock.mockResolvedValue({ data: configurations[0] });
renderPage();
// When: save the active provider without touching credentials
await user.click(
within(
screen.getByRole("region", { name: "Lighthouse AI settings" }),
).getByRole("button", { name: /^Save$/i }),
);
// Then
await waitFor(() => expect(updateConfigurationMock).toHaveBeenCalled());
expect(updateConfigurationMock.mock.calls[0]?.[0]).toBe("config-openai");
expect(updateConfigurationMock.mock.calls[0]?.[1]).not.toHaveProperty(
"credentials",
);
expect(updateConfigurationMock.mock.calls[0]?.[1]).not.toHaveProperty(
"defaultModel",
);
});
it("creates a new OpenAI-compatible configuration with required credentials", async () => {
// Given
const user = userEvent.setup();
const createdConfig: LighthouseV2Configuration = {
id: "config-compatible",
providerType: "openai-compatible",
baseUrl: "https://llm.example.com/v1",
defaultModel: "llama-3.3",
businessContext: "",
connected: null,
connectionLastCheckedAt: null,
insertedAt: "2026-06-24T10:00:00Z",
updatedAt: "2026-06-24T10:00:00Z",
};
createConfigurationMock.mockResolvedValue({ data: createdConfig });
testConnectionMock.mockResolvedValue({
data: {
...createdConfig,
connected: true,
connectionLastCheckedAt: "2026-06-24T10:01:00Z",
},
});
renderPage();
// When
await user.click(
screen.getByRole("button", { name: /OpenAI-compatible/i }),
);
await user.type(screen.getByLabelText("API key"), "provider-key");
await user.type(
screen.getByLabelText("Base URL"),
"https://llm.example.com/v1",
);
await user.click(screen.getByRole("button", { name: /^Save$/i }));
// Then
await waitFor(() => expect(createConfigurationMock).toHaveBeenCalled());
expect(createConfigurationMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
providerType: "openai-compatible",
credentials: { api_key: "provider-key" },
baseUrl: "https://llm.example.com/v1",
}),
);
expect(createConfigurationMock.mock.calls[0]?.[0]).not.toHaveProperty(
"defaultModel",
);
expect(createConfigurationMock.mock.calls[0]?.[0]).not.toHaveProperty(
"businessContext",
);
await waitFor(() =>
expect(testConnectionMock).toHaveBeenCalledWith("config-compatible"),
);
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ title: "Connection successful." }),
),
);
expect(
within(
screen.getByRole("button", { name: /OpenAI-compatible/i }),
).getByText("Connected"),
).toBeInTheDocument();
});
it("hints at stored credentials with a masked placeholder", async () => {
// Given / When: OpenAI and Bedrock already have stored configurations
const user = userEvent.setup();
renderPage();
// Then: secret fields simulate the hidden stored key instead of looking
// empty, at a length resembling a real API key
const maskedKey = "•".repeat(36);
expect(screen.getByLabelText("API key")).toHaveAttribute(
"placeholder",
maskedKey,
);
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
expect(screen.getByLabelText("AWS access key ID")).toHaveAttribute(
"placeholder",
maskedKey,
);
expect(screen.getByLabelText("AWS secret access key")).toHaveAttribute(
"placeholder",
maskedKey,
);
});
it("shows no masked placeholder before credentials are stored", async () => {
// Given
const user = userEvent.setup();
renderPage();
// When: OpenAI-compatible has no configuration yet
await user.click(
screen.getByRole("button", { name: /OpenAI-compatible/i }),
);
// Then
expect(screen.getByLabelText("API key")).not.toHaveAttribute("placeholder");
});
it("blocks OpenAI-compatible save when base URL is missing", async () => {
// Given
const user = userEvent.setup();
renderPage();
// When
await user.click(
screen.getByRole("button", { name: /OpenAI-compatible/i }),
);
await user.type(screen.getByLabelText("API key"), "provider-key");
await user.click(screen.getByRole("button", { name: /^Save$/i }));
// Then
expect(
await screen.findByText(
"Base URL is required for OpenAI-compatible providers.",
),
).toBeInTheDocument();
expect(createConfigurationMock).not.toHaveBeenCalled();
});
it("shows Bedrock access key, secret key, and region fields", async () => {
// Given
const user = userEvent.setup();
renderPage();
// When
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
// Then
expect(screen.getByLabelText("AWS access key ID")).toBeInTheDocument();
expect(screen.getByLabelText("AWS secret access key")).toBeInTheDocument();
expect(screen.getByLabelText("AWS region")).toBeInTheDocument();
});
it("tests the connection and reports the resulting status", async () => {
// Given
const user = userEvent.setup();
testConnectionMock.mockResolvedValue({
data: { ...configurations[0], connected: false },
});
renderPage();
// When
await user.click(screen.getByRole("button", { name: /Test connection/i }));
// Then: the action is polled to completion and the resulting status shown
await waitFor(() =>
expect(testConnectionMock).toHaveBeenCalledWith("config-openai"),
);
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Connection failed.",
variant: "destructive",
}),
),
);
expect(
screen.queryByRole("button", { name: /Refresh status/i }),
).not.toBeInTheDocument();
});
it("confirms before deleting an existing configuration", async () => {
// Given
const user = userEvent.setup();
renderPage();
// When
await user.click(screen.getByRole("button", { name: /^Delete$/i }));
await user.click(
screen.getByRole("button", { name: /Delete configuration/i }),
);
// Then
await waitFor(() =>
expect(deleteConfigurationMock).toHaveBeenCalledWith("config-openai"),
);
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ title: "Configuration removed." }),
),
);
});
});
function renderPage(
props?: Partial<Parameters<typeof LighthouseV2ConfigPage>[0]>,
) {
return render(
<LighthouseV2ConfigPage
configurations={props?.configurations ?? configurations}
providers={props?.providers ?? providers}
error={props?.error}
/>,
);
}
@@ -0,0 +1,184 @@
"use client";
import { useState } from "react";
import {
FEEDBACK_VARIANT,
type FeedbackState,
} from "@/app/(prowler)/lighthouse/_lib/config";
import {
type LighthouseV2Configuration,
type LighthouseV2ProviderType,
type LighthouseV2SupportedProvider,
} from "@/app/(prowler)/lighthouse/_types";
import { Card } from "@/components/shadcn/card/card";
import { useToast } from "@/components/ui";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { LighthouseV2BusinessContextForm } from "./business-context-form";
import { LighthouseV2ConfigurationForm } from "./configuration-form";
import { LighthouseV2EmptyState } from "./empty-state";
import { LighthouseV2ProviderRail } from "./provider-rail";
interface LighthouseV2ConfigPageProps {
configurations: LighthouseV2Configuration[];
providers: LighthouseV2SupportedProvider[];
error?: string;
}
export function LighthouseV2ConfigPage({
configurations,
providers,
error,
}: LighthouseV2ConfigPageProps) {
const { toast } = useToast();
const [localConfigurations, setLocalConfigurations] =
useState(configurations);
const [selectedProvider, setSelectedProvider] =
useState<LighthouseV2ProviderType>(providers[0]?.id ?? "openai");
const showFeedback = (feedback: FeedbackState) => {
toast({
title: feedback.title,
description: feedback.description,
variant:
feedback.variant === FEEDBACK_VARIANT.ERROR ? "destructive" : "default",
});
};
// Surface a load-time error (failed fetch) once, since it is not tied to a
// user interaction that could dispatch the toast itself.
useMountEffect(() => {
if (error) {
showFeedback({
title: "Configuration unavailable",
description: error,
variant: FEEDBACK_VARIANT.ERROR,
});
}
});
// Business context is shared across every provider (the backend syncs it on
// update), so it is edited once against any single configuration.
const businessContextConfig = localConfigurations[0];
const selectedConfig = localConfigurations.find(
(config) => config.providerType === selectedProvider,
);
const selectedProviderDefinition =
providers.find((provider) => provider.id === selectedProvider) ??
providers[0];
// Replace in place (don't filter+append): the shared business-context editor
// is anchored to localConfigurations[0], so reordering on every save/test
// would silently retarget it to a different provider's configuration.
const upsertConfiguration = (configuration: LighthouseV2Configuration) => {
setLocalConfigurations((current) => {
const index = current.findIndex(
(config) => config.id === configuration.id,
);
if (index === -1) {
return [...current, configuration];
}
const next = [...current];
next[index] = configuration;
return next;
});
};
const handleConfigurationSaved = (
configuration: LighthouseV2Configuration,
) => {
upsertConfiguration(configuration);
setSelectedProvider(configuration.providerType);
showFeedback({
title: "Configuration saved.",
description:
"Lighthouse AI can use this provider after it tests cleanly.",
variant: FEEDBACK_VARIANT.SUCCESS,
});
};
const handleConfigurationTested = (
configuration: LighthouseV2Configuration,
) => {
upsertConfiguration(configuration);
showFeedback(
configuration.connected
? {
title: "Connection successful.",
description: "Lighthouse AI can send messages with this provider.",
variant: FEEDBACK_VARIANT.SUCCESS,
}
: {
title: "Connection failed.",
description:
"Review the credentials and test the connection again.",
variant: FEEDBACK_VARIANT.ERROR,
},
);
};
const handleConfigurationDeleted = (configurationId: string) => {
setLocalConfigurations((current) =>
current.filter((config) => config.id !== configurationId),
);
showFeedback({
title: "Configuration removed.",
description: "This provider is no longer available for Lighthouse AI.",
variant: FEEDBACK_VARIANT.INFO,
});
};
if (providers.length === 0 || !selectedProviderDefinition) {
return <LighthouseV2EmptyState error={error} />;
}
return (
<Card
variant="base"
padding="none"
role="region"
aria-label="Lighthouse AI settings"
className="w-full gap-4 p-4 md:p-5"
>
{businessContextConfig ? (
<LighthouseV2BusinessContextForm
key={businessContextConfig.id}
configurationId={businessContextConfig.id}
initialBusinessContext={businessContextConfig.businessContext}
/>
) : (
<Card
variant="inner"
padding="md"
data-lighthouse-v2-business-context-empty=""
className="text-text-neutral-secondary text-sm"
>
Configure a provider first to add shared business context.
</Card>
)}
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[320px_minmax(0,1fr)]">
<LighthouseV2ProviderRail
configurations={localConfigurations}
providers={providers}
selectedProvider={selectedProvider}
onSelectProvider={setSelectedProvider}
/>
<LighthouseV2ConfigurationForm
key={selectedProvider}
configuration={selectedConfig}
provider={selectedProviderDefinition}
onConfigurationSaved={handleConfigurationSaved}
onConfigurationDeleted={handleConfigurationDeleted}
onConfigurationTested={handleConfigurationTested}
onFeedback={(feedback) => {
if (feedback) showFeedback(feedback);
}}
/>
</div>
</Card>
);
}
@@ -0,0 +1,28 @@
import { Icon } from "@iconify/react";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types";
const LIGHTHOUSE_V2_PROVIDER_ICONS = {
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI]: "simple-icons:openai",
[LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK]: "simple-icons:amazonwebservices",
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE]: "simple-icons:openai",
} as const satisfies Record<LighthouseV2ProviderType, string>;
export function ProviderIcon({
provider,
className,
}: {
provider: LighthouseV2ProviderType;
className?: string;
}) {
return (
<Icon
aria-hidden="true"
className={className}
icon={LIGHTHOUSE_V2_PROVIDER_ICONS[provider]}
/>
);
}
@@ -0,0 +1,83 @@
import { getConnectionStatus } from "@/app/(prowler)/lighthouse/_lib/config";
import { formatLastChecked } from "@/app/(prowler)/lighthouse/_lib/format";
import {
type LighthouseV2Configuration,
type LighthouseV2ProviderType,
type LighthouseV2SupportedProvider,
} from "@/app/(prowler)/lighthouse/_types";
import { Card } from "@/components/shadcn/card/card";
import { cn } from "@/lib/utils";
import { ProviderIcon } from "./provider-icon";
import { StatusBadge } from "./status-badge";
export function LighthouseV2ProviderRail({
configurations,
providers,
selectedProvider,
onSelectProvider,
}: {
configurations: LighthouseV2Configuration[];
providers: LighthouseV2SupportedProvider[];
selectedProvider: LighthouseV2ProviderType;
onSelectProvider: (provider: LighthouseV2ProviderType) => void;
}) {
return (
<Card variant="inner" padding="none" className="min-w-0 p-4 md:p-5">
<aside className="flex min-w-0 flex-col gap-3">
<div className="flex items-center justify-between gap-3 px-1">
<div>
<h3 className="text-text-neutral-primary text-sm font-semibold">
Providers
</h3>
<p className="text-text-neutral-secondary text-xs">
Choose provider to configure
</p>
</div>
</div>
<div className="flex flex-col gap-2">
{providers.map((provider) => {
const config = configurations.find(
(item) => item.providerType === provider.id,
);
const active = provider.id === selectedProvider;
const status = getConnectionStatus(config);
return (
<button
key={provider.id}
type="button"
aria-label={provider.name}
aria-pressed={active}
onClick={() => onSelectProvider(provider.id)}
className={cn(
"border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary group flex min-w-0 items-start gap-3 rounded-[12px] border p-3 text-left transition-colors",
active &&
"border-border-input-primary-press bg-bg-neutral-tertiary ring-border-input-primary-press ring-1",
)}
>
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-10 shrink-0 items-center justify-center rounded-[9px] border">
<ProviderIcon
provider={provider.id}
className="text-text-neutral-secondary size-5"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="text-text-neutral-primary truncate text-sm font-medium">
{provider.name}
</span>
<StatusBadge status={status} />
</div>
<p className="text-text-neutral-tertiary mt-1 text-xs">
{formatLastChecked(config?.connectionLastCheckedAt)}
</p>
</div>
</button>
);
})}
</div>
</aside>
</Card>
);
}
@@ -0,0 +1,34 @@
import { AlertCircle, CheckCircle2, CircleDashed } from "lucide-react";
import {
CONNECTION_STATUS,
type ConnectionStatus,
} from "@/app/(prowler)/lighthouse/_lib/config";
import { Badge } from "@/components/shadcn/badge/badge";
export function StatusBadge({ status }: { status: ConnectionStatus }) {
if (status === CONNECTION_STATUS.CONNECTED) {
return (
<Badge variant="success">
<CheckCircle2 />
Connected
</Badge>
);
}
if (status === CONNECTION_STATUS.FAILED) {
return (
<Badge variant="error">
<AlertCircle />
Failed
</Badge>
);
}
return (
<Badge variant="outline">
<CircleDashed />
Not tested
</Badge>
);
}
@@ -0,0 +1 @@
export { LighthouseV2SessionHistory } from "./lighthouse-v2-session-history";
@@ -0,0 +1,276 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LighthouseV2Session } from "@/app/(prowler)/lighthouse/_types";
import { LighthouseV2SessionHistory } from "./lighthouse-v2-session-history";
describe("LighthouseV2SessionHistory", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-25T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("keeps the session title truncated and the age label visible without horizontal overflow", () => {
// Given / When
renderHistory({
sessions: [
session({
id: "session-today",
title:
"This is a very long Lighthouse conversation title that must fit next to the age label",
updatedAt: "2026-06-25T09:00:00Z",
}),
],
});
// Then
const sessionButton = screen.getByRole("button", {
name: /This is a very long Lighthouse conversation title.*Today/,
});
const title = within(sessionButton).getByText(
/This is a very long Lighthouse conversation title/i,
);
const age = within(sessionButton).getByText("Today");
expect(sessionButton).toHaveClass("min-w-0", "overflow-hidden");
expect(sessionButton.parentElement).toHaveClass(
"min-w-0",
"overflow-hidden",
);
expect(title).toHaveClass("min-w-0", "flex-1", "truncate");
expect(age).toHaveClass("shrink-0", "whitespace-nowrap");
});
it("renders session age as numeric day labels instead of compact counters", () => {
// Given / When
renderHistory({
sessions: [
session({
id: "session-today",
title: "Today session",
updatedAt: "2026-06-25T09:00:00Z",
}),
session({
id: "session-one-day",
title: "One day session",
updatedAt: "2026-06-24T09:00:00Z",
}),
session({
id: "session-two-days",
title: "Two days session",
updatedAt: "2026-06-23T09:00:00Z",
}),
session({
id: "session-thirty-days",
title: "Thirty days session",
updatedAt: "2026-05-26T09:00:00Z",
}),
],
});
// Then
expect(screen.getByText("Today")).toBeInTheDocument();
expect(screen.getByText("1 day")).toBeInTheDocument();
expect(screen.getByText("2 days")).toBeInTheDocument();
expect(screen.getByText("30 days")).toBeInTheDocument();
expect(screen.queryByText("today")).not.toBeInTheDocument();
expect(screen.queryByText("1d")).not.toBeInTheDocument();
expect(screen.queryByText("2d")).not.toBeInTheDocument();
expect(screen.queryByText("30d")).not.toBeInTheDocument();
expect(screen.queryByText("one day")).not.toBeInTheDocument();
expect(screen.queryByText("thirty days")).not.toBeInTheDocument();
});
it("filters visible sessions by the current search value", () => {
// Given / When
renderHistory({
search: "threat",
sessions: [
session({
id: "session-threat",
title: "Threat model review",
updatedAt: "2026-06-25T09:00:00Z",
}),
session({
id: "session-compliance",
title: "Compliance gap analysis",
updatedAt: "2026-06-25T09:00:00Z",
}),
],
});
// Then
expect(screen.getByText("Threat model review")).toBeInTheDocument();
expect(
screen.queryByText("Compliance gap analysis"),
).not.toBeInTheDocument();
});
it("replaces the age label with the archive action on row hover", () => {
// Given / When
renderHistory({
sessions: [
session({
id: "session-today",
title: "Threat model review",
updatedAt: "2026-06-25T09:00:00Z",
}),
],
});
// Then
const sessionButton = screen.getByRole("button", {
name: /Threat model review.*Today/,
});
const row = sessionButton.parentElement;
const age = within(sessionButton).getByText("Today");
const archiveButton = screen.getByRole("button", {
name: "Archive Threat model review",
});
expect(row).toHaveClass("hover:bg-bg-neutral-tertiary");
expect(sessionButton).not.toHaveClass("hover:bg-bg-neutral-tertiary");
expect(age).toHaveClass(
"transition-opacity",
"group-hover:opacity-0",
"group-focus-within:opacity-0",
);
expect(archiveButton).toHaveClass(
"absolute",
"right-1",
"opacity-0",
"group-hover:opacity-100",
"group-focus-within:opacity-100",
"hover:text-text-neutral-secondary",
"active:text-text-neutral-secondary",
);
});
it("opens a confirmation modal before archiving a session", async () => {
// Given
vi.useRealTimers();
const user = userEvent.setup();
const onArchiveSession = vi.fn();
renderHistory({
onArchiveSession,
sessions: [
session({
id: "session-today",
title: "Threat model review",
updatedAt: "2026-06-25T09:00:00Z",
}),
],
});
// When
await user.click(
screen.getByRole("button", { name: "Archive Threat model review" }),
);
// Then
expect(onArchiveSession).not.toHaveBeenCalled();
const dialog = screen.getByRole("dialog", {
name: "Are you absolutely sure?",
});
expect(
within(dialog).getByText(
"This action cannot be undone. This will archive this chat and remove it from your chat history.",
),
).toBeInTheDocument();
// When
await user.click(within(dialog).getByRole("button", { name: "Archive" }));
// Then
expect(onArchiveSession).toHaveBeenCalledWith("session-today");
expect(
screen.queryByRole("dialog", { name: "Are you absolutely sure?" }),
).not.toBeInTheDocument();
});
it("explains the new chat button in a tooltip", async () => {
// Given
renderHistory();
vi.useRealTimers();
const user = userEvent.setup();
// When
await user.hover(screen.getByRole("button", { name: "New chat" }));
// Then
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent("New chat");
});
it("disables the new chat button while already on a new chat", () => {
// Given / When
renderHistory({ newChatDisabled: true });
// Then
expect(screen.getByRole("button", { name: "New chat" })).toBeDisabled();
});
it("shows the full trimmed title in a right-side tooltip", async () => {
// Given
const fullTitle =
"This is the complete Lighthouse conversation title shown in the tooltip";
renderHistory({
sessions: [
session({
id: "session-tooltip",
title: fullTitle,
updatedAt: "2026-06-25T09:00:00Z",
}),
],
});
const sessionButton = screen.getByRole("button", {
name: new RegExp(`${fullTitle}.*Today`),
});
vi.useRealTimers();
const user = userEvent.setup();
// When
await user.hover(sessionButton);
// Then
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent(fullTitle);
});
});
function renderHistory(
props?: Partial<Parameters<typeof LighthouseV2SessionHistory>[0]>,
) {
return render(
<LighthouseV2SessionHistory
sessions={props?.sessions ?? []}
activeSessionId={props?.activeSessionId}
search={props?.search ?? ""}
onSearchChange={props?.onSearchChange ?? vi.fn()}
onNewSession={props?.onNewSession ?? vi.fn()}
onOpenSession={props?.onOpenSession ?? vi.fn()}
onArchiveSession={props?.onArchiveSession ?? vi.fn()}
newChatDisabled={props?.newChatDisabled}
compact={props?.compact}
/>,
);
}
function session(
overrides: Partial<LighthouseV2Session> = {},
): LighthouseV2Session {
return {
id: "session-1",
title: "Session",
isArchived: false,
insertedAt: "2026-06-25T09:00:00Z",
updatedAt: "2026-06-25T09:00:00Z",
...overrides,
};
}
@@ -0,0 +1,195 @@
"use client";
import { Archive, Plus } from "lucide-react";
import { useState } from "react";
import { formatSessionAge } from "@/app/(prowler)/lighthouse/_lib/format";
import type { LighthouseV2Session } from "@/app/(prowler)/lighthouse/_types";
import { Button } from "@/components/shadcn/button/button";
import { Modal } from "@/components/shadcn/modal";
import { SearchInput } from "@/components/shadcn/search-input/search-input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
interface LighthouseV2SessionHistoryProps {
sessions: LighthouseV2Session[];
activeSessionId?: string | null;
search: string;
onSearchChange: (value: string) => void;
onNewSession: () => void;
onOpenSession: (sessionId: string) => void;
onArchiveSession: (sessionId: string) => void;
newChatDisabled?: boolean;
compact?: boolean;
}
export function LighthouseV2SessionHistory({
sessions,
activeSessionId,
search,
onSearchChange,
onNewSession,
onOpenSession,
onArchiveSession,
newChatDisabled = false,
compact = false,
}: LighthouseV2SessionHistoryProps) {
const [sessionPendingArchive, setSessionPendingArchive] =
useState<LighthouseV2Session | null>(null);
const visibleSessions = filterSessionsBySearch(sessions, search);
const handleArchiveModalOpenChange = (open: boolean) => {
if (!open) {
setSessionPendingArchive(null);
}
};
const handleConfirmArchive = () => {
if (!sessionPendingArchive) return;
onArchiveSession(sessionPendingArchive.id);
setSessionPendingArchive(null);
};
return (
<aside
className={cn(
"flex min-h-0 w-full min-w-0 flex-col gap-3 overflow-hidden",
compact && "gap-2",
)}
>
<div className="flex items-center gap-2">
<SearchInput
aria-label="Search Lighthouse AI sessions"
value={search}
placeholder="Chat history"
size={compact ? "sm" : "default"}
onChange={(event) => onSearchChange(event.target.value)}
onClear={() => onSearchChange("")}
/>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
type="button"
aria-label="New chat"
size={compact ? "icon-sm" : "icon"}
disabled={newChatDisabled}
onClick={onNewSession}
>
<Plus />
</Button>
</TooltipTrigger>
<TooltipContent side="right">New chat</TooltipContent>
</Tooltip>
</div>
<div className="minimal-scrollbar min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto">
{visibleSessions.length === 0 ? (
<div className="text-text-neutral-secondary px-2 py-8 text-center text-sm">
No chats
</div>
) : (
<div className="grid min-w-0">
{visibleSessions.map((session) => {
const sessionTitle = session.title || "Untitled chat";
const isActive = activeSessionId === session.id;
return (
<div
key={session.id}
className={cn(
"hover:bg-bg-neutral-tertiary group relative flex min-w-0 items-center overflow-hidden rounded-[8px] transition-colors",
isActive &&
"bg-bg-neutral-tertiary before:bg-button-primary before:absolute before:top-1/2 before:left-0 before:h-5 before:w-0.5 before:-translate-y-1/2 before:rounded-full",
)}
>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-[8px] px-2 py-2 text-left text-sm",
isActive && "text-text-neutral-primary",
)}
onClick={() => onOpenSession(session.id)}
>
<span
className={cn(
"min-w-0 flex-1 truncate",
isActive && "font-medium",
)}
>
{sessionTitle}
</span>
<span className="text-text-neutral-tertiary min-w-[3.25rem] shrink-0 text-right text-xs whitespace-nowrap transition-opacity group-focus-within:opacity-0 group-hover:opacity-0">
{formatSessionAge(session.updatedAt)}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">{sessionTitle}</TooltipContent>
</Tooltip>
<Button
type="button"
aria-label={`Archive ${sessionTitle}`}
variant="bare"
size="icon-xs"
className="hover:text-text-neutral-secondary active:text-text-neutral-secondary absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => setSessionPendingArchive(session)}
>
<Archive />
</Button>
</div>
);
})}
</div>
)}
</div>
<Modal
open={Boolean(sessionPendingArchive)}
onOpenChange={handleArchiveModalOpenChange}
title="Are you absolutely sure?"
description="This action cannot be undone. This will archive this chat and remove it from your chat history."
size="md"
>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
onClick={() => setSessionPendingArchive(null)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="lg"
onClick={handleConfirmArchive}
>
<Archive />
Archive
</Button>
</div>
</Modal>
</aside>
);
}
function filterSessionsBySearch(
sessions: LighthouseV2Session[],
search: string,
): LighthouseV2Session[] {
const normalizedSearch = search.trim().toLocaleLowerCase();
if (!normalizedSearch) return sessions;
return sessions.filter((session) =>
(session.title || "Untitled chat")
.toLocaleLowerCase()
.includes(normalizedSearch),
);
}
@@ -0,0 +1,2 @@
export { LighthouseV2NavigationModeSync } from "./lighthouse-v2-navigation-mode-sync";
export { LighthouseV2SidebarChat } from "./lighthouse-v2-sidebar-chat";
@@ -0,0 +1,14 @@
"use client";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { SIDEBAR_NAVIGATION_MODE, useSidebar } from "@/hooks/use-sidebar";
export function LighthouseV2NavigationModeSync() {
const setNavigationMode = useSidebar((state) => state.setNavigationMode);
useMountEffect(() => {
setNavigationMode(SIDEBAR_NAVIGATION_MODE.CHAT);
});
return null;
}
@@ -0,0 +1,247 @@
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT,
LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT,
} from "@/app/(prowler)/lighthouse/_lib/session-events";
import type { LighthouseV2Session } from "@/app/(prowler)/lighthouse/_types";
import { LighthouseV2SidebarChat } from "./lighthouse-v2-sidebar-chat";
const navigationMocks = vi.hoisted(() => ({
push: vi.fn(),
searchParams: "",
pathname: "/lighthouse",
}));
vi.mock("next/navigation", () => ({
usePathname: () => navigationMocks.pathname,
useRouter: () => ({ push: navigationMocks.push }),
useSearchParams: () => new URLSearchParams(navigationMocks.searchParams),
}));
const actions = vi.hoisted(() => ({
archiveLighthouseV2Session: vi.fn(),
getLighthouseV2Sessions: vi.fn(),
}));
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
archiveLighthouseV2Session: actions.archiveLighthouseV2Session,
getLighthouseV2Sessions: actions.getLighthouseV2Sessions,
}));
describe("LighthouseV2SidebarChat", () => {
beforeEach(() => {
navigationMocks.push.mockReset();
navigationMocks.searchParams = "";
navigationMocks.pathname = "/lighthouse";
actions.archiveLighthouseV2Session.mockReset();
actions.getLighthouseV2Sessions.mockReset();
window.history.replaceState(null, "", "/lighthouse");
});
it("marks the URL session as active in chat history", async () => {
// Given
navigationMocks.searchParams = "session=session-active";
actions.getLighthouseV2Sessions.mockResolvedValue({
data: [
session({ id: "session-active", title: "Active chat" }),
session({ id: "session-other", title: "Other chat" }),
],
});
// When
render(<LighthouseV2SidebarChat isOpen />);
// Then
const activeSession = await screen.findByRole("button", {
name: /^Active chat/,
});
const otherSession = screen.getByRole("button", {
name: /^Other chat/,
});
await waitFor(() =>
expect(activeSession.parentElement).toHaveClass("bg-bg-neutral-tertiary"),
);
expect(otherSession.parentElement).not.toHaveClass(
"bg-bg-neutral-tertiary",
);
});
it("navigates back to a new chat when archiving the open session", async () => {
// Given: the archived session is the one currently open (in the URL)
const user = userEvent.setup();
navigationMocks.searchParams = "session=session-active";
actions.getLighthouseV2Sessions.mockResolvedValue({
data: [session({ id: "session-active", title: "Active chat" })],
});
actions.archiveLighthouseV2Session.mockResolvedValue({
data: session({ id: "session-active", isArchived: true }),
});
const archivedIds: string[] = [];
const recordArchivedId = (event: Event) => {
archivedIds.push(
(event as CustomEvent<{ sessionId: string }>).detail.sessionId,
);
};
try {
window.addEventListener(
LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT,
recordArchivedId,
);
render(<LighthouseV2SidebarChat isOpen />);
// When
await user.click(
await screen.findByRole("button", { name: "Archive Active chat" }),
);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", {
name: "Archive",
}),
);
// Then: the URL no longer points at the archived (deleted) conversation,
// and the chat page is told which session died (it may hold a
// live-created session invisible to the router).
await waitFor(() =>
expect(navigationMocks.push).toHaveBeenCalledWith("/lighthouse"),
);
expect(archivedIds).toEqual(["session-active"]);
} finally {
window.removeEventListener(
LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT,
recordArchivedId,
);
}
});
it("stays on the open session when archiving a different one", async () => {
// Given
const user = userEvent.setup();
navigationMocks.searchParams = "session=session-active";
actions.getLighthouseV2Sessions.mockResolvedValue({
data: [
session({ id: "session-active", title: "Active chat" }),
session({ id: "session-other", title: "Other chat" }),
],
});
actions.archiveLighthouseV2Session.mockResolvedValue({
data: session({ id: "session-other", isArchived: true }),
});
render(<LighthouseV2SidebarChat isOpen />);
// When
await user.click(
await screen.findByRole("button", { name: "Archive Other chat" }),
);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", {
name: "Archive",
}),
);
// Then
await waitFor(() =>
expect(actions.archiveLighthouseV2Session).toHaveBeenCalledWith(
"session-other",
),
);
expect(navigationMocks.push).not.toHaveBeenCalled();
});
it("disables the new chat button while already on a pristine new chat", async () => {
// Given: /lighthouse with no session in any URL
actions.getLighthouseV2Sessions.mockResolvedValue({ data: [] });
// When
render(<LighthouseV2SidebarChat isOpen />);
// Then
expect(
await screen.findByRole("button", { name: "New chat" }),
).toBeDisabled();
});
it("enables the new chat button when a conversation is open", async () => {
// Given
navigationMocks.searchParams = "session=session-active";
actions.getLighthouseV2Sessions.mockResolvedValue({
data: [session({ id: "session-active", title: "Active chat" })],
});
// When
render(<LighthouseV2SidebarChat isOpen />);
// Then
expect(
await screen.findByRole("button", { name: "New chat" }),
).toBeEnabled();
});
it("enables the new chat button when the chat creates its session in place", async () => {
// Given: a pristine new chat
actions.getLighthouseV2Sessions.mockResolvedValue({ data: [] });
render(<LighthouseV2SidebarChat isOpen />);
expect(
await screen.findByRole("button", { name: "New chat" }),
).toBeDisabled();
// When: the first message creates the session via replaceState (Next's
// router never sees this URL) and history listeners are notified
act(() => {
window.history.replaceState(null, "", "/lighthouse?session=live-1");
window.dispatchEvent(new Event(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT));
});
// Then
await waitFor(() =>
expect(screen.getByRole("button", { name: "New chat" })).toBeEnabled(),
);
});
it("disables the collapsed new chat button too on a pristine new chat", async () => {
// Given
actions.getLighthouseV2Sessions.mockResolvedValue({ data: [] });
// When
render(<LighthouseV2SidebarChat isOpen={false} />);
// Then
await waitFor(() =>
expect(screen.getByRole("button", { name: "New chat" })).toBeDisabled(),
);
});
it("keeps the new chat button enabled outside the chat page", async () => {
// Given: chat sidebar mode active while browsing another page
navigationMocks.pathname = "/findings";
window.history.replaceState(null, "", "/findings");
actions.getLighthouseV2Sessions.mockResolvedValue({ data: [] });
// When
render(<LighthouseV2SidebarChat isOpen />);
// Then
expect(
await screen.findByRole("button", { name: "New chat" }),
).toBeEnabled();
});
});
function session(
overrides: Partial<LighthouseV2Session> = {},
): LighthouseV2Session {
return {
id: "session-1",
title: "Session",
isArchived: false,
insertedAt: "2026-06-30T09:00:00Z",
updatedAt: new Date().toISOString(),
...overrides,
};
}
@@ -0,0 +1,157 @@
"use client";
import { MessageSquare, Plus } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState, useSyncExternalStore } from "react";
import {
archiveLighthouseV2Session,
getLighthouseV2Sessions,
} from "@/app/(prowler)/lighthouse/_actions";
import {
LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT,
notifyLighthouseV2NewChat,
notifyLighthouseV2SessionArchived,
} from "@/app/(prowler)/lighthouse/_lib/session-events";
import type { LighthouseV2Session } from "@/app/(prowler)/lighthouse/_types";
import { Button } from "@/components/shadcn/button/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import { LighthouseV2SessionHistory } from "../history";
export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const activeSessionId = searchParams.get("session");
const browserUrlSessionId = useBrowserUrlSessionId();
// A pristine new chat is the chat route with no session anywhere; there,
// starting yet another new chat is a no-op.
const isOnNewChat =
pathname === LIGHTHOUSE_ROUTE.CHAT &&
!activeSessionId &&
!browserUrlSessionId;
const [sessions, setSessions] = useState<LighthouseV2Session[]>([]);
const [search, setSearch] = useState("");
const refreshSessions = async () => {
try {
const result = await getLighthouseV2Sessions();
if ("data" in result) {
setSessions(result.data);
}
} catch {
// Best-effort refresh: swallow transport-level failures so a rejected
// server action never escapes the mount effect as an unhandled error.
}
};
const handleSearchChange = (value: string) => {
setSearch(value);
};
const handleNewSession = () => {
// Reset an already-open chat in place, then route (covers other pages too).
notifyLighthouseV2NewChat();
router.push("/lighthouse");
};
const handleOpenSession = (sessionId: string) => {
router.push(`/lighthouse?session=${encodeURIComponent(sessionId)}`);
};
const handleArchiveSession = async (sessionId: string) => {
try {
const result = await archiveLighthouseV2Session(sessionId);
if ("data" in result) {
setSessions((current) =>
current.filter((session) => session.id !== sessionId),
);
// Covers live-created sessions the router can't see (replaceState URL).
notifyLighthouseV2SessionArchived(sessionId);
if (sessionId === activeSessionId) {
// The archived session no longer exists; leave its URL.
router.push("/lighthouse");
}
}
} catch {
// Archiving is recoverable from the sidebar; ignore transient failures.
}
};
useMountEffect(() => {
void refreshSessions();
const refresh = () => void refreshSessions();
window.addEventListener(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT, refresh);
return () => {
window.removeEventListener(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT, refresh);
};
});
if (!isOpen) {
return (
<div className="flex flex-col items-center gap-2 px-2 pt-4">
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
type="button"
aria-label="New chat"
size="icon"
disabled={isOnNewChat}
onClick={handleNewSession}
>
<Plus />
</Button>
</TooltipTrigger>
<TooltipContent side="right">New chat</TooltipContent>
</Tooltip>
<MessageSquare className="text-text-neutral-tertiary size-5" />
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col px-2 pt-4">
<LighthouseV2SessionHistory
compact
sessions={sessions}
activeSessionId={activeSessionId}
search={search}
onSearchChange={handleSearchChange}
onNewSession={handleNewSession}
onOpenSession={handleOpenSession}
onArchiveSession={handleArchiveSession}
newChatDisabled={isOnNewChat}
/>
</div>
);
}
// Sessions created live set their URL via replaceState, invisible to Next's
// router, so the real browser URL is the only reliable session source.
function useBrowserUrlSessionId() {
return useSyncExternalStore(
subscribeToSessionUrl,
readBrowserUrlSessionId,
() => null,
);
}
function subscribeToSessionUrl(onChange: () => void) {
window.addEventListener(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT, onChange);
window.addEventListener("popstate", onChange);
return () => {
window.removeEventListener(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT, onChange);
window.removeEventListener("popstate", onChange);
};
}
function readBrowserUrlSessionId() {
return new URLSearchParams(window.location.search).get("session");
}
+221
View File
@@ -0,0 +1,221 @@
import { z } from "zod";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2BedrockCredentials,
type LighthouseV2Configuration,
type LighthouseV2ConfigurationInput,
type LighthouseV2Credentials,
type LighthouseV2OpenAICompatibleCredentials,
type LighthouseV2OpenAICredentials,
type LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types";
export const BUSINESS_CONTEXT_LIMIT = 5000;
export const CONNECTION_STATUS = {
CONNECTED: "connected",
FAILED: "failed",
NOT_TESTED: "not-tested",
} as const;
export type ConnectionStatus =
(typeof CONNECTION_STATUS)[keyof typeof CONNECTION_STATUS];
export const FEEDBACK_VARIANT = {
ERROR: "error",
SUCCESS: "success",
INFO: "info",
} as const;
export type FeedbackVariant =
(typeof FEEDBACK_VARIANT)[keyof typeof FEEDBACK_VARIANT];
export interface FeedbackState {
title: string;
description?: string;
variant: FeedbackVariant;
}
const lighthouseV2ConfigFormSchemaBase = z.object({
apiKey: z.string(),
awsAccessKeyId: z.string(),
awsSecretAccessKey: z.string(),
awsRegionName: z.string(),
baseUrl: z.string(),
});
export type LighthouseV2ConfigFormValues = z.infer<
typeof lighthouseV2ConfigFormSchemaBase
>;
export const EMPTY_FORM_VALUES: LighthouseV2ConfigFormValues = {
apiKey: "",
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegionName: "",
baseUrl: "",
};
export function getFormDefaults(
configuration?: LighthouseV2Configuration,
): LighthouseV2ConfigFormValues {
return {
...EMPTY_FORM_VALUES,
baseUrl: configuration?.baseUrl ?? "",
};
}
export function buildLighthouseV2ConfigFormSchema(
provider: LighthouseV2ProviderType,
hasConfiguration: boolean,
) {
return lighthouseV2ConfigFormSchemaBase.superRefine((data, ctx) => {
const apiKey = data.apiKey.trim();
const baseUrl = data.baseUrl.trim();
if (
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE &&
!baseUrl
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Base URL is required for OpenAI-compatible providers.",
path: ["baseUrl"],
});
}
// Presence is enforced above per provider; here we only reject malformed
// values so strings like "foo" never reach the Cloud API.
if (baseUrl && !isValidUrl(baseUrl)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Base URL must be a valid URL.",
path: ["baseUrl"],
});
}
if (
(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE) &&
!hasConfiguration &&
!apiKey
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "API key is required for new configurations.",
path: ["apiKey"],
});
}
if (provider !== LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) return;
const hasAnyBedrockCredential =
Boolean(data.awsAccessKeyId.trim()) ||
Boolean(data.awsSecretAccessKey.trim()) ||
Boolean(data.awsRegionName.trim());
const shouldRequireBedrockCredentials =
!hasConfiguration || hasAnyBedrockCredential;
if (!shouldRequireBedrockCredentials) return;
if (!data.awsAccessKeyId.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS access key ID is required.",
path: ["awsAccessKeyId"],
});
}
if (!data.awsSecretAccessKey.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS secret access key is required.",
path: ["awsSecretAccessKey"],
});
}
if (!data.awsRegionName.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS region is required.",
path: ["awsRegionName"],
});
}
});
}
export function buildCredentialPayload(
provider: LighthouseV2ProviderType,
values: LighthouseV2ConfigFormValues,
hasConfiguration: boolean,
): LighthouseV2Credentials | undefined {
if (provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) {
const hasBedrockCredentials =
Boolean(values.awsAccessKeyId.trim()) ||
Boolean(values.awsSecretAccessKey.trim()) ||
Boolean(values.awsRegionName.trim());
if (hasConfiguration && !hasBedrockCredentials) return undefined;
return {
aws_access_key_id: values.awsAccessKeyId.trim(),
aws_secret_access_key: values.awsSecretAccessKey.trim(),
aws_region_name: values.awsRegionName.trim(),
};
}
if (hasConfiguration && !values.apiKey.trim()) return undefined;
return { api_key: values.apiKey.trim() };
}
function isValidUrl(value: string): boolean {
try {
new URL(value);
return true;
} catch {
return false;
}
}
// Builds the provider-keyed discriminated input from the runtime `provider` and
// the credentials the form assembled for it. This is the single place where the
// dynamic provider value is narrowed to a concrete variant, so the casts stay
// confined here while every typed caller of `LighthouseV2ConfigurationInput`
// gets full discriminated-union checking.
export function buildLighthouseV2ConfigurationInput(
provider: LighthouseV2ProviderType,
credentials: LighthouseV2Credentials,
baseUrl: string | null,
): LighthouseV2ConfigurationInput {
switch (provider) {
case LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE:
return {
providerType: LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE,
credentials: credentials as LighthouseV2OpenAICompatibleCredentials,
baseUrl: baseUrl ?? "",
};
case LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK:
return {
providerType: LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK,
credentials: credentials as LighthouseV2BedrockCredentials,
};
case LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI:
return {
providerType: LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI,
credentials: credentials as LighthouseV2OpenAICredentials,
};
}
}
export function trimToNullable(value: string) {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
export function getConnectionStatus(
configuration?: LighthouseV2Configuration,
): ConnectionStatus {
if (configuration?.connected === true) return CONNECTION_STATUS.CONNECTED;
if (configuration?.connected === false) return CONNECTION_STATUS.FAILED;
return CONNECTION_STATUS.NOT_TESTED;
}
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import {
createInitialLighthouseV2StreamState,
reduceLighthouseV2Event,
} from "./event-reducer";
describe("event-reducer", () => {
it("should append message deltas", () => {
// Given
const state = createInitialLighthouseV2StreamState("task-1");
// When
const next = reduceLighthouseV2Event(state, {
type: "message.delta",
content: "Hello",
});
// Then
expect(next.assistantText).toBe("Hello");
expect(next.status).toBe("streaming");
});
it("should pair tool start and end events", () => {
// Given
const state = reduceLighthouseV2Event(
createInitialLighthouseV2StreamState("task-1"),
{
type: "tool_call.start",
toolCallId: "tool-1",
toolName: "search",
},
);
// When
const next = reduceLighthouseV2Event(state, {
type: "tool_call.end",
toolCallId: "tool-1",
outcome: "success",
});
// Then
expect(next.toolCalls).toEqual([
{
id: "tool-1",
name: "search",
status: "completed",
outcome: "success",
},
]);
});
it("should preserve the live display order of text and tool events", () => {
// Given
let state = createInitialLighthouseV2StreamState("task-1");
// When
state = reduceLighthouseV2Event(state, {
type: "message.delta",
content: "Voy a buscar los findings por severidad",
});
state = reduceLighthouseV2Event(state, {
type: "tool_call.start",
toolCallId: "tool-1",
toolName: "prowler_app_search_security_findings",
});
state = reduceLighthouseV2Event(state, {
type: "tool_call.end",
toolCallId: "tool-1",
outcome: "success",
});
state = reduceLighthouseV2Event(state, {
type: "message.delta",
content: "Ahora voy a buscar en los criticos",
});
// Then
expect(state.activityItems).toEqual([
{
id: "text-0",
type: "text",
text: "Voy a buscar los findings por severidad",
},
{
id: "tool-1",
type: "tool_call",
name: "prowler_app_search_security_findings",
status: "completed",
outcome: "success",
},
{
id: "text-2",
type: "text",
text: "Ahora voy a buscar en los criticos",
},
]);
});
it("should mark message end as completed", () => {
// Given
const state = createInitialLighthouseV2StreamState("task-1");
// When
const next = reduceLighthouseV2Event(state, {
type: "message.end",
messageId: "message-1",
});
// Then
expect(next.status).toBe("completed");
expect(next.messageId).toBe("message-1");
expect(next.activeTaskId).toBeNull();
});
it("should store terminal errors", () => {
// Given
const state = createInitialLighthouseV2StreamState("task-1");
// When
const next = reduceLighthouseV2Event(state, {
type: "error",
code: "llm_error",
detail: "Provider failed",
});
// Then
expect(next.status).toBe("error");
expect(next.error).toEqual({
code: "llm_error",
detail: "Provider failed",
});
expect(next.activeTaskId).toBeNull();
});
it("should clear the task gate on disconnect so retry can recover", () => {
// Given
const state = createInitialLighthouseV2StreamState("task-1");
// When
const next = reduceLighthouseV2Event(state, { type: "disconnect" });
// Then
expect(next.status).toBe("disconnected");
// activeTaskId must be cleared: leaving it set keeps canSend false and
// makes the Retry button a no-op after a dropped SSE connection.
expect(next.activeTaskId).toBeNull();
});
});
@@ -0,0 +1,193 @@
import {
LIGHTHOUSE_V2_SSE_EVENT,
type LighthouseV2SSEEvent,
} from "@/app/(prowler)/lighthouse/_types";
export const LIGHTHOUSE_V2_STREAM_STATUS = {
IDLE: "idle",
STREAMING: "streaming",
COMPLETED: "completed",
ERROR: "error",
DISCONNECTED: "disconnected",
} as const;
export type LighthouseV2StreamStatus =
(typeof LIGHTHOUSE_V2_STREAM_STATUS)[keyof typeof LIGHTHOUSE_V2_STREAM_STATUS];
export const LIGHTHOUSE_V2_TOOL_CALL_STATUS = {
RUNNING: "running",
COMPLETED: "completed",
} as const;
export type LighthouseV2ToolCallStatus =
(typeof LIGHTHOUSE_V2_TOOL_CALL_STATUS)[keyof typeof LIGHTHOUSE_V2_TOOL_CALL_STATUS];
export const LIGHTHOUSE_V2_STREAM_ACTIVITY_ITEM_TYPE = {
TEXT: "text",
TOOL_CALL: "tool_call",
} as const;
export interface LighthouseV2ToolCallState {
id: string;
name: string;
status: LighthouseV2ToolCallStatus;
outcome?: string;
}
export interface LighthouseV2StreamTextActivityItem {
id: string;
type: typeof LIGHTHOUSE_V2_STREAM_ACTIVITY_ITEM_TYPE.TEXT;
text: string;
}
export interface LighthouseV2StreamToolCallActivityItem
extends LighthouseV2ToolCallState {
type: typeof LIGHTHOUSE_V2_STREAM_ACTIVITY_ITEM_TYPE.TOOL_CALL;
}
export type LighthouseV2StreamActivityItem =
| LighthouseV2StreamTextActivityItem
| LighthouseV2StreamToolCallActivityItem;
export interface LighthouseV2StreamError {
code: string;
detail: string;
}
export interface LighthouseV2StreamState {
status: LighthouseV2StreamStatus;
activeTaskId: string | null;
assistantText: string;
toolCalls: LighthouseV2ToolCallState[];
activityItems: LighthouseV2StreamActivityItem[];
messageId?: string;
error?: LighthouseV2StreamError;
}
export function createInitialLighthouseV2StreamState(
taskId: string | null = null,
): LighthouseV2StreamState {
return {
status: taskId
? LIGHTHOUSE_V2_STREAM_STATUS.STREAMING
: LIGHTHOUSE_V2_STREAM_STATUS.IDLE,
activeTaskId: taskId,
assistantText: "",
toolCalls: [],
activityItems: [],
};
}
export function reduceLighthouseV2Event(
state: LighthouseV2StreamState,
event: LighthouseV2SSEEvent,
): LighthouseV2StreamState {
switch (event.type) {
case LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_DELTA:
return {
...state,
status: LIGHTHOUSE_V2_STREAM_STATUS.STREAMING,
assistantText: `${state.assistantText}${event.content}`,
activityItems: appendTextActivityItem(
state.activityItems,
event.content,
),
};
case LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_START: {
const toolCall = {
id: event.toolCallId,
name: event.toolName,
status: LIGHTHOUSE_V2_TOOL_CALL_STATUS.RUNNING,
};
return {
...state,
status: LIGHTHOUSE_V2_STREAM_STATUS.STREAMING,
toolCalls: [...state.toolCalls, toolCall],
activityItems: [
...state.activityItems,
{
...toolCall,
type: LIGHTHOUSE_V2_STREAM_ACTIVITY_ITEM_TYPE.TOOL_CALL,
},
],
};
}
case LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_END:
return {
...state,
toolCalls: state.toolCalls.map((toolCall) =>
toolCall.id === event.toolCallId
? {
...toolCall,
status: LIGHTHOUSE_V2_TOOL_CALL_STATUS.COMPLETED,
outcome: event.outcome,
}
: toolCall,
),
activityItems: state.activityItems.map((item) =>
item.type === LIGHTHOUSE_V2_STREAM_ACTIVITY_ITEM_TYPE.TOOL_CALL &&
item.id === event.toolCallId
? {
...item,
status: LIGHTHOUSE_V2_TOOL_CALL_STATUS.COMPLETED,
outcome: event.outcome,
}
: item,
),
};
case LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_END:
return {
...state,
status: LIGHTHOUSE_V2_STREAM_STATUS.COMPLETED,
activeTaskId: null,
messageId: event.messageId,
};
case LIGHTHOUSE_V2_SSE_EVENT.ERROR:
return {
...state,
status: LIGHTHOUSE_V2_STREAM_STATUS.ERROR,
activeTaskId: null,
error: {
code: event.code,
detail: event.detail,
},
};
case LIGHTHOUSE_V2_SSE_EVENT.DISCONNECT:
// Clear the task gate so the UI can recover: keeping activeTaskId set
// leaves canSend false and makes the Retry button a no-op.
return {
...state,
status: LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED,
activeTaskId: null,
};
}
}
function appendTextActivityItem(
activityItems: LighthouseV2StreamActivityItem[],
text: string,
): LighthouseV2StreamActivityItem[] {
if (!text) {
return activityItems;
}
const lastItem = activityItems.at(-1);
if (lastItem?.type === LIGHTHOUSE_V2_STREAM_ACTIVITY_ITEM_TYPE.TEXT) {
return [
...activityItems.slice(0, -1),
{
...lastItem,
text: `${lastItem.text}${text}`,
},
];
}
return [
...activityItems,
{
id: `text-${activityItems.length}`,
type: LIGHTHOUSE_V2_STREAM_ACTIVITY_ITEM_TYPE.TEXT,
text,
},
];
}
@@ -0,0 +1,30 @@
import { differenceInCalendarDays, format, isValid, parseISO } from "date-fns";
import { formatLocalDate } from "@/lib/date-utils";
// Chat bubble timestamp, e.g. "Monday 9:30 AM".
export function formatMessageTimestamp(insertedAt: string): string {
const date = new Date(insertedAt);
if (Number.isNaN(date.getTime())) {
return "";
}
return format(date, "EEEE h:mm a");
}
// Provider connection "last checked" label, reusing the app-wide local date
// format (e.g. "Jun 15, 2026") instead of a bespoke toLocaleDateString call.
export function formatLastChecked(value?: string | null): string {
if (!value) return "Never checked";
const formatted = formatLocalDate(value);
return formatted ? `Last checked ${formatted}` : "Last check unavailable";
}
// Relative age for the session history list, e.g. "Today", "1 day", "5 days".
export function formatSessionAge(dateString: string): string {
const date = parseISO(dateString);
if (!isValid(date)) return "";
const ageInDays = Math.max(0, differenceInCalendarDays(new Date(), date));
if (ageInDays === 0) return "Today";
return ageInDays === 1 ? "1 day" : `${ageInDays} days`;
}
@@ -0,0 +1,60 @@
import {
LIGHTHOUSE_V2_PART_TYPE,
type LighthouseV2Message,
type LighthouseV2MessageRole,
} from "@/app/(prowler)/lighthouse/_types";
// Message parts can arrive as a raw string or as a `{ text }` object; this
// normalizes both to a plain string and ignores anything else.
export function getTextContent(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (
typeof content === "object" &&
content !== null &&
"text" in content &&
typeof content.text === "string"
) {
return content.text;
}
return "";
}
// Monotonic counter guaranteeing unique optimistic ids even when two messages
// are built within the same millisecond (toISOString alone is ms-granular).
let optimisticMessageCounter = 0;
// Builds a client-only message shown immediately after submit, before the
// backend echoes the persisted message back through the stream/refresh.
export function buildOptimisticMessage(
role: LighthouseV2MessageRole,
text: string,
): LighthouseV2Message {
const now = new Date().toISOString();
optimisticMessageCounter += 1;
const id = `optimistic-${role}-${now}-${optimisticMessageCounter}`;
return {
id,
role,
model: null,
tokenUsage: null,
insertedAt: now,
parts: [
{
id: `${id}-part`,
type: LIGHTHOUSE_V2_PART_TYPE.TEXT,
content: { text },
toolCallOutcome: null,
insertedAt: now,
updatedAt: now,
},
],
};
}
// Derives a session title from the first user message (collapsed + truncated).
export function buildSessionTitle(text: string): string {
const normalized = text.replace(/\s+/g, " ").trim();
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
}
@@ -0,0 +1,126 @@
import { describe, expect, it, vi } from "vitest";
import type {
LighthouseV2Configuration,
LighthouseV2ProviderType,
LighthouseV2SupportedModel,
} from "@/app/(prowler)/lighthouse/_types";
import { loadLighthouseV2ConnectedModels } from "./model-loading";
describe("loadLighthouseV2ConnectedModels", () => {
it("loads models only for connected configurations and initializes the rest as empty", async () => {
// Given
const openAIModel = model("gpt-5.5");
const loadModels = vi.fn(async (providerType: LighthouseV2ProviderType) => {
if (providerType === "openai-compatible") {
return { error: "Connection failed", status: 400 };
}
return { data: [openAIModel] };
});
// When
const result = await loadLighthouseV2ConnectedModels(
[
configuration("openai", true),
configuration("bedrock", false),
configuration("openai-compatible", false),
],
loadModels,
);
// Then
expect(loadModels).toHaveBeenCalledTimes(1);
expect(loadModels).toHaveBeenCalledWith("openai");
expect(result.modelsByProvider).toEqual({
openai: [openAIModel],
bedrock: [],
"openai-compatible": [],
});
expect(result.failedModelProviders).toEqual([]);
});
it("reports model-loading failures only for connected configurations", async () => {
// Given
const loadModels = vi.fn(async (providerType: LighthouseV2ProviderType) => {
if (providerType === "bedrock") {
return { error: "Bedrock models unavailable", status: 503 };
}
throw new Error(`Disconnected provider should not load: ${providerType}`);
});
// When
const result = await loadLighthouseV2ConnectedModels(
[
configuration("openai", false),
configuration("bedrock", true),
configuration("openai-compatible", false),
],
loadModels,
);
// Then
expect(loadModels).toHaveBeenCalledTimes(1);
expect(loadModels).toHaveBeenCalledWith("bedrock");
expect(result.modelsByProvider).toEqual({
openai: [],
bedrock: [],
"openai-compatible": [],
});
expect(result.failedModelProviders).toEqual(["bedrock"]);
});
it("dedupes provider types and treats null connection state as disconnected", async () => {
// Given
const openAIModel = model("gpt-5.5");
const loadModels = vi.fn(async () => ({ data: [openAIModel] }));
// When
const result = await loadLighthouseV2ConnectedModels(
[
configuration("openai", true),
{ ...configuration("openai", true), id: "config-openai-2" },
{ ...configuration("bedrock", false), connected: null },
],
loadModels,
);
// Then
expect(loadModels).toHaveBeenCalledTimes(1);
expect(loadModels).toHaveBeenCalledWith("openai");
expect(result.modelsByProvider.openai).toEqual([openAIModel]);
expect(result.modelsByProvider.bedrock).toEqual([]);
});
});
function configuration(
providerType: LighthouseV2ProviderType,
connected: boolean,
): LighthouseV2Configuration {
return {
id: `config-${providerType}`,
providerType,
baseUrl:
providerType === "openai-compatible" ? "https://example.com" : null,
defaultModel: null,
businessContext: "Production account",
connected,
connectionLastCheckedAt: null,
insertedAt: "2026-06-24T09:00:00Z",
updatedAt: "2026-06-24T10:00:00Z",
};
}
function model(id: string): LighthouseV2SupportedModel {
return {
id,
name: id,
maxInputTokens: null,
maxOutputTokens: null,
supportsFunctionCalling: true,
supportsVision: false,
supportsReasoning: true,
};
}
@@ -0,0 +1,79 @@
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2Configuration,
type LighthouseV2ProviderType,
type LighthouseV2SupportedModel,
} from "@/app/(prowler)/lighthouse/_types";
interface LighthouseV2SupportedModelsSuccess {
data: LighthouseV2SupportedModel[];
}
interface LighthouseV2SupportedModelsFailure {
error: string;
errors?: unknown[];
status?: number;
}
type LighthouseV2SupportedModelsResult =
| LighthouseV2SupportedModelsSuccess
| LighthouseV2SupportedModelsFailure;
type LoadLighthouseV2SupportedModels = (
providerType: LighthouseV2ProviderType,
) => Promise<LighthouseV2SupportedModelsResult>;
interface LighthouseV2ConnectedModelsResult {
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>;
failedModelProviders: LighthouseV2ProviderType[];
}
export function createEmptyLighthouseV2ModelsByProvider(): Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
> {
return {
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI]: [],
[LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK]: [],
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE]: [],
};
}
export async function loadLighthouseV2ConnectedModels(
configurations: LighthouseV2Configuration[],
loadModels: LoadLighthouseV2SupportedModels,
): Promise<LighthouseV2ConnectedModelsResult> {
const modelsByProvider = createEmptyLighthouseV2ModelsByProvider();
// Disconnected providers keep the [] pre-seed and never hit the models
// endpoint, so they can't surface spurious load failures either.
const connectedProviderTypes = Array.from(
new Set(
configurations
.filter((configuration) => configuration.connected === true)
.map((configuration) => configuration.providerType),
),
);
const modelsEntries = await Promise.all(
connectedProviderTypes.map(async (providerType) => {
const result = await loadModels(providerType);
return [providerType, result] as const;
}),
);
const failedModelProviders: LighthouseV2ProviderType[] = [];
modelsEntries.forEach(([providerType, result]) => {
if ("data" in result) {
modelsByProvider[providerType] = result.data;
return;
}
failedModelProviders.push(providerType);
});
return { modelsByProvider, failedModelProviders };
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
buildLighthouseV2ModelSelectionValue,
parseLighthouseV2ModelSelectionValue,
} from "./model-selection";
describe("model-selection value codec", () => {
it("round-trips a provider and model id", () => {
// Given
const value = buildLighthouseV2ModelSelectionValue("openai", "gpt-5.1");
// When
const selection = parseLighthouseV2ModelSelectionValue(value);
// Then
expect(value).toBe("openai:gpt-5.1");
expect(selection).toEqual({ providerType: "openai", modelId: "gpt-5.1" });
});
it("keeps colons inside Bedrock model ids by splitting on the first colon only", () => {
// Given
const value = buildLighthouseV2ModelSelectionValue(
"bedrock",
"anthropic.claude-3-sonnet-20240229-v1:0",
);
// When
const selection = parseLighthouseV2ModelSelectionValue(value);
// Then
expect(selection).toEqual({
providerType: "bedrock",
modelId: "anthropic.claude-3-sonnet-20240229-v1:0",
});
});
it("rejects values without a known provider or model id", () => {
// Then
expect(parseLighthouseV2ModelSelectionValue("")).toBeNull();
expect(parseLighthouseV2ModelSelectionValue("gpt-5.1")).toBeNull();
expect(parseLighthouseV2ModelSelectionValue(":gpt-5.1")).toBeNull();
expect(parseLighthouseV2ModelSelectionValue("unknown:gpt-5.1")).toBeNull();
expect(parseLighthouseV2ModelSelectionValue("openai:")).toBeNull();
});
});
@@ -0,0 +1,45 @@
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types";
export interface LighthouseV2ModelSelection {
providerType: LighthouseV2ProviderType;
modelId: string;
}
// Encodes a provider + model into a single combobox option value. The value is
// kept human-readable (no encoding) so the combobox search matches the model id
// the user types. Bedrock model ids can contain ":" (e.g. "...-v1:0"), so the
// parser splits on the FIRST ":" only.
export function buildLighthouseV2ModelSelectionValue(
providerType: LighthouseV2ProviderType,
modelId: string,
) {
return `${providerType}:${modelId}`;
}
export function parseLighthouseV2ModelSelectionValue(
value: string,
): LighthouseV2ModelSelection | null {
const separatorIndex = value.indexOf(":");
if (separatorIndex <= 0) return null;
const providerType = value.slice(0, separatorIndex);
if (!isLighthouseV2ProviderType(providerType)) return null;
const modelId = value.slice(separatorIndex + 1);
if (!modelId) return null;
return { providerType, modelId };
}
function isLighthouseV2ProviderType(
value: string,
): value is LighthouseV2ProviderType {
return (
value === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
value === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK ||
value === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE
);
}
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
export async function redirectWithSearchParams(
searchParams: SearchParams,
pathname: string,
) {
const params = await searchParams;
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => query.append(key, item));
return;
}
if (typeof value === "string") {
query.set(key, value);
}
});
const queryString = query.toString();
redirect(queryString ? `${pathname}?${queryString}` : pathname);
}
@@ -0,0 +1,32 @@
export const LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT =
"lighthouse-v2:sessions-changed";
export function notifyLighthouseV2SessionsChanged() {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT));
}
export const LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT =
"lighthouse-v2:session-archived";
// Carries the archived session id so the chat page can reset itself when its
// open session is archived. Needed because sessions created live set their URL
// via replaceState, so the sidebar can't spot them through useSearchParams.
export function notifyLighthouseV2SessionArchived(sessionId: string) {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT, {
detail: { sessionId },
}),
);
}
export const LIGHTHOUSE_V2_NEW_CHAT_EVENT = "lighthouse-v2:new-chat";
// Lets the sidebar reset an already-mounted chat page. router.push("/lighthouse")
// is a no-op when the URL was set via replaceState (Next's router never saw it),
// so the latest conversation needs a client-side reset signal.
export function notifyLighthouseV2NewChat() {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event(LIGHTHOUSE_V2_NEW_CHAT_EVENT));
}
@@ -0,0 +1,64 @@
import {
LIGHTHOUSE_V2_SSE_EVENT,
type LighthouseV2SSEEvent,
} from "@/app/(prowler)/lighthouse/_types";
// Parses a raw browser SSE event into a typed Lighthouse v2 stream event.
// The backend sends one named event per SSE type; the data payload is JSON.
export function parseStreamEvent(
event: Event,
type: LighthouseV2SSEEvent["type"],
): LighthouseV2SSEEvent {
const data = event instanceof MessageEvent ? parseJsonObject(event.data) : {};
if (type === LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_DELTA) {
return {
type,
content: readString(data, "content"),
};
}
if (type === LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_START) {
return {
type,
toolCallId: readString(data, "tool_call_id"),
toolName: readString(data, "tool_name"),
};
}
if (type === LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_END) {
return {
type,
toolCallId: readString(data, "tool_call_id"),
outcome: readString(data, "outcome"),
};
}
if (type === LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_END) {
return {
type,
messageId: readString(data, "message_id"),
};
}
return {
type: LIGHTHOUSE_V2_SSE_EVENT.ERROR,
code: readString(data, "code"),
detail: readString(data, "detail"),
};
}
function parseJsonObject(value: unknown): Record<string, unknown> {
if (typeof value !== "string") {
return {};
}
try {
const parsed: unknown = JSON.parse(value);
return typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
function readString(data: Record<string, unknown>, key: string): string {
const value = data[key];
return typeof value === "string" ? value : "";
}
@@ -0,0 +1,8 @@
// Same-origin path for the Lighthouse v2 SSE proxy route handler.
//
// The browser EventSource MUST hit our own origin (not the cross-origin API
// host) so it doesn't fail on CORS, and so the access token stays server-side
// (it is attached by the route handler, never placed in the browser URL).
export function buildLighthouseV2StreamUrl(sessionId: string): string {
return `/api/lighthouse/v2/sessions/${encodeURIComponent(sessionId)}/event-stream`;
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
formatToolName,
getToolCallContent,
isToolCallError,
} from "./tool-calls";
describe("getToolCallContent", () => {
it("should normalize the snake_case backend blob to camelCase", () => {
// Given
const content = {
tool_call_id: "call_1",
tool_name: "prowler_app_search_security_findings",
arguments: { severity: "high" },
result: { count: 3 },
outcome: "success",
};
// When
const parsed = getToolCallContent(content);
// Then
expect(parsed).toEqual({
toolCallId: "call_1",
toolName: "prowler_app_search_security_findings",
arguments: { severity: "high" },
result: { count: 3 },
outcome: "success",
});
});
it("should default missing optional fields without throwing", () => {
// Given a blob with only the required tool_name
const parsed = getToolCallContent({ tool_name: "search_tools" });
// Then
expect(parsed).toEqual({
toolCallId: "",
toolName: "search_tools",
arguments: null,
result: null,
outcome: null,
});
});
it("should return null for non-tool-call content", () => {
expect(getToolCallContent(null)).toBeNull();
expect(getToolCallContent("text")).toBeNull();
expect(getToolCallContent({ text: "hi" })).toBeNull();
});
});
describe("formatToolName", () => {
it("should strip the prowler prefix and title-case", () => {
expect(formatToolName("prowler_app_search_security_findings")).toBe(
"Search security findings",
);
expect(formatToolName("prowler_hub_list_checks")).toBe("List checks");
});
it("should humanize prefix-less tools", () => {
expect(formatToolName("search_tools")).toBe("Search tools");
});
});
describe("isToolCallError", () => {
it("should treat success and absent outcomes as non-errors", () => {
expect(isToolCallError("success")).toBe(false);
expect(isToolCallError(null)).toBe(false);
});
it("should treat any other outcome as an error", () => {
expect(isToolCallError("timeout")).toBe(true);
expect(isToolCallError("mcp_tool_error")).toBe(true);
});
});
@@ -0,0 +1,50 @@
import type { LighthouseV2ToolCallContent } from "@/app/(prowler)/lighthouse/_types";
// Prefixes shared by the MCP-sourced tools; stripped for display so a name like
// `prowler_app_search_security_findings` reads as "Search security findings".
const TOOL_NAME_PREFIXES = [
"prowler_app_",
"prowler_hub_",
"prowler_docs_",
] as const;
// Reads the snake_case TOOL_CALL blob the backend persists and normalizes it to
// the camelCase UI shape. Returns null when `content` isn't a tool-call object.
export function getToolCallContent(
content: unknown,
): LighthouseV2ToolCallContent | null {
if (typeof content !== "object" || content === null) {
return null;
}
const record = content as Record<string, unknown>;
if (typeof record.tool_name !== "string") {
return null;
}
return {
toolCallId:
typeof record.tool_call_id === "string" ? record.tool_call_id : "",
toolName: record.tool_name,
arguments: record.arguments ?? null,
result: record.result ?? null,
outcome: typeof record.outcome === "string" ? record.outcome : null,
};
}
// Turns a raw tool name into a human label by dropping the known prefix and
// title-casing, e.g. `prowler_hub_list_checks` -> "List checks". A humanizer
// (not a hardcoded map) keeps this in sync as the MCP tool whitelist grows.
export function formatToolName(toolName: string): string {
const prefix = TOOL_NAME_PREFIXES.find((value) => toolName.startsWith(value));
const stripped = prefix ? toolName.slice(prefix.length) : toolName;
const words = stripped.replace(/_/g, " ").trim();
if (!words) {
return toolName;
}
return words.charAt(0).toUpperCase() + words.slice(1);
}
// A tool call succeeded when its outcome is absent or the literal "success";
// any other outcome is an error surfaced to the user.
export function isToolCallError(outcome: string | null): boolean {
return outcome !== null && outcome.toLowerCase() !== "success";
}
@@ -0,0 +1,97 @@
export const LIGHTHOUSE_V2_PROVIDER_TYPE = {
OPENAI: "openai",
BEDROCK: "bedrock",
OPENAI_COMPATIBLE: "openai-compatible",
} as const;
export type LighthouseV2ProviderType =
(typeof LIGHTHOUSE_V2_PROVIDER_TYPE)[keyof typeof LIGHTHOUSE_V2_PROVIDER_TYPE];
export interface LighthouseV2OpenAICredentials {
api_key: string;
}
export interface LighthouseV2OpenAICompatibleCredentials {
api_key: string;
}
export interface LighthouseV2BedrockAccessKeyCredentials {
aws_access_key_id: string;
aws_secret_access_key: string;
aws_region_name: string;
}
export interface LighthouseV2BedrockApiKeyCredentials {
api_key: string;
aws_region_name: string;
}
export type LighthouseV2BedrockCredentials =
| LighthouseV2BedrockAccessKeyCredentials
| LighthouseV2BedrockApiKeyCredentials;
export type LighthouseV2Credentials =
| LighthouseV2OpenAICredentials
| LighthouseV2OpenAICompatibleCredentials
| LighthouseV2BedrockCredentials;
export interface LighthouseV2Configuration {
id: string;
providerType: LighthouseV2ProviderType;
baseUrl: string | null;
defaultModel: string | null;
businessContext: string;
connected: boolean | null;
connectionLastCheckedAt: string | null;
insertedAt: string;
updatedAt: string;
}
// Provider-keyed input variants: the `providerType` discriminant ties the
// accepted `credentials` shape (and whether `baseUrl` is allowed) to each
// provider, so a mismatched pair fails to type-check at the call site instead
// of slipping past into the adapter/server-action boundary.
export interface LighthouseV2OpenAIConfigurationInput {
providerType: typeof LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI;
credentials: LighthouseV2OpenAICredentials;
baseUrl?: null;
}
export interface LighthouseV2OpenAICompatibleConfigurationInput {
providerType: typeof LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE;
credentials: LighthouseV2OpenAICompatibleCredentials;
baseUrl: string;
}
export interface LighthouseV2BedrockConfigurationInput {
providerType: typeof LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK;
credentials: LighthouseV2BedrockCredentials;
baseUrl?: null;
}
export type LighthouseV2ConfigurationInput =
| LighthouseV2OpenAIConfigurationInput
| LighthouseV2OpenAICompatibleConfigurationInput
| LighthouseV2BedrockConfigurationInput;
export interface LighthouseV2ConfigurationUpdateInput {
credentials?: LighthouseV2Credentials;
baseUrl?: string | null;
defaultModel?: string | null;
businessContext?: string;
}
export interface LighthouseV2SupportedProvider {
id: LighthouseV2ProviderType;
name: string;
}
export interface LighthouseV2SupportedModel {
id: string;
name: string;
maxInputTokens: number | null;
maxOutputTokens: number | null;
supportsFunctionCalling: boolean | null;
supportsVision: boolean | null;
supportsReasoning: boolean | null;
}
@@ -0,0 +1,51 @@
export const LIGHTHOUSE_V2_SSE_EVENT = {
MESSAGE_DELTA: "message.delta",
TOOL_CALL_START: "tool_call.start",
TOOL_CALL_END: "tool_call.end",
MESSAGE_END: "message.end",
ERROR: "error",
DISCONNECT: "disconnect",
} as const;
export type LighthouseV2SSEEventName =
(typeof LIGHTHOUSE_V2_SSE_EVENT)[keyof typeof LIGHTHOUSE_V2_SSE_EVENT];
export interface LighthouseV2MessageDeltaEvent {
type: typeof LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_DELTA;
content: string;
}
export interface LighthouseV2ToolCallStartEvent {
type: typeof LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_START;
toolCallId: string;
toolName: string;
}
export interface LighthouseV2ToolCallEndEvent {
type: typeof LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_END;
toolCallId: string;
outcome: string;
}
export interface LighthouseV2MessageEndEvent {
type: typeof LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_END;
messageId: string;
}
export interface LighthouseV2ErrorEvent {
type: typeof LIGHTHOUSE_V2_SSE_EVENT.ERROR;
code: string;
detail: string;
}
export interface LighthouseV2DisconnectEvent {
type: typeof LIGHTHOUSE_V2_SSE_EVENT.DISCONNECT;
}
export type LighthouseV2SSEEvent =
| LighthouseV2MessageDeltaEvent
| LighthouseV2ToolCallStartEvent
| LighthouseV2ToolCallEndEvent
| LighthouseV2MessageEndEvent
| LighthouseV2ErrorEvent
| LighthouseV2DisconnectEvent;
@@ -0,0 +1,3 @@
export * from "./config";
export * from "./events";
export * from "./sessions";
@@ -0,0 +1,87 @@
import type { LighthouseV2ProviderType } from "./config";
export const LIGHTHOUSE_V2_MESSAGE_ROLE = {
USER: "user",
ASSISTANT: "assistant",
} as const;
export type LighthouseV2MessageRole =
(typeof LIGHTHOUSE_V2_MESSAGE_ROLE)[keyof typeof LIGHTHOUSE_V2_MESSAGE_ROLE];
export const LIGHTHOUSE_V2_PART_TYPE = {
TEXT: "text",
REASONING: "reasoning",
TOOL_CALL: "tool_call",
} as const;
export type LighthouseV2PartType =
(typeof LIGHTHOUSE_V2_PART_TYPE)[keyof typeof LIGHTHOUSE_V2_PART_TYPE];
export interface LighthouseV2Session {
id: string;
title: string | null;
isArchived: boolean;
insertedAt: string;
updatedAt: string;
}
export interface LighthouseV2Part {
id: string;
type: LighthouseV2PartType;
content: unknown;
toolCallOutcome: string | null;
insertedAt: string | null;
updatedAt: string | null;
}
// Normalized shape of a TOOL_CALL part's `content`. The backend persists this
// blob in snake_case (tool_call_id, tool_name, ...); `getToolCallContent`
// maps it to this camelCase form so the UI never touches the raw keys.
export interface LighthouseV2ToolCallContent {
toolCallId: string;
toolName: string;
arguments: unknown;
result: unknown;
outcome: string | null;
}
export interface LighthouseV2Message {
id: string;
role: LighthouseV2MessageRole;
model: string | null;
tokenUsage: unknown;
insertedAt: string;
parts: LighthouseV2Part[];
}
export interface LighthouseV2SendMessageInput {
sessionId: string;
text: string;
provider: LighthouseV2ProviderType;
model?: string | null;
}
export const LIGHTHOUSE_V2_TASK_STATE = {
AVAILABLE: "available",
EXECUTING: "executing",
COMPLETED: "completed",
FAILED: "failed",
CANCELLED: "cancelled",
} as const;
export type LighthouseV2TaskState =
(typeof LIGHTHOUSE_V2_TASK_STATE)[keyof typeof LIGHTHOUSE_V2_TASK_STATE];
export interface LighthouseV2Task {
id: string;
name: string | null;
state: LighthouseV2TaskState | string;
insertedAt?: string;
completedAt?: string | null;
metadata?: unknown;
result?: unknown;
}
export interface LighthouseV2SendMessageResult {
task: LighthouseV2Task;
}
@@ -0,0 +1,9 @@
import { redirectWithSearchParams } from "@/app/(prowler)/lighthouse/_lib/redirect-with-search-params";
export default async function LighthouseConfigConnectRedirectPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
await redirectWithSearchParams(searchParams, "/lighthouse/settings/connect");
}
+4 -13
View File
@@ -1,16 +1,7 @@
import { Spacer } from "@heroui/spacer";
import { redirect } from "next/navigation";
import { LighthouseSettings, LLMProvidersTable } from "@/components/lighthouse";
import { ContentLayout } from "@/components/ui";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
export const dynamic = "force-dynamic";
export default async function ChatbotConfigPage() {
return (
<ContentLayout title="LLM Configuration">
<LLMProvidersTable />
<Spacer y={8} />
<LighthouseSettings />
</ContentLayout>
);
export default function LighthouseConfigRedirectPage() {
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
@@ -0,0 +1,12 @@
import { redirectWithSearchParams } from "@/app/(prowler)/lighthouse/_lib/redirect-with-search-params";
export default async function LighthouseConfigSelectModelRedirectPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
await redirectWithSearchParams(
searchParams,
"/lighthouse/settings/select-model",
);
}
+81 -4
View File
@@ -3,10 +3,21 @@ import { redirect } from "next/navigation";
import {
getLighthouseProvidersConfig,
isLighthouseConfigured,
} from "@/actions/lighthouse/lighthouse";
} from "@/actions/lighthouse-v1/lighthouse";
import {
getLighthouseV2Configurations,
getLighthouseV2Messages,
getLighthouseV2SupportedModels,
getLighthouseV2SupportedProviders,
} from "@/app/(prowler)/lighthouse/_actions";
import { LighthouseV2ChatPage } from "@/app/(prowler)/lighthouse/_components/chat";
import { LighthouseV2NavigationModeSync } from "@/app/(prowler)/lighthouse/_components/navigation";
import { loadLighthouseV2ConnectedModels } from "@/app/(prowler)/lighthouse/_lib/model-loading";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Chat } from "@/components/lighthouse";
import { Chat } from "@/components/lighthouse-v1";
import { ContentLayout } from "@/components/ui";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import { isCloud } from "@/lib/shared/env";
export const dynamic = "force-dynamic";
@@ -18,11 +29,77 @@ export default async function AIChatbot({
const params = await searchParams;
const initialPrompt =
typeof params.prompt === "string" ? params.prompt : undefined;
const activeSessionId =
typeof params.session === "string" ? params.session : undefined;
if (isCloud()) {
const [configurationsResult, supportedProvidersResult] = await Promise.all([
getLighthouseV2Configurations(),
getLighthouseV2SupportedProviders(),
]);
const configurations =
"data" in configurationsResult ? configurationsResult.data : [];
const supportedProviders =
"data" in supportedProvidersResult ? supportedProvidersResult.data : [];
const connectedConfigurations = configurations.filter(
(configuration) => configuration.connected === true,
);
if (connectedConfigurations.length === 0) {
return redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
const { modelsByProvider, failedModelProviders } =
await loadLighthouseV2ConnectedModels(
configurations,
getLighthouseV2SupportedModels,
);
// Surface (rather than silently swallow to []) connected providers whose
// models failed to load, so their empty list reads as a real backend
// failure. Disconnected providers are never fetched (see model-loading.ts).
const modelsError =
failedModelProviders.length > 0
? `Could not load available models for: ${failedModelProviders.join(", ")}. Try again shortly.`
: undefined;
const initialMessages = activeSessionId
? await getLighthouseV2Messages(activeSessionId)
: { data: [] };
// Treat the ?session= id as valid when its messages load (you can't fetch
// messages for a non-existent session, so this is the authoritative
// "session exists" check). A stale/deleted id fails here and is dropped so
// the client starts fresh instead of sending against a dead session.
const sessionLoaded = Boolean(activeSessionId) && "data" in initialMessages;
const validSessionId = sessionLoaded ? activeSessionId : undefined;
const chatMessages =
sessionLoaded && "data" in initialMessages ? initialMessages.data : [];
const chatRouteKey = validSessionId ?? initialPrompt ?? "new";
return (
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}>
<LighthouseV2NavigationModeSync />
{/* [contain:layout] traps streamdown's fixed fullscreen overlay inside
the chat area so it never covers the sidebar or navbar. */}
<div className="h-[calc(100dvh-6.5rem)] min-h-0 [contain:layout]">
<LighthouseV2ChatPage
key={chatRouteKey}
configurations={configurations}
modelsByProvider={modelsByProvider}
supportedProviders={supportedProviders}
initialSessionId={validSessionId}
initialMessages={chatMessages}
initialPrompt={initialPrompt}
initialError={modelsError}
/>
</div>
</ContentLayout>
);
}
const hasConfig = await isLighthouseConfigured();
if (!hasConfig) {
return redirect("/lighthouse/config");
return redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
// Fetch provider configuration with default models
@@ -30,7 +107,7 @@ export default async function AIChatbot({
// Handle errors or missing configuration
if (providersConfig.errors || !providersConfig.providers) {
return redirect("/lighthouse/config");
return redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
return (
@@ -1,11 +1,12 @@
"use client";
import { useSearchParams } from "next/navigation";
import { redirect, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { ConnectLLMProvider } from "@/components/lighthouse/connect-llm-provider";
import { SelectBedrockAuthMethod } from "@/components/lighthouse/select-bedrock-auth-method";
import type { LighthouseProvider } from "@/types/lighthouse";
import { ConnectLLMProvider } from "@/components/lighthouse-v1/connect-llm-provider";
import { SelectBedrockAuthMethod } from "@/components/lighthouse-v1/select-bedrock-auth-method";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
export const BEDROCK_AUTH_MODES = {
IAM: "iam",
@@ -43,6 +44,10 @@ function ConnectContent() {
}
export default function ConnectLLMProviderPage() {
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
return (
<Suspense fallback={<div>Loading...</div>}>
<ConnectContent />
@@ -10,13 +10,14 @@ import React, { useEffect, useState } from "react";
import {
getTenantConfig,
updateTenantConfig,
} from "@/actions/lighthouse/lighthouse";
import { DeleteLLMProviderForm } from "@/components/lighthouse/forms/delete-llm-provider-form";
import { WorkflowConnectLLM } from "@/components/lighthouse/workflow";
} from "@/actions/lighthouse-v1/lighthouse";
import { DeleteLLMProviderForm } from "@/components/lighthouse-v1/forms/delete-llm-provider-form";
import { WorkflowConnectLLM } from "@/components/lighthouse-v1/workflow";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { NavigationHeader } from "@/components/ui";
import type { LighthouseProvider } from "@/types/lighthouse";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
interface ConnectLLMLayoutProps {
children: React.ReactNode;
@@ -54,7 +55,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
await updateTenantConfig({
default_provider: provider,
});
router.push("/lighthouse/config");
router.push(LIGHTHOUSE_ROUTE.SETTINGS);
};
if (!provider) {
@@ -78,7 +79,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
<NavigationHeader
title={isEditMode ? "Configure LLM Provider" : "Connect LLM Provider"}
icon="icon-park-outline:close-small"
href="/lighthouse/config"
href={LIGHTHOUSE_ROUTE.SETTINGS}
/>
<Spacer y={8} />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">
@@ -1,10 +1,11 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { redirect, useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { SelectModel } from "@/components/lighthouse/select-model";
import type { LighthouseProvider } from "@/types/lighthouse";
import { SelectModel } from "@/components/lighthouse-v1/select-model";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
function SelectModelContent() {
const searchParams = useSearchParams();
@@ -20,12 +21,16 @@ function SelectModelContent() {
<SelectModel
provider={provider}
mode={mode}
onSelect={() => router.push("/lighthouse/config")}
onSelect={() => router.push(LIGHTHOUSE_ROUTE.SETTINGS)}
/>
);
}
export default function SelectModelPage() {
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
return (
<Suspense fallback={<div>Loading...</div>}>
<SelectModelContent />
@@ -0,0 +1,50 @@
import {
getLighthouseV2Configurations,
getLighthouseV2SupportedProviders,
} from "@/app/(prowler)/lighthouse/_actions";
import { LighthouseV2ConfigPage } from "@/app/(prowler)/lighthouse/_components/config";
import {
LighthouseSettings,
LLMProvidersTable,
} from "@/components/lighthouse-v1";
import { ContentLayout } from "@/components/ui";
import { isCloud } from "@/lib/shared/env";
export const dynamic = "force-dynamic";
export default async function LighthouseSettingsPage() {
if (isCloud()) {
const [configurationsResult, providersResult] = await Promise.all([
getLighthouseV2Configurations(),
getLighthouseV2SupportedProviders(),
]);
const providers = "data" in providersResult ? providersResult.data : [];
const error =
"error" in configurationsResult
? configurationsResult.error
: "error" in providersResult
? providersResult.error
: undefined;
return (
<ContentLayout title="Settings">
<LighthouseV2ConfigPage
configurations={
"data" in configurationsResult ? configurationsResult.data : []
}
providers={providers}
error={error}
/>
</ContentLayout>
);
}
return (
<ContentLayout title="Settings">
<LLMProvidersTable />
<div className="h-8" aria-hidden="true" />
<LighthouseSettings />
</ContentLayout>
);
}
+16 -4
View File
@@ -2,11 +2,15 @@ import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { getLighthouseV2Configurations } from "@/app/(prowler)/lighthouse/_actions";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import { ContentLayout } from "@/components/ui";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
import { LighthouseOverviewBanner } from "./_overview/_components/lighthouse-overview-banner";
import { getLighthouseOverviewBannerHref } from "./_overview/_lib/lighthouse-banner";
import {
AttackSurfaceSkeleton,
AttackSurfaceSSR,
@@ -40,10 +44,12 @@ export default async function Home({
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
const [providersData, providerGroupsData] = await Promise.all([
getAllProviders(),
getAllProviderGroups(),
]);
const [providersData, providerGroupsData, lighthouseBannerHref] =
await Promise.all([
getAllProviders(),
getAllProviderGroups(),
getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations),
]);
return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
@@ -52,6 +58,12 @@ export default async function Home({
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
</div>
{lighthouseBannerHref ? (
<div className="mb-6">
<LighthouseOverviewBanner href={lighthouseBannerHref} />
</div>
) : null}
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
<Suspense fallback={<ThreatScoreSkeleton />}>
<ThreatScoreSSR searchParams={resolvedSearchParams} />
+15 -5
View File
@@ -3,15 +3,24 @@
import { NextResponse } from "next/server";
import { signIn } from "@/auth.config";
import {
getInvitationTokenFromCallbackPath,
getSafeCallbackPath,
} from "@/lib/auth-callback-url";
import { apiBaseUrl, baseUrl } from "@/lib/helper";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const code = searchParams.get("code");
const callbackPath = getSafeCallbackPath(searchParams);
const invitationToken = getInvitationTokenFromCallbackPath(callbackPath);
const params = new URLSearchParams();
params.append("code", code || "");
if (invitationToken) {
params.append("invitation_token", invitationToken);
}
if (!code) {
return NextResponse.json(
@@ -37,18 +46,20 @@ export async function GET(req: Request) {
const { access, refresh } = data.data.attributes;
try {
// Invitation tokens are accepted during the social token exchange.
const redirectPath = invitationToken ? "/" : callbackPath;
const result = await signIn("social-oauth", {
accessToken: access,
refreshToken: refresh,
redirect: false,
callbackUrl: `${baseUrl}/`,
callbackUrl: new URL(redirectPath, baseUrl).toString(),
});
if (result?.error) {
throw new Error(result.error);
}
return NextResponse.redirect(new URL("/", baseUrl));
return NextResponse.redirect(new URL(redirectPath, baseUrl));
} catch (error) {
console.error("SignIn error:", error);
return NextResponse.redirect(
@@ -57,9 +68,8 @@ export async function GET(req: Request) {
}
} catch (error) {
console.error("Error in Github callback:", error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 },
return NextResponse.redirect(
new URL("/sign-in?error=AuthenticationFailed", baseUrl),
);
}
}
+15 -5
View File
@@ -3,15 +3,24 @@
import { NextResponse } from "next/server";
import { signIn } from "@/auth.config";
import {
getInvitationTokenFromCallbackPath,
getSafeCallbackPath,
} from "@/lib/auth-callback-url";
import { apiBaseUrl, baseUrl } from "@/lib/helper";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const code = searchParams.get("code");
const callbackPath = getSafeCallbackPath(searchParams);
const invitationToken = getInvitationTokenFromCallbackPath(callbackPath);
const params = new URLSearchParams();
params.append("code", code || "");
if (invitationToken) {
params.append("invitation_token", invitationToken);
}
if (!code) {
return NextResponse.json(
@@ -37,18 +46,20 @@ export async function GET(req: Request) {
const { access, refresh } = data.data.attributes;
try {
// Invitation tokens are accepted during the social token exchange.
const redirectPath = invitationToken ? "/" : callbackPath;
const result = await signIn("social-oauth", {
accessToken: access,
refreshToken: refresh,
redirect: false,
callbackUrl: `${baseUrl}/`,
callbackUrl: new URL(redirectPath, baseUrl).toString(),
});
if (result?.error) {
throw new Error(result.error);
}
return NextResponse.redirect(new URL("/", baseUrl));
return NextResponse.redirect(new URL(redirectPath, baseUrl));
} catch (error) {
console.error("SignIn error:", error);
return NextResponse.redirect(
@@ -57,9 +68,8 @@ export async function GET(req: Request) {
}
} catch (error) {
console.error("Error in Google callback:", error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 },
return NextResponse.redirect(
new URL("/sign-in?error=AuthenticationFailed", baseUrl),
);
}
}
+4 -2
View File
@@ -3,11 +3,13 @@
import { NextResponse } from "next/server";
import { signIn } from "@/auth.config";
import { getSafeCallbackPath } from "@/lib/auth-callback-url";
import { apiBaseUrl, baseUrl } from "@/lib/helper";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
const callbackPath = getSafeCallbackPath(searchParams, "callbackUrl");
if (!id) {
return NextResponse.json(
@@ -40,14 +42,14 @@ export async function GET(req: Request) {
accessToken: access,
refreshToken: refresh,
redirect: false,
callbackUrl: `${baseUrl}/`,
callbackUrl: new URL(callbackPath, baseUrl).toString(),
});
if (result?.error) {
throw new Error(result.error);
}
return NextResponse.redirect(new URL("/", baseUrl));
return NextResponse.redirect(new URL(callbackPath, baseUrl));
} catch (error) {
console.error("SAML authentication failed:", error);
return NextResponse.redirect(new URL("/sign-in", baseUrl));
+6 -6
View File
@@ -1,7 +1,7 @@
import * as Sentry from "@sentry/nextjs";
import { createUIMessageStreamResponse, UIMessage } from "ai";
import { getTenantConfig } from "@/actions/lighthouse/lighthouse";
import { getTenantConfig } from "@/actions/lighthouse-v1/lighthouse";
import { auth } from "@/auth.config";
import { getErrorMessage } from "@/lib/helper";
import {
@@ -14,14 +14,14 @@ import {
handleChatModelStreamEvent,
handleToolEvent,
STREAM_MESSAGE_ID,
} from "@/lib/lighthouse/analyst-stream";
import { authContextStorage } from "@/lib/lighthouse/auth-context";
import { getCurrentDataSection } from "@/lib/lighthouse/data";
import { convertVercelMessageToLangChainMessage } from "@/lib/lighthouse/utils";
} from "@/lib/lighthouse-v1/analyst-stream";
import { authContextStorage } from "@/lib/lighthouse-v1/auth-context";
import { getCurrentDataSection } from "@/lib/lighthouse-v1/data";
import { convertVercelMessageToLangChainMessage } from "@/lib/lighthouse-v1/utils";
import {
initLighthouseWorkflow,
type RuntimeConfig,
} from "@/lib/lighthouse/workflow";
} from "@/lib/lighthouse-v1/workflow";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
export async function POST(req: Request) {
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more