From 7b5d724bb7cf182e2f3cf27fd72d88d986b32c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pe=C3=B1a?= Date: Mon, 6 Jul 2026 14:49:07 +0200 Subject: [PATCH 1/8] fix: handle invitations in social and SAML auth (#11752) Co-authored-by: alejandrobailo --- api/CHANGELOG.md | 1 + api/src/backend/api/adapters.py | 68 +++++-- api/src/backend/api/tests/test_adapters.py | 40 +++- api/src/backend/api/tests/test_views.py | 132 ++++++++++--- api/src/backend/api/utils.py | 41 +++- api/src/backend/api/v1/serializers.py | 3 + api/src/backend/api/v1/views.py | 91 +++++++-- ui/CHANGELOG.md | 8 + ui/actions/integrations/saml.ts | 11 +- ui/app/(auth)/(guest-only)/sign-up/page.tsx | 2 + ui/app/api/auth/callback/github/route.ts | 20 +- ui/app/api/auth/callback/google/route.ts | 20 +- ui/app/api/auth/callback/saml/route.ts | 6 +- ui/components/auth/oss/auth-form.tsx | 3 + ui/components/auth/oss/sign-in-form.tsx | 6 +- ui/components/auth/oss/sign-up-form.tsx | 20 +- ui/components/auth/oss/social-buttons.tsx | 208 +++++++++++++------- ui/lib/auth-callback-url.test.ts | 108 ++++++++++ ui/lib/auth-callback-url.ts | 65 ++++++ 19 files changed, 695 insertions(+), 158 deletions(-) create mode 100644 ui/lib/auth-callback-url.test.ts create mode 100644 ui/lib/auth-callback-url.ts diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index a781e57043..07f2203f2c 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🐞 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 diff --git a/api/src/backend/api/adapters.py b/api/src/backend/api/adapters.py index cbd6795731..fa0abacb20 100644 --- a/api/src/backend/api/adapters.py +++ b/api/src/backend/api/adapters.py @@ -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) diff --git a/api/src/backend/api/tests/test_adapters.py b/api/src/backend/api/tests/test_adapters.py index 91d3bb054a..1df9908ee5 100644 --- a/api/src/backend/api/tests/test_adapters.py +++ b/api/src/backend/api/tests/test_adapters.py @@ -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("/") diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index b159a284d3..a4951d936e 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -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) diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index ce1dc0f10d..bb636b1bfa 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -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") diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 1d160b4048..a88f53fc7d 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -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" diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 588c65c30d..e7907cf6f8 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -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) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 7a0a7de47a..d74a3111c1 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.32.1] (Prowler UNRELEASED) + +### 🐞 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) ### πŸš€ Added diff --git a/ui/actions/integrations/saml.ts b/ui/actions/integrations/saml.ts index 05a58e9e5e..41e153a46a 100644 --- a/ui/actions/integrations/saml.ts +++ b/ui/actions/integrations/saml.ts @@ -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", diff --git a/ui/app/(auth)/(guest-only)/sign-up/page.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.tsx index b846c59a93..4854de012b 100644 --- a/ui/app/(auth)/(guest-only)/sign-up/page.tsx +++ b/ui/app/(auth)/(guest-only)/sign-up/page.tsx @@ -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 ({ { const samlError = searchParams.get("sso_saml_failed"); @@ -102,7 +103,7 @@ export const SignInForm = ({ form.setValue("password", ""); } - const result = await initiateSamlAuth(email); + const result = await initiateSamlAuth(email, callbackUrl); if (result.success && result.redirectUrl) { window.location.href = result.redirectUrl; @@ -181,6 +182,7 @@ export const SignInForm = ({ diff --git a/ui/components/auth/oss/sign-up-form.tsx b/ui/components/auth/oss/sign-up-form.tsx index e18ded5845..df5cb0ba12 100644 --- a/ui/components/auth/oss/sign-up-form.tsx +++ b/ui/components/auth/oss/sign-up-form.tsx @@ -41,12 +41,14 @@ const FORM_ERROR_TYPE = { export const SignUpForm = ({ invitationToken, + isCloudEnv, googleAuthUrl, githubAuthUrl, isGoogleOAuthEnabled, isGithubOAuthEnabled, }: { invitationToken?: string | null; + isCloudEnv?: boolean; googleAuthUrl?: string; githubAuthUrl?: string; isGoogleOAuthEnabled?: boolean; @@ -54,6 +56,9 @@ export const SignUpForm = ({ }) => { const router = useRouter(); const { toast } = useToast(); + const callbackUrl = invitationToken + ? `/invitation/accept?invitation_token=${encodeURIComponent(invitationToken)}` + : "/"; const form = useForm({ resolver: zodResolver(signUpSchema), @@ -75,8 +80,14 @@ export const SignUpForm = ({ name: "password", defaultValue: "", }); + const termsAccepted = useWatch({ + control: form.control, + name: "termsAndConditions", + defaultValue: false, + }); const isLoading = form.formState.isSubmitting; + const isSocialAuthDisabled = Boolean(isCloudEnv && !termsAccepted); const onSubmit = async (data: SignUpFormData) => { const newUser = await createNewUser(data); @@ -88,7 +99,7 @@ export const SignUpForm = ({ }); form.reset(); - if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { + if (isCloudEnv) { router.push("/email-verification"); } else { router.push("/sign-in"); @@ -200,7 +211,7 @@ export const SignUpForm = ({ /> )} - {process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && ( + {isCloudEnv && ( - {!invitationToken && ( + {(!invitationToken || isCloudEnv) && ( <>
diff --git a/ui/components/auth/oss/social-buttons.tsx b/ui/components/auth/oss/social-buttons.tsx index 0dd92c48c0..65a891a743 100644 --- a/ui/components/auth/oss/social-buttons.tsx +++ b/ui/components/auth/oss/social-buttons.tsx @@ -1,83 +1,153 @@ -import { Tooltip } from "@heroui/tooltip"; import { Icon } from "@iconify/react"; +import type { ReactNode } from "react"; -import { Button } from "@/components/shadcn"; +import { + Button, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn"; import { CustomLink } from "@/components/ui/custom/custom-link"; +import { appendCallbackState } from "@/lib/auth-callback-url"; + +type SocialProvider = { + key: string; + label: string; + url?: string; + isOAuthEnabled?: boolean; + enabledIcon: string; + disabledIcon: string; + disabledDocs: { + message: string; + href: string; + }; +}; + +const SocialButton = ({ + provider, + isDisabled, + disabledTooltipContent, +}: { + provider: SocialProvider; + isDisabled: boolean; + disabledTooltipContent: ReactNode; +}) => { + const button = ( + + ); + + if (!isDisabled) { + return button; + } + + return ( + + + {button} + + + {provider.isOAuthEnabled ? ( + disabledTooltipContent + ) : ( +
+ {provider.disabledDocs.message}{" "} + + Read the docs + +
+ )} +
+
+ ); +}; export const SocialButtons = ({ googleAuthUrl, githubAuthUrl, + callbackUrl = "/", isGoogleOAuthEnabled, isGithubOAuthEnabled, + isDisabled = false, + disabledTooltipContent, }: { googleAuthUrl?: string; githubAuthUrl?: string; + callbackUrl?: string; isGoogleOAuthEnabled?: boolean; isGithubOAuthEnabled?: boolean; -}) => ( - <> - - Social Login with Google is not enabled.{" "} - - Read the docs - - - } - placement="top" - shadow="sm" - isDisabled={isGoogleOAuthEnabled} - className="w-96" - > - - - - - - Social Login with Github is not enabled.{" "} - - Read the docs - - - } - placement="top" - shadow="sm" - isDisabled={isGithubOAuthEnabled} - className="w-96" - > - - - - - -); + isDisabled?: boolean; + disabledTooltipContent?: ReactNode; +}) => { + const googleUrl = googleAuthUrl + ? appendCallbackState(googleAuthUrl, callbackUrl) + : undefined; + const githubUrl = githubAuthUrl + ? appendCallbackState(githubAuthUrl, callbackUrl) + : undefined; + const socialDisabledTooltip = + disabledTooltipContent || "Social login is currently unavailable."; + + const providers: SocialProvider[] = [ + { + key: "google", + label: "Continue with Google", + url: googleUrl, + isOAuthEnabled: isGoogleOAuthEnabled, + enabledIcon: "flat-color-icons:google", + disabledIcon: "simple-icons:google", + disabledDocs: { + message: "Social Login with Google is not enabled.", + href: "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#google-oauth-configuration", + }, + }, + { + key: "github", + label: "Continue with Github", + url: githubUrl, + isOAuthEnabled: isGithubOAuthEnabled, + enabledIcon: "simple-icons:github", + disabledIcon: "simple-icons:github", + disabledDocs: { + message: "Social Login with Github is not enabled.", + href: "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#github-oauth-configuration", + }, + }, + ]; + + return ( + <> + {providers.map((provider) => ( + + ))} + + ); +}; diff --git a/ui/lib/auth-callback-url.test.ts b/ui/lib/auth-callback-url.test.ts new file mode 100644 index 0000000000..71161c7c98 --- /dev/null +++ b/ui/lib/auth-callback-url.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { + appendCallbackState, + getInvitationTokenFromCallbackPath, + getSafeCallbackPath, +} from "@/lib/auth-callback-url"; + +describe("auth callback URL helpers", () => { + describe("when appending OAuth state", () => { + it("should add a relative callback path as provider state", () => { + const authUrl = "https://accounts.example.com/oauth?client_id=client"; + const callbackPath = "/invitation/accept?invitation_token=test-token"; + + const result = appendCallbackState(authUrl, callbackPath); + + expect(new URL(result).searchParams.get("state")).toBe(callbackPath); + }); + + it("should not add state for the default callback path", () => { + const authUrl = "https://accounts.example.com/oauth?client_id=client"; + + const result = appendCallbackState(authUrl, "/"); + + expect(new URL(result).searchParams.has("state")).toBe(false); + }); + }); + + describe("when reading callback paths", () => { + it("should return relative callback paths", () => { + const params = new URLSearchParams({ + state: "/invitation/accept?invitation_token=test-token", + }); + + const result = getSafeCallbackPath(params); + + expect(result).toBe("/invitation/accept?invitation_token=test-token"); + }); + + it("should reject external callback URLs", () => { + const params = new URLSearchParams({ + state: "https://attacker.example/phishing", + }); + + const result = getSafeCallbackPath(params); + + expect(result).toBe("/"); + }); + + it("should reject protocol-relative callback URLs", () => { + const params = new URLSearchParams({ + state: "//attacker.example/phishing", + }); + + const result = getSafeCallbackPath(params); + + expect(result).toBe("/"); + }); + + it("should reject backslash-normalized callback URLs", () => { + const params = new URLSearchParams({ state: "/\\attacker.example" }); + + const result = getSafeCallbackPath(params); + + expect(result).toBe("/"); + }); + + it("should reject callback URLs with control characters before the host", () => { + const params = new URLSearchParams({ state: "/\t/attacker.example" }); + + const result = getSafeCallbackPath(params); + + expect(result).toBe("/"); + }); + + it("should preserve the query string of relative callback paths", () => { + const params = new URLSearchParams({ + state: "/invitation/accept?invitation_token=test-token&foo=bar", + }); + + const result = getSafeCallbackPath(params); + + expect(result).toBe( + "/invitation/accept?invitation_token=test-token&foo=bar", + ); + }); + }); + + describe("when appending OAuth state for unsafe paths", () => { + it("should not add a backslash-normalized path as provider state", () => { + const authUrl = "https://accounts.example.com/oauth?client_id=client"; + + const result = appendCallbackState(authUrl, "/\\attacker.example"); + + expect(new URL(result).searchParams.has("state")).toBe(false); + }); + }); + + describe("when reading invitation tokens", () => { + it("should return invitation tokens from safe callback paths", () => { + const callbackPath = "/invitation/accept?invitation_token=test-token"; + + const result = getInvitationTokenFromCallbackPath(callbackPath); + + expect(result).toBe("test-token"); + }); + }); +}); diff --git a/ui/lib/auth-callback-url.ts b/ui/lib/auth-callback-url.ts new file mode 100644 index 0000000000..c2bdd6be9c --- /dev/null +++ b/ui/lib/auth-callback-url.ts @@ -0,0 +1,65 @@ +const DEFAULT_CALLBACK_PATH = "/"; +const INVITATION_TOKEN_PARAM = "invitation_token"; +// Origin used only to resolve relative paths; never part of the returned value. +const INTERNAL_ORIGIN = "http://localhost"; + +type CallbackSearchParams = { + get(name: string): string | null; +}; + +export const getSafeCallbackPathFromValue = ( + value: string | null | undefined, +) => { + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return DEFAULT_CALLBACK_PATH; + } + + // A prefix check is not enough: the URL parser normalizes backslashes and + // control characters, so "/\evil.com" or "/\t/evil.com" pass the check above + // yet resolve to an external origin. Resolve against a fixed origin and + // confirm it stayed internal before trusting the path. + try { + const url = new URL(value, INTERNAL_ORIGIN); + if (url.origin !== INTERNAL_ORIGIN) { + return DEFAULT_CALLBACK_PATH; + } + + return `${url.pathname}${url.search}${url.hash}`; + } catch (_error) { + return DEFAULT_CALLBACK_PATH; + } +}; + +export const getSafeCallbackPath = ( + searchParams: CallbackSearchParams, + key = "state", +) => getSafeCallbackPathFromValue(searchParams.get(key)); + +export const appendCallbackState = (authUrl: string, callbackPath: string) => { + const safeCallbackPath = getSafeCallbackPathFromValue(callbackPath); + if (safeCallbackPath === DEFAULT_CALLBACK_PATH) { + return authUrl; + } + + try { + const url = new URL(authUrl); + url.searchParams.set("state", safeCallbackPath); + return url.toString(); + } catch (_error) { + return authUrl; + } +}; + +export const getInvitationTokenFromCallbackPath = (callbackPath: string) => { + const safeCallbackPath = getSafeCallbackPathFromValue(callbackPath); + if (safeCallbackPath === DEFAULT_CALLBACK_PATH) { + return null; + } + + try { + const url = new URL(safeCallbackPath, "http://localhost"); + return url.searchParams.get(INVITATION_TOKEN_PARAM); + } catch (_error) { + return null; + } +}; From 855e9a043ea16526d9b30149cc7e22ae55540c23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 16:01:31 +0200 Subject: [PATCH 2/8] chore(changelog): prepare for 5.32.1 (#11854) --- prowler/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 5730cc4a1e..3a12c814da 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.32.1] (Prowler UNRELEASED) +## [5.32.1] (Prowler v5.32.1) ### 🐞 Fixed From 81c3152ebbd164f16cf8b49b2120a8496cb525b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Mon, 6 Jul 2026 16:14:20 +0200 Subject: [PATCH 3/8] chore(changelog): prepare for 5.32.1 (#11856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: César Arroba <19954079+cesararroba@users.noreply.github.com> --- api/CHANGELOG.md | 2 +- ui/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 07f2203f2c..d50816143f 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler API** are documented in this file. -## [1.33.1] (Prowler UNRELEASED) +## [1.33.1] (Prowler v5.32.1) ### 🐞 Fixed diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index d74a3111c1..7761d6c54d 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.32.1] (Prowler UNRELEASED) +## [1.32.1] (Prowler v5.32.1) ### 🐞 Fixed From 221c558cee75c1a39e970064f73c11d4f62ad48c Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:30:31 +0200 Subject: [PATCH 4/8] feat(ui): connect Lighthouse v2 to Cloud backend (#11690) --- .../{lighthouse => lighthouse-v1}/index.ts | 0 .../lighthouse.ts | 6 +- .../lighthouse-overview-banner.test.tsx | 33 + .../lighthouse-overview-banner.tsx | 128 ++++ .../_overview/_lib/lighthouse-banner.test.ts | 105 +++ .../_overview/_lib/lighthouse-banner.ts | 38 + .../findings-view/findings-view.ssr.tsx | 2 - ui/app/(prowler)/lighthouse/_actions/index.ts | 1 + .../_actions/lighthouse-v2.adapter.test.ts | 276 ++++++++ .../_actions/lighthouse-v2.adapter.ts | 380 ++++++++++ .../lighthouse/_actions/lighthouse-v2.test.ts | 159 +++++ .../lighthouse/_actions/lighthouse-v2.ts | 372 ++++++++++ .../_components/ai-elements/README.md | 7 + .../ai-elements/chain-of-thought.tsx | 239 +++++++ .../_components}/ai-elements/conversation.tsx | 41 +- .../lighthouse/_components/chat/composer.tsx | 196 +++++ .../_components/chat/decrypted-text-hooks.ts | 374 ++++++++++ .../_components/chat/decrypted-text.test.tsx | 23 + .../_components/chat/decrypted-text.tsx | 102 +++ .../_components/chat/empty-state.tsx | 107 +++ .../lighthouse/_components/chat/index.ts | 1 + .../chat/lighthouse-v2-chat-page.test.tsx | 668 ++++++++++++++++++ .../chat/lighthouse-v2-chat-page.tsx | 626 ++++++++++++++++ .../_components/chat/message-bubble.test.tsx | 182 +++++ .../_components/chat/message-bubble.tsx | 206 ++++++ .../_components/chat/message-markdown.tsx | 32 + .../_components/chat/streaming-message.tsx | 244 +++++++ .../_components/chat/tool-call-part.tsx | 124 ++++ .../config/business-context-form.test.tsx | 137 ++++ .../config/business-context-form.tsx | 128 ++++ .../_components/config/configuration-form.tsx | 321 +++++++++ .../config/configuration-section.tsx | 32 + .../_components/config/credential-fields.tsx | 108 +++ .../_components/config/empty-state.tsx | 32 + .../lighthouse/_components/config/index.ts | 1 + .../config/lighthouse-v2-config-page.test.tsx | 365 ++++++++++ .../config/lighthouse-v2-config-page.tsx | 184 +++++ .../_components/config/provider-icon.tsx | 28 + .../_components/config/provider-rail.tsx | 83 +++ .../_components/config/status-badge.tsx | 34 + .../lighthouse/_components/history/index.ts | 1 + .../lighthouse-v2-session-history.test.tsx | 253 +++++++ .../history/lighthouse-v2-session-history.tsx | 187 +++++ .../_components/navigation/index.ts | 2 + .../lighthouse-v2-navigation-mode-sync.tsx | 14 + .../lighthouse-v2-sidebar-chat.test.tsx | 77 ++ .../navigation/lighthouse-v2-sidebar-chat.tsx | 116 +++ ui/app/(prowler)/lighthouse/_lib/config.ts | 221 ++++++ .../lighthouse/_lib/event-reducer.test.ts | 148 ++++ .../lighthouse/_lib/event-reducer.ts | 193 +++++ ui/app/(prowler)/lighthouse/_lib/format.ts | 30 + ui/app/(prowler)/lighthouse/_lib/messages.ts | 60 ++ .../lighthouse/_lib/model-loading.test.ts | 126 ++++ .../lighthouse/_lib/model-loading.ts | 79 +++ .../lighthouse/_lib/model-selection.test.ts | 46 ++ .../lighthouse/_lib/model-selection.ts | 45 ++ .../_lib/redirect-with-search-params.ts | 25 + .../lighthouse/_lib/session-events.ts | 17 + .../lighthouse/_lib/stream-event-parser.ts | 64 ++ .../(prowler)/lighthouse/_lib/stream-url.ts | 8 + .../lighthouse/_lib/tool-calls.test.ts | 77 ++ .../(prowler)/lighthouse/_lib/tool-calls.ts | 50 ++ ui/app/(prowler)/lighthouse/_types/config.ts | 97 +++ ui/app/(prowler)/lighthouse/_types/events.ts | 51 ++ ui/app/(prowler)/lighthouse/_types/index.ts | 3 + .../(prowler)/lighthouse/_types/sessions.ts | 87 +++ .../lighthouse/config/connect/page.tsx | 9 + ui/app/(prowler)/lighthouse/config/page.tsx | 17 +- .../lighthouse/config/select-model/page.tsx | 12 + ui/app/(prowler)/lighthouse/page.tsx | 85 ++- .../(connect-llm)/connect/page.tsx | 13 +- .../(connect-llm)/layout.tsx | 13 +- .../(connect-llm)/select-model/page.tsx | 13 +- ui/app/(prowler)/lighthouse/settings/page.tsx | 50 ++ ui/app/(prowler)/page.tsx | 20 +- ui/app/api/lighthouse/analyst/route.ts | 12 +- .../[sessionId]/event-stream/route.test.ts | 102 +++ .../[sessionId]/event-stream/route.ts | 76 ++ ui/components/icons/Icons.tsx | 100 ++- ui/components/icons/index.ts | 1 + .../icons/lighthouse-icon-with-aura.tsx | 7 + .../ai-elements/actions.tsx | 0 .../ai-elements/chain-of-thought.tsx | 0 .../ai-elements/conversation.tsx | 124 ++++ .../ai-elements/dropdown-menu.tsx | 0 .../ai-elements/input-group.tsx | 0 .../ai-elements/input.tsx | 0 .../ai-elements/prompt-input.tsx | 0 .../ai-elements/select.tsx | 0 .../ai-elements/textarea.tsx | 0 .../ai-elements/tooltip.tsx | 0 .../chain-of-thought-display.tsx | 4 +- .../chat-utils.ts | 4 +- .../{lighthouse => lighthouse-v1}/chat.tsx | 16 +- .../connect-llm-provider.tsx | 8 +- .../forms/delete-llm-provider-form.tsx | 6 +- .../{lighthouse => lighthouse-v1}/index.ts | 1 - .../lighthouse-settings.tsx | 6 +- .../llm-provider-registry.ts | 2 +- .../llm-provider-utils.ts | 4 +- .../llm-providers-table.tsx | 6 +- .../{lighthouse => lighthouse-v1}/loader.tsx | 0 .../message-item.tsx | 82 +-- .../select-bedrock-auth-method.tsx | 0 .../select-model.tsx | 4 +- .../workflow/index.ts | 0 .../workflow/workflow-connect-llm.tsx | 10 +- ui/components/lighthouse/banner-client.tsx | 128 ---- ui/components/lighthouse/banner.tsx | 13 - ui/components/shadcn/combobox/combobox.tsx | 7 +- ui/components/shadcn/textarea/textarea.tsx | 1 + .../sidebar/navigation-mode-toggle.tsx | 111 +++ ui/components/ui/sidebar/menu.test.tsx | 193 ++++- ui/components/ui/sidebar/menu.tsx | 94 ++- ui/hooks/use-sidebar.ts | 14 + ui/lib/lighthouse-routes.ts | 9 + .../analyst-stream.ts | 7 +- .../auth-context.ts | 0 .../constants.ts | 0 ui/lib/{lighthouse => lighthouse-v1}/data.ts | 0 .../llm-factory.ts | 0 .../mcp-client.ts | 2 +- .../definitions/attack-path-custom-query.ts | 0 .../skills/index.ts | 0 .../skills/registry.ts | 0 .../skills/types.ts | 0 .../system-prompt.ts | 2 +- .../tools/load-skill.ts | 2 +- .../tools/meta-tool.ts | 4 +- ui/lib/{lighthouse => lighthouse-v1}/types.ts | 2 +- ui/lib/{lighthouse => lighthouse-v1}/utils.ts | 2 +- .../validation.ts | 4 +- .../{lighthouse => lighthouse-v1}/workflow.ts | 20 +- ui/lib/markdown.ts | 74 ++ ui/lib/menu-list.test.ts | 40 ++ ui/lib/menu-list.ts | 28 +- ui/styles/globals.css | 106 +++ ui/types/jsonapi.ts | 17 + .../credentials.ts | 0 .../{lighthouse => lighthouse-v1}/index.ts | 0 .../lighthouse-providers.ts | 0 .../model-params.ts | 0 ui/types/server-actions.ts | 17 + 143 files changed, 9496 insertions(+), 410 deletions(-) rename ui/actions/{lighthouse => lighthouse-v1}/index.ts (100%) rename ui/actions/{lighthouse => lighthouse-v1}/lighthouse.ts (99%) create mode 100644 ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx create mode 100644 ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.tsx create mode 100644 ui/app/(prowler)/_overview/_lib/lighthouse-banner.test.ts create mode 100644 ui/app/(prowler)/_overview/_lib/lighthouse-banner.ts create mode 100644 ui/app/(prowler)/lighthouse/_actions/index.ts create mode 100644 ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts create mode 100644 ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts create mode 100644 ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts create mode 100644 ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts create mode 100644 ui/app/(prowler)/lighthouse/_components/ai-elements/README.md create mode 100644 ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx rename ui/{components => app/(prowler)/lighthouse/_components}/ai-elements/conversation.tsx (73%) create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/composer.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/decrypted-text-hooks.ts create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/decrypted-text.test.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/decrypted-text.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/empty-state.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/index.ts create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/message-bubble.test.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/message-markdown.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/streaming-message.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/tool-call-part.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/config/business-context-form.test.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/config/business-context-form.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/config/configuration-form.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/config/configuration-section.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/config/credential-fields.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/config/empty-state.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/config/index.ts create mode 100644 ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.test.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/config/provider-icon.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/config/provider-rail.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/config/status-badge.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/history/index.ts create mode 100644 ui/app/(prowler)/lighthouse/_components/history/lighthouse-v2-session-history.test.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/history/lighthouse-v2-session-history.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/navigation/index.ts create mode 100644 ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-navigation-mode-sync.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.test.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.tsx create mode 100644 ui/app/(prowler)/lighthouse/_lib/config.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/event-reducer.test.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/event-reducer.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/format.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/messages.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/model-loading.test.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/model-loading.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/model-selection.test.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/model-selection.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/redirect-with-search-params.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/session-events.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/stream-event-parser.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/stream-url.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/tool-calls.test.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/tool-calls.ts create mode 100644 ui/app/(prowler)/lighthouse/_types/config.ts create mode 100644 ui/app/(prowler)/lighthouse/_types/events.ts create mode 100644 ui/app/(prowler)/lighthouse/_types/index.ts create mode 100644 ui/app/(prowler)/lighthouse/_types/sessions.ts create mode 100644 ui/app/(prowler)/lighthouse/config/connect/page.tsx create mode 100644 ui/app/(prowler)/lighthouse/config/select-model/page.tsx rename ui/app/(prowler)/lighthouse/{config => settings}/(connect-llm)/connect/page.tsx (73%) rename ui/app/(prowler)/lighthouse/{config => settings}/(connect-llm)/layout.tsx (89%) rename ui/app/(prowler)/lighthouse/{config => settings}/(connect-llm)/select-model/page.tsx (56%) create mode 100644 ui/app/(prowler)/lighthouse/settings/page.tsx create mode 100644 ui/app/api/lighthouse/v2/sessions/[sessionId]/event-stream/route.test.ts create mode 100644 ui/app/api/lighthouse/v2/sessions/[sessionId]/event-stream/route.ts create mode 100644 ui/components/icons/lighthouse-icon-with-aura.tsx rename ui/components/{lighthouse => lighthouse-v1}/ai-elements/actions.tsx (100%) rename ui/components/{ => lighthouse-v1}/ai-elements/chain-of-thought.tsx (100%) create mode 100644 ui/components/lighthouse-v1/ai-elements/conversation.tsx rename ui/components/{lighthouse => lighthouse-v1}/ai-elements/dropdown-menu.tsx (100%) rename ui/components/{lighthouse => lighthouse-v1}/ai-elements/input-group.tsx (100%) rename ui/components/{lighthouse => lighthouse-v1}/ai-elements/input.tsx (100%) rename ui/components/{lighthouse => lighthouse-v1}/ai-elements/prompt-input.tsx (100%) rename ui/components/{lighthouse => lighthouse-v1}/ai-elements/select.tsx (100%) rename ui/components/{lighthouse => lighthouse-v1}/ai-elements/textarea.tsx (100%) rename ui/components/{lighthouse => lighthouse-v1}/ai-elements/tooltip.tsx (100%) rename ui/components/{lighthouse => lighthouse-v1}/chain-of-thought-display.tsx (93%) rename ui/components/{lighthouse => lighthouse-v1}/chat-utils.ts (98%) rename ui/components/{lighthouse => lighthouse-v1}/chat.tsx (97%) rename ui/components/{lighthouse => lighthouse-v1}/connect-llm-provider.tsx (98%) rename ui/components/{lighthouse => lighthouse-v1}/forms/delete-llm-provider-form.tsx (93%) rename ui/components/{lighthouse => lighthouse-v1}/index.ts (90%) rename ui/components/{lighthouse => lighthouse-v1}/lighthouse-settings.tsx (95%) rename ui/components/{lighthouse => lighthouse-v1}/llm-provider-registry.ts (97%) rename ui/components/{lighthouse => lighthouse-v1}/llm-provider-utils.ts (97%) rename ui/components/{lighthouse => lighthouse-v1}/llm-providers-table.tsx (97%) rename ui/components/{lighthouse => lighthouse-v1}/loader.tsx (100%) rename ui/components/{lighthouse => lighthouse-v1}/message-item.tsx (70%) rename ui/components/{lighthouse => lighthouse-v1}/select-bedrock-auth-method.tsx (100%) rename ui/components/{lighthouse => lighthouse-v1}/select-model.tsx (98%) rename ui/components/{lighthouse => lighthouse-v1}/workflow/index.ts (100%) rename ui/components/{lighthouse => lighthouse-v1}/workflow/workflow-connect-llm.tsx (94%) delete mode 100644 ui/components/lighthouse/banner-client.tsx delete mode 100644 ui/components/lighthouse/banner.tsx create mode 100644 ui/components/sidebar/navigation-mode-toggle.tsx create mode 100644 ui/lib/lighthouse-routes.ts rename ui/lib/{lighthouse => lighthouse-v1}/analyst-stream.ts (97%) rename ui/lib/{lighthouse => lighthouse-v1}/auth-context.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/constants.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/data.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/llm-factory.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/mcp-client.ts (99%) rename ui/lib/{lighthouse => lighthouse-v1}/skills/definitions/attack-path-custom-query.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/skills/index.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/skills/registry.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/skills/types.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/system-prompt.ts (99%) rename ui/lib/{lighthouse => lighthouse-v1}/tools/load-skill.ts (97%) rename ui/lib/{lighthouse => lighthouse-v1}/tools/meta-tool.ts (97%) rename ui/lib/{lighthouse => lighthouse-v1}/types.ts (95%) rename ui/lib/{lighthouse => lighthouse-v1}/utils.ts (96%) rename ui/lib/{lighthouse => lighthouse-v1}/validation.ts (93%) rename ui/lib/{lighthouse => lighthouse-v1}/workflow.ts (89%) create mode 100644 ui/lib/markdown.ts create mode 100644 ui/types/jsonapi.ts rename ui/types/{lighthouse => lighthouse-v1}/credentials.ts (100%) rename ui/types/{lighthouse => lighthouse-v1}/index.ts (100%) rename ui/types/{lighthouse => lighthouse-v1}/lighthouse-providers.ts (100%) rename ui/types/{lighthouse => lighthouse-v1}/model-params.ts (100%) diff --git a/ui/actions/lighthouse/index.ts b/ui/actions/lighthouse-v1/index.ts similarity index 100% rename from ui/actions/lighthouse/index.ts rename to ui/actions/lighthouse-v1/index.ts diff --git a/ui/actions/lighthouse/lighthouse.ts b/ui/actions/lighthouse-v1/lighthouse.ts similarity index 99% rename from ui/actions/lighthouse/lighthouse.ts rename to ui/actions/lighthouse-v1/lighthouse.ts index 5365517bbd..635571f69e 100644 --- a/ui/actions/lighthouse/lighthouse.ts +++ b/ui/actions/lighthouse-v1/lighthouse.ts @@ -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 = diff --git a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx b/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx new file mode 100644 index 0000000000..2e94daf4c7 --- /dev/null +++ b/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx @@ -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(); + + // 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(); + + // Then + expect( + screen.getByRole("link", { + name: /Find and remediate which actually matters\./, + }), + ).toHaveAttribute("href", "/lighthouse/settings"); + }); +}); diff --git a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.tsx b/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.tsx new file mode 100644 index 0000000000..eb2e0539fb --- /dev/null +++ b/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.tsx @@ -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(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) => { + if (interactiveRef.current) { + const rect = interactiveRef.current.getBoundingClientRect(); + tgXRef.current = event.clientX - rect.left; + tgYRef.current = event.clientY - rect.top; + } + }; + + return ( + + + + + + + + + + + + +
+
+ +
+ +
+ +
+
+ + +
+ + + +
+

+ Lighthouse AI +

+

+ Find and remediate which actually matters. +

+
+
+ +
+ + + ); +} diff --git a/ui/app/(prowler)/_overview/_lib/lighthouse-banner.test.ts b/ui/app/(prowler)/_overview/_lib/lighthouse-banner.test.ts new file mode 100644 index 0000000000..2ac8bcb2df --- /dev/null +++ b/ui/app/(prowler)/_overview/_lib/lighthouse-banner.test.ts @@ -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", + }; +} diff --git a/ui/app/(prowler)/_overview/_lib/lighthouse-banner.ts b/ui/app/(prowler)/_overview/_lib/lighthouse-banner.ts new file mode 100644 index 0000000000..495c2d47d0 --- /dev/null +++ b/ui/app/(prowler)/_overview/_lib/lighthouse-banner.ts @@ -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 +>; + +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 { + if (!cloud) { + return null; + } + + const result = await loadConfigurations(); + if (!("data" in result)) { + return null; + } + + return resolveLighthouseOverviewBannerHref(result.data); +} diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx index 9a7a184a42..c2fa1e379b 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx @@ -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 (
- { + describe("when mapping Cloud JSON:API resources", () => { + it("should map configuration attributes to UI fields", () => { + // Given + const resource: Parameters[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[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[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.", + }); + }); + }); +}); diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts new file mode 100644 index 0000000000..5d9a93295c --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts @@ -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 & { 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( + document: JsonApiDocument, +): TResource[] { + return document.data ?? []; +} + +export function mapLighthouseV2Configuration( + resource: JsonApiResource, +): 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, +): LighthouseV2SupportedProvider { + return { + id: normalizeLighthouseV2ProviderType(resource.id), + name: resource.attributes.name, + }; +} + +export function mapLighthouseV2Model( + resource: JsonApiResource, +): 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, +): 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, +): 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, +): 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>( + attributes: T, +) { + return Object.fromEntries( + Object.entries(attributes).filter(([, value]) => value !== undefined), + ) as Partial; +} + +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; +} diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts new file mode 100644 index 0000000000..bab81d7157 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts @@ -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", + }), + ); + }); +}); diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts new file mode 100644 index 0000000000..c78233b35f --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts @@ -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[0]; + +export type LighthouseV2ActionResult = ServerActionResult; + +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 +> { + return getCollection(CONFIG_ENDPOINT, mapLighthouseV2Configuration); +} + +export async function createLighthouseV2Configuration( + input: LighthouseV2ConfigurationInput, +): Promise> { + 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> { + 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> { + 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> { + 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; + 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 +> { + return getCollection(SUPPORTED_PROVIDERS_ENDPOINT, mapLighthouseV2Provider); +} + +export async function getLighthouseV2SupportedModels( + provider: LighthouseV2ProviderType, +): Promise> { + return getCollection( + `${SUPPORTED_PROVIDERS_ENDPOINT}/${encodeURIComponent(toLighthouseV2ApiProviderType(provider))}/models`, + mapLighthouseV2Model, + ); +} + +export async function getLighthouseV2Sessions(): Promise< + LighthouseV2ActionResult +> { + return getCollection(SESSIONS_ENDPOINT, mapLighthouseV2Session); +} + +export async function createLighthouseV2Session( + title?: string | null, +): Promise> { + // 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> { + 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> { + return updateLighthouseV2Session(sessionId, { isArchived: true }); +} + +export async function getLighthouseV2Messages( + sessionId: string, +): Promise> { + return getCollection( + `${SESSIONS_ENDPOINT}/${encodeURIComponent(sessionId)}/messages`, + mapLighthouseV2Message, + ); +} + +export async function sendLighthouseV2Message( + input: LighthouseV2SendMessageInput, +): Promise> { + 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; + + 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( + path: string, + mapper: (resource: TResource) => TOutput, +): Promise> { + return getCollectionFromUrl(buildApiUrl(path), mapper); +} + +async function getCollectionFromUrl( + url: URL, + mapper: (resource: TResource) => TOutput, +): Promise> { + try { + const headers = await getAuthHeaders({ contentType: false }); + const first = (await handleApiResponse( + await fetch(url.toString(), { + method: "GET", + headers, + cache: "no-store", + }), + )) as JsonApiDocument; + + 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; + 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( + path: string, + init: RequestInit, + mapper: (resource: TResource) => TOutput, + pathToRevalidate: string, + includeContentType = true, +): Promise> { + try { + const response = await fetch(buildApiUrl(path), { + ...init, + headers: await getAuthHeaders({ contentType: includeContentType }), + }); + const document = (await handleApiResponse( + response, + pathToRevalidate, + )) as JsonApiDocument; + 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> { + 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( + document: JsonApiDocument | { error?: unknown }, +): document is JsonApiDocument & { error: string } { + return typeof document.error === "string"; +} + +function toErrorResult( + document: JsonApiDocument, +): Extract, { error: string }> { + return { + error: document.error ?? "Unexpected Lighthouse AI response.", + errors: document.errors, + status: document.status, + }; +} diff --git a/ui/app/(prowler)/lighthouse/_components/ai-elements/README.md b/ui/app/(prowler)/lighthouse/_components/ai-elements/README.md new file mode 100644 index 0000000000..0030ddeb40 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/ai-elements/README.md @@ -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/`. diff --git a/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx b/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx new file mode 100644 index 0000000000..474fed577c --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx @@ -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( + 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 ( + + + {children} + + + ); +} + +export type ChainOfThoughtHeaderProps = ComponentProps< + typeof CollapsibleTrigger +>; + +export function ChainOfThoughtHeader({ + className, + children, + ...props +}: ChainOfThoughtHeaderProps) { + const { isOpen } = useChainOfThought(); + + return ( + + + {children ?? "Chain of Thought"} + + + ); +} + +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 ( +
+
+ +
+
+
+
{label}
+ {description && ( +
{description}
+ )} + {children} +
+
+ ); +} + +export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">; + +export function ChainOfThoughtSearchResults({ + className, + ...props +}: ChainOfThoughtSearchResultsProps) { + return ( +
+ ); +} + +export type ChainOfThoughtSearchResultProps = ComponentProps; + +export function ChainOfThoughtSearchResult({ + className, + children, + ...props +}: ChainOfThoughtSearchResultProps) { + return ( + + {children} + + ); +} + +export type ChainOfThoughtContentProps = ComponentProps< + typeof CollapsibleContent +>; + +export function ChainOfThoughtContent({ + className, + children, + ...props +}: ChainOfThoughtContentProps) { + return ( + + {children} + + ); +} + +export type ChainOfThoughtImageProps = ComponentProps<"div"> & { + caption?: string; +}; + +export function ChainOfThoughtImage({ + className, + children, + caption, + ...props +}: ChainOfThoughtImageProps) { + return ( +
+
+ {children} +
+ {caption &&

{caption}

} +
+ ); +} diff --git a/ui/components/ai-elements/conversation.tsx b/ui/app/(prowler)/lighthouse/_components/ai-elements/conversation.tsx similarity index 73% rename from ui/components/ai-elements/conversation.tsx rename to ui/app/(prowler)/lighthouse/_components/ai-elements/conversation.tsx index 44916f06d1..f1adfa3689 100644 --- a/ui/components/ai-elements/conversation.tsx +++ b/ui/app/(prowler)/lighthouse/_components/ai-elements/conversation.tsx @@ -19,19 +19,42 @@ export const Conversation = ({ className, ...props }: ConversationProps) => ( /> ); -export type ConversationContentProps = ComponentProps< - typeof StickToBottom.Content ->; +type ConversationContentChildren = + | ReactNode + | ((context: ReturnType) => ReactNode); + +export type ConversationContentProps = Omit< + ComponentProps<"div">, + "children" | "ref" +> & { + children?: ConversationContentChildren; + scrollClassName?: string; +}; export const ConversationContent = ({ + children, className, + scrollClassName, ...props -}: ConversationContentProps) => ( - -); +}: ConversationContentProps) => { + const context = useStickToBottomContext(); + const { contentRef, scrollRef } = context; + + return ( +
+
+ {typeof children === "function" ? children(context) : children} +
+
+ ); +}; export type ConversationEmptyStateProps = ComponentProps<"div"> & { title?: string; diff --git a/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx b/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx new file mode 100644 index 0000000000..62d39cfb05 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx @@ -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) => void; + onSubmitText: (text: string) => Promise; +} + +// 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 ( + <> + + + + ); +} + +function ChatFeedbackBar({ + feedback, + canRetry, + onRetry, + onDismiss, +}: { + feedback: string | null; + canRetry: boolean; + onRetry: () => void; + onDismiss: () => void; +}) { + if (!feedback) return null; + + return ( + + + + {feedback} + {canRetry && ( + + )} + + + ); +} + +interface ChatComposerProps { + canSend: boolean; + input: string; + isStreaming: boolean; + modelSelector: ReactNode; + selectedConfigurationConnected: boolean; + onInputChange: (value: string) => void; + onSubmit: (event: SubmitEvent) => void; + onSubmitText: (text: string) => Promise; +} + +function ChatComposer({ + canSend, + input, + isStreaming, + selectedConfigurationConnected, + onInputChange, + modelSelector, + onSubmit, + onSubmitText, +}: ChatComposerProps) { + const textareaRef = useRef(null); + + useMountEffect(() => { + textareaRef.current?.focus(); + }); + + return ( +
+