From 30694865499d8e00ddf9e6cf3ee34e770215ba8f Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:03:59 +0200 Subject: [PATCH] feat: record the tenant profile declared at onboarding (#12818) Co-authored-by: Claude Opus 5 --- api/changelog.d/onboarding-profile.added.md | 1 + api/src/backend/api/filters.py | 7 + .../0098_tenant_onboarding_profile.py | 116 ++++++++ api/src/backend/api/models.py | 81 ++++++ .../api/tests/test_onboarding_profile.py | 174 ++++++++++++ api/src/backend/api/v1/serializers.py | 74 +++++ api/src/backend/api/v1/urls.py | 6 + api/src/backend/api/v1/views.py | 121 +++++++- ui/actions/onboarding/profile.test.ts | 200 +++++++++++++ ui/actions/onboarding/profile.ts | 119 ++++++++ ui/app/(prowler)/layout.tsx | 25 +- ui/changelog.d/onboarding-profile.added.md | 1 + .../onboarding-profile-gate.test.tsx | 267 ++++++++++++++++++ .../onboarding-profile-modal.test.tsx | 73 +++++ .../onboarding/onboarding-profile-gate.tsx | 128 +++++++++ .../onboarding/onboarding-profile-modal.tsx | 172 +++++++++++ ui/lib/onboarding/README.md | 27 +- .../__tests__/profile-gate-decision.test.ts | 32 +++ ui/lib/onboarding/index.ts | 14 + ui/lib/onboarding/onboarding-events.ts | 61 ++++ ui/lib/onboarding/profile-gate-decision.ts | 20 ++ ui/lib/onboarding/profile-marker.ts | 67 +++++ ui/types/onboarding-profile.ts | 63 +++++ 23 files changed, 1844 insertions(+), 5 deletions(-) create mode 100644 api/changelog.d/onboarding-profile.added.md create mode 100644 api/src/backend/api/migrations/0098_tenant_onboarding_profile.py create mode 100644 api/src/backend/api/tests/test_onboarding_profile.py create mode 100644 ui/actions/onboarding/profile.test.ts create mode 100644 ui/actions/onboarding/profile.ts create mode 100644 ui/changelog.d/onboarding-profile.added.md create mode 100644 ui/components/onboarding/__tests__/onboarding-profile-gate.test.tsx create mode 100644 ui/components/onboarding/__tests__/onboarding-profile-modal.test.tsx create mode 100644 ui/components/onboarding/onboarding-profile-gate.tsx create mode 100644 ui/components/onboarding/onboarding-profile-modal.tsx create mode 100644 ui/lib/onboarding/__tests__/profile-gate-decision.test.ts create mode 100644 ui/lib/onboarding/onboarding-events.ts create mode 100644 ui/lib/onboarding/profile-gate-decision.ts create mode 100644 ui/lib/onboarding/profile-marker.ts create mode 100644 ui/types/onboarding-profile.ts diff --git a/api/changelog.d/onboarding-profile.added.md b/api/changelog.d/onboarding-profile.added.md new file mode 100644 index 0000000000..b1588f8ac3 --- /dev/null +++ b/api/changelog.d/onboarding-profile.added.md @@ -0,0 +1 @@ +`POST /onboarding-profiles` records the cloud-account count, area of work and position a tenant declares at first login, or that the step was skipped, once per tenant and never overwritten diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index a7f888b453..8a9f37c366 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -41,6 +41,7 @@ from api.models import ( StatusChoices, Task, TenantAPIKey, + TenantOnboardingProfile, ThreatScoreSnapshot, User, ) @@ -1788,6 +1789,12 @@ class LighthouseProviderModelsFilter(FilterSet): } +class TenantOnboardingProfileFilter(FilterSet): + class Meta: + model = TenantOnboardingProfile + fields = {"skipped": ["exact"]} + + class MuteRuleFilter(FilterSet): inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") updated_at = DateFilter(field_name="updated_at", lookup_expr="date") diff --git a/api/src/backend/api/migrations/0098_tenant_onboarding_profile.py b/api/src/backend/api/migrations/0098_tenant_onboarding_profile.py new file mode 100644 index 0000000000..7ee767fb91 --- /dev/null +++ b/api/src/backend/api/migrations/0098_tenant_onboarding_profile.py @@ -0,0 +1,116 @@ +import uuid + +import api.rls +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0097_attack_paths_scan_db_defaults"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="TenantOnboardingProfile", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ( + "declared_cloud_accounts", + models.CharField( + blank=True, + choices=[ + ("1", "1"), + ("2-10", "2-10"), + ("11-50", "11-50"), + ("51-200", "51-200"), + ("200+", "200+"), + ], + max_length=16, + null=True, + ), + ), + ( + "declared_role", + models.CharField( + blank=True, + choices=[ + ("security", "Security"), + ("devops_platform", "DevOps / Platform"), + ("developer", "Developer"), + ("compliance_grc", "Compliance / GRC"), + ("other", "Other"), + ], + max_length=32, + null=True, + ), + ), + ( + "declared_seniority", + models.CharField( + blank=True, + choices=[ + ("practitioner", "Practitioner / IC"), + ("lead", "Team lead / Manager"), + ("director", "Director / Head of"), + ("executive", "VP / C-level"), + ("founder", "Founder / Owner"), + ], + max_length=32, + null=True, + ), + ), + ("skipped", models.BooleanField(default=False)), + ( + "submitted_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="tenant_onboarding_profiles", + related_query_name="tenant_onboarding_profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "tenant_onboarding_profiles", + "abstract": False, + }, + ), + migrations.AddConstraint( + model_name="tenantonboardingprofile", + constraint=models.UniqueConstraint( + fields=("tenant_id",), name="unique_tenant_onboarding_profile" + ), + ), + migrations.AddConstraint( + model_name="tenantonboardingprofile", + # `statements` written out explicitly: RowLevelSecurityConstraint + # .deconstruct() does not serialize it, so an autogenerated + # migration falls back to ["SELECT"] and leaves the table without + # INSERT/UPDATE/DELETE policies. + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_tenantonboardingprofile", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index a280708d53..4f1e963c3d 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -3113,3 +3113,84 @@ class TenantComplianceSummary(RowLevelSecurityProtectedModel): statements=["SELECT", "INSERT", "UPDATE", "DELETE"], ), ] + + +class TenantOnboardingProfile(RowLevelSecurityProtectedModel): + """What a tenant declared about itself at first login, before the product + shaped its behaviour. + + One row per tenant. The three buckets are closed choices asked in the + onboarding profile step; ``skipped`` records that the step was shown and + dismissed, so a skip is a fact and not the absence of one. + + ``declared_role`` and ``declared_seniority`` are deliberately orthogonal: + the first is the discipline the person works in, the second how far up the + organisation they sit. Kept apart, "a security engineer" and "the CISO" + are two segments rather than one blurred bucket. + + Immutable once written: a second submission returns the existing row so the + first answer, given before any product signal could bias it, is the one + that stays. + """ + + class CloudAccountsBucket(models.TextChoices): + ONE = "1", _("1") + TWO_TO_TEN = "2-10", _("2-10") + ELEVEN_TO_FIFTY = "11-50", _("11-50") + FIFTY_ONE_TO_TWO_HUNDRED = "51-200", _("51-200") + OVER_TWO_HUNDRED = "200+", _("200+") + + class Role(models.TextChoices): + SECURITY = "security", _("Security") + DEVOPS_PLATFORM = "devops_platform", _("DevOps / Platform") + DEVELOPER = "developer", _("Developer") + COMPLIANCE_GRC = "compliance_grc", _("Compliance / GRC") + OTHER = "other", _("Other") + + class Seniority(models.TextChoices): + PRACTITIONER = "practitioner", _("Practitioner / IC") + LEAD = "lead", _("Team lead / Manager") + DIRECTOR = "director", _("Director / Head of") + EXECUTIVE = "executive", _("VP / C-level") + FOUNDER = "founder", _("Founder / Owner") + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + declared_cloud_accounts = models.CharField( + max_length=16, choices=CloudAccountsBucket.choices, null=True, blank=True + ) + declared_role = models.CharField( + max_length=32, choices=Role.choices, null=True, blank=True + ) + declared_seniority = models.CharField( + max_length=32, choices=Seniority.choices, null=True, blank=True + ) + skipped = models.BooleanField(default=False) + submitted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="tenant_onboarding_profiles", + related_query_name="tenant_onboarding_profile", + ) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "tenant_onboarding_profiles" + constraints = [ + models.UniqueConstraint( + fields=["tenant_id"], + name="unique_tenant_onboarding_profile", + ), + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] + + class JSONAPIMeta: + resource_name = "onboarding-profiles" + + def __str__(self) -> str: + return f"onboarding-profile:{self.tenant_id}" diff --git a/api/src/backend/api/tests/test_onboarding_profile.py b/api/src/backend/api/tests/test_onboarding_profile.py new file mode 100644 index 0000000000..ee1d37417d --- /dev/null +++ b/api/src/backend/api/tests/test_onboarding_profile.py @@ -0,0 +1,174 @@ +import json + +import pytest +from api.models import TenantOnboardingProfile +from conftest import API_JSON_CONTENT_TYPE +from django.urls import reverse +from rest_framework import status + +ANSWERS = { + "declared_cloud_accounts": "11-50", + "declared_role": "security", + "declared_seniority": "director", +} + + +def _submit(client, attributes): + payload = {"data": {"type": "onboarding-profiles", "attributes": attributes}} + return client.post( + reverse("onboarding-profile-list"), + data=json.dumps(payload), + content_type=API_JSON_CONTENT_TYPE, + ) + + +def _error_pointers(response): + return [error["source"]["pointer"] for error in response.json()["errors"]] + + +@pytest.mark.django_db +class TestTenantOnboardingProfileViewSet: + def test_declares_the_profile_once( + self, authenticated_client, tenants_fixture, create_test_user + ): + response = _submit(authenticated_client, ANSWERS) + + assert response.status_code == status.HTTP_201_CREATED + attributes = response.json()["data"]["attributes"] + assert attributes["declared_cloud_accounts"] == "11-50" + assert attributes["declared_role"] == "security" + assert attributes["declared_seniority"] == "director" + assert attributes["skipped"] is False + profile = TenantOnboardingProfile.objects.get(tenant_id=tenants_fixture[0].id) + assert profile.submitted_by_id == create_test_user.id + + def test_records_a_skip_as_a_fact(self, authenticated_client, tenants_fixture): + response = _submit(authenticated_client, {"skipped": True}) + + assert response.status_code == status.HTTP_201_CREATED + attributes = response.json()["data"]["attributes"] + assert attributes["skipped"] is True + assert attributes["declared_role"] is None + assert attributes["declared_seniority"] is None + assert TenantOnboardingProfile.objects.filter( + tenant_id=tenants_fixture[0].id, skipped=True + ).exists() + + def test_second_submission_keeps_the_first_answer(self, authenticated_client): + first = _submit(authenticated_client, ANSWERS) + second = _submit( + authenticated_client, {**ANSWERS, "declared_role": "developer"} + ) + + assert first.status_code == status.HTTP_201_CREATED + assert second.status_code == status.HTTP_200_OK + assert second.json()["data"]["id"] == first.json()["data"]["id"] + assert second.json()["data"]["attributes"]["declared_role"] == "security" + assert TenantOnboardingProfile.objects.count() == 1 + + def test_a_skip_cannot_replace_an_answer(self, authenticated_client): + _submit(authenticated_client, ANSWERS) + + response = _submit(authenticated_client, {"skipped": True}) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"]["attributes"]["skipped"] is False + + @pytest.mark.parametrize( + "attributes, pointer", + [ + ( + {"declared_cloud_accounts": "1"}, + "/data/attributes/declared_role", + ), + ( + # Discipline without the ladder is still an incomplete profile. + {k: v for k, v in ANSWERS.items() if k != "declared_seniority"}, + "/data/attributes/declared_seniority", + ), + ({}, "/data/attributes/declared_cloud_accounts"), + ( + {"skipped": True, "declared_role": "developer"}, + "/data/attributes/declared_role", + ), + ( + {**ANSWERS, "declared_cloud_accounts": "1000"}, + "/data/attributes/declared_cloud_accounts", + ), + ( + {**ANSWERS, "declared_role": "ceo"}, + "/data/attributes/declared_role", + ), + ( + # "management" moved out of the discipline list into the ladder. + {**ANSWERS, "declared_role": "management"}, + "/data/attributes/declared_role", + ), + ( + {**ANSWERS, "declared_seniority": "intern"}, + "/data/attributes/declared_seniority", + ), + ( + {"skipped": True, "declared_seniority": "founder"}, + "/data/attributes/declared_seniority", + ), + ({**ANSWERS, "company": "Acme"}, "/data"), + ], + ) + def test_rejects_incomplete_or_unknown_answers( + self, authenticated_client, attributes, pointer + ): + response = _submit(authenticated_client, attributes) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert pointer in _error_pointers(response) + assert not TenantOnboardingProfile.objects.exists() + + def test_lists_only_the_tenant_profile( + self, + authenticated_client, + authenticated_client_for_tenant_factory, + create_test_user, + tenants_fixture, + ): + other_client = authenticated_client_for_tenant_factory( + create_test_user, tenants_fixture[1] + ) + _submit(authenticated_client, ANSWERS) + _submit(other_client, {"skipped": True}) + + own = authenticated_client.get(reverse("onboarding-profile-list")) + other = other_client.get(reverse("onboarding-profile-list")) + + assert own.status_code == status.HTTP_200_OK + assert [entry["attributes"]["skipped"] for entry in own.json()["data"]] == [ + False + ] + assert [entry["attributes"]["skipped"] for entry in other.json()["data"]] == [ + True + ] + + def test_empty_list_before_the_step_runs(self, authenticated_client): + response = authenticated_client.get(reverse("onboarding-profile-list")) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"] == [] + + def test_members_without_permissions_can_answer( + self, authenticated_client_no_permissions_rbac + ): + response = _submit(authenticated_client_no_permissions_rbac, ANSWERS) + + assert response.status_code == status.HTTP_201_CREATED + + def test_detail_route_is_refused(self, authenticated_client): + # One row per tenant, so the collection is the only meaningful read; + # the detail route must refuse rather than serve a second shape. + _submit(authenticated_client, ANSWERS) + profile = TenantOnboardingProfile.objects.get() + + response = authenticated_client.get( + reverse("onboarding-profile-detail", kwargs={"pk": profile.id}) + ) + + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 5e1f2fa6d8..663f199339 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -35,6 +35,7 @@ from api.models import ( StatusChoices, Task, TenantAPIKey, + TenantOnboardingProfile, ThreatScoreSnapshot, User, UserRoleRelationship, @@ -4525,3 +4526,76 @@ class FindingGroupResourceSerializer(BaseSerializerV1): "uid": obj.get("provider_uid", ""), "alias": obj.get("provider_alias", ""), } + + +# Onboarding profile + +DECLARED_PROFILE_FIELDS = ( + "declared_cloud_accounts", + "declared_role", + "declared_seniority", +) + + +class TenantOnboardingProfileSerializer(RLSSerializer): + """Read serializer for the tenant's declared onboarding profile.""" + + class Meta: + model = TenantOnboardingProfile + fields = [ + "id", + "inserted_at", + *DECLARED_PROFILE_FIELDS, + "skipped", + ] + read_only_fields = fields + + +class TenantOnboardingProfileCreateSerializer(RLSSerializer, BaseWriteSerializer): + """Record the profile step's outcome. + + Either every answer is given, or ``skipped`` is true and none is: a skip + is recorded as such so "skipped" can be told from "never shown". + """ + + declared_cloud_accounts = serializers.ChoiceField( + choices=TenantOnboardingProfile.CloudAccountsBucket.choices, + required=False, + allow_null=True, + ) + declared_role = serializers.ChoiceField( + choices=TenantOnboardingProfile.Role.choices, + required=False, + allow_null=True, + ) + declared_seniority = serializers.ChoiceField( + choices=TenantOnboardingProfile.Seniority.choices, + required=False, + allow_null=True, + ) + skipped = serializers.BooleanField(required=False, default=False) + + class Meta: + model = TenantOnboardingProfile + fields = ["id", *DECLARED_PROFILE_FIELDS, "skipped"] + extra_kwargs = {"id": {"read_only": True}} + + class JSONAPIMeta: + resource_name = "onboarding-profiles" + + def validate(self, data): + data = super().validate(data) + answers = {field: data.get(field) for field in DECLARED_PROFILE_FIELDS} + if data.get("skipped"): + answered = [field for field, value in answers.items() if value] + if answered: + raise ValidationError( + {answered[0]: "A skipped profile step carries no answers."} + ) + return data + missing = [field for field, value in answers.items() if not value] + if missing: + raise ValidationError( + {missing[0]: "This field is required unless the step was skipped."} + ) + return data diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py index a558fa6387..0e50962cdb 100644 --- a/api/src/backend/api/v1/urls.py +++ b/api/src/backend/api/v1/urls.py @@ -39,6 +39,7 @@ from api.v1.views import ( TenantApiKeyViewSet, TenantFinishACSView, TenantMembersViewSet, + TenantOnboardingProfileViewSet, TenantViewSet, UserRoleRelationshipView, UserViewSet, @@ -107,6 +108,11 @@ router.register( basename="lighthouse-models", ) router.register(r"mute-rules", MuteRuleViewSet, basename="mute-rule") +router.register( + r"onboarding-profiles", + TenantOnboardingProfileViewSet, + basename="onboarding-profile", +) tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant") tenants_router.register( diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 9df5be424c..df89a69523 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -75,6 +75,7 @@ from api.filters import ( TaskFilter, TenantApiKeyFilter, TenantFilter, + TenantOnboardingProfileFilter, ThreatScoreSnapshotFilter, UserFilter, ) @@ -119,6 +120,7 @@ from api.models import ( Task, TenantAPIKey, TenantComplianceSummary, + TenantOnboardingProfile, ThreatScoreSnapshot, User, UserRoleRelationship, @@ -231,6 +233,8 @@ from api.v1.serializers import ( TenantApiKeyCreateSerializer, TenantApiKeySerializer, TenantApiKeyUpdateSerializer, + TenantOnboardingProfileCreateSerializer, + TenantOnboardingProfileSerializer, TenantSerializer, ThreatScoreSnapshotSerializer, TokenRefreshSerializer, @@ -257,7 +261,7 @@ from django.conf import settings as django_settings from django.contrib.postgres.aggregates import ArrayAgg, BoolAnd, StringAgg from django.contrib.postgres.search import SearchQuery from django.core.exceptions import ValidationError as DjangoValidationError -from django.db import transaction +from django.db import IntegrityError, transaction from django.db.models import ( BooleanField, Case, @@ -293,6 +297,7 @@ from django_celery_beat.models import PeriodicTask from drf_spectacular.settings import spectacular_settings from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import ( + OpenApiExample, OpenApiParameter, OpenApiResponse, extend_schema, @@ -8935,3 +8940,117 @@ class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet): return self._paginated_resource_response( request, filtered_queryset, resource_ids, request.tenant_id ) + + +# Onboarding profile +@extend_schema_view( + list=extend_schema( + tags=["Onboarding"], + summary="Read the tenant's onboarding profile", + description=( + "The answers the tenant gave in the onboarding profile step, or " + "the record that the step was skipped. At most one entry per " + "tenant; an empty list means the step was never completed." + ), + ), + create=extend_schema( + tags=["Onboarding"], + summary="Record the tenant's onboarding profile", + description=( + "Store the declared cloud-account count, role and seniority, or " + "mark the step as skipped. Idempotent: once a " + "profile exists the endpoint answers 200 with the stored one and " + "never overwrites it, so the first answer, given before the " + "product could bias it, is the one that stays." + ), + request=TenantOnboardingProfileCreateSerializer, + responses={ + 200: TenantOnboardingProfileSerializer, + 201: TenantOnboardingProfileSerializer, + }, + examples=[ + OpenApiExample( + "DeclareProfile", + request_only=True, + media_type="application/vnd.api+json", + value={ + "data": { + "type": "onboarding-profiles", + "attributes": { + "declared_cloud_accounts": "11-50", + "declared_role": "security", + "declared_seniority": "director", + }, + } + }, + ), + OpenApiExample( + "SkipProfile", + request_only=True, + media_type="application/vnd.api+json", + value={ + "data": { + "type": "onboarding-profiles", + "attributes": {"skipped": True}, + } + }, + ), + ], + ), + retrieve=extend_schema(exclude=True), +) +class TenantOnboardingProfileViewSet(BaseRLSViewSet): + """The tenant's declared onboarding profile: one row, written once.""" + + queryset = TenantOnboardingProfile.objects.all() + serializer_class = TenantOnboardingProfileSerializer + filterset_class = TenantOnboardingProfileFilter + http_method_names = ["get", "post"] + ordering = ["-inserted_at"] + ordering_fields = ["inserted_at"] + # Any member may answer: the step runs at first login, before roles are + # curated, and the answer describes the tenant rather than the user. + required_permissions = [] + + def set_required_permissions(self): + self.required_permissions = [] + + def get_queryset(self): + return TenantOnboardingProfile.objects.filter(tenant_id=self.request.tenant_id) + + def get_serializer_class(self): + if self.action == "create": + return TenantOnboardingProfileCreateSerializer + return super().get_serializer_class() + + def retrieve(self, request, *args, **kwargs): + # The resource is a single row per tenant, so the collection is the + # only meaningful read. `extend_schema(exclude=True)` hides the detail + # route from the docs; this is what actually closes it. + raise MethodNotAllowed(method="GET") + + def _stored_response(self, profile, http_status): + return Response( + TenantOnboardingProfileSerializer( + profile, context=self.get_serializer_context() + ).data, + status=http_status, + ) + + def create(self, request, *args, **kwargs): + existing = self.get_queryset().first() + if existing is not None: + return self._stored_response(existing, status.HTTP_200_OK) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + with transaction.atomic(using=self.db_alias): + profile = serializer.save(submitted_by=request.user) + except IntegrityError: + # Two first-login tabs raced on the unique tenant row; the answer + # that landed first is the one that stays. + return self._stored_response( + self.get_queryset().first(), status.HTTP_200_OK + ) + return self._stored_response(profile, status.HTTP_201_CREATED) diff --git a/ui/actions/onboarding/profile.test.ts b/ui/actions/onboarding/profile.test.ts new file mode 100644 index 0000000000..0786ec18f1 --- /dev/null +++ b/ui/actions/onboarding/profile.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiErrorMock, + handleApiResponseMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/navigation", () => ({ redirect: vi.fn() })); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { + isOnboardingProfileRecorded, + skipOnboardingProfile, + submitOnboardingProfile, +} from "./profile"; + +const ANSWERS = { + declared_cloud_accounts: "11-50", + declared_role: "security", + declared_seniority: "director", +} as const; + +describe("onboarding profile actions", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + getAuthHeadersMock.mockReset().mockResolvedValue({}); + handleApiErrorMock.mockReset(); + handleApiResponseMock.mockReset().mockResolvedValue({ data: { id: "p1" } }); + }); + + it("reports a stored profile and sends the declared buckets", async () => { + // Given + fetchMock.mockResolvedValue(new Response("{}", { status: 201 })); + + // When + const result = await submitOnboardingProfile(ANSWERS); + + // Then + expect(result).toEqual({ stored: true }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.example.com/api/v1/onboarding-profiles"); + expect(JSON.parse(init.body).data).toEqual({ + type: "onboarding-profiles", + attributes: ANSWERS, + }); + }); + + it("records a skip as its own payload", async () => { + // Given + fetchMock.mockResolvedValue(new Response("{}", { status: 201 })); + + // When + const result = await skipOnboardingProfile(); + + // Then + expect(result).toEqual({ stored: true }); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).data.attributes).toEqual( + { + skipped: true, + }, + ); + }); + + it("treats a transport failure as not stored", async () => { + // Given — the request never reaches the API. + fetchMock.mockRejectedValue(new Error("network down")); + + // When + const result = await submitOnboardingProfile(ANSWERS); + + // Then + expect(result.stored).toBe(false); + expect(result.error).toBeTruthy(); + expect(handleApiErrorMock).toHaveBeenCalledTimes(1); + }); + + it("treats a rejection without an errors array as not stored", async () => { + // Given — e.g. a 403, which `handleApiResponse` returns as a bare error. + fetchMock.mockResolvedValue(new Response("{}", { status: 403 })); + handleApiResponseMock.mockResolvedValue({ + error: "Forbidden", + status: 403, + }); + + // When + const result = await submitOnboardingProfile(ANSWERS); + + // Then + expect(result).toEqual({ stored: false, error: "Forbidden" }); + }); + + it("surfaces the API's own message when the payload carries one", async () => { + // Given + fetchMock.mockResolvedValue(new Response("{}", { status: 400 })); + handleApiResponseMock.mockResolvedValue({ + error: "Bad request", + errors: [{ detail: "This field is required." }], + }); + + // When + const result = await submitOnboardingProfile(ANSWERS); + + // Then + expect(result).toEqual({ + stored: false, + error: "This field is required.", + }); + }); + + it("treats a thrown server error as not stored", async () => { + // Given — `handleApiResponse` throws on 5xx. + fetchMock.mockResolvedValue(new Response("{}", { status: 500 })); + handleApiResponseMock.mockRejectedValue(new Error("server error")); + + // When + const result = await skipOnboardingProfile(); + + // Then + expect(result.stored).toBe(false); + expect(handleApiErrorMock).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["a submission", () => submitOnboardingProfile(ANSWERS)], + ["a skip", () => skipOnboardingProfile()], + ])( + "reports %s as unstored when the session cannot be read", + async (_, run) => { + // Given — `auth()` rejects, e.g. a session this deployment cannot decode. + getAuthHeadersMock.mockRejectedValue(new Error("session unreadable")); + + // When + const result = await run(); + + // Then — a result, never a throw: the step stays eligible next login. + expect(result).toEqual({ stored: false, error: expect.any(String) }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(handleApiErrorMock).toHaveBeenCalledTimes(1); + }, + ); + + it("reads an unreadable session as an unknown profile state", async () => { + // Given — this runs in the root layout, so a throw would abort its render. + getAuthHeadersMock.mockRejectedValue(new Error("session unreadable")); + + // When / Then — `undefined` makes the gate fail open. + await expect(isOnboardingProfileRecorded()).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("refuses answers outside the declared vocabulary", async () => { + // When / Then — validation happens before any request. + await expect( + submitOnboardingProfile({ + ...ANSWERS, + declared_role: "ceo", + } as unknown as typeof ANSWERS), + ).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + ["an empty list", { data: [] }, false], + ["a stored row", { data: [{ id: "p1" }] }, true], + ])("reads %s as recorded=%s", async (_, payload, expected) => { + // Given + fetchMock.mockResolvedValue( + new Response(JSON.stringify(payload), { status: 200 }), + ); + + // Then + expect(await isOnboardingProfileRecorded()).toBe(expected); + }); + + it("returns undefined when the read fails, so the gate fails open", async () => { + // Given + fetchMock.mockResolvedValue(new Response("{}", { status: 500 })); + + // Then + expect(await isOnboardingProfileRecorded()).toBeUndefined(); + }); +}); diff --git a/ui/actions/onboarding/profile.ts b/ui/actions/onboarding/profile.ts new file mode 100644 index 0000000000..e9319378d2 --- /dev/null +++ b/ui/actions/onboarding/profile.ts @@ -0,0 +1,119 @@ +"use server"; + +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import { + DECLARED_CLOUD_ACCOUNTS, + DECLARED_ROLE, + DECLARED_SENIORITY, + type OnboardingProfileAnswers, +} from "@/types/onboarding-profile"; + +const ONBOARDING_PROFILES_PATH = "/onboarding-profiles"; +const RESOURCE_TYPE = "onboarding-profiles"; + +// Explicit outcome instead of the raw API payload: the caller records the +// step as resolved only when the row was really written, and a transport +// failure is as unsuccessful as a rejected payload. +export interface OnboardingProfileResult { + stored: boolean; + error?: string; +} + +const onboardingProfileAnswersSchema = z.object({ + declared_cloud_accounts: z.enum( + Object.values(DECLARED_CLOUD_ACCOUNTS) as [string, ...string[]], + ), + declared_role: z.enum(Object.values(DECLARED_ROLE) as [string, ...string[]]), + declared_seniority: z.enum( + Object.values(DECLARED_SENIORITY) as [string, ...string[]], + ), +}); + +const GENERIC_FAILURE = "The onboarding profile could not be saved."; + +const failureMessage = (payload: unknown): string | undefined => { + if (typeof payload !== "object" || payload === null) return undefined; + const result = payload as { error?: unknown; errors?: unknown }; + if (Array.isArray(result.errors)) { + const detail = (result.errors[0] as { detail?: unknown })?.detail; + if (typeof detail === "string") return detail; + } + return typeof result.error === "string" ? result.error : undefined; +}; + +const postOnboardingProfile = async ( + attributes: Record, +): Promise => { + const body = JSON.stringify({ + data: { type: RESOURCE_TYPE, attributes }, + }); + + let response: Response; + try { + // Inside the boundary: `getAuthHeaders` awaits `auth()`, which rejects on + // an undecodable session. The contract above promises a result, not a + // throw, so a dead session must read as "not stored" and leave the step + // eligible for the next login. + const headers = await getAuthHeaders({ contentType: true }); + response = await fetch(`${apiBaseUrl}${ONBOARDING_PROFILES_PATH}`, { + method: "POST", + headers, + body, + }); + } catch (error) { + handleApiError(error); + return { stored: false, error: GENERIC_FAILURE }; + } + + // `handleApiResponse` reports to Sentry and throws on server errors; the + // status is what decides the outcome, since a rejection can come back + // without an `errors` array. + try { + const payload = await handleApiResponse(response); + if (!response.ok) { + return { + stored: false, + error: failureMessage(payload) ?? GENERIC_FAILURE, + }; + } + return { stored: true }; + } catch (error) { + handleApiError(error); + return { stored: false, error: GENERIC_FAILURE }; + } +}; + +// Records the three declared buckets. The API keeps the first answer per +// tenant: a repeated submission answers 200 with the stored profile. +export const submitOnboardingProfile = async ( + answers: OnboardingProfileAnswers, +): Promise => + postOnboardingProfile(onboardingProfileAnswersSchema.parse(answers)); + +// A skip is a fact worth storing: it separates "declined" from "never +// shown" in the funnel. +export const skipOnboardingProfile = + async (): Promise => + postOnboardingProfile({ skipped: true }); + +// Whether the tenant already went through the step on any device. `undefined` +// means the read failed; the gate fails open and does not force the modal. +export const isOnboardingProfileRecorded = async (): Promise< + boolean | undefined +> => { + const url = new URL(`${apiBaseUrl}${ONBOARDING_PROFILES_PATH}`); + url.searchParams.set("page[size]", "1"); + + try { + const headers = await getAuthHeaders({ contentType: false }); + const response = await fetch(url.toString(), { headers }); + if (!response.ok) return undefined; + const payload = await response.json(); + return Array.isArray(payload?.data) ? payload.data.length > 0 : undefined; + } catch { + return undefined; + } +}; diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx index a753c99c1a..623cc416f6 100644 --- a/ui/app/(prowler)/layout.tsx +++ b/ui/app/(prowler)/layout.tsx @@ -4,15 +4,18 @@ import * as Sentry from "@sentry/nextjs"; import { Metadata, Viewport } from "next"; import { ReactNode, Suspense } from "react"; +import { isOnboardingProfileRecorded } from "@/actions/onboarding/profile"; import { getProviders } from "@/actions/providers"; import { getScansByState } from "@/actions/scans/scans"; import { auth } from "@/auth.config"; import MainLayout from "@/components/layout/main-layout/main-layout"; import { OnboardingCheckpointWatcher, - OnboardingGate, OnboardingSequenceBanner, } from "@/components/onboarding"; +// Imported directly: it pulls the server actions, which the shared barrel +// stays free of so tests can import the barrel without mocking them. +import { OnboardingProfileGate } from "@/components/onboarding/onboarding-profile-gate"; import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config"; import { NavigationProgress } from "@/components/shadcn/navigation-progress"; import { Toaster } from "@/components/shadcn/toast"; @@ -71,6 +74,11 @@ export default async function RootLayout({ let hasCompletedScan = true; // Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open). let hasProviders: boolean | undefined = false; + // Same tri-state for the onboarding profile step; only new tenants pay the read. + let profileRecorded: boolean | undefined = true; + // Scopes the step's local marker, so answering for one tenant does not + // silence it for another. + let tenantId: string | null = null; if (cloudEnabled) { const [providersData, scansByState] = await Promise.all([ @@ -86,6 +94,14 @@ export default async function RootLayout({ hasProviders = Array.isArray(providersData?.data) ? providersData.data.length > 0 : undefined; + if (hasProviders === false) { + const [recorded, session] = await Promise.all([ + isOnboardingProfileRecorded(), + auth(), + ]); + profileRecorded = recorded; + tenantId = session?.tenantId ?? null; + } } const registryEligible = @@ -116,7 +132,12 @@ export default async function RootLayout({ /> {cloudEnabled && ( <> - + {/* Profile step first, then the tour gate it wraps. */} + {/* Single mount point so the watcher survives post-connect navigation. */} {/* Persistent banner shown only while a guided sequence is active. */} diff --git a/ui/changelog.d/onboarding-profile.added.md b/ui/changelog.d/onboarding-profile.added.md new file mode 100644 index 0000000000..07f156517e --- /dev/null +++ b/ui/changelog.d/onboarding-profile.added.md @@ -0,0 +1 @@ +Onboarding profile step at a new tenant's first login asking for cloud accounts, area of work and position (skippable), shown before the "Add your first provider" gate diff --git a/ui/components/onboarding/__tests__/onboarding-profile-gate.test.tsx b/ui/components/onboarding/__tests__/onboarding-profile-gate.test.tsx new file mode 100644 index 0000000000..8adc2c7f24 --- /dev/null +++ b/ui/components/onboarding/__tests__/onboarding-profile-gate.test.tsx @@ -0,0 +1,267 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ONBOARDING_PROFILE_STEP_EVENT, + type OnboardingProfileStepDetail, +} from "@/lib/onboarding/onboarding-events"; +import { onboardingProfileMarkerKey } from "@/lib/onboarding/profile-marker"; + +import { OnboardingProfileGate } from "../onboarding-profile-gate"; + +const { pathnameMock, submitMock, skipMock } = vi.hoisted(() => ({ + pathnameMock: vi.fn(), + submitMock: vi.fn(), + skipMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => pathnameMock(), +})); + +vi.mock("@/actions/onboarding/profile", () => ({ + submitOnboardingProfile: submitMock, + skipOnboardingProfile: skipMock, +})); + +vi.mock("../onboarding-gate", () => ({ + OnboardingGate: ({ hasProviders }: { hasProviders?: boolean }) => ( +
+ ), +})); + +const TENANT_ID = "3f6c2f1e-7b0a-4d5c-9a21-0c9f4f2a7b10"; +const OTHER_TENANT_ID = "8a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; +const MARKER_KEY = onboardingProfileMarkerKey(TENANT_ID) as string; + +const ANSWERS = { + declared_cloud_accounts: "11-50", + declared_role: "security", + declared_seniority: "director", +} as const; + +const answerEverything = async (user: ReturnType) => { + await user.click(screen.getByRole("radio", { name: "11-50" })); + await user.click(screen.getByRole("radio", { name: "Security" })); + await user.click(screen.getByRole("radio", { name: "Director / Head of" })); + await user.click(screen.getByRole("button", { name: "Continue" })); +}; + +describe("OnboardingProfileGate", () => { + const outcomes: OnboardingProfileStepDetail[] = []; + const recordOutcome = (event: Event) => { + outcomes.push((event as CustomEvent).detail); + }; + + beforeEach(() => { + window.localStorage.clear(); + outcomes.length = 0; + window.addEventListener(ONBOARDING_PROFILE_STEP_EVENT, recordOutcome); + pathnameMock.mockReturnValue("/"); + submitMock.mockReset().mockResolvedValue({ stored: true }); + skipMock.mockReset().mockResolvedValue({ stored: true }); + }); + + afterEach(() => { + window.removeEventListener(ONBOARDING_PROFILE_STEP_EVENT, recordOutcome); + }); + + it("asks the profile before the tour gate for a provably new tenant", async () => { + // When + render( + , + ); + + // Then + expect( + await screen.findByRole("button", { name: "Continue" }), + ).toBeInTheDocument(); + expect(screen.queryByTestId("tour-gate")).not.toBeInTheDocument(); + expect(outcomes).toEqual([{ outcome: "shown" }]); + }); + + it("stores the answers, announces them and hands over to the tour gate", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + await screen.findByRole("button", { name: "Continue" }); + + // When + await answerEverything(user); + + // Then + await waitFor(() => + expect(screen.getByTestId("tour-gate")).toBeInTheDocument(), + ); + expect(submitMock).toHaveBeenCalledWith(ANSWERS); + expect(outcomes).toEqual([ + { outcome: "shown" }, + { outcome: "submitted", answers: ANSWERS }, + ]); + expect(window.localStorage.getItem(MARKER_KEY)).toBe("true"); + expect(screen.getByTestId("tour-gate")).toHaveAttribute( + "data-has-providers", + "false", + ); + }); + + it("records a skip as an announced outcome and as a stored fact", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + await screen.findByRole("button", { name: "Skip" }); + + // When + await user.click(screen.getByRole("button", { name: "Skip" })); + + // Then + expect(outcomes.at(-1)).toEqual({ outcome: "skipped" }); + expect(skipMock).toHaveBeenCalledTimes(1); + expect(window.localStorage.getItem(MARKER_KEY)).toBe("true"); + expect(screen.getByTestId("tour-gate")).toBeInTheDocument(); + }); + + it("leaves no marker when the API rejects the answers, so the step returns next login", async () => { + // Given + const user = userEvent.setup(); + submitMock.mockResolvedValue({ stored: false, error: "boom" }); + render( + , + ); + await screen.findByRole("button", { name: "Continue" }); + + // When + await answerEverything(user); + + // Then — closed for this session, but nothing durable was written. + await waitFor(() => + expect(screen.getByTestId("tour-gate")).toBeInTheDocument(), + ); + expect(window.localStorage.getItem(MARKER_KEY)).toBeNull(); + expect(outcomes).toEqual([{ outcome: "shown" }]); + }); + + it.each([ + [ + "the profile is already recorded", + { hasProviders: false, profileRecorded: true }, + ], + [ + "the profile read failed", + { hasProviders: false, profileRecorded: undefined }, + ], + ["providers exist", { hasProviders: true, profileRecorded: false }], + ])("goes straight to the tour gate when %s", async (_, props) => { + // When + render(); + + // Then + expect(screen.getByTestId("tour-gate")).toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByRole("button", { name: "Continue" })).toBeNull(), + ); + expect(outcomes).toEqual([]); + }); + + it("does not reopen once this browser handled the step", () => { + // Given + window.localStorage.setItem(MARKER_KEY, "true"); + + // When + render( + , + ); + + // Then + expect(screen.getByTestId("tour-gate")).toBeInTheDocument(); + expect(outcomes).toEqual([]); + }); + + it("defers on billing routes without resolving the step", () => { + // Given + pathnameMock.mockReturnValue("/billing"); + + // When + render( + , + ); + + // Then + expect(screen.getByTestId("tour-gate")).toBeInTheDocument(); + expect(window.localStorage.getItem(MARKER_KEY)).toBeNull(); + expect(outcomes).toEqual([]); + }); + + it("still asks another tenant that the same browser has not answered", async () => { + // Given — this browser answered for one tenant. + window.localStorage.setItem(MARKER_KEY, "true"); + + // When — the user switches to a second, brand-new tenant. + render( + , + ); + + // Then + expect( + await screen.findByRole("button", { name: "Continue" }), + ).toBeInTheDocument(); + expect(outcomes).toEqual([{ outcome: "shown" }]); + }); + + it("leaves no marker and announces nothing when the skip is not stored", async () => { + // Given + const user = userEvent.setup(); + skipMock.mockResolvedValue({ stored: false, error: "boom" }); + render( + , + ); + await screen.findByRole("button", { name: "Skip" }); + + // When + await user.click(screen.getByRole("button", { name: "Skip" })); + + // Then + await waitFor(() => + expect(screen.getByTestId("tour-gate")).toBeInTheDocument(), + ); + expect(window.localStorage.getItem(MARKER_KEY)).toBeNull(); + expect(outcomes).toEqual([{ outcome: "shown" }]); + }); +}); diff --git a/ui/components/onboarding/__tests__/onboarding-profile-modal.test.tsx b/ui/components/onboarding/__tests__/onboarding-profile-modal.test.tsx new file mode 100644 index 0000000000..3098e6dae8 --- /dev/null +++ b/ui/components/onboarding/__tests__/onboarding-profile-modal.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { OnboardingProfileModal } from "../onboarding-profile-modal"; + +describe("OnboardingProfileModal", () => { + it("asks the three closed questions and keeps Continue disabled until all are answered", async () => { + // Given + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render( + , + ); + const submit = screen.getByRole("button", { name: "Continue" }); + expect(submit).toBeDisabled(); + + // When + await user.click(screen.getByRole("radio", { name: "11-50" })); + expect(submit).toBeDisabled(); + await user.click(screen.getByRole("radio", { name: "Security" })); + // Discipline alone is not enough: the ladder is a separate answer. + expect(submit).toBeDisabled(); + await user.click(screen.getByRole("radio", { name: "Director / Head of" })); + await user.click(submit); + + // Then + expect(onSubmit).toHaveBeenCalledWith({ + declared_cloud_accounts: "11-50", + declared_role: "security", + declared_seniority: "director", + }); + }); + + it("reports a skip from the secondary action and from closing the dialog", async () => { + // Given + const user = userEvent.setup(); + const onSkip = vi.fn(); + render(); + + // When + await user.click(screen.getByRole("button", { name: "Skip" })); + await user.keyboard("{Escape}"); + + // Then + expect(onSkip).toHaveBeenCalledTimes(2); + }); + + it("locks every control while a submission is in flight", async () => { + // Given + const user = userEvent.setup(); + const onSkip = vi.fn(); + render( + , + ); + + // Then + expect(screen.getByRole("button", { name: "Saving..." })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Skip" })).toBeDisabled(); + expect(screen.getByRole("radio", { name: "Security" })).toBeDisabled(); + + // When — closing must not count as a skip mid-flight. + await user.keyboard("{Escape}"); + + // Then + expect(onSkip).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/components/onboarding/onboarding-profile-gate.tsx b/ui/components/onboarding/onboarding-profile-gate.tsx new file mode 100644 index 0000000000..36853ab3cf --- /dev/null +++ b/ui/components/onboarding/onboarding-profile-gate.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useState, useSyncExternalStore } from "react"; + +import { + skipOnboardingProfile, + submitOnboardingProfile, +} from "@/actions/onboarding/profile"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { + dispatchOnboardingProfileStep, + ONBOARDING_STEP_OUTCOME, +} from "@/lib/onboarding/onboarding-events"; +import { shouldStartOnboardingProfile } from "@/lib/onboarding/profile-gate-decision"; +import { + getServerOnboardingProfileHandled, + isOnboardingProfileHandled, + markOnboardingProfileHandled, + subscribeOnboardingProfileMarker, +} from "@/lib/onboarding/profile-marker"; +import type { OnboardingProfileAnswers } from "@/types/onboarding-profile"; + +import { OnboardingGate } from "./onboarding-gate"; +import { OnboardingProfileModal } from "./onboarding-profile-modal"; + +interface OnboardingProfileGateProps { + // `undefined` = fetch failed/ambiguous; fail-open (never force the modal). + hasProviders?: boolean; + // Whether the API already holds the tenant's profile; `undefined` fails open. + profileRecorded?: boolean; + // Scopes the local marker: the step is per tenant, not per browser. + tenantId?: string | null; +} + +const isBillingPath = (pathname: string | null) => + pathname === "/billing" || pathname?.startsWith("/billing/") === true; + +function ShownOnce() { + useMountEffect(() => { + dispatchOnboardingProfileStep({ outcome: ONBOARDING_STEP_OUTCOME.SHOWN }); + }); + return null; +} + +// Profile step in front of the tour gate: the modal resolves first, then +// `OnboardingGate` takes over with the same `hasProviders` signal. Outcomes +// are announced as window events for whoever wants to observe them. +export function OnboardingProfileGate({ + hasProviders, + profileRecorded, + tenantId = null, +}: OnboardingProfileGateProps) { + const pathname = usePathname(); + const handledLocally = useSyncExternalStore( + subscribeOnboardingProfileMarker, + // Bound per tenant so switching accounts re-reads the right key. + () => isOnboardingProfileHandled(tenantId), + getServerOnboardingProfileHandled, + ); + // Session flag keeps the modal closed after submit/skip within this mount, + // including the failure path where no marker is written. + const [resolvedThisSession, setResolvedThisSession] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const showProfile = + // Billing must stay usable before onboarding; leaving it keeps the step eligible. + !isBillingPath(pathname) && + !resolvedThisSession && + shouldStartOnboardingProfile({ + hasProviders, + profileRecorded, + handledLocally, + }); + + if (!showProfile) return ; + + const handleSubmit = async (answers: OnboardingProfileAnswers) => { + setIsSubmitting(true); + try { + const result = await submitOnboardingProfile(answers); + if (!result.stored) { + // Nothing was written, so no marker and no submitted outcome: the + // step returns on the next login instead of being lost. + setResolvedThisSession(true); + return; + } + dispatchOnboardingProfileStep({ + outcome: ONBOARDING_STEP_OUTCOME.SUBMITTED, + answers, + }); + markOnboardingProfileHandled(tenantId); + setResolvedThisSession(true); + } finally { + setIsSubmitting(false); + } + }; + + const handleSkip = async () => { + setIsSubmitting(true); + try { + // A skip is a stored fact too, so it is only remembered locally once + // the row exists. + const result = await skipOnboardingProfile(); + if (result.stored) { + dispatchOnboardingProfileStep({ + outcome: ONBOARDING_STEP_OUTCOME.SKIPPED, + }); + markOnboardingProfileHandled(tenantId); + } + setResolvedThisSession(true); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <> + + + + ); +} diff --git a/ui/components/onboarding/onboarding-profile-modal.tsx b/ui/components/onboarding/onboarding-profile-modal.tsx new file mode 100644 index 0000000000..d0dc7d0298 --- /dev/null +++ b/ui/components/onboarding/onboarding-profile-modal.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/shadcn"; +import { DialogFooter } from "@/components/shadcn/dialog"; +import { Modal } from "@/components/shadcn/modal/modal"; +import { + RadioGroup, + RadioGroupItem, +} from "@/components/shadcn/radio-group/radio-group"; +import { + DECLARED_CLOUD_ACCOUNTS, + DECLARED_ROLE, + DECLARED_ROLE_LABEL, + DECLARED_SENIORITY, + DECLARED_SENIORITY_LABEL, + type DeclaredCloudAccounts, + type DeclaredRole, + type DeclaredSeniority, + type OnboardingProfileAnswers, +} from "@/types/onboarding-profile"; + +interface OnboardingProfileModalProps { + open: boolean; + isSubmitting?: boolean; + onSubmit: (answers: OnboardingProfileAnswers) => void; + onSkip: () => void; +} + +interface ProfileQuestionProps { + name: string; + legend: string; + options: readonly Value[]; + labels?: Partial>; + value: Value | null; + onChange: (value: Value) => void; + disabled: boolean; +} + +interface ProfileDraft { + cloudAccounts: DeclaredCloudAccounts | null; + role: DeclaredRole | null; + seniority: DeclaredSeniority | null; +} + +const EMPTY_DRAFT: ProfileDraft = { + cloudAccounts: null, + role: null, + seniority: null, +}; + +function ProfileQuestion({ + name, + legend, + options, + labels, + value, + onChange, + disabled, +}: ProfileQuestionProps) { + return ( +
+ + {legend} + + onChange(next as Value)} + disabled={disabled} + className="flex flex-row flex-wrap gap-x-6 gap-y-2" + aria-label={legend} + > + {options.map((option) => ( + + ))} + +
+ ); +} + +// Three closed questions asked once, at a new tenant's first login, before +// any product signal could shape the answer. Every path out is recorded by +// the caller: submit, skip, and close (which counts as a skip). +export function OnboardingProfileModal({ + open, + isSubmitting = false, + onSubmit, + onSkip, +}: OnboardingProfileModalProps) { + const [draft, setDraft] = useState(EMPTY_DRAFT); + const isComplete = + draft.cloudAccounts !== null && + draft.role !== null && + draft.seniority !== null; + + const handleSubmit = () => { + const { cloudAccounts, role, seniority } = draft; + if (cloudAccounts === null || role === null || seniority === null) return; + if (isSubmitting) return; + onSubmit({ + declared_cloud_accounts: cloudAccounts, + declared_role: role, + declared_seniority: seniority, + }); + }; + + return ( + { + if (!next && !isSubmitting) onSkip(); + }} + > +
+ + setDraft((current) => ({ ...current, cloudAccounts })) + } + disabled={isSubmitting} + /> + setDraft((current) => ({ ...current, role }))} + disabled={isSubmitting} + /> + + setDraft((current) => ({ ...current, seniority })) + } + disabled={isSubmitting} + /> +
+ + {/* Outline matches the app's modal secondary action (e.g. Launch Scan's Cancel). */} + + + +
+ ); +} diff --git a/ui/lib/onboarding/README.md b/ui/lib/onboarding/README.md index eab352ca2b..99f632e2bf 100644 --- a/ui/lib/onboarding/README.md +++ b/ui/lib/onboarding/README.md @@ -2,8 +2,10 @@ The onboarding system runs short, anchored driver.js tours and orchestrates a cross-route **guided sequence** after a user connects their first provider. -Everything lives in client state and localStorage — there is **zero backend -coupling**. +The tours and the sequence live entirely in client state and localStorage — +**no backend coupling**. The profile step described below is the exception: +it persists the tenant's answer through `POST /onboarding-profiles`, and the +server answer, not localStorage, decides whether it is still owed. ## Building blocks @@ -17,6 +19,8 @@ coupling**. | Ephemeral sequence slice | `ui/store/onboarding-sequence.ts` | | Checkpoint watcher + dialog | `ui/components/onboarding/onboarding-checkpoint-{watcher,dialog}.tsx` | | Mandatory new-user gate | `ui/components/onboarding/onboarding-gate.tsx` | +| Profile step in front of the gate | `ui/components/onboarding/onboarding-profile-{gate,modal}.tsx` | +| Step outcome events (window) | `ui/lib/onboarding/onboarding-events.ts` | | Manual replay list | `ui/components/ui/user-nav/user-nav.tsx` | ## How the guided sequence works @@ -72,3 +76,22 @@ the sequence automatically. `target` must resolve to a real `data-tour-id` anchor within its `coversFiles`. - `pnpm exec vitest run --project unit` — pure logic (slice, helpers, registry, tour shapes). The driver primitive short-circuits in `NODE_ENV==="test"`. + +## Profile step + +Before the mandatory gate offers the first tour, `OnboardingProfileGate` asks +a new tenant three closed questions (cloud accounts, area of work and +position) and +records the answer, or the skip, through `POST /onboarding-profiles`. The API +keeps the first answer per tenant, so the layout only reads +`isOnboardingProfileRecorded()` for tenants without providers and the gate +fails open on any doubt (`shouldStartOnboardingProfile`). A per-tenant localStorage +marker (`prowler.onboarding.profile.`) spares the flash on later +renders; a submission or skip the API did not store writes no marker, so the +step returns next login. + +Each resolution (`shown`, `submitted` with the answers, `skipped`) is announced +as a `prowler:onboarding-profile-step` window event +(`dispatchOnboardingProfileStep`). The step has no listener of its own: a +deployment that wants to observe it subscribes from outside, so the onboarding +stays free of tracking dependencies. diff --git a/ui/lib/onboarding/__tests__/profile-gate-decision.test.ts b/ui/lib/onboarding/__tests__/profile-gate-decision.test.ts new file mode 100644 index 0000000000..0f3430fb2b --- /dev/null +++ b/ui/lib/onboarding/__tests__/profile-gate-decision.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { shouldStartOnboardingProfile } from "../profile-gate-decision"; + +describe("shouldStartOnboardingProfile", () => { + it("opens for a zero-provider tenant with no profile and no local marker", () => { + expect( + shouldStartOnboardingProfile({ + hasProviders: false, + profileRecorded: false, + handledLocally: false, + }), + ).toBe(true); + }); + + it.each([ + ["providers already exist", { hasProviders: true }], + ["the providers read failed", { hasProviders: undefined }], + ["a profile is already recorded", { profileRecorded: true }], + ["the profile read failed", { profileRecorded: undefined }], + ["this browser already resolved the step", { handledLocally: true }], + ])("stays closed when %s", (_, override) => { + expect( + shouldStartOnboardingProfile({ + hasProviders: false, + profileRecorded: false, + handledLocally: false, + ...override, + }), + ).toBe(false); + }); +}); diff --git a/ui/lib/onboarding/index.ts b/ui/lib/onboarding/index.ts index cd6907c036..8d0c967b65 100644 --- a/ui/lib/onboarding/index.ts +++ b/ui/lib/onboarding/index.ts @@ -5,3 +5,17 @@ export { shouldStartOnboarding } from "./gate-decision"; export { isOnFlowRoute } from "./flow-route"; export type { OnboardingContext, OnboardingFlow } from "./onboarding-types"; export { getFlowById, getOrderedFlows, onboardingFlows } from "./registry"; +export type { + OnboardingInviteStepDetail, + OnboardingProfileStepDetail, + OnboardingStepOutcome, +} from "./onboarding-events"; +export { + dispatchOnboardingInviteStep, + dispatchOnboardingProfileStep, + ONBOARDING_INVITE_STEP_EVENT, + ONBOARDING_PROFILE_STEP_EVENT, + ONBOARDING_STEP_OUTCOME, +} from "./onboarding-events"; +export type { ProfileGateDecisionInput } from "./profile-gate-decision"; +export { shouldStartOnboardingProfile } from "./profile-gate-decision"; diff --git a/ui/lib/onboarding/onboarding-events.ts b/ui/lib/onboarding/onboarding-events.ts new file mode 100644 index 0000000000..360cdb1035 --- /dev/null +++ b/ui/lib/onboarding/onboarding-events.ts @@ -0,0 +1,61 @@ +import type { OnboardingProfileAnswers } from "@/types/onboarding-profile"; + +// Window events the onboarding steps dispatch when they resolve. They carry +// no listener of their own: a deployment that wants to observe the steps +// (product analytics, for instance) subscribes from outside, so the steps +// stay free of any tracking dependency. +export const ONBOARDING_PROFILE_STEP_EVENT = "prowler:onboarding-profile-step"; +export const ONBOARDING_INVITE_STEP_EVENT = "prowler:onboarding-invite-step"; + +export const ONBOARDING_STEP_OUTCOME = { + SHOWN: "shown", + SUBMITTED: "submitted", + SKIPPED: "skipped", +} as const; + +export type OnboardingStepOutcome = + (typeof ONBOARDING_STEP_OUTCOME)[keyof typeof ONBOARDING_STEP_OUTCOME]; + +interface OnboardingProfileStepSubmitted { + outcome: typeof ONBOARDING_STEP_OUTCOME.SUBMITTED; + // The stored answers, so a listener never has to read them back. + answers: OnboardingProfileAnswers; +} + +interface OnboardingProfileStepResolved { + outcome: + | typeof ONBOARDING_STEP_OUTCOME.SHOWN + | typeof ONBOARDING_STEP_OUTCOME.SKIPPED; + answers?: never; +} + +// A union rather than an optional field: only a submitted step carries the +// answers, so a listener that narrows on `outcome` gets them without a check. +export type OnboardingProfileStepDetail = + | OnboardingProfileStepSubmitted + | OnboardingProfileStepResolved; + +export interface OnboardingInviteStepDetail { + outcome: OnboardingStepOutcome; +} + +function dispatch(name: string, detail: Detail): void { + if (typeof window === "undefined") return; + try { + window.dispatchEvent(new CustomEvent(name, { detail })); + } catch { + // A listener that throws must never break the step that resolved. + } +} + +export function dispatchOnboardingProfileStep( + detail: OnboardingProfileStepDetail, +): void { + dispatch(ONBOARDING_PROFILE_STEP_EVENT, detail); +} + +export function dispatchOnboardingInviteStep( + detail: OnboardingInviteStepDetail, +): void { + dispatch(ONBOARDING_INVITE_STEP_EVENT, detail); +} diff --git a/ui/lib/onboarding/profile-gate-decision.ts b/ui/lib/onboarding/profile-gate-decision.ts new file mode 100644 index 0000000000..83fedc18a0 --- /dev/null +++ b/ui/lib/onboarding/profile-gate-decision.ts @@ -0,0 +1,20 @@ +export interface ProfileGateDecisionInput { + // `undefined` allowed; the strict `=== false` checks below fail open on + // ambiguous signals, like the tour gate does. + hasProviders: boolean | undefined; + // Whether the API already holds a profile row for the tenant (answered or + // skipped on any device). `undefined` means the read failed. + profileRecorded: boolean | undefined; + // Local marker: this browser already resolved the step. + handledLocally: boolean; +} + +// Shows the profile step only for a provably new tenant that has neither a +// recorded profile nor a local marker. Any doubt keeps the step closed. +export function shouldStartOnboardingProfile({ + hasProviders, + profileRecorded, + handledLocally, +}: ProfileGateDecisionInput): boolean { + return hasProviders === false && profileRecorded === false && !handledLocally; +} diff --git a/ui/lib/onboarding/profile-marker.ts b/ui/lib/onboarding/profile-marker.ts new file mode 100644 index 0000000000..20f0005e8a --- /dev/null +++ b/ui/lib/onboarding/profile-marker.ts @@ -0,0 +1,67 @@ +// Durable "this browser already resolved the profile step" memory, mirroring +// the checkpoint marker. The API row is the system of record; the marker only +// spares a flash of the modal before the server answer catches up. +// +// The key carries the tenant because eligibility is tenant-scoped: a user who +// answered for one tenant must still be asked when they switch to a new one. +const ONBOARDING_PROFILE_MARKER_PREFIX = "prowler.onboarding.profile"; + +const listeners = new Set<() => void>(); + +// Tenant ids are UUIDs; anything else is refused rather than concatenated +// into a storage key. +const TENANT_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function onboardingProfileMarkerKey( + tenantId: string | null | undefined, +): string | null { + if (!tenantId || !TENANT_ID_PATTERN.test(tenantId)) return null; + return `${ONBOARDING_PROFILE_MARKER_PREFIX}.${tenantId.toLowerCase()}`; +} + +export function isOnboardingProfileHandled( + tenantId: string | null | undefined, +): boolean { + if (typeof window === "undefined") return true; + const key = onboardingProfileMarkerKey(tenantId); + // Without a usable tenant the local memory cannot be trusted; the + // server-side answer decides on its own. + if (!key) return false; + try { + return window.localStorage.getItem(key) !== null; + } catch { + // Unreadable storage must not re-open the step forever: treat as handled. + return true; + } +} + +export function markOnboardingProfileHandled( + tenantId: string | null | undefined, +): void { + if (typeof window === "undefined") return; + const key = onboardingProfileMarkerKey(tenantId); + if (key) { + try { + window.localStorage.setItem(key, "true"); + } catch { + // Non-fatal: a re-shown step beats a thrown render. + } + } + listeners.forEach((listener) => listener()); +} + +// For `useSyncExternalStore`: the marker only changes through +// `markOnboardingProfileHandled`, so that is the only notification source. +export function subscribeOnboardingProfileMarker( + listener: () => void, +): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +// Server render and the hydration pass see the step as handled so the modal +// never flashes before the client can read storage. +export const getServerOnboardingProfileHandled = () => true; diff --git a/ui/types/onboarding-profile.ts b/ui/types/onboarding-profile.ts new file mode 100644 index 0000000000..9980912598 --- /dev/null +++ b/ui/types/onboarding-profile.ts @@ -0,0 +1,63 @@ +// Closed buckets the onboarding profile step asks at a new tenant's first +// login. Values mirror `TenantOnboardingProfile` choices in the API and the +// `declared_*` PostHog properties; change them together. + +export const DECLARED_CLOUD_ACCOUNTS = { + ONE: "1", + TWO_TO_TEN: "2-10", + ELEVEN_TO_FIFTY: "11-50", + FIFTY_ONE_TO_TWO_HUNDRED: "51-200", + OVER_TWO_HUNDRED: "200+", +} as const; + +export type DeclaredCloudAccounts = + (typeof DECLARED_CLOUD_ACCOUNTS)[keyof typeof DECLARED_CLOUD_ACCOUNTS]; + +// Discipline, not rank. Management lives in DECLARED_SENIORITY so the two +// questions stay orthogonal: a security engineer and a CISO both answer +// "security" here and differ on the ladder below. +export const DECLARED_ROLE = { + SECURITY: "security", + DEVOPS_PLATFORM: "devops_platform", + DEVELOPER: "developer", + COMPLIANCE_GRC: "compliance_grc", + OTHER: "other", +} as const; + +export type DeclaredRole = (typeof DECLARED_ROLE)[keyof typeof DECLARED_ROLE]; + +// How far up the organisation the answer comes from. Founders get their own +// bucket: a one-person tenant run by a founder evaluating Prowler is a +// different prospect from a lone practitioner inside a large company. +export const DECLARED_SENIORITY = { + PRACTITIONER: "practitioner", + LEAD: "lead", + DIRECTOR: "director", + EXECUTIVE: "executive", + FOUNDER: "founder", +} as const; + +export type DeclaredSeniority = + (typeof DECLARED_SENIORITY)[keyof typeof DECLARED_SENIORITY]; + +export interface OnboardingProfileAnswers { + declared_cloud_accounts: DeclaredCloudAccounts; + declared_role: DeclaredRole; + declared_seniority: DeclaredSeniority; +} + +export const DECLARED_ROLE_LABEL: Record = { + [DECLARED_ROLE.SECURITY]: "Security", + [DECLARED_ROLE.DEVOPS_PLATFORM]: "DevOps / Platform", + [DECLARED_ROLE.DEVELOPER]: "Developer", + [DECLARED_ROLE.COMPLIANCE_GRC]: "Compliance / GRC", + [DECLARED_ROLE.OTHER]: "Other", +}; + +export const DECLARED_SENIORITY_LABEL: Record = { + [DECLARED_SENIORITY.PRACTITIONER]: "Practitioner / IC", + [DECLARED_SENIORITY.LEAD]: "Team lead / Manager", + [DECLARED_SENIORITY.DIRECTOR]: "Director / Head of", + [DECLARED_SENIORITY.EXECUTIVE]: "VP / C-level", + [DECLARED_SENIORITY.FOUNDER]: "Founder / Owner", +};