mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-12 22:44:14 +00:00
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:
178 files changed
+11349
-685
No files matched your search
+10
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+32
-9
@@ -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";
|
||||
+276
@@ -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";
|
||||
+14
@@ -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;
|
||||
}
|
||||
+247
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
+9
-4
@@ -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 />
|
||||
+7
-6
@@ -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">
|
||||
+9
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
Reference in new issue
Block a user