diff --git a/.eslintrc.json b/.eslintrc.json index 68c8ec5c8..d7c82cd0e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -18,7 +18,8 @@ "@sb/webapp-documents/**", "@sb/webapp-finances/**", "@sb/webapp-generative-ai/**", - "@sb/webapp-notifications/**" + "@sb/webapp-notifications/**", + "@sb/webapp-tenants/**" ], "depConstraints": [ { diff --git a/.github/workflows/webapp.yml b/.github/workflows/webapp.yml index f2e062dd8..4549b3b5b 100644 --- a/.github/workflows/webapp.yml +++ b/.github/workflows/webapp.yml @@ -88,6 +88,7 @@ jobs: - webapp-emails - webapp-finances - webapp-generative-ai + - webapp-tenants steps: - uses: actions/checkout@v3 with: @@ -130,5 +131,6 @@ jobs: SONAR_WEBAPP_FINANCES_PROJECT_KEY: ${{ vars.SONAR_WEBAPP_FINANCES_PROJECT_KEY }} SONAR_WEBAPP_GENERATIVE_AI_PROJECT_KEY: ${{ vars.SONAR_WEBAPP_GENERATIVE_AI_PROJECT_KEY }} SONAR_WEBAPP_NOTIFICATIONS_PROJECT_KEY: ${{ vars.SONAR_WEBAPP_NOTIFICATIONS_PROJECT_KEY }} + SONAR_WEBAPP_TENANTS_PROJECT_KEY: ${{ vars.SONAR_WEBAPP_TENANTS_PROJECT_KEY }} with: projectBaseDir: "packages/webapp-libs/${{ matrix.webapp-lib-name }}" diff --git a/.versionrc.js b/.versionrc.js index bd7aab653..6f16cd9f4 100644 --- a/.versionrc.js +++ b/.versionrc.js @@ -49,6 +49,10 @@ module.exports = { filename: './packages/webapp-libs/webapp-notifications/package.json', type: 'json', }, + { + filename: './packages/webapp-libs/webapp-tenants/package.json', + type: 'json', + }, { filename: './packages/workers/package.json', diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml index d4de51899..bb941d0dd 100644 --- a/bitbucket-pipelines.yml +++ b/bitbucket-pipelines.yml @@ -21,6 +21,7 @@ definitions: pnpmwebappemails: $BITBUCKET_CLONE_DIR/.pnpm-store pnpmwebappfinances: $BITBUCKET_CLONE_DIR/.pnpm-store pnpmwebappgenerativeai: $BITBUCKET_CLONE_DIR/.pnpm-store + pnpmwebapptenants: $BITBUCKET_CLONE_DIR/.pnpm-store pnpmworkers: $BITBUCKET_CLONE_DIR/.pnpm-store pnpminfracore: $BITBUCKET_CLONE_DIR/.pnpm-store pnpminfrashared: $BITBUCKET_CLONE_DIR/.pnpm-store @@ -306,6 +307,30 @@ definitions: - pnpmwebappgenerativeai - clis - sonar + - step: &webappTenantsTest + name: 'webapp-tenants: Lint & test' + image: atlassian/default-image:4 + size: 2x + script: + - *initializeStep + - pnpm install + --include-workspace-root + --frozen-lockfile + --filter=webapp-tenants... + - pnpm nx run webapp-tenants:lint + - pnpm nx run webapp-tenants:type-check + - pnpm nx run webapp-tenants:test --watchAll=false --maxWorkers=20% --coverage + - if [ -z "${SONAR_ORGANIZATION}" ]; then exit 0; fi + - pipe: sonarsource/sonarcloud-scan:1.4.0 + variables: + SONAR_ORGANIZATION: ${SONAR_ORGANIZATION} + SONAR_WEBAPP_TENANTS_PROJECT_KEY: ${SONAR_WEBAPP_TENANTS_PROJECT_KEY} + SONAR_TOKEN: ${SONAR_TOKEN} + EXTRA_ARGS: '-Dsonar.projectBaseDir=packages/webapp-libs/webapp-tenants' + caches: + - pnpmwebapptenants + - clis + - sonar - step: &backendBuildAndTest name: 'backend: Build image & run tests' image: atlassian/default-image:4 @@ -501,6 +526,7 @@ pipelines: - step: *webappEmailsTest - step: *webappFinancesTest - step: *webappGenAiTest + - step: *webappTenantsTest - step: *backendBuildAndTest @@ -531,6 +557,7 @@ pipelines: - step: *webappEmailsTest - step: *webappFinancesTest - step: *webappGenAiTest + - step: *webappTenantsTest - step: *backendBuildAndTest diff --git a/package.json b/package.json index 0ef7a2fe5..c93adead3 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "postinstall": "node packages/internal/cli/scripts/build.js" }, "devDependencies": { - "@apollo/client": "^3.8.8", + "@apollo/client": "^3.9.6", "@apollo/rover": "^0.19.1", "@babel/preset-react": "^7.24.1", "@graphql-codegen/cli": "^5.0.0", diff --git a/packages/backend/apps/demo/migrations/0003_cruddemoitem_tenant.py b/packages/backend/apps/demo/migrations/0003_cruddemoitem_tenant.py new file mode 100644 index 000000000..c13a18a16 --- /dev/null +++ b/packages/backend/apps/demo/migrations/0003_cruddemoitem_tenant.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2 on 2024-03-26 12:10 + +from django.db import migrations, models +import django.db.models.deletion + +from apps.multitenancy.constants import TenantType + + +def populate_tenant(apps, schema_editor): + CrudDemoItem = apps.get_model('demo', 'CrudDemoItem') + Tenant = apps.get_model('multitenancy', 'Tenant') + + for item in CrudDemoItem.objects.all(): + if not item.tenant: + default_tenant = Tenant.objects.filter(type=TenantType.DEFAULT, creator=item.created_by).first() + item.tenant = default_tenant + item.save() + + +class Migration(migrations.Migration): + dependencies = [ + ('multitenancy', '0005_tenant_billing_email'), + ('demo', '0002_initial') + ] + + operations = [ + migrations.AddField( + model_name='cruddemoitem', + name='tenant', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='%(class)s_set', + to='multitenancy.tenant', + verbose_name='Tenant', + ), + ), + migrations.RunPython(populate_tenant, reverse_code=migrations.RunPython.noop), + ] diff --git a/packages/backend/apps/demo/models.py b/packages/backend/apps/demo/models.py index 439389a79..9d5682f34 100644 --- a/packages/backend/apps/demo/models.py +++ b/packages/backend/apps/demo/models.py @@ -7,11 +7,12 @@ from apps.content import models as content_models from common.storages import UniqueFilePathGenerator +from common.models import TimestampedMixin, TenantDependentModelMixin User = get_user_model() -class CrudDemoItem(models.Model): +class CrudDemoItem(TenantDependentModelMixin, models.Model): id = hashid_field.HashidAutoField(primary_key=True) name = models.CharField(max_length=255) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True) @@ -24,14 +25,11 @@ def __init__(self, *args, **kwargs): self.edited_by: Optional[User] = None -class ContentfulDemoItemFavorite(models.Model): +class ContentfulDemoItemFavorite(TimestampedMixin, models.Model): id = hashid_field.HashidAutoField(primary_key=True) item = models.ForeignKey(content_models.DemoItem, on_delete=models.CASCADE) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - class Meta: unique_together = [['item', 'user']] diff --git a/packages/backend/apps/demo/notifications.py b/packages/backend/apps/demo/notifications.py index 43dc57c15..266685353 100644 --- a/packages/backend/apps/demo/notifications.py +++ b/packages/backend/apps/demo/notifications.py @@ -1,4 +1,3 @@ -from django.contrib.auth import get_user_model from graphql_relay import to_global_id from apps.notifications import sender @@ -7,10 +6,10 @@ def send_new_entry_created_notification(entry: models.CrudDemoItem): - User = get_user_model() - for admin in User.objects.filter_admins(): + tenant = entry.tenant + for owner in tenant.owners: sender.send_notification( - user=admin, + user=owner, type=constants.Notification.CRUD_ITEM_CREATED.value, data={ "id": to_global_id('CrudDemoItemType', str(entry.id)), @@ -23,8 +22,8 @@ def send_new_entry_created_notification(entry: models.CrudDemoItem): def send_entry_updated_notification(entry: models.CrudDemoItem): if not entry.edited_by: return - User = get_user_model() - users_to_be_notified = set(User.objects.filter_admins()) | {entry.created_by} + tenant = entry.tenant + users_to_be_notified = set(tenant.owners) | {entry.created_by} for user in users_to_be_notified: if user and user != entry.edited_by: sender.send_notification( diff --git a/packages/backend/apps/demo/schema.py b/packages/backend/apps/demo/schema.py index 5013219ab..45a5c4b58 100644 --- a/packages/backend/apps/demo/schema.py +++ b/packages/backend/apps/demo/schema.py @@ -2,10 +2,12 @@ from django.shortcuts import get_object_or_404 from graphene import relay from graphene_django import DjangoObjectType -from graphql_relay import to_global_id +from graphql_relay import to_global_id, from_global_id from apps.content import models as content_models from common.graphql import mutations +from common.acl.policies import IsTenantMemberAccess +from common.graphql.acl import permission_classes from . import models, serializers @@ -42,7 +44,7 @@ class Meta: node = ContentfulDemoItemFavoriteType -class CreateCrudDemoItemMutation(mutations.CreateModelMutation): +class CreateCrudDemoItemMutation(mutations.CreateTenantDependentModelMutation): class Meta: serializer_class = serializers.CrudDemoItemSerializer edge_class = CrudDemoItemConnection.Edge @@ -103,26 +105,28 @@ def mutate_and_get_payload(cls, root, info, id): return cls(deleted_ids=[id]) -class UpdateCrudDemoItemMutation(mutations.UpdateModelMutation): +class UpdateCrudDemoItemMutation(mutations.UpdateTenantDependentModelMutation): class Meta: serializer_class = serializers.CrudDemoItemSerializer edge_class = CrudDemoItemConnection.Edge -class DeleteCrudDemoItemMutation(mutations.DeleteModelMutation): +class DeleteCrudDemoItemMutation(mutations.DeleteTenantDependentModelMutation): class Meta: model = models.CrudDemoItem class Query(graphene.ObjectType): - crud_demo_item = graphene.relay.Node.Field(CrudDemoItemType) - all_crud_demo_items = graphene.relay.ConnectionField(CrudDemoItemConnection) + crud_demo_item = graphene.Field(CrudDemoItemType, id=graphene.ID(), tenant_id=graphene.ID()) + all_crud_demo_items = graphene.relay.ConnectionField(CrudDemoItemConnection, tenant_id=graphene.ID()) all_contentful_demo_item_favorites = graphene.relay.ConnectionField(ContentfulDemoItemFavoriteConnection) all_document_demo_items = graphene.relay.ConnectionField(DocumentDemoItemConnection) @staticmethod - def resolve_all_crud_demo_items(root, info, **kwargs): - return models.CrudDemoItem.objects.all() + @permission_classes(IsTenantMemberAccess) + def resolve_all_crud_demo_items(root, info, tenant_id, **kwargs): + _, pk = from_global_id(tenant_id) + return models.CrudDemoItem.objects.filter(tenant_id=pk).all() @staticmethod def resolve_all_contentful_demo_item_favorites(root, info, **kwargs): @@ -132,11 +136,22 @@ def resolve_all_contentful_demo_item_favorites(root, info, **kwargs): def resolve_all_document_demo_items(root, info, **kwargs): return info.context.user.documents.all() + @staticmethod + @permission_classes(IsTenantMemberAccess) + def resolve_crud_demo_item(root, info, id, tenant_id, **kwargs): + _, pk = from_global_id(id) + _, tenant_pk = from_global_id(tenant_id) + return models.CrudDemoItem.objects.filter(pk=pk, tenant=tenant_pk).first() -class Mutation(graphene.ObjectType): + +@permission_classes(IsTenantMemberAccess) +class TenantMemberMutation(graphene.ObjectType): create_crud_demo_item = CreateCrudDemoItemMutation.Field() update_crud_demo_item = UpdateCrudDemoItemMutation.Field() delete_crud_demo_item = DeleteCrudDemoItemMutation.Field() + + +class Mutation(graphene.ObjectType): create_document_demo_item = CreateDocumentDemoItemMutation.Field() delete_document_demo_item = DeleteDocumentDemoItemMutation.Field() create_favorite_contentful_demo_item = CreateFavoriteContentfulDemoItemMutation.Field() diff --git a/packages/backend/apps/demo/serializers.py b/packages/backend/apps/demo/serializers.py index 3c63b6f8e..d23fea77a 100644 --- a/packages/backend/apps/demo/serializers.py +++ b/packages/backend/apps/demo/serializers.py @@ -9,6 +9,7 @@ class CrudDemoItemSerializer(serializers.ModelSerializer): id = hidrest.HashidSerializerCharField(source_field="users.User.id", read_only=True) + tenant_id = hidrest.HashidSerializerCharField() created_by = serializers.HiddenField(default=serializers.CurrentUserDefault()) def update(self, instance, validated_data): @@ -17,7 +18,7 @@ def update(self, instance, validated_data): class Meta: model = models.CrudDemoItem - fields = ('id', 'name', 'created_by') + fields = ('id', 'tenant_id', 'name', 'created_by') class DocumentDemoItemSerializer(serializers.ModelSerializer): diff --git a/packages/backend/apps/demo/tests/factories.py b/packages/backend/apps/demo/tests/factories.py index d45bf8639..30c198c2c 100644 --- a/packages/backend/apps/demo/tests/factories.py +++ b/packages/backend/apps/demo/tests/factories.py @@ -4,10 +4,12 @@ from apps.users.tests import factories as user_factories from apps.content.tests import factories as content_factories +from apps.multitenancy.tests import factories as multitenancy_factories class CrudDemoItemFactory(factory.django.DjangoModelFactory): name = factory.Faker('pystr') + tenant = factory.SubFactory(multitenancy_factories.TenantFactory) class Meta: model = models.CrudDemoItem diff --git a/packages/backend/apps/demo/tests/test_schema.py b/packages/backend/apps/demo/tests/test_schema.py index b4e19dd25..2f434dc71 100644 --- a/packages/backend/apps/demo/tests/test_schema.py +++ b/packages/backend/apps/demo/tests/test_schema.py @@ -6,19 +6,26 @@ from graphene_file_upload.django.testing import file_graphql_query from graphql_relay import to_global_id, from_global_id from .. import models, constants +from apps.multitenancy.constants import TenantType, TenantUserRole pytestmark = pytest.mark.django_db class TestAllCrudDemoItemsQuery: - def test_returns_all_items(self, graphene_client, crud_demo_item_factory, user): - items = crud_demo_item_factory.create_batch(3) - + def test_returns_all_items( + self, graphene_client, crud_demo_item_factory, tenant_factory, tenant_membership_factory, user + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_2 = tenant_factory(name="Tenant 2", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + accessible_items = crud_demo_item_factory.create_batch(3, tenant=tenant) + crud_demo_item_factory.create_batch(3, tenant=tenant_2) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) graphene_client.force_authenticate(user) executed = graphene_client.query( """ - query { - allCrudDemoItems { + query($tenantId: ID!) { + allCrudDemoItems(tenantId: $tenantId) { edges { node { id @@ -27,7 +34,8 @@ def test_returns_all_items(self, graphene_client, crud_demo_item_factory, user): } } } - """ + """, + variable_values={"tenantId": to_global_id("TenantType", tenant.id)}, ) assert executed == { @@ -40,52 +48,80 @@ def test_returns_all_items(self, graphene_client, crud_demo_item_factory, user): "name": item.name, } } - for item in items + for item in accessible_items ] } } } + def test_returns_all_items_without_membership(self, graphene_client, crud_demo_item_factory, tenant_factory, user): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_2 = tenant_factory(name="Tenant 2", type=TenantType.ORGANIZATION) + crud_demo_item_factory.create_batch(3, tenant=tenant) + crud_demo_item_factory.create_batch(3, tenant=tenant_2) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, None) + executed = graphene_client.query( + """ + query($tenantId: ID!) { + allCrudDemoItems(tenantId: $tenantId) { + edges { + node { + id + name + } + } + } + } + """, + variable_values={"tenantId": to_global_id("TenantType", tenant.id)}, + ) + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + class TestCrudDemoItemQuery: CRUD_DEMO_ITEM_QUERY = """ - query($id: ID!) { - crudDemoItem(id: $id) { + query($id: ID!, $tenantId: ID) { + crudDemoItem(id: $id, tenantId: $tenantId) { id name } } """ - def test_return_error_for_not_authorized_user(self, graphene_client, crud_demo_item): + def test_return_error_for_not_authorized_user(self, graphene_client, crud_demo_item, tenant): item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id)) executed = graphene_client.query( self.CRUD_DEMO_ITEM_QUERY, - variable_values={"id": item_global_id}, + variable_values={"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)}, ) assert executed["errors"] assert executed["errors"][0]["message"] == "permission_denied" - def test_return_none_if_item_does_not_exist(self, graphene_client, user): + def test_return_none_if_item_does_not_exist(self, graphene_client, user, tenant): item_global_id = to_global_id("CrudDemoItemType", "invalid-id") graphene_client.force_authenticate(user) executed = graphene_client.query( self.CRUD_DEMO_ITEM_QUERY, - variable_values={"id": item_global_id}, + variable_values={"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)}, ) assert executed["data"] == {"crudDemoItem": None} - def test_return_item(self, graphene_client, crud_demo_item, user): + def test_return_item(self, graphene_client, crud_demo_item_factory, user, tenant, tenant_membership_factory): + crud_demo_item = crud_demo_item_factory(tenant=tenant) item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id)) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER) graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) executed = graphene_client.query( self.CRUD_DEMO_ITEM_QUERY, - variable_values={"id": item_global_id}, + variable_values={"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)}, ) assert executed == { @@ -97,6 +133,36 @@ def test_return_item(self, graphene_client, crud_demo_item, user): } } + def test_tenant_no_membership(self, graphene_client, crud_demo_item_factory, user, tenant): + crud_demo_item = crud_demo_item_factory(tenant=tenant) + item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id)) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, None) + executed = graphene_client.query( + self.CRUD_DEMO_ITEM_QUERY, + variable_values={"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)}, + ) + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_wrong_tenant_id( + self, graphene_client, crud_demo_item_factory, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory() + tenant_second = tenant_factory() + crud_demo_item = crud_demo_item_factory(tenant=tenant_second) + item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id)) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) + executed = graphene_client.query( + self.CRUD_DEMO_ITEM_QUERY, + variable_values={"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)}, + ) + assert executed["data"] == {"crudDemoItem": None} + class TestCreateCrudDemoItemMutation: CREATE_MUTATION = """ @@ -105,6 +171,9 @@ class TestCreateCrudDemoItemMutation: crudDemoItem { id name + tenant { + id + } } } } @@ -122,35 +191,48 @@ class TestCreateCrudDemoItemMutation: } """ - @pytest.fixture - def input_data(self) -> dict: - return {"name": "Item name"} - - def test_create_new_item(self, graphene_client, user, input_data): + def test_create_new_item(self, graphene_client, user, tenant, tenant_membership_factory): + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER) graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) executed = graphene_client.mutate( self.CREATE_MUTATION, - variable_values={"input": input_data}, + variable_values={"input": {"name": "Item name", "tenantId": to_global_id("TenantType", tenant.id)}}, ) assert executed["data"]["createCrudDemoItem"] assert executed["data"]["createCrudDemoItem"]["crudDemoItem"] - assert executed["data"]["createCrudDemoItem"]["crudDemoItem"]["name"] == input_data["name"] + assert executed["data"]["createCrudDemoItem"]["crudDemoItem"]["name"] == "Item name" item_global_id = executed["data"]["createCrudDemoItem"]["crudDemoItem"]["id"] _, pk = from_global_id(item_global_id) item = models.CrudDemoItem.objects.get(pk=pk) - assert item.name == input_data["name"] + assert item.name == "Item name" + assert item.tenant == tenant - def test_create_new_item_sends_notification(self, graphene_client, user_factory, input_data): - user = user_factory(has_avatar=True) - admin = user_factory(admin=True) + def test_create_new_item_without_membership(self, graphene_client, user, tenant): + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, None) + executed = graphene_client.mutate( + self.CREATE_MUTATION, + variable_values={"input": {"name": "Item name", "tenantId": to_global_id("TenantType", tenant.id)}}, + ) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_create_new_item_sends_notification(self, graphene_client, user_factory, tenant, tenant_membership_factory): + owner = user_factory() + tenant_membership_factory(tenant=tenant, user=owner, role=TenantUserRole.OWNER) + user = user_factory(has_avatar=True) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER) graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) executed = graphene_client.mutate( self.CREATE_MUTATION, - variable_values={"input": input_data}, + variable_values={"input": {"name": "Item name", "tenantId": to_global_id("TenantType", tenant.id)}}, ) item_global_id = executed["data"]["createCrudDemoItem"]["crudDemoItem"]["id"] @@ -160,7 +242,7 @@ def test_create_new_item_sends_notification(self, graphene_client, user_factory, assert Notification.objects.count() == 1 notification = Notification.objects.first() assert notification.type == constants.Notification.CRUD_ITEM_CREATED.value - assert notification.user == admin + assert notification.user == owner assert notification.data == { "id": item_global_id, "name": item.name, @@ -175,6 +257,9 @@ class TestUpdateCrudDemoItemMutation: crudDemoItem { id name + tenant { + id + } } } } @@ -194,18 +279,22 @@ class TestUpdateCrudDemoItemMutation: @pytest.fixture def input_data_factory(self): - def _factory(crud_demo_item, name="New item name") -> dict: + def _factory(crud_demo_item, name="New item name", tenant_id=None) -> dict: return { "id": to_global_id("CrudDemoItemType", str(crud_demo_item.id)), "name": name, + "tenantId": tenant_id or to_global_id("TenantType", crud_demo_item.tenant_id), } return _factory - def test_update_existing_item(self, graphene_client, crud_demo_item, user, input_data_factory): + def test_update_existing_item( + self, graphene_client, crud_demo_item, user, tenant_membership_factory, tenant, input_data_factory + ): input_data = input_data_factory(crud_demo_item) - + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER) graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) executed = graphene_client.mutate( self.UPDATE_MUTATION, variable_values={"input": input_data}, @@ -216,19 +305,43 @@ def test_update_existing_item(self, graphene_client, crud_demo_item, user, input assert executed["data"]["updateCrudDemoItem"] assert executed["data"]["updateCrudDemoItem"]["crudDemoItem"] assert executed["data"]["updateCrudDemoItem"]["crudDemoItem"]["name"] == input_data["name"] + assert from_global_id(executed["data"]["updateCrudDemoItem"]["crudDemoItem"]["tenant"]["id"])[1] == tenant.id assert crud_demo_item.name == input_data["name"] + def test_update_existing_item_without_membership( + self, graphene_client, user, tenant, crud_demo_item, input_data_factory + ): + input_data = input_data_factory(crud_demo_item) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, None) + executed = graphene_client.mutate( + self.UPDATE_MUTATION, + variable_values={"input": input_data}, + ) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + def test_update_existing_item_sends_notification_to_admins_and_creator( - self, graphene_client, crud_demo_item_factory, user_factory, input_data_factory + self, + graphene_client, + crud_demo_item_factory, + user_factory, + input_data_factory, + tenant_membership_factory, + tenant, ): user = user_factory(has_avatar=True) other_user = user_factory(has_avatar=True) - admins = user_factory.create_batch(2, admin=True) - crud_demo_item = crud_demo_item_factory(created_by=user) + owners = user_factory.create_batch(2) + for owner in owners: + tenant_membership_factory(tenant=tenant, user=owner, role=TenantUserRole.OWNER) + crud_demo_item = crud_demo_item_factory(created_by=user, tenant=tenant) item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id)) input_data = input_data_factory(crud_demo_item) - + tenant_membership_factory(tenant=tenant, user=other_user, role=TenantUserRole.MEMBER) graphene_client.force_authenticate(other_user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) graphene_client.mutate( self.UPDATE_MUTATION, variable_values={"input": input_data}, @@ -243,26 +356,36 @@ def test_update_existing_item_sends_notification_to_admins_and_creator( } assert notification.issuer == other_user - assert Notification.objects.filter(user=admins[0], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists() - assert Notification.objects.filter(user=admins[1], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists() + assert Notification.objects.filter(user=owners[0], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists() + assert Notification.objects.filter(user=owners[1], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists() def test_update_existing_item_sends_notification_to_admins_skipping_creator_if_he_is_the_one_updating( - self, graphene_client, crud_demo_item_factory, user_factory, input_data_factory + self, + graphene_client, + crud_demo_item_factory, + user_factory, + input_data_factory, + tenant, + tenant_membership_factory, ): user = user_factory() - crud_demo_item = crud_demo_item_factory(created_by=user) - admins = user_factory.create_batch(2, admin=True) + crud_demo_item = crud_demo_item_factory(created_by=user, tenant=tenant) + owners = user_factory.create_batch(2) + for owner in owners: + tenant_membership_factory(tenant=tenant, user=owner, role=TenantUserRole.OWNER) input_data = input_data_factory(crud_demo_item) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER) graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) graphene_client.mutate( self.UPDATE_MUTATION, variable_values={"input": input_data}, ) assert Notification.objects.count() == 2 - assert Notification.objects.filter(user=admins[0], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists() - assert Notification.objects.filter(user=admins[1], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists() + assert Notification.objects.filter(user=owners[0], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists() + assert Notification.objects.filter(user=owners[1], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists() class TestDeleteCrudDemoItemMutation: @@ -274,24 +397,47 @@ class TestDeleteCrudDemoItemMutation: } """ - def test_deleting_item(self, graphene_client, crud_demo_item, user): + def test_deleting_item(self, graphene_client, crud_demo_item, user, tenant_membership_factory): item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id)) + tenant = crud_demo_item.tenant + + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER) graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) executed = graphene_client.mutate( self.DELETE_MUTATION, - variable_values={"input": {"id": item_global_id}}, + variable_values={"input": {"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)}}, ) assert executed == {"data": {"deleteCrudDemoItem": {"deletedIds": [item_global_id]}}} assert not models.CrudDemoItem.objects.filter(id=crud_demo_item.id).exists() + def test_deleting_item_without_membership(self, graphene_client, crud_demo_item, user): + item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id)) + tenant = crud_demo_item.tenant + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, None) + + executed = graphene_client.mutate( + self.DELETE_MUTATION, + variable_values={"input": {"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)}}, + ) + + assert len(executed["errors"]) == 1 + assert executed["errors"][0]["message"] == "permission_denied" + assert executed["errors"][0]["path"] == ["deleteCrudDemoItem"] + assert executed["data"] == {"deleteCrudDemoItem": None} + assert models.CrudDemoItem.objects.filter(id=crud_demo_item.id).exists() + def test_deleting_item_by_not_authorized_user(self, graphene_client, crud_demo_item): item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id)) + tenant = crud_demo_item.tenant executed = graphene_client.mutate( self.DELETE_MUTATION, - variable_values={"input": {"id": item_global_id}}, + variable_values={"input": {"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)}}, ) assert len(executed["errors"]) == 1 diff --git a/packages/backend/apps/finances/schema.py b/packages/backend/apps/finances/schema.py index d34214cf8..3867e9e0e 100644 --- a/packages/backend/apps/finances/schema.py +++ b/packages/backend/apps/finances/schema.py @@ -11,7 +11,7 @@ from rest_framework.generics import get_object_or_404 from stripe.error import InvalidRequestError -from common.acl.policies import AnyoneFullAccess +from common.acl.policies import AnyoneFullAccess, IsTenantOwnerAccess from common.graphql import mutations from common.graphql.acl import permission_classes from . import constants @@ -189,26 +189,26 @@ class Meta: node = StripeChargeType -class ChangeActiveSubscriptionMutation(mutations.UpdateModelMutation): +class ChangeActiveSubscriptionMutation(mutations.UpdateTenantDependentModelMutation): class Meta: - serializer_class = serializers.UserSubscriptionScheduleSerializer + serializer_class = serializers.TenantSubscriptionScheduleSerializer edge_class = SubscriptionScheduleConnection.Edge require_id_field = False @classmethod def get_object(cls, model_class, root, info, **input): - return subscriptions.get_schedule(user=info.context.user) + return subscriptions.get_schedule(tenant=info.context.tenant) -class CancelActiveSubscriptionMutation(mutations.UpdateModelMutation): +class CancelActiveSubscriptionMutation(mutations.UpdateTenantDependentModelMutation): class Meta: - serializer_class = serializers.CancelUserActiveSubscriptionSerializer + serializer_class = serializers.CancelTenantActiveSubscriptionSerializer edge_class = SubscriptionScheduleConnection.Edge require_id_field = False @classmethod def get_object(cls, model_class, root, info, **input): - return subscriptions.get_schedule(user=info.context.user) + return subscriptions.get_schedule(tenant=info.context.tenant) class PaymentMethodConnection(graphene.Connection): @@ -218,8 +218,8 @@ class Meta: class PaymentMethodGetObjectMixin: @classmethod - def get_payment_method(cls, id, user): - filter_kwargs = {"id": id, "customer__subscriber": user} + def get_payment_method(cls, id, tenant): + filter_kwargs = {"id": id, "customer__subscriber": tenant} queryset = djstripe_models.PaymentMethod.objects.filter(**filter_kwargs) if not queryset.exists(): try: @@ -250,6 +250,7 @@ class UpdateDefaultPaymentMethodMutation(PaymentMethodGetObjectMixin, mutations. class Input: id = graphene.String() + tenant_id = graphene.String(required=True) class Meta: serializer_class = serializers.UpdateDefaultPaymentMethodSerializer @@ -259,56 +260,62 @@ class Meta: @classmethod def get_object(cls, model_class, info, *args, **kwargs): - return cls.get_payment_method(kwargs["id"], info.context.user) + return cls.get_payment_method(kwargs["id"], info.context.tenant) @classmethod def mutate_and_get_payload(cls, root, info, **input): super().mutate_and_get_payload(root, info, **input) return UpdateDefaultPaymentMethodMutation( - active_subscription=subscriptions.get_schedule(user=info.context.user), + active_subscription=subscriptions.get_schedule(tenant=info.context.tenant), payment_method_edge=SubscriptionScheduleConnection.Edge( cursor=offset_to_cursor(0), - node=cls.get_payment_method(input["id"], info.context.user), + node=cls.get_payment_method(input["id"], info.context.tenant), ), ) -class DeletePaymentMethodMutation(PaymentMethodGetObjectMixin, mutations.DeleteModelMutation): +class DeletePaymentMethodMutation(PaymentMethodGetObjectMixin, mutations.DeleteTenantDependentModelMutation): active_subscription = graphene.Field(SubscriptionScheduleType) class Meta: model = djstripe_models.PaymentMethod + class Input: + id = graphene.String() + tenant_id = graphene.String(required=True) + @classmethod @transaction.atomic - def mutate_and_get_payload(cls, root, info, id): - obj = cls.get_payment_method(id, info.context.user) + def mutate_and_get_payload(cls, root, info, id, **input): + if "tenant_id" in input: + _, input["tenant_id"] = from_global_id(input["tenant_id"]) + obj = cls.get_payment_method(id, info.context.tenant) pk = obj.pk customers.remove_payment_method(payment_method=obj) obj.delete() return cls( - active_subscription=subscriptions.get_schedule(user=info.context.user), + active_subscription=subscriptions.get_schedule(tenant=info.context.tenant), deleted_ids=[to_global_id("StripePaymentMethodType", str(pk))], ) -class CreatePaymentIntentMutation(mutations.CreateModelMutation): +class CreatePaymentIntentMutation(mutations.CreateTenantDependentModelMutation): class Meta: model = djstripe_models.PaymentIntent serializer_class = serializers.PaymentIntentSerializer -class UpdatePaymentIntentMutation(mutations.UpdateModelMutation): +class UpdatePaymentIntentMutation(mutations.UpdateTenantDependentModelMutation): class Meta: model = djstripe_models.PaymentIntent serializer_class = serializers.PaymentIntentSerializer @classmethod def get_queryset(cls, model_class, root, info, **input): - return djstripe_models.PaymentIntent.objects.filter(customer__subscriber=info.context.user) + return djstripe_models.PaymentIntent.objects.filter(customer__subscriber=info.context.tenant) -class CreateSetupIntentMutation(mutations.CreateModelMutation): +class CreateSetupIntentMutation(mutations.CreateTenantDependentModelMutation): class Meta: model = djstripe_models.SetupIntent serializer_class = serializers.SetupIntentSerializer @@ -316,11 +323,11 @@ class Meta: class Query(graphene.ObjectType): all_subscription_plans = graphene.relay.ConnectionField(StripePriceConnection) - active_subscription = graphene.Field(SubscriptionScheduleType) - all_payment_methods = graphene.relay.ConnectionField(PaymentMethodConnection) - all_charges = graphene.relay.ConnectionField(ChargeConnection) - charge = graphene.Field(StripeChargeType, id=graphene.ID()) - payment_intent = graphene.Field(StripePaymentIntentType, id=graphene.ID()) + active_subscription = graphene.Field(SubscriptionScheduleType, tenant_id=graphene.ID()) + all_payment_methods = graphene.relay.ConnectionField(PaymentMethodConnection, tenant_id=graphene.ID()) + all_charges = graphene.relay.ConnectionField(ChargeConnection, tenant_id=graphene.ID()) + charge = graphene.Field(StripeChargeType, id=graphene.ID(), tenant_id=graphene.ID()) + payment_intent = graphene.Field(StripePaymentIntentType, id=graphene.ID(), tenant_id=graphene.ID()) @staticmethod @permission_classes(AnyoneFullAccess) @@ -335,30 +342,36 @@ def resolve_all_subscription_plans(root, info, **kwargs): ) @staticmethod - def resolve_active_subscription(root, info): - return subscriptions.get_schedule(user=info.context.user) + @permission_classes(IsTenantOwnerAccess) + def resolve_active_subscription(root, info, **kwargs): + return subscriptions.get_schedule(tenant=info.context.tenant) @staticmethod + @permission_classes(IsTenantOwnerAccess) def resolve_all_payment_methods(root, info, **kwargs): - return djstripe_models.PaymentMethod.objects.filter(customer__subscriber=info.context.user) + return djstripe_models.PaymentMethod.objects.filter(customer__subscriber=info.context.tenant) @staticmethod + @permission_classes(IsTenantOwnerAccess) def resolve_all_charges(root, info, **kwargs): - customer, _ = djstripe_models.Customer.get_or_create(info.context.user) + customer, _ = djstripe_models.Customer.get_or_create(info.context.tenant) return customer.charges.filter(status=djstripe_enums.ChargeStatus.succeeded).order_by("-created") @staticmethod - def resolve_charge(root, info, id): + @permission_classes(IsTenantOwnerAccess) + def resolve_charge(root, info, id, **kwargs): _, pk = from_global_id(id) - customer, _ = djstripe_models.Customer.get_or_create(info.context.user) + customer, _ = djstripe_models.Customer.get_or_create(info.context.tenant) return customer.charges.get(status=djstripe_enums.ChargeStatus.succeeded, pk=pk) @staticmethod - def resolve_payment_intent(root, info, id): + @permission_classes(IsTenantOwnerAccess) + def resolve_payment_intent(root, info, id, **kwargs): _, pk = from_global_id(id) - return djstripe_models.PaymentIntent.objects.get(customer__subscriber=info.context.user, pk=pk) + return djstripe_models.PaymentIntent.objects.get(customer__subscriber=info.context.tenant, pk=pk) +@permission_classes(IsTenantOwnerAccess) class Mutation(graphene.ObjectType): change_active_subscription = ChangeActiveSubscriptionMutation.Field() cancel_active_subscription = CancelActiveSubscriptionMutation.Field() diff --git a/packages/backend/apps/finances/serializers.py b/packages/backend/apps/finances/serializers.py index 9f4d51c93..e006b5ed1 100644 --- a/packages/backend/apps/finances/serializers.py +++ b/packages/backend/apps/finances/serializers.py @@ -31,7 +31,7 @@ class Meta: def create(self, validated_data): request = self.context['request'] - (customer, _) = djstripe_models.Customer.get_or_create(request.user) + (customer, _) = djstripe_models.Customer.get_or_create(request.tenant) amount = int(validated_data['product']) * 100 payment_intent_response = djstripe_models.PaymentIntent._api_create( amount=amount, @@ -56,7 +56,7 @@ class Meta: def create(self, validated_data): request = self.context['request'] - (customer, _) = djstripe_models.Customer.get_or_create(request.user) + (customer, _) = djstripe_models.Customer.get_or_create(request.tenant) setup_intent_response = djstripe_models.SetupIntent._api_create( customer=customer.id, payment_method_types=['card'], usage='off_session' ) @@ -77,7 +77,7 @@ class Meta: class UpdateDefaultPaymentMethodSerializer(serializers.Serializer): def update(self, instance, validated_data): - customer, _ = djstripe_models.Customer.get_or_create(self.context['request'].user) + customer, _ = djstripe_models.Customer.get_or_create(self.context['request'].tenant) customers.set_default_payment_method(customer=customer, payment_method=instance) return instance @@ -149,7 +149,7 @@ def get_item(self, obj): return SubscriptionSchedulePhaseItemSerializer(obj['items'][0]).data -class UserSubscriptionScheduleSerializer(serializers.ModelSerializer): +class TenantSubscriptionScheduleSerializer(serializers.ModelSerializer): subscription = SubscriptionSerializer(source='customer.subscription', read_only=True) default_payment_method = PaymentMethodSerializer(source='customer.default_payment_method', read_only=True) phases = serializers.SerializerMethodField() @@ -229,7 +229,7 @@ class Meta: fields = ('subscription', 'phases', 'can_activate_trial', 'price', 'default_payment_method') -class CancelUserActiveSubscriptionSerializer(serializers.ModelSerializer): +class CancelTenantActiveSubscriptionSerializer(serializers.ModelSerializer): def validate(self, attrs): if subscriptions.is_current_schedule_phase_plan(schedule=self.instance, plan_config=constants.FREE_PLAN): raise serializers.ValidationError( @@ -306,7 +306,7 @@ def update(self, instance: djstripe_models.PaymentIntent, validated_data): return instance -class UserChargeInvoiceItemSerializer(serializers.ModelSerializer): +class TenantChargeInvoiceItemSerializer(serializers.ModelSerializer): price = PriceSerializer() class Meta: @@ -314,16 +314,16 @@ class Meta: fields = ('id', 'price') -class UserChargeInvoiceSerializer(serializers.ModelSerializer): - items = UserChargeInvoiceItemSerializer(source='invoiceitems', many=True) +class TenantChargeInvoiceSerializer(serializers.ModelSerializer): + items = TenantChargeInvoiceItemSerializer(source='invoiceitems', many=True) class Meta: model = djstripe_models.Invoice fields = ('id', 'billing_reason', 'items') -class UserChargeSerializer(serializers.ModelSerializer): - invoice = UserChargeInvoiceSerializer() +class TenantChargeSerializer(serializers.ModelSerializer): + invoice = TenantChargeInvoiceSerializer() billing_details = serializers.JSONField(read_only=True) payment_method_details = serializers.JSONField(read_only=True) diff --git a/packages/backend/apps/finances/services/subscriptions.py b/packages/backend/apps/finances/services/subscriptions.py index 3e500bb8a..70d6136c5 100644 --- a/packages/backend/apps/finances/services/subscriptions.py +++ b/packages/backend/apps/finances/services/subscriptions.py @@ -5,7 +5,7 @@ from ..exceptions import UserOrCustomerNotDefined, SubscriptionAndPriceDefinedTogether, SubscriptionOrPriceNotDefined -def initialize_user(user): +def initialize_tenant(tenant): """ Primary purpose for separating this code into its own function is the ability to mock it during tests so we utilise a schedule created by factories instead of relying on stripe-mock response @@ -14,12 +14,12 @@ def initialize_user(user): :return: """ price = models.Price.objects.get_by_plan(constants.FREE_PLAN) - create_schedule(user=user, price=price) + create_schedule(tenant=tenant, price=price) -def get_schedule(user=None, customer=None): - if user: - customer, _ = djstripe_models.Customer.get_or_create(user) +def get_schedule(tenant=None, customer=None): + if tenant: + customer, _ = djstripe_models.Customer.get_or_create(tenant) if customer is None: raise UserOrCustomerNotDefined("Either user or customer must be defined") @@ -27,15 +27,15 @@ def get_schedule(user=None, customer=None): def create_schedule( - subscription: djstripe_models.Subscription = None, price: djstripe_models.Price = None, user=None, customer=None + subscription: djstripe_models.Subscription = None, price: djstripe_models.Price = None, tenant=None, customer=None ): if subscription and price: raise SubscriptionAndPriceDefinedTogether("Subscription and price can't be defined together") subscription_schedule_stripe_instance = None if price: - if user: - customer, _ = djstripe_models.Customer.get_or_create(user) + if tenant: + customer, _ = djstripe_models.Customer.get_or_create(tenant) if customer is None: raise UserOrCustomerNotDefined("Either user or customer must be defined") diff --git a/packages/backend/apps/finances/signals.py b/packages/backend/apps/finances/signals.py index ea8dfc61b..a0596a2f5 100644 --- a/packages/backend/apps/finances/signals.py +++ b/packages/backend/apps/finances/signals.py @@ -1,27 +1,25 @@ import logging -from django.contrib.auth import get_user_model from django.db.models.signals import post_save from django.dispatch import receiver from django.conf import settings from stripe.error import AuthenticationError from .services import subscriptions +from apps.multitenancy.models import Tenant logger = logging.getLogger(__name__) -User = get_user_model() - -@receiver(post_save, sender=User) +@receiver(post_save, sender=Tenant) def create_free_plan_subscription(sender, instance, created, **kwargs): if not settings.STRIPE_ENABLED: return if created: try: - subscriptions.initialize_user(user=instance) + subscriptions.initialize_tenant(tenant=instance) except AuthenticationError as e: logger.error(msg=e._message, exc_info=e) return diff --git a/packages/backend/apps/finances/tests/factories.py b/packages/backend/apps/finances/tests/factories.py index b8baa5a7c..8c5213475 100644 --- a/packages/backend/apps/finances/tests/factories.py +++ b/packages/backend/apps/finances/tests/factories.py @@ -6,7 +6,7 @@ from django.utils import timezone from djstripe import models as djstripe_models, enums -from apps.users.tests import factories as user_factories +from apps.multitenancy.tests import factories as multitenancy_factories from .. import models, constants @@ -19,7 +19,7 @@ class Meta: livemode = False currency = 'usd' tax_exempt = enums.CustomerTaxExempt.none - subscriber = factory.SubFactory(user_factories.UserFactory) + subscriber = factory.SubFactory(multitenancy_factories.TenantFactory) email = factory.LazyAttribute(lambda obj: obj.subscriber.email) diff --git a/packages/backend/apps/finances/tests/fixtures.py b/packages/backend/apps/finances/tests/fixtures.py index 70b0ae8f9..06e1d6680 100644 --- a/packages/backend/apps/finances/tests/fixtures.py +++ b/packages/backend/apps/finances/tests/fixtures.py @@ -26,7 +26,7 @@ @pytest.fixture(autouse=True) def mock_init_user(mocker): - mocker.patch('apps.finances.services.subscriptions.initialize_user') + mocker.patch('apps.finances.services.subscriptions.initialize_tenant') @pytest.fixture(scope='function', autouse=True) diff --git a/packages/backend/apps/finances/tests/test_schema.py b/packages/backend/apps/finances/tests/test_schema.py index fc2ee1f32..4c50e63ca 100644 --- a/packages/backend/apps/finances/tests/test_schema.py +++ b/packages/backend/apps/finances/tests/test_schema.py @@ -6,6 +6,8 @@ from apps.finances.tests.utils import stripe_encode from django.utils import timezone +from apps.multitenancy.constants import TenantUserRole + pytestmark = pytest.mark.django_db @@ -44,8 +46,8 @@ def test_returns_all_items(self, graphene_client, free_plan_price, monthly_plan_ class TestActiveSubscriptionQuery: ACTIVE_SUBSCRIPTION_QUERY = ''' - query { - activeSubscription { + query($tenantId: ID) { + activeSubscription(tenantId: $tenantId) { subscription { pk status @@ -114,43 +116,99 @@ def assert_response(self, response, schedule): }, } - def test_return_error_for_unauthorized_user(self, graphene_client): - executed = graphene_client.query(self.ACTIVE_SUBSCRIPTION_QUERY) + @staticmethod + def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER): + user = user_factory() + tenant_membership_factory(tenant=tenant, role=role, user=user) + return user + + def test_return_error_for_unauthorized_user(self, graphene_client, tenant): + executed = graphene_client.query( + self.ACTIVE_SUBSCRIPTION_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)} + ) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_return_error_for_admin_user( + self, + graphene_client, + subscription_schedule_factory, + monthly_plan_price, + user_factory, + tenant_membership_factory, + ): + subscription_schedule = subscription_schedule_factory( + phases=[{'items': [{'price': monthly_plan_price.id}], 'trialing': True}] + ) + customer = subscription_schedule.customer + tenant = customer.subscriber + user = self._get_user_from_customer_tenant( + tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN + ) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = graphene_client.query( + self.ACTIVE_SUBSCRIPTION_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)} + ) assert executed["errors"] assert executed["errors"][0]["message"] == "permission_denied" def test_trial_fields_in_response_when_customer_already_activated_trial( - self, graphene_client, subscription_schedule_factory, monthly_plan_price + self, + graphene_client, + subscription_schedule_factory, + monthly_plan_price, + user_factory, + tenant_membership_factory, ): subscription_schedule = subscription_schedule_factory( phases=[{'items': [{'price': monthly_plan_price.id}], 'trialing': True}] ) customer = subscription_schedule.customer - graphene_client.force_authenticate(customer.subscriber) + tenant = customer.subscriber + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) - executed = graphene_client.query(self.ACTIVE_SUBSCRIPTION_QUERY) + executed = graphene_client.query( + self.ACTIVE_SUBSCRIPTION_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)} + ) assert executed['data']['activeSubscription']['phases'][0]['trialEnd'] assert executed['data']['activeSubscription']['subscription']['trialStart'] assert executed['data']['activeSubscription']['subscription']['trialEnd'] assert not executed['data']['activeSubscription']['canActivateTrial'] - def test_trial_fields_in_response_when_user_never_activated_it(self, graphene_client, subscription_schedule): - user = subscription_schedule.customer.subscriber + def test_trial_fields_in_response_when_user_never_activated_it( + self, graphene_client, subscription_schedule, user_factory, tenant_membership_factory + ): + tenant = subscription_schedule.customer.subscriber + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) - executed = graphene_client.query(self.ACTIVE_SUBSCRIPTION_QUERY) + executed = graphene_client.query( + self.ACTIVE_SUBSCRIPTION_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)} + ) response_phase = executed['data']['activeSubscription']['phases'][0] assert not response_phase['trialEnd'] assert executed['data']['activeSubscription']['canActivateTrial'] - def test_return_active_subscription_data(self, graphene_client, subscription_schedule): - user = subscription_schedule.customer.subscriber + def test_return_active_subscription_data( + self, graphene_client, subscription_schedule, user_factory, tenant_membership_factory + ): + tenant = subscription_schedule.customer.subscriber + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) - executed = graphene_client.query(self.ACTIVE_SUBSCRIPTION_QUERY) + executed = graphene_client.query( + self.ACTIVE_SUBSCRIPTION_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)} + ) + print(executed) self.assert_response(executed['data']['activeSubscription'], subscription_schedule) @@ -187,11 +245,42 @@ class TestChangeActiveSubscriptionMutation: } ''' - def test_change_active_subscription(self, graphene_client, subscription_schedule, monthly_plan_price): - input_data = {'price': monthly_plan_price.id} - user = subscription_schedule.customer.subscriber + @staticmethod + def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER): + user = user_factory() + tenant_membership_factory(tenant=tenant, role=role, user=user) + return user + + def test_change_active_subscription_by_admin( + self, graphene_client, subscription_schedule, monthly_plan_price, user_factory, tenant_membership_factory + ): + tenant = subscription_schedule.customer.subscriber + input_data = {'price': monthly_plan_price.id, "tenantId": to_global_id("TenantType", tenant.pk)} + user = self._get_user_from_customer_tenant( + tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN + ) graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + + executed = graphene_client.mutate( + self.CHANGE_ACTIVE_SUBSCRIPTION_MUTATION, + variable_values={'input': input_data}, + ) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_change_active_subscription( + self, graphene_client, subscription_schedule, monthly_plan_price, user_factory, tenant_membership_factory + ): + tenant = subscription_schedule.customer.subscriber + input_data = {'price': monthly_plan_price.id, "tenantId": to_global_id("TenantType", tenant.pk)} + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = graphene_client.mutate( self.CHANGE_ACTIVE_SUBSCRIPTION_MUTATION, variable_values={'input': input_data}, @@ -201,15 +290,24 @@ def test_change_active_subscription(self, graphene_client, subscription_schedule assert "errors" not in executed def test_change_when_user_has_no_payment_method_but_can_activate_trial( - self, graphene_client, customer_factory, subscription_schedule_factory, free_plan_price, monthly_plan_price + self, + graphene_client, + customer_factory, + subscription_schedule_factory, + free_plan_price, + monthly_plan_price, + user_factory, + tenant_membership_factory, ): - input_data = {'price': monthly_plan_price.id} customer = customer_factory(default_payment_method=None) - user = customer.subscriber + tenant = customer.subscriber + input_data = {'price': monthly_plan_price.id, "tenantId": to_global_id("TenantType", tenant.pk)} + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) subscription_schedule_factory(customer=customer, phases=[{'items': [{'price': free_plan_price.id}]}]) djstripe_models.PaymentMethod.objects.filter(customer=customer).delete() graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.CHANGE_ACTIVE_SUBSCRIPTION_MUTATION, variable_values={'input': input_data}, @@ -219,17 +317,25 @@ def test_change_when_user_has_no_payment_method_but_can_activate_trial( assert "errors" not in executed def test_return_error_on_change_if_customer_has_no_payment_method( - self, graphene_client, customer_factory, monthly_plan_price, subscription_schedule_factory + self, + graphene_client, + customer_factory, + monthly_plan_price, + subscription_schedule_factory, + user_factory, + tenant_membership_factory, ): - input_data = {'price': monthly_plan_price.id} customer = customer_factory(default_payment_method=None) - user = customer.subscriber + tenant = customer.subscriber + input_data = {'price': monthly_plan_price.id, "tenantId": to_global_id("TenantType", tenant.pk)} + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) subscription_schedule_factory( customer=customer, phases=[{'items': [{'price': monthly_plan_price.id}], 'trial_completed': True}] ) djstripe_models.PaymentMethod.objects.filter(customer=customer).delete() graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.CHANGE_ACTIVE_SUBSCRIPTION_MUTATION, variable_values={'input': input_data}, @@ -254,21 +360,32 @@ class TestCancelActiveSubscriptionMutation: } ''' - def test_return_error_for_unauthorized_user(self, graphene_client): + @staticmethod + def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER): + user = user_factory() + tenant_membership_factory(tenant=tenant, role=role, user=user) + return user + + def test_return_error_for_unauthorized_user(self, graphene_client, tenant): executed = graphene_client.mutate( self.CANCEL_ACTIVE_SUBSCRIPTION_MUTATION, - variable_values={'input': {}}, + variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}}, ) assert executed["errors"] assert executed["errors"][0]["message"] == "permission_denied" - def test_return_error_if_customer_has_no_paid_subscription(self, graphene_client, subscription_schedule): - graphene_client.force_authenticate(subscription_schedule.customer.subscriber) + def test_return_error_if_customer_has_no_paid_subscription( + self, graphene_client, subscription_schedule, user_factory, tenant_membership_factory + ): + tenant = subscription_schedule.customer.subscriber + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.CANCEL_ACTIVE_SUBSCRIPTION_MUTATION, - variable_values={'input': {}}, + variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}}, ) assert len(executed["errors"]) == 1 @@ -278,15 +395,51 @@ def test_return_error_if_customer_has_no_paid_subscription(self, graphene_client {'message': 'Customer has no paid subscription to cancel', 'code': 'no_paid_subscription'} ] - def test_cancel_trialing_subscription(self, graphene_client, subscription_schedule_factory, monthly_plan_price): + def test_cancel_trialing_subscription_by_admin( + self, + graphene_client, + subscription_schedule_factory, + monthly_plan_price, + user_factory, + tenant_membership_factory, + ): + subscription_schedule = subscription_schedule_factory( + phases=[{'items': [{'price': monthly_plan_price.id}], 'trialing': True}] + ) + tenant = subscription_schedule.customer.subscriber + user = self._get_user_from_customer_tenant( + tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN + ) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + + executed = graphene_client.mutate( + self.CANCEL_ACTIVE_SUBSCRIPTION_MUTATION, + variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}}, + ) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_cancel_trialing_subscription( + self, + graphene_client, + subscription_schedule_factory, + monthly_plan_price, + user_factory, + tenant_membership_factory, + ): subscription_schedule = subscription_schedule_factory( phases=[{'items': [{'price': monthly_plan_price.id}], 'trialing': True}] ) - graphene_client.force_authenticate(subscription_schedule.customer.subscriber) + tenant = subscription_schedule.customer.subscriber + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.CANCEL_ACTIVE_SUBSCRIPTION_MUTATION, - variable_values={'input': {}}, + variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}}, ) assert executed['data'] @@ -295,8 +448,8 @@ def test_cancel_trialing_subscription(self, graphene_client, subscription_schedu class TestAllPaymentMethodsQuery: ALL_PAYMENT_METHODS_QUERY = ''' - query { - allPaymentMethods { + query($tenantId: ID) { + allPaymentMethods(tenantId: $tenantId) { edges { node { pk @@ -306,18 +459,52 @@ class TestAllPaymentMethodsQuery: } ''' - def test_return_error_for_unauthorized_user(self, graphene_client): - executed = graphene_client.query(self.ALL_PAYMENT_METHODS_QUERY) + @staticmethod + def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER): + user = user_factory() + tenant_membership_factory(tenant=tenant, role=role, user=user) + return user + + def test_return_error_for_unauthorized_user(self, graphene_client, tenant): + executed = graphene_client.query( + self.ALL_PAYMENT_METHODS_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)} + ) assert executed["errors"] assert executed["errors"][0]["message"] == "permission_denied" - def test_return_only_customer_payment_methods(self, graphene_client, customer, payment_method_factory): + def test_return_only_customer_payment_methods_by_admin( + self, graphene_client, customer, payment_method_factory, user_factory, tenant_membership_factory + ): + payment_method_factory(customer=customer) + payment_method_factory() + tenant = customer.subscriber + user = self._get_user_from_customer_tenant( + tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN + ) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = graphene_client.query( + self.ALL_PAYMENT_METHODS_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)} + ) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_return_only_customer_payment_methods( + self, graphene_client, customer, payment_method_factory, user_factory, tenant_membership_factory + ): payment_method = payment_method_factory(customer=customer) other_customer_payment_method = payment_method_factory() + tenant = customer.subscriber + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) - graphene_client.force_authenticate(customer.subscriber) - executed = graphene_client.query(self.ALL_PAYMENT_METHODS_QUERY) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = graphene_client.query( + self.ALL_PAYMENT_METHODS_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)} + ) assert executed["data"] assert executed["data"]["allPaymentMethods"] @@ -343,21 +530,32 @@ class TestUpdateDefaultPaymentMethodMutation: } ''' - def test_return_error_for_unauthorized_user(self, graphene_client): + @staticmethod + def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER): + user = user_factory() + tenant_membership_factory(tenant=tenant, role=role, user=user) + return user + + def test_return_error_for_unauthorized_user(self, graphene_client, tenant): executed = graphene_client.mutate( self.UPDATE_DEFAULT_PAYMENT_METHOD_MUTATION, - variable_values={'input': {}}, + variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}}, ) assert executed["errors"] assert executed["errors"][0]["message"] == "permission_denied" - def test_fetch_unknown_payment_method_from_stripe(self, graphene_client, stripe_request, payment_method_factory): + def test_fetch_unknown_payment_method_from_stripe( + self, graphene_client, stripe_request, payment_method_factory, user_factory, tenant_membership_factory + ): other_users_pm = payment_method_factory() payment_method = payment_method_factory() - input_data = {"id": other_users_pm.id} + tenant = payment_method.customer.subscriber + input_data = {"id": other_users_pm.id, "tenantId": to_global_id("TenantType", tenant.pk)} + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) - graphene_client.force_authenticate(payment_method.customer.subscriber) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.UPDATE_DEFAULT_PAYMENT_METHOD_MUTATION, variable_values={'input': input_data}, @@ -369,11 +567,36 @@ def test_fetch_unknown_payment_method_from_stripe(self, graphene_client, stripe_ 'get', calleee.EndsWith(f'/payment_methods/{other_users_pm.id}'), calleee.Any(), None ) - def test_set_default_payment_method(self, graphene_client, payment_method_factory, customer, stripe_request): + def test_set_default_payment_method_by_admin( + self, graphene_client, payment_method_factory, customer, user_factory, tenant_membership_factory + ): payment_method = payment_method_factory(customer=customer) - input_data = {"id": payment_method.id} + tenant = payment_method.customer.subscriber + input_data = {"id": payment_method.id, "tenantId": to_global_id("TenantType", tenant.pk)} + user = self._get_user_from_customer_tenant( + tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN + ) - graphene_client.force_authenticate(payment_method.customer.subscriber) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = graphene_client.mutate( + self.UPDATE_DEFAULT_PAYMENT_METHOD_MUTATION, + variable_values={'input': input_data}, + ) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_set_default_payment_method( + self, graphene_client, payment_method_factory, customer, stripe_request, user_factory, tenant_membership_factory + ): + payment_method = payment_method_factory(customer=customer) + tenant = payment_method.customer.subscriber + input_data = {"id": payment_method.id, "tenantId": to_global_id("TenantType", tenant.pk)} + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.UPDATE_DEFAULT_PAYMENT_METHOD_MUTATION, variable_values={'input': input_data}, @@ -401,12 +624,23 @@ class TestDeletePaymentMethodMutation: } ''' - def test_return_error_for_other_users_payment_method(self, graphene_client, stripe_request, payment_method_factory): + @staticmethod + def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER): + user = user_factory() + tenant_membership_factory(tenant=tenant, role=role, user=user) + return user + + def test_return_error_for_other_users_payment_method( + self, graphene_client, stripe_request, payment_method_factory, user_factory, tenant_membership_factory + ): other_users_pm = payment_method_factory() payment_method = payment_method_factory() - input_data = {"id": other_users_pm.id} + tenant = payment_method.customer.subscriber + input_data = {"id": other_users_pm.id, "tenantId": to_global_id("TenantType", tenant.pk)} + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) - graphene_client.force_authenticate(payment_method.customer.subscriber) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.DELETE_PAYMENT_METHOD_MUTATION, variable_values={'input': input_data}, @@ -418,12 +652,36 @@ def test_return_error_for_other_users_payment_method(self, graphene_client, stri 'get', calleee.EndsWith(f'/payment_methods/{other_users_pm.id}'), calleee.Any(), None ) - def test_detach_payment_method(self, graphene_client, stripe_request, payment_method): + def test_detach_payment_method_by_admin( + self, graphene_client, payment_method, user_factory, tenant_membership_factory + ): + tenant = payment_method.customer.subscriber + input_data = {"id": payment_method.id, "tenantId": to_global_id("TenantType", tenant.pk)} + user = self._get_user_from_customer_tenant( + tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN + ) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = graphene_client.mutate( + self.DELETE_PAYMENT_METHOD_MUTATION, + variable_values={'input': input_data}, + ) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_detach_payment_method( + self, graphene_client, stripe_request, payment_method, user_factory, tenant_membership_factory + ): customer = payment_method.customer payment_method_global_id = to_global_id('StripePaymentMethodType', str(payment_method.djstripe_id)) - input_data = {"id": payment_method.id} + tenant = payment_method.customer.subscriber + input_data = {"id": payment_method.id, "tenantId": to_global_id("TenantType", tenant.pk)} + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) - graphene_client.force_authenticate(customer.subscriber) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.DELETE_PAYMENT_METHOD_MUTATION, variable_values={'input': input_data}, @@ -440,15 +698,18 @@ def test_detach_payment_method(self, graphene_client, stripe_request, payment_me ) def test_set_default_payment_method_to_next_one( - self, graphene_client, stripe_request, customer, payment_method_factory + self, graphene_client, stripe_request, customer, payment_method_factory, user_factory, tenant_membership_factory ): payment_method = payment_method_factory(customer=customer) - input_data = {"id": payment_method.id} customer.default_payment_method = payment_method customer.save() other_payment_method = payment_method_factory(customer=customer) + tenant = payment_method.customer.subscriber + input_data = {"id": payment_method.id, "tenantId": to_global_id("TenantType", tenant.pk)} + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) - graphene_client.force_authenticate(customer.subscriber) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.DELETE_PAYMENT_METHOD_MUTATION, variable_values={'input': input_data}, @@ -473,8 +734,8 @@ def test_set_default_payment_method_to_next_one( class TestAllChargesQuery: ALL_CHARGES_QUERY = ''' - query { - allCharges { + query($tenantId: ID) { + allCharges(tenantId: $tenantId) { edges { node { pk @@ -484,18 +745,50 @@ class TestAllChargesQuery: } ''' - def test_return_error_for_unauthorized_user(self, graphene_client): - executed = graphene_client.query(self.ALL_CHARGES_QUERY) + @staticmethod + def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER): + user = user_factory() + tenant_membership_factory(tenant=tenant, role=role, user=user) + return user + + def test_return_error_for_unauthorized_user(self, graphene_client, tenant): + executed = graphene_client.query( + self.ALL_CHARGES_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)} + ) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_return_only_customer_charges_by_admin( + self, graphene_client, customer, user_factory, tenant_membership_factory + ): + tenant = customer.subscriber + user = self._get_user_from_customer_tenant( + tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN + ) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = graphene_client.query( + self.ALL_CHARGES_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)} + ) assert executed["errors"] assert executed["errors"][0]["message"] == "permission_denied" - def test_return_only_customer_charges(self, graphene_client, customer, charge_factory): + def test_return_only_customer_charges( + self, graphene_client, customer, charge_factory, user_factory, tenant_membership_factory + ): other_customer_charge = charge_factory() regular_charge = charge_factory(customer=customer) + tenant = customer.subscriber + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) - graphene_client.force_authenticate(customer.subscriber) - executed = graphene_client.query(self.ALL_CHARGES_QUERY) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = graphene_client.query( + self.ALL_CHARGES_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)} + ) assert executed["data"] assert executed["data"]["allCharges"] @@ -506,13 +799,20 @@ def test_return_only_customer_charges(self, graphene_client, customer, charge_fa assert regular_charge.id in charge_ids assert other_customer_charge.id not in charge_ids - def test_return_charges_ordered_by_creation_date_descending(self, graphene_client, customer, charge_factory): + def test_return_charges_ordered_by_creation_date_descending( + self, graphene_client, customer, charge_factory, user_factory, tenant_membership_factory + ): old_charge = charge_factory(customer=customer, created=timezone.now() - timedelta(days=1)) oldest_charge = charge_factory(customer=customer, created=timezone.now() - timedelta(days=2)) new_charge = charge_factory(customer=customer, created=timezone.now()) + tenant = customer.subscriber + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) - graphene_client.force_authenticate(customer.subscriber) - executed = graphene_client.query(self.ALL_CHARGES_QUERY) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = graphene_client.query( + self.ALL_CHARGES_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)} + ) assert executed["data"] assert executed["data"]["allCharges"] @@ -525,8 +825,8 @@ def test_return_charges_ordered_by_creation_date_descending(self, graphene_clien class TestChargeQuery: CHARGE_QUERY = ''' - query getCharge($id: ID!) { - charge(id: $id) { + query getCharge($id: ID!, $tenantId: ID) { + charge(id: $id, tenantId: $tenantId) { id amount billingDetails @@ -543,21 +843,51 @@ class TestChargeQuery: } ''' - def test_return_error_for_unauthorized_user(self, graphene_client, customer, charge_factory): + @staticmethod + def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER): + user = user_factory() + tenant_membership_factory(tenant=tenant, role=role, user=user) + return user + + def test_return_error_for_unauthorized_user(self, graphene_client, customer, charge_factory, tenant): charge = charge_factory(customer=customer) - variable_values = {"id": to_global_id('StripeChargeType', str(charge.pk))} + variable_values = { + "id": to_global_id('StripeChargeType', str(charge.pk)), + "tenantId": to_global_id("TenantType", tenant.id), + } executed = graphene_client.query(self.CHARGE_QUERY, variable_values=variable_values) assert executed["errors"] assert executed["errors"][0]["message"] == "permission_denied" - def test_return_charge(self, graphene_client, customer, charge_factory): + def test_return_charge_by_admin( + self, graphene_client, customer, charge_factory, user_factory, tenant_membership_factory + ): charge = charge_factory(customer=customer) charge_global_id = to_global_id('StripeChargeType', str(charge.pk)) - variable_values = {"id": charge_global_id} + tenant = customer.subscriber + variable_values = {"id": charge_global_id, "tenantId": to_global_id("TenantType", tenant.id)} + user = self._get_user_from_customer_tenant( + tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN + ) - graphene_client.force_authenticate(customer.subscriber) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = graphene_client.query(self.CHARGE_QUERY, variable_values=variable_values) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_return_charge(self, graphene_client, customer, charge_factory, user_factory, tenant_membership_factory): + charge = charge_factory(customer=customer) + charge_global_id = to_global_id('StripeChargeType', str(charge.pk)) + tenant = customer.subscriber + variable_values = {"id": charge_global_id, "tenantId": to_global_id("TenantType", tenant.id)} + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.query(self.CHARGE_QUERY, variable_values=variable_values) assert executed["data"] @@ -590,8 +920,8 @@ def test_return_charge(self, graphene_client, customer, charge_factory): class TestPaymentIntentQuery: PAYMENT_INTENT_QUERY = ''' - query getPaymentIntent($id: ID!) { - paymentIntent(id: $id) { + query getPaymentIntent($id: ID!, $tenantId: ID) { + paymentIntent(id: $id, tenantId: $tenantId) { id amount currency @@ -600,32 +930,69 @@ class TestPaymentIntentQuery: } ''' - def test_return_error_for_unauthorized_user(self, graphene_client, customer, payment_intent_factory): + @staticmethod + def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER): + user = user_factory() + tenant_membership_factory(tenant=tenant, role=role, user=user) + return user + + def test_return_error_for_unauthorized_user(self, graphene_client, customer, payment_intent_factory, tenant): payment_intent = payment_intent_factory(customer=customer) - variable_values = {"id": to_global_id('StripePaymentIntentType', str(payment_intent.pk))} + variable_values = { + "id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)), + "tenantId": to_global_id("TenantType", tenant.id), + } executed = graphene_client.query(self.PAYMENT_INTENT_QUERY, variable_values=variable_values) assert executed["errors"] assert executed["errors"][0]["message"] == "permission_denied" - def test_return_error_if_not_users_payment_intent(self, graphene_client, customer, payment_intent_factory): + def test_return_error_if_not_users_payment_intent( + self, graphene_client, customer, payment_intent_factory, user_factory, tenant_membership_factory + ): payment_intent = payment_intent_factory() payment_intent_global_id = to_global_id('StripePaymentIntentType', str(payment_intent.pk)) - variable_values = {"id": payment_intent_global_id} + tenant = customer.subscriber + variable_values = {"id": payment_intent_global_id, "tenantId": to_global_id("TenantType", tenant.id)} + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) - graphene_client.force_authenticate(customer.subscriber) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.query(self.PAYMENT_INTENT_QUERY, variable_values=variable_values) assert executed["errors"] assert executed["errors"][0]["message"] == "PaymentIntent matching query does not exist." - def test_return_payment_intent(self, graphene_client, customer, payment_intent_factory): + def test_return_payment_intent_by_admin( + self, graphene_client, customer, payment_intent_factory, user_factory, tenant_membership_factory + ): + payment_intent = payment_intent_factory(customer=customer) + payment_intent_global_id = to_global_id('StripePaymentIntentType', str(payment_intent.pk)) + tenant = customer.subscriber + variable_values = {"id": payment_intent_global_id, "tenantId": to_global_id("TenantType", tenant.id)} + user = self._get_user_from_customer_tenant( + tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN + ) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = graphene_client.query(self.PAYMENT_INTENT_QUERY, variable_values=variable_values) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_return_payment_intent( + self, graphene_client, customer, payment_intent_factory, user_factory, tenant_membership_factory + ): payment_intent = payment_intent_factory(customer=customer) payment_intent_global_id = to_global_id('StripePaymentIntentType', str(payment_intent.pk)) - variable_values = {"id": payment_intent_global_id} + tenant = customer.subscriber + variable_values = {"id": payment_intent_global_id, "tenantId": to_global_id("TenantType", tenant.id)} + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) - graphene_client.force_authenticate(customer.subscriber) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.query(self.PAYMENT_INTENT_QUERY, variable_values=variable_values) assert executed["data"] @@ -652,8 +1019,14 @@ class TestCreatePaymentIntentMutation: } ''' - def test_return_error_for_unauthorized_user(self, graphene_client): - input_data = {"product": "A"} + @staticmethod + def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER): + user = user_factory() + tenant_membership_factory(tenant=tenant, role=role, user=user) + return user + + def test_return_error_for_unauthorized_user(self, graphene_client, tenant): + input_data = {"product": "A", "tenantId": to_global_id("TenantType", tenant.pk)} executed = graphene_client.mutate( self.CREATE_PAYMENT_INTENT_MUTATION, @@ -663,8 +1036,15 @@ def test_return_error_for_unauthorized_user(self, graphene_client): assert executed["errors"] assert executed["errors"][0]["message"] == "permission_denied" - def test_return_error_if_product_is_not_passed(self, graphene_client, user): - input_data = {} + def test_return_error_if_product_is_not_passed( + self, graphene_client, user_factory, tenant, tenant_membership_factory + ): + input_data = {"tenantId": to_global_id("TenantType", tenant.pk)} + + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) graphene_client.force_authenticate(user) executed = graphene_client.mutate( @@ -673,14 +1053,17 @@ def test_return_error_if_product_is_not_passed(self, graphene_client, user): ) assert executed["errors"] - assert executed["errors"][0]["message"] == ( - "Variable '$input' got invalid value {}; Field 'product' of required type 'String!' was not provided." - ) + assert "Field 'product' of required type 'String!' was not provided." in executed["errors"][0]["message"] - def test_return_error_if_product_does_not_exist(self, graphene_client, user): - input_data = {"product": "A"} + def test_return_error_if_product_does_not_exist( + self, graphene_client, user_factory, tenant, tenant_membership_factory + ): + input_data = {"product": "A", "tenantId": to_global_id("TenantType", tenant.pk)} + + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.CREATE_PAYMENT_INTENT_MUTATION, variable_values={'input': input_data}, @@ -691,10 +1074,30 @@ def test_return_error_if_product_does_not_exist(self, graphene_client, user): assert error["path"] == ["createPaymentIntent"] assert error["extensions"]["product"] == [{'message': '"A" is not a valid choice.', 'code': 'invalid_choice'}] - def test_creates_payment_intent(self, graphene_client, user): - input_data = {"product": "5"} + def test_creates_payment_intent_by_admin(self, graphene_client, user_factory, tenant, tenant_membership_factory): + input_data = {"product": "5", "tenantId": to_global_id("TenantType", tenant.pk)} + + user = self._get_user_from_customer_tenant( + tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN + ) graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = graphene_client.mutate( + self.CREATE_PAYMENT_INTENT_MUTATION, + variable_values={'input': input_data}, + ) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_creates_payment_intent(self, graphene_client, user_factory, tenant, tenant_membership_factory): + input_data = {"product": "5", "tenantId": to_global_id("TenantType", tenant.pk)} + + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.CREATE_PAYMENT_INTENT_MUTATION, variable_values={'input': input_data}, @@ -726,9 +1129,19 @@ class TestUpdatePaymentIntentMutation: } ''' - def test_return_error_for_unauthorized_user(self, graphene_client, payment_intent_factory): + @staticmethod + def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER): + user = user_factory() + tenant_membership_factory(tenant=tenant, role=role, user=user) + return user + + def test_return_error_for_unauthorized_user(self, graphene_client, payment_intent_factory, tenant): payment_intent = payment_intent_factory(amount=50) - input_data = {"id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)), "product": "10"} + input_data = { + "id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)), + "product": "10", + "tenantId": to_global_id("TenantType", tenant.pk), + } executed = graphene_client.mutate( self.UPDATE_PAYMENT_INTENT_MUTATION, @@ -738,11 +1151,20 @@ def test_return_error_for_unauthorized_user(self, graphene_client, payment_inten assert executed["errors"] assert executed["errors"][0]["message"] == "permission_denied" - def test_return_error_if_product_belongs_to_other_user(self, graphene_client, payment_intent_factory, customer): + def test_return_error_if_product_belongs_to_other_user( + self, graphene_client, payment_intent_factory, customer, user_factory, tenant_membership_factory + ): payment_intent = payment_intent_factory(amount=50) - input_data = {"id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)), "product": "10"} + tenant = customer.subscriber + input_data = { + "id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)), + "product": "10", + "tenantId": to_global_id("TenantType", tenant.pk), + } + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) - graphene_client.force_authenticate(customer.subscriber) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.UPDATE_PAYMENT_INTENT_MUTATION, variable_values={'input': input_data}, @@ -751,11 +1173,44 @@ def test_return_error_if_product_belongs_to_other_user(self, graphene_client, pa assert executed["errors"] assert executed["errors"][0]["message"] == "No PaymentIntent matches the given query." - def test_update_payment_intent_amount(self, graphene_client, payment_intent_factory, customer): + def test_update_payment_intent_amount_by_admin( + self, graphene_client, payment_intent_factory, customer, user_factory, tenant_membership_factory + ): payment_intent = payment_intent_factory(amount=50, customer=customer) - input_data = {"id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)), "product": "10"} + tenant = customer.subscriber + input_data = { + "id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)), + "product": "10", + "tenantId": to_global_id("TenantType", tenant.pk), + } + user = self._get_user_from_customer_tenant( + tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN + ) - graphene_client.force_authenticate(customer.subscriber) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = graphene_client.mutate( + self.UPDATE_PAYMENT_INTENT_MUTATION, + variable_values={'input': input_data}, + ) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_update_payment_intent_amount( + self, graphene_client, payment_intent_factory, customer, user_factory, tenant_membership_factory + ): + payment_intent = payment_intent_factory(amount=50, customer=customer) + tenant = customer.subscriber + input_data = { + "id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)), + "product": "10", + "tenantId": to_global_id("TenantType", tenant.pk), + } + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.UPDATE_PAYMENT_INTENT_MUTATION, variable_values={'input': input_data}, @@ -781,20 +1236,44 @@ class TestCreateSetupIntentMutation: } ''' - def test_return_error_for_unauthorized_user(self, graphene_client): + @staticmethod + def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER): + user = user_factory() + tenant_membership_factory(tenant=tenant, role=role, user=user) + return user + + def test_return_error_for_unauthorized_user(self, graphene_client, tenant): executed = graphene_client.mutate( self.CREATE_PAYMENT_INTENT_MUTATION, - variable_values={'input': {}}, + variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}}, ) assert executed["errors"] assert executed["errors"][0]["message"] == "permission_denied" - def test_creates_payment_intent(self, graphene_client, user): + def test_creates_payment_intent_by_admin(self, graphene_client, user_factory, tenant, tenant_membership_factory): + user = self._get_user_from_customer_tenant( + tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN + ) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = graphene_client.mutate( + self.CREATE_PAYMENT_INTENT_MUTATION, + variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}}, + ) + + assert executed["errors"] + assert executed["errors"][0]["message"] == "permission_denied" + + def test_creates_payment_intent(self, graphene_client, user_factory, tenant, tenant_membership_factory): + user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) executed = graphene_client.mutate( self.CREATE_PAYMENT_INTENT_MUTATION, - variable_values={'input': {}}, + variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}}, ) assert executed['data']['createSetupIntent'] diff --git a/packages/backend/apps/multitenancy/__init__.py b/packages/backend/apps/multitenancy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/backend/apps/multitenancy/admin.py b/packages/backend/apps/multitenancy/admin.py new file mode 100644 index 000000000..70cb87bc6 --- /dev/null +++ b/packages/backend/apps/multitenancy/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin + +from . import models + + +@admin.register(models.Tenant) +class TenantAdmin(admin.ModelAdmin): + list_display = ("id", "name", "type") + + +@admin.register(models.TenantMembership) +class TenantMembershipAdmin(admin.ModelAdmin): + list_display = ("id", "role", "user", "invitee_email_address", "tenant", "is_accepted") diff --git a/packages/backend/apps/multitenancy/apps.py b/packages/backend/apps/multitenancy/apps.py new file mode 100644 index 000000000..c832d5184 --- /dev/null +++ b/packages/backend/apps/multitenancy/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MultitenancyConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.multitenancy' diff --git a/packages/backend/apps/multitenancy/constants.py b/packages/backend/apps/multitenancy/constants.py new file mode 100644 index 000000000..1fcf714d6 --- /dev/null +++ b/packages/backend/apps/multitenancy/constants.py @@ -0,0 +1,32 @@ +from enum import Enum +from django.db import models + + +class TenantType(models.TextChoices): + """ + DEFAULT is a tenant type created during user sign-up. + It serves as the default tenant for them, ensuring that they always have at least one. + ORGANIZATION is a tenant type for tenants created manually by the user for the purpose of inviting other members. + """ + + DEFAULT = "default", "Default" + ORGANIZATION = "organization", "Organization" + + +class TenantUserRole(models.TextChoices): + """ + Predefined tenant user roles: + - OWNER + - ADMIN + - MEMBER + """ + + OWNER = "OWNER", "Owner" + ADMIN = "ADMIN", "Administrator" + MEMBER = "MEMBER", "Member" + + +class Notification(Enum): + TENANT_INVITATION_CREATED = "TENANT_INVITATION_CREATED" + TENANT_INVITATION_ACCEPTED = "TENANT_INVITATION_ACCEPTED" + TENANT_INVITATION_DECLINED = "TENANT_INVITATION_DECLINED" diff --git a/packages/backend/apps/multitenancy/email_serializers.py b/packages/backend/apps/multitenancy/email_serializers.py new file mode 100644 index 000000000..0f0491d2f --- /dev/null +++ b/packages/backend/apps/multitenancy/email_serializers.py @@ -0,0 +1,6 @@ +from rest_framework import serializers + + +class TenantInvitationEmailSerializer(serializers.Serializer): + token = serializers.CharField() + tenant_membership_id = serializers.CharField() diff --git a/packages/backend/apps/multitenancy/managers.py b/packages/backend/apps/multitenancy/managers.py new file mode 100644 index 000000000..734b6ed65 --- /dev/null +++ b/packages/backend/apps/multitenancy/managers.py @@ -0,0 +1,58 @@ +from django.db import models + +from .constants import TenantType, TenantUserRole + + +class TenantManager(models.Manager): + def get_or_create_user_default_tenant(self, user): + """ + Description: + Retrieves or creates a default tenant for a given user, ensuring that there is always at least one tenant + instance associated with them. + + Parameters: + - user (User): The user for whom the tenant is retrieved or created. + + Returns: + Tenant: The associated or newly created tenant instance of SIGN_UP type. + """ + default_tenant = self.filter(creator=user, type=TenantType.DEFAULT).order_by('created_at').first() + if default_tenant: + return default_tenant, False + + new_tenant = self.create(creator=user, type=TenantType.DEFAULT, name=str(user)) + new_tenant.members.add( + user, through_defaults={"tenant": new_tenant, "role": TenantUserRole.OWNER, "is_accepted": True} + ) + + return new_tenant, True + + +class TenantMembershipManager(models.Manager): + def get_queryset(self): + """ + Overrides the default get_queryset function to exclude invitations from the queryset. + """ + return super().get_queryset().filter(is_accepted=True) + + def get_not_accepted(self): + """ + Retrieves not accepted tenant invitations. + """ + return super().get_queryset().filter(is_accepted=False) + + def get_all(self, **kwargs): + """ + Retrieves all tenants memberships. + """ + return super().get_queryset(**kwargs) + + def associate_invitations_with_user(self, email, user): + """ + Associates not accepted tenant invitations with a user. + + This method finds not accepted tenant invitations with the specified email address + and associates them with the provided user. + """ + invitations = self.get_not_accepted().filter(invitee_email_address=email) + invitations.update(user=user) diff --git a/packages/backend/apps/multitenancy/middleware.py b/packages/backend/apps/multitenancy/middleware.py new file mode 100644 index 000000000..f654e6b88 --- /dev/null +++ b/packages/backend/apps/multitenancy/middleware.py @@ -0,0 +1,82 @@ +from graphql_relay import from_global_id +from django.utils.functional import SimpleLazyObject +from .models import Tenant, TenantMembership + + +def get_current_tenant(tenant_id): + """ + Retrieve the current tenant based on the provided tenant ID. + + Args: + tenant_id (str): The global ID of the tenant. + + Returns: + Tenant or None: The retrieved tenant or None if not found. + """ + try: + return Tenant.objects.get(pk=tenant_id) + except (Tenant.DoesNotExist, TypeError): + return None + + +def get_current_user_role(tenant, user): + """ + Retrieve the user role within the specified tenant. + + Args: + tenant (Tenant): The current tenant. + user (User): The user for whom the role is to be retrieved. + + Returns: + str or None: The user role or None if not found or invalid conditions. + """ + if user and user.is_authenticated and tenant: + try: + membership = TenantMembership.objects.get(user=user, tenant=tenant) + return membership.role + except TenantMembership.DoesNotExist: + return None + + return None + + +class TenantUserRoleMiddleware(object): + """ + Middleware for resolving the current tenant and user role lazily. + + This middleware is responsible for setting the "tenant" and "user_role" attributes in the GraphQL execution context. + The actual retrieval of the current tenant and user role is deferred until the values are accessed. Lazy loading is + employed to optimize performance by loading these values only when necessary. + """ + + @staticmethod + def _get_tenant_id_from_arguments(args): + """ + Extract the tenant ID from GraphQL arguments. + + Args: + args (dict): GraphQL arguments. + + Returns: + str or None: The extracted tenant ID or None if not found. + """ + request_input = args.get("input") + + tenant_id = ( + request_input.get("tenant_id") or request_input.get("id") + if request_input + else args.get("tenant_id") or args.get("id") + ) + + if tenant_id: + id_type, pk = from_global_id(tenant_id) + if id_type == "TenantType": + return pk + return None + + def resolve(self, next, root, info, **args): + if not hasattr(info.context, "tenant_id"): + info.context.tenant_id = self._get_tenant_id_from_arguments(args) + info.context.tenant = SimpleLazyObject(lambda: get_current_tenant(info.context.tenant_id)) + info.context.user_role = SimpleLazyObject(lambda: get_current_user_role(info.context.tenant, info.context.user)) + return next(root, info, **args) diff --git a/packages/backend/apps/multitenancy/migrations/0001_initial.py b/packages/backend/apps/multitenancy/migrations/0001_initial.py new file mode 100644 index 000000000..2685e615a --- /dev/null +++ b/packages/backend/apps/multitenancy/migrations/0001_initial.py @@ -0,0 +1,84 @@ +# Generated by Django 4.2 on 2024-02-14 11:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import hashid_field.field + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Tenant', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ( + 'id', + hashid_field.field.HashidAutoField( + alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', + min_length=7, + prefix='', + primary_key=True, + serialize=False, + ), + ), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('type', models.CharField(choices=[('default', 'Default'), ('organization', 'Organization')])), + ( + 'creator', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TenantMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ( + 'role', + models.CharField( + choices=[('owner', 'Owner'), ('admin', 'Administrator'), ('member', 'Member')], default='owner' + ), + ), + ( + 'tenant', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='user_memberships', + to='multitenancy.tenant', + ), + ), + ( + 'user', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='tenant_memberships', + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'unique_together': {('user', 'tenant')}, + }, + ), + migrations.AddField( + model_name='tenant', + name='members', + field=models.ManyToManyField( + blank=True, related_name='tenants', through='multitenancy.TenantMembership', to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/packages/backend/apps/multitenancy/migrations/0002_alter_tenant_name.py b/packages/backend/apps/multitenancy/migrations/0002_alter_tenant_name.py new file mode 100644 index 000000000..309b30344 --- /dev/null +++ b/packages/backend/apps/multitenancy/migrations/0002_alter_tenant_name.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2 on 2024-02-16 08:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('multitenancy', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='tenant', + name='name', + field=models.CharField(max_length=100), + ), + ] diff --git a/packages/backend/apps/multitenancy/migrations/0003_alter_tenantmembership_unique_together_and_more.py b/packages/backend/apps/multitenancy/migrations/0003_alter_tenantmembership_unique_together_and_more.py new file mode 100644 index 000000000..a14a535be --- /dev/null +++ b/packages/backend/apps/multitenancy/migrations/0003_alter_tenantmembership_unique_together_and_more.py @@ -0,0 +1,74 @@ +# Generated by Django 4.2 on 2024-03-06 12:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import hashid_field.field + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('multitenancy', '0002_alter_tenant_name'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='tenantmembership', + unique_together=set(), + ), + migrations.AddField( + model_name='tenantmembership', + name='invitation_accepted_at', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='tenantmembership', + name='invitee_email_address', + field=models.EmailField( + db_collation='case_insensitive', default='', max_length=255, verbose_name='invitee email address' + ), + ), + migrations.AddField( + model_name='tenantmembership', + name='is_accepted', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='tenantmembership', + name='id', + field=hashid_field.field.HashidAutoField( + alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', + min_length=7, + prefix='', + primary_key=True, + serialize=False, + ), + ), + migrations.AlterField( + model_name='tenantmembership', + name='user', + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='tenant_memberships', + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddConstraint( + model_name='tenantmembership', + constraint=models.UniqueConstraint( + condition=models.Q(('user__isnull', False)), + fields=('user', 'tenant'), + name='unique_non_null_user_and_tenant', + ), + ), + migrations.AddConstraint( + model_name='tenantmembership', + constraint=models.UniqueConstraint( + condition=models.Q(('invitee_email_address__exact', ''), _negated=True), + fields=('invitee_email_address', 'tenant'), + name='unique_non_null_user_and_invitee_email_address', + ), + ), + ] diff --git a/packages/backend/apps/multitenancy/migrations/0004_auto_20240318_1003.py b/packages/backend/apps/multitenancy/migrations/0004_auto_20240318_1003.py new file mode 100644 index 000000000..600f9c5cf --- /dev/null +++ b/packages/backend/apps/multitenancy/migrations/0004_auto_20240318_1003.py @@ -0,0 +1,79 @@ +from django.db import migrations +from django.utils.text import slugify +from django.apps.registry import apps as global_apps + +from ..constants import TenantType, TenantUserRole + + +def move_user_to_tenant(apps, schema_editor): + Customer = apps.get_model('djstripe', 'Customer') + Tenant = apps.get_model('multitenancy', 'Tenant') + User = apps.get_model('users', 'User') + connection = schema_editor.connection + + for customer in Customer.objects.all(): + default_tenant = Tenant.objects.filter(type=TenantType.DEFAULT, creator_id=customer.subscriber_id).first() + if not default_tenant: + user = User.objects.filter(pk=customer.subscriber_id).first() + default_tenant = Tenant.objects.create( + creator_id=customer.subscriber_id, type=TenantType.DEFAULT, name=user.email, slug=slugify(user.email)) + default_tenant.members.add( + customer.subscriber_id, + through_defaults={"tenant": default_tenant, "role": TenantUserRole.OWNER, "is_accepted": True} + ) + tenant_id = default_tenant.id.id + customer_id = customer.id + with connection.cursor() as cursor: + cursor.execute( + "UPDATE djstripe_customer SET new_subscriber_id = %s WHERE id = %s", + [tenant_id, customer_id], + ) + + +def revert_move_user_to_tenant(apps, schema_editor): + Customer = global_apps.get_model('djstripe', 'Customer') + Tenant = apps.get_model('multitenancy', 'Tenant') + connection = schema_editor.connection + + for customer in Customer.objects.all(): + connected_tenant = Tenant.objects.filter(pk=customer.subscriber_id).first() + if connected_tenant and connected_tenant.type == TenantType.DEFAULT: + tenant_id = connected_tenant.creator_id.id + customer_id = customer.id + with connection.cursor() as cursor: + cursor.execute( + "UPDATE djstripe_customer SET new_subscriber_id = %s WHERE id = %s", + [tenant_id, customer_id], + ) + + +class Migration(migrations.Migration): + dependencies = [ + ('multitenancy', '0003_alter_tenantmembership_unique_together_and_more'), + ('djstripe', '0012_2_8'), + ] + + operations = [ + # SQL for adding the new_subscriber_id column with foreign key definition + migrations.RunSQL( + 'ALTER TABLE djstripe_customer ADD COLUMN new_subscriber_id integer REFERENCES multitenancy_tenant(id);', + reverse_sql=[ + 'ALTER TABLE djstripe_customer DROP COLUMN subscriber_id;', + 'ALTER TABLE djstripe_customer RENAME COLUMN new_subscriber_id TO subscriber_id;' + ], + ), + + # Python function to move user data to tenant + migrations.RunPython(move_user_to_tenant, revert_move_user_to_tenant), + + # SQL for removing the old subscriber_id column and renaming the new_subscriber_id column back to subscriber_id + migrations.RunSQL( + [ + 'ALTER TABLE djstripe_customer DROP COLUMN subscriber_id;', + 'ALTER TABLE djstripe_customer RENAME COLUMN new_subscriber_id TO subscriber_id;' + ], + reverse_sql=[ + 'ALTER TABLE djstripe_customer ADD COLUMN new_subscriber_id integer REFERENCES users_user(id);', + ] + ), + ] diff --git a/packages/backend/apps/multitenancy/migrations/0005_tenant_billing_email.py b/packages/backend/apps/multitenancy/migrations/0005_tenant_billing_email.py new file mode 100644 index 000000000..89dc4c2d0 --- /dev/null +++ b/packages/backend/apps/multitenancy/migrations/0005_tenant_billing_email.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2 on 2024-03-25 08:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('multitenancy', '0004_auto_20240318_1003'), + ] + + operations = [ + migrations.AddField( + model_name='tenant', + name='billing_email', + field=models.EmailField( + blank=True, db_collation='case_insensitive', max_length=255, verbose_name='billing email address' + ), + ), + ] diff --git a/packages/backend/apps/multitenancy/migrations/0006_alter_tenantmembership_role.py b/packages/backend/apps/multitenancy/migrations/0006_alter_tenantmembership_role.py new file mode 100644 index 000000000..0971d8d27 --- /dev/null +++ b/packages/backend/apps/multitenancy/migrations/0006_alter_tenantmembership_role.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2 on 2024-03-27 13:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('multitenancy', '0005_tenant_billing_email'), + ] + + operations = [ + migrations.AlterField( + model_name='tenantmembership', + name='role', + field=models.CharField( + choices=[('OWNER', 'Owner'), ('ADMIN', 'Administrator'), ('MEMBER', 'Member')], default='OWNER' + ), + ), + ] diff --git a/packages/backend/apps/multitenancy/migrations/0007_tenantmembership_creator.py b/packages/backend/apps/multitenancy/migrations/0007_tenantmembership_creator.py new file mode 100644 index 000000000..b936ff3f7 --- /dev/null +++ b/packages/backend/apps/multitenancy/migrations/0007_tenantmembership_creator.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2 on 2024-04-04 18:56 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('multitenancy', '0006_alter_tenantmembership_role'), + ] + + operations = [ + migrations.AddField( + model_name='tenantmembership', + name='creator', + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='created_tenant_memberships', + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/packages/backend/apps/multitenancy/migrations/__init__.py b/packages/backend/apps/multitenancy/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/backend/apps/multitenancy/models.py b/packages/backend/apps/multitenancy/models.py new file mode 100644 index 000000000..937d4dd69 --- /dev/null +++ b/packages/backend/apps/multitenancy/models.py @@ -0,0 +1,161 @@ +import hashid_field + +from django.db import models, IntegrityError, transaction +from django.conf import settings +from django.utils.text import slugify +from django.db.models import UniqueConstraint, Q + +from . import constants +from .managers import TenantManager, TenantMembershipManager +from common.models import TimestampedMixin + + +class Tenant(TimestampedMixin, models.Model): + """ + Represents a tenant within the application. + + Fields: + - id: A unique identifier for the tenant. + - creator: The user who created the tenant. + - name: The name of the tenant. + - slug: A URL-friendly version of the name. + - type: The type of the tenant. + - billing_email: Address used for billing purposes and it is provided to Stripe + - members: Many-to-many relationship with users through TenantMembership. + + Methods: + - save: Overrides the default save method to ensure unique slug generation based on the name field. + + Initialization: + - __original_name: Private attribute to track changes to the name field during the instance's lifecycle. + + Slug Generation: + - The save method ensures the generation of a unique slug for the tenant. If the name is modified or the slug is + not provided, it generates a slug based on the name. In case of a name collision, a counter is appended to the + base slug to ensure uniqueness. + """ + + id: str = hashid_field.HashidAutoField(primary_key=True) + creator: settings.AUTH_USER_MODEL = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + name: str = models.CharField(max_length=100, unique=False) + slug: str = models.SlugField(max_length=100, unique=True) + type: str = models.CharField(choices=constants.TenantType.choices) + members = models.ManyToManyField( + settings.AUTH_USER_MODEL, + through='TenantMembership', + related_name='tenants', + blank=True, + through_fields=('tenant', 'user'), + ) + billing_email = models.EmailField( + db_collation="case_insensitive", + verbose_name="billing email address", + max_length=255, + unique=False, + blank=True, + ) + + objects = TenantManager() + + MAX_SAVE_ATTEMPTS = 10 + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + counter = 0 + while counter < self.MAX_SAVE_ATTEMPTS: + try: + with transaction.atomic(): + if not counter: + self.slug = slugify(self.name) + else: + self.slug = f"{slugify(self.name)}-{counter}" + super().save(*args, **kwargs) + break + except IntegrityError as e: + if 'duplicate key' in str(e).lower(): + counter += 1 + else: + raise e + + @property + def email(self): + return self.billing_email if self.billing_email else self.creator.email + + @property + def owners_count(self): + """ + Calculate the total number of tenant owners for this tenant. + Returns the count of tenant owners. + """ + return self.members.filter(tenant_memberships__role=constants.TenantUserRole.OWNER).count() + + @property + def owners(self): + """ + Returns the list of Users with an owner role. + """ + return self.members.filter(tenant_memberships__role=constants.TenantUserRole.OWNER).all() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__original_name = self.name + + +class TenantMembership(TimestampedMixin, models.Model): + """ + Represents the membership of a user in a tenant. As well accepted as not accepted (invitations). + + Fields: + - id: A unique identifier for the membership. + - user: The user associated with the membership. + - role: The role of the user in the tenant. Can be owner, admin or member. + - tenant: The tenant to which the user belongs. + - is_accepted: Indicates whether the membership invitation is accepted. + - invitation_accepted_at: Timestamp when the invitation was accepted. + - invitee_email_address: The email address of the invited user if not connected to an existing user. + + Constraints: + - unique_non_null_user_and_tenant: Ensures the uniqueness of non-null user and tenant combinations. + - unique_non_null_user_and_invitee_email_address: Ensures the uniqueness of non-null user and invitee email address + combinations. + """ + + id: str = hashid_field.HashidAutoField(primary_key=True) + # User - Tenant connection fields + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="tenant_memberships", null=True + ) + creator: settings.AUTH_USER_MODEL = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="created_tenant_memberships" + ) + role = models.CharField(choices=constants.TenantUserRole.choices, default=constants.TenantUserRole.OWNER) + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name="user_memberships") + + # Invitation connected fields + is_accepted = models.BooleanField(default=False) + invitation_accepted_at = models.DateTimeField(null=True) + invitee_email_address = models.EmailField( + db_collation="case_insensitive", + verbose_name="invitee email address", + max_length=255, + default="", + ) + + objects = TenantMembershipManager() + + class Meta: + constraints = [ + UniqueConstraint( + name="unique_non_null_user_and_tenant", fields=["user", "tenant"], condition=Q(user__isnull=False) + ), + UniqueConstraint( + name="unique_non_null_user_and_invitee_email_address", + fields=["invitee_email_address", "tenant"], + condition=~Q(invitee_email_address__exact=""), + ), + ] + + def __str__(self): + return f"{self.user.email} {self.tenant.name} {self.role}" diff --git a/packages/backend/apps/multitenancy/notifications.py b/packages/backend/apps/multitenancy/notifications.py new file mode 100644 index 000000000..43e4dad02 --- /dev/null +++ b/packages/backend/apps/multitenancy/notifications.py @@ -0,0 +1,56 @@ +import logging + +from common import emails +from apps.notifications import sender +from . import constants +from . import models +from . import email_serializers + +logger = logging.getLogger(__name__) + + +class TenantInvitationEmail(emails.Email): + name = 'TENANT_INVITATION' + serializer_class = email_serializers.TenantInvitationEmailSerializer + + +def send_tenant_invitation_notification(tenant_membership: models.TenantMembership, membership_id: str, token: str): + if tenant_membership.user: + sender.send_notification( + user=tenant_membership.user, + type=constants.Notification.TENANT_INVITATION_CREATED.value, + data={ + "id": membership_id, + "token": token, + "tenant_name": tenant_membership.tenant.name, + }, + issuer=tenant_membership.creator, + ) + + +def send_accepted_tenant_invitation_notification(tenant_membership: models.TenantMembership, membership_id: str): + if tenant_membership.creator: + sender.send_notification( + user=tenant_membership.creator, + type=constants.Notification.TENANT_INVITATION_ACCEPTED.value, + data={ + "id": membership_id, + "name": str(tenant_membership.user.profile) or str(tenant_membership.user), + "tenant_name": str(tenant_membership.tenant), + }, + issuer=tenant_membership.user, + ) + + +def send_declined_tenant_invitation_notification(tenant_membership: models.TenantMembership, membership_id: str): + if tenant_membership.creator: + sender.send_notification( + user=tenant_membership.creator, + type=constants.Notification.TENANT_INVITATION_DECLINED.value, + data={ + "id": membership_id, + "name": str(tenant_membership.user.profile) or str(tenant_membership.user), + "tenant_name": str(tenant_membership.tenant), + }, + issuer=tenant_membership.user, + ) diff --git a/packages/backend/apps/multitenancy/schema.py b/packages/backend/apps/multitenancy/schema.py new file mode 100644 index 000000000..ca44d71dc --- /dev/null +++ b/packages/backend/apps/multitenancy/schema.py @@ -0,0 +1,278 @@ +import graphene +from graphene import relay +from graphql_relay import to_global_id, from_global_id +from graphene_django import DjangoObjectType +from django.shortcuts import get_object_or_404 +from rest_framework.exceptions import PermissionDenied + +from apps.users.services.users import get_user_from_resolver, get_user_avatar_url +from common.acl import policies +from common.graphql import mutations, exceptions +from common.graphql.acl.decorators import permission_classes +from common.graphql.acl.wrappers import PERMISSION_DENIED_MESSAGE +from apps.finances.services import subscriptions +from apps.finances.serializers import CancelTenantActiveSubscriptionSerializer +from . import models +from . import serializers +from .tokens import tenant_invitation_token +from .constants import TenantUserRole, TenantType as ConstantsTenantType + + +TenantUserRoleType = graphene.Enum.from_enum(TenantUserRole) + + +class TenantMembershipType(DjangoObjectType): + id = graphene.ID(required=True) + invitation_accepted = graphene.Boolean() + user_id = graphene.ID() + invitee_email_address = graphene.String() + invitation_token = graphene.String() + first_name = graphene.String() + last_name = graphene.String() + user_email = graphene.String() + avatar = graphene.String() + role = TenantUserRoleType() + + class Meta: + model = models.TenantMembership + fields = ( + "id", + "role", + "invitation_accepted", + "user_id", + "invitee_email_address", + "invitation_token", + "first_name", + "last_name", + "user_email", + "avatar", + ) + interfaces = (relay.Node,) + + def resolve_id(self, info): + return to_global_id("TenantMembershipType", self.id) + + @staticmethod + def resolve_invitation_token(parent, info): + user = get_user_from_resolver(info) + if parent.user and user == parent.user and not parent.is_accepted: + return tenant_invitation_token.make_token(user.email, parent) + return None + + @staticmethod + def resolve_invitation_accepted(parent, info): + return parent.is_accepted + + @staticmethod + def resolve_first_name(parent, info): + return parent.user.profile.first_name if parent.user else None + + @staticmethod + def resolve_last_name(parent, info): + return parent.user.profile.last_name if parent.user else None + + @staticmethod + def resolve_user_email(parent, info): + return parent.user.email if parent.user else None + + @staticmethod + def resolve_avatar(parent, info): + return get_user_avatar_url(parent.user) if parent.user else None + + +class TenantType(DjangoObjectType): + id = graphene.ID(required=True) + name = graphene.String() + slug = graphene.String() + type = graphene.String() + billing_email = graphene.String() + membership = graphene.NonNull(of_type=TenantMembershipType) + user_memberships = graphene.List(of_type=TenantMembershipType) + + class Meta: + model = models.Tenant + fields = ("id", "name", "slug", "billing_email", "type", "membership", "user_memberships") + interfaces = (relay.Node,) + + @staticmethod + def resolve_membership(parent, info): + user = get_user_from_resolver(info) + return models.TenantMembership.objects.get_all().filter(user=user, tenant=parent).first() + + def resolve_id(self, info): + return to_global_id("TenantType", self.id) + + @staticmethod + @permission_classes(policies.IsTenantAdminAccess) + def resolve_user_memberships(parent, info): + return parent.user_memberships.get_all().filter(tenant=parent) + + +class TenantConnection(graphene.Connection): + class Meta: + node = TenantType + + +class CreateTenantMutation(mutations.CreateModelMutation): + class Meta: + serializer_class = serializers.TenantSerializer + edge_class = TenantConnection.Edge + + +class UpdateTenantMutation(mutations.UpdateModelMutation): + class Meta: + serializer_class = serializers.TenantSerializer + edge_class = TenantConnection.Edge + + @classmethod + def get_object(cls, model_class, root, info, **input): + return info.context.tenant + + +class DeleteTenantMutation(mutations.DeleteModelMutation): + """ + Mutation to delete a tenant from the system. + """ + + class Meta: + model = models.Tenant + + @classmethod + def mutate_and_get_payload(cls, root, info, id, **kwargs): + """ + Perform deletion of a tenant and subscription cancellation. + + Returns: + DeleteTenantMutation: The mutation object with list of deleted_ids. + + Raises: + GraphQlValidationError: If deletion encounters validation errors. + """ + tenant = info.context.tenant + + if tenant.type == ConstantsTenantType.DEFAULT: + raise exceptions.GraphQlValidationError("Cannot delete default type tenant.") + + schedule = subscriptions.get_schedule(tenant) + cancel_subscription_serializer = CancelTenantActiveSubscriptionSerializer(instance=schedule, data={}) + if cancel_subscription_serializer.is_valid(): + cancel_subscription_serializer.save() + + tenant.delete() + return cls(deleted_ids=[id]) + + +class CreateTenantInvitationMutation(mutations.SerializerMutation): + ok = graphene.Boolean() + + class Meta: + serializer_class = serializers.CreateTenantInvitationSerializer + + +class DeleteTenantMembershipMutation(mutations.DeleteModelMutation): + class Input: + id = graphene.String() + tenant_id = graphene.String(required=True) + + class Meta: + model = models.TenantMembership + + @classmethod + def get_object(cls, id, tenant, **kwargs): + model = cls._meta.model + _, pk = from_global_id(id) + return get_object_or_404(model.objects.get_all(), pk=pk, tenant=tenant) + + @classmethod + def mutate_and_get_payload(cls, root, info, id, **kwargs): + obj = cls.get_object(id, info.context.tenant) + user = info.context.user + user_role = info.context.user_role + + if user_role != TenantUserRole.OWNER and obj.user != user: + raise PermissionDenied(PERMISSION_DENIED_MESSAGE) + + if obj.role == TenantUserRole.OWNER and info.context.tenant.owners_count == 1: + raise exceptions.GraphQlValidationError("There must be at least one owner in the Tenant.") + + obj.delete() + return cls(deleted_ids=[id]) + + +class UpdateTenantMembershipMutation(mutations.UpdateModelMutation): + class Input: + id = graphene.String() + tenant_id = graphene.String(required=True) + + class Meta: + serializer_class = serializers.UpdateTenantMembershipSerializer + edge_class = TenantConnection.Edge + convert_choices_to_enum = True + + @classmethod + def get_object(cls, model_class, root, info, **input): + return get_object_or_404(model_class.objects.get_all(), pk=input["id"], tenant=info.context.tenant) + + @classmethod + def mutate_and_get_payload(cls, root, info, **input): + if "id" in input: + _, input["id"] = from_global_id(input["id"]) + return super().mutate_and_get_payload(root, info, **input) + + +class AcceptTenantInvitationMutation(mutations.SerializerMutation): + ok = graphene.Boolean() + + class Meta: + serializer_class = serializers.AcceptTenantInvitationSerializer + + @classmethod + def mutate_and_get_payload(cls, root, info, **input): + if "id" in input: + _, input["id"] = from_global_id(input["id"]) + return super().mutate_and_get_payload(root, info, **input) + + +class DeclineTenantInvitationMutation(mutations.SerializerMutation): + ok = graphene.Boolean() + + class Meta: + serializer_class = serializers.DeclineTenantInvitationSerializer + + @classmethod + def mutate_and_get_payload(cls, root, info, **input): + if "id" in input: + _, input["id"] = from_global_id(input["id"]) + return super().mutate_and_get_payload(root, info, **input) + + +class Query(graphene.ObjectType): + all_tenants = graphene.relay.ConnectionField(TenantConnection) + tenant = graphene.Field(TenantType, id=graphene.ID()) + + @staticmethod + @permission_classes(policies.AnyoneFullAccess) + def resolve_all_tenants(root, info, **kwargs): + if info.context.user.is_authenticated: + return models.Tenant.objects.filter(user_memberships__user=info.context.user).all() + return [] + + @staticmethod + def resolve_tenant(root, info, id): + _, pk = from_global_id(id) + return models.Tenant.objects.filter(pk=pk, user_memberships__user=info.context.user).first() + + +@permission_classes(policies.IsTenantOwnerAccess) +class TenantOwnerMutation(graphene.ObjectType): + update_tenant = UpdateTenantMutation.Field() + delete_tenant = DeleteTenantMutation.Field() + create_tenant_invitation = CreateTenantInvitationMutation.Field() + update_tenant_membership = UpdateTenantMembershipMutation.Field() + + +class Mutation(graphene.ObjectType): + create_tenant = CreateTenantMutation.Field() + accept_tenant_invitation = AcceptTenantInvitationMutation.Field() + decline_tenant_invitation = DeclineTenantInvitationMutation.Field() + delete_tenant_membership = DeleteTenantMembershipMutation.Field() diff --git a/packages/backend/apps/multitenancy/serializers.py b/packages/backend/apps/multitenancy/serializers.py new file mode 100644 index 000000000..d647fc89f --- /dev/null +++ b/packages/backend/apps/multitenancy/serializers.py @@ -0,0 +1,169 @@ +from hashid_field import rest as hidrest +from rest_framework import serializers, exceptions +from django.contrib.auth import get_user_model +from django.contrib.auth.models import BaseUserManager +from django.utils.translation import gettext_lazy as _ +from django.db.models import Q +from django.utils import timezone +from graphql_relay import to_global_id + +from common.graphql.field_conversions import TextChoicesFieldType +from . import models, notifications +from .constants import TenantType, TenantUserRole +from .services.membership import create_tenant_membership +from .tokens import tenant_invitation_token + + +class TenantSerializer(serializers.ModelSerializer): + id = hidrest.HashidSerializerCharField(source_field="multitenancy.Tenant.id", read_only=True) + + def create(self, validated_data): + validated_data["creator"] = self.context["request"].user + validated_data["type"] = TenantType.ORGANIZATION + tenant = super().create(validated_data) + create_tenant_membership( + user=validated_data["creator"], tenant=tenant, role=TenantUserRole.OWNER, is_accepted=True + ) + return tenant + + class Meta: + model = models.Tenant + fields = ("id", "name", "billing_email") + + +class TenantInvitationActionSerializer(serializers.Serializer): + """ + Parent serializer for Accept and Decline serializers. + + It validates if invitation exists and if token is correct before proceeding with invitation action. + """ + + id = hidrest.HashidSerializerCharField(source_field="multitenancy.TenantMembership.id", write_only=True) + token = serializers.CharField(write_only=True, help_text=_("Token")) + ok = serializers.BooleanField(read_only=True) + + def validate(self, attrs): + membership_id = attrs["id"] + user = self.context["request"].user + membership = models.TenantMembership.objects.get_not_accepted().filter(pk=membership_id, user=user).first() + + if not membership: + raise exceptions.NotFound("Invitation not found.") + + if not tenant_invitation_token.check_token(user.email, attrs["token"], membership): + raise exceptions.ValidationError(_("Malformed tenant invitation token")) + + return attrs + + +class AcceptTenantInvitationSerializer(TenantInvitationActionSerializer): + """ + Updates not accepted invitation membership object to be accepted one. + """ + + def create(self, validated_data): + membership_id = validated_data["id"] + user = self.context["request"].user + membership = models.TenantMembership.objects.get_not_accepted().filter(pk=membership_id, user=user).first() + if membership: + models.TenantMembership.objects.get_not_accepted().filter(pk=membership_id, user=user).update( + is_accepted=True, invitation_accepted_at=timezone.now() + ) + notifications.send_accepted_tenant_invitation_notification( + membership, to_global_id("TenantMembershipType", membership_id) + ) + return {"ok": True} + + +class DeclineTenantInvitationSerializer(TenantInvitationActionSerializer): + """ + Removes membership object if user decides to decline invitation. + """ + + def create(self, validated_data): + membership_id = validated_data["id"] + user = self.context["request"].user + membership = models.TenantMembership.objects.get_not_accepted().filter(pk=membership_id, user=user).first() + if membership: + membership.delete() + notifications.send_declined_tenant_invitation_notification( + membership, to_global_id("TenantMembershipType", membership_id) + ) + return {"ok": True} + + +class CreateTenantInvitationSerializer(serializers.Serializer): + """ + Serializer for creating a not-yet-accepted membership invitation. + + This serializer is designed to handle the creation of a membership invitation within a tenant. + It validates the input data, ensuring that the connection between the specified user or invitee email + and the tenant does not already exist. If the connection is valid, it creates a new not accepted membership object. + """ + + email = serializers.EmailField(required=True) + role = TextChoicesFieldType(choices=TenantUserRole.choices, choices_class=TenantUserRole) + tenant_id = serializers.CharField() + ok = serializers.BooleanField(read_only=True) + + def validate(self, attrs): + email = BaseUserManager.normalize_email(attrs["email"]) + tenant = self.context["request"].tenant + if tenant.type == TenantType.DEFAULT: + raise serializers.ValidationError(_("Invitation for personal tenant cannot be created.")) + if ( + models.TenantMembership.objects.get_all() + .filter(Q(user__email=email, tenant=tenant) | Q(invitee_email_address=email, tenant=tenant)) + .exists() + ): + raise serializers.ValidationError(_("Invitation already exists")) + return super().validate(attrs) + + def create(self, validated_data): + email = BaseUserManager.normalize_email(validated_data["email"]) + role = validated_data["role"] + tenant = self.context["request"].tenant + User = get_user_model() + tenant_membership_data = { + "role": role, + "tenant": tenant, + "creator": self.context["request"].user, + } + try: + tenant_membership_data["user"] = User.objects.get(email=email) + except User.DoesNotExist: + tenant_membership_data["invitee_email_address"] = email + + create_tenant_membership(**tenant_membership_data) + + return {"ok": True, **validated_data} + + +class UpdateTenantMembershipSerializer(serializers.ModelSerializer): + """ + Serializer for update a tenant membership. + + This serializer is designed to handle the update of a membership within a tenant. + """ + + id = hidrest.HashidSerializerCharField(source_field="multitenancy.TenantMembership.id", write_only=True) + role = TextChoicesFieldType(choices=TenantUserRole.choices, choices_class=TenantUserRole) + + def validate(self, attrs): + tenant = self.context["request"].tenant + actual_value = models.TenantMembership.objects.get_all().filter(pk=attrs["id"]).first() + if ( + actual_value + and actual_value.role == TenantUserRole.OWNER + and attrs["role"] != TenantUserRole.OWNER + and tenant.owners_count == 1 + ): + raise exceptions.ValidationError("There must be at least one owner in the Tenant.") + return super().validate(attrs) + + class Meta: + model = models.TenantMembership + fields = ( + "id", + "role", + ) diff --git a/packages/backend/apps/multitenancy/services/membership.py b/packages/backend/apps/multitenancy/services/membership.py new file mode 100644 index 000000000..96948663f --- /dev/null +++ b/packages/backend/apps/multitenancy/services/membership.py @@ -0,0 +1,41 @@ +from graphql_relay import to_global_id +from django.contrib.auth import get_user_model + +from ..models import Tenant, TenantMembership +from ..constants import TenantUserRole +from ..tokens import tenant_invitation_token +from ..notifications import TenantInvitationEmail, send_tenant_invitation_notification + +User = get_user_model() + + +def create_tenant_membership( + tenant: Tenant, + user: User = None, + creator: User = None, + invitee_email_address: str = "", + role: TenantUserRole = TenantUserRole.MEMBER, + is_accepted: bool = False, +): + membership = TenantMembership.objects.create( + user=user, + tenant=tenant, + role=role, + invitee_email_address=invitee_email_address, + is_accepted=is_accepted, + creator=creator, + ) + if not is_accepted: + token = tenant_invitation_token.make_token( + user_email=invitee_email_address if invitee_email_address else user.email, tenant_membership=membership + ) + global_tenant_membership_id = to_global_id("TenantMembershipType", membership.id) + TenantInvitationEmail( + to=user.email if user else invitee_email_address, + data={'tenant_membership_id': global_tenant_membership_id, 'token': token}, + ).send() + + if user: + send_tenant_invitation_notification(membership, global_tenant_membership_id, token) + + return membership diff --git a/packages/backend/apps/multitenancy/tests/__init__.py b/packages/backend/apps/multitenancy/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/backend/apps/multitenancy/tests/factories.py b/packages/backend/apps/multitenancy/tests/factories.py new file mode 100644 index 000000000..53862de00 --- /dev/null +++ b/packages/backend/apps/multitenancy/tests/factories.py @@ -0,0 +1,30 @@ +import factory + +from .. import models +from .. import constants + + +class TenantFactory(factory.django.DjangoModelFactory): + creator = factory.SubFactory("apps.users.tests.factories.UserFactory") + type = factory.Iterator(constants.TenantType.values) + name = factory.Faker('pystr') + slug = factory.Faker('pystr') + billing_email = factory.Faker('email') + created_at = factory.Faker('date_time') + updated_at = factory.Faker('date_time') + + class Meta: + model = models.Tenant + + +class TenantMembershipFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory("apps.users.tests.factories.UserFactory") + creator = factory.SubFactory("apps.users.tests.factories.UserFactory") + tenant = factory.SubFactory(TenantFactory) + role = factory.Iterator(constants.TenantUserRole.values) + created_at = factory.Faker('date_time') + updated_at = factory.Faker('date_time') + is_accepted = True + + class Meta: + model = models.TenantMembership diff --git a/packages/backend/apps/multitenancy/tests/fixtures.py b/packages/backend/apps/multitenancy/tests/fixtures.py new file mode 100644 index 000000000..1bee31053 --- /dev/null +++ b/packages/backend/apps/multitenancy/tests/fixtures.py @@ -0,0 +1,6 @@ +import pytest_factoryboy + +from . import factories + +pytest_factoryboy.register(factories.TenantFactory) +pytest_factoryboy.register(factories.TenantMembershipFactory) diff --git a/packages/backend/apps/multitenancy/tests/test_middleware.py b/packages/backend/apps/multitenancy/tests/test_middleware.py new file mode 100644 index 000000000..cf1c63bb4 --- /dev/null +++ b/packages/backend/apps/multitenancy/tests/test_middleware.py @@ -0,0 +1,88 @@ +import pytest +from graphql_relay import to_global_id +from unittest.mock import Mock + +from ..middleware import get_current_tenant, get_current_user_role, TenantUserRoleMiddleware + + +pytestmark = pytest.mark.django_db + + +class TestGetTenantIdFromArguments: + def test_get_tenant_id_from_arguments_with_input(self, tenant): + args = {"input": {"tenant_id": to_global_id("TenantType", tenant.id)}} + result = TenantUserRoleMiddleware._get_tenant_id_from_arguments(args) + assert result == tenant.id + + def test_get_tenant_id_from_arguments_without_input(self, tenant): + args = {"id": to_global_id("TenantType", tenant.id)} + result = TenantUserRoleMiddleware._get_tenant_id_from_arguments(args) + assert result == tenant.id + + def test_get_tenant_id_from_arguments_invalid_id_type(self): + args = {"input": {"tenant_id": "InvalidType:123"}} + result = TenantUserRoleMiddleware._get_tenant_id_from_arguments(args) + assert result is None + + def test_get_tenant_id_from_arguments_no_tenant_id(self): + args = {"input": {"other_field": "value"}} + result = TenantUserRoleMiddleware._get_tenant_id_from_arguments(args) + assert result is None + + def test_get_tenant_id_from_arguments_no_args(self): + args = {} + result = TenantUserRoleMiddleware._get_tenant_id_from_arguments(args) + assert result is None + + +class TestTenantUserRoleMiddlewareGetCurrentTenant: + def test_get_current_tenant_with_tenant_id(self, tenant_factory): + tenant_factory.create_batch(10) + tenant = tenant_factory(name="Test Tenant") + result = get_current_tenant(tenant.id) + assert result == tenant + + def test_get_current_tenant_nonexistent_tenant(self, tenant_factory): + tenant_factory.create_batch(10) + tenant_factory(name="Test Tenant") + result = get_current_tenant("9999") + assert result is None + + def test_get_current_tenant_missing_tenant_id(self, tenant_factory): + tenant_factory.create_batch(10) + tenant_factory(name="Test Tenant") + result = get_current_tenant(None) + assert result is None + + +class TestTenantUserRoleMiddlewareGetCurrentUserRole: + def test_get_current_user_role_authenticated_user(self, graphene_client, tenant, user, tenant_membership_factory): + tenant_membership = tenant_membership_factory(user=user, tenant=tenant, role="admin") + info = Mock() + info.context.user = user + info.context.tenant = tenant + info.context.tenant_id = tenant.id + graphene_client.force_authenticate(user) + result = get_current_user_role(info.context.tenant, info.context.user) + assert result == tenant_membership.role + + def test_get_current_user_role_unauthenticated_user(self, tenant, user, tenant_membership_factory): + tenant_membership_factory(user=user, tenant=tenant, role="admin") + info = Mock() + info.context.user = None + info.context.tenant = tenant + info.context.tenant_id = tenant.id + result = get_current_user_role(info.context.tenant, info.context.user) + assert result is None + + def test_get_current_user_role_membership_does_not_exist( + self, graphene_client, tenant, user, tenant_membership_factory + ): + tenant_membership_factory(user=user, tenant=tenant, role="admin") + info = Mock() + info.context.user = user + info.context.tenant = None + info.context.tenant_id = None + graphene_client.force_authenticate(user) + result = get_current_user_role(info.context.tenant, info.context.user) + assert result is None diff --git a/packages/backend/apps/multitenancy/tests/test_models.py b/packages/backend/apps/multitenancy/tests/test_models.py new file mode 100644 index 000000000..afd0a7a83 --- /dev/null +++ b/packages/backend/apps/multitenancy/tests/test_models.py @@ -0,0 +1,55 @@ +import pytest +from unittest.mock import patch +from django.utils.text import slugify +from django.db import IntegrityError + +from ..models import Tenant + +pytestmark = pytest.mark.django_db + + +class TestTenant: + def test_save_unique_slug_generation(self, user): + with patch("apps.multitenancy.models.slugify", side_effect=slugify) as mock_slugify: + tenant = Tenant(name="Test Tenant", creator=user) + tenant.save() + + mock_slugify.assert_called_once_with("Test Tenant") + assert tenant.slug == "test-tenant" + + def test_save_unique_slug_with_collision(self, user, tenant_factory): + tenant_factory(name="Test Tenant", creator=user) + with patch("apps.multitenancy.models.slugify", side_effect=slugify) as mock_slugify: + tenant = Tenant(name="Test Tenant", creator=user) + tenant.save() + + mock_slugify.assert_called_with("Test Tenant") + assert mock_slugify.call_count == 2 + assert tenant.slug == "test-tenant-1" + + def test_save_unique_slug_raises_different_integrity_error(self): + tenant = Tenant(name="Test Tenant") + try: + tenant.save() + except IntegrityError as e: + assert "not-null constraint" in str(e).lower() + + +class TestTenantMembership: + def test_unique_non_null_user_and_tenant(self, tenant, user, tenant_membership_factory): + tenant_membership_factory(user=user, tenant=tenant) + try: + tenant_membership_factory(user=user, tenant=tenant) + except IntegrityError: + pass + else: + assert False, "IntegrityError not raised" + + def test_unique_non_null_user_and_invitee_email_address(self, tenant, tenant_membership_factory): + tenant_membership_factory(invitee_email_address="user@example.com", tenant=tenant) + try: + tenant_membership_factory(invitee_email_address="user@example.com", tenant=tenant) + except IntegrityError: + pass + else: + assert False, "IntegrityError not raised" diff --git a/packages/backend/apps/multitenancy/tests/test_schema.py b/packages/backend/apps/multitenancy/tests/test_schema.py new file mode 100644 index 000000000..07b386bcb --- /dev/null +++ b/packages/backend/apps/multitenancy/tests/test_schema.py @@ -0,0 +1,1260 @@ +import pytest +import os +from graphql_relay import to_global_id + +from apps.notifications.models import Notification +from ..constants import TenantType, TenantUserRole, Notification as NotificationConstant +from ..models import TenantMembership + + +pytestmark = pytest.mark.django_db + + +class TestCreateTenantMutation: + MUTATION = ''' + mutation CreateTenant($input: CreateTenantMutationInput!) { + createTenant(input: $input) { + tenant { + id + name + slug + type + billingEmail + membership { + role + invitationAccepted + } + } + } + } + ''' + + def test_create_new_tenant(self, graphene_client, user): + graphene_client.force_authenticate(user) + executed = self.mutate(graphene_client, {"name": "Test", "billingEmail": "test@example.com"}) + response_data = executed["data"]["createTenant"]["tenant"] + assert response_data["name"] == "Test" + assert response_data["slug"] == "test" + assert response_data["type"] == TenantType.ORGANIZATION + assert response_data["billingEmail"] == "test@example.com" + assert response_data["membership"]["role"] == TenantUserRole.OWNER + + def test_create_new_tenant_with_same_name(self, graphene_client, user, tenant_factory): + tenant_factory(name="Test", slug="test") + graphene_client.force_authenticate(user) + executed = self.mutate(graphene_client, {"name": "Test"}) + response_data = executed["data"]["createTenant"]["tenant"] + assert response_data["name"] == "Test" + assert response_data["slug"] == "test-1" + assert response_data["type"] == TenantType.ORGANIZATION + assert response_data["membership"]["role"] == TenantUserRole.OWNER + + def test_unauthenticated_user(self, graphene_client): + executed = self.mutate(graphene_client, {"name": "Test"}) + assert executed["errors"][0]["message"] == "permission_denied" + + @classmethod + def mutate(cls, graphene_client, data): + return graphene_client.mutate(cls.MUTATION, variable_values={'input': data}) + + +class TestUpdateTenantMutation: + MUTATION = ''' + mutation UpdateTenant($input: UpdateTenantMutationInput!) { + updateTenant(input: $input) { + tenant { + id + name + slug + type + billingEmail + membership { + role + invitationAccepted + } + } + } + } + ''' + + def test_update_tenant(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + {"id": to_global_id("TenantType", tenant.id), "name": "Tenant 2", "billingEmail": "test@example.com"}, + ) + response_data = executed["data"]["updateTenant"]["tenant"] + assert response_data["name"] == "Tenant 2" + assert response_data["slug"] == "tenant-2" + assert response_data["type"] == TenantType.ORGANIZATION + assert response_data["billingEmail"] == "test@example.com" + assert response_data["membership"]["role"] == TenantUserRole.OWNER + + def test_user_without_membership(self, graphene_client, user, tenant_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, None) + executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id), "name": "Tenant 2"}) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_user_with_admin_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.ADMIN) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id), "name": "Tenant 2"}) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_user_with_member_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) + executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id), "name": "Tenant 2"}) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_unauthenticated_user(self, graphene_client, tenant_factory): + tenant = tenant_factory(name="Tenant 1") + executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id), "name": "Tenant 2"}) + assert executed["errors"][0]["message"] == "permission_denied" + + @classmethod + def mutate(cls, graphene_client, data): + return graphene_client.mutate(cls.MUTATION, variable_values={'input': data}) + + +class TestDeleteTenantMutation: + MUTATION = ''' + mutation DeleteTenant($input: DeleteTenantMutationInput!) { + deleteTenant(input: $input) { + deletedIds + } + } + ''' + + def test_delete_tenant( + self, + graphene_client, + user, + tenant_factory, + tenant_membership_factory, + subscription_schedule_factory, + monthly_plan_price, + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_id = tenant.id + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + subscription_schedule_factory( + phases=[{'items': [{'price': monthly_plan_price.id}], 'trialing': True}], customer__subscriber=tenant + ) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)}) + response_data = executed["data"]["deleteTenant"]["deletedIds"] + assert response_data[0] == to_global_id("TenantType", tenant_id) + + def test_delete_tenant_with_free_plan( + self, + graphene_client, + user, + tenant_factory, + tenant_membership_factory, + subscription_schedule_factory, + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_id = tenant.id + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + subscription_schedule_factory(phases=None, customer__subscriber=tenant) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)}) + response_data = executed["data"]["deleteTenant"]["deletedIds"] + assert response_data[0] == to_global_id("TenantType", tenant_id) + + def test_delete_default_tenant(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.DEFAULT) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)}) + assert executed["errors"][0]["message"] == "GraphQlValidationError" + + def test_user_without_membership(self, graphene_client, user, tenant_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, None) + executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)}) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_user_with_admin_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.ADMIN) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)}) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_user_with_member_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) + executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)}) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_unauthenticated_user(self, graphene_client, tenant_factory): + tenant = tenant_factory(name="Tenant 1") + executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)}) + assert executed["errors"][0]["message"] == "permission_denied" + + @classmethod + def mutate(cls, graphene_client, data): + return graphene_client.mutate(cls.MUTATION, variable_values={'input': data}) + + +class TestCreateTenantInvitationMutation: + MUTATION = ''' + mutation CreateTenantInvitation($input: CreateTenantInvitationMutationInput!) { + createTenantInvitation(input: $input) { + ok + email + role + tenantId + } + } + ''' + + def test_create_tenant_invitation_by_owner( + self, mocker, graphene_client, user, user_factory, tenant_factory, tenant_membership_factory + ): + make_token = mocker.patch( + "apps.multitenancy.tokens.TenantInvitationTokenGenerator.make_token", return_value="token" + ) + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + invited_user = user_factory() + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "email": invited_user.email, + "role": TenantUserRole.ADMIN, + }, + ) + response_data = executed["data"]["createTenantInvitation"] + assert response_data["ok"] is True + assert response_data["email"] == invited_user.email + assert response_data["role"] == TenantUserRole.ADMIN + assert response_data["tenantId"] == to_global_id("TenantType", tenant.id) + make_token.assert_called_once() + + assert Notification.objects.count() == 1 + notification = Notification.objects.first() + assert notification.type == NotificationConstant.TENANT_INVITATION_CREATED.value + assert notification.user == invited_user + assert notification.issuer == user + + def test_create_default_tenant_invitation_by_owner( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.DEFAULT) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "email": "test@example.com", + "role": TenantUserRole.ADMIN.upper(), + }, + ) + assert executed["errors"][0]["message"] == "GraphQlValidationError" + + def test_create_tenant_invitation_by_admin(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.ADMIN) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "email": "test@example.com", + "role": TenantUserRole.ADMIN.upper(), + }, + ) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_create_tenant_invitation_by_member(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "email": "test@example.com", + "role": TenantUserRole.ADMIN.upper(), + }, + ) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_user_without_membership(self, graphene_client, user, tenant_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, None) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "email": "test@example.com", + "role": TenantUserRole.ADMIN.upper(), + }, + ) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_unauthenticated_user(self, graphene_client, tenant_factory): + tenant = tenant_factory(name="Tenant 1") + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "email": "test@example.com", + "role": TenantUserRole.ADMIN.upper(), + }, + ) + assert executed["errors"][0]["message"] == "permission_denied" + + @classmethod + def mutate(cls, graphene_client, data): + return graphene_client.mutate(cls.MUTATION, variable_values={'input': data}) + + +class TestDeleteTenantMembershipMutation: + MUTATION = ''' + mutation DeleteTenantMembership($input: DeleteTenantMembershipMutationInput!) { + deleteTenantMembership(input: $input) { + deletedIds + } + } + ''' + + def test_delete_tenant_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + }, + ) + assert executed["data"]["deleteTenantMembership"]["deletedIds"][0] == to_global_id( + "TenantMembershipType", tenant_membership.id + ) + + def test_delete_own_tenant_membership_by_member( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + }, + ) + assert executed["data"]["deleteTenantMembership"]["deletedIds"][0] == to_global_id( + "TenantMembershipType", tenant_membership.id + ) + + def test_delete_own_tenant_membership_by_admin( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.ADMIN) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + }, + ) + assert executed["data"]["deleteTenantMembership"]["deletedIds"][0] == to_global_id( + "TenantMembershipType", tenant_membership.id + ) + + def test_delete_tenant_membership_not_accepted( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, is_accepted=False) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + }, + ) + assert executed["data"]["deleteTenantMembership"]["deletedIds"][0] == to_global_id( + "TenantMembershipType", tenant_membership.id + ) + + def test_delete_tenant_membership_with_invalid_id( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", "InvalidID"), + }, + ) + assert executed["errors"][0]["message"] == "No TenantMembership matches the given query." + + def test_delete_default_tenant_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.DEFAULT) + tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + }, + ) + assert executed["errors"][0]["message"] == "GraphQlValidationError" + + def test_delete_organization_tenant_membership_last_owner( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + }, + ) + assert executed["errors"][0]["message"] == "GraphQlValidationError" + + def test_delete_tenant_membership_with_different_tenant_id( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_2 = tenant_factory(name="Tenant 2", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + tenant_membership_factory(tenant=tenant_2, user=user, role=TenantUserRole.OWNER) + tenant_membership = tenant_membership_factory(tenant=tenant_2, role=TenantUserRole.MEMBER) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + }, + ) + assert executed["errors"][0]["message"] == "No TenantMembership matches the given query." + + def test_delete_tenant_membership_by_member( + self, graphene_client, user, user_factory, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER) + tenant_membership = tenant_membership_factory(tenant=tenant, user=user_factory(), role=TenantUserRole.MEMBER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + }, + ) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_delete_tenant_membership_by_admin( + self, graphene_client, user, user_factory, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.ADMIN) + tenant_membership = tenant_membership_factory(tenant=tenant, user=user_factory(), role=TenantUserRole.MEMBER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + }, + ) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_delete_tenant_membership_by_not_a_member( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, None) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + }, + ) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_delete_tenant_membership_by_unauthorized( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER) + graphene_client.set_tenant_dependent_context(tenant, None) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + }, + ) + assert executed["errors"][0]["message"] == "permission_denied" + + @classmethod + def mutate(cls, graphene_client, data): + return graphene_client.mutate(cls.MUTATION, variable_values={'input': data}) + + +class TestUpdateTenantMembershipMutation: + MUTATION = ''' + mutation UpdateTenantMembership($input: UpdateTenantMembershipMutationInput!) { + updateTenantMembership(input: $input) { + tenantMembership { + id + role + } + } + } + ''' + + def test_update_tenant_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + "role": "ADMIN", + }, + ) + data = executed["data"]["updateTenantMembership"]["tenantMembership"] + assert data["id"] == to_global_id("TenantMembershipType", tenant_membership.id) + assert data["role"] == TenantUserRole.ADMIN + + def test_update_tenant_membership_not_accepted( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, is_accepted=False) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + "role": "ADMIN", + }, + ) + print(executed) + data = executed["data"]["updateTenantMembership"]["tenantMembership"] + assert data["id"] == to_global_id("TenantMembershipType", tenant_membership.id) + assert data["role"] == TenantUserRole.ADMIN + + def test_update_own_tenant_membership_by_member( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + "role": "ADMIN", + }, + ) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_update_own_tenant_membership_by_admin( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.ADMIN) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + "role": "MEMBER", + }, + ) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_update_tenant_membership_with_invalid_id( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", "InvalidID"), + "role": "ADMIN", + }, + ) + assert executed["errors"][0]["message"] == "No TenantMembership matches the given query." + + def test_update_default_tenant_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.DEFAULT) + tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + "role": "ADMIN", + }, + ) + assert executed["errors"][0]["message"] == "GraphQlValidationError" + + def test_update_organization_tenant_membership_last_owner( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + "role": "ADMIN", + }, + ) + assert executed["errors"][0]["message"] == "GraphQlValidationError" + + def test_update_tenant_membership_with_different_tenant_id( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_2 = tenant_factory(name="Tenant 2", type=TenantType.ORGANIZATION) + tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER) + tenant_membership_factory(tenant=tenant_2, user=user, role=TenantUserRole.OWNER) + tenant_membership = tenant_membership_factory(tenant=tenant_2, role=TenantUserRole.MEMBER) + + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + "role": "ADMIN", + }, + ) + assert executed["errors"][0]["message"] == "No TenantMembership matches the given query." + + def test_update_tenant_membership_by_not_a_member( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant, None) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + "role": "ADMIN", + }, + ) + assert executed["errors"][0]["message"] == "permission_denied" + + def test_update_tenant_membership_by_unauthorized( + self, graphene_client, user, tenant_factory, tenant_membership_factory + ): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER) + graphene_client.set_tenant_dependent_context(tenant, None) + executed = self.mutate( + graphene_client, + { + "tenantId": to_global_id("TenantType", tenant.id), + "id": to_global_id("TenantMembershipType", tenant_membership.id), + "role": "ADMIN", + }, + ) + assert executed["errors"][0]["message"] == "permission_denied" + + @classmethod + def mutate(cls, graphene_client, data): + return graphene_client.mutate(cls.MUTATION, variable_values={'input': data}) + + +class TestAcceptTenantInvitationMutation: + MUTATION = ''' + mutation AcceptTenantInvitation($input: AcceptTenantInvitationMutationInput!) { + acceptTenantInvitation(input: $input) { + ok + } + } + ''' + + def test_accept_invitation_by_invitee( + self, mocker, graphene_client, user, tenant_factory, tenant_membership_factory + ): + check_token = mocker.patch( + "apps.multitenancy.tokens.TenantInvitationTokenGenerator.check_token", return_value=True + ) + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER, is_accepted=False) + graphene_client.force_authenticate(user) + executed = self.mutate( + graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"} + ) + response_data = executed["data"]["acceptTenantInvitation"] + assert response_data["ok"] is True + membership = TenantMembership.objects.filter(tenant=tenant, user=user).first() + assert membership.is_accepted + assert membership.invitation_accepted_at + check_token.assert_called_once() + + assert Notification.objects.count() == 1 + notification = Notification.objects.first() + assert notification.type == NotificationConstant.TENANT_INVITATION_ACCEPTED.value + assert notification.user == membership.creator + assert notification.issuer == user + + def test_accept_invitation_by_invitee_wrong_token( + self, mocker, graphene_client, user, tenant_factory, tenant_membership_factory + ): + check_token = mocker.patch( + "apps.multitenancy.tokens.TenantInvitationTokenGenerator.check_token", return_value=False + ) + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER, is_accepted=False) + graphene_client.force_authenticate(user) + executed = self.mutate( + graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"} + ) + assert executed["errors"][0]["message"] == "GraphQlValidationError" + check_token.assert_called_once() + + def test_accept_invitation_by_other_user( + self, graphene_client, user_factory, tenant_factory, tenant_membership_factory + ): + logged_user = user_factory() + invitee_user = user_factory() + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + membership = tenant_membership_factory( + tenant=tenant, user=invitee_user, role=TenantUserRole.MEMBER, is_accepted=False + ) + graphene_client.force_authenticate(logged_user) + executed = self.mutate( + graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"} + ) + assert executed["errors"][0]["message"] == "Invitation not found." + + def test_unauthenticated_user(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER, is_accepted=False) + executed = self.mutate( + graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"} + ) + assert executed["errors"][0]["message"] == "permission_denied" + + @classmethod + def mutate(cls, graphene_client, data): + return graphene_client.mutate(cls.MUTATION, variable_values={'input': data}) + + +class TestDeclineTenantInvitationMutation: + MUTATION = ''' + mutation DeclineTenantInvitation($input:DeclineTenantInvitationMutationInput!) { + declineTenantInvitation(input: $input) { + ok + } + } + ''' + + def test_decline_invitation_by_invitee( + self, mocker, graphene_client, user, tenant_factory, tenant_membership_factory + ): + check_token = mocker.patch( + "apps.multitenancy.tokens.TenantInvitationTokenGenerator.check_token", return_value=True + ) + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER, is_accepted=False) + graphene_client.force_authenticate(user) + executed = self.mutate( + graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"} + ) + response_data = executed["data"]["declineTenantInvitation"] + assert response_data["ok"] is True + assert not TenantMembership.objects.filter(tenant=tenant, user=user).exists() + check_token.assert_called_once() + + assert Notification.objects.count() == 1 + notification = Notification.objects.first() + assert notification.type == NotificationConstant.TENANT_INVITATION_DECLINED.value + assert notification.user == membership.creator + assert notification.issuer == user + + def test_decline_invitation_by_invitee_wrong_token( + self, mocker, graphene_client, user, tenant_factory, tenant_membership_factory + ): + check_token = mocker.patch( + "apps.multitenancy.tokens.TenantInvitationTokenGenerator.check_token", return_value=False + ) + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER, is_accepted=False) + graphene_client.force_authenticate(user) + executed = self.mutate( + graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"} + ) + assert executed["errors"][0]["message"] == "GraphQlValidationError" + check_token.assert_called_once() + + def test_decline_invitation_by_other_user( + self, graphene_client, user_factory, tenant_factory, tenant_membership_factory + ): + logged_user = user_factory() + invitee_user = user_factory() + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + membership = tenant_membership_factory( + tenant=tenant, user=invitee_user, role=TenantUserRole.MEMBER, is_accepted=False + ) + graphene_client.force_authenticate(logged_user) + executed = self.mutate( + graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"} + ) + assert executed["errors"][0]["message"] == "Invitation not found." + + def test_unauthenticated_user(self, graphene_client, user, tenant_factory, tenant_membership_factory): + tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION) + membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER, is_accepted=False) + executed = self.mutate( + graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"} + ) + assert executed["errors"][0]["message"] == "permission_denied" + + @classmethod + def mutate(cls, graphene_client, data): + return graphene_client.mutate(cls.MUTATION, variable_values={"input": data}) + + +class TestAllTenantsQuery: + def test_all_tenants_query(self, mocker, graphene_client, user_factory, tenant_factory, tenant_membership_factory): + query = """ + query getAllTenants { + allTenants { + edges { + node { + id + name + slug + type + membership { + id + role + invitationAccepted + userId + inviteeEmailAddress + firstName + lastName + avatar + userEmail + invitationToken + } + userMemberships { + id + role + invitationAccepted + userId + inviteeEmailAddress + firstName + lastName + avatar + userEmail + invitationToken + } + } + } + } + } + """ + user = user_factory(has_avatar=True) + tenant_factory.create_batch(10) + make_token = mocker.patch( + "apps.multitenancy.tokens.TenantInvitationTokenGenerator.make_token", return_value="token" + ) + default_user_tenant = user.tenants.first() + tenant_with_invitation = tenant_factory(name="Invitation Tenant", type=TenantType.ORGANIZATION) + tenants = [ + default_user_tenant, + tenant_with_invitation, + tenant_factory(name="Test tenant", type=TenantType.ORGANIZATION), + tenant_factory(name="Test tenant 2", type=TenantType.ORGANIZATION), + ] + default_user_tenant_membership = TenantMembership.objects.filter(user=user, tenant=default_user_tenant).first() + invitation_membership = tenant_membership_factory( + tenant=tenant_with_invitation, role=TenantUserRole.ADMIN, user=user, is_accepted=False + ) + memberships = [ + default_user_tenant_membership, + invitation_membership, + tenant_membership_factory(tenant=tenants[2], role=TenantUserRole.OWNER, user=user), + tenant_membership_factory(tenant=tenants[3], role=TenantUserRole.MEMBER, user=user), + ] + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(None, None) + executed = graphene_client.query(query) + executed_tenants = executed["data"]["allTenants"]["edges"] + for idx, executed_tenant in enumerate(executed_tenants): + assert executed_tenant["node"]["id"] == to_global_id("TenantType", tenants[idx].id) + assert executed_tenant["node"]["name"] == tenants[idx].name + assert executed_tenant["node"]["slug"] == tenants[idx].slug + assert executed_tenant["node"]["type"] == tenants[idx].type + assert executed_tenant["node"]["userMemberships"] is None + assert executed_tenant["node"]["membership"]["id"] == to_global_id( + "TenantMembershipType", memberships[idx].id + ) + assert executed_tenant["node"]["membership"]["role"] == memberships[idx].role + assert executed_tenant["node"]["membership"]["invitationAccepted"] == memberships[idx].is_accepted + assert executed_tenant["node"]["membership"]["userId"] == user.id + assert executed_tenant["node"]["membership"]["firstName"] == user.profile.first_name + assert executed_tenant["node"]["membership"]["lastName"] == user.profile.last_name + assert executed_tenant["node"]["membership"]["userEmail"] == user.email + assert ( + os.path.split(executed_tenant["node"]["membership"]["avatar"])[1] + == os.path.split(user.profile.avatar.thumbnail.name)[1] + ) + if memberships[idx].is_accepted: + assert executed_tenant["node"]["membership"]["invitationToken"] is None + else: + assert executed_tenant["node"]["membership"]["invitationToken"] == "token" + + make_token.assert_called_once() + + def test_all_tenants_query_unauthenticated_user(self, graphene_client): + query = """ + query getAllTenants { + allTenants { + edges { + node { + id + name + slug + type + membership { + id + role + invitationAccepted + userId + inviteeEmailAddress + firstName + lastName + avatar + userEmail + invitationToken + } + } + } + } + } + """ + executed = graphene_client.query(query) + executed_tenants = executed["data"]["allTenants"]["edges"] + assert executed_tenants == [] + + +class TestTenantQuery: + def test_tenant_query(self, graphene_client, user_factory, tenant_factory, tenant_membership_factory): + query = """ + query getTenant($id: ID!) { + tenant(id: $id) { + id + name + slug + type + membership { + id + role + invitationAccepted + userId + inviteeEmailAddress + firstName + lastName + avatar + userEmail + invitationToken + } + userMemberships { + id + role + invitationAccepted + userId + inviteeEmailAddress + invitationToken + firstName + lastName + avatar + userEmail + } + } + } + """ + user = user_factory(has_avatar=True) + tenant_factory.create_batch(10) + tenant = tenant_factory(name="Test tenant", type=TenantType.ORGANIZATION) + membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.OWNER, user=user) + tenant_users = user_factory.create_batch(5) + for tenant_user in tenant_users: + tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, user=tenant_user) + tenant_invited_users = user_factory.create_batch(5) + for tenant_user in tenant_invited_users: + tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, user=tenant_user, is_accepted=False) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant=tenant, role=TenantUserRole.OWNER) + executed = graphene_client.query(query, variable_values={"id": to_global_id("TenantType", tenant.pk)}) + executed_tenant = executed["data"]["tenant"] + assert executed_tenant["id"] == to_global_id("TenantType", tenant.id) + assert executed_tenant["name"] == tenant.name + assert executed_tenant["slug"] == tenant.slug + assert executed_tenant["type"] == tenant.type + assert len(executed_tenant["userMemberships"]) == 11 + for user_membership in executed_tenant["userMemberships"]: + assert user_membership["invitationToken"] is None + assert executed_tenant["membership"]["id"] == to_global_id("TenantMembershipType", membership.id) + assert executed_tenant["membership"]["role"] == membership.role + assert executed_tenant["membership"]["invitationAccepted"] == membership.is_accepted + assert executed_tenant["membership"]["userId"] == user.id + assert executed_tenant["membership"]["firstName"] == user.profile.first_name + assert executed_tenant["membership"]["lastName"] == user.profile.last_name + assert executed_tenant["membership"]["userEmail"] == user.email + assert ( + os.path.split(executed_tenant["membership"]["avatar"])[1] + == os.path.split(user.profile.avatar.thumbnail.name)[1] + ) + assert executed_tenant["membership"]["invitationToken"] is None + + def test_tenant_query_user_with_invitation( + self, mocker, graphene_client, user_factory, tenant_factory, tenant_membership_factory + ): + query = """ + query getTenant($id: ID!) { + tenant(id: $id) { + id + name + slug + type + membership { + id + role + invitationAccepted + userId + inviteeEmailAddress + firstName + lastName + avatar + userEmail + invitationToken + } + userMemberships { + id + role + invitationAccepted + userId + inviteeEmailAddress + invitationToken + firstName + lastName + avatar + userEmail + } + } + } + """ + user = user_factory(has_avatar=True) + tenant_factory.create_batch(10) + make_token = mocker.patch( + "apps.multitenancy.tokens.TenantInvitationTokenGenerator.make_token", return_value="token" + ) + tenant = tenant_factory(name="Test tenant", type=TenantType.ORGANIZATION) + membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.OWNER, user=user, is_accepted=False) + tenant_users = user_factory.create_batch(5) + for tenant_user in tenant_users: + tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, user=tenant_user) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant=tenant, role=None) + executed = graphene_client.query(query, variable_values={"id": to_global_id("TenantType", tenant.pk)}) + executed_tenant = executed["data"]["tenant"] + assert executed_tenant["id"] == to_global_id("TenantType", tenant.id) + assert executed_tenant["name"] == tenant.name + assert executed_tenant["slug"] == tenant.slug + assert executed_tenant["type"] == tenant.type + assert executed_tenant["userMemberships"] is None + assert executed_tenant["membership"]["id"] == to_global_id("TenantMembershipType", membership.id) + assert executed_tenant["membership"]["role"] == membership.role + assert executed_tenant["membership"]["invitationAccepted"] == membership.is_accepted + assert executed_tenant["membership"]["userId"] == user.id + assert executed_tenant["membership"]["firstName"] == user.profile.first_name + assert executed_tenant["membership"]["lastName"] == user.profile.last_name + assert executed_tenant["membership"]["userEmail"] == user.email + assert ( + os.path.split(executed_tenant["membership"]["avatar"])[1] + == os.path.split(user.profile.avatar.thumbnail.name)[1] + ) + assert executed_tenant["membership"]["invitationToken"] == "token" + # No permissions for userMemberships for not accepted invitation + assert executed["errors"][0]["message"] == "permission_denied" + make_token.assert_called_once() + + def test_tenant_query_user_without_membership( + self, graphene_client, user_factory, tenant_factory, tenant_membership_factory + ): + query = """ + query getTenant($id: ID!) { + tenant(id: $id) { + id + name + slug + type + membership { + id + role + invitationAccepted + userId + inviteeEmailAddress + firstName + lastName + avatar + userEmail + invitationToken + } + userMemberships { + id + role + invitationAccepted + userId + inviteeEmailAddress + invitationToken + firstName + lastName + avatar + userEmail + } + } + } + """ + tenant_factory.create_batch(10) + tenant = tenant_factory(name="Test tenant", type=TenantType.ORGANIZATION) + tenant_users = user_factory.create_batch(5) + user = user_factory() + for tenant_user in tenant_users: + tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, user=tenant_user) + graphene_client.force_authenticate(user) + graphene_client.set_tenant_dependent_context(tenant=tenant, role=None) + executed = graphene_client.query(query, variable_values={"id": to_global_id("TenantType", tenant.pk)}) + assert executed["data"]["tenant"] is None + + def test_tenant_query_unauthenticated_user( + self, graphene_client, user_factory, tenant_factory, tenant_membership_factory + ): + query = """ + query getTenant($id: ID!) { + tenant(id: $id) { + id + name + slug + type + membership { + id + role + invitationAccepted + userId + inviteeEmailAddress + firstName + lastName + avatar + userEmail + invitationToken + } + userMemberships { + id + role + invitationAccepted + userId + inviteeEmailAddress + invitationToken + firstName + lastName + avatar + userEmail + } + } + } + """ + tenant_factory.create_batch(10) + tenant = tenant_factory(name="Test tenant", type=TenantType.ORGANIZATION) + tenant_users = user_factory.create_batch(5) + for tenant_user in tenant_users: + tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, user=tenant_user) + executed = graphene_client.query(query, variable_values={"id": to_global_id("TenantType", tenant.pk)}) + assert executed["errors"][0]["message"] == "permission_denied" diff --git a/packages/backend/apps/multitenancy/tests/test_serializers.py b/packages/backend/apps/multitenancy/tests/test_serializers.py new file mode 100644 index 000000000..250fc27a1 --- /dev/null +++ b/packages/backend/apps/multitenancy/tests/test_serializers.py @@ -0,0 +1,93 @@ +import pytest +from unittest.mock import Mock + +from ..models import TenantMembership +from ..constants import TenantUserRole, TenantType +from ..serializers import CreateTenantInvitationSerializer + + +pytestmark = pytest.mark.django_db + + +class TestCreateTenantInvitationSerializer: + def test_create_invitation_existing_user(self, mocker, user, user_factory, tenant_factory): + make_token = mocker.patch( + "apps.multitenancy.tokens.TenantInvitationTokenGenerator.make_token", return_value="token" + ) + creator = user_factory() + + tenant = tenant_factory(name="Test Tenant", type=TenantType.ORGANIZATION) + + data = { + "email": user.email, + "role": TenantUserRole.ADMIN, + "tenant_id": str(tenant.id), + } + serializer = CreateTenantInvitationSerializer(data=data, context={'request': Mock(tenant=tenant, user=creator)}) + assert serializer.is_valid() + + result = serializer.create(serializer.validated_data) + + assert result['ok'] + assert ( + TenantMembership.objects.get_not_accepted() + .filter(user=user, tenant=tenant, role=TenantUserRole.ADMIN) + .exists() + ) + make_token.assert_called_once() + + def test_create_invitation_new_user(self, mocker, user_factory, tenant_factory): + make_token = mocker.patch( + "apps.multitenancy.tokens.TenantInvitationTokenGenerator.make_token", return_value="token" + ) + creator = user_factory() + tenant = tenant_factory(name="Test Tenant", type=TenantType.ORGANIZATION) + + data = { + "email": "new_user@example.com", + "role": TenantUserRole.MEMBER, + "tenant_id": str(tenant.id), + } + serializer = CreateTenantInvitationSerializer(data=data, context={'request': Mock(tenant=tenant, user=creator)}) + assert serializer.is_valid() + + result = serializer.create(serializer.validated_data) + + assert result['ok'] + assert ( + TenantMembership.objects.get_not_accepted() + .filter( + invitee_email_address='new_user@example.com', + tenant=tenant, + role=TenantUserRole.MEMBER, + user__isnull=True, + ) + .exists() + ) + make_token.assert_called_once() + + def test_create_invitation_for_default_tenant_new_user(self, tenant_factory): + tenant = tenant_factory(name="Test Tenant", type=TenantType.DEFAULT) + + data = { + "email": "new_user@example.com", + "role": TenantUserRole.MEMBER, + "tenant_id": str(tenant.id), + } + serializer = CreateTenantInvitationSerializer(data=data, context={'request': Mock(tenant=tenant)}) + assert not serializer.is_valid() + assert "Invitation for personal tenant cannot be created." in serializer.errors['non_field_errors'][0] + + def test_create_invitation_existing_user_duplicate(self, user, tenant_factory): + tenant = tenant_factory(name="Test Tenant", type=TenantType.ORGANIZATION) + TenantMembership.objects.create(user=user, tenant=tenant, role=TenantUserRole.ADMIN) + + data = { + "email": user.email, + "role": TenantUserRole.MEMBER, + "tenant_id": str(tenant.id), + } + serializer = CreateTenantInvitationSerializer(data=data, context={'request': Mock(tenant=tenant)}) + + assert not serializer.is_valid() + assert 'Invitation already exists' in serializer.errors['non_field_errors'][0] diff --git a/packages/backend/apps/multitenancy/tests/test_tokens.py b/packages/backend/apps/multitenancy/tests/test_tokens.py new file mode 100644 index 000000000..88303cb3b --- /dev/null +++ b/packages/backend/apps/multitenancy/tests/test_tokens.py @@ -0,0 +1,47 @@ +import pytest + +from datetime import datetime, timedelta, timezone +from django.conf import settings +from ..tokens import tenant_invitation_token + + +pytestmark = pytest.mark.django_db + + +class TestTenantInvitationTokenGenerator: + def test_make_token(self, user, tenant_membership): + token = tenant_invitation_token.make_token(user.email, tenant_membership) + assert isinstance(token, str) + + def test_check_token_valid(self, user, tenant_membership): + token = tenant_invitation_token.make_token(user.email, tenant_membership) + assert tenant_invitation_token.check_token(user.email, token, tenant_membership) + + def test_check_token_invalid_email(self, user, tenant_membership): + token = tenant_invitation_token.make_token(user.email, tenant_membership) + invalid_email = "invalid@example.com" + assert not tenant_invitation_token.check_token(invalid_email, token, tenant_membership) + + def test_check_token_expired(self, mocker, user, tenant_membership): + # Reduce timeout for testing purposes + settings.TENANT_INVITATION_TIMEOUT = 1 + token = tenant_invitation_token.make_token(user.email, tenant_membership) + + # Sleep for more than the timeout + expired_date = datetime.now(tz=timezone.utc) + timedelta(seconds=2) + with mocker.patch.object(tenant_invitation_token, "_now", return_value=expired_date): + assert not tenant_invitation_token.check_token(user.email, token, tenant_membership) + # Restore original timeout value + settings.TENANT_INVITATION_TIMEOUT = 2 * 24 * 3600 + + def test_check_token_tampered(self, user, tenant_membership): + token = tenant_invitation_token.make_token(user.email, tenant_membership) + + # Tamper with the token + tampered_token = token.replace(token[0], token[2]) + + assert not tenant_invitation_token.check_token(user.email, tampered_token, tenant_membership) + + def test_check_token_invalid_format(self, user, tenant_membership): + invalid_token = "invalid_token_format" + assert not tenant_invitation_token.check_token(user.email, invalid_token, tenant_membership) diff --git a/packages/backend/apps/multitenancy/tokens.py b/packages/backend/apps/multitenancy/tokens.py new file mode 100644 index 000000000..4e84fa8e9 --- /dev/null +++ b/packages/backend/apps/multitenancy/tokens.py @@ -0,0 +1,81 @@ +import six + +from datetime import datetime, timezone +from django.conf import settings +from django.utils.crypto import constant_time_compare, salted_hmac +from django.utils.http import base36_to_int, int_to_base36 +from django.contrib.auth.tokens import PasswordResetTokenGenerator + + +class TenantInvitationTokenGenerator(PasswordResetTokenGenerator): + def _make_hash_value(self, user_email, timestamp, tenant_membership_pk): + return six.text_type(user_email) + six.text_type(timestamp) + six.text_type(str(tenant_membership_pk)) + + def _make_token_with_timestamp(self, user_email, timestamp, tenant_membership_pk, secret): + # timestamp is number of seconds since 2001-1-1. Converted to base 36, + # this gives us a 6 digit string until about 2069. + ts_b36 = int_to_base36(timestamp) + hash_string = salted_hmac( + self.key_salt, + self._make_hash_value(user_email, timestamp, tenant_membership_pk), + secret=secret, + algorithm=self.algorithm, + ).hexdigest()[ + ::2 + ] # Limit to shorten the URL. + return "%s-%s" % (ts_b36, hash_string) + + def make_token(self, user_email, tenant_membership): + """ + Return a token that can be used once to make action on tenant invitation + for the given user. + """ + return self._make_token_with_timestamp( + user_email, + self._num_seconds(tenant_membership.created_at), + tenant_membership.pk, + self.secret, + ) + + def _num_seconds(self, dt): + return int((dt - datetime(2001, 1, 1, tzinfo=timezone.utc)).total_seconds()) + + def _now(self): + # Used for mocking in tests + return datetime.now(tz=timezone.utc) + + def check_token(self, user_email, token, tenant_membership): + """ + Check that a tenant invitation token is correct for a given user. + """ + if not (user_email and token): + return False + # Parse the token + try: + ts_b36, _ = token.split("-") + except ValueError: + return False + + try: + ts = base36_to_int(ts_b36) + except ValueError: + return False + + # Check that the timestamp/uid has not been tampered with + for secret in [self.secret, *self.secret_fallbacks]: + if constant_time_compare( + self._make_token_with_timestamp(user_email, ts, tenant_membership.pk, secret), + token, + ): + break + else: + return False + + # Check the timestamp is within limit. + if (self._num_seconds(self._now()) - ts) > settings.TENANT_INVITATION_TIMEOUT: + return False + + return True + + +tenant_invitation_token = TenantInvitationTokenGenerator() diff --git a/packages/backend/apps/users/management/commands/init_customers_plans.py b/packages/backend/apps/users/management/commands/init_customers_plans.py index 21103a53d..9d11dd615 100644 --- a/packages/backend/apps/users/management/commands/init_customers_plans.py +++ b/packages/backend/apps/users/management/commands/init_customers_plans.py @@ -1,6 +1,6 @@ from django.core.management.base import BaseCommand -from ...models import User +from apps.multitenancy.models import Tenant from apps.finances.services import subscriptions @@ -8,6 +8,6 @@ class Command(BaseCommand): help = 'Creates stripe customer and schedule subscription plan' def handle(self, *args, **options): - users = User.objects.filter(djstripe_customers__isnull=True, is_superuser=False) - for user in users: - subscriptions.initialize_user(user=user) + tenants = Tenant.objects.filter(djstripe_customers__isnull=True) + for tenant in tenants: + subscriptions.initialize_tenant(tenant=tenant) diff --git a/packages/backend/apps/users/models.py b/packages/backend/apps/users/models.py index 3bf4b7bef..ee075e093 100644 --- a/packages/backend/apps/users/models.py +++ b/packages/backend/apps/users/models.py @@ -6,6 +6,7 @@ from common.acl.helpers import CommonGroups from common.models import ImageWithThumbnailMixin from common.storages import UniqueFilePathGenerator, PublicS3Boto3StorageWithCDN +from apps.multitenancy.models import TenantMembership class UserManager(BaseUserManager): @@ -13,8 +14,9 @@ def create_user(self, email, password=None): if not email: raise ValueError("Users must have an email address") + normalized_email = self.normalize_email(email) user = self.model( - email=self.normalize_email(email), + email=normalized_email, ) user.set_password(password) user_group = Group.objects.get(name=CommonGroups.User) @@ -24,6 +26,8 @@ def create_user(self, email, password=None): UserProfile.objects.create(user=user) + TenantMembership.objects.associate_invitations_with_user(normalized_email, user) + return user def create_superuser(self, email, password): diff --git a/packages/backend/apps/users/schema.py b/packages/backend/apps/users/schema.py index e789aed37..dc86cce4c 100644 --- a/packages/backend/apps/users/schema.py +++ b/packages/backend/apps/users/schema.py @@ -9,6 +9,8 @@ from common.graphql import mutations from common.graphql import ratelimit from common.graphql.acl.decorators import permission_classes +from apps.multitenancy.models import Tenant +from apps.multitenancy.schema import TenantType from . import models from . import serializers from .services.users import get_user_from_resolver, get_role_names, get_user_avatar_url @@ -134,11 +136,12 @@ class CurrentUserType(DjangoObjectType): first_name = graphene.String() last_name = graphene.String() roles = graphene.List(of_type=graphene.String) + tenants = graphene.List(of_type=TenantType) avatar = graphene.String() class Meta: model = models.User - fields = ("id", "email", "first_name", "last_name", "roles", "avatar", "otp_enabled", "otp_verified") + fields = ("id", "email", "first_name", "last_name", "roles", "avatar", "otp_enabled", "otp_verified", "tenants") @staticmethod def resolve_first_name(parent, info): @@ -156,6 +159,14 @@ def resolve_roles(parent, info): def resolve_avatar(parent, info): return get_user_avatar_url(get_user_from_resolver(info)) + @staticmethod + def resolve_tenants(parent, info): + user = get_user_from_resolver(info) + tenants = user.tenants.all() + if not len(tenants): + Tenant.objects.get_or_create_user_default_tenant(user) + return tenants + class UserProfileType(DjangoObjectType): class Meta: diff --git a/packages/backend/apps/users/serializers.py b/packages/backend/apps/users/serializers.py index afd683909..461d632d1 100644 --- a/packages/backend/apps/users/serializers.py +++ b/packages/backend/apps/users/serializers.py @@ -17,6 +17,8 @@ from .services import otp as otp_services from .utils import generate_otp_auth_token +from apps.multitenancy.models import Tenant + UPLOADED_AVATAR_SIZE_LIMIT = 1 * 1024 * 1024 @@ -86,6 +88,9 @@ def create(self, validated_data): user=user, data={'user_id': user.id.hashid, 'token': tokens.account_activation_token.make_token(user)} ).send() + # Create user signup tenant + Tenant.objects.get_or_create_user_default_tenant(user) + return {'id': user.id, 'email': user.email, 'access': str(refresh.access_token), 'refresh': str(refresh)} diff --git a/packages/backend/apps/users/tests/factories.py b/packages/backend/apps/users/tests/factories.py index 755a42557..eb3d6e7d8 100644 --- a/packages/backend/apps/users/tests/factories.py +++ b/packages/backend/apps/users/tests/factories.py @@ -8,6 +8,8 @@ from djstripe.models import Customer from common.acl.helpers import CommonGroups +from apps.multitenancy.models import Tenant +from apps.multitenancy.tests.factories import TenantFactory class GroupFactory(factory.django.DjangoModelFactory): @@ -47,6 +49,13 @@ def groups(self, create: bool, extracted: Optional[List[str]], **kwargs): group_names = extracted or [CommonGroups.User] self.groups.add(*[Group.objects.get_or_create(name=group_name)[0] for group_name in group_names]) + @factory.post_generation + def sign_up_tenant(self, create: bool, extracted: Optional[List[str]], **kwargs): + if not create: + return + + Tenant.objects.get_or_create_user_default_tenant(self) + @factory.post_generation def admin(self, create, extracted, **kwargs): if extracted is None: @@ -98,4 +107,4 @@ class StripeCustomerFactory(factory.django.DjangoModelFactory): class Meta: model = Customer - subscriber = factory.SubFactory(UserFactory, profile=None) + subscriber = factory.SubFactory(TenantFactory) diff --git a/packages/backend/apps/users/tests/test_commands.py b/packages/backend/apps/users/tests/test_commands.py index c2322d56d..14adc5897 100644 --- a/packages/backend/apps/users/tests/test_commands.py +++ b/packages/backend/apps/users/tests/test_commands.py @@ -8,21 +8,14 @@ class TestInitCustomerCommand: + # NOTE: TenantFactory triggers UserFactory which generates default tenant for user. Mocks are called twice. @factory.django.mute_signals(signals.post_save) - def test_command_run_for_users_without_customer(self, stripe_customer_factory, user_factory, mocker): - mock = mocker.patch("apps.finances.services.subscriptions.initialize_user") - user = user_factory.create() - stripe_customer_factory.create() + def test_command_run_for_users_without_customer(self, tenant_factory, mocker): + mock = mocker.patch("apps.finances.services.subscriptions.initialize_tenant") + tenant = tenant_factory.create() Command().handle() - mock.assert_called_once_with(user=user) + _, last_call_kwargs = mock.call_args_list[-1] - @factory.django.mute_signals(signals.post_save) - def test_command_do_not_run_for_superusers(self, user_factory, mocker): - mock = mocker.patch("apps.finances.services.subscriptions.initialize_user") - user_factory.create(is_superuser=True) - - Command().handle() - - mock.assert_not_called() + assert last_call_kwargs == {"tenant": tenant} diff --git a/packages/backend/apps/users/tests/test_schema.py b/packages/backend/apps/users/tests/test_schema.py index 357c4923c..cf16ad42b 100644 --- a/packages/backend/apps/users/tests/test_schema.py +++ b/packages/backend/apps/users/tests/test_schema.py @@ -10,6 +10,7 @@ from rest_framework_simplejwt.tokens import RefreshToken, BlacklistedToken, AccessToken from .. import models, tokens from ..utils import generate_otp_auth_token +from apps.multitenancy.constants import TenantType pytestmark = pytest.mark.django_db @@ -35,6 +36,12 @@ class TestSignup: } ''' + @staticmethod + def _run_correct_sing_up_mutation(graphene_client, faker): + return graphene_client.mutate( + TestSignup.MUTATION, variable_values={'input': {'email': faker.email(), 'password': faker.password()}} + ) + def test_return_error_with_missing_email(self, graphene_client, faker): password = faker.password() executed = graphene_client.mutate(self.MUTATION, variable_values={'input': {'password': password}}) @@ -69,21 +76,23 @@ def test_create_user(self, graphene_client, faker): assert models.User.objects.get(email=email) def test_create_user_profile_instance(self, graphene_client, faker): - executed = graphene_client.mutate( - self.MUTATION, variable_values={'input': {'email': faker.email(), 'password': faker.password()}} - ) - + executed = TestSignup._run_correct_sing_up_mutation(graphene_client, faker) user = models.User.objects.get(id=executed['data']['signUp']["id"]) + assert user.profile def test_add_to_user_group(self, graphene_client, faker): - executed = graphene_client.mutate( - self.MUTATION, variable_values={'input': {'email': faker.email(), 'password': faker.password()}} - ) - + executed = TestSignup._run_correct_sing_up_mutation(graphene_client, faker) user = models.User.objects.get(id=executed['data']['signUp']["id"]) + assert user.has_group(CommonGroups.User) + def test_add_user_signup_tenant(self, graphene_client, faker): + executed = TestSignup._run_correct_sing_up_mutation(graphene_client, faker) + user = models.User.objects.get(id=executed['data']['signUp']["id"]) + + assert user.tenants.count() + class TestObtainToken: MUTATION = ''' @@ -148,6 +157,16 @@ def test_response_data(self, graphene_client, user_factory, user_avatar_factory) avatar otpEnabled otpVerified + tenants { + id + name + slug + type + membership{ + role + invitationToken + } + } } } ''' @@ -173,6 +192,48 @@ def test_response_data(self, graphene_client, user_factory, user_avatar_factory) ), data["avatar"] assert data["otpEnabled"] == user.otp_enabled assert data["otpVerified"] == user.otp_verified + assert len(data["tenants"]) > 0 + assert data["tenants"][0]["name"] == "test@example.com" + assert data["tenants"][0]["membership"]["role"] == "OWNER" + assert data["tenants"][0]["type"] == "default" + assert data["tenants"][0]["membership"]["invitationToken"] is None + + def test_response_data_invitation_token_active_invitation( + self, graphene_client, user_factory, tenant_factory, tenant_membership_factory + ): + query = ''' + query { + currentUser { + tenants { + type + membership{ + id + role + invitationAccepted + invitationToken + } + } + } + } + ''' + user = user_factory( + has_avatar=True, + email="test@example.com", + profile__first_name="Grzegorz", + profile__last_name="Brzęczyszczykiewicz", + groups=[CommonGroups.User, CommonGroups.Admin], + ) + tenant = tenant_factory(type=TenantType.ORGANIZATION) + tenant_membership_factory(is_accepted=False, user=user, tenant=tenant) + graphene_client.force_authenticate(user) + + executed = graphene_client.query(query) + data = executed["data"]["currentUser"] + for tenant in data["tenants"]: + if tenant["type"] == "default": + assert tenant["membership"]["invitationToken"] is None + else: + assert tenant["membership"]["invitationToken"] is not None def test_not_authenticated(self, graphene_client): executed = graphene_client.query( diff --git a/packages/backend/apps/users/tests/test_signals.py b/packages/backend/apps/users/tests/test_signals.py index 27b89e429..eca528366 100644 --- a/packages/backend/apps/users/tests/test_signals.py +++ b/packages/backend/apps/users/tests/test_signals.py @@ -5,20 +5,23 @@ class TestUserPostSaveSignal: - def test_signal_is_not_raising_exception_on_auth_error(self, user_factory, mocker): - mock = mocker.patch("apps.finances.services.subscriptions.initialize_user", side_effect=AuthenticationError()) + # NOTE: TenantFactory triggers UserFactory which generates default tenant for user. Mocks are called twice. + def test_signal_is_not_raising_exception_on_auth_error(self, tenant_factory, mocker): + mock = mocker.patch("apps.finances.services.subscriptions.initialize_tenant", side_effect=AuthenticationError()) sentry_mock = mocker.patch("apps.finances.signals.logger.error") - user_factory.create() + tenant = tenant_factory.create() - mock.assert_called_once() - sentry_mock.assert_called_once() + _, last_call_kwargs = mock.call_args_list[-1] - def test_reraise_stripe_error(self, user_factory, totp_mock, mocker): + assert last_call_kwargs == {"tenant": tenant} + assert sentry_mock.call_count == 2 + + def test_reraise_stripe_error(self, tenant_factory, totp_mock, mocker): initial_error = Exception - mocker.patch("apps.finances.services.subscriptions.initialize_user", side_effect=initial_error()) + mocker.patch("apps.finances.services.subscriptions.initialize_tenant", side_effect=initial_error()) with pytest.raises(initial_error) as error: - user_factory.create() + tenant_factory.create() assert initial_error == error.type diff --git a/packages/backend/common/acl/helpers.py b/packages/backend/common/acl/helpers.py index 61509dceb..9cdde5150 100644 --- a/packages/backend/common/acl/helpers.py +++ b/packages/backend/common/acl/helpers.py @@ -1,3 +1,6 @@ +from apps.multitenancy.constants import TenantUserRole + + def make_statement(action, effect, principal, condition=None): statement = { 'action': action, @@ -47,3 +50,9 @@ def id(name): @staticmethod def group(name): return f'group:{name}' + + +class TenantRoles: + Owner = [TenantUserRole.OWNER] + Admin = [TenantUserRole.OWNER, TenantUserRole.ADMIN] + Member = [TenantUserRole.OWNER, TenantUserRole.ADMIN, TenantUserRole.MEMBER] diff --git a/packages/backend/common/acl/policies.py b/packages/backend/common/acl/policies.py index 489d081f0..92cf8c12c 100644 --- a/packages/backend/common/acl/policies.py +++ b/packages/backend/common/acl/policies.py @@ -1,6 +1,6 @@ from rest_access_policy import AccessPolicy -from .helpers import make_statement, Action, Effect, Principal, CommonGroups +from .helpers import make_statement, Action, Effect, Principal, CommonGroups, TenantRoles class AdminFullAccess(AccessPolicy): @@ -21,3 +21,47 @@ class IsAuthenticatedFullAccess(AccessPolicy): class AnyoneFullAccess(AccessPolicy): statements = [make_statement(principal=Principal.Any, action=Action.Any, effect=Effect.Allow)] + + +class TenantDependentAccess(AccessPolicy): + def is_request_from_tenant_owner(self, request, view, action) -> bool: + return request.user_role in TenantRoles.Owner + + def is_request_from_tenant_admin(self, request, view, action) -> bool: + return request.user_role in TenantRoles.Admin + + def is_request_from_tenant_member(self, request, view, action) -> bool: + return request.user_role in TenantRoles.Member + + +class IsTenantOwnerAccess(TenantDependentAccess): + statements = [ + make_statement( + principal=Principal.Authenticated, + action=Action.Any, + effect=Effect.Allow, + condition=["is_request_from_tenant_owner"], + ) + ] + + +class IsTenantAdminAccess(TenantDependentAccess): + statements = [ + make_statement( + principal=Principal.Authenticated, + action=Action.Any, + effect=Effect.Allow, + condition=["is_request_from_tenant_admin"], + ) + ] + + +class IsTenantMemberAccess(TenantDependentAccess): + statements = [ + make_statement( + principal=Principal.Authenticated, + action=Action.Any, + effect=Effect.Allow, + condition=["is_request_from_tenant_member"], + ) + ] diff --git a/packages/backend/common/graphql/field_conversions.py b/packages/backend/common/graphql/field_conversions.py index d0ea0cc85..b9a9e7491 100644 --- a/packages/backend/common/graphql/field_conversions.py +++ b/packages/backend/common/graphql/field_conversions.py @@ -25,3 +25,14 @@ def convert_models_file_field_to_file_field_type(field, registry=None): @get_graphene_type_from_serializer_field.register(serializers.FileField) def convert_serializers_file_field_to_upload(field): return Upload + + +class TextChoicesFieldType(serializers.ChoiceField): + def __init__(self, choices, choices_class, **kwargs): + self.choices_class = choices_class + super().__init__(choices, **kwargs) + + +@get_graphene_type_from_serializer_field.register(TextChoicesFieldType) +def convert_serializer_field_to_enum(field): + return graphene.Enum.from_enum(field.choices_class) diff --git a/packages/backend/common/graphql/mutations.py b/packages/backend/common/graphql/mutations.py index f7f683e6e..8ba61e25a 100644 --- a/packages/backend/common/graphql/mutations.py +++ b/packages/backend/common/graphql/mutations.py @@ -615,7 +615,7 @@ def get_object(cls, id, **kwargs): return get_object_or_404(model, pk=pk, **kwargs) @classmethod - def mutate_and_get_payload(cls, root, info, id): + def mutate_and_get_payload(cls, root, info, id, **kwargs): """ Perform a mutation to delete a model instance @@ -627,3 +627,68 @@ def mutate_and_get_payload(cls, root, info, id): obj = cls.get_object(id) obj.delete() return cls(deleted_ids=[id]) + + +class DeleteTenantDependentModelMutation(DeleteModelMutation): + """ + `DeleteTenantDependentModelMutation` is a mutation class that inherits from + [`DeleteModelMutation`](#deletemodelmutation). + It is used to delete an object of a specified model from the database which is dependent on tenant. + It implements `tenant_id` field in input. + """ + + class Meta: + abstract = True + + class Input: + id = graphene.String() + tenant_id = graphene.String(required=True) + + @classmethod + def mutate_and_get_payload(cls, root, info, **input): + if "tenant_id" in input: + _, input["tenant_id"] = from_global_id(input["tenant_id"]) + return super().mutate_and_get_payload(root, info, **input) + + +class UpdateTenantDependentModelMutation(UpdateModelMutation): + """ + `UpdateTenantDependentModelMutation` is a mutation class that inherits from + [`UpdateModelMutation`](#updatemodelmutation). + It is used to update an object of a specified model in the database which is dependent on tenant. + It implements `tenant_id` field in input. + """ + + class Meta: + abstract = True + + class Input: + id = graphene.String() + tenant_id = graphene.String(required=True) + + @classmethod + def mutate_and_get_payload(cls, root, info, **input): + if "tenant_id" in input: + _, input["tenant_id"] = from_global_id(input["tenant_id"]) + return super().mutate_and_get_payload(root, info, **input) + + +class CreateTenantDependentModelMutation(CreateModelMutation): + """ + `CreateTenantDependentModelMutation` is a Relay mutation class that inherits from + [`CreateModelMutation`](#createmodelmutation). + It is used to create a new object of a specified model in the database which is dependent on tenant. + It implements `tenant_id` field in input. + """ + + class Meta: + abstract = True + + class Input: + tenant_id = graphene.String(required=True) + + @classmethod + def mutate_and_get_payload(cls, root, info, **input): + if "tenant_id" in input: + _, input["tenant_id"] = from_global_id(input["tenant_id"]) + return super().mutate_and_get_payload(root, info, **input) diff --git a/packages/backend/common/models.py b/packages/backend/common/models.py index 40f073f8f..fd12949ee 100644 --- a/packages/backend/common/models.py +++ b/packages/backend/common/models.py @@ -1,3 +1,4 @@ +from django.db import models from django.core.files.base import ContentFile from io import BytesIO from common.graphql import exceptions as graphql_exceptions @@ -31,3 +32,29 @@ def save(self, *args, **kwargs): if self.original: self.make_thumbnail() super().save(*args, **kwargs) + + +class TimestampedMixin(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class TenantDependentModelMixin(models.Model): + """ + A mixin class for models that are dependent on a specific tenant. + """ + + tenant = models.ForeignKey( + to="multitenancy.Tenant", + on_delete=models.CASCADE, + related_name="%(class)s_set", + verbose_name="Tenant", + blank=True, + null=True, + ) + + class Meta: + abstract = True diff --git a/packages/backend/common/tests/test_graphql/test_acl.py b/packages/backend/common/tests/test_graphql/test_acl.py index 3c52b0944..849fde601 100644 --- a/packages/backend/common/tests/test_graphql/test_acl.py +++ b/packages/backend/common/tests/test_graphql/test_acl.py @@ -158,39 +158,39 @@ def resolve_crud_demo_item_by_id(self, info, id): class TestPermissionClassesForMutationField: - def test_global_policy(self, graphene_client): + def test_global_policy(self, graphene_client, tenant): Mutation = self.create_mutation() schema = create_mutation_schema(Mutation) graphene_client.schema = schema - executed = self.call_mutation(graphene_client) + executed = self.call_mutation(graphene_client, tenant) assert executed["errors"][0]["message"] == "permission_denied", executed - def test_override_global_policy(self, graphene_client, user): + def test_override_global_policy(self, graphene_client, user, tenant): Mutation = self.create_mutation(mutation_policies=(policies.AdminFullAccess,)) schema = create_mutation_schema(Mutation) graphene_client.schema = schema graphene_client.force_authenticate(user) - executed = self.call_mutation(graphene_client) + executed = self.call_mutation(graphene_client, tenant) assert executed["errors"][0]["message"] == "permission_denied" - def test_global_policy_by_authenticated_user(self, graphene_client, user): + def test_global_policy_by_authenticated_user(self, graphene_client, user, tenant): Mutation = self.create_mutation() schema = create_mutation_schema(Mutation) graphene_client.schema = schema graphene_client.force_authenticate(user) - executed = self.call_mutation(graphene_client) + executed = self.call_mutation(graphene_client, tenant) assert executed['data']['createCrudDemoItem'] assert executed['data']['createCrudDemoItem']['crudDemoItem'] assert executed['data']['createCrudDemoItem']['crudDemoItem']['name'] == 'Item name' @staticmethod - def call_mutation(client): + def call_mutation(client, tenant): return client.mutate( """ mutation ($input: TestCreateCrudDemoItemMutationInput!){ @@ -202,7 +202,7 @@ def call_mutation(client): } } """, - variable_values={"input": {'name': 'Item name'}}, + variable_values={"input": {'name': 'Item name', "tenantId": to_global_id("TenantType", tenant.id)}}, ) @staticmethod @@ -217,7 +217,7 @@ class TestCrudDemoItemConnection(graphene.Connection): class Meta: node = TestCrudDemoItemType - class TestCreateCrudDemoItemMutation(mutations.CreateModelMutation): + class TestCreateCrudDemoItemMutation(mutations.CreateTenantDependentModelMutation): class Meta: serializer_class = serializers.CrudDemoItemSerializer edge_class = TestCrudDemoItemConnection.Edge diff --git a/packages/backend/config/schema.py b/packages/backend/config/schema.py index 32acc05ab..8b00bf134 100644 --- a/packages/backend/config/schema.py +++ b/packages/backend/config/schema.py @@ -5,19 +5,31 @@ from apps.notifications import schema as notifications_schema from apps.users import schema as users_schema from apps.integrations import schema as integrations_schema +from apps.multitenancy import schema as multitenancy_schema from common.graphql.utils import graphql_query, graphql_mutation, graphql_subscription schema = graphene.Schema( - query=graphql_query([demo_schema.Query, notifications_schema.Query, users_schema.Query, finances_schema.Query]), + query=graphql_query( + [ + demo_schema.Query, + notifications_schema.Query, + users_schema.Query, + finances_schema.Query, + multitenancy_schema.Query, + ] + ), mutation=graphql_mutation( [ demo_schema.Mutation, + demo_schema.TenantMemberMutation, notifications_schema.Mutation, users_schema.AnyoneMutation, users_schema.AuthenticatedMutation, users_schema.Mutation, finances_schema.Mutation, integrations_schema.Mutation, + multitenancy_schema.Mutation, + multitenancy_schema.TenantOwnerMutation, ] ), subscription=graphql_subscription(([notifications_schema.Subscription])), diff --git a/packages/backend/config/settings.py b/packages/backend/config/settings.py index 6a6444071..a01b55d5e 100644 --- a/packages/backend/config/settings.py +++ b/packages/backend/config/settings.py @@ -65,6 +65,7 @@ "apps.notifications", "apps.websockets", "apps.integrations", + "apps.multitenancy", ] INSTALLED_APPS = ( @@ -312,6 +313,14 @@ STRIPE_LIVE_MODE = env.bool("STRIPE_LIVE_MODE", default=False) DJSTRIPE_WEBHOOK_SECRET = env("DJSTRIPE_WEBHOOK_SECRET", default="") DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id" + + +def tenant_request_callback(request): + return request.tenant + + +DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK = tenant_request_callback +DJSTRIPE_SUBSCRIBER_MODEL = "multitenancy.Tenant" # Disable stripe checks for keys on django application start STRIPE_CHECKS_ENABLED = env.bool("STRIPE_CHECKS_ENABLED", default=True) if not STRIPE_CHECKS_ENABLED: @@ -324,7 +333,7 @@ GRAPHENE = { "SCHEMA": "config.schema.schema", "DEFAULT_PERMISSION_CLASSES": ("common.acl.policies.IsAuthenticatedFullAccess",), - "MIDDLEWARE": ["common.middleware.SentryMiddleware"], + "MIDDLEWARE": ["common.middleware.SentryMiddleware", "apps.multitenancy.middleware.TenantUserRoleMiddleware"], } NOTIFICATIONS_STRATEGIES = ["InAppNotificationStrategy"] @@ -361,3 +370,5 @@ UPLOADED_DOCUMENT_SIZE_LIMIT = env.int("UPLOADED_DOCUMENT_SIZE_LIMIT", default=10 * 1024 * 1024) USER_DOCUMENTS_NUMBER_LIMIT = env.int("USER_DOCUMENTS_NUMBER_LIMIT", default=10) + +TENANT_INVITATION_TIMEOUT = env("TENANT_INVITATION_TIMEOUT", default=60 * 60 * 24 * 14) diff --git a/packages/backend/conftest.py b/packages/backend/conftest.py index 0b57acebf..97a97866e 100644 --- a/packages/backend/conftest.py +++ b/packages/backend/conftest.py @@ -21,6 +21,7 @@ 'apps.content.tests.fixtures', 'apps.notifications.tests.fixtures', 'apps.integrations.tests.fixtures', + 'apps.multitenancy.tests.fixtures', ] @@ -39,6 +40,10 @@ def mutate(self, *args, **kwargs): def force_authenticate(self, user): self.execute_options["context_value"].user = user + def set_tenant_dependent_context(self, tenant, role): + self.execute_options["context_value"].tenant = tenant + self.execute_options["context_value"].user_role = role + def set_cookies(self, cookies): self.execute_options["context_value"].cookies = cookies self.execute_options["context_value"].COOKIES = {morsel.key: morsel.coded_value for morsel in cookies.values()} diff --git a/packages/internal/docs/.gitignore b/packages/internal/docs/.gitignore index 488b8e8b2..bd80eac74 100644 --- a/packages/internal/docs/.gitignore +++ b/packages/internal/docs/.gitignore @@ -26,4 +26,5 @@ yarn-error.log* docs/api-reference/webapp/generated docs/api-reference/webapp-core/generated docs/api-reference/webapp-api-client/generated +docs/api-reference/webapp-tenants/generated docs/api-reference/backend/generated diff --git a/packages/internal/docs/docs/api-reference/webapp-tenants/commands.mdx b/packages/internal/docs/docs/api-reference/webapp-tenants/commands.mdx new file mode 100644 index 000000000..510fd11f9 --- /dev/null +++ b/packages/internal/docs/docs/api-reference/webapp-tenants/commands.mdx @@ -0,0 +1,30 @@ +--- +title: CLI commands +description: Commands within `webapp-tenants` package context +--- + +This page contains a list of CLI commands that you can use within the `webapp-tenants` package context. + +--- + +### `pnpm run lint` + +Runs `eslint` and `stylelint` for the `webapp` package. + +--- + +### `pnpm run type-check` + +Runs `tsc` compilation with disabled files emitting. + +--- + +### `pnpm run test` + +Runs `jest` for the `webapp` package. + +--- + +### `pnpm run test:watch` + +Runs `jest` with `--watch` flag. Useful for development purpose. diff --git a/packages/internal/docs/docs/introduction/features/crud.mdx b/packages/internal/docs/docs/introduction/features/crud.mdx index c78dc1a7b..67fcf5d05 100644 --- a/packages/internal/docs/docs/introduction/features/crud.mdx +++ b/packages/internal/docs/docs/introduction/features/crud.mdx @@ -2,6 +2,7 @@ title: CRUD model generator --- import useBaseUrl from "@docusaurus/useBaseUrl"; +import ProjectName from '../../shared/components/ProjectName.component'; CRUD (Create, Read, Update, Delete) is a standard set of database operations that are commonly used in web applications. These operations allow developers to interact with the database and perform basic functions such as creating, reading, @@ -10,9 +11,10 @@ starting point for developers to build upon. ### CRUD Example Items -SaaS Boilerplate comes with a built-in example CRUD module under the `CRUD Example Items` tab in the application. This + comes with a built-in example CRUD module under the `CRUD Example Items` tab in the application. This module contains a list of created items that are stored in the database and loaded through the GraphQL backend API. -Users can create, update, and delete items from the list. +CRUD module in is tenant dependent. It means that CRUD Example Items are connected with tenant and +shared between tenant members who can create, update, and delete them from the list.

Items list diff --git a/packages/internal/docs/docs/introduction/features/multi-tenancy.mdx b/packages/internal/docs/docs/introduction/features/multi-tenancy.mdx new file mode 100644 index 000000000..5e84e8e9b --- /dev/null +++ b/packages/internal/docs/docs/introduction/features/multi-tenancy.mdx @@ -0,0 +1,38 @@ +--- +title: Multitenancy +description: Managing multiple tenants within a single account +--- +import ProjectName from '../../shared/components/ProjectName.component'; + +The multitenancy feature in enables users to create and manage multiple tenants within their account. +Each tenant operates autonomously, with its own settings, members, and resources. + +### Default Tenant + +Upon account creation, users are provided with a default tenant. This serves as the primary unit for their activities, +enabling immediate engagement without additional setup. + +### Tenant Creation + +Users can create additional tenants as needed, allowing them to manage multiple projects or entities within the same +account. + +### Membership Roles + +The multitenancy feature introduces three distinct roles for members within each tenant: + +- **Owner**: Owners have full control and authority over the tenant. They can manage settings, invite new members, +assign roles, and oversee tenant activities. +- **Admin**: Admins assist owners in managing the tenant. They possess similar privileges to owners but may have +limitations on certain administrative actions. +- **Member**: Members are regular users within the tenant, contributing to projects and collaborating with teammates. + +:::note +These roles are set up by default in the . Developers can customize these roles or add their own +configuration to suit the specific needs of their application. +::: + +### Invitation System + +Owners can invite new members to join their tenant, controlling access and ensuring secure collaboration. Invited +members receive notifications and can accept or decline invitations. diff --git a/packages/internal/docs/docs/introduction/features/payments.mdx b/packages/internal/docs/docs/introduction/features/payments.mdx index c83b807bf..fb3285a51 100644 --- a/packages/internal/docs/docs/introduction/features/payments.mdx +++ b/packages/internal/docs/docs/introduction/features/payments.mdx @@ -3,22 +3,28 @@ title: Payments and subscriptions description: Integration with Stripe --- import useBaseUrl from "@docusaurus/useBaseUrl"; +import ProjectName from '../../shared/components/ProjectName.component'; + + + provides a payments and subscriptions functionality seamlessly integrated with Stripe. +This feature empowers tenants to handle financial transactions and manage subscription plans directly within the application. +This functionality is divided into two parts: + +- **[payments](#payments)** +- **[subscription management](#subscription-management)** -SaaS Boilerplate provides a payments and subscriptions functionality out of the box based on the integration with -Stripe. This functionality is divided into two parts: **[payments](#payments)** and -**[subscription management](#subscription-management)**. ### Payments -The payments functionality allows users to define payment methods (including multiple cards) that are stored in the -Stripe merchant account. Users can edit and update their payment methods at any time. +The payments functionality allows tenant owners to define payment methods (including multiple cards) that are stored in the +Stripe merchant account. Tenant owners can edit and update their payment methods at any time.

Payment method form

-The payment form allows users to make transactions with a defined amount of USD, using an already added payment method -or adding a new card for one time charge. Successful transactions are added to the transaction history list. +Tenants can initiate transactions using the payment form, specifying the amount in USD and selecting a stored payment +method or adding a new card for one-time charges. Successful transactions are recorded in the transaction history for reference.

Payment form @@ -27,7 +33,7 @@ or adding a new card for one time charge. Successful transactions are added to t ### Subscription management -The subscription management functionality allows users to select one of the three pre-defined plans in the Stripe admin +The subscription management functionality allows tenant owners to select one of the three pre-defined plans in the Stripe admin panel: Free, Monthly, and Yearly. The selected plan is automatically registered as an active subscription in the system and can be used in the business logic of the application. The system supports changing and canceling subscriptions based on the rules defined by the application administrator in Stripe. @@ -47,7 +53,7 @@ or if you need to adjust it to meet the specific needs of your application, plea ---- Having Stripe payments integrated from the start of a SaaS project offers several advantages. First, it allows for a -seamless user experience, as users can easily subscribe to paid plans and make payments without leaving the application. +seamless user experience, as tenants can easily subscribe to paid plans and make payments without leaving the application. Second, it allows for better financial tracking and reporting, as all payment information is stored in the Stripe merchant account. Finally, it provides a scalable payment solution that can grow with the application, as Stripe supports a wide range of payment methods and currencies. diff --git a/packages/internal/docs/docs/shared/partials/_sb_description.mdx b/packages/internal/docs/docs/shared/partials/_sb_description.mdx index 82679612b..140d7a377 100644 --- a/packages/internal/docs/docs/shared/partials/_sb_description.mdx +++ b/packages/internal/docs/docs/shared/partials/_sb_description.mdx @@ -6,6 +6,7 @@ It consists following architecture modules: and Continuous Deployment - Application code that contains generic functionality like: - [Payments and Subscriptions](/introduction/features/payments.mdx) + - [Multi-tenancy](/introduction/features/multi-tenancy.mdx) is easy to manage multiple tenants within a single account - [Integration with Contentful CMS](/introduction/features/cms.mdx) as an example to be ready to build up upon - [Authorization](/introduction/features/auth.mdx) is ready to implement custom design or adjustments - [CRUD](/introduction/features/crud.mdx) as a shortcut to speed up the development diff --git a/packages/internal/docs/docs/working-with-sb/graphql/backend/adding-new-mutation.mdx b/packages/internal/docs/docs/working-with-sb/graphql/backend/adding-new-mutation.mdx index f9a32b559..58e4bf93b 100644 --- a/packages/internal/docs/docs/working-with-sb/graphql/backend/adding-new-mutation.mdx +++ b/packages/internal/docs/docs/working-with-sb/graphql/backend/adding-new-mutation.mdx @@ -166,6 +166,17 @@ See the API Reference for: - [DeleteModelMutation](../../../api-reference/backend/generated/common/graphql/mutations#deletemodelmutation) - and a more generic [SerializerMutation](../../../api-reference/backend/generated/common/graphql/mutations#serializermutation). +::: + +:::info + +For tenant dependent models there are prepared Mutation classes with already defined `tenant_id` field in input. You can +see usage example in CRUD module. Here is API Reference for above, all are inherited from base `ModelMutation` classes: +- [CreateTenantDependentModelMutation](../../../api-reference/backend/generated/common/graphql/mutations#createtenantdependentmodelmutation) +- [UpdateTenantDependentModelMutation](../../../api-reference/backend/generated/common/graphql/mutations#updatetenantdependentmodelmutation) +- [DeleteTenantDependentModelMutation](../../../api-reference/backend/generated/common/graphql/mutations#deletetenantdependentmodelmutation) + + ::: ### Create mutation fields in schema diff --git a/packages/internal/docs/docusaurus.config.js b/packages/internal/docs/docusaurus.config.js index 47e50f5d0..93d2baab2 100644 --- a/packages/internal/docs/docusaurus.config.js +++ b/packages/internal/docs/docusaurus.config.js @@ -124,6 +124,21 @@ module.exports = { watch: process.env.TYPEDOC_WATCH, }, ], + [ + 'docusaurus-plugin-typedoc', + { + id: 'typedoc-webapp-tenants', + entryPoints: [ + '../../webapp-libs/webapp-tenants/src/hooks/index.ts', + '../../webapp-libs/webapp-tenants/src/providers/index.ts', + '../../webapp-libs/webapp-tenants/src/tests/utils/rendering.tsx', + ], + tsconfig: '../../webapp-libs/webapp-tenants/tsconfig.lib.json', + out: 'api-reference/webapp-tenants/generated', + readme: 'none', + watch: process.env.TYPEDOC_WATCH, + }, + ], [ 'docusaurus-plugin-typedoc', { diff --git a/packages/internal/docs/sidebars.js b/packages/internal/docs/sidebars.js index 43b3b13ae..3d6bb76c4 100644 --- a/packages/internal/docs/sidebars.js +++ b/packages/internal/docs/sidebars.js @@ -118,6 +118,17 @@ module.exports = { collapsed: false, items: ['api-reference/webapp-emails/commands'], }, + { + type: 'category', + label: 'webapp-tenants', + collapsed: false, + items: [ + { + type: 'autogenerated', + dirName: 'api-reference/webapp-tenants/generated', + }, + ], + }, { type: 'category', label: 'tools', @@ -208,7 +219,7 @@ module.exports = { 'aws/architecture/cicd-architecture', ], }, - 'aws/troubleshooting' + 'aws/troubleshooting', ], }, ], @@ -238,6 +249,7 @@ module.exports = { 'introduction/features/emails', 'introduction/features/notifications', 'introduction/features/openai', + 'introduction/features/multi-tenancy', 'introduction/features/payments', 'introduction/features/iac', 'introduction/features/cicd', diff --git a/packages/webapp-libs/webapp-api-client/graphql/schema/api.graphql b/packages/webapp-libs/webapp-api-client/graphql/schema/api.graphql index bf98334f5..8ede3e108 100644 --- a/packages/webapp-libs/webapp-api-client/graphql/schema/api.graphql +++ b/packages/webapp-libs/webapp-api-client/graphql/schema/api.graphql @@ -36,20 +36,19 @@ that will be handled by the multipart request spec """ scalar Upload type Query { + allTenants(before: String, after: String, first: Int, last: Int): TenantConnection + tenant(id: ID): TenantType allSubscriptionPlans(before: String, after: String, first: Int, last: Int): StripePriceConnection - activeSubscription: SubscriptionScheduleType - allPaymentMethods(before: String, after: String, first: Int, last: Int): PaymentMethodConnection - allCharges(before: String, after: String, first: Int, last: Int): ChargeConnection - charge(id: ID): StripeChargeType - paymentIntent(id: ID): StripePaymentIntentType + activeSubscription(tenantId: ID): SubscriptionScheduleType + allPaymentMethods(tenantId: ID, before: String, after: String, first: Int, last: Int): PaymentMethodConnection + allCharges(tenantId: ID, before: String, after: String, first: Int, last: Int): ChargeConnection + charge(id: ID, tenantId: ID): StripeChargeType + paymentIntent(id: ID, tenantId: ID): StripePaymentIntentType currentUser: CurrentUserType hasUnreadNotifications: Boolean allNotifications(before: String, after: String, first: Int, last: Int): NotificationConnection - crudDemoItem( - "The ID of the object" - id: ID! - ): CrudDemoItemType - allCrudDemoItems(before: String, after: String, first: Int, last: Int): CrudDemoItemConnection + crudDemoItem(id: ID, tenantId: ID): CrudDemoItemType + allCrudDemoItems(tenantId: ID, before: String, after: String, first: Int, last: Int): CrudDemoItemConnection allContentfulDemoItemFavorites(before: String, after: String, first: Int, last: Int): ContentfulDemoItemFavoriteConnection allDocumentDemoItems(before: String, after: String, first: Int, last: Int): DocumentDemoItemConnection node( @@ -57,11 +56,11 @@ type Query { id: ID! ): Node } -type StripePriceConnection { +type TenantConnection { "Pagination data for this connection." pageInfo: PageInfo! "Contains the nodes in this connection." - edges: [StripePriceEdge]! + edges: [TenantEdge]! } "The Relay compliant `PageInfo` type, containing data necessary to paginate this connection." type PageInfo { @@ -74,6 +73,40 @@ type PageInfo { "When paginating forwards, the cursor to continue." endCursor: String } +"A Relay edge containing a `Tenant` and its cursor." +type TenantEdge { + "The item at the end of the edge" + node: TenantType + "A cursor for use in pagination" + cursor: String! +} +type TenantType implements Node { + id: ID! + name: String + slug: String + type: String + billingEmail: String + userMemberships: [TenantMembershipType] + membership: TenantMembershipType! +} +type TenantMembershipType implements Node { + id: ID! + role: TenantUserRole + inviteeEmailAddress: String + invitationAccepted: Boolean + userId: ID + invitationToken: String + firstName: String + lastName: String + userEmail: String + avatar: String +} +type StripePriceConnection { + "Pagination data for this connection." + pageInfo: PageInfo! + "Contains the nodes in this connection." + edges: [StripePriceEdge]! +} "A Relay edge containing a `StripePrice` and its cursor." type StripePriceEdge { "The item at the end of the edge" @@ -589,6 +622,7 @@ type CurrentUserType { email: String! otpEnabled: Boolean! otpVerified: Boolean! + tenants: [TenantType] firstName: String lastName: String roles: [String] @@ -625,6 +659,7 @@ type UserType { avatar: String } type CrudDemoItemType implements Node { + tenant: TenantType "The ID of the object" id: ID! name: String! @@ -657,12 +692,12 @@ type ContentfulDemoItemFavoriteEdge { cursor: String! } type ContentfulDemoItemFavoriteType implements Node { + createdAt: DateTime! + updatedAt: DateTime! "The ID of the object" id: ID! item: ContentfulDemoItemType! user: CurrentUserType! - createdAt: DateTime! - updatedAt: DateTime! } type ContentfulDemoItemType implements Node { "The ID of the object" @@ -710,6 +745,14 @@ type FileFieldType { name: String } type ApiMutation { + updateTenant(input: UpdateTenantMutationInput!): UpdateTenantMutationPayload + deleteTenant(input: DeleteTenantMutationInput!): DeleteTenantMutationPayload + createTenantInvitation(input: CreateTenantInvitationMutationInput!): CreateTenantInvitationMutationPayload + updateTenantMembership(input: UpdateTenantMembershipMutationInput!): UpdateTenantMembershipMutationPayload + createTenant(input: CreateTenantMutationInput!): CreateTenantMutationPayload + acceptTenantInvitation(input: AcceptTenantInvitationMutationInput!): AcceptTenantInvitationMutationPayload + declineTenantInvitation(input: DeclineTenantInvitationMutationInput!): DeclineTenantInvitationMutationPayload + deleteTenantMembership(input: DeleteTenantMembershipMutationInput!): DeleteTenantMembershipMutationPayload generateSaasIdeas(input: GenerateSaasIdeasMutationInput!): GenerateSaasIdeasMutationPayload changeActiveSubscription(input: ChangeActiveSubscriptionMutationInput!): ChangeActiveSubscriptionMutationPayload cancelActiveSubscription(input: CancelActiveSubscriptionMutationInput!): CancelActiveSubscriptionMutationPayload @@ -739,6 +782,44 @@ type ApiMutation { createFavoriteContentfulDemoItem(input: CreateFavoriteContentfulDemoItemMutationInput!): CreateFavoriteContentfulDemoItemMutationPayload deleteFavoriteContentfulDemoItem(input: DeleteFavoriteContentfulDemoItemMutationInput!): DeleteFavoriteContentfulDemoItemMutationPayload } +type UpdateTenantMutationPayload { + tenant: TenantType + tenantEdge: TenantEdge + clientMutationId: String +} +type DeleteTenantMutationPayload { + deletedIds: [ID] + clientMutationId: String +} +type CreateTenantInvitationMutationPayload { + email: String + role: TenantUserRole + tenantId: String + ok: Boolean + clientMutationId: String +} +type UpdateTenantMembershipMutationPayload { + tenantMembership: TenantMembershipType + tenantMembershipEdge: TenantEdge + clientMutationId: String +} +type CreateTenantMutationPayload { + tenant: TenantType + tenantEdge: TenantEdge + clientMutationId: String +} +type AcceptTenantInvitationMutationPayload { + ok: Boolean + clientMutationId: String +} +type DeclineTenantInvitationMutationPayload { + ok: Boolean + clientMutationId: String +} +type DeleteTenantMembershipMutationPayload { + deletedIds: [ID] + clientMutationId: String +} type GenerateSaasIdeasMutationPayload { ideas: [String] clientMutationId: String @@ -937,6 +1018,11 @@ interface Node { "The ID of the object" id: ID! } +enum TenantUserRole { + OWNER + ADMIN + MEMBER +} enum DjstripeProductTypeChoices { "Good" GOOD @@ -1351,35 +1437,88 @@ enum DjstripeSetupIntentUsageChoices { "On session" ON_SESSION } +input UpdateTenantMutationInput { + name: String! + billingEmail: String + id: ID! + clientMutationId: String +} +input DeleteTenantMutationInput { + id: String + clientMutationId: String +} +input CreateTenantInvitationMutationInput { + email: String! + role: TenantUserRole! + tenantId: String! + clientMutationId: String +} +input UpdateTenantMembershipMutationInput { + id: ID! + tenantId: String! + role: TenantUserRole! + clientMutationId: String +} +input CreateTenantMutationInput { + name: String! + billingEmail: String + clientMutationId: String +} +input AcceptTenantInvitationMutationInput { + id: String! + "Token" + token: String! + clientMutationId: String +} +input DeclineTenantInvitationMutationInput { + id: String! + "Token" + token: String! + clientMutationId: String +} +input DeleteTenantMembershipMutationInput { + id: String + tenantId: String! + clientMutationId: String +} input GenerateSaasIdeasMutationInput { keywords: [String] clientMutationId: String } input ChangeActiveSubscriptionMutationInput { + id: String + tenantId: String! price: String! clientMutationId: String } input CancelActiveSubscriptionMutationInput { + id: String + tenantId: String! clientMutationId: String } input UpdateDefaultPaymentMethodMutationInput { id: String + tenantId: String! clientMutationId: String } input DeletePaymentMethodMutationInput { id: String + tenantId: String! clientMutationId: String } input CreatePaymentIntentMutationInput { + tenantId: String! product: String! clientMutationId: String } input UpdatePaymentIntentMutationInput { - product: String! id: ID! + tenantId: String! + product: String! clientMutationId: String } input CreateSetupIntentMutationInput { + tenantId: String! clientMutationId: String } input ChangePasswordMutationInput { @@ -1448,18 +1587,21 @@ input MarkReadAllNotificationsMutationInput { clientMutationId: String } input CreateCrudDemoItemMutationInput { + tenantId: String! name: String! createdBy: String clientMutationId: String } input UpdateCrudDemoItemMutationInput { + id: ID! + tenantId: String! name: String! createdBy: String - id: ID! clientMutationId: String } input DeleteCrudDemoItemMutationInput { id: String + tenantId: String! clientMutationId: String } input CreateDocumentDemoItemMutationInput { diff --git a/packages/webapp-libs/webapp-api-client/src/constants/index.ts b/packages/webapp-libs/webapp-api-client/src/constants/index.ts new file mode 100644 index 000000000..de2a8735f --- /dev/null +++ b/packages/webapp-libs/webapp-api-client/src/constants/index.ts @@ -0,0 +1 @@ +export * from './tenant.types'; diff --git a/packages/webapp-libs/webapp-api-client/src/constants/tenant.types.ts b/packages/webapp-libs/webapp-api-client/src/constants/tenant.types.ts new file mode 100644 index 000000000..af3c2e6b5 --- /dev/null +++ b/packages/webapp-libs/webapp-api-client/src/constants/tenant.types.ts @@ -0,0 +1,4 @@ +export enum TenantType { + PERSONAL = 'default', + ORGANIZATION = 'organization', +} diff --git a/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/gql.ts b/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/gql.ts index 63b2f49c6..79b7b4e5b 100644 --- a/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/gql.ts +++ b/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/gql.ts @@ -15,7 +15,8 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/ const documents = { "\n query paginationListTestQuery($first: Int, $after: String, $last: Int, $before: String) {\n allNotifications(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n": types.PaginationListTestQueryDocument, "\n fragment commonQueryCurrentUserFragment on CurrentUserType {\n id\n email\n firstName\n lastName\n roles\n avatar\n otpVerified\n otpEnabled\n }\n": types.CommonQueryCurrentUserFragmentFragmentDoc, - "\n query commonQueryCurrentUserQuery {\n currentUser {\n ...commonQueryCurrentUserFragment\n }\n }\n": types.CommonQueryCurrentUserQueryDocument, + "\n fragment commonQueryTenantItemFragment on TenantType {\n id\n name\n type\n membership {\n id\n role\n invitationAccepted\n invitationToken\n }\n }\n": types.CommonQueryTenantItemFragmentFragmentDoc, + "\n query commonQueryCurrentUserQuery {\n currentUser {\n ...commonQueryCurrentUserFragment\n tenants {\n ...commonQueryTenantItemFragment\n }\n }\n }\n": types.CommonQueryCurrentUserQueryDocument, "\n query configContentfulAppConfigQuery {\n appConfigCollection(limit: 1) {\n items {\n name\n privacyPolicy\n termsAndConditions\n }\n }\n }\n": types.ConfigContentfulAppConfigQueryDocument, "\n mutation useFavoriteDemoItemListCreateMutation($input: CreateFavoriteContentfulDemoItemMutationInput!) {\n createFavoriteContentfulDemoItem(input: $input) {\n contentfulDemoItemFavoriteEdge {\n node {\n id\n item {\n pk\n }\n }\n }\n }\n }\n": types.UseFavoriteDemoItemListCreateMutationDocument, "\n fragment useFavoriteDemoItem_item on ContentfulDemoItemFavoriteType {\n id\n item {\n pk\n }\n }\n": types.UseFavoriteDemoItem_ItemFragmentDoc, @@ -25,13 +26,13 @@ const documents = { "\n fragment demoItemListItemFragment on DemoItem {\n title\n image {\n title\n url\n }\n }\n": types.DemoItemListItemFragmentFragmentDoc, "\n query demoItemsAllQuery {\n demoItemCollection {\n items {\n sys {\n id\n }\n ...demoItemListItemFragment\n }\n }\n }\n": types.DemoItemsAllQueryDocument, "\n mutation addCrudDemoItemMutation($input: CreateCrudDemoItemMutationInput!) {\n createCrudDemoItem(input: $input) {\n crudDemoItemEdge {\n node {\n id\n name\n }\n }\n }\n }\n": types.AddCrudDemoItemMutationDocument, - "\n query crudDemoItemDetailsQuery($id: ID!) {\n crudDemoItem(id: $id) {\n id\n name\n }\n }\n": types.CrudDemoItemDetailsQueryDocument, - "\n query crudDemoItemListQuery($first: Int, $after: String, $last: Int, $before: String) {\n allCrudDemoItems(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n ...crudDemoItemListItem\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n": types.CrudDemoItemListQueryDocument, + "\n query crudDemoItemDetailsQuery($id: ID!, $tenantId: ID!) {\n crudDemoItem(id: $id, tenantId: $tenantId) {\n id\n name\n }\n }\n": types.CrudDemoItemDetailsQueryDocument, + "\n query crudDemoItemListQuery($tenantId: ID!, $first: Int, $after: String, $last: Int, $before: String) {\n allCrudDemoItems(tenantId: $tenantId, first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n ...crudDemoItemListItem\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n": types.CrudDemoItemListQueryDocument, "\n query crudDemoItemListItemTestQuery {\n item: crudDemoItem(id: \"test-id\") {\n ...crudDemoItemListItem\n }\n }\n": types.CrudDemoItemListItemTestQueryDocument, "\n mutation crudDemoItemListItemDeleteMutation($input: DeleteCrudDemoItemMutationInput!) {\n deleteCrudDemoItem(input: $input) {\n deletedIds\n }\n }\n": types.CrudDemoItemListItemDeleteMutationDocument, "\n fragment crudDemoItemListItem on CrudDemoItemType {\n id\n name\n }\n": types.CrudDemoItemListItemFragmentDoc, "\n query crudDemoItemListItemDefaultStoryQuery {\n item: crudDemoItem(id: \"test-id\") {\n ...crudDemoItemListItem\n }\n }\n": types.CrudDemoItemListItemDefaultStoryQueryDocument, - "\n query editCrudDemoItemQuery($id: ID!) {\n crudDemoItem(id: $id) {\n id\n name\n }\n }\n": types.EditCrudDemoItemQueryDocument, + "\n query editCrudDemoItemQuery($id: ID!, $tenantId: ID!) {\n crudDemoItem(id: $id, tenantId: $tenantId) {\n id\n name\n }\n }\n": types.EditCrudDemoItemQueryDocument, "\n mutation editCrudDemoItemContentMutation($input: UpdateCrudDemoItemMutationInput!) {\n updateCrudDemoItem(input: $input) {\n crudDemoItem {\n id\n name\n }\n }\n }\n": types.EditCrudDemoItemContentMutationDocument, "\n fragment documentListItem on DocumentDemoItemType {\n id\n file {\n url\n name\n }\n createdAt\n }\n": types.DocumentListItemFragmentDoc, "\n query documentsListQuery {\n allDocumentDemoItems(first: 10) {\n edges {\n node {\n id\n ...documentListItem\n }\n }\n }\n }\n": types.DocumentsListQueryDocument, @@ -41,18 +42,18 @@ const documents = { "\n mutation stripeCreatePaymentIntentMutation_($input: CreatePaymentIntentMutationInput!) {\n createPaymentIntent(input: $input) {\n paymentIntent {\n ...stripePaymentIntentFragment\n id\n amount\n clientSecret\n currency\n pk\n }\n }\n }\n": types.StripeCreatePaymentIntentMutation_Document, "\n mutation stripeUpdatePaymentIntentMutation_($input: UpdatePaymentIntentMutationInput!) {\n updatePaymentIntent(input: $input) {\n paymentIntent {\n ...stripePaymentIntentFragment\n id\n amount\n clientSecret\n currency\n pk\n }\n }\n }\n": types.StripeUpdatePaymentIntentMutation_Document, "\n fragment stripePaymentMethodFragment on StripePaymentMethodType {\n id\n pk\n type\n card\n billingDetails\n }\n": types.StripePaymentMethodFragmentFragmentDoc, - "\n query stripeSubscriptionQuery {\n allPaymentMethods(first: 100) {\n edges {\n node {\n id\n pk\n type\n card\n billingDetails\n ...stripePaymentMethodFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n __typename\n }\n }\n": types.StripeSubscriptionQueryDocument, + "\n query stripeSubscriptionQuery($tenantId: ID!) {\n allPaymentMethods(tenantId: $tenantId, first: 100) {\n edges {\n node {\n id\n pk\n type\n card\n billingDetails\n ...stripePaymentMethodFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n\n activeSubscription(tenantId: $tenantId) {\n ...subscriptionActiveSubscriptionFragment\n id\n __typename\n }\n }\n": types.StripeSubscriptionQueryDocument, "\n mutation stripeDeletePaymentMethodMutation($input: DeletePaymentMethodMutationInput!) {\n deletePaymentMethod(input: $input) {\n deletedIds\n activeSubscription {\n defaultPaymentMethod {\n ...stripePaymentMethodFragment\n }\n }\n }\n }\n": types.StripeDeletePaymentMethodMutationDocument, "\n mutation stripeUpdateDefaultPaymentMethodMutation($input: UpdateDefaultPaymentMethodMutationInput!) {\n updateDefaultPaymentMethod(input: $input) {\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n paymentMethodEdge {\n node {\n ...stripePaymentMethodFragment\n id\n }\n }\n }\n }\n": types.StripeUpdateDefaultPaymentMethodMutationDocument, "\n fragment subscriptionActiveSubscriptionFragment on SubscriptionScheduleType {\n phases {\n startDate\n endDate\n trialEnd\n item {\n price {\n pk\n product {\n id\n name\n }\n unitAmount\n id\n }\n quantity\n }\n }\n subscription {\n startDate\n trialEnd\n trialStart\n id\n }\n canActivateTrial\n defaultPaymentMethod {\n ...stripePaymentMethodFragment_\n id\n }\n }\n\n fragment subscriptionPlanItemFragment on SubscriptionPlanType {\n id\n pk\n product {\n id\n name\n }\n amount\n }\n": types.SubscriptionActiveSubscriptionFragmentFragmentDoc, "\n fragment subscriptionActiveSubscriptionDetailsFragment on SubscriptionScheduleType {\n phases {\n startDate\n endDate\n trialEnd\n item {\n price {\n ...subscriptionPriceItemFragment\n id\n }\n quantity\n }\n }\n subscription {\n startDate\n trialEnd\n trialStart\n id\n }\n canActivateTrial\n defaultPaymentMethod {\n ...stripePaymentMethodFragment_\n id\n }\n }\n\n fragment stripePaymentMethodFragment_ on StripePaymentMethodType {\n id\n pk\n type\n card\n billingDetails\n }\n": types.SubscriptionActiveSubscriptionDetailsFragmentFragmentDoc, - "\n query subscriptionActivePlanDetailsQuery_ {\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n": types.SubscriptionActivePlanDetailsQuery_Document, + "\n query subscriptionActivePlanDetailsQuery_($tenantId: ID!) {\n activeSubscription(tenantId: $tenantId) {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n": types.SubscriptionActivePlanDetailsQuery_Document, "\n mutation subscriptionCancelActiveSubscriptionMutation($input: CancelActiveSubscriptionMutationInput!) {\n cancelActiveSubscription(input: $input) {\n subscriptionSchedule {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n }\n": types.SubscriptionCancelActiveSubscriptionMutationDocument, "\n mutation stripeCreateSetupIntentMutation_($input: CreateSetupIntentMutationInput!) {\n createSetupIntent(input: $input) {\n setupIntent {\n id\n ...stripeSetupIntentFragment\n }\n }\n }\n\n fragment stripeSetupIntentFragment on StripeSetupIntentType {\n id\n clientSecret\n }\n": types.StripeCreateSetupIntentMutation_Document, "\n mutation subscriptionChangeActiveSubscriptionMutation($input: ChangeActiveSubscriptionMutationInput!) {\n changeActiveSubscription(input: $input) {\n subscriptionSchedule {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n }\n": types.SubscriptionChangeActiveSubscriptionMutationDocument, "\n query subscriptionPlansAllQuery {\n allSubscriptionPlans(first: 100) {\n edges {\n node {\n ...subscriptionPriceItemFragment\n id\n pk\n product {\n id\n name\n }\n unitAmount\n }\n }\n }\n }\n": types.SubscriptionPlansAllQueryDocument, "\n fragment subscriptionPriceItemFragment on StripePriceType {\n id\n pk\n product {\n id\n name\n }\n unitAmount\n }\n": types.SubscriptionPriceItemFragmentFragmentDoc, - "\n query stripeAllChargesQuery {\n allCharges {\n edges {\n node {\n id\n ...stripeChargeFragment\n }\n }\n }\n }\n": types.StripeAllChargesQueryDocument, + "\n query stripeAllChargesQuery($tenantId: ID!) {\n allCharges(tenantId: $tenantId) {\n edges {\n node {\n id\n ...stripeChargeFragment\n }\n }\n }\n }\n": types.StripeAllChargesQueryDocument, "\n fragment stripeChargeFragment on StripeChargeType {\n id\n created\n billingDetails\n paymentMethod {\n ...stripePaymentMethodFragment\n id\n }\n amount\n invoice {\n id\n subscription {\n plan {\n ...subscriptionPlanItemFragment\n }\n }\n }\n }\n": types.StripeChargeFragmentFragmentDoc, "\n fragment subscriptionPlanItemFragment on SubscriptionPlanType {\n id\n pk\n product {\n id\n name\n }\n amount\n }\n": types.SubscriptionPlanItemFragmentFragmentDoc, "\n mutation generateSaasIdeasMutation($input: GenerateSaasIdeasMutationInput!) {\n generateSaasIdeas(input: $input) {\n ideas\n }\n }\n": types.GenerateSaasIdeasMutationDocument, @@ -63,6 +64,17 @@ const documents = { "\n fragment notificationsListContentFragment on Query {\n hasUnreadNotifications\n allNotifications(first: $count, after: $cursor) {\n edges {\n node {\n id\n ...notificationsListItemFragment\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n": types.NotificationsListContentFragmentFragmentDoc, "\n fragment notificationsListItemFragment on NotificationType {\n id\n data\n createdAt\n readAt\n type\n issuer {\n id\n avatar\n email\n }\n }\n": types.NotificationsListItemFragmentFragmentDoc, "\n mutation notificationsListMarkAsReadMutation($input: MarkReadAllNotificationsMutationInput!) {\n markReadAllNotifications(input: $input) {\n ok\n }\n }\n": types.NotificationsListMarkAsReadMutationDocument, + "\n mutation deleteTenantMutation($input: DeleteTenantMutationInput!) {\n deleteTenant(input: $input) {\n deletedIds\n clientMutationId\n }\n }\n": types.DeleteTenantMutationDocument, + "\n mutation updateTenantMembershipMutation($input: UpdateTenantMembershipMutationInput!) {\n updateTenantMembership(input: $input) {\n tenantMembership {\n id\n }\n }\n }\n": types.UpdateTenantMembershipMutationDocument, + "\n mutation deleteTenantMembershipMutation($input: DeleteTenantMembershipMutationInput!) {\n deleteTenantMembership(input: $input) {\n deletedIds\n clientMutationId\n }\n }\n": types.DeleteTenantMembershipMutationDocument, + "\n query tenantMembersListQuery($id: ID!) {\n tenant(id: $id) {\n userMemberships {\n id\n role\n invitationAccepted\n inviteeEmailAddress\n userId\n firstName\n lastName\n userEmail\n avatar\n }\n }\n }\n": types.TenantMembersListQueryDocument, + "\n fragment tenantFragment on TenantType {\n id\n name\n slug\n membership {\n role\n invitationAccepted\n }\n }\n": types.TenantFragmentFragmentDoc, + "\n query currentTenantQuery($id: ID!) {\n tenant(id: $id) {\n ...tenantFragment\n }\n }\n": types.CurrentTenantQueryDocument, + "\n mutation addTenantMutation($input: CreateTenantMutationInput!) {\n createTenant(input: $input) {\n tenantEdge {\n node {\n id\n name\n }\n }\n }\n }\n": types.AddTenantMutationDocument, + "\n mutation acceptTenantInvitationMutation($input: AcceptTenantInvitationMutationInput!) {\n acceptTenantInvitation(input: $input) {\n ok\n }\n }\n": types.AcceptTenantInvitationMutationDocument, + "\n mutation declineTenantInvitationMutation($input: DeclineTenantInvitationMutationInput!) {\n declineTenantInvitation(input: $input) {\n ok\n }\n }\n": types.DeclineTenantInvitationMutationDocument, + "\n mutation updateTenantMutation($input: UpdateTenantMutationInput!) {\n updateTenant(input: $input) {\n tenant {\n id\n name\n }\n }\n }\n": types.UpdateTenantMutationDocument, + "\n mutation createTenantInvitationMutation($input: CreateTenantInvitationMutationInput!) {\n createTenantInvitation(input: $input) {\n email\n role\n }\n }\n": types.CreateTenantInvitationMutationDocument, "\n mutation authConfirmUserEmailMutation($input: ConfirmEmailMutationInput!) {\n confirm(input: $input) {\n ok\n }\n }\n": types.AuthConfirmUserEmailMutationDocument, "\n mutation authChangePasswordMutation($input: ChangePasswordMutationInput!) {\n changePassword(input: $input) {\n access\n refresh\n }\n }\n": types.AuthChangePasswordMutationDocument, "\n mutation authUpdateUserProfileMutation($input: UpdateCurrentUserMutationInput!) {\n updateCurrentUser(input: $input) {\n userProfile {\n id\n user {\n ...commonQueryCurrentUserFragment\n }\n }\n }\n }\n": types.AuthUpdateUserProfileMutationDocument, @@ -101,7 +113,11 @@ export function gql(source: "\n fragment commonQueryCurrentUserFragment on Curr /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "\n query commonQueryCurrentUserQuery {\n currentUser {\n ...commonQueryCurrentUserFragment\n }\n }\n"): (typeof documents)["\n query commonQueryCurrentUserQuery {\n currentUser {\n ...commonQueryCurrentUserFragment\n }\n }\n"]; +export function gql(source: "\n fragment commonQueryTenantItemFragment on TenantType {\n id\n name\n type\n membership {\n id\n role\n invitationAccepted\n invitationToken\n }\n }\n"): (typeof documents)["\n fragment commonQueryTenantItemFragment on TenantType {\n id\n name\n type\n membership {\n id\n role\n invitationAccepted\n invitationToken\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n query commonQueryCurrentUserQuery {\n currentUser {\n ...commonQueryCurrentUserFragment\n tenants {\n ...commonQueryTenantItemFragment\n }\n }\n }\n"): (typeof documents)["\n query commonQueryCurrentUserQuery {\n currentUser {\n ...commonQueryCurrentUserFragment\n tenants {\n ...commonQueryTenantItemFragment\n }\n }\n }\n"]; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -141,11 +157,11 @@ export function gql(source: "\n mutation addCrudDemoItemMutation($input: Create /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "\n query crudDemoItemDetailsQuery($id: ID!) {\n crudDemoItem(id: $id) {\n id\n name\n }\n }\n"): (typeof documents)["\n query crudDemoItemDetailsQuery($id: ID!) {\n crudDemoItem(id: $id) {\n id\n name\n }\n }\n"]; +export function gql(source: "\n query crudDemoItemDetailsQuery($id: ID!, $tenantId: ID!) {\n crudDemoItem(id: $id, tenantId: $tenantId) {\n id\n name\n }\n }\n"): (typeof documents)["\n query crudDemoItemDetailsQuery($id: ID!, $tenantId: ID!) {\n crudDemoItem(id: $id, tenantId: $tenantId) {\n id\n name\n }\n }\n"]; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "\n query crudDemoItemListQuery($first: Int, $after: String, $last: Int, $before: String) {\n allCrudDemoItems(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n ...crudDemoItemListItem\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n"): (typeof documents)["\n query crudDemoItemListQuery($first: Int, $after: String, $last: Int, $before: String) {\n allCrudDemoItems(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n ...crudDemoItemListItem\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n"]; +export function gql(source: "\n query crudDemoItemListQuery($tenantId: ID!, $first: Int, $after: String, $last: Int, $before: String) {\n allCrudDemoItems(tenantId: $tenantId, first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n ...crudDemoItemListItem\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n"): (typeof documents)["\n query crudDemoItemListQuery($tenantId: ID!, $first: Int, $after: String, $last: Int, $before: String) {\n allCrudDemoItems(tenantId: $tenantId, first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n ...crudDemoItemListItem\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n"]; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -165,7 +181,7 @@ export function gql(source: "\n query crudDemoItemListItemDefaultStoryQuery {\n /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "\n query editCrudDemoItemQuery($id: ID!) {\n crudDemoItem(id: $id) {\n id\n name\n }\n }\n"): (typeof documents)["\n query editCrudDemoItemQuery($id: ID!) {\n crudDemoItem(id: $id) {\n id\n name\n }\n }\n"]; +export function gql(source: "\n query editCrudDemoItemQuery($id: ID!, $tenantId: ID!) {\n crudDemoItem(id: $id, tenantId: $tenantId) {\n id\n name\n }\n }\n"): (typeof documents)["\n query editCrudDemoItemQuery($id: ID!, $tenantId: ID!) {\n crudDemoItem(id: $id, tenantId: $tenantId) {\n id\n name\n }\n }\n"]; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -205,7 +221,7 @@ export function gql(source: "\n fragment stripePaymentMethodFragment on StripeP /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "\n query stripeSubscriptionQuery {\n allPaymentMethods(first: 100) {\n edges {\n node {\n id\n pk\n type\n card\n billingDetails\n ...stripePaymentMethodFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n __typename\n }\n }\n"): (typeof documents)["\n query stripeSubscriptionQuery {\n allPaymentMethods(first: 100) {\n edges {\n node {\n id\n pk\n type\n card\n billingDetails\n ...stripePaymentMethodFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n __typename\n }\n }\n"]; +export function gql(source: "\n query stripeSubscriptionQuery($tenantId: ID!) {\n allPaymentMethods(tenantId: $tenantId, first: 100) {\n edges {\n node {\n id\n pk\n type\n card\n billingDetails\n ...stripePaymentMethodFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n\n activeSubscription(tenantId: $tenantId) {\n ...subscriptionActiveSubscriptionFragment\n id\n __typename\n }\n }\n"): (typeof documents)["\n query stripeSubscriptionQuery($tenantId: ID!) {\n allPaymentMethods(tenantId: $tenantId, first: 100) {\n edges {\n node {\n id\n pk\n type\n card\n billingDetails\n ...stripePaymentMethodFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n\n activeSubscription(tenantId: $tenantId) {\n ...subscriptionActiveSubscriptionFragment\n id\n __typename\n }\n }\n"]; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -225,7 +241,7 @@ export function gql(source: "\n fragment subscriptionActiveSubscriptionDetailsF /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "\n query subscriptionActivePlanDetailsQuery_ {\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n"): (typeof documents)["\n query subscriptionActivePlanDetailsQuery_ {\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n"]; +export function gql(source: "\n query subscriptionActivePlanDetailsQuery_($tenantId: ID!) {\n activeSubscription(tenantId: $tenantId) {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n"): (typeof documents)["\n query subscriptionActivePlanDetailsQuery_($tenantId: ID!) {\n activeSubscription(tenantId: $tenantId) {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n"]; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -249,7 +265,7 @@ export function gql(source: "\n fragment subscriptionPriceItemFragment on Strip /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "\n query stripeAllChargesQuery {\n allCharges {\n edges {\n node {\n id\n ...stripeChargeFragment\n }\n }\n }\n }\n"): (typeof documents)["\n query stripeAllChargesQuery {\n allCharges {\n edges {\n node {\n id\n ...stripeChargeFragment\n }\n }\n }\n }\n"]; +export function gql(source: "\n query stripeAllChargesQuery($tenantId: ID!) {\n allCharges(tenantId: $tenantId) {\n edges {\n node {\n id\n ...stripeChargeFragment\n }\n }\n }\n }\n"): (typeof documents)["\n query stripeAllChargesQuery($tenantId: ID!) {\n allCharges(tenantId: $tenantId) {\n edges {\n node {\n id\n ...stripeChargeFragment\n }\n }\n }\n }\n"]; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -290,6 +306,50 @@ export function gql(source: "\n fragment notificationsListItemFragment on Notif * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql(source: "\n mutation notificationsListMarkAsReadMutation($input: MarkReadAllNotificationsMutationInput!) {\n markReadAllNotifications(input: $input) {\n ok\n }\n }\n"): (typeof documents)["\n mutation notificationsListMarkAsReadMutation($input: MarkReadAllNotificationsMutationInput!) {\n markReadAllNotifications(input: $input) {\n ok\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n mutation deleteTenantMutation($input: DeleteTenantMutationInput!) {\n deleteTenant(input: $input) {\n deletedIds\n clientMutationId\n }\n }\n"): (typeof documents)["\n mutation deleteTenantMutation($input: DeleteTenantMutationInput!) {\n deleteTenant(input: $input) {\n deletedIds\n clientMutationId\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n mutation updateTenantMembershipMutation($input: UpdateTenantMembershipMutationInput!) {\n updateTenantMembership(input: $input) {\n tenantMembership {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation updateTenantMembershipMutation($input: UpdateTenantMembershipMutationInput!) {\n updateTenantMembership(input: $input) {\n tenantMembership {\n id\n }\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n mutation deleteTenantMembershipMutation($input: DeleteTenantMembershipMutationInput!) {\n deleteTenantMembership(input: $input) {\n deletedIds\n clientMutationId\n }\n }\n"): (typeof documents)["\n mutation deleteTenantMembershipMutation($input: DeleteTenantMembershipMutationInput!) {\n deleteTenantMembership(input: $input) {\n deletedIds\n clientMutationId\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n query tenantMembersListQuery($id: ID!) {\n tenant(id: $id) {\n userMemberships {\n id\n role\n invitationAccepted\n inviteeEmailAddress\n userId\n firstName\n lastName\n userEmail\n avatar\n }\n }\n }\n"): (typeof documents)["\n query tenantMembersListQuery($id: ID!) {\n tenant(id: $id) {\n userMemberships {\n id\n role\n invitationAccepted\n inviteeEmailAddress\n userId\n firstName\n lastName\n userEmail\n avatar\n }\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n fragment tenantFragment on TenantType {\n id\n name\n slug\n membership {\n role\n invitationAccepted\n }\n }\n"): (typeof documents)["\n fragment tenantFragment on TenantType {\n id\n name\n slug\n membership {\n role\n invitationAccepted\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n query currentTenantQuery($id: ID!) {\n tenant(id: $id) {\n ...tenantFragment\n }\n }\n"): (typeof documents)["\n query currentTenantQuery($id: ID!) {\n tenant(id: $id) {\n ...tenantFragment\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n mutation addTenantMutation($input: CreateTenantMutationInput!) {\n createTenant(input: $input) {\n tenantEdge {\n node {\n id\n name\n }\n }\n }\n }\n"): (typeof documents)["\n mutation addTenantMutation($input: CreateTenantMutationInput!) {\n createTenant(input: $input) {\n tenantEdge {\n node {\n id\n name\n }\n }\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n mutation acceptTenantInvitationMutation($input: AcceptTenantInvitationMutationInput!) {\n acceptTenantInvitation(input: $input) {\n ok\n }\n }\n"): (typeof documents)["\n mutation acceptTenantInvitationMutation($input: AcceptTenantInvitationMutationInput!) {\n acceptTenantInvitation(input: $input) {\n ok\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n mutation declineTenantInvitationMutation($input: DeclineTenantInvitationMutationInput!) {\n declineTenantInvitation(input: $input) {\n ok\n }\n }\n"): (typeof documents)["\n mutation declineTenantInvitationMutation($input: DeclineTenantInvitationMutationInput!) {\n declineTenantInvitation(input: $input) {\n ok\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n mutation updateTenantMutation($input: UpdateTenantMutationInput!) {\n updateTenant(input: $input) {\n tenant {\n id\n name\n }\n }\n }\n"): (typeof documents)["\n mutation updateTenantMutation($input: UpdateTenantMutationInput!) {\n updateTenant(input: $input) {\n tenant {\n id\n name\n }\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n mutation createTenantInvitationMutation($input: CreateTenantInvitationMutationInput!) {\n createTenantInvitation(input: $input) {\n email\n role\n }\n }\n"): (typeof documents)["\n mutation createTenantInvitationMutation($input: CreateTenantInvitationMutationInput!) {\n createTenantInvitation(input: $input) {\n email\n role\n }\n }\n"]; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/graphql.ts b/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/graphql.ts index 323b2565f..aa732d1a7 100644 --- a/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/graphql.ts +++ b/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/graphql.ts @@ -55,8 +55,22 @@ export type Scalars = { Upload: { input: any; output: any; } }; +export type AcceptTenantInvitationMutationInput = { + clientMutationId?: InputMaybe; + id: Scalars['String']['input']; + /** Token */ + token: Scalars['String']['input']; +}; + +export type AcceptTenantInvitationMutationPayload = { + __typename?: 'AcceptTenantInvitationMutationPayload'; + clientMutationId?: Maybe; + ok?: Maybe; +}; + export type ApiMutation = { __typename?: 'ApiMutation'; + acceptTenantInvitation?: Maybe; cancelActiveSubscription?: Maybe; changeActiveSubscription?: Maybe; changePassword?: Maybe; @@ -66,10 +80,15 @@ export type ApiMutation = { createFavoriteContentfulDemoItem?: Maybe; createPaymentIntent?: Maybe; createSetupIntent?: Maybe; + createTenant?: Maybe; + createTenantInvitation?: Maybe; + declineTenantInvitation?: Maybe; deleteCrudDemoItem?: Maybe; deleteDocumentDemoItem?: Maybe; deleteFavoriteContentfulDemoItem?: Maybe; deletePaymentMethod?: Maybe; + deleteTenant?: Maybe; + deleteTenantMembership?: Maybe; disableOtp?: Maybe; generateOtp?: Maybe; generateSaasIdeas?: Maybe; @@ -83,11 +102,18 @@ export type ApiMutation = { updateDefaultPaymentMethod?: Maybe; updateNotification?: Maybe; updatePaymentIntent?: Maybe; + updateTenant?: Maybe; + updateTenantMembership?: Maybe; validateOtp?: Maybe; verifyOtp?: Maybe; }; +export type ApiMutationAcceptTenantInvitationArgs = { + input: AcceptTenantInvitationMutationInput; +}; + + export type ApiMutationCancelActiveSubscriptionArgs = { input: CancelActiveSubscriptionMutationInput; }; @@ -133,6 +159,21 @@ export type ApiMutationCreateSetupIntentArgs = { }; +export type ApiMutationCreateTenantArgs = { + input: CreateTenantMutationInput; +}; + + +export type ApiMutationCreateTenantInvitationArgs = { + input: CreateTenantInvitationMutationInput; +}; + + +export type ApiMutationDeclineTenantInvitationArgs = { + input: DeclineTenantInvitationMutationInput; +}; + + export type ApiMutationDeleteCrudDemoItemArgs = { input: DeleteCrudDemoItemMutationInput; }; @@ -153,6 +194,16 @@ export type ApiMutationDeletePaymentMethodArgs = { }; +export type ApiMutationDeleteTenantArgs = { + input: DeleteTenantMutationInput; +}; + + +export type ApiMutationDeleteTenantMembershipArgs = { + input: DeleteTenantMembershipMutationInput; +}; + + export type ApiMutationDisableOtpArgs = { input: DisableOtpMutationInput; }; @@ -218,6 +269,16 @@ export type ApiMutationUpdatePaymentIntentArgs = { }; +export type ApiMutationUpdateTenantArgs = { + input: UpdateTenantMutationInput; +}; + + +export type ApiMutationUpdateTenantMembershipArgs = { + input: UpdateTenantMembershipMutationInput; +}; + + export type ApiMutationValidateOtpArgs = { input: ValidateOtpMutationInput; }; @@ -487,7 +548,6 @@ export type AssetLinkingCollections = { export type AssetLinkingCollectionsDemoItemCollectionArgs = { limit?: InputMaybe; locale?: InputMaybe; - order?: InputMaybe>>; preview?: InputMaybe; skip?: InputMaybe; }; @@ -500,19 +560,6 @@ export type AssetLinkingCollectionsEntryCollectionArgs = { skip?: InputMaybe; }; -export enum AssetLinkingCollectionsDemoItemCollectionOrder { - SYS_FIRSTPUBLISHEDAT_ASC = 'sys_firstPublishedAt_ASC', - SYS_FIRSTPUBLISHEDAT_DESC = 'sys_firstPublishedAt_DESC', - SYS_ID_ASC = 'sys_id_ASC', - SYS_ID_DESC = 'sys_id_DESC', - SYS_PUBLISHEDAT_ASC = 'sys_publishedAt_ASC', - SYS_PUBLISHEDAT_DESC = 'sys_publishedAt_DESC', - SYS_PUBLISHEDVERSION_ASC = 'sys_publishedVersion_ASC', - SYS_PUBLISHEDVERSION_DESC = 'sys_publishedVersion_DESC', - TITLE_ASC = 'title_ASC', - TITLE_DESC = 'title_DESC' -} - export enum AssetOrder { CONTENTTYPE_ASC = 'contentType_ASC', CONTENTTYPE_DESC = 'contentType_DESC', @@ -538,6 +585,8 @@ export enum AssetOrder { export type CancelActiveSubscriptionMutationInput = { clientMutationId?: InputMaybe; + id?: InputMaybe; + tenantId: Scalars['String']['input']; }; export type CancelActiveSubscriptionMutationPayload = { @@ -549,7 +598,9 @@ export type CancelActiveSubscriptionMutationPayload = { export type ChangeActiveSubscriptionMutationInput = { clientMutationId?: InputMaybe; + id?: InputMaybe; price: Scalars['String']['input']; + tenantId: Scalars['String']['input']; }; export type ChangeActiveSubscriptionMutationPayload = { @@ -696,6 +747,7 @@ export type CreateCrudDemoItemMutationInput = { clientMutationId?: InputMaybe; createdBy?: InputMaybe; name: Scalars['String']['input']; + tenantId: Scalars['String']['input']; }; export type CreateCrudDemoItemMutationPayload = { @@ -734,6 +786,7 @@ export type CreateFavoriteContentfulDemoItemMutationPayload = { export type CreatePaymentIntentMutationInput = { clientMutationId?: InputMaybe; product: Scalars['String']['input']; + tenantId: Scalars['String']['input']; }; export type CreatePaymentIntentMutationPayload = { @@ -744,6 +797,7 @@ export type CreatePaymentIntentMutationPayload = { export type CreateSetupIntentMutationInput = { clientMutationId?: InputMaybe; + tenantId: Scalars['String']['input']; }; export type CreateSetupIntentMutationPayload = { @@ -752,6 +806,35 @@ export type CreateSetupIntentMutationPayload = { setupIntent?: Maybe; }; +export type CreateTenantInvitationMutationInput = { + clientMutationId?: InputMaybe; + email: Scalars['String']['input']; + role: TenantUserRole; + tenantId: Scalars['String']['input']; +}; + +export type CreateTenantInvitationMutationPayload = { + __typename?: 'CreateTenantInvitationMutationPayload'; + clientMutationId?: Maybe; + email?: Maybe; + ok?: Maybe; + role?: Maybe; + tenantId?: Maybe; +}; + +export type CreateTenantMutationInput = { + billingEmail?: InputMaybe; + clientMutationId?: InputMaybe; + name: Scalars['String']['input']; +}; + +export type CreateTenantMutationPayload = { + __typename?: 'CreateTenantMutationPayload'; + clientMutationId?: Maybe; + tenant?: Maybe; + tenantEdge?: Maybe; +}; + export type CrudDemoItemConnection = { __typename?: 'CrudDemoItemConnection'; /** Contains the nodes in this connection. */ @@ -775,6 +858,7 @@ export type CrudDemoItemType = Node & { /** The ID of the object */ id: Scalars['ID']['output']; name: Scalars['String']['output']; + tenant?: Maybe; }; /** A Relay edge containing a `CurrentUser` and its cursor. */ @@ -796,11 +880,26 @@ export type CurrentUserType = { otpEnabled: Scalars['Boolean']['output']; otpVerified: Scalars['Boolean']['output']; roles?: Maybe>>; + tenants?: Maybe>>; +}; + +export type DeclineTenantInvitationMutationInput = { + clientMutationId?: InputMaybe; + id: Scalars['String']['input']; + /** Token */ + token: Scalars['String']['input']; +}; + +export type DeclineTenantInvitationMutationPayload = { + __typename?: 'DeclineTenantInvitationMutationPayload'; + clientMutationId?: Maybe; + ok?: Maybe; }; export type DeleteCrudDemoItemMutationInput = { clientMutationId?: InputMaybe; id?: InputMaybe; + tenantId: Scalars['String']['input']; }; export type DeleteCrudDemoItemMutationPayload = { @@ -834,6 +933,7 @@ export type DeleteFavoriteContentfulDemoItemMutationPayload = { export type DeletePaymentMethodMutationInput = { clientMutationId?: InputMaybe; id?: InputMaybe; + tenantId: Scalars['String']['input']; }; export type DeletePaymentMethodMutationPayload = { @@ -843,6 +943,29 @@ export type DeletePaymentMethodMutationPayload = { deletedIds?: Maybe>>; }; +export type DeleteTenantMembershipMutationInput = { + clientMutationId?: InputMaybe; + id?: InputMaybe; + tenantId: Scalars['String']['input']; +}; + +export type DeleteTenantMembershipMutationPayload = { + __typename?: 'DeleteTenantMembershipMutationPayload'; + clientMutationId?: Maybe; + deletedIds?: Maybe>>; +}; + +export type DeleteTenantMutationInput = { + clientMutationId?: InputMaybe; + id?: InputMaybe; +}; + +export type DeleteTenantMutationPayload = { + __typename?: 'DeleteTenantMutationPayload'; + clientMutationId?: Maybe; + deletedIds?: Maybe>>; +}; + /** [See type definition](https://app.contentful.com/spaces/m7e7pnsr61vp/content_types/demoItem) */ export type DemoItem = Entry & { __typename?: 'DemoItem'; @@ -1685,6 +1808,7 @@ export type PaymentMethodEdge = { export type Query = { __typename?: 'Query'; + _node?: Maybe<_Node>; activeSubscription?: Maybe; allCharges?: Maybe; allContentfulDemoItemFavorites?: Maybe; @@ -1693,6 +1817,7 @@ export type Query = { allNotifications?: Maybe; allPaymentMethods?: Maybe; allSubscriptionPlans?: Maybe; + allTenants?: Maybe; appConfig?: Maybe; appConfigCollection?: Maybe; asset?: Maybe; @@ -1706,6 +1831,19 @@ export type Query = { hasUnreadNotifications?: Maybe; node?: Maybe; paymentIntent?: Maybe; + tenant?: Maybe; +}; + + +export type Query_NodeArgs = { + id: Scalars['ID']['input']; + locale?: InputMaybe; + preview?: InputMaybe; +}; + + +export type QueryActiveSubscriptionArgs = { + tenantId?: InputMaybe; }; @@ -1714,6 +1852,7 @@ export type QueryAllChargesArgs = { before?: InputMaybe; first?: InputMaybe; last?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1730,6 +1869,7 @@ export type QueryAllCrudDemoItemsArgs = { before?: InputMaybe; first?: InputMaybe; last?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1754,6 +1894,7 @@ export type QueryAllPaymentMethodsArgs = { before?: InputMaybe; first?: InputMaybe; last?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1765,6 +1906,14 @@ export type QueryAllSubscriptionPlansArgs = { }; +export type QueryAllTenantsArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; +}; + + export type QueryAppConfigArgs = { id: Scalars['String']['input']; locale?: InputMaybe; @@ -1801,11 +1950,13 @@ export type QueryAssetCollectionArgs = { export type QueryChargeArgs = { id?: InputMaybe; + tenantId?: InputMaybe; }; export type QueryCrudDemoItemArgs = { - id: Scalars['ID']['input']; + id?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1843,6 +1994,12 @@ export type QueryNodeArgs = { export type QueryPaymentIntentArgs = { id?: InputMaybe; + tenantId?: InputMaybe; +}; + + +export type QueryTenantArgs = { + id?: InputMaybe; }; export type SingUpMutationInput = { @@ -2539,11 +2696,60 @@ export type SysFilter = { publishedVersion_not_in?: InputMaybe>>; }; +export type TenantConnection = { + __typename?: 'TenantConnection'; + /** Contains the nodes in this connection. */ + edges: Array>; + /** Pagination data for this connection. */ + pageInfo: PageInfo; +}; + +/** A Relay edge containing a `Tenant` and its cursor. */ +export type TenantEdge = { + __typename?: 'TenantEdge'; + /** A cursor for use in pagination */ + cursor: Scalars['String']['output']; + /** The item at the end of the edge */ + node?: Maybe; +}; + +export type TenantMembershipType = Node & { + __typename?: 'TenantMembershipType'; + avatar?: Maybe; + firstName?: Maybe; + id: Scalars['ID']['output']; + invitationAccepted?: Maybe; + invitationToken?: Maybe; + inviteeEmailAddress?: Maybe; + lastName?: Maybe; + role?: Maybe; + userEmail?: Maybe; + userId?: Maybe; +}; + +export type TenantType = Node & { + __typename?: 'TenantType'; + billingEmail?: Maybe; + id: Scalars['ID']['output']; + membership: TenantMembershipType; + name?: Maybe; + slug?: Maybe; + type?: Maybe; + userMemberships?: Maybe>>; +}; + +export enum TenantUserRole { + ADMIN = 'ADMIN', + MEMBER = 'MEMBER', + OWNER = 'OWNER' +} + export type UpdateCrudDemoItemMutationInput = { clientMutationId?: InputMaybe; createdBy?: InputMaybe; id: Scalars['ID']['input']; name: Scalars['String']['input']; + tenantId: Scalars['String']['input']; }; export type UpdateCrudDemoItemMutationPayload = { @@ -2570,6 +2776,7 @@ export type UpdateCurrentUserMutationPayload = { export type UpdateDefaultPaymentMethodMutationInput = { clientMutationId?: InputMaybe; id?: InputMaybe; + tenantId: Scalars['String']['input']; }; export type UpdateDefaultPaymentMethodMutationPayload = { @@ -2597,6 +2804,7 @@ export type UpdatePaymentIntentMutationInput = { clientMutationId?: InputMaybe; id: Scalars['ID']['input']; product: Scalars['String']['input']; + tenantId: Scalars['String']['input']; }; export type UpdatePaymentIntentMutationPayload = { @@ -2605,6 +2813,34 @@ export type UpdatePaymentIntentMutationPayload = { paymentIntent?: Maybe; }; +export type UpdateTenantMembershipMutationInput = { + clientMutationId?: InputMaybe; + id: Scalars['ID']['input']; + role: TenantUserRole; + tenantId: Scalars['String']['input']; +}; + +export type UpdateTenantMembershipMutationPayload = { + __typename?: 'UpdateTenantMembershipMutationPayload'; + clientMutationId?: Maybe; + tenantMembership?: Maybe; + tenantMembershipEdge?: Maybe; +}; + +export type UpdateTenantMutationInput = { + billingEmail?: InputMaybe; + clientMutationId?: InputMaybe; + id: Scalars['ID']['input']; + name: Scalars['String']['input']; +}; + +export type UpdateTenantMutationPayload = { + __typename?: 'UpdateTenantMutationPayload'; + clientMutationId?: Maybe; + tenant?: Maybe; + tenantEdge?: Maybe; +}; + export type UserProfileType = Node & { __typename?: 'UserProfileType'; firstName: Scalars['String']['output']; @@ -2647,6 +2883,10 @@ export type VerifyOtpMutationPayload = { otpVerified?: Maybe; }; +export type _Node = { + _id: Scalars['ID']['output']; +}; + export type PaginationListTestQueryQueryVariables = Exact<{ first?: InputMaybe; after?: InputMaybe; @@ -2659,11 +2899,16 @@ export type PaginationListTestQueryQuery = { __typename?: 'Query', allNotificati export type CommonQueryCurrentUserFragmentFragment = { __typename?: 'CurrentUserType', id: string, email: string, firstName?: string | null, lastName?: string | null, roles?: Array | null, avatar?: string | null, otpVerified: boolean, otpEnabled: boolean } & { ' $fragmentName'?: 'CommonQueryCurrentUserFragmentFragment' }; +export type CommonQueryTenantItemFragmentFragment = { __typename?: 'TenantType', id: string, name?: string | null, type?: string | null, membership: { __typename?: 'TenantMembershipType', id: string, role?: TenantUserRole | null, invitationAccepted?: boolean | null, invitationToken?: string | null } } & { ' $fragmentName'?: 'CommonQueryTenantItemFragmentFragment' }; + export type CommonQueryCurrentUserQueryQueryVariables = Exact<{ [key: string]: never; }>; export type CommonQueryCurrentUserQueryQuery = { __typename?: 'Query', currentUser?: ( - { __typename?: 'CurrentUserType' } + { __typename?: 'CurrentUserType', tenants?: Array<( + { __typename?: 'TenantType' } + & { ' $fragmentRefs'?: { 'CommonQueryTenantItemFragmentFragment': CommonQueryTenantItemFragmentFragment } } + ) | null> | null } & { ' $fragmentRefs'?: { 'CommonQueryCurrentUserFragmentFragment': CommonQueryCurrentUserFragmentFragment } } ) | null }; @@ -2722,12 +2967,14 @@ export type AddCrudDemoItemMutationMutation = { __typename?: 'ApiMutation', crea export type CrudDemoItemDetailsQueryQueryVariables = Exact<{ id: Scalars['ID']['input']; + tenantId: Scalars['ID']['input']; }>; export type CrudDemoItemDetailsQueryQuery = { __typename?: 'Query', crudDemoItem?: { __typename?: 'CrudDemoItemType', id: string, name: string } | null }; export type CrudDemoItemListQueryQueryVariables = Exact<{ + tenantId: Scalars['ID']['input']; first?: InputMaybe; after?: InputMaybe; last?: InputMaybe; @@ -2767,6 +3014,7 @@ export type CrudDemoItemListItemDefaultStoryQueryQuery = { __typename?: 'Query', export type EditCrudDemoItemQueryQueryVariables = Exact<{ id: Scalars['ID']['input']; + tenantId: Scalars['ID']['input']; }>; @@ -2830,7 +3078,9 @@ export type StripeUpdatePaymentIntentMutation_Mutation = { __typename?: 'ApiMuta export type StripePaymentMethodFragmentFragment = { __typename?: 'StripePaymentMethodType', id: string, pk?: string | null, type: DjstripePaymentMethodTypeChoices, card?: any | null, billingDetails?: any | null } & { ' $fragmentName'?: 'StripePaymentMethodFragmentFragment' }; -export type StripeSubscriptionQueryQueryVariables = Exact<{ [key: string]: never; }>; +export type StripeSubscriptionQueryQueryVariables = Exact<{ + tenantId: Scalars['ID']['input']; +}>; export type StripeSubscriptionQueryQuery = { __typename?: 'Query', allPaymentMethods?: { __typename?: 'PaymentMethodConnection', edges: Array<{ __typename?: 'PaymentMethodEdge', cursor: string, node?: ( @@ -2881,7 +3131,9 @@ export type SubscriptionActiveSubscriptionDetailsFragmentFragment = { __typename export type StripePaymentMethodFragment_Fragment = { __typename?: 'StripePaymentMethodType', id: string, pk?: string | null, type: DjstripePaymentMethodTypeChoices, card?: any | null, billingDetails?: any | null } & { ' $fragmentName'?: 'StripePaymentMethodFragment_Fragment' }; -export type SubscriptionActivePlanDetailsQuery_QueryVariables = Exact<{ [key: string]: never; }>; +export type SubscriptionActivePlanDetailsQuery_QueryVariables = Exact<{ + tenantId: Scalars['ID']['input']; +}>; export type SubscriptionActivePlanDetailsQuery_Query = { __typename?: 'Query', activeSubscription?: ( @@ -2931,7 +3183,9 @@ export type SubscriptionPlansAllQueryQuery = { __typename?: 'Query', allSubscrip export type SubscriptionPriceItemFragmentFragment = { __typename?: 'StripePriceType', id: string, pk?: string | null, unitAmount?: any | null, product: { __typename?: 'StripeProductType', id: string, name: string } } & { ' $fragmentName'?: 'SubscriptionPriceItemFragmentFragment' }; -export type StripeAllChargesQueryQueryVariables = Exact<{ [key: string]: never; }>; +export type StripeAllChargesQueryQueryVariables = Exact<{ + tenantId: Scalars['ID']['input']; +}>; export type StripeAllChargesQueryQuery = { __typename?: 'Query', allCharges?: { __typename?: 'ChargeConnection', edges: Array<{ __typename?: 'ChargeEdge', node?: ( @@ -2996,6 +3250,81 @@ export type NotificationsListMarkAsReadMutationMutationVariables = Exact<{ export type NotificationsListMarkAsReadMutationMutation = { __typename?: 'ApiMutation', markReadAllNotifications?: { __typename?: 'MarkReadAllNotificationsMutationPayload', ok?: boolean | null } | null }; +export type DeleteTenantMutationMutationVariables = Exact<{ + input: DeleteTenantMutationInput; +}>; + + +export type DeleteTenantMutationMutation = { __typename?: 'ApiMutation', deleteTenant?: { __typename?: 'DeleteTenantMutationPayload', deletedIds?: Array | null, clientMutationId?: string | null } | null }; + +export type UpdateTenantMembershipMutationMutationVariables = Exact<{ + input: UpdateTenantMembershipMutationInput; +}>; + + +export type UpdateTenantMembershipMutationMutation = { __typename?: 'ApiMutation', updateTenantMembership?: { __typename?: 'UpdateTenantMembershipMutationPayload', tenantMembership?: { __typename?: 'TenantMembershipType', id: string } | null } | null }; + +export type DeleteTenantMembershipMutationMutationVariables = Exact<{ + input: DeleteTenantMembershipMutationInput; +}>; + + +export type DeleteTenantMembershipMutationMutation = { __typename?: 'ApiMutation', deleteTenantMembership?: { __typename?: 'DeleteTenantMembershipMutationPayload', deletedIds?: Array | null, clientMutationId?: string | null } | null }; + +export type TenantMembersListQueryQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type TenantMembersListQueryQuery = { __typename?: 'Query', tenant?: { __typename?: 'TenantType', userMemberships?: Array<{ __typename?: 'TenantMembershipType', id: string, role?: TenantUserRole | null, invitationAccepted?: boolean | null, inviteeEmailAddress?: string | null, userId?: string | null, firstName?: string | null, lastName?: string | null, userEmail?: string | null, avatar?: string | null } | null> | null } | null }; + +export type TenantFragmentFragment = { __typename?: 'TenantType', id: string, name?: string | null, slug?: string | null, membership: { __typename?: 'TenantMembershipType', role?: TenantUserRole | null, invitationAccepted?: boolean | null } } & { ' $fragmentName'?: 'TenantFragmentFragment' }; + +export type CurrentTenantQueryQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type CurrentTenantQueryQuery = { __typename?: 'Query', tenant?: ( + { __typename?: 'TenantType' } + & { ' $fragmentRefs'?: { 'TenantFragmentFragment': TenantFragmentFragment } } + ) | null }; + +export type AddTenantMutationMutationVariables = Exact<{ + input: CreateTenantMutationInput; +}>; + + +export type AddTenantMutationMutation = { __typename?: 'ApiMutation', createTenant?: { __typename?: 'CreateTenantMutationPayload', tenantEdge?: { __typename?: 'TenantEdge', node?: { __typename?: 'TenantType', id: string, name?: string | null } | null } | null } | null }; + +export type AcceptTenantInvitationMutationMutationVariables = Exact<{ + input: AcceptTenantInvitationMutationInput; +}>; + + +export type AcceptTenantInvitationMutationMutation = { __typename?: 'ApiMutation', acceptTenantInvitation?: { __typename?: 'AcceptTenantInvitationMutationPayload', ok?: boolean | null } | null }; + +export type DeclineTenantInvitationMutationMutationVariables = Exact<{ + input: DeclineTenantInvitationMutationInput; +}>; + + +export type DeclineTenantInvitationMutationMutation = { __typename?: 'ApiMutation', declineTenantInvitation?: { __typename?: 'DeclineTenantInvitationMutationPayload', ok?: boolean | null } | null }; + +export type UpdateTenantMutationMutationVariables = Exact<{ + input: UpdateTenantMutationInput; +}>; + + +export type UpdateTenantMutationMutation = { __typename?: 'ApiMutation', updateTenant?: { __typename?: 'UpdateTenantMutationPayload', tenant?: { __typename?: 'TenantType', id: string, name?: string | null } | null } | null }; + +export type CreateTenantInvitationMutationMutationVariables = Exact<{ + input: CreateTenantInvitationMutationInput; +}>; + + +export type CreateTenantInvitationMutationMutation = { __typename?: 'ApiMutation', createTenantInvitation?: { __typename?: 'CreateTenantInvitationMutationPayload', email?: string | null, role?: TenantUserRole | null } | null }; + export type AuthConfirmUserEmailMutationMutationVariables = Exact<{ input: ConfirmEmailMutationInput; }>; @@ -3077,6 +3406,7 @@ export type DisableOtpMutationVariables = Exact<{ export type DisableOtpMutation = { __typename?: 'ApiMutation', disableOtp?: { __typename?: 'DisableOTPMutationPayload', ok?: boolean | null } | null }; export const CommonQueryCurrentUserFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"commonQueryCurrentUserFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CurrentUserType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"otpVerified"}},{"kind":"Field","name":{"kind":"Name","value":"otpEnabled"}}]}}]} as unknown as DocumentNode; +export const CommonQueryTenantItemFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"commonQueryTenantItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TenantType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"membership"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitationAccepted"}},{"kind":"Field","name":{"kind":"Name","value":"invitationToken"}}]}}]}}]} as unknown as DocumentNode; export const UseFavoriteDemoItem_ItemFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"useFavoriteDemoItem_item"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ContentfulDemoItemFavoriteType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pk"}}]}}]}}]} as unknown as DocumentNode; export const DemoItemListItemFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"demoItemListItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"DemoItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; export const CrudDemoItemListItemFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"crudDemoItemListItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CrudDemoItemType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]} as unknown as DocumentNode; @@ -3093,8 +3423,9 @@ export const StripeChargeFragmentFragmentDoc = {"kind":"Document","definitions": export const NotificationsButtonContentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"notificationsButtonContent"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasUnreadNotifications"}}]}}]} as unknown as DocumentNode; export const NotificationsListItemFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"notificationsListItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"readAt"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; export const NotificationsListContentFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"notificationsListContentFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasUnreadNotifications"}},{"kind":"Field","name":{"kind":"Name","value":"allNotifications"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"count"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"notificationsListItemFragment"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"notificationsListItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"readAt"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; +export const TenantFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"tenantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TenantType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"membership"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitationAccepted"}}]}}]}}]} as unknown as DocumentNode; export const PaginationListTestQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"paginationListTestQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"last"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"before"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allNotifications"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"last"},"value":{"kind":"Variable","name":{"kind":"Name","value":"last"}}},{"kind":"Argument","name":{"kind":"Name","value":"before"},"value":{"kind":"Variable","name":{"kind":"Name","value":"before"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}}]} as unknown as DocumentNode; -export const CommonQueryCurrentUserQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"commonQueryCurrentUserQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"commonQueryCurrentUserFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"commonQueryCurrentUserFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CurrentUserType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"otpVerified"}},{"kind":"Field","name":{"kind":"Name","value":"otpEnabled"}}]}}]} as unknown as DocumentNode; +export const CommonQueryCurrentUserQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"commonQueryCurrentUserQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"commonQueryCurrentUserFragment"}},{"kind":"Field","name":{"kind":"Name","value":"tenants"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"commonQueryTenantItemFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"commonQueryCurrentUserFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CurrentUserType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"otpVerified"}},{"kind":"Field","name":{"kind":"Name","value":"otpEnabled"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"commonQueryTenantItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TenantType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"membership"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitationAccepted"}},{"kind":"Field","name":{"kind":"Name","value":"invitationToken"}}]}}]}}]} as unknown as DocumentNode; export const ConfigContentfulAppConfigQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"configContentfulAppConfigQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appConfigCollection"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"privacyPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"termsAndConditions"}}]}}]}}]}}]} as unknown as DocumentNode; export const UseFavoriteDemoItemListCreateMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"useFavoriteDemoItemListCreateMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateFavoriteContentfulDemoItemMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createFavoriteContentfulDemoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contentfulDemoItemFavoriteEdge"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pk"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const UseFavoriteDemoItemListDeleteMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"useFavoriteDemoItemListDeleteMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteFavoriteContentfulDemoItemMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteFavoriteContentfulDemoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletedIds"}}]}}]}}]} as unknown as DocumentNode; @@ -3102,32 +3433,42 @@ export const UseFavoriteDemoItemListQueryDocument = {"kind":"Document","definiti export const DemoItemQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"demoItemQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"demoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]}}]} as unknown as DocumentNode; export const DemoItemsAllQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"demoItemsAllQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"demoItemCollection"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"demoItemListItemFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"demoItemListItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"DemoItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; export const AddCrudDemoItemMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addCrudDemoItemMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateCrudDemoItemMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createCrudDemoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"crudDemoItemEdge"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const CrudDemoItemDetailsQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"crudDemoItemDetailsQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"crudDemoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; -export const CrudDemoItemListQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"crudDemoItemListQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"last"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"before"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allCrudDemoItems"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"last"},"value":{"kind":"Variable","name":{"kind":"Name","value":"last"}}},{"kind":"Argument","name":{"kind":"Name","value":"before"},"value":{"kind":"Variable","name":{"kind":"Name","value":"before"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"crudDemoItemListItem"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"crudDemoItemListItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CrudDemoItemType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]} as unknown as DocumentNode; +export const CrudDemoItemDetailsQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"crudDemoItemDetailsQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"crudDemoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"tenantId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; +export const CrudDemoItemListQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"crudDemoItemListQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"last"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"before"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allCrudDemoItems"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tenantId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"last"},"value":{"kind":"Variable","name":{"kind":"Name","value":"last"}}},{"kind":"Argument","name":{"kind":"Name","value":"before"},"value":{"kind":"Variable","name":{"kind":"Name","value":"before"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"crudDemoItemListItem"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"crudDemoItemListItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CrudDemoItemType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]} as unknown as DocumentNode; export const CrudDemoItemListItemTestQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"crudDemoItemListItemTestQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"item"},"name":{"kind":"Name","value":"crudDemoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"StringValue","value":"test-id","block":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"crudDemoItemListItem"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"crudDemoItemListItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CrudDemoItemType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]} as unknown as DocumentNode; export const CrudDemoItemListItemDeleteMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"crudDemoItemListItemDeleteMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteCrudDemoItemMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteCrudDemoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletedIds"}}]}}]}}]} as unknown as DocumentNode; export const CrudDemoItemListItemDefaultStoryQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"crudDemoItemListItemDefaultStoryQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"item"},"name":{"kind":"Name","value":"crudDemoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"StringValue","value":"test-id","block":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"crudDemoItemListItem"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"crudDemoItemListItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CrudDemoItemType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]} as unknown as DocumentNode; -export const EditCrudDemoItemQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"editCrudDemoItemQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"crudDemoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; +export const EditCrudDemoItemQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"editCrudDemoItemQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"crudDemoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"tenantId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const EditCrudDemoItemContentMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"editCrudDemoItemContentMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateCrudDemoItemMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateCrudDemoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"crudDemoItem"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; export const DocumentsListQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"documentsListQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allDocumentDemoItems"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"10"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"documentListItem"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"documentListItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentDemoItemType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"file"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; export const DocumentsListCreateMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"documentsListCreateMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateDocumentDemoItemMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createDocumentDemoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentDemoItemEdge"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"documentListItem"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"documentListItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentDemoItemType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"file"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; export const DocumentsDeleteMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"documentsDeleteMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteDocumentDemoItemMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteDocumentDemoItem"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletedIds"}}]}}]}}]} as unknown as DocumentNode; export const StripeCreatePaymentIntentMutation_Document = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"stripeCreatePaymentIntentMutation_"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreatePaymentIntentMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createPaymentIntent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"paymentIntent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentIntentFragment"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentIntentFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentIntentType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}}]}}]} as unknown as DocumentNode; export const StripeUpdatePaymentIntentMutation_Document = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"stripeUpdatePaymentIntentMutation_"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdatePaymentIntentMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updatePaymentIntent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"paymentIntent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentIntentFragment"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentIntentFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentIntentType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}}]}}]} as unknown as DocumentNode; -export const StripeSubscriptionQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"stripeSubscriptionQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allPaymentMethods"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentMethodFragment"}},{"kind":"Field","name":{"kind":"Name","value":"__typename"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"activeSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"__typename"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentMethodFragment_"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentMethodType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentMethodFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentMethodType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionScheduleType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"endDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"trialStart"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canActivateTrial"}},{"kind":"Field","name":{"kind":"Name","value":"defaultPaymentMethod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentMethodFragment_"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; +export const StripeSubscriptionQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"stripeSubscriptionQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allPaymentMethods"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tenantId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentMethodFragment"}},{"kind":"Field","name":{"kind":"Name","value":"__typename"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"activeSubscription"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tenantId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"__typename"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentMethodFragment_"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentMethodType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentMethodFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentMethodType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionScheduleType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"endDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"trialStart"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canActivateTrial"}},{"kind":"Field","name":{"kind":"Name","value":"defaultPaymentMethod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentMethodFragment_"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const StripeDeletePaymentMethodMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"stripeDeletePaymentMethodMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeletePaymentMethodMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletePaymentMethod"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletedIds"}},{"kind":"Field","name":{"kind":"Name","value":"activeSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"defaultPaymentMethod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentMethodFragment"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentMethodFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentMethodType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}}]}}]} as unknown as DocumentNode; export const StripeUpdateDefaultPaymentMethodMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"stripeUpdateDefaultPaymentMethodMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateDefaultPaymentMethodMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateDefaultPaymentMethod"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"paymentMethodEdge"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentMethodFragment"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentMethodFragment_"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentMethodType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionScheduleType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"endDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"trialStart"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canActivateTrial"}},{"kind":"Field","name":{"kind":"Name","value":"defaultPaymentMethod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentMethodFragment_"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentMethodFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentMethodType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}}]}}]} as unknown as DocumentNode; -export const SubscriptionActivePlanDetailsQuery_Document = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"subscriptionActivePlanDetailsQuery_"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentMethodFragment_"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentMethodType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionScheduleType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"endDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"trialStart"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canActivateTrial"}},{"kind":"Field","name":{"kind":"Name","value":"defaultPaymentMethod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentMethodFragment_"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; +export const SubscriptionActivePlanDetailsQuery_Document = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"subscriptionActivePlanDetailsQuery_"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeSubscription"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tenantId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentMethodFragment_"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentMethodType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionScheduleType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"endDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"trialStart"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canActivateTrial"}},{"kind":"Field","name":{"kind":"Name","value":"defaultPaymentMethod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentMethodFragment_"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const SubscriptionCancelActiveSubscriptionMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"subscriptionCancelActiveSubscriptionMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CancelActiveSubscriptionMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelActiveSubscription"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subscriptionSchedule"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentMethodFragment_"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentMethodType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionScheduleType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"endDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"trialStart"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canActivateTrial"}},{"kind":"Field","name":{"kind":"Name","value":"defaultPaymentMethod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentMethodFragment_"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const StripeCreateSetupIntentMutation_Document = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"stripeCreateSetupIntentMutation_"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateSetupIntentMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createSetupIntent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setupIntent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripeSetupIntentFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripeSetupIntentFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripeSetupIntentType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}}]}}]} as unknown as DocumentNode; export const SubscriptionChangeActiveSubscriptionMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"subscriptionChangeActiveSubscriptionMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ChangeActiveSubscriptionMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"changeActiveSubscription"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subscriptionSchedule"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentMethodFragment_"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentMethodType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"subscriptionActiveSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionScheduleType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"endDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"trialStart"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canActivateTrial"}},{"kind":"Field","name":{"kind":"Name","value":"defaultPaymentMethod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentMethodFragment_"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const SubscriptionPlansAllQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"subscriptionPlansAllQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allSubscriptionPlans"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"subscriptionPriceItemFragment"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"subscriptionPriceItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePriceType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}}]}}]} as unknown as DocumentNode; -export const StripeAllChargesQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"stripeAllChargesQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allCharges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripeChargeFragment"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentMethodFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentMethodType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"subscriptionPlanItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionPlanType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"amount"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripeChargeFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripeChargeType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"created"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}},{"kind":"Field","name":{"kind":"Name","value":"paymentMethod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentMethodFragment"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"invoice"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"subscriptionPlanItemFragment"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const StripeAllChargesQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"stripeAllChargesQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allCharges"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tenantId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripeChargeFragment"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripePaymentMethodFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripePaymentMethodType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"card"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"subscriptionPlanItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionPlanType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"pk"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"amount"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"stripeChargeFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StripeChargeType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"created"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"}},{"kind":"Field","name":{"kind":"Name","value":"paymentMethod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"stripePaymentMethodFragment"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"invoice"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"subscriptionPlanItemFragment"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GenerateSaasIdeasMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"generateSaasIdeasMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"GenerateSaasIdeasMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"generateSaasIdeas"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ideas"}}]}}]}}]} as unknown as DocumentNode; export const NotificationMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"notificationMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateNotificationMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateNotification"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasUnreadNotifications"}},{"kind":"Field","name":{"kind":"Name","value":"notificationEdge"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"readAt"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const NotificationsListQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"notificationsListQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"count"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"20"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"notificationsListContentFragment"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"notificationsButtonContent"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"notificationsListItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"readAt"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"notificationsListContentFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasUnreadNotifications"}},{"kind":"Field","name":{"kind":"Name","value":"allNotifications"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"count"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"notificationsListItemFragment"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"notificationsButtonContent"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasUnreadNotifications"}}]}}]} as unknown as DocumentNode; export const NotificationCreatedSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"NotificationCreatedSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"notificationCreated"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"notification"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"notificationsListItemFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"notificationsListItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"readAt"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; export const NotificationsListMarkAsReadMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"notificationsListMarkAsReadMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"MarkReadAllNotificationsMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"markReadAllNotifications"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const DeleteTenantMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteTenantMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteTenantMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteTenant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletedIds"}},{"kind":"Field","name":{"kind":"Name","value":"clientMutationId"}}]}}]}}]} as unknown as DocumentNode; +export const UpdateTenantMembershipMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateTenantMembershipMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateTenantMembershipMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateTenantMembership"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenantMembership"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const DeleteTenantMembershipMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteTenantMembershipMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteTenantMembershipMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteTenantMembership"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletedIds"}},{"kind":"Field","name":{"kind":"Name","value":"clientMutationId"}}]}}]}}]} as unknown as DocumentNode; +export const TenantMembersListQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"tenantMembersListQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userMemberships"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitationAccepted"}},{"kind":"Field","name":{"kind":"Name","value":"inviteeEmailAddress"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CurrentTenantQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"currentTenantQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"tenantFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"tenantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TenantType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"membership"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitationAccepted"}}]}}]}}]} as unknown as DocumentNode; +export const AddTenantMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addTenantMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateTenantMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createTenant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenantEdge"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const AcceptTenantInvitationMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"acceptTenantInvitationMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AcceptTenantInvitationMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"acceptTenantInvitation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const DeclineTenantInvitationMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"declineTenantInvitationMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeclineTenantInvitationMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"declineTenantInvitation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const UpdateTenantMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateTenantMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateTenantMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateTenant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenant"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateTenantInvitationMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"createTenantInvitationMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateTenantInvitationMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createTenantInvitation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode; export const AuthConfirmUserEmailMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"authConfirmUserEmailMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ConfirmEmailMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"confirm"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const AuthChangePasswordMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"authChangePasswordMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ChangePasswordMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"changePassword"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"access"}},{"kind":"Field","name":{"kind":"Name","value":"refresh"}}]}}]}}]} as unknown as DocumentNode; export const AuthUpdateUserProfileMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"authUpdateUserProfileMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateCurrentUserMutationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateCurrentUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userProfile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"commonQueryCurrentUserFragment"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"commonQueryCurrentUserFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CurrentUserType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"otpVerified"}},{"kind":"Field","name":{"kind":"Name","value":"otpEnabled"}}]}}]} as unknown as DocumentNode; diff --git a/packages/webapp-libs/webapp-api-client/src/hooks/usePaginatedQuery/usePaginatedQuery.hook.ts b/packages/webapp-libs/webapp-api-client/src/hooks/usePaginatedQuery/usePaginatedQuery.hook.ts index 951a248d0..e48ac49a3 100644 --- a/packages/webapp-libs/webapp-api-client/src/hooks/usePaginatedQuery/usePaginatedQuery.hook.ts +++ b/packages/webapp-libs/webapp-api-client/src/hooks/usePaginatedQuery/usePaginatedQuery.hook.ts @@ -1,15 +1,13 @@ -import { QueryHookOptions, TypedDocumentNode, useQuery } from '@apollo/client'; +import { NoInfer, QueryHookOptions, TypedDocumentNode, useQuery } from '@apollo/client'; import { useCallback, useEffect, useState } from 'react'; -import { Exact, InputMaybe } from '@sb/webapp-api-client/graphql'; +import { InputMaybe } from '@sb/webapp-api-client/graphql'; -type CursorsInput = Exact<{ +type CursorsInput = { first?: InputMaybe | undefined; after?: InputMaybe | undefined; last?: InputMaybe | undefined; before?: InputMaybe | undefined; -}>; - -type ExtractGeneric = Type extends TypedDocumentNode ? QueryData : never; +}; /** * An usePaginatedQuery is a hook that allows you to retrieve data with ready-made logic for cursor-based bidirectional pagination. @@ -47,23 +45,27 @@ type ExtractGeneric = Type extends TypedDocumentNode ? Qu * */ -export const usePaginatedQuery = ( +export const usePaginatedQuery = < + A extends { [key: string]: any }, + B extends { [key: string]: any }, + T extends TypedDocumentNode, NoInfer>, +>( query: T, options: { - hookOptions?: QueryHookOptions, CursorsInput>; - dataKey: keyof ExtractGeneric; + hookOptions?: QueryHookOptions; + dataKey: keyof A; } ) => { const [cachedCursors, setCachedCursors] = useState>([]); const [hasPrevious, setHasPrevious] = useState(false); const [hasNext, setHasNext] = useState(false); - const { data, loading, fetchMore } = useQuery, CursorsInput>(query, options.hookOptions); + const { data, loading, fetchMore } = useQuery(query, options.hookOptions); useEffect(() => { if (cachedCursors.includes(data?.[options.dataKey]?.pageInfo.endCursor)) { - setCachedCursors([]) + setCachedCursors([]); } - }, [data, cachedCursors, options.dataKey]) + }, [data, cachedCursors, options.dataKey]); useEffect(() => { setHasPrevious(cachedCursors.length > 0); diff --git a/packages/webapp-libs/webapp-api-client/src/providers/commonQuery/commonQuery.graphql.ts b/packages/webapp-libs/webapp-api-client/src/providers/commonQuery/commonQuery.graphql.ts index 30bdc8a43..0d695e457 100644 --- a/packages/webapp-libs/webapp-api-client/src/providers/commonQuery/commonQuery.graphql.ts +++ b/packages/webapp-libs/webapp-api-client/src/providers/commonQuery/commonQuery.graphql.ts @@ -16,6 +16,23 @@ export const commonQueryCurrentUserFragment = gql(/* GraphQL */ ` } `); +/** + * @category graphql + */ +export const commonQueryTenantItemFragment = gql(/* GraphQL */ ` + fragment commonQueryTenantItemFragment on TenantType { + id + name + type + membership { + id + role + invitationAccepted + invitationToken + } + } +`); + /** * @category graphql */ @@ -23,6 +40,9 @@ export const commonQueryCurrentUserQuery = gql(/* GraphQL */ ` query commonQueryCurrentUserQuery { currentUser { ...commonQueryCurrentUserFragment + tenants { + ...commonQueryTenantItemFragment + } } } `); diff --git a/packages/webapp-libs/webapp-api-client/src/tests/factories/auth.ts b/packages/webapp-libs/webapp-api-client/src/tests/factories/auth.ts index e99e0b618..a89350d0d 100644 --- a/packages/webapp-libs/webapp-api-client/src/tests/factories/auth.ts +++ b/packages/webapp-libs/webapp-api-client/src/tests/factories/auth.ts @@ -1,5 +1,5 @@ import { Role } from '../../api/auth'; -import { CurrentUserType } from '../../graphql'; +import { CurrentUserType, TenantUserRole } from '../../graphql'; import { createFactory, makeId } from '../utils'; export const currentUserFactory = createFactory(() => ({ @@ -11,4 +11,19 @@ export const currentUserFactory = createFactory(() => ({ avatar: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/315.jpg', otpEnabled: false, otpVerified: false, + tenants: [ + { + id: makeId(32), + name: 'Tenant Name', + type: 'default', + __typename: 'TenantType', + membership: { + id: makeId(32), + invitationAccepted: true, + invitationToken: makeId(32), + role: TenantUserRole.OWNER, + __typename: 'TenantMembershipType', + }, + }, + ], })); diff --git a/packages/webapp-libs/webapp-api-client/src/tests/setupTests.ts b/packages/webapp-libs/webapp-api-client/src/tests/setupTests.ts index 292195508..051c3300d 100644 --- a/packages/webapp-libs/webapp-api-client/src/tests/setupTests.ts +++ b/packages/webapp-libs/webapp-api-client/src/tests/setupTests.ts @@ -13,4 +13,3 @@ afterAll(() => server.close()); // @ts-ignore axios.defaults.adapter = require('axios/lib/adapters/http'); - diff --git a/packages/webapp-libs/webapp-api-client/src/tests/utils/rendering.tsx b/packages/webapp-libs/webapp-api-client/src/tests/utils/rendering.tsx index fb52784f7..a1cb91a4d 100644 --- a/packages/webapp-libs/webapp-api-client/src/tests/utils/rendering.tsx +++ b/packages/webapp-libs/webapp-api-client/src/tests/utils/rendering.tsx @@ -100,7 +100,7 @@ export function getWrapper( export type CustomRenderOptions< Q extends Queries = typeof queries, Container extends Element | DocumentFragment = HTMLElement, - BaseElement extends Element | DocumentFragment = Container + BaseElement extends Element | DocumentFragment = Container, > = RenderOptions & WrapperProps; /** @@ -114,7 +114,7 @@ export type CustomRenderOptions< function customRender< Q extends Queries = typeof queries, Container extends Element | DocumentFragment = HTMLElement, - BaseElement extends Element | DocumentFragment = Container + BaseElement extends Element | DocumentFragment = Container, >( ui: ReactElement, options: CustomRenderOptions = {} diff --git a/packages/webapp-libs/webapp-contentful/graphql/schema/contentful.graphql b/packages/webapp-libs/webapp-contentful/graphql/schema/contentful.graphql index 14f2823bd..0e3a93eb4 100644 --- a/packages/webapp-libs/webapp-contentful/graphql/schema/contentful.graphql +++ b/packages/webapp-libs/webapp-contentful/graphql/schema/contentful.graphql @@ -19,6 +19,7 @@ type Query { appConfig(id: String!, preview: Boolean, locale: String): AppConfig appConfigCollection(skip: Int = 0, limit: Int = 100, preview: Boolean, locale: String, where: AppConfigFilter, order: [AppConfigOrder]): AppConfigCollection entryCollection(skip: Int = 0, limit: Int = 100, preview: Boolean, locale: String, where: EntryFilter, order: [EntryOrder]): EntryCollection + _node(id: ID!, preview: Boolean, locale: String): _Node } "Represents a binary file in a space. An asset can be any file type." type Asset { @@ -55,7 +56,7 @@ type ContentfulTag { } type AssetLinkingCollections { entryCollection(skip: Int = 0, limit: Int = 100, preview: Boolean, locale: String): EntryCollection - demoItemCollection(skip: Int = 0, limit: Int = 100, preview: Boolean, locale: String, order: [AssetLinkingCollectionsDemoItemCollectionOrder]): DemoItemCollection + demoItemCollection(skip: Int = 0, limit: Int = 100, preview: Boolean, locale: String): DemoItemCollection } type EntryCollection { total: Int! @@ -109,6 +110,9 @@ interface Entry { sys: Sys! contentfulMetadata: ContentfulMetadata! } +interface _Node { + _id: ID! +} enum ImageResizeStrategy { "Resizes the image to fit into the specified dimensions." FIT @@ -171,18 +175,6 @@ enum ImageFormat { WEBP AVIF } -enum AssetLinkingCollectionsDemoItemCollectionOrder { - title_ASC - title_DESC - sys_id_ASC - sys_id_DESC - sys_publishedAt_ASC - sys_publishedAt_DESC - sys_firstPublishedAt_ASC - sys_firstPublishedAt_DESC - sys_publishedVersion_ASC - sys_publishedVersion_DESC -} enum AssetOrder { url_ASC url_DESC diff --git a/packages/webapp-libs/webapp-core/package.json b/packages/webapp-libs/webapp-core/package.json index e34c88307..a9a2996a5 100644 --- a/packages/webapp-libs/webapp-core/package.json +++ b/packages/webapp-libs/webapp-core/package.json @@ -9,16 +9,21 @@ "type-check": "tsc --noEmit --project tsconfig.lib.json" }, "dependencies": { + "@radix-ui/react-alert-dialog": "1.0.5", "@radix-ui/react-avatar": "1.0.4", "@radix-ui/react-checkbox": "1.0.4", "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-dropdown-menu": "2.0.6", + "@radix-ui/react-icons": "1.3.0", "@radix-ui/react-label": "2.0.2", "@radix-ui/react-popover": "1.0.7", "@radix-ui/react-radio-group": "1.1.3", + "@radix-ui/react-select": "1.2.2", "@radix-ui/react-separator": "1.0.3", "@radix-ui/react-slot": "1.0.2", "@radix-ui/react-tabs": "1.0.4", "@radix-ui/react-toast": "1.1.5", + "@radix-ui/react-tooltip": "1.0.7", "color": "^4.2.3", "core-js": "^3.37.1", "lodash.throttle": "^4.1.1", diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/__tests__/alertDialog.component.spec.tsx b/packages/webapp-libs/webapp-core/src/components/alertDialog/__tests__/alertDialog.component.spec.tsx new file mode 100644 index 000000000..f67ef2551 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/__tests__/alertDialog.component.spec.tsx @@ -0,0 +1,59 @@ +import { fireEvent, screen } from '@testing-library/react'; +import React from 'react'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '../'; +import { render } from '../../../tests/utils/rendering'; + +const triggerText = 'Open'; +const titleText = 'Title'; +const descriptionText = 'Description'; +const cancelCTA = 'Cancel'; +const actionCTA = 'Continue'; + +const Component = () => ( + + {triggerText} + + + {titleText} + {descriptionText} + + + {cancelCTA} + {actionCTA} + + + +); + +describe('AlertDialog: Component', () => { + it('should render trigger only when not pressed', async () => { + render(); + + expect(await screen.findByText(triggerText)).toBeInTheDocument(); + expect(screen.queryByText(titleText)).not.toBeInTheDocument(); + }); + + it('should render content when pressed', async () => { + render(); + + expect(screen.queryByText(titleText)).not.toBeInTheDocument(); + expect(screen.queryByText(descriptionText)).not.toBeInTheDocument(); + + const button = await screen.findByText(triggerText); + fireEvent.click(button); + + expect(await screen.findByText(titleText)).toBeInTheDocument(); + expect(await screen.findByText(descriptionText)).toBeInTheDocument(); + }); +}); diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialog.component.tsx b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialog.component.tsx new file mode 100644 index 000000000..7150f1f49 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialog.component.tsx @@ -0,0 +1,9 @@ +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +export { AlertDialog, AlertDialogPortal, AlertDialogTrigger }; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialog.stories.tsx b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialog.stories.tsx new file mode 100644 index 000000000..2ceda8995 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialog.stories.tsx @@ -0,0 +1,45 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from './'; + +type Story = StoryObj; + +const meta: Meta = { + title: 'Core/AlertDialog', + component: AlertDialog, +}; + +export default meta; + +export const Default: Story = { + render: () => ( +

+ + Open + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your account and remove your data from our + servers. + + + + Cancel + Continue + + + +
+ ), +}; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogAction/alertDialogAction.component.tsx b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogAction/alertDialogAction.component.tsx new file mode 100644 index 000000000..26b8d1557 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogAction/alertDialogAction.component.tsx @@ -0,0 +1,14 @@ +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import * as React from 'react'; + +import { cn } from '../../../lib/utils'; +import { buttonVariants } from '../../buttons/button/button.styles'; + +export const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogAction/index.ts b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogAction/index.ts new file mode 100644 index 000000000..e01d72107 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogAction/index.ts @@ -0,0 +1 @@ +export * from './alertDialogAction.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogCancel/alertDialogCancel.component.tsx b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogCancel/alertDialogCancel.component.tsx new file mode 100644 index 000000000..bc125dade --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogCancel/alertDialogCancel.component.tsx @@ -0,0 +1,18 @@ +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import * as React from 'react'; + +import { cn } from '../../../lib/utils'; +import { buttonVariants } from '../../buttons/button/button.styles'; + +export const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogCancel/index.ts b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogCancel/index.ts new file mode 100644 index 000000000..a9e5f6718 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogCancel/index.ts @@ -0,0 +1 @@ +export * from './alertDialogCancel.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogContent/alertDialogContent.component.tsx b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogContent/alertDialogContent.component.tsx new file mode 100644 index 000000000..eef46f962 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogContent/alertDialogContent.component.tsx @@ -0,0 +1,25 @@ +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import * as React from 'react'; + +import { cn } from '../../../lib/utils'; +import { AlertDialogPortal } from '../alertDialog.component'; +import { AlertDialogOverlay } from '../alertDialogOverlay'; + +export const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); + +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogContent/index.ts b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogContent/index.ts new file mode 100644 index 000000000..752329301 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogContent/index.ts @@ -0,0 +1 @@ +export * from './alertDialogContent.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogDescription/alertDialogDescription.component.tsx b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogDescription/alertDialogDescription.component.tsx new file mode 100644 index 000000000..030e68d2b --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogDescription/alertDialogDescription.component.tsx @@ -0,0 +1,13 @@ +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import * as React from 'react'; + +import { cn } from '../../../lib/utils'; + +export const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogDescription/index.ts b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogDescription/index.ts new file mode 100644 index 000000000..f1a3242b0 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogDescription/index.ts @@ -0,0 +1 @@ +export * from './alertDialogDescription.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogFooter/alertDialogFooter.component.tsx b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogFooter/alertDialogFooter.component.tsx new file mode 100644 index 000000000..4cb7b5851 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogFooter/alertDialogFooter.component.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; + +import { cn } from '../../../lib/utils'; + +export const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); + +AlertDialogFooter.displayName = 'AlertDialogFooter'; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogFooter/index.ts b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogFooter/index.ts new file mode 100644 index 000000000..7bfbae3d0 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogFooter/index.ts @@ -0,0 +1 @@ +export * from './alertDialogFooter.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogHeader/alertDialogHeader.component.tsx b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogHeader/alertDialogHeader.component.tsx new file mode 100644 index 000000000..2a8324ac0 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogHeader/alertDialogHeader.component.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; + +import { cn } from '../../../lib/utils'; + +export const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); + +AlertDialogHeader.displayName = 'AlertDialogHeader'; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogHeader/index.ts b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogHeader/index.ts new file mode 100644 index 000000000..5db64c93f --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogHeader/index.ts @@ -0,0 +1 @@ +export * from './alertDialogHeader.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogOverlay/alertDialogOverlay.component.tsx b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogOverlay/alertDialogOverlay.component.tsx new file mode 100644 index 000000000..ae936db56 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogOverlay/alertDialogOverlay.component.tsx @@ -0,0 +1,19 @@ +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import * as React from 'react'; + +import { cn } from '../../../lib/utils'; + +export const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogOverlay/index.ts b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogOverlay/index.ts new file mode 100644 index 000000000..704e38f32 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogOverlay/index.ts @@ -0,0 +1 @@ +export * from './alertDialogOverlay.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogTitle/alertDialogTitle.component.tsx b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogTitle/alertDialogTitle.component.tsx new file mode 100644 index 000000000..83a72d5da --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogTitle/alertDialogTitle.component.tsx @@ -0,0 +1,13 @@ +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import * as React from 'react'; + +import { cn } from '../../../lib/utils'; + +export const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogTitle/index.ts b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogTitle/index.ts new file mode 100644 index 000000000..9485c8ee8 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/alertDialogTitle/index.ts @@ -0,0 +1 @@ +export * from './alertDialogTitle.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/index.ts b/packages/webapp-libs/webapp-core/src/components/alertDialog/index.ts new file mode 100644 index 000000000..8e047e9b2 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/index.ts @@ -0,0 +1,9 @@ +export * from './alertDialog.component'; +export * from './alertDialogAction'; +export * from './alertDialogCancel'; +export * from './alertDialogContent'; +export * from './alertDialogDescription'; +export * from './alertDialogFooter'; +export * from './alertDialogHeader'; +export * from './alertDialogOverlay'; +export * from './alertDialogTitle'; diff --git a/packages/webapp-libs/webapp-core/src/components/buttons/button/button.styles.ts b/packages/webapp-libs/webapp-core/src/components/buttons/button/button.styles.ts index 137aaad5d..995f77d2a 100644 --- a/packages/webapp-libs/webapp-core/src/components/buttons/button/button.styles.ts +++ b/packages/webapp-libs/webapp-core/src/components/buttons/button/button.styles.ts @@ -16,6 +16,7 @@ export const buttonVariants = cva( default: 'h-10 py-2 px-4', sm: 'h-9 px-3 rounded-md', lg: 'h-11 px-8 rounded-md', + icon: 'h-9 w-9', }, }, defaultVariants: { diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/__tests__/dropdownMenu.component.spec.tsx b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/__tests__/dropdownMenu.component.spec.tsx new file mode 100644 index 000000000..e947f5482 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/__tests__/dropdownMenu.component.spec.tsx @@ -0,0 +1,86 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '../'; +import { MockPointerEvent } from '../../../tests/mocks/pointerEvent'; +import { render } from '../../../tests/utils/rendering'; + +const triggerText = 'Trigger'; +const labelText = 'Label'; +const submenuTriggerText = 'Sub menu trigger'; +const submenuItemText = 'Submenu item'; + +const oldPointerEvent = window.PointerEvent; + +const Component = () => ( + + {triggerText} + + {labelText} + + + + Menu item + ⇧⌘P + + + + + + {submenuTriggerText} + + + {submenuItemText} + + + + + + +); + +describe('DropdownMenu', () => { + beforeEach(() => { + window.PointerEvent = MockPointerEvent as any; + }); + + afterEach(() => { + window.PointerEvent = oldPointerEvent; + }); + + it('should render dropdownMenu button', async () => { + render(); + + expect(screen.getByText(triggerText)).toBeInTheDocument(); + expect(screen.queryByText(labelText)).not.toBeInTheDocument(); + }); + + it('should render dropdownMenu content after click', async () => { + render(); + + fireEvent.pointerDown(screen.getByText(triggerText)); + expect(await screen.findByText(labelText)).toBeInTheDocument(); + }); + + it('should render submenu content after click', async () => { + render(); + + fireEvent.pointerDown(screen.getByText(triggerText), { button: 0, ctrlKey: false }); + expect(await screen.findByText(labelText)).toBeInTheDocument(); + + fireEvent.pointerMove(screen.getByText(submenuTriggerText)); + expect(await screen.findByText(submenuItemText)).toBeInTheDocument(); + }); +}); diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenu.component.tsx b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenu.component.tsx new file mode 100644 index 000000000..dbb6d744c --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenu.component.tsx @@ -0,0 +1,13 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; + +export const DropdownMenu = DropdownMenuPrimitive.Root; + +export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +export const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +export const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +export const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenu.stories.tsx b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenu.stories.tsx new file mode 100644 index 000000000..94686e8f0 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenu.stories.tsx @@ -0,0 +1,34 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { DropdownMenu, DropdownMenuTrigger } from './dropdownMenu.component'; +import { DropdownMenuContent } from './dropdownMenuContent'; +import { DropdownMenuItem } from './dropdownMenuItem'; +import { DropdownMenuLabel } from './dropdownMenuLabel'; +import { DropdownMenuSeparator } from './dropdownMenuSeparator'; + +const Template: StoryFn = () => { + return ( +
+ + Open + + My Account + + Profile + Billing + Team + Subscription + + +
+ ); +}; + +const meta: Meta = { + title: 'Core / Dropdown Menu', + component: Template, +}; + +export default meta; + +export const Default: StoryObj = {}; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuCheckboxItem/dropdownMenuCheckboxItem.component.tsx b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuCheckboxItem/dropdownMenuCheckboxItem.component.tsx new file mode 100644 index 000000000..f279af556 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuCheckboxItem/dropdownMenuCheckboxItem.component.tsx @@ -0,0 +1,29 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { CheckIcon } from '@radix-ui/react-icons'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; + +import { cn } from '../../..//lib/utils'; + +export const DropdownMenuCheckboxItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); + +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuCheckboxItem/index.ts b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuCheckboxItem/index.ts new file mode 100644 index 000000000..dc7783360 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuCheckboxItem/index.ts @@ -0,0 +1 @@ +export * from './dropdownMenuCheckboxItem.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuContent/dropdownMenuContent.component.tsx b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuContent/dropdownMenuContent.component.tsx new file mode 100644 index 000000000..9f6f224bc --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuContent/dropdownMenuContent.component.tsx @@ -0,0 +1,23 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; + +import { cn } from '../../../lib/utils'; + +export const DropdownMenuContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuContent/index.ts b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuContent/index.ts new file mode 100644 index 000000000..8d71a0132 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuContent/index.ts @@ -0,0 +1 @@ +export * from './dropdownMenuContent.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuItem/dropdownMenuItem.component.tsx b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuItem/dropdownMenuItem.component.tsx new file mode 100644 index 000000000..ec02680fc --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuItem/dropdownMenuItem.component.tsx @@ -0,0 +1,22 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; + +import { cn } from '../../../lib/utils'; + +export const DropdownMenuItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuItem/index.ts b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuItem/index.ts new file mode 100644 index 000000000..73b061d43 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuItem/index.ts @@ -0,0 +1 @@ +export * from './dropdownMenuItem.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuLabel/dropdownMenuLabel.component.tsx b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuLabel/dropdownMenuLabel.component.tsx new file mode 100644 index 000000000..d58966dfb --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuLabel/dropdownMenuLabel.component.tsx @@ -0,0 +1,19 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; + +import { cn } from '../../..//lib/utils'; + +export const DropdownMenuLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); + +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuLabel/index.ts b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuLabel/index.ts new file mode 100644 index 000000000..97844f30d --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuLabel/index.ts @@ -0,0 +1 @@ +export * from './dropdownMenuLabel.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuRadioItem/dropdownMenuRadioItem.component.tsx b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuRadioItem/dropdownMenuRadioItem.component.tsx new file mode 100644 index 000000000..0e862381a --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuRadioItem/dropdownMenuRadioItem.component.tsx @@ -0,0 +1,28 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { DotFilledIcon } from '@radix-ui/react-icons'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; + +import { cn } from '../../..//lib/utils'; + +export const DropdownMenuRadioItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); + +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuRadioItem/index.ts b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuRadioItem/index.ts new file mode 100644 index 000000000..85abba230 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuRadioItem/index.ts @@ -0,0 +1 @@ +export * from './dropdownMenuRadioItem.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSeparator/dropdownMenuSeparator.component.tsx b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSeparator/dropdownMenuSeparator.component.tsx new file mode 100644 index 000000000..fa9f726cd --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSeparator/dropdownMenuSeparator.component.tsx @@ -0,0 +1,14 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import * as React from 'react'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; + +import { cn } from '../../../lib/utils'; + +export const DropdownMenuSeparator = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSeparator/index.ts b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSeparator/index.ts new file mode 100644 index 000000000..68798b729 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSeparator/index.ts @@ -0,0 +1 @@ +export * from './dropdownMenuSeparator.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuShortcut/dropdownMenuShortcut.component.tsx b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuShortcut/dropdownMenuShortcut.component.tsx new file mode 100644 index 000000000..5c741db15 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuShortcut/dropdownMenuShortcut.component.tsx @@ -0,0 +1,9 @@ +import { HTMLAttributes } from 'react'; + +import { cn } from '../../..//lib/utils'; + +export const DropdownMenuShortcut = ({ className, ...props }: HTMLAttributes) => { + return ; +}; + +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuShortcut/index.ts b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuShortcut/index.ts new file mode 100644 index 000000000..93ba7639a --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuShortcut/index.ts @@ -0,0 +1 @@ +export * from './dropdownMenuShortcut.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSubContent/dropdownMenuSubContent.component.tsx b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSubContent/dropdownMenuSubContent.component.tsx new file mode 100644 index 000000000..631a31589 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSubContent/dropdownMenuSubContent.component.tsx @@ -0,0 +1,19 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; + +import { cn } from '../../../lib/utils'; + +export const DropdownMenuSubContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSubContent/index.ts b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSubContent/index.ts new file mode 100644 index 000000000..f2e8dd3ca --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSubContent/index.ts @@ -0,0 +1 @@ +export * from './dropdownMenuSubContent.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSubTrigger/dropdownMenuSubTrigger.component.tsx b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSubTrigger/dropdownMenuSubTrigger.component.tsx new file mode 100644 index 000000000..7e6b7aae2 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSubTrigger/dropdownMenuSubTrigger.component.tsx @@ -0,0 +1,25 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { ChevronRightIcon } from '@radix-ui/react-icons'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import { cn } from '@sb/webapp-core/lib/utils'; + +export const DropdownMenuSubTrigger = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSubTrigger/index.ts b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSubTrigger/index.ts new file mode 100644 index 000000000..b6f2bd595 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/dropdownMenuSubTrigger/index.ts @@ -0,0 +1 @@ +export * from './dropdownMenuSubTrigger.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/dropdownMenu/index.ts b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/index.ts new file mode 100644 index 000000000..a20626607 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/dropdownMenu/index.ts @@ -0,0 +1,10 @@ +export * from './dropdownMenu.component'; +export * from './dropdownMenuSubTrigger'; +export * from './dropdownMenuSubContent'; +export * from './dropdownMenuContent'; +export * from './dropdownMenuItem'; +export * from './dropdownMenuCheckboxItem'; +export * from './dropdownMenuRadioItem'; +export * from './dropdownMenuLabel'; +export * from './dropdownMenuSeparator'; +export * from './dropdownMenuShortcut'; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/index.ts b/packages/webapp-libs/webapp-core/src/components/forms/index.ts index 76f58476f..4da72080b 100644 --- a/packages/webapp-libs/webapp-core/src/components/forms/index.ts +++ b/packages/webapp-libs/webapp-core/src/components/forms/index.ts @@ -2,3 +2,4 @@ export * from './input'; export * from './dropzone'; export * from './checkbox'; export * from './form'; +export * from './select'; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/__tests__/select.component.spec.tsx b/packages/webapp-libs/webapp-core/src/components/forms/select/__tests__/select.component.spec.tsx new file mode 100644 index 000000000..768236a9b --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/__tests__/select.component.spec.tsx @@ -0,0 +1,48 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../'; +import { MockPointerEvent } from '../../../../tests/mocks/pointerEvent'; +import { render } from '../../../../tests/utils/rendering'; + +const placeholderText = 'Select'; +const option1Text = 'Option 1'; +const option2Text = 'Option 2'; + +const oldPointerEvent = window.PointerEvent; + +const Component = () => ( + +); + +describe('Select', () => { + beforeEach(() => { + window.PointerEvent = MockPointerEvent as any; + }); + + afterEach(() => { + window.PointerEvent = oldPointerEvent; + }); + + it('should render select trigger button', async () => { + render(); + + expect(screen.getByText(placeholderText)).toBeInTheDocument(); + expect(screen.queryByText(option1Text)).not.toBeInTheDocument(); + }); + + it('should render select content after click', async () => { + render(); + + fireEvent.pointerDown(screen.getByText(placeholderText)); + expect(await screen.findByText(option1Text)).toBeInTheDocument(); + }); +}); diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/index.ts b/packages/webapp-libs/webapp-core/src/components/forms/select/index.ts new file mode 100644 index 000000000..debaf6445 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/index.ts @@ -0,0 +1,8 @@ +export { Select, SelectValue, SelectGroup } from './select.component'; +export { SelectTrigger } from './selectTrigger'; +export { SelectScrollUpButton } from './selectScrollUpButton'; +export { SelectScrollDownButton } from './selectScrollDownButton'; +export { SelectContent } from './selectContent'; +export { SelectLabel } from './selectLabel'; +export { SelectItem } from './selectItem'; +export { SelectSeparator } from './selectSeparator'; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/select.component.tsx b/packages/webapp-libs/webapp-core/src/components/forms/select/select.component.tsx new file mode 100644 index 000000000..4b026dc01 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/select.component.tsx @@ -0,0 +1,9 @@ +import * as SelectPrimitive from '@radix-ui/react-select'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +export { Select, SelectGroup, SelectValue }; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/select.stories.tsx b/packages/webapp-libs/webapp-core/src/components/forms/select/select.stories.tsx new file mode 100644 index 000000000..fd7c7d349 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/select.stories.tsx @@ -0,0 +1,32 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { Select, SelectValue } from './select.component'; +import { SelectContent } from './selectContent'; +import { SelectItem } from './selectItem'; +import { SelectTrigger } from './selectTrigger'; + +const Template: StoryFn = () => { + return ( +
+ +
+ ); +}; + +const meta: Meta = { + title: 'Core / Forms / Select', + component: Template, +}; + +export default meta; + +export const Default: StoryObj = {}; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectContent/index.ts b/packages/webapp-libs/webapp-core/src/components/forms/select/selectContent/index.ts new file mode 100644 index 000000000..9e25b18fb --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectContent/index.ts @@ -0,0 +1 @@ +export { SelectContent } from './selectContent.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectContent/selectContent.component.tsx b/packages/webapp-libs/webapp-core/src/components/forms/select/selectContent/selectContent.component.tsx new file mode 100644 index 000000000..c6f93aa3b --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectContent/selectContent.component.tsx @@ -0,0 +1,41 @@ +import * as SelectPrimitive from '@radix-ui/react-select'; +import React from 'react'; + +import { cn } from '../../../../lib/utils'; +import { SelectScrollDownButton } from '../selectScrollDownButton'; +import { SelectScrollUpButton } from '../selectScrollUpButton'; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); + +SelectContent.displayName = SelectPrimitive.Content.displayName; + +export { SelectContent }; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectItem/index.ts b/packages/webapp-libs/webapp-core/src/components/forms/select/selectItem/index.ts new file mode 100644 index 000000000..69b739094 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectItem/index.ts @@ -0,0 +1 @@ +export { SelectItem } from './selectItem.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectItem/selectItem.component.tsx b/packages/webapp-libs/webapp-core/src/components/forms/select/selectItem/selectItem.component.tsx new file mode 100644 index 000000000..562eb6ca9 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectItem/selectItem.component.tsx @@ -0,0 +1,31 @@ +import * as SelectPrimitive from '@radix-ui/react-select'; +import { Check } from 'lucide-react'; +import React from 'react'; + +import { cn } from '../../../../lib/utils'; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); + +SelectItem.displayName = SelectPrimitive.Item.displayName; + +export { SelectItem }; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectLabel/index.ts b/packages/webapp-libs/webapp-core/src/components/forms/select/selectLabel/index.ts new file mode 100644 index 000000000..00cb2df2d --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectLabel/index.ts @@ -0,0 +1 @@ +export { SelectLabel } from './selectLabel.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectLabel/selectLabel.component.tsx b/packages/webapp-libs/webapp-core/src/components/forms/select/selectLabel/selectLabel.component.tsx new file mode 100644 index 000000000..82c079c36 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectLabel/selectLabel.component.tsx @@ -0,0 +1,15 @@ +import * as SelectPrimitive from '@radix-ui/react-select'; +import React from 'react'; + +import { cn } from '../../../../lib/utils'; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +export { SelectLabel }; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectScrollDownButton/index.ts b/packages/webapp-libs/webapp-core/src/components/forms/select/selectScrollDownButton/index.ts new file mode 100644 index 000000000..db8a03c60 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectScrollDownButton/index.ts @@ -0,0 +1 @@ +export { SelectScrollDownButton } from './selectScrollDownButton.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectScrollDownButton/selectScrollDownButton.component.tsx b/packages/webapp-libs/webapp-core/src/components/forms/select/selectScrollDownButton/selectScrollDownButton.component.tsx new file mode 100644 index 000000000..ac1563a97 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectScrollDownButton/selectScrollDownButton.component.tsx @@ -0,0 +1,22 @@ +import * as SelectPrimitive from '@radix-ui/react-select'; +import { ChevronDown } from 'lucide-react'; +import React from 'react'; + +import { cn } from '../../../../lib/utils'; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); + +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +export { SelectScrollDownButton }; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectScrollUpButton/index.ts b/packages/webapp-libs/webapp-core/src/components/forms/select/selectScrollUpButton/index.ts new file mode 100644 index 000000000..371fbc877 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectScrollUpButton/index.ts @@ -0,0 +1 @@ +export { SelectScrollUpButton } from './selectScrollUpButton.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectScrollUpButton/selectScrollUpButton.component.tsx b/packages/webapp-libs/webapp-core/src/components/forms/select/selectScrollUpButton/selectScrollUpButton.component.tsx new file mode 100644 index 000000000..e0e0e6db3 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectScrollUpButton/selectScrollUpButton.component.tsx @@ -0,0 +1,21 @@ +import * as SelectPrimitive from '@radix-ui/react-select'; +import { ChevronUp } from 'lucide-react'; +import React from 'react'; + +import { cn } from '../../../../lib/utils'; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +export { SelectScrollUpButton }; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectSeparator/index.ts b/packages/webapp-libs/webapp-core/src/components/forms/select/selectSeparator/index.ts new file mode 100644 index 000000000..332bcef37 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectSeparator/index.ts @@ -0,0 +1 @@ +export { SelectSeparator } from './selectSeparator.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectSeparator/selectSeparator.component.tsx b/packages/webapp-libs/webapp-core/src/components/forms/select/selectSeparator/selectSeparator.component.tsx new file mode 100644 index 000000000..fcbee00e7 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectSeparator/selectSeparator.component.tsx @@ -0,0 +1,15 @@ +import * as SelectPrimitive from '@radix-ui/react-select'; +import React from 'react'; + +import { cn } from '../../../../lib/utils'; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { SelectSeparator }; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectTrigger/index.ts b/packages/webapp-libs/webapp-core/src/components/forms/select/selectTrigger/index.ts new file mode 100644 index 000000000..db754055a --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectTrigger/index.ts @@ -0,0 +1 @@ +export { SelectTrigger } from './selectTrigger.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/forms/select/selectTrigger/selectTrigger.component.tsx b/packages/webapp-libs/webapp-core/src/components/forms/select/selectTrigger/selectTrigger.component.tsx new file mode 100644 index 000000000..2a20fad1c --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/forms/select/selectTrigger/selectTrigger.component.tsx @@ -0,0 +1,26 @@ +import * as SelectPrimitive from '@radix-ui/react-select'; +import { ChevronDown } from 'lucide-react'; +import React from 'react'; + +import { cn } from '../../../../lib/utils'; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; +export { SelectTrigger }; diff --git a/packages/webapp-libs/webapp-core/src/components/tooltip/__tests__/tooltip.component.spec.tsx b/packages/webapp-libs/webapp-core/src/components/tooltip/__tests__/tooltip.component.spec.tsx new file mode 100644 index 000000000..6efceb11a --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/tooltip/__tests__/tooltip.component.spec.tsx @@ -0,0 +1,35 @@ +import { act, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { Tooltip, TooltipContent, TooltipTrigger } from '../'; +import { render } from '../../../tests/utils/rendering'; + +const triggerText = 'Trigger'; +const contentText = 'Content'; + +const Component = () => ( + + {triggerText} + {contentText} + +); + +describe('Tooltip', () => { + it('should render Trigger element', async () => { + render(); + expect(screen.getByText(triggerText)).toBeInTheDocument(); + expect(screen.queryByText(contentText)).not.toBeInTheDocument(); + }); + + it('should render Trigger and Content after hover', async () => { + render(); + const trigger = screen.getByText(triggerText); + // fireEvent.mouseOver(trigger); + act(() => { + trigger.focus(); + }); + trigger.focus(); + // somehow the tooltip is rendering content twice + await waitFor(() => expect(screen.getAllByText(contentText)).toHaveLength(2)); + }); +}); diff --git a/packages/webapp-libs/webapp-core/src/components/tooltip/index.ts b/packages/webapp-libs/webapp-core/src/components/tooltip/index.ts new file mode 100644 index 000000000..10d476e55 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/tooltip/index.ts @@ -0,0 +1,2 @@ +export * from './tooltipContent'; +export * from './tooltip.component'; diff --git a/packages/webapp-libs/webapp-core/src/components/tooltip/tooltip.component.tsx b/packages/webapp-libs/webapp-core/src/components/tooltip/tooltip.component.tsx new file mode 100644 index 000000000..68feb991c --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/tooltip/tooltip.component.tsx @@ -0,0 +1,9 @@ +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; + +const TooltipProvider = TooltipPrimitive.Provider; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +export { Tooltip, TooltipTrigger, TooltipProvider }; diff --git a/packages/webapp-libs/webapp-core/src/components/tooltip/tooltip.stories.tsx b/packages/webapp-libs/webapp-core/src/components/tooltip/tooltip.stories.tsx new file mode 100644 index 000000000..d80dc0b34 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/tooltip/tooltip.stories.tsx @@ -0,0 +1,26 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { Tooltip, TooltipProvider, TooltipTrigger } from './tooltip.component'; +import { TooltipContent } from './tooltipContent'; + +const Template: StoryFn = () => { + return ( +
+ + + Hover me + Tooltip content + + +
+ ); +}; + +const meta: Meta = { + title: 'Core/Tooltip', + component: Template, +}; + +export default meta; + +export const Default: StoryObj = {}; diff --git a/packages/webapp-libs/webapp-core/src/components/tooltip/tooltipContent/TooltipContent.component.tsx b/packages/webapp-libs/webapp-core/src/components/tooltip/tooltipContent/TooltipContent.component.tsx new file mode 100644 index 000000000..274d61b03 --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/tooltip/tooltipContent/TooltipContent.component.tsx @@ -0,0 +1,21 @@ +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import * as React from 'react'; + +import { cn } from '../../../lib/utils'; + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; +export { TooltipContent }; diff --git a/packages/webapp-libs/webapp-core/src/components/tooltip/tooltipContent/index.ts b/packages/webapp-libs/webapp-core/src/components/tooltip/tooltipContent/index.ts new file mode 100644 index 000000000..15f2578ee --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/components/tooltip/tooltipContent/index.ts @@ -0,0 +1 @@ +export * from './TooltipContent.component'; diff --git a/packages/webapp-libs/webapp-core/src/config/routes.ts b/packages/webapp-libs/webapp-core/src/config/routes.ts index 8ca343638..f4b9f2cc1 100644 --- a/packages/webapp-libs/webapp-core/src/config/routes.ts +++ b/packages/webapp-libs/webapp-core/src/config/routes.ts @@ -13,4 +13,6 @@ export const RoutesConfig = { passwordReset: nestedPath('auth/reset-password', { confirm: 'confirm/:user/:token', }), + addTenant: 'add-tenant', + tenantInvitation: 'tenant-invitation/:token', }; diff --git a/packages/webapp-libs/webapp-core/src/services/analytics/analytics.ts b/packages/webapp-libs/webapp-core/src/services/analytics/analytics.ts index 4fcb9cb40..f5167b63b 100644 --- a/packages/webapp-libs/webapp-core/src/services/analytics/analytics.ts +++ b/packages/webapp-libs/webapp-core/src/services/analytics/analytics.ts @@ -18,6 +18,8 @@ type ActionMap = { subscription: 'change-plan' | 'cancel' | 'add-payment-method' | 'edit-payment-method'; profile: 'avatar-update' | 'personal-data-update' | 'password-update' | 'setup-2fa' | 'disable-2fa'; document: 'upload' | 'delete'; + tenant: 'add' | 'edit' | 'delete'; + tenantInvitation: 'invite' | 'accept' | 'decline'; }; export const isAvailable = () => Boolean(ENV.GOOGLE_ANALYTICS_TRACKING_ID); diff --git a/packages/webapp-libs/webapp-core/src/tests/mocks/icons.tsx b/packages/webapp-libs/webapp-core/src/tests/mocks/icons.tsx index 39ab752b1..1376b8b5d 100644 --- a/packages/webapp-libs/webapp-core/src/tests/mocks/icons.tsx +++ b/packages/webapp-libs/webapp-core/src/tests/mocks/icons.tsx @@ -9,6 +9,7 @@ jest.mock('@iconify-icons/ion/star', () => IconMock); jest.mock('@iconify-icons/ion/document-text-outline', () => IconMock); jest.mock('@iconify-icons/ion/star-outline', () => IconMock); jest.mock('@iconify-icons/ion/camera-outline', () => IconMock); +jest.mock('@iconify-icons/ion/alert-circle-outline', () => IconMock); jest.mock('@iconify-icons/ion/mail-outline', () => IconMock); jest.mock('@iconify-icons/ion/mail-unread-outline', () => IconMock); jest.mock('@iconify-icons/ion/mail-open-outline', () => IconMock); diff --git a/packages/webapp-libs/webapp-core/src/tests/mocks/pointerEvent.ts b/packages/webapp-libs/webapp-core/src/tests/mocks/pointerEvent.ts new file mode 100644 index 000000000..abd57be1c --- /dev/null +++ b/packages/webapp-libs/webapp-core/src/tests/mocks/pointerEvent.ts @@ -0,0 +1,16 @@ +// JSDOM doesn't implement PointerEvent so we need to mock our own implementation +// Default to mouse left click interaction +// https://github.com/radix-ui/primitives/issues/1207 +// https://github.com/jsdom/jsdom/pull/2666 +export class MockPointerEvent extends Event { + button: number; + ctrlKey: boolean; + pointerType: string; + + constructor(type: string, props: PointerEventInit) { + super(type, props); + this.button = props.button || 0; + this.ctrlKey = props.ctrlKey || false; + this.pointerType = props.pointerType || 'mouse'; + } +} diff --git a/packages/webapp-libs/webapp-core/src/tests/setupTests.ts b/packages/webapp-libs/webapp-core/src/tests/setupTests.ts index f57335877..a13ea235e 100644 --- a/packages/webapp-libs/webapp-core/src/tests/setupTests.ts +++ b/packages/webapp-libs/webapp-core/src/tests/setupTests.ts @@ -27,7 +27,6 @@ window.matchMedia = ENV.ENVIRONMENT_NAME = 'test'; -// window.PointerEvent = MockPointerEvent as any; window.HTMLElement.prototype.scrollIntoView = jest.fn(); window.HTMLElement.prototype.releasePointerCapture = jest.fn(); window.HTMLElement.prototype.hasPointerCapture = jest.fn(); diff --git a/packages/webapp-libs/webapp-core/src/tests/utils/rendering.tsx b/packages/webapp-libs/webapp-core/src/tests/utils/rendering.tsx index acfb28697..4753c65c4 100644 --- a/packages/webapp-libs/webapp-core/src/tests/utils/rendering.tsx +++ b/packages/webapp-libs/webapp-core/src/tests/utils/rendering.tsx @@ -5,6 +5,7 @@ import { HelmetProvider } from 'react-helmet-async'; import { IntlProvider } from 'react-intl'; import { MemoryRouter, MemoryRouterProps } from 'react-router-dom'; +import { TooltipProvider } from '../../components/tooltip'; import { DEFAULT_LOCALE, Locale, TranslationMessages, translationMessages } from '../../config/i18n'; import { LocalesProvider, ResponsiveThemeProvider } from '../../providers'; import { ToastProvider, Toaster } from '../../toast'; @@ -37,12 +38,14 @@ export function CoreTestProviders({ children, routerProps, intlMessages, intlLoc - - <> - {children} - - - + + + <> + {children} + + + + @@ -82,7 +85,7 @@ export function getWrapper( export type CustomRenderOptions< Q extends Queries = typeof queries, Container extends Element | DocumentFragment = HTMLElement, - BaseElement extends Element | DocumentFragment = Container + BaseElement extends Element | DocumentFragment = Container, > = RenderOptions & WrapperProps; /** @@ -95,7 +98,7 @@ export type CustomRenderOptions< function customRender< Q extends Queries = typeof queries, Container extends Element | DocumentFragment = HTMLElement, - BaseElement extends Element | DocumentFragment = Container + BaseElement extends Element | DocumentFragment = Container, >( ui: ReactElement, options: CustomRenderOptions = {} diff --git a/packages/webapp-libs/webapp-core/src/utils/__tests__/path.spec.ts b/packages/webapp-libs/webapp-core/src/utils/__tests__/path.spec.ts index 52833cbb1..6135de391 100644 --- a/packages/webapp-libs/webapp-core/src/utils/__tests__/path.spec.ts +++ b/packages/webapp-libs/webapp-core/src/utils/__tests__/path.spec.ts @@ -19,6 +19,7 @@ describe('Utils: path', () => { anotherStep: 'root/another-step/:id', getRelativeUrl: expect.any(Function), getLocalePath: expect.any(Function), + getTenantPath: expect.any(Function), }); }); @@ -48,9 +49,11 @@ describe('Utils: path', () => { anotherStep: 'root/nested/another-step/:id', getRelativeUrl: expect.any(Function), getLocalePath: expect.any(Function), + getTenantPath: expect.any(Function), }, getRelativeUrl: expect.any(Function), getLocalePath: expect.any(Function), + getTenantPath: expect.any(Function), }); }); diff --git a/packages/webapp-libs/webapp-core/src/utils/path.ts b/packages/webapp-libs/webapp-core/src/utils/path.ts index 37a0c8918..4f606c01c 100644 --- a/packages/webapp-libs/webapp-core/src/utils/path.ts +++ b/packages/webapp-libs/webapp-core/src/utils/path.ts @@ -1,16 +1,21 @@ import { is, map } from 'ramda'; export const getLocalePath = (p: string) => `/:lang/${p}`; +export const getTenantPath = (p: string) => `/:tenantId/${p}`; -const assignGetLocalePath = - (paths: Partial) => +const removeInitialSlash = (str: string): string => (str.startsWith('/') ? str.slice(1) : str); + +const assignLocalePathFn = + (fn: (p: string) => string, paths: Partial) => (route: keyof T) => { if (typeof paths[route] !== 'string') { throw Error('Invalid route'); } - return getLocalePath(paths[route] as string); + return fn(paths[route] as string); }; +export const getTenantPathHelper = (p: string) => getLocalePath(removeInitialSlash(getTenantPath(p))); + /** * Helper function to define typed nested route config * @@ -78,7 +83,8 @@ export const nestedPath = (root: string, nestedRoutes: T) => { return { ...paths, getRelativeUrl: (route: keyof T) => nestedRoutes[route], - getLocalePath: assignGetLocalePath(paths), + getLocalePath: assignLocalePathFn(getLocalePath, paths), + getTenantPath: assignLocalePathFn(getTenantPathHelper, paths), }; }; @@ -92,6 +98,7 @@ const mapRoot = (root: string, obj: N): N => { return { ...obj, ...override, - getLocalePath: assignGetLocalePath(override), + getLocalePath: assignLocalePathFn(getLocalePath, override), + getTenantPath: assignLocalePathFn(getTenantPathHelper, override), }; }; diff --git a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/addCrudDemoItem/__tests__/addCrudDemoItem.component.spec.tsx b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/addCrudDemoItem/__tests__/addCrudDemoItem.component.spec.tsx index e8ebbf8fc..7b0a266cc 100644 --- a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/addCrudDemoItem/__tests__/addCrudDemoItem.component.spec.tsx +++ b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/addCrudDemoItem/__tests__/addCrudDemoItem.component.spec.tsx @@ -1,6 +1,7 @@ -import { fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; import { composeMockedQueryResult } from '@sb/webapp-api-client/tests/utils'; import { trackEvent } from '@sb/webapp-core/services/analytics'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; @@ -10,6 +11,8 @@ import { AddCrudDemoItem, addCrudDemoItemMutation } from '../addCrudDemoItem.com jest.mock('@sb/webapp-core/services/analytics'); +const tenantId = 'tenantId'; + describe('AddCrudDemoItem: Component', () => { const Component = () => ; @@ -22,10 +25,18 @@ describe('AddCrudDemoItem: Component', () => { describe('action completes successfully', () => { it('should commit mutation', async () => { - const commonQueryMock = fillCommonQueryWithUser(); + const commonQueryMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const variables = { - input: { name: 'new item name' }, + input: { name: 'new item name', tenantId }, }; const data = { createCrudDemoItem: { @@ -44,6 +55,7 @@ describe('AddCrudDemoItem: Component', () => { const refetchMock = composeMockedQueryResult(crudDemoItemListQuery, { variables: { + tenantId, first: 8, }, data: [data], @@ -66,9 +78,17 @@ describe('AddCrudDemoItem: Component', () => { }); it('should show success message', async () => { - const commonQueryMock = fillCommonQueryWithUser(); + const commonQueryMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const variables = { - input: { name: 'new item' }, + input: { name: 'new item', tenantId }, }; const data = { createCrudDemoItem: { diff --git a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/addCrudDemoItem/addCrudDemoItem.component.tsx b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/addCrudDemoItem/addCrudDemoItem.component.tsx index c72da7a19..f44a7c5b7 100644 --- a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/addCrudDemoItem/addCrudDemoItem.component.tsx +++ b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/addCrudDemoItem/addCrudDemoItem.component.tsx @@ -5,6 +5,7 @@ import { PageLayout } from '@sb/webapp-core/components/pageLayout'; import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; import { trackEvent } from '@sb/webapp-core/services/analytics'; import { useToast } from '@sb/webapp-core/toast/useToast'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { FormattedMessage, useIntl } from 'react-intl'; import { useNavigate } from 'react-router'; @@ -31,6 +32,7 @@ export const AddCrudDemoItem = () => { const { toast } = useToast(); const intl = useIntl(); const navigate = useNavigate(); + const { data: currentTenant } = useCurrentTenant(); const successMessage = intl.formatMessage({ id: 'CrudDemoItem form / AddCrudDemoItem / Success message', @@ -38,12 +40,15 @@ export const AddCrudDemoItem = () => { }); const [commitCrudDemoItemFormMutation, { error, loading: loadingMutation }] = useMutation(addCrudDemoItemMutation, { - refetchQueries: () => [{ - query: crudDemoItemListQuery, - variables: { - first: ITEMS_PER_PAGE - } - }], + refetchQueries: () => [ + { + query: crudDemoItemListQuery, + variables: { + first: ITEMS_PER_PAGE, + tenantId: currentTenant?.id, + }, + }, + ], onCompleted: (data) => { const id = data?.createCrudDemoItem?.crudDemoItemEdge?.node?.id; @@ -56,9 +61,11 @@ export const AddCrudDemoItem = () => { }); const onFormSubmit = (formData: CrudDemoItemFormFields) => { + if (!currentTenant) return; + commitCrudDemoItemFormMutation({ variables: { - input: { name: formData.name }, + input: { name: formData.name, tenantId: currentTenant?.id }, }, }); }; diff --git a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemDetails/__tests__/crudDemoItemDetails.component.spec.tsx b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemDetails/__tests__/crudDemoItemDetails.component.spec.tsx index 4bf5dafe6..796355a51 100644 --- a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemDetails/__tests__/crudDemoItemDetails.component.spec.tsx +++ b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemDetails/__tests__/crudDemoItemDetails.component.spec.tsx @@ -1,5 +1,6 @@ -import { fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; import { getLocalePath } from '@sb/webapp-core/utils'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { screen } from '@testing-library/react'; import { Route, Routes } from 'react-router'; @@ -8,6 +9,8 @@ import { fillCrudDemoItemDetailsQuery } from '../../../../tests/factories'; import { createMockRouterProps, render } from '../../../../tests/utils/rendering'; import { CrudDemoItemDetails } from '../crudDemoItemDetails.component'; +const tenantId = 'tenantId'; + describe('CrudDemoItemDetails: Component', () => { const defaultItemId = 'test-id'; @@ -18,12 +21,21 @@ describe('CrudDemoItemDetails: Component', () => { ); it('should render item details', async () => { + const commonQueryMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const routerProps = createMockRouterProps(RoutesConfig.crudDemoItem.details, { id: defaultItemId }); - const variables = { id: defaultItemId }; + const variables = { id: defaultItemId, tenantId }; const data = { id: defaultItemId, name: 'demo item name' }; const mockRequest = fillCrudDemoItemDetailsQuery(data, variables); - const apolloMocks = [fillCommonQueryWithUser(), mockRequest]; + const apolloMocks = [commonQueryMock, mockRequest]; render(, { routerProps, apolloMocks }); diff --git a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemDetails/crudDemoItemDetails.component.tsx b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemDetails/crudDemoItemDetails.component.tsx index 5c66c41c7..48845a885 100644 --- a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemDetails/crudDemoItemDetails.component.tsx +++ b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemDetails/crudDemoItemDetails.component.tsx @@ -4,11 +4,12 @@ import { PageHeadline } from '@sb/webapp-core/components/pageHeadline'; import { PageLayout } from '@sb/webapp-core/components/pageLayout'; import { Separator } from '@sb/webapp-core/components/separator'; import { Skeleton } from '@sb/webapp-core/components/skeleton'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { useParams } from 'react-router'; export const crudDemoItemDetailsQuery = gql(/* GraphQL */ ` - query crudDemoItemDetailsQuery($id: ID!) { - crudDemoItem(id: $id) { + query crudDemoItemDetailsQuery($id: ID!, $tenantId: ID!) { + crudDemoItem(id: $id, tenantId: $tenantId) { id name } @@ -20,11 +21,14 @@ export const CrudDemoItemDetails = () => { id: string; }; const { id } = useParams() as Params; + const { data: currentTenant } = useCurrentTenant(); const { loading, data } = useQuery(crudDemoItemDetailsQuery, { variables: { id, + tenantId: currentTenant?.id || '', }, + skip: !currentTenant, }); if (loading) { diff --git a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/__tests__/crudDemoItemList.component.spec.tsx b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/__tests__/crudDemoItemList.component.spec.tsx index 2761ccfe5..dd806de24 100644 --- a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/__tests__/crudDemoItemList.component.spec.tsx +++ b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/__tests__/crudDemoItemList.component.spec.tsx @@ -1,5 +1,6 @@ -import { fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; import { getLocalePath } from '@sb/webapp-core/utils'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { Route, Routes } from 'react-router-dom'; @@ -24,10 +25,23 @@ describe('CrudDemoItemList: Component', () => { }) ); + const tenantId = 'tenantId'; + it('should render all items', async () => { const routerProps = createMockRouterProps(RoutesConfig.crudDemoItem.list); - const apolloMocks = [fillCommonQueryWithUser(), fillCrudDemoItemPaginationListQuery(allItems, {}, { first: 8 })]; + const apolloMocks = [ + fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ), + fillCrudDemoItemPaginationListQuery(allItems, {}, { tenantId, first: 8 }), + ]; render(, { routerProps, apolloMocks }); expect(await screen.findByText('1 item')).toBeInTheDocument(); diff --git a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemList.component.tsx b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemList.component.tsx index d8270ba93..854f69b75 100644 --- a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemList.component.tsx +++ b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemList.component.tsx @@ -1,4 +1,4 @@ -import { gql } from '@sb/webapp-api-client/graphql'; +import { CrudDemoItemListQueryQuery, gql } from '@sb/webapp-api-client/graphql'; import { usePaginatedQuery } from '@sb/webapp-api-client/hooks'; import { ButtonVariant, Link } from '@sb/webapp-core/components/buttons'; import { Card, CardContent } from '@sb/webapp-core/components/cards'; @@ -7,6 +7,7 @@ import { PageLayout } from '@sb/webapp-core/components/pageLayout'; import { Pagination } from '@sb/webapp-core/components/pagination'; import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; import { mapConnection } from '@sb/webapp-core/utils/graphql'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { PlusCircle } from 'lucide-react'; import { FormattedMessage } from 'react-intl'; @@ -15,8 +16,8 @@ import { CrudDemoItemListItem } from './crudDemoItemListItem'; import { ListSkeleton } from './listSkeleton'; export const crudDemoItemListQuery = gql(/* GraphQL */ ` - query crudDemoItemListQuery($first: Int, $after: String, $last: Int, $before: String) { - allCrudDemoItems(first: $first, after: $after, last: $last, before: $before) { + query crudDemoItemListQuery($tenantId: ID!, $first: Int, $after: String, $last: Int, $before: String) { + allCrudDemoItems(tenantId: $tenantId, first: $first, after: $after, last: $last, before: $before) { edges { node { id @@ -36,12 +37,19 @@ export const ITEMS_PER_PAGE = 8; export const CrudDemoItemList = () => { const generateLocalePath = useGenerateLocalePath(); + const { data: currentTenant } = useCurrentTenant(); - const { data, loading, hasNext, hasPrevious, loadNext, loadPrevious } = usePaginatedQuery(crudDemoItemListQuery, { + const { data, loading, hasNext, hasPrevious, loadNext, loadPrevious } = usePaginatedQuery< + CrudDemoItemListQueryQuery, + { tenantId: string }, + typeof crudDemoItemListQuery + >(crudDemoItemListQuery, { hookOptions: { variables: { first: ITEMS_PER_PAGE, + tenantId: currentTenant?.id ?? '', }, + skip: !currentTenant, }, dataKey: 'allCrudDemoItems', }); diff --git a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemListItem/__tests__/crudDemoItemListItem.component.spec.tsx b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemListItem/__tests__/crudDemoItemListItem.component.spec.tsx index 309b47f35..91ae6f31d 100644 --- a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemListItem/__tests__/crudDemoItemListItem.component.spec.tsx +++ b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemListItem/__tests__/crudDemoItemListItem.component.spec.tsx @@ -1,9 +1,10 @@ import { useQuery } from '@apollo/client'; import { gql } from '@sb/webapp-api-client'; -import { fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; import { composeMockedQueryResult } from '@sb/webapp-api-client/tests/utils'; import { trackEvent } from '@sb/webapp-core/services/analytics'; -import { getLocalePath } from '@sb/webapp-core/utils'; +import { getTenantPathHelper } from '@sb/webapp-core/utils'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { Route, Routes, useParams } from 'react-router'; @@ -23,6 +24,8 @@ const crudDemoItemListItemTestQuery = gql(/* GraphQL */ ` jest.mock('@sb/webapp-core/services/analytics'); +const tenantId = 'tenantId'; + describe('CrudDemoItemListItem: Component', () => { const EditRouteMock = () => { const params = useParams<{ id: string }>(); @@ -44,17 +47,25 @@ describe('CrudDemoItemListItem: Component', () => { return ( } /> - } /> - } /> + } /> + } /> ); }; it('should render link to details page', async () => { - const item = { id: 'test-id', name: 'demo item name' }; + const item = { id: 'test-id', name: 'demo item name', tenantId }; const apolloMocks = [ - fillCommonQueryWithUser(), + fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ), composeMockedQueryResult(crudDemoItemListItemTestQuery, { data: { item: { @@ -72,10 +83,18 @@ describe('CrudDemoItemListItem: Component', () => { }); it('should render link to edit form', async () => { - const item = { id: 'test-id', name: 'demo item name' }; + const item = { id: 'test-id', name: 'demo item name', tenantId }; const apolloMocks = [ - fillCommonQueryWithUser(), + fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ), composeMockedQueryResult(crudDemoItemListItemTestQuery, { data: { item: { @@ -94,10 +113,18 @@ describe('CrudDemoItemListItem: Component', () => { expect(screen.getByText('Crud demo item edit mock test-id')).toBeInTheDocument(); }); it('should delete item', async () => { - const item = { id: 'test-id', name: 'demo item name' }; + const item = { id: 'test-id', name: 'demo item name', tenantId }; const apolloMocks = [ - fillCommonQueryWithUser(), + fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ), composeMockedQueryResult(crudDemoItemListItemTestQuery, { data: { item: { @@ -114,7 +141,7 @@ describe('CrudDemoItemListItem: Component', () => { }, }, variables: { - input: { id: item.id }, + input: { id: item.id, tenantId }, }, }), ]; @@ -128,10 +155,18 @@ describe('CrudDemoItemListItem: Component', () => { }); it('should show success message', async () => { - const item = { id: 'test-id', name: 'demo item name' }; + const item = { id: 'test-id', name: 'demo item name', tenantId }; const apolloMocks = [ - fillCommonQueryWithUser(), + fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ), composeMockedQueryResult(crudDemoItemListItemTestQuery, { data: { item: { @@ -148,7 +183,7 @@ describe('CrudDemoItemListItem: Component', () => { }, }, variables: { - input: { id: item.id }, + input: { id: item.id, tenantId }, }, }), ]; diff --git a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemListItem/crudDemoItemListItem.component.tsx b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemListItem/crudDemoItemListItem.component.tsx index 84ea94f22..0c1d8df27 100644 --- a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemListItem/crudDemoItemListItem.component.tsx +++ b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemListItem/crudDemoItemListItem.component.tsx @@ -4,10 +4,12 @@ import deleteIcon from '@iconify-icons/ion/trash-outline'; import { FragmentType, getFragmentData } from '@sb/webapp-api-client/graphql'; import { Button, ButtonVariant, Link } from '@sb/webapp-core/components/buttons'; import { Icon } from '@sb/webapp-core/components/icons'; -import { useGenerateLocalePath, useMediaQuery } from '@sb/webapp-core/hooks'; +import { useMediaQuery } from '@sb/webapp-core/hooks'; import { trackEvent } from '@sb/webapp-core/services/analytics'; import { media } from '@sb/webapp-core/theme'; import { useToast } from '@sb/webapp-core/toast'; +import { useGenerateTenantPath } from '@sb/webapp-tenants/hooks'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { MouseEvent } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; @@ -20,7 +22,8 @@ export type CrudDemoItemListItemProps = { }; export const CrudDemoItemListItem = ({ item }: CrudDemoItemListItemProps) => { - const generateLocalePath = useGenerateLocalePath(); + const { data: currentTenant } = useCurrentTenant(); + const generateTenantPath = useGenerateTenantPath(); const { matches: isDesktop } = useMediaQuery({ above: media.Breakpoint.TABLET }); const { toast } = useToast(); const intl = useIntl(); @@ -49,9 +52,10 @@ export const CrudDemoItemListItem = ({ item }: CrudDemoItemListItemProps) => { const handleDelete = (e: MouseEvent) => { e.preventDefault(); + if (!currentTenant) return; commitDeleteMutation({ variables: { - input: { id: data.id }, + input: { id: data.id, tenantId: currentTenant.id }, }, }); }; @@ -61,7 +65,7 @@ export const CrudDemoItemListItem = ({ item }: CrudDemoItemListItemProps) => { } > @@ -87,7 +91,7 @@ export const CrudDemoItemListItem = ({ item }: CrudDemoItemListItemProps) => {

{data.name} diff --git a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemListItem/crudDropdownMenu/crudDropdownMenu.component.tsx b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemListItem/crudDropdownMenu/crudDropdownMenu.component.tsx index c9cbb5651..4f25f2195 100644 --- a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemListItem/crudDropdownMenu/crudDropdownMenu.component.tsx +++ b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/crudDemoItemList/crudDemoItemListItem/crudDropdownMenu/crudDropdownMenu.component.tsx @@ -3,8 +3,8 @@ import deleteIcon from '@iconify-icons/ion/trash-outline'; import { Button, Link as ButtonLink, ButtonVariant } from '@sb/webapp-core/components/buttons'; import { Icon } from '@sb/webapp-core/components/icons'; import { Popover, PopoverContent, PopoverTrigger } from '@sb/webapp-core/components/popover'; -import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; import { cn } from '@sb/webapp-core/lib/utils'; +import { useGenerateTenantPath } from '@sb/webapp-tenants/hooks'; import { MouseEvent } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; @@ -19,7 +19,7 @@ export type CrudDropdownMenuProps = { export const CrudDropdownMenu = ({ itemId, className, handleDelete, loading }: CrudDropdownMenuProps) => { const intl = useIntl(); - const generateLocalePath = useGenerateLocalePath(); + const generateTenantPath = useGenerateTenantPath(); return ( @@ -42,7 +42,7 @@ export const CrudDropdownMenu = ({ itemId, className, handleDelete, loading }: C

} className="justify-start mb-2" > diff --git a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/editCrudDemoItem/__tests__/editCrudDemoItem.component.spec.tsx b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/editCrudDemoItem/__tests__/editCrudDemoItem.component.spec.tsx index eb06e080f..1ee0c1c71 100644 --- a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/editCrudDemoItem/__tests__/editCrudDemoItem.component.spec.tsx +++ b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/editCrudDemoItem/__tests__/editCrudDemoItem.component.spec.tsx @@ -1,6 +1,8 @@ +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; import { composeMockedQueryResult } from '@sb/webapp-api-client/tests/utils'; import { trackEvent } from '@sb/webapp-core/services/analytics'; import { getLocalePath } from '@sb/webapp-core/utils'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { Route, Routes } from 'react-router'; @@ -13,6 +15,8 @@ import { editCrudDemoItemMutation } from '../editCrudDemoItem.graphql'; jest.mock('@sb/webapp-core/services/analytics'); +const tenantId = 'tenantId'; + describe('EditCrudDemoItem: Component', () => { const defaultItemId = 'test-id'; const oldName = 'old item'; @@ -24,9 +28,10 @@ describe('EditCrudDemoItem: Component', () => { name: oldName, __typename: 'CrudDemoItemType', }; - const queryVariables = { id: defaultItemId }; + const queryVariables = { id: defaultItemId, tenantId }; + + const mutationVariables = { input: { id: defaultItemId, name: newName, tenantId } }; - const mutationVariables = { input: { id: defaultItemId, name: newName } }; const mutationData = { updateCrudDemoItem: { crudDemoItem: { id: defaultItemId, name: newName, __typename: 'CrudDemoItemType' } }, }; @@ -42,12 +47,21 @@ describe('EditCrudDemoItem: Component', () => { ); it('should display prefilled form', async () => { + const commonQueryMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const routerProps = createMockRouterProps(RoutesConfig.crudDemoItem.edit, { id: defaultItemId }); const queryMock = fillEditCrudDemoItemQuery(queryData, queryVariables); render(, { routerProps, - apolloMocks: (defaultMocks) => defaultMocks.concat(queryMock), + apolloMocks: [commonQueryMock, queryMock], }); expect(await screen.findByDisplayValue(oldName)).toBeInTheDocument(); @@ -55,6 +69,15 @@ describe('EditCrudDemoItem: Component', () => { describe('action completes successfully', () => { it('should commit mutation', async () => { + const commonQueryMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const routerProps = createMockRouterProps(RoutesConfig.crudDemoItem.edit, { id: defaultItemId }); const queryMock = fillEditCrudDemoItemQuery(queryData, queryVariables); @@ -69,7 +92,7 @@ describe('EditCrudDemoItem: Component', () => { render(, { routerProps, - apolloMocks: (defaultMocks) => defaultMocks.concat(queryMock, requestMock), + apolloMocks: [commonQueryMock, queryMock, requestMock], }); const nameField = await screen.findByPlaceholderText(/name/i); @@ -81,6 +104,15 @@ describe('EditCrudDemoItem: Component', () => { }); it('should show success message', async () => { + const commonQueryMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const routerProps = createMockRouterProps(RoutesConfig.crudDemoItem.edit, { id: defaultItemId }); const queryMock = fillEditCrudDemoItemQuery(queryData, queryVariables); const requestMock = composeMockedQueryResult(editCrudDemoItemMutation, { @@ -90,7 +122,7 @@ describe('EditCrudDemoItem: Component', () => { render(, { routerProps, - apolloMocks: (defaultMocks) => defaultMocks.concat(queryMock, requestMock), + apolloMocks: [commonQueryMock, queryMock, requestMock], }); const nameField = await screen.findByPlaceholderText(/name/i); diff --git a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/editCrudDemoItem/editCrudDemoItem.component.tsx b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/editCrudDemoItem/editCrudDemoItem.component.tsx index 2f0aa8857..4b1e3fb6b 100644 --- a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/editCrudDemoItem/editCrudDemoItem.component.tsx +++ b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/editCrudDemoItem/editCrudDemoItem.component.tsx @@ -6,6 +6,7 @@ import { Skeleton } from '@sb/webapp-core/components/skeleton'; import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; import { trackEvent } from '@sb/webapp-core/services/analytics'; import { useToast } from '@sb/webapp-core/toast/useToast'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { FormattedMessage, useIntl } from 'react-intl'; import { Navigate, useNavigate, useParams } from 'react-router'; @@ -17,9 +18,13 @@ import { editCrudDemoItemMutation, editCrudDemoItemQuery } from './editCrudDemoI type Params = { id: string }; export const EditCrudDemoItem = () => { + const { data: currentTenant } = useCurrentTenant(); const navigate = useNavigate(); const { id } = useParams(); - const { data, loading } = useQuery(editCrudDemoItemQuery, { variables: { id: id ?? '' } }); + const { data, loading } = useQuery(editCrudDemoItemQuery, { + variables: { id: id ?? '', tenantId: currentTenant?.id ?? '' }, + skip: !id || !currentTenant, + }); const crudDemoItem = data?.crudDemoItem; const { toast } = useToast(); @@ -65,7 +70,10 @@ export const EditCrudDemoItem = () => { if (!crudDemoItem) return ; const onFormSubmit = (formData: CrudDemoItemFormFields) => { - commitEditCrudDemoItemMutation({ variables: { input: { id: crudDemoItem.id, name: formData.name } } }); + if (!currentTenant) return; + commitEditCrudDemoItemMutation({ + variables: { input: { tenantId: currentTenant.id, id: crudDemoItem.id, name: formData.name } }, + }); }; return ( diff --git a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/editCrudDemoItem/editCrudDemoItem.graphql.ts b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/editCrudDemoItem/editCrudDemoItem.graphql.ts index 2798ce4a7..4803f2539 100644 --- a/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/editCrudDemoItem/editCrudDemoItem.graphql.ts +++ b/packages/webapp-libs/webapp-crud-demo/src/routes/crudDemoItem/editCrudDemoItem/editCrudDemoItem.graphql.ts @@ -1,8 +1,8 @@ import { gql } from '@sb/webapp-api-client/graphql'; export const editCrudDemoItemQuery = gql(/* GraphQL */ ` - query editCrudDemoItemQuery($id: ID!) { - crudDemoItem(id: $id) { + query editCrudDemoItemQuery($id: ID!, $tenantId: ID!) { + crudDemoItem(id: $id, tenantId: $tenantId) { id name } diff --git a/packages/webapp-libs/webapp-crud-demo/src/tests/utils/rendering.tsx b/packages/webapp-libs/webapp-crud-demo/src/tests/utils/rendering.tsx index a1de56bd5..e86e741a6 100644 --- a/packages/webapp-libs/webapp-crud-demo/src/tests/utils/rendering.tsx +++ b/packages/webapp-libs/webapp-crud-demo/src/tests/utils/rendering.tsx @@ -1,5 +1,6 @@ import * as apiUtils from '@sb/webapp-api-client/tests/utils/rendering'; import * as corePath from '@sb/webapp-core/utils/path'; +import * as tenantsUtils from '@sb/webapp-tenants/tests/utils/rendering'; import { RenderOptions, render, renderHook } from '@testing-library/react'; import { ComponentClass, ComponentType, FC, ReactElement } from 'react'; import { MemoryRouterProps, generatePath } from 'react-router'; @@ -13,7 +14,7 @@ export function getWrapper( wrapper: ComponentType; waitForApolloMocks: (mockIndex?: number) => Promise; } { - return apiUtils.getWrapper(apiUtils.ApiTestProviders, wrapperProps); + return tenantsUtils.getWrapper(tenantsUtils.TenantsTestProviders, wrapperProps); } export type CustomRenderOptions = RenderOptions & WrapperProps; diff --git a/packages/webapp-libs/webapp-emails/src/templates/templates.config.ts b/packages/webapp-libs/webapp-emails/src/templates/templates.config.ts index b3c0b7c67..b99046091 100644 --- a/packages/webapp-libs/webapp-emails/src/templates/templates.config.ts +++ b/packages/webapp-libs/webapp-emails/src/templates/templates.config.ts @@ -6,6 +6,7 @@ import * as TrialExpiresSoon from './trialExpiresSoon'; import * as UserExport from './userExport'; import * as UserExportAdmin from './userExportAdmin'; +import * as TenantInvitation from './tenantInvitation'; //<-- INJECT EMAIL TEMPLATE IMPORT --> export const templates: Record = { @@ -15,5 +16,6 @@ export const templates: Record = { [EmailTemplateType.TRIAL_EXPIRES_SOON]: TrialExpiresSoon, [EmailTemplateType.USER_EXPORT]: UserExport, [EmailTemplateType.USER_EXPORT_ADMIN]: UserExportAdmin, + [EmailTemplateType.TENANT_INVITATION]: TenantInvitation, //<-- INJECT EMAIL TEMPLATE --> }; diff --git a/packages/webapp-libs/webapp-emails/src/templates/tenantInvitation/index.ts b/packages/webapp-libs/webapp-emails/src/templates/tenantInvitation/index.ts new file mode 100644 index 000000000..54cda6668 --- /dev/null +++ b/packages/webapp-libs/webapp-emails/src/templates/tenantInvitation/index.ts @@ -0,0 +1 @@ +export * from './tenantInvitation.component'; diff --git a/packages/webapp-libs/webapp-emails/src/templates/tenantInvitation/tenantInvitation.component.tsx b/packages/webapp-libs/webapp-emails/src/templates/tenantInvitation/tenantInvitation.component.tsx new file mode 100644 index 000000000..3c4f8e756 --- /dev/null +++ b/packages/webapp-libs/webapp-emails/src/templates/tenantInvitation/tenantInvitation.component.tsx @@ -0,0 +1,41 @@ +import { RoutesConfig } from '@sb/webapp-core/config/routes'; +import { useGenerateAbsoluteLocalePath } from '@sb/webapp-core/hooks'; +import { FormattedMessage } from 'react-intl'; + +import { Button, Layout } from '../../base'; +import { EmailComponentProps } from '../../types'; + +export type TenantInvitationProps = EmailComponentProps & { + token: string; + tenantMembershipId: string; +}; + +export const Template = ({ token, tenantMembershipId }: TenantInvitationProps) => { + const generateLocalePath = useGenerateAbsoluteLocalePath(); + const url = generateLocalePath(RoutesConfig.tenantInvitation, { token }); + + return ( + + } + text={ + + } + > + + + ); +}; + +export const Subject = () => ( + +); diff --git a/packages/webapp-libs/webapp-emails/src/templates/tenantInvitation/tenantInvitation.stories.tsx b/packages/webapp-libs/webapp-emails/src/templates/tenantInvitation/tenantInvitation.stories.tsx new file mode 100644 index 000000000..d5ef1159b --- /dev/null +++ b/packages/webapp-libs/webapp-emails/src/templates/tenantInvitation/tenantInvitation.stories.tsx @@ -0,0 +1,23 @@ +import { StoryFn } from '@storybook/react'; + +import { EmailTemplateType } from '../../types'; +import { EmailStory } from '../../emailStory/emailStory.component'; +import { + Template as TenantInvitationEmail, + Subject as TenantInvitationSubject, + TenantInvitationProps, +} from './tenantInvitation.component'; + +const Template: StoryFn = (args) => ( + } emailData={args}> + + +); + +export default { + title: 'Emails/TenantInvitation', + component: TenantInvitationEmail, +}; + +export const Primary = Template.bind({}); +Primary.args = {}; diff --git a/packages/webapp-libs/webapp-emails/src/types.ts b/packages/webapp-libs/webapp-emails/src/types.ts index 3393ca921..c8ad5d6bf 100644 --- a/packages/webapp-libs/webapp-emails/src/types.ts +++ b/packages/webapp-libs/webapp-emails/src/types.ts @@ -7,6 +7,7 @@ export enum EmailTemplateType { TRIAL_EXPIRES_SOON = 'TRIAL_EXPIRES_SOON', USER_EXPORT = 'USER_EXPORT', USER_EXPORT_ADMIN = 'USER_EXPORT_ADMIN', + TENANT_INVITATION = 'TENANT_INVITATION', //<-- INJECT EMAIL TYPE --> } diff --git a/packages/webapp-libs/webapp-finances/sonar-project.properties b/packages/webapp-libs/webapp-finances/sonar-project.properties index f686666d4..20365b8eb 100644 --- a/packages/webapp-libs/webapp-finances/sonar-project.properties +++ b/packages/webapp-libs/webapp-finances/sonar-project.properties @@ -11,4 +11,4 @@ sonar.test.inclusions = src/**/*.spec.* # Exclude test and generated subdirectories from source scope sonar.exclusions = src/**/*.spec.* -sonar.coverage.exclusions = src/**/*.stories.*, +sonar.coverage.exclusions = src/**/*.stories.*,src/utils/storybook.tsx,src/tests/setupTests.ts,src/tests/factories/**,src/tests/utils/**, diff --git a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePayment.hooks.ts b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePayment.hooks.ts index 941c92438..dc6cca0fb 100644 --- a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePayment.hooks.ts +++ b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePayment.hooks.ts @@ -3,6 +3,7 @@ import { ApolloError } from '@apollo/client/errors'; import { StripePaymentIntentType } from '@sb/webapp-api-client/graphql'; import { useApiForm } from '@sb/webapp-api-client/hooks'; import { trackEvent } from '@sb/webapp-core/services/analytics'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { GraphQLError } from 'graphql'; import { useState } from 'react'; @@ -24,6 +25,7 @@ interface UseStripePaymentMethodsProps { } export const useStripePaymentMethods = ({ onUpdateSuccess }: UseStripePaymentMethodsProps = {}) => { + const { data: currentTenant } = useCurrentTenant(); const [commitDeletePaymentMethodMutation] = useMutation(stripeDeletePaymentMethodMutation, { update(cache, { data }) { const deletedId = data?.deletePaymentMethod?.deletedIds?.[0]; @@ -59,11 +61,19 @@ export const useStripePaymentMethods = ({ onUpdateSuccess }: UseStripePaymentMet }); const deletePaymentMethod = async (id: string) => { - return await commitDeletePaymentMethodMutation({ variables: { input: { id } } }); + if (!currentTenant) return; + + return await commitDeletePaymentMethodMutation({ + variables: { input: { id, tenantId: currentTenant.id } }, + }); }; const updateDefaultPaymentMethod = async (id: string) => { - return await commitUpdateDefaultPaymentMethodMutation({ variables: { input: { id } } }); + if (!currentTenant) return; + + return await commitUpdateDefaultPaymentMethodMutation({ + variables: { input: { id, tenantId: currentTenant.id } }, + }); }; return { deletePaymentMethod, updateDefaultPaymentMethod }; @@ -74,6 +84,7 @@ interface StripePaymentFormFields extends PaymentFormFields { } export const useStripePaymentIntent = (onError: (error: ApolloError, clientOptions?: BaseMutationOptions) => void) => { + const { data: currentTenant } = useCurrentTenant(); const [paymentIntent, setPaymentIntent] = useState(null); const [commitCreatePaymentIntentMutation, { loading: createLoading }] = useMutation( @@ -95,11 +106,13 @@ export const useStripePaymentIntent = (onError: (error: ApolloError, clientOptio const updateOrCreatePaymentIntent = async ( product: TestProduct ): Promise<{ errors?: readonly GraphQLError[]; paymentIntent?: StripePaymentIntentType | null }> => { + if (!currentTenant) return {}; if (!paymentIntent) { const { data, errors } = await commitCreatePaymentIntentMutation({ variables: { input: { product, + tenantId: currentTenant.id, }, }, }); @@ -115,6 +128,7 @@ export const useStripePaymentIntent = (onError: (error: ApolloError, clientOptio input: { product, id: paymentIntent.id, + tenantId: currentTenant.id, }, }, }); diff --git a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentForm/__tests__/stripePaymentForm.component.spec.tsx b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentForm/__tests__/stripePaymentForm.component.spec.tsx index 045a1230e..c762cfcc0 100644 --- a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentForm/__tests__/stripePaymentForm.component.spec.tsx +++ b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentForm/__tests__/stripePaymentForm.component.spec.tsx @@ -1,7 +1,13 @@ import { Subscription } from '@sb/webapp-api-client/api/subscription/types'; -import { paymentMethodFactory, subscriptionFactory } from '@sb/webapp-api-client/tests/factories'; +import { + currentUserFactory, + fillCommonQueryWithUser, + paymentMethodFactory, + subscriptionFactory, +} from '@sb/webapp-api-client/tests/factories'; import { composeMockedQueryResult } from '@sb/webapp-api-client/tests/utils/fixtures'; import { trackEvent } from '@sb/webapp-core/services/analytics'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { Elements } from '@stripe/react-stripe-js'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; @@ -25,11 +31,6 @@ jest.mock('../../stripePayment.stripe.hook', () => { jest.mock('@sb/webapp-core/services/analytics'); -const mutationVariables = { - input: { - product: '5', - }, -}; const mutationData = { createPaymentIntent: { paymentIntent: { @@ -44,6 +45,8 @@ const mutationData = { }, }; +const tenantId = 'tenantId'; + describe('StripePaymentForm: Component', () => { beforeEach(() => { mockConfirmPayment.mockClear(); @@ -66,30 +69,57 @@ describe('StripePaymentForm: Component', () => { const sendForm = async () => userEvent.click(await screen.findByRole('button', { name: /Pay \d+ USD/i })); it('should render without errors', async () => { - const requestMock = fillAllPaymentsMethodsQuery(allPaymentsMock as Partial[]); + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); + const requestMock = fillAllPaymentsMethodsQuery(allPaymentsMock as Partial[], tenantId); const requestSubscriptionScheduleMock = fillSubscriptionScheduleQuery( subscriptionFactory({ defaultPaymentMethod: allPaymentsMock[0], }), - allPaymentsMock + allPaymentsMock, + tenantId ); const { waitForApolloMocks } = render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestSubscriptionScheduleMock, requestMock), + apolloMocks: [requestSubscriptionScheduleMock, requestMock, tenantMock], }); - await waitForApolloMocks(1); + await waitForApolloMocks(); expect(await screen.findAllByRole('radio')).toHaveLength(allPaymentsMock.length + Object.keys(TestProduct).length); }); describe('action completes successfully', () => { it('should call create payment intent mutation', async () => { - const requestAllPaymentsMock = fillAllPaymentsMethodsQuery(allPaymentsMock as Partial[]); + const mutationVariables = { + input: { + product: '5', + tenantId, + }, + }; + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); + const requestAllPaymentsMock = fillAllPaymentsMethodsQuery(allPaymentsMock as Partial[], tenantId); const requestSubscriptionScheduleMock = fillSubscriptionScheduleQuery( subscriptionFactory({ defaultPaymentMethod: allPaymentsMock[0], - }) + }), + undefined, + tenantId ); const requestPaymentMutation = composeMockedQueryResult(stripeCreatePaymentIntentMutation, { variables: mutationVariables, @@ -101,8 +131,7 @@ describe('StripePaymentForm: Component', () => { })); render(, { - apolloMocks: (defaultProps) => - defaultProps.concat(requestSubscriptionScheduleMock, requestAllPaymentsMock, requestPaymentMutation), + apolloMocks: [requestSubscriptionScheduleMock, requestAllPaymentsMock, requestPaymentMutation, tenantMock], }); await selectProduct(); @@ -112,6 +141,21 @@ describe('StripePaymentForm: Component', () => { }); it('should call confirm payment and onSuccess', async () => { + const mutationVariables = { + input: { + product: '5', + tenantId, + }, + }; + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const requestSubscriptionScheduleMock = fillSubscriptionScheduleQuery(subscriptionFactory()); const requestAllPaymentsMock = fillAllPaymentsMethodsQuery(allPaymentsMock as Partial[]); const requestPaymentMutation = composeMockedQueryResult(stripeCreatePaymentIntentMutation, { @@ -123,8 +167,7 @@ describe('StripePaymentForm: Component', () => { const onSuccess = jest.fn(); const { waitForApolloMocks } = render(, { - apolloMocks: (defaultProps) => - defaultProps.concat(requestSubscriptionScheduleMock, requestAllPaymentsMock, requestPaymentMutation), + apolloMocks: [requestSubscriptionScheduleMock, requestAllPaymentsMock, requestPaymentMutation, tenantMock], }); await selectProduct(); @@ -144,6 +187,21 @@ describe('StripePaymentForm: Component', () => { describe('when something goes wrong', () => { it('should show error message if creating payment intent throws error', async () => { + const mutationVariables = { + input: { + product: '5', + tenantId, + }, + }; + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const errorMessage = 'Something went wrong'; const requestSubscriptionScheduleMock = fillSubscriptionScheduleQuery(subscriptionFactory()); const requestAllPaymentsMock = fillAllPaymentsMethodsQuery(allPaymentsMock as Partial[]); @@ -155,8 +213,7 @@ describe('StripePaymentForm: Component', () => { const onSuccess = jest.fn(); const { waitForApolloMocks } = render(, { - apolloMocks: (defaultProps) => - defaultProps.concat(requestSubscriptionScheduleMock, requestAllPaymentsMock, requestPaymentMutation), + apolloMocks: [requestSubscriptionScheduleMock, requestAllPaymentsMock, requestPaymentMutation, tenantMock], }); await selectProduct(); @@ -170,6 +227,21 @@ describe('StripePaymentForm: Component', () => { }); it('should show error message if confirm payment return error', async () => { + const mutationVariables = { + input: { + product: '5', + tenantId, + }, + }; + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const requestSubscriptionScheduleMock = fillSubscriptionScheduleQuery(subscriptionFactory()); const requestAllPaymentsMock = fillAllPaymentsMethodsQuery(allPaymentsMock as Partial[]); const requestPaymentMutation = composeMockedQueryResult(stripeCreatePaymentIntentMutation, { @@ -181,8 +253,7 @@ describe('StripePaymentForm: Component', () => { const onSuccess = jest.fn(); const { waitForApolloMocks } = render(, { - apolloMocks: (defaultProps) => - defaultProps.concat(requestSubscriptionScheduleMock, requestAllPaymentsMock, requestPaymentMutation), + apolloMocks: [requestSubscriptionScheduleMock, requestAllPaymentsMock, requestPaymentMutation, tenantMock], }); await selectProduct(); diff --git a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodInfo/__tests__/stripePaymentMethodInfo.component.spec.tsx b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodInfo/__tests__/stripePaymentMethodInfo.component.spec.tsx index 3a88b05a3..b47999fc3 100644 --- a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodInfo/__tests__/stripePaymentMethodInfo.component.spec.tsx +++ b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodInfo/__tests__/stripePaymentMethodInfo.component.spec.tsx @@ -1,11 +1,14 @@ import { useQuery } from '@apollo/client'; import { + currentUserFactory, + fillCommonQueryWithUser, paymentMethodFactory, subscriptionFactory, subscriptionPhaseFactory, } from '@sb/webapp-api-client/tests/factories'; import { matchTextContent } from '@sb/webapp-core/tests/utils/match'; import { mapConnection } from '@sb/webapp-core/utils/graphql'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { screen } from '@testing-library/react'; import { append } from 'ramda'; @@ -14,8 +17,8 @@ import { render } from '../../../../tests/utils/rendering'; import { stripeSubscriptionQuery } from '../../stripePaymentMethodSelector'; import { StripePaymentMethodInfo, StripePaymentMethodInfoProps } from '../stripePaymentMethodInfo.component'; -const Component = (props: Partial) => { - const { data } = useQuery(stripeSubscriptionQuery, { nextFetchPolicy: 'cache-and-network' }); +const Component = ({ tenantId, ...props }: Partial & { tenantId: string }) => { + const { data } = useQuery(stripeSubscriptionQuery, { nextFetchPolicy: 'cache-and-network', variables: { tenantId } }); const paymentMethods = mapConnection((plan) => plan, data?.allPaymentMethods); const firstPaymentMethod = paymentMethods?.[0]; @@ -23,13 +26,24 @@ const Component = (props: Partial) => { return ; }; +const tenantId = 'tenantId'; + describe('StripePaymentMethodInfo: Component', () => { const paymentMethods = [paymentMethodFactory()]; it('should render all info', async () => { - const requestMock = fillSubscriptionScheduleQueryWithPhases([subscriptionPhaseFactory()], paymentMethods); - - render(, { apolloMocks: append(requestMock) }); + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); + const requestMock = fillSubscriptionScheduleQueryWithPhases([subscriptionPhaseFactory()], paymentMethods, tenantId); + + render(, { apolloMocks: [requestMock, tenantMock] }); expect(await screen.findByText(matchTextContent('MockLastName Visa **** 9999'))).toBeInTheDocument(); }); @@ -37,7 +51,7 @@ describe('StripePaymentMethodInfo: Component', () => { it('should render "None" string', async () => { const requestMock = fillSubscriptionScheduleQuery(subscriptionFactory()); - render(, { apolloMocks: append(requestMock) }); + render(, { apolloMocks: append(requestMock) }); expect(await screen.findByText('None')).toBeInTheDocument(); }); }); diff --git a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodInfo/stripePaymentMethodInfo.stories.tsx b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodInfo/stripePaymentMethodInfo.stories.tsx index 499d27afa..cb9c96e1f 100644 --- a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodInfo/stripePaymentMethodInfo.stories.tsx +++ b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodInfo/stripePaymentMethodInfo.stories.tsx @@ -13,6 +13,7 @@ import { StripePaymentMethodInfo, StripePaymentMethodInfoProps } from './stripeP const Template: StoryFn = (args: StripePaymentMethodInfoProps) => { const { data } = useQuery(stripeSubscriptionQuery, { nextFetchPolicy: 'cache-and-network', + variables: { tenantId: 'tenantId' }, }); const paymentMethods = mapConnection((plan) => plan, data?.allPaymentMethods); diff --git a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodSelector/__tests__/stripePaymentMethodSelector.component.spec.tsx b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodSelector/__tests__/stripePaymentMethodSelector.component.spec.tsx index cc83be63f..d104c546c 100644 --- a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodSelector/__tests__/stripePaymentMethodSelector.component.spec.tsx +++ b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodSelector/__tests__/stripePaymentMethodSelector.component.spec.tsx @@ -1,10 +1,15 @@ import { useApiForm } from '@sb/webapp-api-client/hooks'; -import { paymentMethodFactory, subscriptionPhaseFactory } from '@sb/webapp-api-client/tests/factories'; +import { + currentUserFactory, + fillCommonQueryWithUser, + paymentMethodFactory, + subscriptionPhaseFactory, +} from '@sb/webapp-api-client/tests/factories'; import { Form } from '@sb/webapp-core/components/forms'; import { matchTextContent } from '@sb/webapp-core/tests/utils/match'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { Elements } from '@stripe/react-stripe-js'; import { screen } from '@testing-library/react'; -import { append } from 'ramda'; import { StripePaymentMethodSelector } from '../'; import { fillSubscriptionScheduleQueryWithPhases } from '../../../../tests/factories'; @@ -32,13 +37,25 @@ const paymentMethods = [ paymentMethodFactory({ billingDetails: { name: 'First Owner' }, card: { last4: '1234' } }), paymentMethodFactory({ billingDetails: { name: 'Second Owner' }, card: { last4: '9999' } }), ]; + const phases = [subscriptionPhaseFactory()]; +const tenantId = 'tenantId'; + describe('StripePaymentMethodSelector: Component', () => { describe('there are payment methods available already', () => { it('should list possible payment methods', async () => { - const requestMock = fillSubscriptionScheduleQueryWithPhases(phases, paymentMethods); - const { waitForApolloMocks } = render(, { apolloMocks: append(requestMock) }); + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); + const requestMock = fillSubscriptionScheduleQueryWithPhases(phases, paymentMethods, tenantId); + const { waitForApolloMocks } = render(, { apolloMocks: [tenantMock, requestMock] }); await waitForApolloMocks(); @@ -47,8 +64,17 @@ describe('StripePaymentMethodSelector: Component', () => { }); it('should show add new method button', async () => { + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const requestMock = fillSubscriptionScheduleQueryWithPhases(phases, paymentMethods); - const { waitForApolloMocks } = render(, { apolloMocks: append(requestMock) }); + const { waitForApolloMocks } = render(, { apolloMocks: [tenantMock, requestMock] }); await waitForApolloMocks(); diff --git a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodSelector/stripePaymentMethodSelector.component.tsx b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodSelector/stripePaymentMethodSelector.component.tsx index 2139ce030..770a3f8a1 100644 --- a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodSelector/stripePaymentMethodSelector.component.tsx +++ b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodSelector/stripePaymentMethodSelector.component.tsx @@ -6,6 +6,7 @@ import { RadioGroup } from '@sb/webapp-core/components/forms/radioGroup'; import { Separator } from '@sb/webapp-core/components/separator'; import { Skeleton } from '@sb/webapp-core/components/skeleton'; import { mapConnection } from '@sb/webapp-core/utils/graphql'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { ChevronLeft, Trash2 } from 'lucide-react'; import { isEmpty } from 'ramda'; import { Control, PathValue, useController } from 'react-hook-form'; @@ -35,8 +36,13 @@ export const StripePaymentMethodSelector = ({ defaultSavedPaymentMethodId, control, }: StripePaymentMethodSelectorProps) => { + const { data: currentTenant } = useCurrentTenant(); const { data, loading } = useQuery(stripeSubscriptionQuery, { fetchPolicy: 'cache-and-network', + variables: { + tenantId: currentTenant?.id ?? '', + }, + skip: !currentTenant?.id, }); if (loading) { diff --git a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodSelector/stripePaymentMethodSelector.graphql.ts b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodSelector/stripePaymentMethodSelector.graphql.ts index 0fdb1e9fc..0b1a09403 100644 --- a/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodSelector/stripePaymentMethodSelector.graphql.ts +++ b/packages/webapp-libs/webapp-finances/src/components/stripe/stripePaymentMethodSelector/stripePaymentMethodSelector.graphql.ts @@ -1,8 +1,8 @@ import { gql } from '@sb/webapp-api-client/graphql'; export const stripeSubscriptionQuery = gql(/* GraphQL */ ` - query stripeSubscriptionQuery { - allPaymentMethods(first: 100) { + query stripeSubscriptionQuery($tenantId: ID!) { + allPaymentMethods(tenantId: $tenantId, first: 100) { edges { node { id @@ -21,7 +21,7 @@ export const stripeSubscriptionQuery = gql(/* GraphQL */ ` } } - activeSubscription { + activeSubscription(tenantId: $tenantId) { ...subscriptionActiveSubscriptionFragment id __typename diff --git a/packages/webapp-libs/webapp-finances/src/components/stripe/transactionHistory/__tests__/transactionHistory.component.spec.tsx b/packages/webapp-libs/webapp-finances/src/components/stripe/transactionHistory/__tests__/transactionHistory.component.spec.tsx index 7285f0dd8..487dcf8f9 100644 --- a/packages/webapp-libs/webapp-finances/src/components/stripe/transactionHistory/__tests__/transactionHistory.component.spec.tsx +++ b/packages/webapp-libs/webapp-finances/src/components/stripe/transactionHistory/__tests__/transactionHistory.component.spec.tsx @@ -1,5 +1,11 @@ import { Subscription } from '@sb/webapp-api-client/api/subscription/types'; -import { paymentMethodFactory, transactionHistoryEntryFactory } from '@sb/webapp-api-client/tests/factories'; +import { + currentUserFactory, + fillCommonQueryWithUser, + paymentMethodFactory, + transactionHistoryEntryFactory, +} from '@sb/webapp-api-client/tests/factories'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { screen } from '@testing-library/react'; import { fillAllPaymentsMethodsQuery, fillAllStripeChargesQuery } from '../../../../tests/factories'; @@ -28,10 +34,20 @@ describe('TransactionHistory: Component', () => { ]; it('should render all items', async () => { - const requestChargesMock = fillAllStripeChargesQuery(transactionHistory); - const requestPaymentsMock = fillAllPaymentsMethodsQuery(paymentMethods as Partial[]); + const tenantId = 'tenantId'; + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); + const requestChargesMock = fillAllStripeChargesQuery(transactionHistory, tenantId); + const requestPaymentsMock = fillAllPaymentsMethodsQuery(paymentMethods as Partial[], tenantId); render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestChargesMock, requestPaymentsMock), + apolloMocks: [requestChargesMock, requestPaymentsMock, tenantMock], }); expect(await screen.findByText('Owner 1 Visa **** 1234')).toBeInTheDocument(); diff --git a/packages/webapp-libs/webapp-finances/src/components/stripe/transactionHistory/transactionHistory.component.tsx b/packages/webapp-libs/webapp-finances/src/components/stripe/transactionHistory/transactionHistory.component.tsx index b7ad8fbc6..6b15df52d 100644 --- a/packages/webapp-libs/webapp-finances/src/components/stripe/transactionHistory/transactionHistory.component.tsx +++ b/packages/webapp-libs/webapp-finances/src/components/stripe/transactionHistory/transactionHistory.component.tsx @@ -1,13 +1,21 @@ import { useQuery } from '@apollo/client'; import { Table, TableBody, TableHead, TableHeader, TableRow } from '@sb/webapp-core/components/table'; import { mapConnection } from '@sb/webapp-core/utils/graphql'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { FormattedMessage } from 'react-intl'; import { stripeAllChargesQuery } from '../../../routes/subscriptions/subscriptions.graphql'; import { TransactionHistoryEntry } from './transactionHistoryEntry'; export const TransactionHistory = () => { - const { data } = useQuery(stripeAllChargesQuery, { fetchPolicy: 'cache-and-network' }); + const { data: currentTenant } = useCurrentTenant(); + const { data } = useQuery(stripeAllChargesQuery, { + fetchPolicy: 'cache-and-network', + variables: { + tenantId: currentTenant?.id ?? '', + }, + skip: !currentTenant, + }); return ( @@ -28,9 +36,12 @@ export const TransactionHistory = () => { - {mapConnection((entry) => { - return ; - }, data?.allCharges)} + {mapConnection( + (entry) => { + return ; + }, + data?.allCharges + )}
); diff --git a/packages/webapp-libs/webapp-finances/src/components/stripe/transactionHistory/transactionHistory.stories.tsx b/packages/webapp-libs/webapp-finances/src/components/stripe/transactionHistory/transactionHistory.stories.tsx index be7c6f991..44af95ab0 100644 --- a/packages/webapp-libs/webapp-finances/src/components/stripe/transactionHistory/transactionHistory.stories.tsx +++ b/packages/webapp-libs/webapp-finances/src/components/stripe/transactionHistory/transactionHistory.stories.tsx @@ -1,4 +1,10 @@ -import { paymentMethodFactory, transactionHistoryEntryFactory } from '@sb/webapp-api-client/tests/factories'; +import { + currentUserFactory, + fillCommonQueryWithUser, + paymentMethodFactory, + transactionHistoryEntryFactory, +} from '@sb/webapp-api-client/tests/factories'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { Meta, StoryFn, StoryObj } from '@storybook/react'; import { fillAllStripeChargesQuery } from '../../../tests/factories'; @@ -33,7 +39,17 @@ const meta: Meta = { }), }), ]; - return defaultMocks.concat([fillAllStripeChargesQuery(data)]); + const tenantId = 'tenantId'; + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); + return [tenantMock, fillAllStripeChargesQuery(data, tenantId)]; }, }), ], diff --git a/packages/webapp-libs/webapp-finances/src/hooks/useSubscriptionPlanDetails/useSubscriptionPlanDetails.graphql.ts b/packages/webapp-libs/webapp-finances/src/hooks/useSubscriptionPlanDetails/useSubscriptionPlanDetails.graphql.ts index 5366220fc..5cc12ca03 100644 --- a/packages/webapp-libs/webapp-finances/src/hooks/useSubscriptionPlanDetails/useSubscriptionPlanDetails.graphql.ts +++ b/packages/webapp-libs/webapp-finances/src/hooks/useSubscriptionPlanDetails/useSubscriptionPlanDetails.graphql.ts @@ -37,8 +37,8 @@ export const subscriptionActiveFragment = gql(/* GraphQL */ ` `); export const subscriptionActivePlanDetailsQuery = gql(/* GraphQL */ ` - query subscriptionActivePlanDetailsQuery_ { - activeSubscription { + query subscriptionActivePlanDetailsQuery_($tenantId: ID!) { + activeSubscription(tenantId: $tenantId) { ...subscriptionActiveSubscriptionFragment id } diff --git a/packages/webapp-libs/webapp-finances/src/hooks/useSubscriptionPlanDetails/useSubscriptionPlanDetails.hook.ts b/packages/webapp-libs/webapp-finances/src/hooks/useSubscriptionPlanDetails/useSubscriptionPlanDetails.hook.ts index 5e1c5899d..d7593975f 100644 --- a/packages/webapp-libs/webapp-finances/src/hooks/useSubscriptionPlanDetails/useSubscriptionPlanDetails.hook.ts +++ b/packages/webapp-libs/webapp-finances/src/hooks/useSubscriptionPlanDetails/useSubscriptionPlanDetails.hook.ts @@ -1,6 +1,7 @@ import { useQuery } from '@apollo/client'; import { ResultOf } from '@graphql-typed-document-node/core'; import { SubscriptionPlanName } from '@sb/webapp-api-client/api/subscription/types'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { times } from 'ramda'; import { useIntl } from 'react-intl'; @@ -56,7 +57,18 @@ export const useSubscriptionPlanDetails = (plan?: ResultOf { - const { data } = useQuery(stripeSubscriptionQuery, { nextFetchPolicy: 'cache-and-network' }); + const { data: currentTenant } = useCurrentTenant(); - return { allPaymentMethods: data?.allPaymentMethods, activeSubscription: data?.activeSubscription }; + const { data } = useQuery(stripeSubscriptionQuery, { + nextFetchPolicy: 'cache-and-network', + variables: { + tenantId: currentTenant?.id ?? '', + }, + skip: !currentTenant?.id, + }); + + return { + allPaymentMethods: data?.allPaymentMethods, + activeSubscription: data?.activeSubscription, + }; }; diff --git a/packages/webapp-libs/webapp-finances/src/routes/cancelSubscription/__tests__/cancelSubscription.component.spec.tsx b/packages/webapp-libs/webapp-finances/src/routes/cancelSubscription/__tests__/cancelSubscription.component.spec.tsx index 36e25dbf6..25e1c6879 100644 --- a/packages/webapp-libs/webapp-finances/src/routes/cancelSubscription/__tests__/cancelSubscription.component.spec.tsx +++ b/packages/webapp-libs/webapp-finances/src/routes/cancelSubscription/__tests__/cancelSubscription.component.spec.tsx @@ -1,16 +1,18 @@ import { SubscriptionPlanName } from '@sb/webapp-api-client/api/subscription/types'; import { + currentUserFactory, + fillCommonQueryWithUser, paymentMethodFactory, subscriptionFactory, subscriptionPhaseFactory, } from '@sb/webapp-api-client/tests/factories'; import { composeMockedQueryResult } from '@sb/webapp-api-client/tests/utils/fixtures'; import { trackEvent } from '@sb/webapp-core/services/analytics'; -import { getLocalePath } from '@sb/webapp-core/utils'; +import { getTenantPath } from '@sb/webapp-core/utils'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { GraphQLError } from 'graphql'; -import { append } from 'ramda'; import { Route, Routes } from 'react-router-dom'; import { ActiveSubscriptionContext } from '../../../components/activeSubscriptionContext'; @@ -22,7 +24,7 @@ import { subscriptionCancelMutation } from '../cancelSubscription.graphql'; jest.mock('@sb/webapp-core/services/analytics'); -const fillSubscriptionScheduleQueryWithPhases = (phases: any) => { +const fillSubscriptionScheduleQueryWithPhases = (phases: any, tenantId = 'tenantId') => { return fillSubscriptionScheduleQuery( subscriptionFactory({ defaultPaymentMethod: paymentMethodFactory({ @@ -30,18 +32,23 @@ const fillSubscriptionScheduleQueryWithPhases = (phases: any) => { card: { last4: '1234' }, }), phases, - }) + }), + undefined, + tenantId ); }; -const resolveSubscriptionDetailsQuery = () => { - return fillSubscriptionScheduleQueryWithPhases([ - subscriptionPhaseFactory({ - endDate: '2020-10-10', - item: { price: { product: { name: SubscriptionPlanName.MONTHLY } } }, - }), - subscriptionPhaseFactory({ startDate: '2020-10-10' }), - ]); +const resolveSubscriptionDetailsQuery = (tenantId = 'tenantId') => { + return fillSubscriptionScheduleQueryWithPhases( + [ + subscriptionPhaseFactory({ + endDate: '2020-10-10', + item: { price: { product: { name: SubscriptionPlanName.MONTHLY } } }, + }), + subscriptionPhaseFactory({ startDate: '2020-10-10' }), + ], + tenantId + ); }; const mutationData = { @@ -61,9 +68,9 @@ const mutationData = { const mutationVariables = { input: {} }; -const resolveSubscriptionCancelMutation = (errors?: GraphQLError[]) => { +const resolveSubscriptionCancelMutation = (errors?: GraphQLError[], variables = mutationVariables) => { return composeMockedQueryResult(subscriptionCancelMutation, { - variables: mutationVariables, + variables, data: mutationData, errors, }); @@ -75,19 +82,30 @@ const Component = () => { return ( }> - } /> + } /> ); }; +const tenantId = 'tenantId'; + describe('CancelSubscription: Component', () => { it('should render current plan details', async () => { + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const routerProps = createMockRouterProps(routePath); - const requestMock = resolveSubscriptionDetailsQuery(); + const requestMock = resolveSubscriptionDetailsQuery(tenantId); const { waitForApolloMocks } = render(, { routerProps, - apolloMocks: append(requestMock), + apolloMocks: [tenantMock, requestMock], }); await waitForApolloMocks(0); @@ -100,16 +118,25 @@ describe('CancelSubscription: Component', () => { describe('cancel button is clicked', () => { it('should trigger cancelSubscription action', async () => { + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const routerProps = createMockRouterProps(routePath); - const requestMock = resolveSubscriptionDetailsQuery(); - const requestCancelMock = resolveSubscriptionCancelMutation(); + const requestMock = resolveSubscriptionDetailsQuery(tenantId); + const requestCancelMock = resolveSubscriptionCancelMutation(undefined, { input: { tenantId } }); requestCancelMock.newData = jest.fn(() => ({ data: mutationData, })); render(, { routerProps, - apolloMocks: (defaultMocks) => defaultMocks.concat(requestMock, requestCancelMock), + apolloMocks: [tenantMock, requestMock, requestCancelMock], }); await userEvent.click(await screen.findByText(/cancel subscription/i)); @@ -120,13 +147,22 @@ describe('CancelSubscription: Component', () => { describe('cancel completes successfully', () => { it('should show success message and redirect to subscriptions page', async () => { + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const routerProps = createMockRouterProps(routePath); - const requestMock = resolveSubscriptionDetailsQuery(); - const requestCancelMock = resolveSubscriptionCancelMutation(); + const requestMock = resolveSubscriptionDetailsQuery(tenantId); + const requestCancelMock = resolveSubscriptionCancelMutation(undefined, { input: { tenantId } }); render(, { routerProps, - apolloMocks: (defaultMocks) => defaultMocks.concat(requestMock, requestCancelMock), + apolloMocks: [tenantMock, requestMock, requestCancelMock], }); await userEvent.click(await screen.findByText(/cancel subscription/i)); @@ -139,14 +175,23 @@ describe('CancelSubscription: Component', () => { describe('cancel completes with error', () => { it('shouldnt show success message and redirect to subscriptions page', async () => { + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const errors = [new GraphQLError('Bad Error')]; const routerProps = createMockRouterProps(routePath); - const requestMock = resolveSubscriptionDetailsQuery(); - const requestCancelMock = resolveSubscriptionCancelMutation(errors); + const requestMock = resolveSubscriptionDetailsQuery(tenantId); + const requestCancelMock = resolveSubscriptionCancelMutation(errors, { input: { tenantId } }); render(, { routerProps, - apolloMocks: (defaultMocks) => defaultMocks.concat(requestMock, requestCancelMock), + apolloMocks: [tenantMock, requestMock, requestCancelMock], }); await userEvent.click(await screen.findByText(/cancel subscription/i)); diff --git a/packages/webapp-libs/webapp-finances/src/routes/cancelSubscription/cancelSubscription.hook.ts b/packages/webapp-libs/webapp-finances/src/routes/cancelSubscription/cancelSubscription.hook.ts index d6dca292d..44724839d 100644 --- a/packages/webapp-libs/webapp-finances/src/routes/cancelSubscription/cancelSubscription.hook.ts +++ b/packages/webapp-libs/webapp-finances/src/routes/cancelSubscription/cancelSubscription.hook.ts @@ -3,6 +3,7 @@ import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; import { trackEvent } from '@sb/webapp-core/services/analytics'; import { useToast } from '@sb/webapp-core/toast/useToast'; import { reportError } from '@sb/webapp-core/utils/reportError'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { useIntl } from 'react-intl'; import { useNavigate } from 'react-router-dom'; @@ -14,6 +15,7 @@ export const useCancelSubscription = () => { const { toast } = useToast(); const navigate = useNavigate(); const generateLocalePath = useGenerateLocalePath(); + const { data: currentTenant } = useCurrentTenant(); const successMessage = intl.formatMessage({ defaultMessage: 'You will be moved to free plan with the next billing period', @@ -32,9 +34,13 @@ export const useCancelSubscription = () => { }); const handleCancel = () => { + if (!currentTenant) return; + commitCancelActiveSubscriptionMutation({ variables: { - input: {}, + input: { + tenantId: currentTenant.id, + }, }, }); }; diff --git a/packages/webapp-libs/webapp-finances/src/routes/editPaymentMethod/editPaymentMethod.component.tsx b/packages/webapp-libs/webapp-finances/src/routes/editPaymentMethod/editPaymentMethod.component.tsx index 4c3404468..f7947d723 100644 --- a/packages/webapp-libs/webapp-finances/src/routes/editPaymentMethod/editPaymentMethod.component.tsx +++ b/packages/webapp-libs/webapp-finances/src/routes/editPaymentMethod/editPaymentMethod.component.tsx @@ -1,7 +1,7 @@ import { PageHeadline } from '@sb/webapp-core/components/pageHeadline'; import { PageLayout } from '@sb/webapp-core/components/pageLayout'; -import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; import { useToast } from '@sb/webapp-core/toast/useToast'; +import { useGenerateTenantPath } from '@sb/webapp-tenants/hooks'; import { Elements } from '@stripe/react-stripe-js'; import { FormattedMessage, useIntl } from 'react-intl'; import { useNavigate } from 'react-router-dom'; @@ -14,7 +14,7 @@ export const EditPaymentMethod = () => { const intl = useIntl(); const { toast } = useToast(); const navigate = useNavigate(); - const generateLocalePath = useGenerateLocalePath(); + const generateTenantPath = useGenerateTenantPath(); const successMessage = intl.formatMessage({ defaultMessage: 'Payment method changed successfully', @@ -34,7 +34,7 @@ export const EditPaymentMethod = () => { { - navigate(generateLocalePath(RoutesConfig.subscriptions.index)); + navigate(generateTenantPath(RoutesConfig.subscriptions.index)); toast({ description: successMessage }); }} /> diff --git a/packages/webapp-libs/webapp-finances/src/routes/editPaymentMethod/editPaymentMethodForm/__tests__/editPaymentMethodForm.component.spec.tsx b/packages/webapp-libs/webapp-finances/src/routes/editPaymentMethod/editPaymentMethodForm/__tests__/editPaymentMethodForm.component.spec.tsx index 5fbeb82b1..2ed07db5d 100644 --- a/packages/webapp-libs/webapp-finances/src/routes/editPaymentMethod/editPaymentMethodForm/__tests__/editPaymentMethodForm.component.spec.tsx +++ b/packages/webapp-libs/webapp-finances/src/routes/editPaymentMethod/editPaymentMethodForm/__tests__/editPaymentMethodForm.component.spec.tsx @@ -1,16 +1,19 @@ import { SubscriptionPlanName } from '@sb/webapp-api-client/api/subscription/types'; import { + currentUserFactory, + fillCommonQueryWithUser, paymentMethodFactory, subscriptionFactory, subscriptionPhaseFactory, subscriptionPlanFactory, } from '@sb/webapp-api-client/tests/factories'; import { composeMockedQueryResult } from '@sb/webapp-api-client/tests/utils/fixtures'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { Elements } from '@stripe/react-stripe-js'; import { StripeElementChangeEvent } from '@stripe/stripe-js'; import { fireEvent, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; -import { append, times } from 'ramda'; +import { times } from 'ramda'; import React from 'react'; import { Route, Routes } from 'react-router-dom'; @@ -90,6 +93,8 @@ jest.mock('@stripe/react-stripe-js', () => ({ ), })); +const tenantId = 'tenantId'; + describe('EditPaymentMethodForm: Component', () => { const defaultProps = { onSuccess: () => null, @@ -133,6 +138,15 @@ describe('EditPaymentMethodForm: Component', () => { }; it('should render without errors', async () => { + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const paymentMethods = times(() => paymentMethodFactory(), 2); const requestMock = fillSubscriptionScheduleQueryWithPhases( [ @@ -142,15 +156,24 @@ describe('EditPaymentMethodForm: Component', () => { ], paymentMethods ); - const { waitForApolloMocks } = render(, { apolloMocks: append(requestMock) }); + const { waitForApolloMocks } = render(, { apolloMocks: [requestMock, tenantMock] }); await waitForApolloMocks(); }); it('should set default card if selected other already added card', async () => { + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const onSuccess = jest.fn(); const id = 'pk-test-id'; const paymentMethods = times(() => paymentMethodFactory(), 2); - const requestMethodsMock = fillSubscriptionScheduleQuery(subscriptionFactory({ id }), paymentMethods); + const requestMethodsMock = fillSubscriptionScheduleQuery(subscriptionFactory({ id }), paymentMethods, tenantId); const requestUpdateMutationMock = composeMockedQueryResult(stripeUpdateDefaultPaymentMethodMutation, { data: { updateDefaultPaymentMethod: { @@ -162,7 +185,7 @@ describe('EditPaymentMethodForm: Component', () => { }, }, }, - variables: { input: { id } }, + variables: { input: { id, tenantId } }, }); const phases = [ @@ -170,10 +193,10 @@ describe('EditPaymentMethodForm: Component', () => { item: { price: subscriptionPlanFactory({ product: { name: SubscriptionPlanName.FREE } }) }, }), ]; - const requestMock = fillSubscriptionScheduleQueryWithPhases(phases, paymentMethods); + const requestMock = fillSubscriptionScheduleQueryWithPhases(phases, paymentMethods, tenantId); const { waitForApolloMocks } = render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestMock, requestMethodsMock, requestUpdateMutationMock), + apolloMocks: [tenantMock, requestMock, requestMethodsMock, requestUpdateMutationMock], }); await waitForApolloMocks(1); @@ -190,6 +213,15 @@ describe('EditPaymentMethodForm: Component', () => { }); it('should call create setup intent if added new card', async () => { + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const onSuccess = jest.fn(); const phases = [ subscriptionPhaseFactory({ @@ -200,7 +232,7 @@ describe('EditPaymentMethodForm: Component', () => { const requestCreateIntentMock = composeMockedQueryResult(stripeCreateSetupIntentMutation, { data: { createSetupIntent: {} }, - variables: { input: {} }, + variables: { input: { tenantId } }, }); requestCreateIntentMock.newData = jest.fn(() => ({ @@ -213,10 +245,10 @@ describe('EditPaymentMethodForm: Component', () => { }, })); - const requestMock = fillSubscriptionScheduleQueryWithPhases(phases, paymentMethods); + const requestMock = fillSubscriptionScheduleQueryWithPhases(phases, paymentMethods, tenantId); render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestMock, requestCreateIntentMock), + apolloMocks: [tenantMock, requestMock, requestCreateIntentMock], }); await pressNewCardButton(); diff --git a/packages/webapp-libs/webapp-finances/src/routes/editPaymentMethod/editPaymentMethodForm/editPaymentMethodForm.hooks.tsx b/packages/webapp-libs/webapp-finances/src/routes/editPaymentMethod/editPaymentMethodForm/editPaymentMethodForm.hooks.tsx index 9278dea9d..863f052bd 100644 --- a/packages/webapp-libs/webapp-finances/src/routes/editPaymentMethod/editPaymentMethodForm/editPaymentMethodForm.hooks.tsx +++ b/packages/webapp-libs/webapp-finances/src/routes/editPaymentMethod/editPaymentMethodForm/editPaymentMethodForm.hooks.tsx @@ -1,6 +1,7 @@ import { useMutation } from '@apollo/client'; import { StripePaymentMethodType } from '@sb/webapp-api-client/api/stripe/paymentMethod'; import { StripeSetupIntentFragmentFragment } from '@sb/webapp-api-client/graphql'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { CardNumberElement, useElements, useStripe } from '@stripe/react-stripe-js'; import { GraphQLError } from 'graphql'; @@ -13,14 +14,19 @@ interface UseStripeSetupIntentProps { } export const useStripeSetupIntent = ({ onSuccess, onError }: UseStripeSetupIntentProps) => { + const { data: currentTenant } = useCurrentTenant(); const [commitCreateSetupIntentMutation, { data }] = useMutation(stripeCreateSetupIntentMutation, { onCompleted: (data) => onSuccess(data.createSetupIntent?.setupIntent as StripeSetupIntentFragmentFragment), onError: (error) => onError(error.graphQLErrors), }); const createSetupIntent = async () => { + if (!currentTenant) return; + if (!data?.createSetupIntent?.setupIntent) { - await commitCreateSetupIntentMutation({ variables: { input: {} } }); + await commitCreateSetupIntentMutation({ + variables: { input: { tenantId: currentTenant.id } }, + }); } }; diff --git a/packages/webapp-libs/webapp-finances/src/routes/editSubscription/__tests__/editSubscription.component.spec.tsx b/packages/webapp-libs/webapp-finances/src/routes/editSubscription/__tests__/editSubscription.component.spec.tsx index f0f8bff5e..a0015a9d7 100644 --- a/packages/webapp-libs/webapp-finances/src/routes/editSubscription/__tests__/editSubscription.component.spec.tsx +++ b/packages/webapp-libs/webapp-finances/src/routes/editSubscription/__tests__/editSubscription.component.spec.tsx @@ -1,8 +1,15 @@ +import { SubscriptionChangeActiveSubscriptionMutationMutationVariables } from '@sb/webapp-api-client'; import { SubscriptionPlanName } from '@sb/webapp-api-client/api/subscription/types'; -import { subscriptionPhaseFactory, subscriptionPlanFactory } from '@sb/webapp-api-client/tests/factories'; +import { + currentUserFactory, + fillCommonQueryWithUser, + subscriptionPhaseFactory, + subscriptionPlanFactory, +} from '@sb/webapp-api-client/tests/factories'; import { composeMockedQueryResult } from '@sb/webapp-api-client/tests/utils/fixtures'; import { RoutesConfig as CoreRoutesConfig } from '@sb/webapp-core/config/routes'; import { trackEvent } from '@sb/webapp-core/services/analytics'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { GraphQLError } from 'graphql'; @@ -23,7 +30,9 @@ const mockMonthlyPlan = subscriptionPlanFactory({ }); const mockYearlyPlan = subscriptionPlanFactory({ id: 'plan_yearly', product: { name: SubscriptionPlanName.YEARLY } }); -const mockMutationVariables = { input: { price: 'plan_monthly' } }; +const mockMutationVariables: SubscriptionChangeActiveSubscriptionMutationMutationVariables = { + input: { price: 'plan_monthly' }, +}; const mockMutationData = { changeActiveSubscription: { @@ -47,22 +56,25 @@ const phases = [ }), ]; -const fillCurrentSubscriptionQuery = () => fillSubscriptionScheduleQueryWithPhases(phases); -const fillChangeSubscriptionMutation = (errors?: GraphQLError[]) => +const fillCurrentSubscriptionQuery = (tenantId = 'tenantId') => + fillSubscriptionScheduleQueryWithPhases(phases, undefined, tenantId); +const fillChangeSubscriptionMutation = (errors?: GraphQLError[], variables = mockMutationVariables) => composeMockedQueryResult(subscriptionChangeActiveMutation, { data: mockMutationData, - variables: mockMutationVariables, + variables, errors, }); const placeholder = 'Subscriptions placeholder'; +const tenantId = 'tenantId'; + const Component = () => { return ( }> } /> - {placeholder}} /> + {placeholder}} /> ); @@ -71,14 +83,25 @@ const Component = () => { describe('EditSubscription: Component', () => { describe('plan is changed sucessfully', () => { it('should show success message and redirect to my subscription page', async () => { - const requestMock = fillCurrentSubscriptionQuery(); + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); + const requestMock = fillCurrentSubscriptionQuery(tenantId); const requestPlansMock = fillSubscriptionPlansAllQuery([mockMonthlyPlan, mockYearlyPlan]); - const requestMockMutation = fillChangeSubscriptionMutation(); + const requestMockMutation = fillChangeSubscriptionMutation(undefined, { + input: { price: 'plan_monthly', tenantId }, + }); const routerProps = createMockRouterProps(CoreRoutesConfig.home); render(, { routerProps, - apolloMocks: (defaultMock) => defaultMock.concat(requestMock, requestMockMutation, requestPlansMock), + apolloMocks: [tenantMock, requestMock, requestMockMutation, requestPlansMock], }); await userEvent.click(await screen.findByText(/monthly/i)); @@ -99,15 +122,26 @@ describe('EditSubscription: Component', () => { describe('plan fails to update', () => { it('should show error message', async () => { - const requestMock = fillCurrentSubscriptionQuery(); + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); + const requestMock = fillCurrentSubscriptionQuery(tenantId); const errorMessage = 'Missing payment method'; const requestPlansMock = fillSubscriptionPlansAllQuery([mockMonthlyPlan]); - const requestMockMutation = fillChangeSubscriptionMutation([new GraphQLError(errorMessage)]); + const requestMockMutation = fillChangeSubscriptionMutation([new GraphQLError(errorMessage)], { + input: { price: 'plan_monthly', tenantId }, + }); const routerProps = createMockRouterProps(CoreRoutesConfig.home); render(, { routerProps, - apolloMocks: (defaultMock) => defaultMock.concat(requestMock, requestMockMutation, requestPlansMock), + apolloMocks: [requestMock, requestMockMutation, requestPlansMock, tenantMock], }); await userEvent.click(await screen.findByText(/monthly/i)); diff --git a/packages/webapp-libs/webapp-finances/src/routes/editSubscription/editSubscription.hook.ts b/packages/webapp-libs/webapp-finances/src/routes/editSubscription/editSubscription.hook.ts index f5b5281b3..a594df13c 100644 --- a/packages/webapp-libs/webapp-finances/src/routes/editSubscription/editSubscription.hook.ts +++ b/packages/webapp-libs/webapp-finances/src/routes/editSubscription/editSubscription.hook.ts @@ -1,7 +1,8 @@ import { useMutation } from '@apollo/client'; -import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; import { trackEvent } from '@sb/webapp-core/services/analytics'; import { useToast } from '@sb/webapp-core/toast/useToast'; +import { useGenerateTenantPath } from '@sb/webapp-tenants/hooks'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { useIntl } from 'react-intl'; import { useNavigate } from 'react-router-dom'; @@ -12,7 +13,8 @@ export const useEditSubscription = () => { const intl = useIntl(); const { toast } = useToast(); const navigate = useNavigate(); - const generateLocalePath = useGenerateLocalePath(); + const generateTenantPath = useGenerateTenantPath(); + const { data: currentTenant } = useCurrentTenant(); const successMessage = intl.formatMessage({ id: 'Change plan / Success message', @@ -32,17 +34,18 @@ export const useEditSubscription = () => { trackEvent('subscription', 'change-plan'); toast({ description: successMessage }); - navigate(generateLocalePath(RoutesConfig.subscriptions.index)); + navigate(generateTenantPath(RoutesConfig.subscriptions.index)); }, }); const selectPlan = async (plan: string | null) => { - if (!plan) return; + if (!plan || !currentTenant) return; await commitChangeActiveSubscriptionMutation({ variables: { input: { price: plan, + tenantId: currentTenant.id, }, }, }); diff --git a/packages/webapp-libs/webapp-finances/src/routes/editSubscription/subscriptionPlanItem/__tests__/subscriptionPlanItem.component.spec.tsx b/packages/webapp-libs/webapp-finances/src/routes/editSubscription/subscriptionPlanItem/__tests__/subscriptionPlanItem.component.spec.tsx index 8748a5076..b536343e1 100644 --- a/packages/webapp-libs/webapp-finances/src/routes/editSubscription/subscriptionPlanItem/__tests__/subscriptionPlanItem.component.spec.tsx +++ b/packages/webapp-libs/webapp-finances/src/routes/editSubscription/subscriptionPlanItem/__tests__/subscriptionPlanItem.component.spec.tsx @@ -1,12 +1,15 @@ import { useQuery } from '@apollo/client'; import { SubscriptionPlanName } from '@sb/webapp-api-client/api/subscription'; import { + currentUserFactory, + fillCommonQueryWithUser, paymentMethodFactory, subscriptionFactory, subscriptionPhaseFactory, subscriptionPlanFactory, } from '@sb/webapp-api-client/tests/factories'; import { mapConnection } from '@sb/webapp-core/utils/graphql'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { Route, Routes } from 'react-router-dom'; @@ -26,6 +29,8 @@ import { SubscriptionPlanItem, SubscriptionPlanItemProps } from '../subscription const defaultPaymentPlan = [paymentMethodFactory()]; +const tenantId = 'tenantId'; + describe('SubscriptionPlanItem: Component', () => { const defaultProps: Pick = { onSelect: () => jest.fn() }; @@ -121,12 +126,21 @@ describe('SubscriptionPlanItem: Component', () => { describe('next billing plan is same as the clicked one', () => { it('should not call onSelect', async () => { + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const onSelect = jest.fn(); const requestMock = fillSubscriptionScheduleQuery(subscriptionWithMonthlyPlan); const requestPlansMock = fillSubscriptionPlansAllQuery([monthlyPlan]); const { waitForApolloMocks } = render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestMock, requestPlansMock), + apolloMocks: [requestMock, requestPlansMock, tenantMock], }); await waitForApolloMocks(); @@ -155,12 +169,21 @@ describe('SubscriptionPlanItem: Component', () => { describe('trial is eligible', () => { it('should show trial info', async () => { + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const activableTrialSubscription = subscriptionFactory({ canActivateTrial: true }); - const requestSubscriptionMock = fillSubscriptionScheduleQuery(activableTrialSubscription); + const requestSubscriptionMock = fillSubscriptionScheduleQuery(activableTrialSubscription, undefined, tenantId); const requestPlansMock = fillSubscriptionPlansAllQuery([monthlyPlan]); const { waitForApolloMocks } = render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestSubscriptionMock, requestPlansMock), + apolloMocks: [requestSubscriptionMock, requestPlansMock, tenantMock], }); await waitForApolloMocks(); expect(await screen.findByText(/will start with a trial/i)).toBeInTheDocument(); diff --git a/packages/webapp-libs/webapp-finances/src/routes/subscriptions/__tests__/subscriptions.component.spec.tsx b/packages/webapp-libs/webapp-finances/src/routes/subscriptions/__tests__/subscriptions.component.spec.tsx index f5698a23f..59b2dcf02 100644 --- a/packages/webapp-libs/webapp-finances/src/routes/subscriptions/__tests__/subscriptions.component.spec.tsx +++ b/packages/webapp-libs/webapp-finances/src/routes/subscriptions/__tests__/subscriptions.component.spec.tsx @@ -1,12 +1,15 @@ import { SubscriptionPlanName, Subscription as SubscriptionType } from '@sb/webapp-api-client/api/subscription/types'; import { + currentUserFactory, + fillCommonQueryWithUser, paymentMethodFactory, subscriptionFactory, subscriptionPhaseFactory, subscriptionPlanFactory, } from '@sb/webapp-api-client/tests/factories'; import { matchTextContent } from '@sb/webapp-core/tests/utils/match'; -import { getLocalePath } from '@sb/webapp-core/utils'; +import { getTenantPath, getTenantPathHelper } from '@sb/webapp-core/utils'; +import { tenantFactory } from '@sb/webapp-tenants/tests/factories/tenant'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { append } from 'ramda'; @@ -33,30 +36,41 @@ const defaultActivePlan = { }, }; -const resolveSubscriptionDetailsQuery = () => { - return fillSubscriptionScheduleQueryWithPhases([ - subscriptionPhaseFactory({ - endDate: new Date('Jan 1, 2099 GMT').toISOString(), - item: { price: { product: { name: SubscriptionPlanName.FREE } } }, - }), - ]); +const resolveSubscriptionDetailsQuery = (tenantId = 'tenantId') => { + return fillSubscriptionScheduleQueryWithPhases( + [ + subscriptionPhaseFactory({ + endDate: new Date('Jan 1, 2099 GMT').toISOString(), + item: { price: { product: { name: SubscriptionPlanName.FREE } } }, + }), + ], + undefined, + tenantId + ); }; -const resolveSubscriptionDetailsQueryWithSubscriptionCanceled = () => { - return fillSubscriptionScheduleQueryWithPhases([ - subscriptionPhaseFactory({ - endDate: new Date('Jan 1, 2099 GMT').toISOString(), - item: { price: { product: { name: SubscriptionPlanName.MONTHLY } } }, - }), - subscriptionPhaseFactory({ - startDate: new Date('Jan 1, 2099 GMT').toISOString(), - item: { price: { product: { name: SubscriptionPlanName.FREE } } }, - }), - ]); +const resolveSubscriptionDetailsQueryWithSubscriptionCanceled = (tenantId = 'tenantId') => { + return fillSubscriptionScheduleQueryWithPhases( + [ + subscriptionPhaseFactory({ + endDate: new Date('Jan 1, 2099 GMT').toISOString(), + item: { price: { product: { name: SubscriptionPlanName.MONTHLY } } }, + }), + subscriptionPhaseFactory({ + startDate: new Date('Jan 1, 2099 GMT').toISOString(), + item: { price: { product: { name: SubscriptionPlanName.FREE } } }, + }), + ], + undefined, + tenantId + ); }; -const resolveActiveSubscriptionMocks = (subscription = defaultActivePlan as SubscriptionType) => { - const activePlanMock = fillActivePlanDetailsQuery(subscription); +const resolveActiveSubscriptionMocks = ( + tenantId = 'tenantId', + subscription = defaultActivePlan as SubscriptionType +) => { + const activePlanMock = fillActivePlanDetailsQuery(subscription, tenantId); const stripeChargesMock = fillAllStripeChargesQuery(); return [activePlanMock, stripeChargesMock]; }; @@ -68,37 +82,54 @@ const Component = () => ( }> }> - } /> } + /> + } /> } /> } /> } /> ); +const tenantId = 'tenantId'; const currentSubscriptionTabPath = RoutesConfig.subscriptions.index; -const currentSubscriptionTabRouterProps = createMockRouterProps(currentSubscriptionTabPath); +const currentSubscriptionTabRouterProps = createMockRouterProps(getTenantPath(currentSubscriptionTabPath), { + tenantId, +}); describe('Subscriptions: Component', () => { it('should render current subscription plan', async () => { const requestMock = resolveSubscriptionDetailsQuery(); + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); + render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestMock, resolveActiveSubscriptionMocks()), + apolloMocks: [tenantMock, requestMock, ...resolveActiveSubscriptionMocks(tenantId)], routerProps: currentSubscriptionTabRouterProps, }); @@ -106,13 +137,22 @@ describe('Subscriptions: Component', () => { }); it('should render default payment method', async () => { + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const subscription = subscriptionFactory({ defaultPaymentMethod: paymentMethodsMock[0], }); - const requestSubscriptionScheduleMock = fillSubscriptionScheduleQuery(subscription, paymentMethodsMock); + const requestSubscriptionScheduleMock = fillSubscriptionScheduleQuery(subscription, paymentMethodsMock, tenantId); const { waitForApolloMocks } = render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestSubscriptionScheduleMock), + apolloMocks: [requestSubscriptionScheduleMock, tenantMock], routerProps: currentSubscriptionTabRouterProps, }); @@ -127,8 +167,18 @@ describe('Subscriptions: Component', () => { it('should render next renewal date', async () => { const requestMock = resolveSubscriptionDetailsQuery(); + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); + render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestMock, resolveActiveSubscriptionMocks()), + apolloMocks: [tenantMock, requestMock, ...resolveActiveSubscriptionMocks(tenantId)], routerProps: currentSubscriptionTabRouterProps, }); @@ -147,8 +197,18 @@ describe('Subscriptions: Component', () => { it('should render cancellation date', async () => { const requestMock = resolveSubscriptionDetailsQueryWithSubscriptionCanceled(); + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); + render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestMock, resolveActiveSubscriptionMocks()), + apolloMocks: [tenantMock, requestMock, ...resolveActiveSubscriptionMocks(tenantId)], routerProps: currentSubscriptionTabRouterProps, }); @@ -165,10 +225,19 @@ describe('Subscriptions: Component', () => { describe('edit subscription button', () => { it('should navigate to change plan screen', async () => { + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const requestMock = resolveSubscriptionDetailsQuery(); render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestMock, resolveActiveSubscriptionMocks()), + apolloMocks: [tenantMock, requestMock, ...resolveActiveSubscriptionMocks(tenantId)], routerProps: currentSubscriptionTabRouterProps, }); @@ -201,12 +270,21 @@ describe('Subscriptions: Component', () => { }); it('should navigate to cancel subscription screen', async () => { + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const activeSubscription = subscriptionFactory(); const requestMock = fillSubscriptionScheduleQuery(activeSubscription); render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestMock, resolveActiveSubscriptionMocks()), + apolloMocks: [tenantMock, requestMock, ...resolveActiveSubscriptionMocks(tenantId)], routerProps: currentSubscriptionTabRouterProps, }); @@ -217,13 +295,23 @@ describe('Subscriptions: Component', () => { describe('trial section', () => { it('shouldnt be displayed if user has no trial active', async () => { - const requestMock = resolveSubscriptionDetailsQuery(); + const requestMock = resolveSubscriptionDetailsQuery(tenantId); + + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); const { waitForApolloMocks } = render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestMock), + apolloMocks: [tenantMock, requestMock, ...resolveActiveSubscriptionMocks(tenantId)], routerProps: currentSubscriptionTabRouterProps, }); - await waitForApolloMocks(); + await waitForApolloMocks(1); expect(screen.queryByText(/Free trial expiry date/i)).not.toBeInTheDocument(); }); @@ -241,10 +329,20 @@ describe('Subscriptions: Component', () => { ], }); - const requestMock = fillSubscriptionScheduleQuery(activeSubscription); + const requestMock = fillSubscriptionScheduleQuery(activeSubscription, undefined, tenantId); + + const tenantMock = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + id: tenantId, + }), + ], + }) + ); render(, { - apolloMocks: (defaultMocks) => defaultMocks.concat(requestMock, resolveActiveSubscriptionMocks()), + apolloMocks: [tenantMock, requestMock, ...resolveActiveSubscriptionMocks(tenantId)], routerProps: currentSubscriptionTabRouterProps, }); diff --git a/packages/webapp-libs/webapp-finances/src/routes/subscriptions/currentSubscription.component.tsx b/packages/webapp-libs/webapp-finances/src/routes/subscriptions/currentSubscription.component.tsx index 392aea8fe..731b71206 100644 --- a/packages/webapp-libs/webapp-finances/src/routes/subscriptions/currentSubscription.component.tsx +++ b/packages/webapp-libs/webapp-finances/src/routes/subscriptions/currentSubscription.component.tsx @@ -1,7 +1,7 @@ import { PageHeadline } from '@sb/webapp-core/components/pageHeadline'; import { PageLayout } from '@sb/webapp-core/components/pageLayout'; import { Tabs, TabsList, TabsTrigger } from '@sb/webapp-core/components/tabs'; -import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; +import { useGenerateTenantPath } from '@sb/webapp-tenants/hooks'; import { FormattedMessage } from 'react-intl'; import { Link, Outlet, useLocation } from 'react-router-dom'; @@ -10,34 +10,35 @@ import { useActiveSubscriptionQueryLoader } from '../../hooks'; export const Subscriptions = () => { const location = useLocation(); - const generateLocalePath = useGenerateLocalePath(); + + const generateTenantPath = useGenerateTenantPath(); const activeSubscriptionData = useActiveSubscriptionQueryLoader(); return ( } + header={} subheader={ } /> - - + + - - + + - - + + diff --git a/packages/webapp-libs/webapp-finances/src/routes/subscriptions/paymentMethod.content.tsx b/packages/webapp-libs/webapp-finances/src/routes/subscriptions/paymentMethod.content.tsx index 7a2f6ebd8..8c591df96 100644 --- a/packages/webapp-libs/webapp-finances/src/routes/subscriptions/paymentMethod.content.tsx +++ b/packages/webapp-libs/webapp-finances/src/routes/subscriptions/paymentMethod.content.tsx @@ -4,8 +4,9 @@ import { Link } from '@sb/webapp-core/components/buttons'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@sb/webapp-core/components/cards'; import { PageHeadline } from '@sb/webapp-core/components/pageHeadline'; import { TabsContent } from '@sb/webapp-core/components/tabs'; -import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; import { mapConnection } from '@sb/webapp-core/utils/graphql'; +import { useGenerateTenantPath } from '@sb/webapp-tenants/hooks'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { FormattedMessage } from 'react-intl'; import { useActiveSubscriptionDetails } from '../../components/activeSubscriptionContext'; @@ -14,11 +15,18 @@ import { RoutesConfig } from '../../config/routes'; import { subscriptionActivePlanDetailsQuery, subscriptionActiveSubscriptionFragment } from '../../hooks'; const PaymentMethodContent = () => { - const generateLocalePath = useGenerateLocalePath(); + const generateTenantPath = useGenerateTenantPath(); const { allPaymentMethods } = useActiveSubscriptionDetails(); + const { data: currentTenant } = useCurrentTenant(); + + const { data } = useQuery(subscriptionActivePlanDetailsQuery, { + variables: { + tenantId: currentTenant?.id ?? '', + }, + skip: !currentTenant?.id, + }); - const { data } = useQuery(subscriptionActivePlanDetailsQuery); const activeSubscription = getFragmentData(subscriptionActiveSubscriptionFragment, data?.activeSubscription); const paymentMethods = mapConnection((plan) => plan, allPaymentMethods); @@ -52,7 +60,7 @@ const PaymentMethodContent = () => { ); return ( - +
} @@ -68,7 +76,7 @@ const PaymentMethodContent = () => {
{defaultMethod && renderCardDetails()} {paymentMethods.length === 0 && renderEmptyList()} - + {paymentMethods.length ? ( { - const generateLocalePath = useGenerateLocalePath(); + const generateTenantPath = useGenerateTenantPath(); const { activeSubscription } = useActiveSubscriptionDetails(); @@ -27,7 +27,7 @@ const SubscriptionsContent = () => { } = useActiveSubscriptionDetailsData(activeSubscription); return ( - +
{
@@ -120,7 +120,7 @@ const SubscriptionsContent = () => { {activeSubscriptionPlan && !activeSubscriptionPlan.isFree && !activeSubscriptionIsCancelled && ( diff --git a/packages/webapp-libs/webapp-finances/src/routes/subscriptions/subscriptions.graphql.ts b/packages/webapp-libs/webapp-finances/src/routes/subscriptions/subscriptions.graphql.ts index dcc0a240d..0d033aa61 100644 --- a/packages/webapp-libs/webapp-finances/src/routes/subscriptions/subscriptions.graphql.ts +++ b/packages/webapp-libs/webapp-finances/src/routes/subscriptions/subscriptions.graphql.ts @@ -1,8 +1,8 @@ import { gql } from '@sb/webapp-api-client/graphql'; export const stripeAllChargesQuery = gql(/* GraphQL */ ` - query stripeAllChargesQuery { - allCharges { + query stripeAllChargesQuery($tenantId: ID!) { + allCharges(tenantId: $tenantId) { edges { node { id diff --git a/packages/webapp-libs/webapp-finances/src/routes/subscriptions/transactionsHistory.content.tsx b/packages/webapp-libs/webapp-finances/src/routes/subscriptions/transactionsHistory.content.tsx index a323b288d..3c0a0a23f 100644 --- a/packages/webapp-libs/webapp-finances/src/routes/subscriptions/transactionsHistory.content.tsx +++ b/packages/webapp-libs/webapp-finances/src/routes/subscriptions/transactionsHistory.content.tsx @@ -2,20 +2,27 @@ import { useQuery } from '@apollo/client'; import { Link } from '@sb/webapp-core/components/buttons'; import { PageHeadline } from '@sb/webapp-core/components/pageHeadline'; import { TabsContent } from '@sb/webapp-core/components/tabs'; -import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; +import { useGenerateTenantPath } from '@sb/webapp-tenants/hooks'; +import { useCurrentTenant } from '@sb/webapp-tenants/providers'; import { FormattedMessage } from 'react-intl'; import { RoutesConfig } from '../../config/routes'; import { stripeAllChargesQuery } from './subscriptions.graphql'; const TransactionsHistoryContent = () => { - const generateLocalePath = useGenerateLocalePath(); - const { data } = useQuery(stripeAllChargesQuery); + const { data: currentTenant } = useCurrentTenant(); + const generateTenantPath = useGenerateTenantPath(); + const { data } = useQuery(stripeAllChargesQuery, { + variables: { + tenantId: currentTenant?.id ?? '', + }, + skip: !currentTenant, + }); const length = data?.allCharges?.edges?.length ?? 0; return ( - +
} @@ -33,7 +40,7 @@ const TransactionsHistoryContent = () => {
) : (
- +
diff --git a/packages/webapp-libs/webapp-finances/src/tests/factories/stripe.ts b/packages/webapp-libs/webapp-finances/src/tests/factories/stripe.ts index 0c24d5ff6..573b30c74 100644 --- a/packages/webapp-libs/webapp-finances/src/tests/factories/stripe.ts +++ b/packages/webapp-libs/webapp-finances/src/tests/factories/stripe.ts @@ -4,8 +4,14 @@ import { times } from 'ramda'; import { stripeAllChargesQuery } from '../../routes/subscriptions/subscriptions.graphql'; -export const fillAllStripeChargesQuery = (data = times(() => transactionHistoryEntryFactory(), 5)) => { +export const fillAllStripeChargesQuery = ( + data = times(() => transactionHistoryEntryFactory(), 5), + tenantId = 'tenantId' +) => { return composeMockedListQueryResult(stripeAllChargesQuery, 'allCharges', 'StripeChargeType', { data, + variables: { + tenantId, + }, }); }; diff --git a/packages/webapp-libs/webapp-finances/src/tests/factories/subscription.ts b/packages/webapp-libs/webapp-finances/src/tests/factories/subscription.ts index 43ee16888..e519e7694 100644 --- a/packages/webapp-libs/webapp-finances/src/tests/factories/subscription.ts +++ b/packages/webapp-libs/webapp-finances/src/tests/factories/subscription.ts @@ -13,7 +13,8 @@ import { subscriptionPlansAllQuery } from '../../routes/editSubscription/subscri export const fillSubscriptionScheduleQuery = ( subscription: Partial, - paymentMethods?: StripePaymentMethod[] + paymentMethods?: StripePaymentMethod[], + tenantId = 'tenantId' ) => { const defaultPaymentMethod = subscription.defaultPaymentMethod || ({} as StripePaymentMethod); if (!paymentMethods) { @@ -25,12 +26,14 @@ export const fillSubscriptionScheduleQuery = ( activeSubscription: { ...subscription, __typename: 'SubscriptionScheduleType' }, allPaymentMethods: mapRelayEdges(paymentMethods, 'StripePaymentMethodType'), }, + variables: { tenantId }, }); }; export const fillSubscriptionScheduleQueryWithPhases = ( phases: SubscriptionPhase[], - paymentMethods?: StripePaymentMethod[] + paymentMethods?: StripePaymentMethod[], + tenantId = 'tenantId' ) => { return fillSubscriptionScheduleQuery( subscriptionFactory({ @@ -40,7 +43,8 @@ export const fillSubscriptionScheduleQueryWithPhases = ( }), phases, }), - paymentMethods + paymentMethods, + tenantId ); }; @@ -51,14 +55,16 @@ export const fillSubscriptionPlansAllQuery = (data: SubscriptionPlan[] = []) => }; // Apollo Mocks -export const fillAllPaymentsMethodsQuery = (data: Partial[]) => +export const fillAllPaymentsMethodsQuery = (data: Partial[], tenantId = 'tenantId') => composeMockedListQueryResult(stripeSubscriptionQuery, 'allPaymentMethods', 'StripePaymentMethodType', { data, + variables: { tenantId }, }); -export const fillActivePlanDetailsQuery = (data: Partial) => +export const fillActivePlanDetailsQuery = (data: Partial, tenantId = 'tenantId') => composeMockedQueryResult(subscriptionActivePlanDetailsQuery, { data: { activeSubscription: data, }, + variables: { tenantId }, }); diff --git a/packages/webapp-libs/webapp-finances/src/tests/utils/rendering.tsx b/packages/webapp-libs/webapp-finances/src/tests/utils/rendering.tsx index a1de56bd5..940bc97b2 100644 --- a/packages/webapp-libs/webapp-finances/src/tests/utils/rendering.tsx +++ b/packages/webapp-libs/webapp-finances/src/tests/utils/rendering.tsx @@ -1,25 +1,23 @@ import * as apiUtils from '@sb/webapp-api-client/tests/utils/rendering'; import * as corePath from '@sb/webapp-core/utils/path'; +import * as tenantsUtils from '@sb/webapp-tenants/tests/utils/rendering'; import { RenderOptions, render, renderHook } from '@testing-library/react'; -import { ComponentClass, ComponentType, FC, ReactElement } from 'react'; +import { ComponentType, ReactElement } from 'react'; import { MemoryRouterProps, generatePath } from 'react-router'; export type WrapperProps = apiUtils.WrapperProps; -export function getWrapper( - WrapperComponent: ComponentClass | FC, - wrapperProps: WrapperProps -): { +export function getWrapper(wrapperProps: WrapperProps): { wrapper: ComponentType; waitForApolloMocks: (mockIndex?: number) => Promise; } { - return apiUtils.getWrapper(apiUtils.ApiTestProviders, wrapperProps); + return tenantsUtils.getWrapper(tenantsUtils.TenantsTestProviders, wrapperProps); } export type CustomRenderOptions = RenderOptions & WrapperProps; function customRender(ui: ReactElement, options: CustomRenderOptions = {}) { - const { wrapper, waitForApolloMocks } = getWrapper(apiUtils.ApiTestProviders, options); + const { wrapper, waitForApolloMocks } = getWrapper(options); return { ...render(ui, { @@ -31,7 +29,7 @@ function customRender(ui: ReactElement, options: CustomRenderOptions = {}) { } function customRenderHook(hook: (initialProps: Props) => Result, options: CustomRenderOptions = {}) { - const { wrapper, waitForApolloMocks } = getWrapper(apiUtils.ApiTestProviders, options); + const { wrapper, waitForApolloMocks } = getWrapper(options); return { ...renderHook(hook, { diff --git a/packages/webapp-libs/webapp-finances/src/utils/storybook.tsx b/packages/webapp-libs/webapp-finances/src/utils/storybook.tsx index 8f454ca5c..4039d2f9b 100644 --- a/packages/webapp-libs/webapp-finances/src/utils/storybook.tsx +++ b/packages/webapp-libs/webapp-finances/src/utils/storybook.tsx @@ -1,4 +1,3 @@ -import { ApiTestProviders } from '@sb/webapp-api-client/tests/utils/rendering'; import { StoryFn } from '@storybook/react'; import { Elements } from '@stripe/react-stripe-js'; import { Route, Routes } from 'react-router-dom'; @@ -26,7 +25,7 @@ export const withActiveSubscriptionContext = (StoryComponent: StoryFn) => { export function withProviders(wrapperProps: WrapperProps = {}) { return (StoryComponent: StoryFn) => { - const { wrapper: WrapperComponent } = getWrapper(ApiTestProviders, wrapperProps) as any; + const { wrapper: WrapperComponent } = getWrapper(wrapperProps) as any; return ( diff --git a/packages/webapp-libs/webapp-notifications/src/__tests__/notifications.component.spec.tsx b/packages/webapp-libs/webapp-notifications/src/__tests__/notifications.component.spec.tsx index 6e9483d81..873088928 100644 --- a/packages/webapp-libs/webapp-notifications/src/__tests__/notifications.component.spec.tsx +++ b/packages/webapp-libs/webapp-notifications/src/__tests__/notifications.component.spec.tsx @@ -3,18 +3,27 @@ import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { ElementType } from 'react'; -import { Notifications } from '../notifications.component'; +import { Notifications, NotificationsProps } from '../notifications.component'; import { NotificationTypes } from '../notifications.types'; -import { fillNotificationCreatedSubscriptionQuery, notificationFactory } from '../tests/factories'; +import { + fillNotificationCreatedSubscriptionQuery, + fillNotificationsListQuery, + notificationFactory, +} from '../tests/factories'; import { render } from '../tests/utils/rendering'; describe('Notifications: Component', () => { const templates: Record = { [NotificationTypes.CRUD_ITEM_CREATED]: () => <>CRUD_ITEM_CREATED, [NotificationTypes.CRUD_ITEM_UPDATED]: () => <>CRUD_ITEM_UPDATED, + [NotificationTypes.TENANT_INVITATION_CREATED]: () => <>TENANT_INVITATION_CREATED, + [NotificationTypes.TENANT_INVITATION_ACCEPTED]: () => <>TENANT_INVITATION_ACCEPTED, + [NotificationTypes.TENANT_INVITATION_DECLINED]: () => <>TENANT_INVITATION_DECLINED, }; - const Component = () => ; + const Component = ({ events = {} }: { events?: NotificationsProps['events'] }) => ( + + ); it('Should show trigger button', async () => { const apolloMocks = [ @@ -39,4 +48,43 @@ describe('Notifications: Component', () => { await userEvent.click(await screen.findByTestId('notifications-trigger-testid')); expect(await screen.findByText('Notifications')).toBeInTheDocument(); }); + + it('Should call notification event for proper type', async () => { + const notificationType = NotificationTypes.TENANT_INVITATION_CREATED; + + const apolloMocks = [ + fillCommonQueryWithUser(), + fillNotificationCreatedSubscriptionQuery( + notificationFactory({ + type: notificationType, + }) + ), + ]; + + const mockEvent = jest.fn(); + const events = { [notificationType]: mockEvent }; + + const { waitForApolloMocks } = render(, { apolloMocks }); + await waitForApolloMocks(); + + expect(mockEvent).toHaveBeenCalled(); + }); + + it('Should ignore existing notification from Subscription', async () => { + const notification = notificationFactory({ + type: NotificationTypes.TENANT_INVITATION_ACCEPTED, + }); + + const apolloMocks = [ + fillCommonQueryWithUser(), + fillNotificationsListQuery([notification]), + fillNotificationCreatedSubscriptionQuery(notification), + ]; + + const { waitForApolloMocks } = render(, { apolloMocks }); + await waitForApolloMocks(); + + await userEvent.click(await screen.findByTestId('notifications-trigger-testid')); + expect(await screen.findByText('TENANT_INVITATION_ACCEPTED')).toBeInTheDocument(); + }); }); diff --git a/packages/webapp-libs/webapp-notifications/src/notifications.component.tsx b/packages/webapp-libs/webapp-notifications/src/notifications.component.tsx index f6f306a10..766375634 100644 --- a/packages/webapp-libs/webapp-notifications/src/notifications.component.tsx +++ b/packages/webapp-libs/webapp-notifications/src/notifications.component.tsx @@ -13,11 +13,12 @@ import { } from './notificationsList'; import { NOTIFICATIONS_PER_PAGE } from './notificationsList/notificationsList.constants'; -type NotificationsProps = { +export type NotificationsProps = { templates: Record; + events: Partial void | undefined>>; }; -export const Notifications: FC = ({ templates }) => { +export const Notifications: FC = ({ templates, events }) => { const { loading, data, fetchMore, networkStatus } = useQuery(notificationsListQuery); useSubscription(notificationCreatedSubscription, { onData: (options) => { @@ -25,6 +26,9 @@ export const Notifications: FC = ({ templates }) => { notificationsListItemFragment, options.data?.data?.notificationCreated?.notification ); + + if (notificationData) events[notificationData.type as NotificationTypes]?.(); + options.client.cache.updateQuery({ query: notificationsListQuery }, (prev) => { const prevListData = getFragmentData(notificationsListContentFragment, prev); const alreadyExists = prevListData?.allNotifications?.edges?.find((edge) => { diff --git a/packages/webapp-libs/webapp-notifications/src/notifications.types.ts b/packages/webapp-libs/webapp-notifications/src/notifications.types.ts index 376cdb854..4dcdf6164 100644 --- a/packages/webapp-libs/webapp-notifications/src/notifications.types.ts +++ b/packages/webapp-libs/webapp-notifications/src/notifications.types.ts @@ -4,6 +4,9 @@ import { UnknownObject } from '@sb/webapp-core/utils/types'; export enum NotificationTypes { CRUD_ITEM_CREATED = 'CRUD_ITEM_CREATED', CRUD_ITEM_UPDATED = 'CRUD_ITEM_UPDATED', + TENANT_INVITATION_CREATED = 'TENANT_INVITATION_CREATED', + TENANT_INVITATION_ACCEPTED = 'TENANT_INVITATION_ACCEPTED', + TENANT_INVITATION_DECLINED = 'TENANT_INVITATION_DECLINED', //<-- INJECT NOTIFICATION TYPE --> } diff --git a/packages/webapp-libs/webapp-notifications/src/notificationsList/__tests__/notificationsList.component.spec.tsx b/packages/webapp-libs/webapp-notifications/src/notificationsList/__tests__/notificationsList.component.spec.tsx index 26b378cb7..28320e842 100644 --- a/packages/webapp-libs/webapp-notifications/src/notificationsList/__tests__/notificationsList.component.spec.tsx +++ b/packages/webapp-libs/webapp-notifications/src/notificationsList/__tests__/notificationsList.component.spec.tsx @@ -28,6 +28,9 @@ describe('NotificationsList: Component', () => { templates={{ [NotificationTypes.CRUD_ITEM_CREATED]: NotificationMock, [NotificationTypes.CRUD_ITEM_UPDATED]: NotificationMock, + [NotificationTypes.TENANT_INVITATION_CREATED]: NotificationMock, + [NotificationTypes.TENANT_INVITATION_ACCEPTED]: NotificationMock, + [NotificationTypes.TENANT_INVITATION_DECLINED]: NotificationMock, }} onLoadMore={() => null} loading={loading} diff --git a/packages/webapp-libs/webapp-notifications/src/tests/factories/notification.ts b/packages/webapp-libs/webapp-notifications/src/tests/factories/notification.ts index c9c9cb110..bbe8727fa 100644 --- a/packages/webapp-libs/webapp-notifications/src/tests/factories/notification.ts +++ b/packages/webapp-libs/webapp-notifications/src/tests/factories/notification.ts @@ -42,6 +42,6 @@ export const fillNotificationsListQuery = ( export const fillNotificationCreatedSubscriptionQuery = (notification: NotificationType) => { return composeMockedQueryResult(notificationCreatedSubscription, { - data: notification, + data: { notificationCreated: { notification } }, }); }; diff --git a/packages/webapp-libs/webapp-tenants/.eslintrc.json b/packages/webapp-libs/webapp-tenants/.eslintrc.json new file mode 100644 index 000000000..7f27b3c76 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/.eslintrc.json @@ -0,0 +1,38 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*", "node_modules/**/*"], + "plugins": ["react-hooks", "formatjs", "testing-library"], + "rules": { + "import/order": ["error"], + "formatjs/no-offset": "error", + "react/jsx-curly-brace-presence": "error" + }, + "overrides": [ + { + "files": "*.stories.tsx", + "rules": { + "import/no-anonymous-default-export": "off" + } + }, + { + "files": "*.{ts,tsx}", + "rules": { + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "import/no-anonymous-default-export": "off", + "@typescript-eslint/no-explicit-any": "off", + "no-empty": "off", + "react/jsx-no-useless-fragment": "off" + } + }, + { + "files": ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"], + "extends": ["plugin:testing-library/react"], + "rules": { + "testing-library/render-result-naming-convention": "off", + "testing-library/no-node-access": "off" + } + } + ] +} diff --git a/packages/webapp-libs/webapp-tenants/.prettierrc b/packages/webapp-libs/webapp-tenants/.prettierrc new file mode 100644 index 000000000..af20e9802 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/.prettierrc @@ -0,0 +1,14 @@ +{ + "plugins": ["@trivago/prettier-plugin-sort-imports"], + "printWidth": 120, + "singleQuote": true, + "trailingComma": "es5", + "importOrder": [ + "^(path|dns|fs)/?", + "", + "^(__generated__|__generated|@types|app|contexts|emails|fonts|images|mocks|modules|routes|shared|tests|theme|translations)/?", + "^[./]" + ], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true +} diff --git a/packages/webapp-libs/webapp-tenants/graphql/codegen.ts b/packages/webapp-libs/webapp-tenants/graphql/codegen.ts new file mode 100644 index 000000000..c82bb03d5 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/graphql/codegen.ts @@ -0,0 +1,13 @@ +import { CodegenConfig } from '@graphql-codegen/cli'; + +const config: Partial = { + generates: { + 'src/graphql/__generated/gql/': { + documents: ['../webapp-tenants/src/**/*.ts', '../webapp-tenants/src/**/*.tsx'], + + plugins: [], + }, + }, +}; + +export default config; diff --git a/packages/webapp-libs/webapp-tenants/jest.config.ts b/packages/webapp-libs/webapp-tenants/jest.config.ts new file mode 100644 index 000000000..1682419fe --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/jest.config.ts @@ -0,0 +1,23 @@ +/* eslint-disable */ +export default { + displayName: 'webapp-tenants', + preset: '../../../jest.preset.js', + transform: { + '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', + '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], + }, + transformIgnorePatterns: ['/node_modules/(?!(@iconify-icons|react-markdown)/)'], + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + }, + }, + moduleNameMapper: { + 'react-markdown': '/../../../node_modules/react-markdown/react-markdown.min.js', + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageReporters: ['lcov'], + coveragePathIgnorePatterns: ['/node_modules/', '.*.svg'], + setupFilesAfterEnv: ['./src/tests/setupTests.ts'], + watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], +}; diff --git a/packages/webapp-libs/webapp-tenants/package.json b/packages/webapp-libs/webapp-tenants/package.json new file mode 100644 index 000000000..13b985909 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/package.json @@ -0,0 +1,17 @@ +{ + "name": "@sb/webapp-tenants", + "version": "2.6.0", + "type": "commonjs", + "scripts": { + "test": "jest", + "test:watch": "jest --watch", + "lint": "eslint .", + "type-check": "tsc --noEmit --project tsconfig.lib.json" + }, + "dependencies": { + "@sb/webapp-api-client": "workspace:*", + "@sb/webapp-core": "workspace:*", + "@sb/webapp-notifications": "workspace:*", + "lucide-react": "^0.224.0" + } +} diff --git a/packages/webapp-libs/webapp-tenants/project.json b/packages/webapp-libs/webapp-tenants/project.json new file mode 100644 index 000000000..f43975838 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/project.json @@ -0,0 +1,8 @@ +{ + "name": "webapp-tenants", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/webapp-libs/webapp-tenants/src", + "projectType": "library", + "targets": {}, + "tags": [] +} diff --git a/packages/webapp-libs/webapp-tenants/sonar-project.properties b/packages/webapp-libs/webapp-tenants/sonar-project.properties new file mode 100644 index 000000000..62df06c1e --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/sonar-project.properties @@ -0,0 +1,14 @@ +sonar.organization=${env.SONAR_ORGANIZATION} +sonar.projectKey=${env.SONAR_WEBAPP_TENANTS_PROJECT_KEY} +sonar.javascript.lcov.reportPaths=coverage/lcov.info + +# Define the same root directory for sources and tests +sonar.sources = src/ +sonar.tests = src/ + +# Include test subdirectories in test scope +sonar.test.inclusions = src/**/*.spec.* + +# Exclude test and generated subdirectories from source scope +sonar.exclusions = src/**/*.spec.* +sonar.coverage.exclusions = src/**/*.stories.*,src/tests/setupTests.ts,src/tests/factories/**,src/tests/utils/**,src/utils/storybook.tsx, diff --git a/packages/webapp-libs/webapp-tenants/src/components/routes/tenantAuthRoute/__tests__/tenantAuthRoute.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/components/routes/tenantAuthRoute/__tests__/tenantAuthRoute.component.spec.tsx new file mode 100644 index 000000000..b54f6e026 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/routes/tenantAuthRoute/__tests__/tenantAuthRoute.component.spec.tsx @@ -0,0 +1,75 @@ +import { Role, TenantUserRole } from '@sb/webapp-api-client'; +import { TenantType } from '@sb/webapp-api-client/constants'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { RoutesConfig } from '@sb/webapp-core/config/routes'; +import { screen } from '@testing-library/react'; +import { Route, Routes } from 'react-router-dom'; + +import { tenantFactory } from '../../../../tests/factories/tenant'; +import { + PLACEHOLDER_CONTENT, + PLACEHOLDER_TEST_ID, + CurrentTenantRouteWrapper as TenantWrapper, + createMockRouterProps, + render, +} from '../../../../tests/utils/rendering'; +import { TenantAuthRoute, TenantAuthRouteProps } from '../tenantAuthRoute.component'; + +describe('TenantAuthRoute: Component', () => { + const defaultProps: TenantAuthRouteProps = {}; + + const Component = (props: Partial) => ( + + }> + + + + ); + const tenants = [ + tenantFactory({ + type: TenantType.PERSONAL, + membership: { role: TenantUserRole.OWNER }, + }), + tenantFactory({ + membership: { role: TenantUserRole.ADMIN }, + }), + ]; + const currentUser = currentUserFactory({ + roles: [Role.USER], + tenants, + }); + + describe('no allowedRoles prop is specified', () => { + it('should render content', async () => { + const apolloMocks = [fillCommonQueryWithUser(currentUser)]; + render(, { apolloMocks, TenantWrapper }); + expect(await screen.findByTestId(PLACEHOLDER_TEST_ID)).toBeInTheDocument(); + }); + }); + + describe('user has required role', () => { + it('should render content', async () => { + const apolloMocks = [fillCommonQueryWithUser(currentUser)]; + const routerProps = createMockRouterProps(RoutesConfig.home, { tenantId: tenants[1].id }); + render(, { + apolloMocks, + routerProps, + TenantWrapper, + }); + expect(await screen.findByTestId(PLACEHOLDER_TEST_ID)).toBeInTheDocument(); + }); + }); + + describe('user doesnt have required role', () => { + it('should redirect to not found page', async () => { + const apolloMocks = [fillCommonQueryWithUser(currentUser)]; + const routerProps = createMockRouterProps(RoutesConfig.home, { tenantId: tenants[1].id }); + render(, { + apolloMocks, + routerProps, + TenantWrapper, + }); + expect(screen.queryByTestId('content')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/components/routes/tenantAuthRoute/index.ts b/packages/webapp-libs/webapp-tenants/src/components/routes/tenantAuthRoute/index.ts new file mode 100644 index 000000000..48d7ad6ac --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/routes/tenantAuthRoute/index.ts @@ -0,0 +1 @@ +export { TenantAuthRoute } from './tenantAuthRoute.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/components/routes/tenantAuthRoute/tenantAuthRoute.component.tsx b/packages/webapp-libs/webapp-tenants/src/components/routes/tenantAuthRoute/tenantAuthRoute.component.tsx new file mode 100644 index 000000000..697d71da0 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/routes/tenantAuthRoute/tenantAuthRoute.component.tsx @@ -0,0 +1,37 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { RoutesConfig } from '@sb/webapp-core/config/routes'; +import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; +import { Navigate, Outlet } from 'react-router-dom'; + +import { useTenantRoleAccessCheck } from '../../../hooks'; + +export type TenantAuthRouteProps = { + allowedRoles?: TenantUserRole | TenantUserRole[]; +}; + +/** + * Renders route only for users that has specific tenant role + * + * @param allowedRoles + * @constructor + * + * @category Component + * + * @example + * Example route configuration using `AuthRoute` component: + * ```tsx showLineNumbers + * }> + * Page accessible only by tenant admins} /> + * + * ``` + */ +export const TenantAuthRoute = ({ + allowedRoles = [TenantUserRole.MEMBER, TenantUserRole.ADMIN, TenantUserRole.OWNER], +}: TenantAuthRouteProps) => { + const { isAllowed } = useTenantRoleAccessCheck(allowedRoles); + const generateLocalePath = useGenerateLocalePath(); + const fallbackUrl = generateLocalePath(RoutesConfig.notFound); + + if (!isAllowed) return ; + return ; +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/__tests__/tenantDangerZone.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/__tests__/tenantDangerZone.component.spec.tsx new file mode 100644 index 000000000..7d405e261 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/__tests__/tenantDangerZone.component.spec.tsx @@ -0,0 +1,126 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { commonQueryCurrentUserQuery } from '@sb/webapp-api-client/providers'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { composeMockedQueryResult } from '@sb/webapp-api-client/tests/utils'; +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { RoutesConfig } from '../../../config/routes'; +import { membershipFactory, tenantFactory } from '../../../tests/factories/tenant'; +import { createMockRouterProps, render } from '../../../tests/utils/rendering'; +import { TenantDangerZone } from '../tenantDangerZone.component'; +import { deleteTenantMutation } from '../tenantDangerZone.graphql'; + +describe('TenantDangerSettings: Component', () => { + const Component = () => ; + + it('should render title', async () => { + const user = currentUserFactory({ + tenants: [ + tenantFactory({ + name: 'name', + id: '1', + }), + ], + }); + const commonQueryMock = fillCommonQueryWithUser(user); + const routerProps = createMockRouterProps(RoutesConfig.tenant.settings.general, { tenantId: '1' }); + + render(, { apolloMocks: [commonQueryMock], routerProps }); + + expect(await screen.findByText('Danger Zone')).toBeInTheDocument(); + }); + + it('should render delete organization', async () => { + const user = currentUserFactory({ + tenants: [ + tenantFactory({ + name: 'name', + id: '1', + }), + ], + }); + const commonQueryMock = fillCommonQueryWithUser(user); + const routerProps = createMockRouterProps(RoutesConfig.tenant.settings.general, { tenantId: '1' }); + + render(, { apolloMocks: [commonQueryMock], routerProps }); + + expect(await screen.findByText('Delete this organization')).toBeInTheDocument(); + expect(screen.getByText('Remove organization')).toBeInTheDocument(); + }); + + it('should render delete organization subtitle about permissions', async () => { + const user = currentUserFactory({ + tenants: [ + tenantFactory({ + name: 'name', + id: '1', + membership: membershipFactory({ role: TenantUserRole.MEMBER }), + }), + ], + }); + const commonQueryMock = fillCommonQueryWithUser(user); + const routerProps = createMockRouterProps(RoutesConfig.tenant.settings.general, { tenantId: '1' }); + + render(, { apolloMocks: [commonQueryMock], routerProps }); + + const button = await screen.findByRole('button', { name: /remove organization/i }); + expect(button).toBeInTheDocument(); + expect(screen.getByText('Only members with the Owner role can delete organization')).toBeInTheDocument(); + }); + + it('should delete organization', async () => { + const user = currentUserFactory({ + tenants: [ + tenantFactory({ + name: 'name', + id: '1', + membership: membershipFactory({ role: TenantUserRole.OWNER }), + }), + ], + }); + const commonQueryMock = fillCommonQueryWithUser(user); + + const variables = { + input: { id: '1' }, + }; + const data = { + deleteTenant: { + deletedIds: ['1'], + // clientMutationId prevents errors in the console + clientMutationId: '123', + }, + }; + const requestMock = composeMockedQueryResult(deleteTenantMutation, { + variables, + data, + }); + const currentUserRefetchData = { + ...user, + tenants: [], + }; + const refetchMock = composeMockedQueryResult(commonQueryCurrentUserQuery, { + data: currentUserRefetchData, + }); + requestMock.newData = jest.fn(() => ({ + data, + })); + refetchMock.newData = jest.fn(() => ({ + data: { + currentUser: currentUserRefetchData, + }, + })); + const routerProps = createMockRouterProps(RoutesConfig.tenant.settings.general, { tenantId: '1' }); + render(, { apolloMocks: [commonQueryMock, requestMock, refetchMock], routerProps }); + + const button = await screen.findByRole('button', { name: /remove organization/i }); + await userEvent.click(button); + + const continueButton = await screen.findByRole('button', { name: /continue/i }); + await userEvent.click(continueButton); + + expect(requestMock.newData).toHaveBeenCalled(); + const toast = await screen.findByTestId('toast-1'); + expect(toast).toHaveTextContent('🎉 Organization deleted successfully!'); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/index.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/index.ts new file mode 100644 index 000000000..23f4c27c0 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/index.ts @@ -0,0 +1 @@ +export { TenantDangerZone } from './tenantDangerZone.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/tenantDangerZone.component.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/tenantDangerZone.component.tsx new file mode 100644 index 000000000..9e71b0a7c --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/tenantDangerZone.component.tsx @@ -0,0 +1,49 @@ +import dangerIcon from '@iconify-icons/ion/alert-circle-outline'; +import { TenantUserRole } from '@sb/webapp-api-client'; +import { Icon } from '@sb/webapp-core/components/icons'; +import { H3, Paragraph } from '@sb/webapp-core/components/typography'; +import { FormattedMessage } from 'react-intl'; + +import { useCurrentTenant } from '../../providers'; +import { TenantDeleteAlert } from '../tenantDeleteAlert'; +import { useTenantDelete } from './tenantDangerZone.hook'; + +export const TenantDangerZone = () => { + const { data: currentTenant } = useCurrentTenant(); + const isOwner = currentTenant?.membership.role === TenantUserRole.OWNER; + + const { deleteTenant, loading } = useTenantDelete(); + + return ( +
+
+ +

+ +

+
+ +
+
+ + + + + {isOwner ? undefined : ( + + )} + + + + +
+
+
+ ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/tenantDangerZone.graphql.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/tenantDangerZone.graphql.ts new file mode 100644 index 000000000..f887f72c1 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/tenantDangerZone.graphql.ts @@ -0,0 +1,11 @@ +import { gql } from '@sb/webapp-api-client/graphql'; + + +export const deleteTenantMutation = gql(/* GraphQL */ ` + mutation deleteTenantMutation($input: DeleteTenantMutationInput!) { + deleteTenant(input: $input) { + deletedIds + clientMutationId + } + } +`); \ No newline at end of file diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/tenantDangerZone.hook.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/tenantDangerZone.hook.ts new file mode 100644 index 000000000..b4b585544 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/tenantDangerZone.hook.ts @@ -0,0 +1,58 @@ +import { useMutation } from '@apollo/client'; +import { useCommonQuery } from '@sb/webapp-api-client/providers'; +import { RoutesConfig } from '@sb/webapp-core/config/routes'; +import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; +import { trackEvent } from '@sb/webapp-core/services/analytics'; +import { useToast } from '@sb/webapp-core/toast'; +import { useIntl } from 'react-intl'; +import { useNavigate } from 'react-router'; + +import { useCurrentTenant } from '../../providers'; +import { deleteTenantMutation } from './tenantDangerZone.graphql'; + +export const useTenantDelete = () => { + const { data: currentTenant } = useCurrentTenant(); + const { reload: reloadCommonQuery } = useCommonQuery(); + const navigate = useNavigate(); + const { toast } = useToast(); + const intl = useIntl(); + + const generateLocalePath = useGenerateLocalePath(); + + const successDeleteMessage = intl.formatMessage({ + id: 'Tenant form / DeleteTenant / Success message', + defaultMessage: '🎉 Organization deleted successfully!', + }); + + const failDeleteMessage = intl.formatMessage({ + id: 'Membership Entry / DeleteTenant / Fail message', + defaultMessage: 'Unable to delete the organization.', + }); + + const [commitRemoveMutation, { loading }] = useMutation(deleteTenantMutation, { + onCompleted: (data) => { + const id = data.deleteTenant?.deletedIds?.[0]?.toString(); + reloadCommonQuery(); + trackEvent('tenant', 'delete', id); + toast({ description: successDeleteMessage }); + navigate(generateLocalePath(RoutesConfig.home), { replace: true }); + }, + onError: () => { + toast({ description: failDeleteMessage, variant: 'destructive' }); + }, + }); + + const deleteTenant = () => { + if (!currentTenant) return; + + commitRemoveMutation({ + variables: { + input: { + id: currentTenant.id, + }, + }, + }); + }; + + return { deleteTenant, loading }; +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/tenantDangerZone.stories.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/tenantDangerZone.stories.tsx new file mode 100644 index 000000000..d1984dd1d --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantDangerZone/tenantDangerZone.stories.tsx @@ -0,0 +1,44 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { membershipFactory, tenantFactory } from '../../tests/factories/tenant'; +import { withProviders } from '../../utils/storybook'; +import { TenantDangerZone } from './tenantDangerZone.component'; + +const Template: StoryFn = () => { + return ; +}; + +const meta: Meta = { + title: 'Tenants / TenantDangerZone', + component: Template, +}; + +export default meta; + +export const Default: StoryObj = { + render: Template, + decorators: [withProviders({})], +}; + +const ownerUserResponse = fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ + name: 'name', + id: '1', + membership: membershipFactory({ role: TenantUserRole.OWNER }), + }), + ], + }) +); + +export const OwnerView: StoryObj = { + render: Template, + decorators: [ + withProviders({ + apolloMocks: [ownerUserResponse], + }), + ], +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantDeleteAlert/__tests__/tenantDeleteAlert.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantDeleteAlert/__tests__/tenantDeleteAlert.component.spec.tsx new file mode 100644 index 000000000..decfb4945 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantDeleteAlert/__tests__/tenantDeleteAlert.component.spec.tsx @@ -0,0 +1,50 @@ +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { render } from '../../../tests/utils/rendering'; +import { TenantDeleteAlert, TenantDeleteAlertProps } from '../tenantDeleteAlert.component'; + +const defaultProps: TenantDeleteAlertProps = { + disabled: false, + onContinue: jest.fn(), +}; + +describe('TenantDeleteAlert: Component', () => { + const Component = (args: Partial) => ; + + it('should render alert when button is clicked', async () => { + render(); + + const button = await screen.findByRole('button', { name: /remove organization/i }); + await userEvent.click(button); + + expect(await screen.findByText('Are you absolutely sure?')).toBeInTheDocument(); + }); + + it('should call onContinue', async () => { + const onContinueMock = jest.fn(); + render(); + + const button = await screen.findByRole('button', { name: /remove organization/i }); + await userEvent.click(button); + + const continueButton = await screen.findByRole('button', { name: /continue/i }); + await userEvent.click(continueButton); + + expect(onContinueMock).toHaveBeenCalled(); + expect(screen.queryByText('Are you absolutely sure?')).not.toBeInTheDocument(); + }); + + it('should hide Alert on Cancel click', async () => { + const onContinueMock = jest.fn(); + render(); + + const button = await screen.findByRole('button', { name: /remove organization/i }); + await userEvent.click(button); + + const continueButton = await screen.findByRole('button', { name: /cancel/i }); + await userEvent.click(continueButton); + + expect(screen.queryByText('Are you absolutely sure?')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantDeleteAlert/index.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantDeleteAlert/index.ts new file mode 100644 index 000000000..55cf60e63 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantDeleteAlert/index.ts @@ -0,0 +1 @@ +export { TenantDeleteAlert } from './tenantDeleteAlert.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantDeleteAlert/tenantDeleteAlert.component.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantDeleteAlert/tenantDeleteAlert.component.tsx new file mode 100644 index 000000000..80c5b9f28 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantDeleteAlert/tenantDeleteAlert.component.tsx @@ -0,0 +1,61 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@sb/webapp-core/components/alertDialog'; +import { buttonVariants } from '@sb/webapp-core/components/buttons/button/button.styles'; +import { FormattedMessage } from 'react-intl'; + +export type TenantDeleteAlertProps = { + onContinue: () => void; + disabled: boolean; +}; + +export const TenantDeleteAlert = ({ onContinue, disabled }: TenantDeleteAlertProps) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantDeleteAlert/tenantDeleteAlert.stories.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantDeleteAlert/tenantDeleteAlert.stories.tsx new file mode 100644 index 000000000..62b2963ec --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantDeleteAlert/tenantDeleteAlert.stories.tsx @@ -0,0 +1,33 @@ +import { action } from '@storybook/addon-actions'; +import { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { withProviders } from '../../utils/storybook'; +import { TenantDeleteAlert, TenantDeleteAlertProps } from './tenantDeleteAlert.component'; + +const defaultProps: TenantDeleteAlertProps = { + disabled: false, + onContinue: action('TenantDelete mutation'), +}; + +const Template: StoryFn = (args: TenantDeleteAlertProps) => { + return ; +}; + +const meta: Meta = { + title: 'Tenants / TenantDeleteAlert', + component: Template, +}; + +export default meta; + +export const Enabled: StoryObj = { + decorators: [withProviders({})], +}; + +export const Disabled: StoryObj = { + args: { + ...defaultProps, + disabled: true, + }, + decorators: [withProviders({})], +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantForm/__tests__/tenantForm.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantForm/__tests__/tenantForm.component.spec.tsx new file mode 100644 index 000000000..81fc2d792 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantForm/__tests__/tenantForm.component.spec.tsx @@ -0,0 +1,45 @@ +import { ApolloError } from '@apollo/client'; +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { GraphQLError } from 'graphql/error/GraphQLError'; + +import { render } from '../../../tests/utils/rendering'; +import { TenantForm, TenantFormProps } from '../tenantForm.component'; + +describe('TenantForm: Component', () => { + const defaultProps: TenantFormProps = { + initialData: { + name: 'initial name', + }, + onSubmit: jest.fn(), + loading: false, + }; + + const Component = (props: Partial) => ; + + it('should display empty string', async () => { + render(); + const value = (await screen.findByPlaceholderText(/name/i)).getAttribute('value'); + expect(value).toBe(''); + }); + + describe('action completes successfully', () => { + it('should call onSubmit prop', async () => { + const onSubmit = jest.fn(); + render(); + + const nameField = await screen.findByPlaceholderText(/name/i); + await userEvent.clear(nameField); + await userEvent.type(nameField, 'new tenant name'); + await userEvent.click(screen.getByRole('button', { name: /save/i })); + + expect(onSubmit).toHaveBeenCalledWith({ name: 'new tenant name' }); + }); + }); + + it('should show non field error if error', async () => { + render(); + + expect(await screen.findByText('Provided value is invalid')).toBeInTheDocument(); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantForm/index.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantForm/index.ts new file mode 100644 index 000000000..2e988e4b1 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantForm/index.ts @@ -0,0 +1 @@ +export { TenantForm } from './tenantForm.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantForm/tenantForm.component.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantForm/tenantForm.component.tsx new file mode 100644 index 000000000..b4f179431 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantForm/tenantForm.component.tsx @@ -0,0 +1,95 @@ +import { ApolloError } from '@apollo/client'; +import { Button, ButtonVariant, Link } from '@sb/webapp-core/components/buttons'; +import { Form, FormControl, FormField, FormItem, Input } from '@sb/webapp-core/components/forms'; +import { RoutesConfig } from '@sb/webapp-core/config/routes'; +import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { useTenantForm } from './tenantForm.hook'; + +const MAX_NAME_LENGTH = 255; + +export type TenantFormFields = { + name: string; +}; + +export type TenantFormProps = { + initialData?: TenantFormFields | null; + onSubmit: (formData: TenantFormFields) => void; + loading: boolean; + error?: ApolloError; +}; + +export const TenantForm = ({ initialData, onSubmit, error, loading }: TenantFormProps) => { + const intl = useIntl(); + const generateLocalePath = useGenerateLocalePath(); + + const { + form: { + register, + formState: { errors }, + control, + }, + form, + genericError, + hasGenericErrorOnly, + handleFormSubmit, + } = useTenantForm({ initialData, onSubmit, error }); + + return ( +
+ + ( + + + + + + )} + /> + + {hasGenericErrorOnly && {genericError}} + +
+ + + + + +
+ + + ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantForm/tenantForm.hook.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantForm/tenantForm.hook.ts new file mode 100644 index 000000000..1ca8858e2 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantForm/tenantForm.hook.ts @@ -0,0 +1,24 @@ +import { useApiForm } from '@sb/webapp-api-client/hooks'; +import { useEffect } from 'react'; + +import { TenantFormFields, TenantFormProps } from './tenantForm.component'; + +type UseTenantFormProps = Omit; + +export const useTenantForm = ({ error, onSubmit, initialData }: UseTenantFormProps) => { + const form = useApiForm({ + defaultValues: { + name: initialData?.name, + }, + }); + + const { handleSubmit, setApolloGraphQLResponseErrors } = form; + + useEffect(() => { + if (error) setApolloGraphQLResponseErrors(error.graphQLErrors); + }, [error, setApolloGraphQLResponseErrors]); + + const handleFormSubmit = handleSubmit((formData: TenantFormFields) => onSubmit(formData)); + + return { ...form, handleFormSubmit }; +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantForm/tenantForm.stories.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantForm/tenantForm.stories.tsx new file mode 100644 index 000000000..b04371d51 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantForm/tenantForm.stories.tsx @@ -0,0 +1,29 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { withProviders } from '../../utils/storybook'; +import { TenantForm, TenantFormProps } from './tenantForm.component'; + +const Template: StoryFn = (args: TenantFormProps) => { + return ; +}; + +const meta: Meta = { + title: 'Tenants / TenantForm', + component: Template, +}; + +export default meta; + +export const WithInitialData: StoryObj = { + args: { + initialData: { + name: 'initial name', + }, + }, + + decorators: [withProviders({})], +}; + +export const WithoutData: StoryObj = { + decorators: [withProviders({})], +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/__tests__/tenantInvitationForm.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/__tests__/tenantInvitationForm.component.spec.tsx new file mode 100644 index 000000000..79d2cf721 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/__tests__/tenantInvitationForm.component.spec.tsx @@ -0,0 +1,53 @@ +import { ApolloError } from '@apollo/client'; +import { TenantUserRole } from '@sb/webapp-api-client'; +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { GraphQLError } from 'graphql/error/GraphQLError'; + +import { render } from '../../../tests/utils/rendering'; +import { TenantInvitationForm, TenantInvitationFormProps } from '../tenantInvitationForm.component'; + +describe('TenantInvitationForm: Component', () => { + const defaultProps: TenantInvitationFormProps = { + initialData: { + email: 'admin@example.com', + role: TenantUserRole.MEMBER, + }, + onSubmit: jest.fn(), + loading: false, + }; + + const Component = (props: Partial) => ( + + ); + + it('should display initial email', async () => { + render(); + const value = (await screen.findByLabelText(/email/i)).getAttribute('value'); + expect(value).toBe(defaultProps.initialData?.email); + }); + + describe('action completes successfully', () => { + it('should call onSubmit prop', async () => { + const onSubmit = jest.fn(); + render(); + + const emailField = await screen.findByLabelText(/email/i); + await userEvent.clear(emailField); + const emailValue = 'example@example.com'; + await userEvent.type(emailField, emailValue); + + await userEvent.click(screen.getByDisplayValue(/Member/i)); + await userEvent.click(screen.getByRole('button', { name: /invite/i })); + + expect(onSubmit).toHaveBeenCalledWith({ email: emailValue, role: TenantUserRole.MEMBER }); + }); + }); + + it('should show non field error if error', async () => { + const errorText = 'Provided value is invalid'; + render(); + + expect(await screen.findByText(errorText)).toBeInTheDocument(); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/index.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/index.ts new file mode 100644 index 000000000..44be6de69 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/index.ts @@ -0,0 +1 @@ +export { TenantInvitationForm, type TenantInvitationFormFields } from './tenantInvitationForm.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/tenantInvitationForm.component.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/tenantInvitationForm.component.tsx new file mode 100644 index 000000000..9bdd761fd --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/tenantInvitationForm.component.tsx @@ -0,0 +1,152 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { Button } from '@sb/webapp-core/components/buttons'; +import { Card, CardContent, CardHeader, CardTitle } from '@sb/webapp-core/components/cards'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@sb/webapp-core/components/forms'; +import { cn } from '@sb/webapp-core/lib/utils'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { useTenantRoles } from '../../hooks'; +import { UseTenantInvitationFormHookProps, useTenantInvitationForm } from './tenantInvitationForm.hook'; + +export type TenantInvitationFormFields = { + email: string; + role: TenantUserRole; +}; + +export type TenantInvitationFormProps = UseTenantInvitationFormHookProps & { + loading: boolean; +}; + +export const TenantInvitationForm = ({ initialData, onSubmit, error, loading }: TenantInvitationFormProps) => { + const intl = useIntl(); + const { getRoleTranslation } = useTenantRoles(); + + const rolePlaceholder = intl.formatMessage({ + defaultMessage: 'Select role', + id: 'Tenant invitation form / Role placeholder', + }); + + const { + form: { + register, + formState: { errors }, + }, + form, + genericError, + hasGenericErrorOnly, + handleFormSubmit, + } = useTenantInvitationForm({ initialData, onSubmit, error }); + + return ( + + + + + + + +
+ {hasGenericErrorOnly && {genericError}} + +
+ ( + + + + + + )} + /> + + ( + +
+ +

+ +

+ +
+ +
+
+ )} + rules={{ + required: { + value: true, + message: intl.formatMessage({ + defaultMessage: 'Role is required', + id: 'Tenant invitation form / Role required', + }), + }, + }} + /> +
+ +
+ +
+
+ +
+
+ ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/tenantInvitationForm.hook.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/tenantInvitationForm.hook.ts new file mode 100644 index 000000000..1cb0ca8b3 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/tenantInvitationForm.hook.ts @@ -0,0 +1,30 @@ +import { ApolloError } from '@apollo/client'; +import { useApiForm } from '@sb/webapp-api-client/hooks'; +import { useEffect } from 'react'; + +import { TenantInvitationFormFields } from './tenantInvitationForm.component'; + +export type UseTenantInvitationFormHookProps = { + initialData?: TenantInvitationFormFields | null; + onSubmit: (formData: TenantInvitationFormFields) => void; + error?: ApolloError; +}; + +export const useTenantInvitationForm = ({ error, onSubmit, initialData }: UseTenantInvitationFormHookProps) => { + const form = useApiForm({ + defaultValues: { + email: initialData?.email, + role: initialData?.role, + }, + }); + + const { handleSubmit, setApolloGraphQLResponseErrors } = form; + + useEffect(() => { + if (error) setApolloGraphQLResponseErrors(error.graphQLErrors); + }, [error, setApolloGraphQLResponseErrors]); + + const handleFormSubmit = handleSubmit((formData: TenantInvitationFormFields) => onSubmit(formData)); + + return { ...form, handleFormSubmit }; +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/tenantInvitationForm.stories.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/tenantInvitationForm.stories.tsx new file mode 100644 index 000000000..183f323b7 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantInvitationForm/tenantInvitationForm.stories.tsx @@ -0,0 +1,31 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { withProviders } from '../../utils/storybook'; +import { TenantInvitationForm, TenantInvitationFormProps } from './tenantInvitationForm.component'; + +const Template: StoryFn = (args: TenantInvitationFormProps) => { + return ; +}; + +const meta: Meta = { + title: 'Tenants / TenantInvitationForm', + component: Template, +}; + +export default meta; + +export const WithInitialData: StoryObj = { + args: { + initialData: { + email: 'example@email.com', + role: TenantUserRole.MEMBER, + }, + }, + + decorators: [withProviders({})], +}; + +export const WithoutData: StoryObj = { + decorators: [withProviders({})], +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/__tests__/__snapshots__/tenantMembersList.component.spec.tsx.snap b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/__tests__/__snapshots__/tenantMembersList.component.spec.tsx.snap new file mode 100644 index 000000000..acafd58a4 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/__tests__/__snapshots__/tenantMembersList.component.spec.tsx.snap @@ -0,0 +1,371 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TenantMembersList: Component should render list of memberships 1`] = ` +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Username + + Role + + Invitation accepted + + Actions +
+ testFirstName testLastName + + Owner + +
+ + + + + + Yes +
+
+ +
+ Firstname 1 Firstname 1 + + Admin + +
+ + + + + + Yes +
+
+ +
+ example@example.com + + Member + +
+ + + + + + + No +
+
+ +
+
+
+
    +
+
+`; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/__tests__/tenantMembersList.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/__tests__/tenantMembersList.component.spec.tsx new file mode 100644 index 000000000..5e9400d08 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/__tests__/tenantMembersList.component.spec.tsx @@ -0,0 +1,72 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { composeMockedQueryResult, makeId } from '@sb/webapp-api-client/tests/utils'; +import { RoutesConfig } from '@sb/webapp-core/config/routes'; + +import { membershipFactory, tenantFactory } from '../../../tests/factories/tenant'; +import { createMockRouterProps, render } from '../../../tests/utils/rendering'; +import { TenantMembersList } from '../tenantMembersList.component'; +import { tenantMembersListQuery } from '@sb/webapp-tenants/components/tenantMembersList/tenantMembersList.graphql'; + +describe('TenantMembersList: Component', () => { + const Component = () => ; + + it('should render list of memberships', async () => { + const currentUserMembership = membershipFactory({ role: TenantUserRole.OWNER }); + const tenant = tenantFactory({ membership: currentUserMembership }); + const user = currentUserFactory({ tenants: [tenant] }); + const commonQueryMock = fillCommonQueryWithUser(user); + const routerProps = createMockRouterProps(RoutesConfig.home, { tenantId: tenant.id }); + + const listQueryVariables = { + id: tenant.id, + }; + + const listQueryData = { + tenant: { + userMemberships: [ + membershipFactory({ + role: TenantUserRole.OWNER, + inviteeEmailAddress: null, + userId: user.id, + firstName: user.firstName, + lastName: user.lastName, + userEmail: user.email, + avatar: null, + }), + membershipFactory({ + role: TenantUserRole.ADMIN, + inviteeEmailAddress: null, + userId: makeId(32), + firstName: 'Firstname 1', + lastName: 'Firstname 1', + userEmail: null, + avatar: null, + }), + membershipFactory({ + role: TenantUserRole.MEMBER, + invitationAccepted: false, + inviteeEmailAddress: 'example@example.com', + userId: makeId(32), + firstName: null, + lastName: null, + userEmail: null, + avatar: null, + }), + ], + }, + }; + + const requestListMock = composeMockedQueryResult(tenantMembersListQuery, { + variables: listQueryVariables, + data: listQueryData, + }); + + const { container, waitForApolloMocks } = render(, { + apolloMocks: [commonQueryMock, requestListMock], + routerProps, + }); + await waitForApolloMocks(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/index.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/index.ts new file mode 100644 index 000000000..c1d8d6d79 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/index.ts @@ -0,0 +1 @@ +export { TenantMembersList } from './tenantMembersList.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/membershipEntry/__tests__/membershipEntry.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/membershipEntry/__tests__/membershipEntry.component.spec.tsx new file mode 100644 index 000000000..4f7058227 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/membershipEntry/__tests__/membershipEntry.component.spec.tsx @@ -0,0 +1,95 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { composeMockedQueryResult } from '@sb/webapp-api-client/tests/utils'; +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { DocumentNode } from 'graphql'; + +import { membershipFactory, tenantFactory } from '../../../../tests/factories/tenant'; +import { render } from '../../../../tests/utils/rendering'; +import { MembershipEntry, MembershipEntryProps } from '../membershipEntry.component'; +import { deleteTenantMembershipMutation, updateTenantMembershipMutation } from '../membershipEntry.graphql'; + +const prepareMocks = (query: T, input: Record = {}) => { + const mockedMembershipId = '1'; + const mockedTenantId = '2'; + const membership = membershipFactory({ + role: TenantUserRole.ADMIN, + id: mockedMembershipId, + }); + const user = currentUserFactory({ + roles: [TenantUserRole.ADMIN], + tenants: [ + tenantFactory({ + name: 'name', + id: mockedTenantId, + }), + ], + }); + const commonQueryMock = fillCommonQueryWithUser(user); + const data = { + tenantMembership: { + id: mockedMembershipId, + }, + }; + const variables = { + input: { + id: mockedMembershipId, + tenantId: mockedTenantId, + ...input, + }, + }; + const requestMock = composeMockedQueryResult(query, { + variables, + data, + }); + + requestMock.newData = jest.fn(() => ({ + data, + })); + + const refetch = jest.fn(); + + return { + membership, + commonQueryMock, + requestMock, + refetch, + }; +}; + +describe('MembershipEntry: Component', () => { + const Component = (props: MembershipEntryProps) => ; + it('should commit update mutation', async () => { + const { membership, commonQueryMock, requestMock, refetch } = prepareMocks(updateTenantMembershipMutation, { + role: TenantUserRole.MEMBER, + }); + + render(, { + apolloMocks: [commonQueryMock, requestMock], + }); + + await userEvent.click(await screen.findByRole('button')); + await userEvent.click(screen.getByRole('button', { name: /Change role/i })); + await userEvent.click(screen.getByRole('button', { name: /Member/i })); + expect(requestMock.newData).toHaveBeenCalled(); + expect(refetch).toHaveBeenCalled(); + const toast = await screen.findByTestId('toast-1'); + expect(toast).toHaveTextContent('🎉 The user role was updated successfully!'); + }); + + it('should commit delete mutation', async () => { + const { membership, commonQueryMock, requestMock, refetch } = prepareMocks(deleteTenantMembershipMutation); + + render(, { + apolloMocks: [commonQueryMock, requestMock], + }); + + await userEvent.click(await screen.findByRole('button')); + await userEvent.click(screen.getByRole('button', { name: /Delete/i })); + expect(requestMock.newData).toHaveBeenCalled(); + expect(refetch).toHaveBeenCalled(); + const toast = await screen.findByTestId('toast-1'); + expect(toast).toHaveTextContent('🎉 User was deleted successfully!'); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/membershipEntry/index.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/membershipEntry/index.ts new file mode 100644 index 000000000..c93ea85ae --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/membershipEntry/index.ts @@ -0,0 +1 @@ +export * from './membershipEntry.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/membershipEntry/membershipEntry.component.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/membershipEntry/membershipEntry.component.tsx new file mode 100644 index 000000000..9c4b8a2f1 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/membershipEntry/membershipEntry.component.tsx @@ -0,0 +1,169 @@ +import { useMutation } from '@apollo/client'; +import { TenantMembershipType, TenantUserRole } from '@sb/webapp-api-client'; +import { Button } from '@sb/webapp-core/components/buttons'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@sb/webapp-core/components/dropdownMenu'; +import { Skeleton as SkeletonComponent } from '@sb/webapp-core/components/skeleton'; +import { TableCell, TableRow } from '@sb/webapp-core/components/table'; +import { useToast } from '@sb/webapp-core/toast'; +import { GripHorizontal, Hourglass, UserCheck } from 'lucide-react'; +import { indexBy, prop, trim } from 'ramda'; +import { useMemo } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { useTenantRoles } from '../../../hooks/useTenantRoles'; +import { useCurrentTenant } from '../../../providers'; +import { deleteTenantMembershipMutation, updateTenantMembershipMutation } from './membershipEntry.graphql'; + +export type MembershipEntryProps = { + className?: string; + membership: TenantMembershipType; + onAfterUpdate?: () => void; +}; + +export const MembershipEntry = ({ membership, className, onAfterUpdate }: MembershipEntryProps) => { + const { data: currentTenant } = useCurrentTenant(); + const { toast } = useToast(); + const { getRoleTranslation } = useTenantRoles(); + const intl = useIntl(); + + const updateSuccessMessage = intl.formatMessage({ + id: 'Membership Entry / UpdateRole / Success message', + defaultMessage: '🎉 The user role was updated successfully!', + }); + + const updateFailMessage = intl.formatMessage({ + id: 'Membership Entry / UpdateRole / Fail message', + defaultMessage: 'Unable to change the user role.', + }); + + const deleteSuccessMessage = intl.formatMessage({ + id: 'Membership Entry / DeleteMembership / Success message', + defaultMessage: '🎉 User was deleted successfully!', + }); + + const deleteFailMessage = intl.formatMessage({ + id: 'Membership Entry / DeleteMembership / Fail message', + defaultMessage: 'Unable to delete the user.', + }); + + const [commitUpdateMutation, { loading }] = useMutation(updateTenantMembershipMutation, { + onCompleted: () => { + onAfterUpdate?.(); + toast({ description: updateSuccessMessage }); + }, + onError: () => { + toast({ description: updateFailMessage, variant: 'destructive' }); + }, + }); + + const [commitDeleteMutation] = useMutation(deleteTenantMembershipMutation, { + onCompleted: () => { + onAfterUpdate?.(); + toast({ description: deleteSuccessMessage }); + }, + onError: () => { + toast({ description: deleteFailMessage, variant: 'destructive' }); + }, + }); + + const roles = [TenantUserRole.OWNER, TenantUserRole.ADMIN, TenantUserRole.MEMBER]; + const roleChangeCallbacks = useMemo(() => { + if (!currentTenant) return; + + const mapper = roles.map((role) => ({ + role, + callback: () => + commitUpdateMutation({ + variables: { + input: { id: membership.id, tenantId: currentTenant.id, role }, + }, + }), + })); + + return indexBy(prop('role'), mapper); + }, [roles, membership.id]); + + const deleteMembership = () => { + if (!currentTenant) return; + + commitDeleteMutation({ + variables: { + input: { + id: membership.id, + tenantId: currentTenant.id, + }, + }, + }); + }; + + const name = trim([membership.firstName, membership.lastName].map((s) => trim(s ?? '')).join(' ')); + return ( + + {name || membership.userEmail || membership.inviteeEmailAddress} + + + {loading ? ( + + ) : ( + getRoleTranslation(membership.role?.toUpperCase() as TenantUserRole) + )} + + + {membership.invitationAccepted ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+ + + + + + + + + + + + + + + + {roles + .filter((role) => role !== (membership.role?.toUpperCase() as TenantUserRole)) + .map((role) => ( + + {getRoleTranslation(role)} + + ))} + + + + + + + + + +
+ ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/membershipEntry/membershipEntry.graphql.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/membershipEntry/membershipEntry.graphql.ts new file mode 100644 index 000000000..da0c6b976 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/membershipEntry/membershipEntry.graphql.ts @@ -0,0 +1,20 @@ +import { gql } from '@sb/webapp-api-client/graphql'; + +export const updateTenantMembershipMutation = gql(/* GraphQL */ ` + mutation updateTenantMembershipMutation($input: UpdateTenantMembershipMutationInput!) { + updateTenantMembership(input: $input) { + tenantMembership { + id + } + } + } +`); + +export const deleteTenantMembershipMutation = gql(/* GraphQL */ ` + mutation deleteTenantMembershipMutation($input: DeleteTenantMembershipMutationInput!) { + deleteTenantMembership(input: $input) { + deletedIds + clientMutationId + } + } +`); diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/tenantMembersList.component.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/tenantMembersList.component.tsx new file mode 100644 index 000000000..1cfeca3c6 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/tenantMembersList.component.tsx @@ -0,0 +1,46 @@ +import { useQuery } from '@apollo/client'; +import { Table, TableBody, TableHead, TableHeader, TableRow } from '@sb/webapp-core/components/table'; +import { FormattedMessage } from 'react-intl'; + +import { useCurrentTenant } from '../../providers'; +import { MembershipEntry } from './membershipEntry'; +import { tenantMembersListQuery } from './tenantMembersList.graphql'; + +export const TenantMembersList = () => { + const { data: currentTenant } = useCurrentTenant(); + const { data, refetch } = useQuery(tenantMembersListQuery, { + fetchPolicy: 'cache-and-network', + variables: { + id: currentTenant?.id ?? '', + }, + skip: !currentTenant, + }); + + const memberships = data?.tenant?.userMemberships?.filter((membership) => !!membership) ?? []; + return ( + + + + + + + + + + + + + + + + + + + {memberships.map( + (membership) => + membership && + )} + +
+ ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/tenantMembersList.graphql.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/tenantMembersList.graphql.ts new file mode 100644 index 000000000..d68517e1d --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantMembersList/tenantMembersList.graphql.ts @@ -0,0 +1,19 @@ +import { gql } from '@sb/webapp-api-client/graphql'; + +export const tenantMembersListQuery = gql(/* GraphQL */ ` + query tenantMembersListQuery($id: ID!) { + tenant(id: $id) { + userMemberships { + id + role + invitationAccepted + inviteeEmailAddress + userId + firstName + lastName + userEmail + avatar + } + } + } +`); diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantRoleAccess/__tests__/tenantRoleAccess.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantRoleAccess/__tests__/tenantRoleAccess.component.spec.tsx new file mode 100644 index 000000000..d009ef85f --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantRoleAccess/__tests__/tenantRoleAccess.component.spec.tsx @@ -0,0 +1,37 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { screen } from '@testing-library/react'; + +import { membershipFactory, tenantFactory } from '../../../tests/factories/tenant'; +import { PLACEHOLDER_CONTENT, PLACEHOLDER_TEST_ID, render } from '../../../tests/utils/rendering'; +import { RoleAccessProps, TenantRoleAccess } from '../tenantRoleAccess.component'; + +describe('TenantRoleAccess: Component', () => { + const defaultProps: RoleAccessProps = { + allowedRoles: [TenantUserRole.ADMIN], + children: PLACEHOLDER_CONTENT, + }; + + const Component = (props: Partial) => ; + + const createUser = (role: TenantUserRole) => { + const currentUserMembership = membershipFactory({ role }); + const tenant = tenantFactory({ membership: currentUserMembership }); + return currentUserFactory({ tenants: [tenant] }); + }; + + it('should render children if user has allowed role', async () => { + const user = createUser(TenantUserRole.ADMIN); + const apolloMocks = [fillCommonQueryWithUser(user)]; + render(, { apolloMocks }); + expect(await screen.findByTestId(PLACEHOLDER_TEST_ID)).toBeInTheDocument(); + }); + + it('should render nothing if user doesnt have allowed role', async () => { + const user = createUser(TenantUserRole.MEMBER); + const apolloMocks = [fillCommonQueryWithUser(user)]; + const { waitForApolloMocks } = render(, { apolloMocks }); + await waitForApolloMocks(); + expect(screen.queryByTestId(PLACEHOLDER_TEST_ID)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantRoleAccess/index.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantRoleAccess/index.ts new file mode 100644 index 000000000..262288559 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantRoleAccess/index.ts @@ -0,0 +1 @@ +export { TenantRoleAccess } from './tenantRoleAccess.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantRoleAccess/tenantRoleAccess.component.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantRoleAccess/tenantRoleAccess.component.tsx new file mode 100644 index 000000000..b360f87af --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantRoleAccess/tenantRoleAccess.component.tsx @@ -0,0 +1,14 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { ReactNode } from 'react'; + +import { useTenantRoleAccessCheck } from '../../hooks'; + +export type RoleAccessProps = { + children: ReactNode; + allowedRoles?: TenantUserRole | TenantUserRole[]; +}; + +export const TenantRoleAccess = ({ children, allowedRoles }: RoleAccessProps) => { + const { isAllowed } = useTenantRoleAccessCheck(allowedRoles); + return <>{isAllowed ? children : null}; +}; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantSwitch/__tests__/tenantSwitch.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantSwitch/__tests__/tenantSwitch.component.spec.tsx new file mode 100644 index 000000000..3327be622 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantSwitch/__tests__/tenantSwitch.component.spec.tsx @@ -0,0 +1,140 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { TenantType } from '@sb/webapp-api-client/constants'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { RoutesConfig } from '@sb/webapp-core/config/routes'; +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { tenantFactory } from '../../../tests/factories/tenant'; +import { + CurrentTenantRouteWrapper as TenantWrapper, + createMockRouterProps, + render, +} from '../../../tests/utils/rendering'; +import { TenantSwitch } from '../tenantSwitch.component'; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => { + return { + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, + }; +}); + +const personalTenantName = 'Test Tenant'; +const organizationTenantName = 'Organization Tenant'; +const organizationPendingTenantName = 'Organization Pending Tenant'; + +const personalTenant = tenantFactory({ + name: personalTenantName, + type: TenantType.PERSONAL, + membership: { role: TenantUserRole.OWNER }, +}); +const organizationTenant = tenantFactory({ + name: organizationTenantName, + type: TenantType.ORGANIZATION, + membership: { role: TenantUserRole.MEMBER, invitationAccepted: true }, +}); +const organizationPendingTenant = tenantFactory({ + name: organizationPendingTenantName, + type: TenantType.ORGANIZATION, + membership: { role: TenantUserRole.MEMBER, invitationAccepted: false }, +}); + +const tenants = [personalTenant, organizationTenant, organizationPendingTenant]; +const getApolloMocks = () => [fillCommonQueryWithUser(currentUserFactory({ tenants }))]; + +describe('TenantSwitch: Component', () => { + const Component = () => ; + + it('should render current tenant name', async () => { + render(, { apolloMocks: getApolloMocks() }); + + expect(await screen.findByText(personalTenantName)).toBeInTheDocument(); + expect(screen.getByTestId('tenant-settings-btn')).toBeInTheDocument(); + }); + + it('should render correct tenant name when param in url', async () => { + const routerProps = createMockRouterProps(RoutesConfig.home, { tenantId: organizationTenant.id }); + render(, { apolloMocks: getApolloMocks(), routerProps, TenantWrapper }); + + expect(await screen.findByText(organizationTenantName)).toBeInTheDocument(); + expect(screen.queryByTestId('tenant-settings-btn')).not.toBeInTheDocument(); + }); + + it('should not render organization and invitation labels if only personal tenant', async () => { + render(, { + apolloMocks: [fillCommonQueryWithUser(currentUserFactory({ tenants: [personalTenant] }))], + }); + expect(await screen.findByText(personalTenantName)).toBeInTheDocument(); + + const currentTenantButton = await screen.findByText(personalTenantName); + await userEvent.click(currentTenantButton); + + expect(screen.queryByText(/organizations/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/invitations/i)).not.toBeInTheDocument(); + expect(screen.queryByTestId('tenant-invitation-pending-btn')).not.toBeInTheDocument(); + }); + + it('should handle invitation tenant click', async () => { + render(, { apolloMocks: getApolloMocks(), TenantWrapper }); + + const currentTenantButton = await screen.findByText(personalTenantName); + await userEvent.click(currentTenantButton); + + expect(await screen.findByText(/invitations/i)).toBeInTheDocument(); + const invitationTenantButton = await screen.findByText(organizationPendingTenantName); + + await userEvent.click(invitationTenantButton); + + expect(mockNavigate).toHaveBeenCalledWith( + `/en/tenant-invitation/${organizationPendingTenant.membership.invitationToken}` + ); + }); + + it('should handle tenant change', async () => { + render(, { apolloMocks: getApolloMocks(), TenantWrapper }); + + const currentTenantButton = await screen.findByText(personalTenantName); + await userEvent.click(currentTenantButton); + + expect(await screen.findByText(/organizations/i)).toBeInTheDocument(); + const invitationTenantButton = await screen.findByText(organizationTenantName); + + await userEvent.click(invitationTenantButton); + + expect(mockNavigate).toHaveBeenCalledWith(`/en/${organizationTenant.id}`); + }); + + it('should handle create new tenant click', async () => { + render(, { apolloMocks: getApolloMocks() }); + + const currentTenantButton = await screen.findByText(personalTenantName); + await userEvent.click(currentTenantButton); + + const newTenantButton = await screen.findByText(/create new organization/i); + await userEvent.click(newTenantButton); + + expect(mockNavigate).toHaveBeenCalledWith(`/en/add-tenant`); + }); + + it('should handle settings click', async () => { + render(, { apolloMocks: getApolloMocks() }); + + const settingsButton = await screen.findByTestId('tenant-settings-btn'); + await userEvent.click(settingsButton); + + expect(mockNavigate).toHaveBeenCalledWith(`/en/${personalTenant.id}/tenant/settings/members`); + }); + + it('should handle invitation pending badge click', async () => { + render(, { apolloMocks: getApolloMocks() }); + + const invitationPendingButton = await screen.findByTestId('tenant-invitation-pending-btn'); + await userEvent.click(invitationPendingButton); + + expect(mockNavigate).toHaveBeenCalledWith( + `/en/tenant-invitation/${organizationPendingTenant.membership.invitationToken}` + ); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantSwitch/index.ts b/packages/webapp-libs/webapp-tenants/src/components/tenantSwitch/index.ts new file mode 100644 index 000000000..b21024d33 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantSwitch/index.ts @@ -0,0 +1 @@ +export * from './tenantSwitch.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/components/tenantSwitch/tenantSwitch.component.tsx b/packages/webapp-libs/webapp-tenants/src/components/tenantSwitch/tenantSwitch.component.tsx new file mode 100644 index 000000000..810ee3705 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/components/tenantSwitch/tenantSwitch.component.tsx @@ -0,0 +1,151 @@ +import { TenantType } from '@sb/webapp-api-client/constants'; +import { CommonQueryTenantItemFragmentFragment, TenantUserRole } from '@sb/webapp-api-client/graphql'; +import { Button } from '@sb/webapp-core/components/buttons'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@sb/webapp-core/components/dropdownMenu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@sb/webapp-core/components/tooltip'; +import { RoutesConfig } from '@sb/webapp-core/config/routes'; +import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; +import { ChevronDown, Plus, Settings, UserPlus } from 'lucide-react'; +import { groupBy, head, prop } from 'ramda'; +import { FormattedMessage } from 'react-intl'; +import { useNavigate } from 'react-router-dom'; + +import { RoutesConfig as TenantRoutesConfig } from '../../config/routes'; +import { useGenerateTenantPath, useTenantRoleAccessCheck, useTenants } from '../../hooks'; +import { useCurrentTenant } from '../../providers'; + +export const TenantSwitch = () => { + const { data: currentTenant } = useCurrentTenant(); + const tenants = useTenants(); + const navigate = useNavigate(); + const generateTenantPath = useGenerateTenantPath(); + const generateLocalePath = useGenerateLocalePath(); + const { isAllowed: hasAccessToTenantSettings } = useTenantRoleAccessCheck([ + TenantUserRole.OWNER, + TenantUserRole.ADMIN, + ]); + + const tenantsGrouped = groupBy(prop('type'), tenants); + const personalTenant = head(tenantsGrouped[TenantType.PERSONAL]); + const organizationTenants = groupBy( + (tenant) => (tenant?.membership?.invitationAccepted ? 'organizations' : 'invitations'), + tenantsGrouped[TenantType.ORGANIZATION] ?? [] + ); + + const handleTenantChange = (tenant?: CommonQueryTenantItemFragmentFragment | null) => () => { + if (!tenant) return; + navigate(generateTenantPath(RoutesConfig.home, { tenantId: tenant.id })); + }; + + const handleInvitationClick = (tenant?: CommonQueryTenantItemFragmentFragment | null) => () => { + const token = tenant?.membership?.invitationToken; + if (!token) return; + navigate(generateLocalePath(RoutesConfig.tenantInvitation, { token })); + }; + + const handleNewTenantClick = () => { + navigate(generateLocalePath(RoutesConfig.addTenant)); + }; + + const handleTenantSettingsClick = () => { + navigate(generateTenantPath(TenantRoutesConfig.tenant.settings.members)); + }; + + const handleLastInvitationClick = () => { + handleInvitationClick(organizationTenants?.invitations?.[0])(); + }; + + const renderPendingInvitationBadge = () => { + return ( + + ); + }; + + const renderTenantSettings = () => ( + + + + + + + + + ); + + return ( + <> + + + + + + + + + + {personalTenant?.name} + + {organizationTenants?.organizations?.length > 0 && ( + <> + + + + + + )} + {organizationTenants?.organizations?.map((tenant) => ( + + {tenant?.name} + + ))} + {organizationTenants?.invitations?.length > 0 && ( + <> + + + + + + )} + {organizationTenants?.invitations?.map((invitation) => ( + + {invitation?.name} + + ))} + + + {' '} + + + + + {hasAccessToTenantSettings && renderTenantSettings()} + {organizationTenants?.invitations?.length > 0 && renderPendingInvitationBadge()} + + ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/config/routes.ts b/packages/webapp-libs/webapp-tenants/src/config/routes.ts new file mode 100644 index 000000000..06113ffd4 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/config/routes.ts @@ -0,0 +1,10 @@ +import { nestedPath } from '@sb/webapp-core/utils'; + +export const RoutesConfig = { + tenant: nestedPath('tenant', { + settings: nestedPath('settings', { + members: 'members', + general: 'general', + }), + }), +}; diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/index.ts b/packages/webapp-libs/webapp-tenants/src/hooks/index.ts new file mode 100644 index 000000000..f469cd2ab --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './useGenerateTenantPath'; +export * from './useTenants'; +export * from './useTenantRoles'; +export * from './useTenantRoleAccessCheck'; +export * from './useCurrentTenantRole'; diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useCurrentTenantRole/__tests__/useCurrentTenantRole.hook.spec.tsx b/packages/webapp-libs/webapp-tenants/src/hooks/useCurrentTenantRole/__tests__/useCurrentTenantRole.hook.spec.tsx new file mode 100644 index 000000000..8efed683c --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useCurrentTenantRole/__tests__/useCurrentTenantRole.hook.spec.tsx @@ -0,0 +1,69 @@ +import { TenantType, TenantUserRole } from '@sb/webapp-api-client'; +import { TenantType as TenantTypeType } from '@sb/webapp-api-client/constants'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { RoutesConfig } from '@sb/webapp-core/config/routes'; +import { useParams } from 'react-router-dom'; + +import { tenantFactory } from '../../../tests/factories/tenant'; +import { CustomRenderOptions, createMockRouterProps, renderHook } from '../../../tests/utils/rendering'; +import { useCurrentTenantRole } from '../useCurrentTenantRole.hook'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn().mockReturnValue({ tenantId: undefined }), +})); + +const render = ({ tenants }: { tenants: TenantType[] }, opts: CustomRenderOptions = {}) => { + const apolloMocks = [fillCommonQueryWithUser(currentUserFactory({ tenants }))]; + return renderHook(() => useCurrentTenantRole(), { + apolloMocks, + ...opts, + }); +}; + +describe('useCurrentTenantRole: Hook', () => { + describe('user is not logged in', () => { + it('should return null', async () => { + const tenants: TenantType[] = []; + const { result, waitForApolloMocks } = render({ tenants }); + await waitForApolloMocks(); + expect(result.current).toEqual(null); + }); + }); + describe('user is member of single tenant', () => { + it('should return OWNER role', async () => { + const role = TenantUserRole.OWNER; + const tenants = [tenantFactory({ membership: { role } })]; + const { result, waitForApolloMocks } = render({ tenants }); + await waitForApolloMocks(); + expect(result.current).toEqual(role); + }); + }); + + describe('user is member of two tenants', () => { + const tenants = [ + tenantFactory({ membership: { role: TenantUserRole.OWNER } }), + tenantFactory({ + id: 'fake-tenant-id', + membership: { role: TenantUserRole.MEMBER }, + type: TenantTypeType.ORGANIZATION, + }), + ]; + + it('should return the proper role for the owned tenant', async () => { + const { result, waitForApolloMocks } = render({ tenants }); + await waitForApolloMocks(); + expect(result.current).toEqual(TenantUserRole.OWNER); + }); + + it('should return the proper role for the second tenant', async () => { + const mockedRouterParams = useParams as jest.Mock; + mockedRouterParams.mockReturnValue({ tenantId: 'fake-tenant-id' }); + + const routerProps = createMockRouterProps(RoutesConfig.home, { tenantId: tenants[1].id }); + const { result, waitForApolloMocks } = render({ tenants }, { routerProps }); + await waitForApolloMocks(); + expect(result.current).toEqual(TenantUserRole.MEMBER); + }); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useCurrentTenantRole/index.ts b/packages/webapp-libs/webapp-tenants/src/hooks/useCurrentTenantRole/index.ts new file mode 100644 index 000000000..221e63297 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useCurrentTenantRole/index.ts @@ -0,0 +1 @@ +export { useCurrentTenantRole } from './useCurrentTenantRole.hook'; diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useCurrentTenantRole/useCurrentTenantRole.hook.ts b/packages/webapp-libs/webapp-tenants/src/hooks/useCurrentTenantRole/useCurrentTenantRole.hook.ts new file mode 100644 index 000000000..ce54797ec --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useCurrentTenantRole/useCurrentTenantRole.hook.ts @@ -0,0 +1,17 @@ +import { useCurrentTenant } from '../../providers'; + +/** + * Hook that retrieves the user's role of the current tenant. + * + * This hook uses the `useCurrentTenant` hook to get the current tenant's data, and then extracts the role from the + * tenant's membership data. + * + * @returns {string | null} The role of the current tenant if available, or `null` otherwise. + * + */ +export const useCurrentTenantRole = () => { + const currentTenant = useCurrentTenant(); + const membership = currentTenant?.data?.membership; + if (!membership?.invitationAccepted) return null; + return membership?.role ?? null; +}; diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useGenerateTenantPath/__tests__/useGenerateTenantPath.hook.spec.tsx b/packages/webapp-libs/webapp-tenants/src/hooks/useGenerateTenantPath/__tests__/useGenerateTenantPath.hook.spec.tsx new file mode 100644 index 000000000..10d56ddcb --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useGenerateTenantPath/__tests__/useGenerateTenantPath.hook.spec.tsx @@ -0,0 +1,26 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { RoutesConfig } from '@sb/webapp-core/config/routes'; + +import { useGenerateTenantPath } from '../'; +import { tenantFactory } from '../../../tests/factories/tenant'; +import { renderHook } from '../../../tests/utils/rendering'; + +const render = () => { + const apolloMocks = [ + fillCommonQueryWithUser( + currentUserFactory({ tenants: [tenantFactory({ membership: { role: TenantUserRole.MEMBER } })] }) + ), + ]; + return renderHook(() => useGenerateTenantPath(), { + apolloMocks, + }); +}; + +describe('useGenerateTenantPath: Hook', () => { + it('should compose the right url to the tenant home page', async () => { + const { result, waitForApolloMocks } = render(); + await waitForApolloMocks(); + expect(result.current(RoutesConfig.home, { tenantId: 'example-tenant' })).toEqual('/en/example-tenant'); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useGenerateTenantPath/index.ts b/packages/webapp-libs/webapp-tenants/src/hooks/useGenerateTenantPath/index.ts new file mode 100644 index 000000000..446ac055b --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useGenerateTenantPath/index.ts @@ -0,0 +1 @@ +export { useGenerateTenantPath } from './useGenerateTenantPath.hook'; diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useGenerateTenantPath/useGenerateTenantPath.hook.ts b/packages/webapp-libs/webapp-tenants/src/hooks/useGenerateTenantPath/useGenerateTenantPath.hook.ts new file mode 100644 index 000000000..79f33f19b --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useGenerateTenantPath/useGenerateTenantPath.hook.ts @@ -0,0 +1,42 @@ +import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; +import { getTenantPath } from '@sb/webapp-core/utils/path'; +import { useCallback } from 'react'; +import { generatePath } from 'react-router-dom'; + +import { useCurrentTenant } from '../../providers/currentTenantProvider/currentTenantProvider.hook'; + +/** + * A hook that returns a function you can use to generate a path that includes proper tenant and locale code. + * Underneath, it uses `useGenerateLocalePath` hook + * + * @example + * ```tsx showLineNumbers + * import { useGenerateTenantPath } from '@sb/webapp-tenants/hooks'; + * import { Link } from 'react-router-dom'; + * + * const Example = () => { + * const generateTenantPath = useGenerateTenantPath(); + * + * return ( + * + * Press me + * + * ) + * } + * ``` + * + */ +export const useGenerateTenantPath = () => { + const generateLocalePath = useGenerateLocalePath(); + const currentTenant = useCurrentTenant(); + + const tenantId = currentTenant.data?.id ?? ''; + + return useCallback( + (path: string, params: Record = {}) => + generatePath(generateLocalePath('') + getTenantPath(path), { tenantId, ...params }), + [tenantId] + ); +}; + +export type GenerateTenantPath = ReturnType; diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoleAccessCheck/__tests__/useTenantRoleAccessCheck.hook.spec.tsx b/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoleAccessCheck/__tests__/useTenantRoleAccessCheck.hook.spec.tsx new file mode 100644 index 000000000..fec2835ea --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoleAccessCheck/__tests__/useTenantRoleAccessCheck.hook.spec.tsx @@ -0,0 +1,39 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; + +import { tenantFactory } from '../../../tests/factories/tenant'; +import { renderHook } from '../../../tests/utils/rendering'; +import { useTenantRoleAccessCheck } from '../useTenantRoleAccessCheck.hook'; + +const render = ({ role, allowedRoles }: { role: TenantUserRole; allowedRoles: TenantUserRole | TenantUserRole[] }) => { + const apolloMocks = [ + fillCommonQueryWithUser(currentUserFactory({ tenants: [tenantFactory({ membership: { role } })] })), + ]; + return renderHook(() => useTenantRoleAccessCheck(allowedRoles), { + apolloMocks, + }); +}; + +describe('useTenantRoleAccessCheck: Hook', () => { + describe('user doesnt have allowed role', () => { + it('should not allow user', async () => { + const { result, waitForApolloMocks } = render({ + role: TenantUserRole.MEMBER, + allowedRoles: TenantUserRole.ADMIN, + }); + await waitForApolloMocks(); + expect(result.current.isAllowed).toEqual(false); + }); + }); + + describe('user have allowed role', () => { + it('should allow user', async () => { + const { result, waitForApolloMocks } = render({ + role: TenantUserRole.ADMIN, + allowedRoles: [TenantUserRole.ADMIN, TenantUserRole.OWNER], + }); + await waitForApolloMocks(); + expect(result.current.isAllowed).toEqual(true); + }); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoleAccessCheck/index.ts b/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoleAccessCheck/index.ts new file mode 100644 index 000000000..97fb29bee --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoleAccessCheck/index.ts @@ -0,0 +1 @@ +export * from './useTenantRoleAccessCheck.hook'; diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoleAccessCheck/useTenantRoleAccessCheck.hook.ts b/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoleAccessCheck/useTenantRoleAccessCheck.hook.ts new file mode 100644 index 000000000..da660749f --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoleAccessCheck/useTenantRoleAccessCheck.hook.ts @@ -0,0 +1,13 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; + +import { useCurrentTenantRole } from '../useCurrentTenantRole'; + +export const useTenantRoleAccessCheck = ( + allowedRole: TenantUserRole | TenantUserRole[] = [TenantUserRole.OWNER, TenantUserRole.ADMIN, TenantUserRole.MEMBER] +) => { + const userRole = useCurrentTenantRole(); + const allowedRolesArray = Array.isArray(allowedRole) ? allowedRole : [allowedRole]; + return { + isAllowed: allowedRolesArray.some((role) => role === userRole), + }; +}; diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoles/index.ts b/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoles/index.ts new file mode 100644 index 000000000..afedab950 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoles/index.ts @@ -0,0 +1 @@ +export * from './useTenantRoles.hook'; diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoles/useTenantRoles.hook.ts b/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoles/useTenantRoles.hook.ts new file mode 100644 index 000000000..e2a06e47d --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useTenantRoles/useTenantRoles.hook.ts @@ -0,0 +1,29 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { useIntl } from 'react-intl'; + +/** + * Hook that provides translations for tenant roles. + * + * The hook returns an object with two properties: + * - `roleTranslations`: an object mapping each role to its translated string. + * - `getRoleTranslation`: a function that takes a role and returns its translated string. + * + * @returns {Object} An object containing role translations and a function to get role translation. + * + */ +export const useTenantRoles = () => { + const intl = useIntl(); + + const roleTranslations = { + [TenantUserRole.OWNER]: intl.formatMessage({ defaultMessage: 'Owner', id: 'Tenant roles / Owner' }), + [TenantUserRole.ADMIN]: intl.formatMessage({ defaultMessage: 'Admin', id: 'Tenant roles / Admin' }), + [TenantUserRole.MEMBER]: intl.formatMessage({ defaultMessage: 'Member', id: 'Tenant roles / Member' }), + }; + + const getRoleTranslation = (role: TenantUserRole) => roleTranslations[role]; + + return { + roleTranslations, + getRoleTranslation, + }; +}; diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useTenants/__tests__/useTenants.hook.spec.tsx b/packages/webapp-libs/webapp-tenants/src/hooks/useTenants/__tests__/useTenants.hook.spec.tsx new file mode 100644 index 000000000..d9a4412d1 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useTenants/__tests__/useTenants.hook.spec.tsx @@ -0,0 +1,52 @@ +import { TenantType, TenantUserRole } from '@sb/webapp-api-client'; +import { TenantType as TenantTypeType } from '@sb/webapp-api-client/constants'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; + +import { tenantFactory } from '../../../tests/factories/tenant'; +import { renderHook } from '../../../tests/utils/rendering'; +import { useTenants } from '../useTenants.hook'; + +const render = ({ tenants }: { tenants: TenantType[] }) => { + const apolloMocks = [fillCommonQueryWithUser(currentUserFactory({ tenants }))]; + return renderHook(() => useTenants(), { + apolloMocks, + }); +}; + +describe('useTenants: Hook', () => { + describe('user is not logged in', () => { + it('should return empty tenants', async () => { + const tenants: TenantType[] = []; + const { result, waitForApolloMocks } = render({ tenants }); + await waitForApolloMocks(); + expect(result.current.length).toEqual(0); + }); + }); + describe('user is member of single tenant', () => { + it('should return single tenant', async () => { + const tenants = [tenantFactory({ membership: { role: TenantUserRole.OWNER } })]; + const { result, waitForApolloMocks } = render({ tenants }); + await waitForApolloMocks(); + expect(result.current.length).toEqual(tenants.length); + expect(result.current[0]?.id).not.toBe(null); + expect(result.current[0]?.id).toEqual(tenants[0].id); + }); + }); + + describe('user is member of two tenants', () => { + it('should return two tenants', async () => { + const tenants = [ + tenantFactory({ membership: { role: TenantUserRole.OWNER } }), + tenantFactory({ membership: { role: TenantUserRole.MEMBER }, type: TenantTypeType.ORGANIZATION }), + ]; + + const { result, waitForApolloMocks } = render({ tenants }); + await waitForApolloMocks(); + expect(result.current.length).toEqual(tenants.length); + expect(result.current[0]?.id).not.toBe(null); + expect(result.current[0]?.id).toEqual(tenants[0].id); + expect(result.current[1]?.id).not.toBe(null); + expect(result.current[1]?.id).toEqual(tenants[1].id); + }); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useTenants/index.ts b/packages/webapp-libs/webapp-tenants/src/hooks/useTenants/index.ts new file mode 100644 index 000000000..ac19b91bd --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useTenants/index.ts @@ -0,0 +1 @@ +export * from './useTenants.hook'; diff --git a/packages/webapp-libs/webapp-tenants/src/hooks/useTenants/useTenants.hook.ts b/packages/webapp-libs/webapp-tenants/src/hooks/useTenants/useTenants.hook.ts new file mode 100644 index 000000000..86b5dad09 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/hooks/useTenants/useTenants.hook.ts @@ -0,0 +1,18 @@ +import { getFragmentData } from '@sb/webapp-api-client/graphql'; +import { commonQueryTenantItemFragment, useCommonQuery } from '@sb/webapp-api-client/providers'; +import { useMemo } from 'react'; + +/** + * Hook that retrieves the current user's tenants. + * + * This hook uses the `useCommonQuery` hook to get the current user's data, and then extracts the tenants from it. + * + * @returns {Array} An array of the current user's tenants. + */ +export const useTenants = () => { + const { data } = useCommonQuery(); + return useMemo(() => { + const tenantsRaw = data?.currentUser?.tenants || []; + return tenantsRaw.map((t) => getFragmentData(commonQueryTenantItemFragment, t)); + }, [data]); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/notifications/index.ts b/packages/webapp-libs/webapp-tenants/src/notifications/index.ts new file mode 100644 index 000000000..0121f0f06 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/notifications/index.ts @@ -0,0 +1,3 @@ +export * from './tenantInvitationCreated'; +export * from './tenantInvitationAccepted'; +export * from './tenantInvitationDeclined'; diff --git a/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationAccepted/index.ts b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationAccepted/index.ts new file mode 100644 index 000000000..180345ee6 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationAccepted/index.ts @@ -0,0 +1 @@ +export { TenantInvitationAccepted } from './tenantInvitationAccepted.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationAccepted/tenantInvitationAccepted.component.tsx b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationAccepted/tenantInvitationAccepted.component.tsx new file mode 100644 index 000000000..a7d6da195 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationAccepted/tenantInvitationAccepted.component.tsx @@ -0,0 +1,29 @@ +import { Notification, NotificationType } from '@sb/webapp-notifications'; +import { FormattedMessage } from 'react-intl'; + +export type TenantInvitationAcceptedProps = NotificationType<{ + id: string; + name: string; + tenant_name: string; +}>; + +export const TenantInvitationAccepted = ({ + data: { name, tenant_name }, + issuer, + ...restProps +}: TenantInvitationAcceptedProps) => { + return ( + + } + /> + ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationAccepted/tenantInvitationAccepted.stories.tsx b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationAccepted/tenantInvitationAccepted.stories.tsx new file mode 100644 index 000000000..e4148fb5d --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationAccepted/tenantInvitationAccepted.stories.tsx @@ -0,0 +1,37 @@ +import { NotificationTypes } from '@sb/webapp-notifications'; +import { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { withProviders } from '../../utils/storybook'; +import { TenantInvitationAccepted, TenantInvitationAcceptedProps } from './tenantInvitationAccepted.component'; + +const Template: StoryFn = (args: TenantInvitationAcceptedProps) => { + return ; +}; + +const meta: Meta = { + title: 'Tenants / Notifications / TenantInvitationAccepted', + component: Template, +}; + +export default meta; + +export const Default: StoryObj = { + args: { + type: NotificationTypes.TENANT_INVITATION_ACCEPTED, + readAt: null, + createdAt: '2021-06-17T11:45:33', + + data: { + id: 'data-mock-uuid', + name: 'User Name', + tenant_name: 'Lorem ipsum', + }, + issuer: { + id: 'mock-user-uuid', + email: 'example@example.com', + avatar: 'https://picsum.photos/24/24', + }, + }, + + decorators: [withProviders()], +}; diff --git a/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationCreated/index.ts b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationCreated/index.ts new file mode 100644 index 000000000..07c26a9d9 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationCreated/index.ts @@ -0,0 +1 @@ +export { TenantInvitationCreated } from './tenantInvitationCreated.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationCreated/tenantInvitationCreated.component.tsx b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationCreated/tenantInvitationCreated.component.tsx new file mode 100644 index 000000000..1b1234209 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationCreated/tenantInvitationCreated.component.tsx @@ -0,0 +1,48 @@ +import { RoutesConfig } from '@sb/webapp-core/config/routes'; +import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; +import { Notification, NotificationType } from '@sb/webapp-notifications'; +import { FormattedMessage } from 'react-intl'; +import { useNavigate } from 'react-router'; + +import { useTenants } from '../../hooks'; + +export type TenantInvitationCreatedProps = NotificationType<{ + id: string; + tenant_name: string; +}>; + +export const TenantInvitationCreated = ({ + data: { id, tenant_name }, + issuer, + ...restProps +}: TenantInvitationCreatedProps) => { + const generateLocalePath = useGenerateLocalePath(); + const navigate = useNavigate(); + + const tenants = useTenants(); + + const handleInvitationClick = () => { + const tenant = tenants.find((tenant) => tenant?.membership?.id === id); + + const token = tenant?.membership.invitationToken; + if (!token) return; + + navigate(generateLocalePath(RoutesConfig.tenantInvitation, { token })); + }; + + return ( + + } + /> + ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationCreated/tenantInvitationCreated.stories.tsx b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationCreated/tenantInvitationCreated.stories.tsx new file mode 100644 index 000000000..522637a7f --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationCreated/tenantInvitationCreated.stories.tsx @@ -0,0 +1,36 @@ +import { NotificationTypes } from '@sb/webapp-notifications'; +import { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { withProviders } from '../../utils/storybook'; +import { TenantInvitationCreated, TenantInvitationCreatedProps } from './tenantInvitationCreated.component'; + +const Template: StoryFn = (args: TenantInvitationCreatedProps) => { + return ; +}; + +const meta: Meta = { + title: 'Tenants / Notifications / TenantInvitationCreated', + component: Template, +}; + +export default meta; + +export const Default: StoryObj = { + args: { + type: NotificationTypes.TENANT_INVITATION_CREATED, + readAt: null, + createdAt: '2021-06-17T11:45:33', + + data: { + id: 'data-mock-uuid', + tenant_name: 'Lorem ipsum', + }, + issuer: { + id: 'mock-user-uuid', + email: 'example@example.com', + avatar: 'https://picsum.photos/24/24', + }, + }, + + decorators: [withProviders()], +}; diff --git a/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationDeclined/index.ts b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationDeclined/index.ts new file mode 100644 index 000000000..e066145f1 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationDeclined/index.ts @@ -0,0 +1 @@ +export { TenantInvitationDeclined } from './tenantInvitationDeclined.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationDeclined/tenantInvitationDeclined.component.tsx b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationDeclined/tenantInvitationDeclined.component.tsx new file mode 100644 index 000000000..28c967994 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationDeclined/tenantInvitationDeclined.component.tsx @@ -0,0 +1,29 @@ +import { Notification, NotificationType } from '@sb/webapp-notifications'; +import { FormattedMessage } from 'react-intl'; + +export type TenantInvitationDeclinedProps = NotificationType<{ + id: string; + name: string; + tenant_name: string; +}>; + +export const TenantInvitationDeclined = ({ + data: { name, tenant_name }, + issuer, + ...restProps +}: TenantInvitationDeclinedProps) => { + return ( + + } + /> + ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationDeclined/tenantInvitationDeclined.stories.tsx b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationDeclined/tenantInvitationDeclined.stories.tsx new file mode 100644 index 000000000..e3caf08b9 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/notifications/tenantInvitationDeclined/tenantInvitationDeclined.stories.tsx @@ -0,0 +1,36 @@ +import { NotificationTypes } from '@sb/webapp-notifications'; +import { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { withProviders } from '../../utils/storybook'; +import { TenantInvitationDeclined, TenantInvitationDeclinedProps } from './tenantInvitationDeclined.component'; + +const Template: StoryFn = (args: TenantInvitationDeclinedProps) => { + return ; +}; + +const meta: Meta = { + title: 'Tenants / Notifications / TenantInvitationDeclined', + component: Template, +}; + +export default meta; + +export const Default: StoryObj = { + args: { + type: NotificationTypes.TENANT_INVITATION_DECLINED, + readAt: null, + createdAt: '2021-06-17T11:45:33', + data: { + id: 'data-mock-uuid', + name: 'User Name', + tenant_name: 'Lorem ipsum', + }, + issuer: { + id: 'mock-user-uuid', + email: 'example@example.com', + avatar: 'https://picsum.photos/24/24', + }, + }, + + decorators: [withProviders()], +}; diff --git a/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.component.tsx b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.component.tsx new file mode 100644 index 000000000..5481efc1a --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.component.tsx @@ -0,0 +1,76 @@ +import { getFragmentData } from '@sb/webapp-api-client'; +import { TenantType } from '@sb/webapp-api-client/constants'; +import { commonQueryCurrentUserFragment, useCommonQuery } from '@sb/webapp-api-client/providers'; +import { prop } from 'ramda'; +import { useEffect, useMemo, useSyncExternalStore } from 'react'; +import { useParams } from 'react-router-dom'; + +import { useTenants } from '../../hooks/useTenants/useTenants.hook'; +import currentTenantContext from './currentTenantProvider.context'; +import { setCurrentTenantStorageState, store } from './currentTenantProvider.storage'; +import { CurrentTenantProviderProps, CurrentTenantStorageState } from './currentTenantProvider.types'; + +export type TenantPathParams = { + tenantId: string; +}; + +/** + * + * @param children + * @constructor + * + * @category Component + */ +export const CurrentTenantProvider = ({ children }: CurrentTenantProviderProps) => { + const params = useParams(); + let { tenantId } = params; + const { data } = useCommonQuery(); + const profile = getFragmentData(commonQueryCurrentUserFragment, data?.currentUser); + const userId = profile?.id; + + const tenants = useTenants(); + const storedState = useSyncExternalStore(store.subscribe, store.getSnapshot); + let storedTenantId, parsedStoredState: CurrentTenantStorageState; + try { + parsedStoredState = JSON.parse(storedState); + storedTenantId = userId ? parsedStoredState?.[userId] ?? null : null; + } catch (e) { + parsedStoredState = {}; + storedTenantId = null; + } + + if ( + !tenantId && + storedTenantId && + tenants + .map(prop('id')) + .filter((id) => !!id) + .includes(storedTenantId) + ) { + tenantId = storedTenantId; + } + + let currentTenant = tenants.filter((t) => t?.membership.invitationAccepted).find((t) => t?.id === tenantId); + if (!currentTenant) { + // select first default + currentTenant = tenants.find((t) => t?.type === TenantType.PERSONAL); + + if (!currentTenant && tenants.length > 0) { + currentTenant = tenants[0]; + } + } + + useEffect(() => { + if (currentTenant && userId) { + parsedStoredState[userId] = currentTenant.id; + setCurrentTenantStorageState(parsedStoredState); + } + }, [currentTenant?.id, userId]); + + const value = useMemo( + () => ({ data: currentTenant || null }), + [currentTenant?.id, currentTenant?.membership?.role, currentTenant?.name] + ); + + return {children}; +}; diff --git a/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.context.ts b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.context.ts new file mode 100644 index 000000000..e57bd1258 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.context.ts @@ -0,0 +1,10 @@ +import { CommonQueryTenantItemFragmentFragment } from '@sb/webapp-api-client/graphql'; +import React from 'react'; + +type CurrentTenantContext = { + data: CommonQueryTenantItemFragmentFragment | null; +}; + +export default React.createContext({ + data: null, +}); diff --git a/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.graphql.ts b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.graphql.ts new file mode 100644 index 000000000..ff5178a0f --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.graphql.ts @@ -0,0 +1,27 @@ +import { gql } from '@sb/webapp-api-client/graphql'; + +/** + * @category graphql + */ +export const tenantFragment = gql(/* GraphQL */ ` + fragment tenantFragment on TenantType { + id + name + slug + membership { + role + invitationAccepted + } + } +`); + +/** + * @category graphql + */ +export const currentTenantQuery = gql(/* GraphQL */ ` + query currentTenantQuery($id: ID!) { + tenant(id: $id) { + ...tenantFragment + } + } +`); diff --git a/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.hook.ts b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.hook.ts new file mode 100644 index 000000000..44e3ebec8 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; + +import currentTenantContext from './currentTenantProvider.context'; + +/** + * @category hook + */ +export const useCurrentTenant = () => { + return useContext(currentTenantContext); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.storage.ts b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.storage.ts new file mode 100644 index 000000000..b17debb55 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.storage.ts @@ -0,0 +1,23 @@ +import { reportError } from '@sb/webapp-core/utils/reportError'; + +import { CURRENT_TENANT_STORAGE_KEY, CurrentTenantStorageState } from './currentTenantProvider.types'; + +export const setCurrentTenantStorageState = (storageState: CurrentTenantStorageState) => { + try { + const newValue = JSON.stringify(storageState); + localStorage.setItem(CURRENT_TENANT_STORAGE_KEY, newValue); + // On localStorage.setItem, the storage event is only triggered on other tabs and windows. + // So we manually dispatch a storage event to trigger the subscribe function on the current window as well. + window.dispatchEvent(new StorageEvent('storage', { key: CURRENT_TENANT_STORAGE_KEY, newValue })); + } catch (e) { + reportError(e); + } +}; + +export const store = { + getSnapshot: () => localStorage.getItem(CURRENT_TENANT_STORAGE_KEY) ?? '{}', + subscribe: (listener: () => void) => { + window.addEventListener('storage', listener); + return () => window.removeEventListener('storage', listener); + }, +}; diff --git a/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.types.ts b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.types.ts new file mode 100644 index 000000000..969c6f370 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/currentTenantProvider.types.ts @@ -0,0 +1,11 @@ +import { PropsWithChildren } from 'react'; + +export const CURRENT_TENANT_STORAGE_KEY = 'currentTenant'; + +export type CurrentTenantProviderProps = { + storageKey?: string; +} & PropsWithChildren; + +export type CurrentTenantStorageState = { + [key: string]: string; +}; diff --git a/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/index.ts b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/index.ts new file mode 100644 index 000000000..681a5a179 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/providers/currentTenantProvider/index.ts @@ -0,0 +1,4 @@ +export * from './currentTenantProvider.types'; +export * from './currentTenantProvider.component'; +export * from './currentTenantProvider.graphql'; +export * from './currentTenantProvider.hook'; diff --git a/packages/webapp-libs/webapp-tenants/src/providers/index.ts b/packages/webapp-libs/webapp-tenants/src/providers/index.ts new file mode 100644 index 000000000..2087af6be --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/providers/index.ts @@ -0,0 +1 @@ +export * from './currentTenantProvider'; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/addTenantForm/__tests__/addTenantForm.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/routes/addTenantForm/__tests__/addTenantForm.component.spec.tsx new file mode 100644 index 000000000..dc358e3a8 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/addTenantForm/__tests__/addTenantForm.component.spec.tsx @@ -0,0 +1,88 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { TenantType as TenantTypeField } from '@sb/webapp-api-client/constants'; +import { commonQueryCurrentUserQuery } from '@sb/webapp-api-client/providers'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { composeMockedQueryResult } from '@sb/webapp-api-client/tests/utils'; +import { trackEvent } from '@sb/webapp-core/services/analytics'; +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { membershipFactory, tenantFactory } from '../../../tests/factories/tenant'; +import { render } from '../../../tests/utils/rendering'; +import { AddTenantForm, addTenantMutation } from '../addTenantForm.component'; + +jest.mock('@sb/webapp-core/services/analytics'); + +describe('AddTenantForm: Component', () => { + const Component = () => ; + + it('should display empty form', async () => { + const { waitForApolloMocks } = render(); + await waitForApolloMocks(); + const value = (await screen.findByPlaceholderText(/name/i)).getAttribute('value'); + expect(value).toBe(''); + }); + + describe('action completes successfully', () => { + it('should commit mutation', async () => { + const user = currentUserFactory(); + const commonQueryMock = fillCommonQueryWithUser(user); + + const variables = { + input: { name: 'new item name' }, + }; + const data = { + createTenant: { + tenantEdge: { + node: { + id: '1', + ...variables.input, + }, + }, + }, + }; + const requestMock = composeMockedQueryResult(addTenantMutation, { + variables, + data, + }); + + const currentUserRefetchData = { + ...user, + tenants: [ + ...(user.tenants ?? []), + tenantFactory({ + id: '1', + name: variables.input.name, + type: TenantTypeField.ORGANIZATION, + membership: membershipFactory({ role: TenantUserRole.OWNER }), + }), + ], + }; + const refetchMock = composeMockedQueryResult(commonQueryCurrentUserQuery, { + data: currentUserRefetchData, + }); + + requestMock.newData = jest.fn(() => ({ + data, + })); + + refetchMock.newData = jest.fn(() => ({ + data: { + currentUser: currentUserRefetchData, + }, + })); + + render(, { apolloMocks: [commonQueryMock, requestMock, refetchMock] }); + + await userEvent.type(await screen.findByPlaceholderText(/name/i), 'new item name'); + await userEvent.click(screen.getByRole('button', { name: /save/i })); + expect(requestMock.newData).toHaveBeenCalled(); + expect(refetchMock.newData).toHaveBeenCalled(); + + const toast = await screen.findByTestId('toast-1'); + + expect(trackEvent).toHaveBeenCalledWith('tenant', 'add', '1'); + expect(toast).toHaveTextContent('🎉 Organization added successfully!'); + }); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/routes/addTenantForm/addTenantForm.component.tsx b/packages/webapp-libs/webapp-tenants/src/routes/addTenantForm/addTenantForm.component.tsx new file mode 100644 index 000000000..f9cc7e6e5 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/addTenantForm/addTenantForm.component.tsx @@ -0,0 +1,74 @@ +import { useMutation } from '@apollo/client'; +import { gql } from '@sb/webapp-api-client/graphql'; +import { useCommonQuery } from '@sb/webapp-api-client/providers'; +import { PageHeadline } from '@sb/webapp-core/components/pageHeadline'; +import { PageLayout } from '@sb/webapp-core/components/pageLayout'; +import { RoutesConfig } from '@sb/webapp-core/config/routes'; +import { trackEvent } from '@sb/webapp-core/services/analytics'; +import { useToast } from '@sb/webapp-core/toast/useToast'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useNavigate } from 'react-router'; + +import { TenantForm } from '../../components/tenantForm'; +import { TenantFormFields } from '../../components/tenantForm/tenantForm.component'; +import { useGenerateTenantPath } from '../../hooks'; + +export const addTenantMutation = gql(/* GraphQL */ ` + mutation addTenantMutation($input: CreateTenantMutationInput!) { + createTenant(input: $input) { + tenantEdge { + node { + id + name + } + } + } + } +`); + +export const AddTenantForm = () => { + const generateTenantPath = useGenerateTenantPath(); + const { toast } = useToast(); + const intl = useIntl(); + const navigate = useNavigate(); + const { reload: reloadCommonQuery } = useCommonQuery(); + + const successMessage = intl.formatMessage({ + id: 'Tenant form / AddTenant / Success message', + defaultMessage: '🎉 Organization added successfully!', + }); + + const [commitTenantFormMutation, { error, loading: loadingMutation }] = useMutation(addTenantMutation, { + onCompleted: (data) => { + const id = data?.createTenant?.tenantEdge?.node?.id; + reloadCommonQuery(); + + trackEvent('tenant', 'add', id); + + toast({ description: successMessage }); + + navigate(generateTenantPath(RoutesConfig.home, { tenantId: id! })); + }, + }); + + const onFormSubmit = (formData: TenantFormFields) => { + commitTenantFormMutation({ + variables: { + input: { + name: formData.name, + }, + }, + }); + }; + + return ( + + } + /> + + + + ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/addTenantForm/addTenantForm.stories.tsx b/packages/webapp-libs/webapp-tenants/src/routes/addTenantForm/addTenantForm.stories.tsx new file mode 100644 index 000000000..6d90d0f93 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/addTenantForm/addTenantForm.stories.tsx @@ -0,0 +1,18 @@ +import { StoryFn, StoryObj } from '@storybook/react'; + +import { withProviders } from '../../utils/storybook'; +import { AddTenantForm } from './addTenantForm.component'; + +const Template: StoryFn = () => { + return ; +}; + +export default { + title: 'Tenants / AddTenant', + component: AddTenantForm, +}; + +export const Default: StoryObj = { + render: Template, + decorators: [withProviders({})], +}; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/addTenantForm/index.ts b/packages/webapp-libs/webapp-tenants/src/routes/addTenantForm/index.ts new file mode 100644 index 000000000..ed645f0c9 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/addTenantForm/index.ts @@ -0,0 +1 @@ +export { AddTenantForm as default } from './addTenantForm.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/index.ts b/packages/webapp-libs/webapp-tenants/src/routes/index.ts new file mode 100644 index 000000000..d263576b8 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/index.ts @@ -0,0 +1,7 @@ +import { asyncComponent } from '@sb/webapp-core/utils/asyncComponent'; + +export const AddTenantForm = asyncComponent(() => import('./addTenantForm')); +export const TenantSettings = asyncComponent(() => import('./tenantSettings')); +export const TenantMembers = asyncComponent(() => import('./tenantSettings/tenantMembers')); +export const TenantGeneralSettings = asyncComponent(() => import('./tenantSettings/tenantGeneralSettings')); +export const TenantInvitation = asyncComponent(() => import('./tenantInvitation')); diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/__tests__/tenantInvitation.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/__tests__/tenantInvitation.component.spec.tsx new file mode 100644 index 000000000..e482923ef --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/__tests__/tenantInvitation.component.spec.tsx @@ -0,0 +1,187 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { TenantType as TenantTypeType } from '@sb/webapp-api-client/constants'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { composeMockedQueryResult } from '@sb/webapp-api-client/tests/utils'; +import { RoutesConfig } from '@sb/webapp-core/config/routes'; +import { trackEvent } from '@sb/webapp-core/services/analytics'; +import { getLocalePath, getTenantPathHelper } from '@sb/webapp-core/utils'; +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { Route, Routes } from 'react-router-dom'; + +import { tenantFactory } from '../../../tests/factories/tenant'; +import { createMockRouterProps, render } from '../../../tests/utils/rendering'; +import { TenantInvitation } from '../tenantInvitation.component'; +import { acceptTenantInvitationMutation, declineTenantInvitationMutation } from '../tenantInvitation.graphql'; + +jest.mock('@sb/webapp-core/services/analytics'); + +describe('TenantInvitation: Component', () => { + const Component = () => ( + + } /> + Home page mock
} /> + Org home page mock
} /> + + ); + const routePath = RoutesConfig.tenantInvitation; + + describe('token is invalid', () => { + it('should redirect to home', async () => { + const routerProps = createMockRouterProps(routePath, { tenantId: '', token: 'invalid-token' }); + + render(, { routerProps }); + + expect(await screen.findByText(/Home page mock/i)).toBeInTheDocument(); + }); + }); + + describe('token is already accepted', () => { + it('should redirect to org home', async () => { + const token = 'valid-token'; + const routerProps = createMockRouterProps(routePath, { tenantId: '', token }); + const targetTenant = tenantFactory({ + membership: { role: TenantUserRole.MEMBER, invitationAccepted: true, invitationToken: token }, + type: TenantTypeType.ORGANIZATION, + }); + const apolloMocks = [ + fillCommonQueryWithUser( + currentUserFactory({ + tenants: [tenantFactory({ membership: { role: TenantUserRole.OWNER } }), targetTenant], + }) + ), + ]; + + render(, { routerProps, apolloMocks }); + + expect(await screen.findByText(/Org home page mock/i)).toBeInTheDocument(); + }); + }); + + describe('token is valid', () => { + it('should send accept mutation on button click', async () => { + const token = 'valid-token'; + const routerProps = createMockRouterProps(routePath, { tenantId: '', token }); + const targetTenant = tenantFactory({ + membership: { role: TenantUserRole.MEMBER, invitationAccepted: false, invitationToken: token }, + type: TenantTypeType.ORGANIZATION, + }); + + const user = currentUserFactory({ + tenants: [tenantFactory({ membership: { role: TenantUserRole.OWNER } }), targetTenant], + }); + + const variables = { + input: { token, id: targetTenant.membership.id }, + }; + const data = { + acceptTenantInvitation: { + ok: true, + }, + }; + const requestMock = composeMockedQueryResult(acceptTenantInvitationMutation, { + variables, + data, + }); + + const currentUserRefetchData = { + ...user, + tenants: [ + user.tenants![0], + { + ...targetTenant, + membership: { + ...targetTenant.membership, + invitationAccepted: true, + }, + }, + ], + }; + const refetchMock = fillCommonQueryWithUser(currentUserRefetchData); + + requestMock.newData = jest.fn(() => ({ + data, + })); + + refetchMock.newData = jest.fn(() => ({ + data: { + currentUser: currentUserRefetchData, + }, + })); + + const apolloMocks = [fillCommonQueryWithUser(user), requestMock, refetchMock]; + + render(, { routerProps, apolloMocks }); + + expect(await screen.findByText(/Accept/i)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /accept/i })); + + expect(requestMock.newData).toHaveBeenCalled(); + expect(refetchMock.newData).toHaveBeenCalled(); + + const toast = await screen.findByTestId('toast-1'); + + expect(trackEvent).toHaveBeenCalledWith('tenantInvitation', 'accept', targetTenant.id); + expect(toast).toHaveTextContent('🎉 Invitation accepted!'); + }); + + it('should send decline mutation on button click', async () => { + const token = 'valid-token'; + const routerProps = createMockRouterProps(routePath, { tenantId: '', token }); + const targetTenant = tenantFactory({ + membership: { role: TenantUserRole.MEMBER, invitationAccepted: false, invitationToken: token }, + type: TenantTypeType.ORGANIZATION, + }); + + const user = currentUserFactory({ + tenants: [tenantFactory({ membership: { role: TenantUserRole.OWNER } }), targetTenant], + }); + + const variables = { + input: { token, id: targetTenant.membership.id }, + }; + const data = { + declineTenantInvitation: { + ok: true, + }, + }; + const requestMock = composeMockedQueryResult(declineTenantInvitationMutation, { + variables, + data, + }); + + const currentUserRefetchData = { + ...user, + tenants: [user.tenants![0]], + }; + const refetchMock = fillCommonQueryWithUser(currentUserRefetchData); + + requestMock.newData = jest.fn(() => ({ + data, + })); + + refetchMock.newData = jest.fn(() => ({ + data: { + currentUser: currentUserRefetchData, + }, + })); + + const apolloMocks = [fillCommonQueryWithUser(user), requestMock, refetchMock]; + + render(, { routerProps, apolloMocks }); + + expect(await screen.findByText(/Decline/i)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /decline/i })); + + expect(requestMock.newData).toHaveBeenCalled(); + expect(refetchMock.newData).toHaveBeenCalled(); + + const toast = await screen.findByTestId('toast-1'); + + expect(trackEvent).toHaveBeenCalledWith('tenantInvitation', 'decline', targetTenant.id); + expect(toast).toHaveTextContent('🎉 Invitation declined!'); + }); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/index.ts b/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/index.ts new file mode 100644 index 000000000..4d142b3e7 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/index.ts @@ -0,0 +1 @@ +export { TenantInvitation as default } from './tenantInvitation.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/tenantInvitation.component.tsx b/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/tenantInvitation.component.tsx new file mode 100644 index 000000000..c070a6f18 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/tenantInvitation.component.tsx @@ -0,0 +1,134 @@ +import { useMutation } from '@apollo/client'; +import { useCommonQuery } from '@sb/webapp-api-client/providers'; +import { Button } from '@sb/webapp-core/components/buttons'; +import { Card, CardContent, CardHeader, CardTitle } from '@sb/webapp-core/components/cards'; +import { PageLayout } from '@sb/webapp-core/components/pageLayout'; +import { RoutesConfig } from '@sb/webapp-core/config/routes'; +import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; +import { trackEvent } from '@sb/webapp-core/services/analytics'; +import { useToast } from '@sb/webapp-core/toast'; +import { useCallback, useEffect } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { useGenerateTenantPath, useTenants } from '../../hooks'; +import { acceptTenantInvitationMutation, declineTenantInvitationMutation } from './tenantInvitation.graphql'; + +export type InvitationPathParams = { + token: string; +}; + +export const TenantInvitation = () => { + const params = useParams(); + const { reload: reloadCommonQuery } = useCommonQuery(); + const tenants = useTenants(); + const navigate = useNavigate(); + const generateTenantPath = useGenerateTenantPath(); + const generateLocalePath = useGenerateLocalePath(); + const { toast } = useToast(); + const intl = useIntl(); + const { token } = params; + + const acceptSuccessMessage = intl.formatMessage({ + id: 'Tenant Invitation / Accept / Success message', + defaultMessage: '🎉 Invitation accepted!', + }); + + const declineSuccessMessage = intl.formatMessage({ + id: 'Tenant Invitation / Decline / Success message', + defaultMessage: '🎉 Invitation declined!', + }); + + const tenant = tenants.find((t) => t?.membership?.invitationToken === token); + + const [commitAcceptMutation, { loading: acceptLoading }] = useMutation(acceptTenantInvitationMutation, { + onCompleted: () => { + reloadCommonQuery(); + trackEvent('tenantInvitation', 'accept', tenant?.id); + toast({ description: acceptSuccessMessage }); + if (tenant) navigate(generateTenantPath(RoutesConfig.home, { tenantId: tenant?.id })); + }, + }); + + const handleAccept = useCallback(() => { + if (!token || !tenant) return; + commitAcceptMutation({ + variables: { + input: { + token, + id: tenant.membership.id, + }, + }, + }); + }, [token]); + + const [commitDeclineMutation, { loading: declineLoading }] = useMutation(declineTenantInvitationMutation, { + onCompleted: () => { + reloadCommonQuery(); + trackEvent('tenantInvitation', 'decline', tenant?.id); + toast({ description: declineSuccessMessage }); + navigate(generateLocalePath(RoutesConfig.home)); + }, + }); + + const handleDecline = useCallback(() => { + if (!token || !tenant) return; + commitDeclineMutation({ + variables: { + input: { + token, + id: tenant.membership.id, + }, + }, + }); + }, [token]); + + let redirectPath: string | null = null; + + if (!tenant) { + redirectPath = generateLocalePath(RoutesConfig.home); + } else if (tenant.membership?.invitationAccepted) { + redirectPath = generateTenantPath(RoutesConfig.home, { tenantId: tenant.id }); + } + + useEffect(() => { + if (redirectPath) { + navigate(redirectPath); + } + }, [redirectPath]); + + if (!tenant || redirectPath) { + return null; + } + + const isLoading = acceptLoading || declineLoading; + + return ( + + + + + + + + +
+ +
+
+ + +
+
+
+
+ ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/tenantInvitation.graphql.ts b/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/tenantInvitation.graphql.ts new file mode 100644 index 000000000..796ffd64a --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/tenantInvitation.graphql.ts @@ -0,0 +1,17 @@ +import { gql } from '@sb/webapp-api-client/graphql'; + +export const acceptTenantInvitationMutation = gql(/* GraphQL */ ` + mutation acceptTenantInvitationMutation($input: AcceptTenantInvitationMutationInput!) { + acceptTenantInvitation(input: $input) { + ok + } + } +`); + +export const declineTenantInvitationMutation = gql(/* GraphQL */ ` + mutation declineTenantInvitationMutation($input: DeclineTenantInvitationMutationInput!) { + declineTenantInvitation(input: $input) { + ok + } + } +`); diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/tenantInvitation.stories.tsx b/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/tenantInvitation.stories.tsx new file mode 100644 index 000000000..13fc39077 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantInvitation/tenantInvitation.stories.tsx @@ -0,0 +1,57 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { TenantType as TenantTypeType } from '@sb/webapp-api-client/constants'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { RoutesConfig } from '@sb/webapp-core/config/routes'; +import { getLocalePath } from '@sb/webapp-core/utils'; +import { StoryFn, StoryObj } from '@storybook/react'; +import { Route, Routes } from 'react-router'; + +import { tenantFactory } from '../../tests/factories/tenant'; +import { createMockRouterProps } from '../../tests/utils/rendering'; +import { withProviders } from '../../utils/storybook'; +import { TenantInvitation } from './tenantInvitation.component'; + +const invitationToken = 'invitation-token'; +const routePath = RoutesConfig.tenantInvitation; + +const Template: StoryFn = () => { + return ( + + } /> + + ); +}; + +export default { + title: 'Tenants / TenantInvitation', + component: TenantInvitation, +}; + +export const Default: StoryObj = { + render: Template, + decorators: [ + withProviders({ + routerProps: createMockRouterProps(routePath, { + token: invitationToken, + tenantId: '', + }), + apolloMocks: [ + fillCommonQueryWithUser( + currentUserFactory({ + tenants: [ + tenantFactory({ membership: { role: TenantUserRole.OWNER } }), + tenantFactory({ + membership: { + role: TenantUserRole.MEMBER, + invitationAccepted: false, + invitationToken, + }, + type: TenantTypeType.ORGANIZATION, + }), + ], + }) + ), + ], + }), + ], +}; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/index.ts b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/index.ts new file mode 100644 index 000000000..076abba30 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/index.ts @@ -0,0 +1 @@ +export { TenantSettings as default } from './tenantSettings.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantGeneralSettings/__tests__/tenantGeneralSettings.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantGeneralSettings/__tests__/tenantGeneralSettings.component.spec.tsx new file mode 100644 index 000000000..827a39c87 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantGeneralSettings/__tests__/tenantGeneralSettings.component.spec.tsx @@ -0,0 +1,80 @@ +import { TenantType as TenantTypeField } from '@sb/webapp-api-client/constants'; +import { commonQueryCurrentUserQuery } from '@sb/webapp-api-client/providers'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { composeMockedQueryResult } from '@sb/webapp-api-client/tests/utils'; +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { RoutesConfig } from '../../../../config/routes'; +import { tenantFactory } from '../../../../tests/factories/tenant'; +import { createMockRouterProps, render } from '../../../../tests/utils/rendering'; +import { TenantGeneralSettings } from '../tenantGeneralSettings.component'; +import { updateTenantMutation } from '../tenantGeneralSettings.graphql'; + +describe('TenantGeneralSettings: Component', () => { + const Component = () => ; + + it('should commit update mutation', async () => { + const user = currentUserFactory({ + tenants: [ + tenantFactory({ + name: 'name', + id: '1', + }), + ], + }); + const commonQueryMock = fillCommonQueryWithUser(user); + + const variables = { + input: { id: '1', name: 'name - new item name' }, + }; + + const data = { + createTenant: { + tenant: variables.input, + }, + }; + + const requestMock = composeMockedQueryResult(updateTenantMutation, { + variables, + data, + }); + + const currentUserRefetchData = { + ...user, + tenants: [ + ...(user.tenants ?? []), + tenantFactory({ + id: '1', + name: variables.input.name, + type: TenantTypeField.ORGANIZATION, + }), + ], + }; + + const refetchMock = composeMockedQueryResult(commonQueryCurrentUserQuery, { + data: currentUserRefetchData, + }); + + requestMock.newData = jest.fn(() => ({ + data, + })); + + refetchMock.newData = jest.fn(() => ({ + data: { + currentUser: currentUserRefetchData, + }, + })); + + const routerProps = createMockRouterProps(RoutesConfig.tenant.settings.general, { tenantId: '1' }); + + render(, { apolloMocks: [commonQueryMock, requestMock, refetchMock], routerProps }); + + await userEvent.type(await screen.findByPlaceholderText(/name/i), ' - new item name'); + await userEvent.click(screen.getByRole('button', { name: /save/i })); + expect(requestMock.newData).toHaveBeenCalled(); + const toast = await screen.findByTestId('toast-1'); + + expect(toast).toHaveTextContent('🎉 Organization updated successfully!'); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantGeneralSettings/index.ts b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantGeneralSettings/index.ts new file mode 100644 index 000000000..899659c3c --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantGeneralSettings/index.ts @@ -0,0 +1 @@ +export { TenantGeneralSettings as default } from './tenantGeneralSettings.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantGeneralSettings/tenantGeneralSettings.component.tsx b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantGeneralSettings/tenantGeneralSettings.component.tsx new file mode 100644 index 000000000..b5877106e --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantGeneralSettings/tenantGeneralSettings.component.tsx @@ -0,0 +1,79 @@ +import { useMutation } from '@apollo/client'; +import { TenantType } from '@sb/webapp-api-client/constants'; +import { useCommonQuery } from '@sb/webapp-api-client/providers'; +import { PageHeadline } from '@sb/webapp-core/components/pageHeadline'; +import { trackEvent } from '@sb/webapp-core/services/analytics'; +import { useToast } from '@sb/webapp-core/toast'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { TenantDangerZone } from '../../../components/tenantDangerZone'; +import { TenantForm } from '../../../components/tenantForm'; +import { TenantFormFields } from '../../../components/tenantForm/tenantForm.component'; +import { useCurrentTenant } from '../../../providers'; +import { updateTenantMutation } from './tenantGeneralSettings.graphql'; + +export const TenantGeneralSettings = () => { + const { data: currentTenant } = useCurrentTenant(); + const { reload: reloadCommonQuery } = useCommonQuery(); + const { toast } = useToast(); + const intl = useIntl(); + + const successMessage = intl.formatMessage({ + id: 'Tenant form / UpdateTenant / Success message', + defaultMessage: '🎉 Organization updated successfully!', + }); + + const failMessage = intl.formatMessage({ + id: 'Membership Entry / UpdateTenant / Fail message', + defaultMessage: 'Unable to change the organization data.', + }); + + const isOrganizationType = currentTenant?.type === TenantType.ORGANIZATION; + + const [commitUpdateMutation, { loading, error }] = useMutation(updateTenantMutation, { + onCompleted: (data) => { + const id = data.updateTenant?.tenant?.id; + reloadCommonQuery(); + trackEvent('tenant', 'edit', id); + toast({ description: successMessage }); + }, + onError: () => { + toast({ description: failMessage, variant: 'destructive' }); + }, + }); + + const onFormSubmit = (formData: TenantFormFields) => { + if (!currentTenant) return; + + commitUpdateMutation({ + variables: { + input: { + id: currentTenant.id, + name: formData.name, + }, + }, + }); + }; + + return ( +
+ } + subheader={ + + } + /> + + + {isOrganizationType && } +
+ ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantGeneralSettings/tenantGeneralSettings.graphql.ts b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantGeneralSettings/tenantGeneralSettings.graphql.ts new file mode 100644 index 000000000..7bf2ab12f --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantGeneralSettings/tenantGeneralSettings.graphql.ts @@ -0,0 +1,12 @@ +import { gql } from '@sb/webapp-api-client/graphql'; + +export const updateTenantMutation = gql(/* GraphQL */ ` + mutation updateTenantMutation($input: UpdateTenantMutationInput!) { + updateTenant(input: $input) { + tenant { + id + name + } + } + } +`); diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/__tests__/tenantMembers.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/__tests__/tenantMembers.component.spec.tsx new file mode 100644 index 000000000..dfcbcc0d4 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/__tests__/tenantMembers.component.spec.tsx @@ -0,0 +1,65 @@ +import { TenantType } from '@sb/webapp-api-client/constants'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { Tabs } from '@sb/webapp-core/components/tabs'; +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { RoutesConfig } from '../../../../config/routes'; +import { tenantFactory } from '../../../../tests/factories/tenant'; +import { createMockRouterProps, render } from '../../../../tests/utils/rendering'; +import { TenantMembers } from '../tenantMembers.component'; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => { + return { + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, + }; +}); + +const tenantId = 'testId'; + +describe('TenantMembers: Component', () => { + const Component = () => ( + + + + ); + + it('should render alert for personal account', async () => { + const tenant = tenantFactory({ id: tenantId, type: TenantType.PERSONAL }); + const routerProps = createMockRouterProps(RoutesConfig.tenant.settings.general, { tenantId }); + + const apolloMocks = [fillCommonQueryWithUser(currentUserFactory({ tenants: [tenant] }))]; + + render(, { apolloMocks, routerProps }); + + expect(await screen.findByTestId('tenant-members-alert')).toBeInTheDocument(); + expect(await screen.findByTestId('tenant-members-create-button')).toBeInTheDocument(); + }); + + it('should handle new tenant click', async () => { + const tenant = tenantFactory({ id: tenantId, type: TenantType.PERSONAL }); + const routerProps = createMockRouterProps(RoutesConfig.tenant.settings.general, { tenantId }); + + const apolloMocks = [fillCommonQueryWithUser(currentUserFactory({ tenants: [tenant] }))]; + + render(, { apolloMocks, routerProps }); + + const newTenantButton = await screen.findByTestId('tenant-members-create-button'); + await userEvent.click(newTenantButton); + + expect(mockNavigate).toHaveBeenCalledWith('/en/add-tenant'); + }); + + it('should render members with invitation form', async () => { + const tenant = tenantFactory({ id: tenantId, type: TenantType.ORGANIZATION }); + const routerProps = createMockRouterProps(RoutesConfig.tenant.settings.general, { tenantId }); + + const apolloMocks = [fillCommonQueryWithUser(currentUserFactory({ tenants: [tenant] }))]; + + render(, { apolloMocks, routerProps }); + + expect(await screen.findByTestId('tenant-members-list')).toBeInTheDocument(); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/index.ts b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/index.ts new file mode 100644 index 000000000..469d95a18 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/index.ts @@ -0,0 +1 @@ +export { TenantMembers as default } from './tenantMembers.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/invitationForm/__tests__/invitationForm.component.spec.tsx b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/invitationForm/__tests__/invitationForm.component.spec.tsx new file mode 100644 index 000000000..1221ecce8 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/invitationForm/__tests__/invitationForm.component.spec.tsx @@ -0,0 +1,97 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; +import { currentUserFactory, fillCommonQueryWithUser } from '@sb/webapp-api-client/tests/factories'; +import { composeMockedQueryResult } from '@sb/webapp-api-client/tests/utils'; +import { trackEvent } from '@sb/webapp-core/services/analytics'; +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { tenantMembersListQuery } from '../../../../../components/tenantMembersList/tenantMembersList.graphql'; +import { tenantFactory } from '../../../../../tests/factories/tenant'; +import { render } from '../../../../../tests/utils/rendering'; +import { createTenantInvitation } from '../invitationForm.graphql'; +import { InvitationForm } from '../invotationForm.component'; + +jest.mock('@sb/webapp-core/services/analytics'); + +describe('InvitationForm: Component', () => { + const Component = () => ; + + it('should display empty form', async () => { + const { waitForApolloMocks } = render(); + await waitForApolloMocks(); + const emailValue = (await screen.findByLabelText(/email/i)).getAttribute('value'); + expect(emailValue).toBe(''); + + const roleValue = (await screen.findByLabelText(/role/i)).getAttribute('value'); + expect(roleValue).toBe(null); + }); + + describe('action completes successfully', () => { + it('should commit mutation', async () => { + const tenants = [tenantFactory({ membership: { role: TenantUserRole.MEMBER } })]; + const currentUser = currentUserFactory({ tenants }); + + const emailValue = 'example@example.com'; + const roleValue = TenantUserRole.MEMBER; + + const variables = { + input: { + email: emailValue, + role: roleValue, + tenantId: tenants[0].id, + }, + }; + + const data = { + createTenantInvitation: { + email: emailValue, + role: roleValue, + }, + }; + const requestMock = composeMockedQueryResult(createTenantInvitation, { + variables, + data, + }); + + const refetchData = { + tenant: { + userMemberships: [], + }, + }; + + const refetchMock = composeMockedQueryResult(tenantMembersListQuery, { + data: refetchData, + variables: { + id: tenants[0].id, + }, + }); + + requestMock.newData = jest.fn(() => ({ + data, + })); + + refetchMock.newData = jest.fn(() => ({ + data: refetchData, + })); + + const apolloMocks = [fillCommonQueryWithUser(currentUser), requestMock, refetchMock]; + + const { waitForApolloMocks } = render(, { apolloMocks }); + + await waitForApolloMocks(0); + + await userEvent.type(await screen.findByLabelText(/email/i), emailValue); + expect(await screen.findByText('Member')).toBeInTheDocument(); + await userEvent.selectOptions(screen.getByRole('combobox', { name: '', hidden: true }), TenantUserRole.MEMBER); + await userEvent.click(screen.getByRole('button', { name: 'Invite' })); + + expect(requestMock.newData).toHaveBeenCalled(); + expect(refetchMock.newData).toHaveBeenCalled(); + + const toast = await screen.findByTestId('toast-1'); + + expect(trackEvent).toHaveBeenCalledWith('tenantInvitation', 'invite', tenants[0].id); + expect(toast).toHaveTextContent('🎉 User invited successfully!'); + }); + }); +}); diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/invitationForm/index.ts b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/invitationForm/index.ts new file mode 100644 index 000000000..2b8267b5d --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/invitationForm/index.ts @@ -0,0 +1 @@ +export * from './invotationForm.component'; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/invitationForm/invitationForm.graphql.ts b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/invitationForm/invitationForm.graphql.ts new file mode 100644 index 000000000..078b68aa2 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/invitationForm/invitationForm.graphql.ts @@ -0,0 +1,10 @@ +import { gql } from '@sb/webapp-api-client/graphql'; + +export const createTenantInvitation = gql(/* GraphQL */ ` + mutation createTenantInvitationMutation($input: CreateTenantInvitationMutationInput!) { + createTenantInvitation(input: $input) { + email + role + } + } +`); diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/invitationForm/invotationForm.component.tsx b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/invitationForm/invotationForm.component.tsx new file mode 100644 index 000000000..b1cf999dd --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/invitationForm/invotationForm.component.tsx @@ -0,0 +1,49 @@ +import { useMutation } from '@apollo/client'; +import { trackEvent } from '@sb/webapp-core/services/analytics'; +import { useToast } from '@sb/webapp-core/toast'; +import { useIntl } from 'react-intl'; + +import { TenantInvitationForm, type TenantInvitationFormFields } from '../../../../components/tenantInvitationForm'; +import { tenantMembersListQuery } from '../../../../components/tenantMembersList/tenantMembersList.graphql'; +import { useCurrentTenant } from '../../../../providers'; +import { createTenantInvitation } from './invitationForm.graphql'; + +export const InvitationForm = () => { + const { toast } = useToast(); + const intl = useIntl(); + const currentTenant = useCurrentTenant(); + + const successMessage = intl.formatMessage({ + id: 'Tenant Members / Invitation form / Success message', + defaultMessage: '🎉 User invited successfully!', + }); + + const [commitTenantInvitationMutation, { error, loading: loadingMutation }] = useMutation(createTenantInvitation, { + refetchQueries: () => [ + { + query: tenantMembersListQuery, + variables: { + id: currentTenant.data!.id, + }, + }, + ], + onCompleted: () => { + trackEvent('tenantInvitation', 'invite', currentTenant.data?.id); + toast({ description: successMessage }); + }, + }); + + const onInvitationFormSubmit = async (formData: TenantInvitationFormFields) => { + await commitTenantInvitationMutation({ + variables: { + input: { + email: formData.email, + role: formData.role, + tenantId: currentTenant.data!.id, + }, + }, + }); + }; + + return ; +}; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/tenantMembers.component.tsx b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/tenantMembers.component.tsx new file mode 100644 index 000000000..8ce3aa955 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantMembers/tenantMembers.component.tsx @@ -0,0 +1,60 @@ +import { TenantType } from '@sb/webapp-api-client/constants'; +import { Alert } from '@sb/webapp-core/components/alert'; +import { Button } from '@sb/webapp-core/components/buttons'; +import { PageHeadline } from '@sb/webapp-core/components/pageHeadline'; +import { TabsContent } from '@sb/webapp-core/components/tabs'; +import { RoutesConfig as RootRoutesConfig } from '@sb/webapp-core/config/routes'; +import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; +import { Paragraph } from '@sb/webapp-core/theme/typography'; +import { Plus } from 'lucide-react'; +import { FormattedMessage } from 'react-intl'; +import { useNavigate } from 'react-router-dom'; + +import { TenantMembersList } from '../../../components/tenantMembersList'; +import { RoutesConfig } from '../../../config/routes'; +import { useGenerateTenantPath } from '../../../hooks'; +import { useCurrentTenant } from '../../../providers'; +import { InvitationForm } from './invitationForm'; + +export const TenantMembers = () => { + const generateTenantPath = useGenerateTenantPath(); + const generateLocalePath = useGenerateLocalePath(); + const navigate = useNavigate(); + const isPersonal = useCurrentTenant().data?.type === TenantType.PERSONAL; + + const handleNewTenantClick = () => { + navigate(generateLocalePath(RootRoutesConfig.addTenant)); + }; + + return ( + +
+ } + subheader={ + + } + /> + {isPersonal ? ( + + + + + + + ) : ( +
+ + +
+ )} +
+
+ ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantSettings.component.tsx b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantSettings.component.tsx new file mode 100644 index 000000000..443e5600c --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/routes/tenantSettings/tenantSettings.component.tsx @@ -0,0 +1,44 @@ +import { PageHeadline } from '@sb/webapp-core/components/pageHeadline'; +import { PageLayout } from '@sb/webapp-core/components/pageLayout'; +import { Tabs, TabsList, TabsTrigger } from '@sb/webapp-core/components/tabs'; +import { RoutesConfig as FinancesRoutesConfig } from '@sb/webapp-finances/config/routes'; +import { FormattedMessage } from 'react-intl'; +import { Link, Outlet, useLocation } from 'react-router-dom'; + +import { RoutesConfig } from '../../config/routes'; +import { useGenerateTenantPath } from '../../hooks'; + +export const TenantSettings = () => { + const location = useLocation(); + const generateTenantPath = useGenerateTenantPath(); + + return ( + + } + subheader={} + /> + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/tests/factories/tenant.ts b/packages/webapp-libs/webapp-tenants/src/tests/factories/tenant.ts new file mode 100644 index 000000000..3fe4d57da --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/tests/factories/tenant.ts @@ -0,0 +1,19 @@ +import { TenantType as TenantTypeField } from '@sb/webapp-api-client/constants/tenant.types'; +import { TenantMembershipType, TenantType, TenantUserRole } from '@sb/webapp-api-client/graphql'; +import { createDeepFactory, makeId } from '@sb/webapp-api-client/tests/utils'; + +export const membershipFactory = createDeepFactory(() => ({ + id: makeId(32), + invitationAccepted: true, + invitationToken: makeId(32), + role: TenantUserRole.MEMBER, + __typename: 'TenantMembershipType', +})); + +export const tenantFactory = createDeepFactory(() => ({ + id: makeId(32), + name: 'Tenant Name', + membership: membershipFactory(), + type: TenantTypeField.PERSONAL, + __typename: 'TenantType', +})); diff --git a/packages/webapp-libs/webapp-tenants/src/tests/setupTests.ts b/packages/webapp-libs/webapp-tenants/src/tests/setupTests.ts new file mode 100644 index 000000000..170487a8f --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/tests/setupTests.ts @@ -0,0 +1,2 @@ +import '@sb/webapp-api-client/tests/setupTests'; +import '@sb/webapp-core/tests/setupTests'; diff --git a/packages/webapp-libs/webapp-tenants/src/tests/utils/rendering.tsx b/packages/webapp-libs/webapp-tenants/src/tests/utils/rendering.tsx new file mode 100644 index 000000000..fabb648bc --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/tests/utils/rendering.tsx @@ -0,0 +1,132 @@ +import * as apiUtils from '@sb/webapp-api-client/tests/utils/rendering'; +import * as corePath from '@sb/webapp-core/utils/path'; +import { Queries, queries } from '@testing-library/dom'; +import { RenderOptions, RenderResult, render, renderHook } from '@testing-library/react'; +import { ComponentClass, ComponentType, FC, PropsWithChildren, ReactElement } from 'react'; +import { MemoryRouterProps, generatePath } from 'react-router'; +import { Outlet, Route, Routes } from 'react-router-dom'; + +import { CurrentTenantProvider } from '../../providers'; + +export type WrapperProps = apiUtils.WrapperProps & { + TenantWrapper?: ComponentType; +}; + +/** + * Component that wraps `children` with [`CurrentTenantProvider`](./providers#currenttenantprovider) + * @param children + * @param TenantWrapper + * @constructor + */ +export function TenantsTestProviders({ children, TenantWrapper = CurrentTenantProvider }: WrapperProps) { + return {children}; +} + +/** @ignore */ +export function getWrapper( + WrapperComponent: ComponentClass | FC, + wrapperProps: WrapperProps +): { + wrapper: ComponentType; + waitForApolloMocks: (mockIndex?: number) => Promise; +} { + const { wrapper: ApiCoreWrapper, ...rest } = apiUtils.getWrapper(apiUtils.ApiTestProviders, wrapperProps); + const wrapper = (props: WrapperProps) => ( + + + + ); + return { + ...rest, + wrapper, + }; +} + +export type CustomRenderOptions< + Q extends Queries = typeof queries, + Container extends Element | DocumentFragment = HTMLElement, + BaseElement extends Element | DocumentFragment = Container, +> = RenderOptions & WrapperProps; + +/** + * Method that extends [`render`](https://testing-library.com/docs/react-testing-library/api#render) method from + * `@testing-library/react` package. It composes a wrapper using [`TenantsTestProviders`](#tenantstestproviders) + * component. + * + * @param ui + * @param options + */ +function customRender< + Q extends Queries = typeof queries, + Container extends Element | DocumentFragment = HTMLElement, + BaseElement extends Element | DocumentFragment = Container, +>( + ui: ReactElement, + options: CustomRenderOptions = {} +): RenderResult & { waitForApolloMocks: apiUtils.WaitForApolloMocks } { + const { wrapper, waitForApolloMocks } = getWrapper(TenantsTestProviders, options); + + return { + ...render(ui, { + ...options, + wrapper, + }), + waitForApolloMocks, + }; +} + +/** + * Method that extends [`renderHook`](https://testing-library.com/docs/react-testing-library/api#renderhook) method from + * `@testing-library/react` package. It composes a wrapper using [`TenantsTestProviders`](#tenantstestproviders) + * component. + * + * @param hook + * @param options + */ +function customRenderHook(hook: (initialProps: Props) => Result, options: CustomRenderOptions = {}) { + const { wrapper, waitForApolloMocks } = getWrapper(TenantsTestProviders, options); + + return { + ...renderHook(hook, { + ...options, + wrapper, + }), + waitForApolloMocks, + }; +} + +export { customRender as render, customRenderHook as renderHook }; + +export const createMockRouterProps = (pathName: string, params?: Record): MemoryRouterProps => { + return { + initialEntries: [ + generatePath(corePath.getLocalePath(corePath.getTenantPath(pathName)), { + lang: 'en', + ...(params ?? {}), + }), + ], + }; +}; + +/** @ignore */ +export const PLACEHOLDER_TEST_ID = 'content'; +/** @ignore */ +export const PLACEHOLDER_CONTENT = content; + +/** @ignore */ +const CurrentTenantRouteElement = () => ( + + + +); + +/** @ignore */ +export const CurrentTenantRouteWrapper = ({ children }: PropsWithChildren) => { + return ( + + }> + + + + ); +}; diff --git a/packages/webapp-libs/webapp-tenants/src/types/index.d.ts b/packages/webapp-libs/webapp-tenants/src/types/index.d.ts new file mode 100644 index 000000000..5c8ab6acd --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/types/index.d.ts @@ -0,0 +1 @@ +import '@sb/webapp-core/types/styled'; diff --git a/packages/webapp-libs/webapp-tenants/src/utils/storybook.tsx b/packages/webapp-libs/webapp-tenants/src/utils/storybook.tsx new file mode 100644 index 000000000..8ff4f71a7 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/src/utils/storybook.tsx @@ -0,0 +1,15 @@ +import { StoryFn } from '@storybook/react'; + +import { TenantsTestProviders, WrapperProps, getWrapper } from '../tests/utils/rendering'; + +export function withProviders(wrapperProps: WrapperProps = {}) { + return (StoryComponent: StoryFn) => { + const { wrapper: WrapperComponent } = getWrapper(TenantsTestProviders, wrapperProps); + + return ( + + + + ); + }; +} diff --git a/packages/webapp-libs/webapp-tenants/tsconfig.json b/packages/webapp-libs/webapp-tenants/tsconfig.json new file mode 100644 index 000000000..8122543a9 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/webapp-libs/webapp-tenants/tsconfig.lib.json b/packages/webapp-libs/webapp-tenants/tsconfig.lib.json new file mode 100644 index 000000000..f806fc0f0 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/tsconfig.lib.json @@ -0,0 +1,32 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "strict": true, + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "esModuleInterop": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": [ + "jest.config.ts", + "src/**/*.spec.ts", + "src/**/*.spec.tsx", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/tests/**/*.ts", + "src/tests/**/*.tsx" + ] +} diff --git a/packages/webapp-libs/webapp-tenants/tsconfig.spec.json b/packages/webapp-libs/webapp-tenants/tsconfig.spec.json new file mode 100644 index 000000000..27777e4d7 --- /dev/null +++ b/packages/webapp-libs/webapp-tenants/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"], + "esModuleInterop": true, + "jsx": "react-jsx" + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx", + "src/**/*.d.ts", + "src/tests/**/*.ts", + "src/tests/**/*.tsx" + ] +} diff --git a/packages/webapp/package.json b/packages/webapp/package.json index bda234bda..b08d1c271 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -41,6 +41,7 @@ "@sb/webapp-finances": "workspace:*", "@sb/webapp-generative-ai": "workspace:*", "@sb/webapp-notifications": "workspace:*", + "@sb/webapp-tenants": "workspace:*", "@storybook/addon-essentials": "^8.0.9", "@storybook/addon-measure": "^8.0.9", "@storybook/addon-themes": "^8.0.10", diff --git a/packages/webapp/src/app/app.component.tsx b/packages/webapp/src/app/app.component.tsx index 80e2ce7d4..60003f46f 100644 --- a/packages/webapp/src/app/app.component.tsx +++ b/packages/webapp/src/app/app.component.tsx @@ -1,3 +1,4 @@ +import { TenantUserRole } from '@sb/webapp-api-client'; import { DemoItem, DemoItems, PrivacyPolicy, TermsAndConditions } from '@sb/webapp-contentful/routes'; import { DEFAULT_LOCALE, translationMessages } from '@sb/webapp-core/config/i18n'; import { CrudDemoItem } from '@sb/webapp-crud-demo/routes'; @@ -15,6 +16,14 @@ import { TransactionsHistoryContent, } from '@sb/webapp-finances/routes'; import { SaasIdeas } from '@sb/webapp-generative-ai/routes'; +import { TenantAuthRoute } from '@sb/webapp-tenants/components/routes/tenantAuthRoute'; +import { + AddTenantForm, + TenantGeneralSettings, + TenantInvitation, + TenantMembers, + TenantSettings, +} from '@sb/webapp-tenants/routes'; import { IntlProvider } from 'react-intl'; import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; @@ -24,7 +33,7 @@ import { PasswordReset } from '../routes/auth/passwordReset'; import ValidateOtp from '../routes/auth/validateOtp'; import { AnonymousRoute, AuthRoute } from '../shared/components/routes'; import { ConfirmEmail, Home, Login, Logout, NotFound, Profile, Signup } from './asyncComponents'; -import { LANG_PREFIX, RoutesConfig } from './config/routes'; +import { LANG_PREFIX, RoutesConfig, TENANT_PREFIX } from './config/routes'; import { ValidRoutesProviders } from './providers'; export const App = () => { @@ -42,29 +51,39 @@ export const App = () => { } /> } /> - }> + }> } /> - } /> + }> + }> + } /> + } /> + + }> + }> + } /> + } /> + } + /> + + } /> + } /> + } /> + + } /> + } /> + } /> } /> } /> - }> - }> - } /> - } /> - } - /> - - } /> - } /> - } /> - - } /> - } /> } /> } /> + + }> + } /> + } /> + } /> } /> }> diff --git a/packages/webapp/src/app/config/routes.ts b/packages/webapp/src/app/config/routes.ts index d30030533..877bcac07 100644 --- a/packages/webapp/src/app/config/routes.ts +++ b/packages/webapp/src/app/config/routes.ts @@ -4,12 +4,15 @@ import { getLocalePath } from '@sb/webapp-core/utils/path'; import { RoutesConfig as CrudDemoRoutesConfig } from '@sb/webapp-crud-demo/config/routes'; import { RoutesConfig as FinancesRoutesConfig } from '@sb/webapp-finances/config/routes'; import { RoutesConfig as GenerativeAIRoutesConfig } from '@sb/webapp-generative-ai/config/routes'; +import { RoutesConfig as TenantsRoutesConfig } from '@sb/webapp-tenants/config/routes'; export const LANG_PREFIX = `/:lang?/*`; +export const TENANT_PREFIX = `/:lang?/:tenantId?/*`; export const RoutesConfig = { ...CoreRoutesConfig, documents: 'documents', + ...TenantsRoutesConfig, ...GenerativeAIRoutesConfig, ...ContentfulRoutesConfig, ...CrudDemoRoutesConfig, diff --git a/packages/webapp/src/app/providers/validRoutesProvider/validRoutesProviders.tsx b/packages/webapp/src/app/providers/validRoutesProvider/validRoutesProviders.tsx index fbae81881..671df24f4 100644 --- a/packages/webapp/src/app/providers/validRoutesProvider/validRoutesProviders.tsx +++ b/packages/webapp/src/app/providers/validRoutesProvider/validRoutesProviders.tsx @@ -1,7 +1,9 @@ +import { TooltipProvider } from '@sb/webapp-core/components/tooltip'; import { translationMessages } from '@sb/webapp-core/config/i18n'; import { useLocales } from '@sb/webapp-core/hooks'; import { ResponsiveThemeProvider } from '@sb/webapp-core/providers'; import { Toaster } from '@sb/webapp-core/toast'; +import { CurrentTenantProvider } from '@sb/webapp-tenants/providers'; import { useEffect } from 'react'; import { Helmet } from 'react-helmet-async'; import { FormattedMessage, IntlProvider } from 'react-intl'; @@ -41,9 +43,13 @@ export const ValidRoutesProviders = () => {
- - - + + + + + + + diff --git a/packages/webapp/src/routes/home/home.component.tsx b/packages/webapp/src/routes/home/home.component.tsx index 58087d545..446fa7b94 100644 --- a/packages/webapp/src/routes/home/home.component.tsx +++ b/packages/webapp/src/routes/home/home.component.tsx @@ -4,6 +4,7 @@ import { PageHeadline } from '@sb/webapp-core/components/pageHeadline'; import { PageLayout } from '@sb/webapp-core/components/pageLayout'; import { H4, Paragraph } from '@sb/webapp-core/components/typography'; import { useGenerateLocalePath } from '@sb/webapp-core/hooks'; +import { useGenerateTenantPath } from '@sb/webapp-tenants/hooks'; import { AlertCircle, ArrowUpRight } from 'lucide-react'; import { Helmet } from 'react-helmet-async'; import { FormattedMessage, useIntl } from 'react-intl'; @@ -19,6 +20,8 @@ type DashboardItem = { export const Home = () => { const intl = useIntl(); const generateLocalePath = useGenerateLocalePath(); + const generateTenantPath = useGenerateTenantPath(); + const dashboardItems: DashboardItem[] = [ { title: intl.formatMessage({ @@ -29,7 +32,7 @@ export const Home = () => { defaultMessage: 'Example of single payment form.', id: 'Home / Payments / Subtitle', }), - link: generateLocalePath(RoutesConfig.finances.paymentConfirm), + link: generateTenantPath(RoutesConfig.finances.paymentConfirm), }, { title: intl.formatMessage({ @@ -40,7 +43,7 @@ export const Home = () => { defaultMessage: 'Example of subscription management.', id: 'Home / Subscriptions / Subtitle', }), - link: generateLocalePath(RoutesConfig.subscriptions.currentSubscription.index), + link: generateTenantPath(RoutesConfig.subscriptions.currentSubscription.index), }, { title: intl.formatMessage({ diff --git a/packages/webapp/src/shared/components/layout/header/header.component.tsx b/packages/webapp/src/shared/components/layout/header/header.component.tsx index e68643d44..fc90c7302 100644 --- a/packages/webapp/src/shared/components/layout/header/header.component.tsx +++ b/packages/webapp/src/shared/components/layout/header/header.component.tsx @@ -1,15 +1,18 @@ +import { useCommonQuery } from '@sb/webapp-api-client/providers'; import { Button, Link as ButtonLink, ButtonVariant } from '@sb/webapp-core/components/buttons'; import { Popover, PopoverContent, PopoverTrigger } from '@sb/webapp-core/components/popover'; import { useGenerateLocalePath, useOpenState } from '@sb/webapp-core/hooks'; import { useTheme } from '@sb/webapp-core/hooks/useTheme/useTheme'; import { cn } from '@sb/webapp-core/lib/utils'; import { Notifications } from '@sb/webapp-notifications'; +import { TenantSwitch } from '@sb/webapp-tenants/components/tenantSwitch'; import { LogOut, Menu, Sun, User } from 'lucide-react'; import { HTMLAttributes, useContext } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import { RoutesConfig } from '../../../../app/config/routes'; import notificationTemplates from '../../../constants/notificationTemplates'; +import getNotificationEvents from '../../../events/notificationEvents'; import { useAuth } from '../../../hooks'; import { Avatar } from '../../avatar'; import { LayoutContext } from '../layout.context'; @@ -23,6 +26,8 @@ export const Header = (props: HeaderProps) => { const userDropdown = useOpenState(false); const generateLocalePath = useGenerateLocalePath(); const { setSideMenuOpen, isSideMenuOpen, isSidebarAvailable } = useContext(LayoutContext); + const { reload: reloadCommonQuery } = useCommonQuery(); + const events = getNotificationEvents({ reloadCommonQuery }); return (
@@ -43,6 +48,8 @@ export const Header = (props: HeaderProps) => {
)} + {isLoggedIn && } +