From 5fe1a6713b266388241906e66c18562be7414bdf Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:01:41 +0200 Subject: [PATCH] revert: remove the onboarding profile step (#12818) (#12838) --- api/changelog.d/onboarding-profile.added.md | 1 - api/src/backend/api/filters.py | 7 - .../0099_delete_tenant_onboarding_profile.py | 15 + 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 | 17 +- 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 | 29 +- .../__tests__/profile-gate-decision.test.ts | 32 --- ui/lib/onboarding/index.ts | 5 - ui/lib/onboarding/onboarding-events.ts | 28 -- ui/lib/onboarding/profile-gate-decision.ts | 20 -- ui/lib/onboarding/profile-marker.ts | 67 ----- ui/types/onboarding-profile.ts | 63 ----- 23 files changed, 23 insertions(+), 1677 deletions(-) delete mode 100644 api/changelog.d/onboarding-profile.added.md create mode 100644 api/src/backend/api/migrations/0099_delete_tenant_onboarding_profile.py delete mode 100644 api/src/backend/api/tests/test_onboarding_profile.py delete mode 100644 ui/actions/onboarding/profile.test.ts delete mode 100644 ui/actions/onboarding/profile.ts delete mode 100644 ui/changelog.d/onboarding-profile.added.md delete mode 100644 ui/components/onboarding/__tests__/onboarding-profile-gate.test.tsx delete mode 100644 ui/components/onboarding/__tests__/onboarding-profile-modal.test.tsx delete mode 100644 ui/components/onboarding/onboarding-profile-gate.tsx delete mode 100644 ui/components/onboarding/onboarding-profile-modal.tsx delete mode 100644 ui/lib/onboarding/__tests__/profile-gate-decision.test.ts delete mode 100644 ui/lib/onboarding/profile-gate-decision.ts delete mode 100644 ui/lib/onboarding/profile-marker.ts delete 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 deleted file mode 100644 index b1588f8ac3..0000000000 --- a/api/changelog.d/onboarding-profile.added.md +++ /dev/null @@ -1 +0,0 @@ -`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 2b3a4b333a..ad89671c89 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -41,7 +41,6 @@ from api.models import ( StatusChoices, Task, TenantAPIKey, - TenantOnboardingProfile, ThreatScoreSnapshot, User, ) @@ -1801,12 +1800,6 @@ 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/0099_delete_tenant_onboarding_profile.py b/api/src/backend/api/migrations/0099_delete_tenant_onboarding_profile.py new file mode 100644 index 0000000000..870805fb2d --- /dev/null +++ b/api/src/backend/api/migrations/0099_delete_tenant_onboarding_profile.py @@ -0,0 +1,15 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + # The onboarding profile step was reverted after 0098 had been merged, so + # the table goes away through a new migration rather than by deleting 0098. + dependencies = [ + ("api", "0098_tenant_onboarding_profile"), + ] + + operations = [ + migrations.DeleteModel( + name="TenantOnboardingProfile", + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index ef19712f94..6a18cd1994 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -3122,84 +3122,3 @@ 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 deleted file mode 100644 index ee1d37417d..0000000000 --- a/api/src/backend/api/tests/test_onboarding_profile.py +++ /dev/null @@ -1,174 +0,0 @@ -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 639ddade61..271d3a9b47 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -35,7 +35,6 @@ from api.models import ( StatusChoices, Task, TenantAPIKey, - TenantOnboardingProfile, ThreatScoreSnapshot, User, UserRoleRelationship, @@ -4537,76 +4536,3 @@ 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 0e50962cdb..a558fa6387 100644 --- a/api/src/backend/api/v1/urls.py +++ b/api/src/backend/api/v1/urls.py @@ -39,7 +39,6 @@ from api.v1.views import ( TenantApiKeyViewSet, TenantFinishACSView, TenantMembersViewSet, - TenantOnboardingProfileViewSet, TenantViewSet, UserRoleRelationshipView, UserViewSet, @@ -108,11 +107,6 @@ 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 8839fcc686..d0294f4abd 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -75,7 +75,6 @@ from api.filters import ( TaskFilter, TenantApiKeyFilter, TenantFilter, - TenantOnboardingProfileFilter, ThreatScoreSnapshotFilter, UserFilter, ) @@ -120,7 +119,6 @@ from api.models import ( Task, TenantAPIKey, TenantComplianceSummary, - TenantOnboardingProfile, ThreatScoreSnapshot, User, UserRoleRelationship, @@ -233,8 +231,6 @@ from api.v1.serializers import ( TenantApiKeyCreateSerializer, TenantApiKeySerializer, TenantApiKeyUpdateSerializer, - TenantOnboardingProfileCreateSerializer, - TenantOnboardingProfileSerializer, TenantSerializer, ThreatScoreSnapshotSerializer, TokenRefreshSerializer, @@ -261,7 +257,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 IntegrityError, transaction +from django.db import transaction from django.db.models import ( BooleanField, Case, @@ -297,7 +293,6 @@ 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, @@ -8940,117 +8935,3 @@ 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 deleted file mode 100644 index 0786ec18f1..0000000000 --- a/ui/actions/onboarding/profile.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -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 deleted file mode 100644 index e9319378d2..0000000000 --- a/ui/actions/onboarding/profile.ts +++ /dev/null @@ -1,119 +0,0 @@ -"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 bd58df359b..4a6244ffec 100644 --- a/ui/app/(prowler)/layout.tsx +++ b/ui/app/(prowler)/layout.tsx @@ -4,18 +4,15 @@ 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"; @@ -74,8 +71,6 @@ 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 onboarding steps' local markers, so resolving them for one // tenant does not silence them for another. let tenantId: string | null = null; @@ -95,9 +90,6 @@ export default async function RootLayout({ ? providersData.data.length > 0 : undefined; tenantId = (await auth())?.tenantId ?? null; - if (hasProviders === false) { - profileRecorded = await isOnboardingProfileRecorded(); - } } const registryEligible = @@ -128,12 +120,7 @@ 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 deleted file mode 100644 index 07f156517e..0000000000 --- a/ui/changelog.d/onboarding-profile.added.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 8adc2c7f24..0000000000 --- a/ui/components/onboarding/__tests__/onboarding-profile-gate.test.tsx +++ /dev/null @@ -1,267 +0,0 @@ -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 deleted file mode 100644 index 3098e6dae8..0000000000 --- a/ui/components/onboarding/__tests__/onboarding-profile-modal.test.tsx +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 36853ab3cf..0000000000 --- a/ui/components/onboarding/onboarding-profile-gate.tsx +++ /dev/null @@ -1,128 +0,0 @@ -"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 deleted file mode 100644 index d0dc7d0298..0000000000 --- a/ui/components/onboarding/onboarding-profile-modal.tsx +++ /dev/null @@ -1,172 +0,0 @@ -"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 390466e429..0b76ec6ced 100644 --- a/ui/lib/onboarding/README.md +++ b/ui/lib/onboarding/README.md @@ -2,10 +2,11 @@ The onboarding system runs short, anchored driver.js tours and orchestrates a cross-route **guided sequence** after a user connects their first provider. -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. +The tours and the guided sequence run on client state (the sequence slice +is ephemeral and resets on a hard reload); tour completion and the one-time +markers persist in localStorage. Server input is the tri-state `hasProviders` +the layout derives from `getProviders()`, plus the invitation the invite step +posts to the API. ## Building blocks @@ -19,7 +20,6 @@ server answer, not localStorage, decides whether it is still owed. | 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` | | Invite step before the checkpoint | `ui/components/onboarding/onboarding-invite-{step,dialog}.tsx` | | Manual replay list | `ui/components/ui/user-nav/user-nav.tsx` | @@ -78,25 +78,6 @@ the sequence automatically. - `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. - ## Invite step The first time the checkpoint opens (right after the first provider is diff --git a/ui/lib/onboarding/__tests__/profile-gate-decision.test.ts b/ui/lib/onboarding/__tests__/profile-gate-decision.test.ts deleted file mode 100644 index 0f3430fb2b..0000000000 --- a/ui/lib/onboarding/__tests__/profile-gate-decision.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 8d0c967b65..42e1811b28 100644 --- a/ui/lib/onboarding/index.ts +++ b/ui/lib/onboarding/index.ts @@ -7,15 +7,10 @@ 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 index 360cdb1035..23e924e7ac 100644 --- a/ui/lib/onboarding/onboarding-events.ts +++ b/ui/lib/onboarding/onboarding-events.ts @@ -1,10 +1,7 @@ -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 = { @@ -16,25 +13,6 @@ export const ONBOARDING_STEP_OUTCOME = { 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; } @@ -48,12 +26,6 @@ function dispatch(name: string, detail: Detail): void { } } -export function dispatchOnboardingProfileStep( - detail: OnboardingProfileStepDetail, -): void { - dispatch(ONBOARDING_PROFILE_STEP_EVENT, detail); -} - export function dispatchOnboardingInviteStep( detail: OnboardingInviteStepDetail, ): void { diff --git a/ui/lib/onboarding/profile-gate-decision.ts b/ui/lib/onboarding/profile-gate-decision.ts deleted file mode 100644 index 83fedc18a0..0000000000 --- a/ui/lib/onboarding/profile-gate-decision.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 20f0005e8a..0000000000 --- a/ui/lib/onboarding/profile-marker.ts +++ /dev/null @@ -1,67 +0,0 @@ -// 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 deleted file mode 100644 index 9980912598..0000000000 --- a/ui/types/onboarding-profile.ts +++ /dev/null @@ -1,63 +0,0 @@ -// 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", -};