diff --git a/auth/auth_server/alembic/versions/76fd6db54f65_user_verified_column.py b/auth/auth_server/alembic/versions/76fd6db54f65_user_verified_column.py new file mode 100644 index 000000000..708b3fcd1 --- /dev/null +++ b/auth/auth_server/alembic/versions/76fd6db54f65_user_verified_column.py @@ -0,0 +1,50 @@ +# pylint: disable=C0103 +"""user verified column + +Revision ID: 76fd6db54f65 +Revises: 245d4295bbd6 +Create Date: 2024-11-18 09:57:42.103381 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import update, Integer, and_, Boolean +from sqlalchemy.orm import Session +from sqlalchemy.sql import table, column + +# revision identifiers, used by Alembic. +revision = '76fd6db54f65' +down_revision = '245d4295bbd6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('verified', Boolean(), nullable=False)) + user_table = table( + 'user', + column('deleted_at', Integer()), + column('verified', Boolean()) + ) + bind = op.get_bind() + session = Session(bind=bind) + try: + upd_stmt = update(user_table).values(verified=True).where(and_( + user_table.c.verified.is_(False), + user_table.c.deleted_at == 0 + )) + session.execute(upd_stmt) + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'verified') + # ### end Alembic commands ### diff --git a/auth/auth_server/controllers/signin.py b/auth/auth_server/controllers/signin.py index ae6bca93a..9627f247d 100644 --- a/auth/auth_server/controllers/signin.py +++ b/auth/auth_server/controllers/signin.py @@ -258,7 +258,7 @@ def signin(self, **kwargs): password=self._gen_password(), self_registration=True, token='', is_password_autogenerated=True) - + user.verified = True token_dict = self.token_ctl.create_token_by_user_id( user_id=user.id, ip=ip, provider=provider, register=register) diff --git a/auth/auth_server/controllers/token.py b/auth/auth_server/controllers/token.py index ed5130f8b..455a1daef 100644 --- a/auth/auth_server/controllers/token.py +++ b/auth/auth_server/controllers/token.py @@ -57,10 +57,14 @@ def _check_user(self, email, password=None, verification_code=None): if password: if user.password != hash_password(password, user.salt): raise ForbiddenException(Err.OA0037, []) + if not user.verified: + raise ForbiddenException(Err.OA0073, []) else: vc_used = self.use_verification_code(email, verification_code) if not vc_used: raise ForbiddenException(Err.OA0071, []) + elif not user.verified: + user.verified = True if not user.is_active: raise ForbiddenException(Err.OA0038, []) return user @@ -94,6 +98,8 @@ def create_token_by_user_id(self, **kwargs): raise NotFoundException(Err.OA0043, [user_id]) if not user.is_active: raise ForbiddenException(Err.OA0038, []) + if not user.verified: + raise ForbiddenException(Err.OA0073, []) return self.create_user_token(user, **kwargs) def create_user_token(self, user, **kwargs): diff --git a/auth/auth_server/controllers/user.py b/auth/auth_server/controllers/user.py index ce14b9407..8b3c085d8 100644 --- a/auth/auth_server/controllers/user.py +++ b/auth/auth_server/controllers/user.py @@ -37,6 +37,7 @@ def _get_input(self, **input_): is_password_autogenerated = input_.get( 'is_password_autogenerated', False) self_registration = input_.get('self_registration', False) + verified = input_.get('verified', False) if self_registration: root_type = self.session.query(Type).filter( and_( @@ -48,7 +49,7 @@ def _get_input(self, **input_): raise NotFoundException(Err.OA0064, []) type_id = root_type.id return (email, display_name, is_active, password, type_id, scope_id, - is_password_autogenerated) + is_password_autogenerated, verified) @staticmethod def _is_self_edit(user, user_to_edit_id): @@ -139,7 +140,7 @@ def create(self, **kwargs): self_registration = kwargs.pop('self_registration', False) self.check_create_restrictions(**kwargs) (email, display_name, is_active, password, type_id, - scope_id, is_password_autogenerated) = self._get_input( + scope_id, is_password_autogenerated, verified) = self._get_input( self_registration=self_registration, **kwargs) self._check_input(email, display_name, is_active, password, type_id, scope_id) @@ -163,7 +164,8 @@ def create(self, **kwargs): type_id=type_id, display_name=display_name, is_active=is_active, - is_password_autogenerated=is_password_autogenerated + is_password_autogenerated=is_password_autogenerated, + verified=verified ) self.session.add(user) try: diff --git a/auth/auth_server/exceptions.py b/auth/auth_server/exceptions.py index 716f41e27..83d640444 100644 --- a/auth/auth_server/exceptions.py +++ b/auth/auth_server/exceptions.py @@ -202,3 +202,6 @@ class Err(enum.Enum): OA0072 = [ "The verification code can be generated once in a minute" ] + OA0073 = [ + "Email not verified" + ] diff --git a/auth/auth_server/handlers/v2/users.py b/auth/auth_server/handlers/v2/users.py index 8d9082c81..898df5337 100644 --- a/auth/auth_server/handlers/v2/users.py +++ b/auth/auth_server/handlers/v2/users.py @@ -238,6 +238,8 @@ async def post(self, **url_params): scope_id: {type: string, description: User scope id (None scope means root)} token: {type: string, description: Token} + verified: {type: boolean, + description: "Is email verified?"} responses: 201: {description: Success (returns created user data)} 400: @@ -288,6 +290,9 @@ async def post(self, **url_params): unexpected_string = ', '.join(duplicates) raise OptHTTPError(400, Err.OA0022, [unexpected_string]) body.update(url_params) + if not self.check_cluster_secret(raises=False): + # verified users can be created only by secret + body.pop('verified', None) self._validate_params(**body) res = await run_task( self.controller.create, **body, **self.token, diff --git a/auth/auth_server/models/models.py b/auth/auth_server/models/models.py index c6de1b7a0..83a5f1b73 100644 --- a/auth/auth_server/models/models.py +++ b/auth/auth_server/models/models.py @@ -176,6 +176,8 @@ class User(Base, BaseMixin): info=ColumnPermissions.create_only) email = Column(String(256), nullable=False, index=True, info=ColumnPermissions.create_only) + verified = Column(Boolean, nullable=False, default=False, + info=ColumnPermissions.create_only) password = Column(String(64), nullable=False, info=ColumnPermissions.full) salt = Column(String(20), nullable=False) @@ -204,7 +206,8 @@ def to_json(self): def __init__(self, email=None, type_=None, password=None, salt=None, scope_id=None, type_id=None, display_name=None, is_active=True, slack_connected=False, - is_password_autogenerated=False, jira_connected=False): + is_password_autogenerated=False, jira_connected=False, + verified=False): if type_: self.type = type_ if type_id is not None: @@ -219,6 +222,7 @@ def __init__(self, email=None, type_=None, password=None, self.slack_connected = slack_connected self.is_password_autogenerated = is_password_autogenerated self.jira_connected = jira_connected + self.verified = verified def __repr__(self): return f'' diff --git a/auth/auth_server/tests/unittests/test_api_base.py b/auth/auth_server/tests/unittests/test_api_base.py index c6315b14a..535333827 100644 --- a/auth/auth_server/tests/unittests/test_api_base.py +++ b/auth/auth_server/tests/unittests/test_api_base.py @@ -58,8 +58,13 @@ def setUp(self, version='v1'): self.client = TestAuthBase.get_auth_client(version).Client( http_provider=http_provider) self.client.secret = secret + self.user_verified_mock = patch( + 'auth.auth_server.models.models.User.verified', + new_callable=PropertyMock, return_value=True) + self.user_verified_mock.start() def tearDown(self): + self.user_verified_mock.stop() DBFactory.clean_type(DBType.TEST) super().tearDown() diff --git a/auth/auth_server/tests/unittests/test_api_signin.py b/auth/auth_server/tests/unittests/test_api_signin.py index 5332b5309..836a7598b 100644 --- a/auth/auth_server/tests/unittests/test_api_signin.py +++ b/auth/auth_server/tests/unittests/test_api_signin.py @@ -241,3 +241,21 @@ def test_signin_existing_user_ms(self): token='token') self.assertEqual(code, 201) self.assertEqual(resp['user_email'], self.user_customer_email) + + @patch.dict(os.environ, {'MICROSOFT_OAUTH_CLIENT_ID': '123'}, clear=True) + def test_signin_user_verified(self): + self.user_verified_mock.stop() + patch('auth.auth_server.controllers.user.UserController.' + 'domain_blacklist').start() + token_info = (self.user_customer_email, self.user_customer_name) + with patch('auth.auth_server.controllers.signin.' + 'MicrosoftOauth2Provider.verify', + return_value=token_info): + code, resp = self.client.signin(provider='microsoft', + token='token') + self.assertEqual(code, 201) + + code, resp = self.client.user_get(resp['user_id']) + self.assertEqual(code, 200) + self.assertEqual(resp['email'], self.user_customer_email) + self.assertTrue(resp['verified']) diff --git a/auth/auth_server/tests/unittests/test_api_token.py b/auth/auth_server/tests/unittests/test_api_token.py index fa8550092..d4ebec2e8 100644 --- a/auth/auth_server/tests/unittests/test_api_token.py +++ b/auth/auth_server/tests/unittests/test_api_token.py @@ -115,6 +115,7 @@ def test_get_token_cluster_secret(self): extract_caveats(token_info['token']) def test_token_by_verification_code(self): + self.user_verified_mock.stop() session = self.db_session partner_salt = gen_salt() partner_password = 'pass1234' @@ -152,7 +153,9 @@ def test_token_by_verification_code(self): code, resp = self.client.post('tokens', body) self.assertEqual(code, 403) self.assertEqual(resp['error']['error_code'], 'OA0071') - + verified = session.query(User.verified).filter( + User.id == partner_user.id).scalar() + self.assertFalse(verified) body = { 'verification_code': code_4, 'email': partner_user.email, @@ -161,3 +164,6 @@ def test_token_by_verification_code(self): self.assertEqual(code, 201) self.assertEqual(resp['user_email'], partner_user.email) self.assertTrue(vc_4.deleted_at != 0) + verified = session.query(User.verified).filter( + User.id == partner_user.id).scalar() + self.assertTrue(verified) diff --git a/auth/auth_server/tests/unittests/test_api_user.py b/auth/auth_server/tests/unittests/test_api_user.py index 9c120c005..24ef9470f 100644 --- a/auth/auth_server/tests/unittests/test_api_user.py +++ b/auth/auth_server/tests/unittests/test_api_user.py @@ -857,3 +857,18 @@ def test_create_email_domain_whitelist(self): wl_email = 'user@example.com' code, response = self._create_user(email=wl_email) self.assertEqual(code, 400) + + def test_create_verified_user(self): + secret = self.client.secret + self.user_verified_mock.stop() + self.client.secret = None + code, user = self.client.user_create('test@email.com', 'pass1', + display_name='test', + verified=True) + self.assertFalse(user['verified']) + + self.client.secret = secret + code, user = self.client.user_create('test2@email.com', 'pass1', + display_name='test', + verified=True) + self.assertTrue(user['verified']) diff --git a/bumiworker/bumiworker/modules/archive/obsolete_snapshots.py b/bumiworker/bumiworker/modules/archive/obsolete_snapshots.py index 173c4b69c..126c93b03 100644 --- a/bumiworker/bumiworker/modules/archive/obsolete_snapshots.py +++ b/bumiworker/bumiworker/modules/archive/obsolete_snapshots.py @@ -25,10 +25,8 @@ def supported_cloud_types(self): def get_used_resources(self, now, cloud_account_id, cloud_config, obsolete_threshold): - snapshots_used_by_images = {} - if cloud_config.get('type') == AWS_CLOUD: - snapshots_used_by_images = self.get_snapshots_used_by_images( - now, cloud_config) + snapshots_used_by_images = self.get_snapshots_used_by_images( + now, cloud_config) snapshots_using_volumes = self._get_snapshots_using_volumes( now, cloud_account_id) snapshots_used_by_volumes = self.get_snapshots_used_by_volumes( diff --git a/bumiworker/bumiworker/modules/obsolete_snapshots_base.py b/bumiworker/bumiworker/modules/obsolete_snapshots_base.py index 60e232219..99e036159 100644 --- a/bumiworker/bumiworker/modules/obsolete_snapshots_base.py +++ b/bumiworker/bumiworker/modules/obsolete_snapshots_base.py @@ -35,6 +35,8 @@ def get_obsolete_resources(self, now, cloud_account_id, config, def merge_last_used(self, collection, to_merge): for resource_id, last_used in to_merge.items(): + if resource_id is None: + continue current_last_used = collection.get(resource_id) if current_last_used is None or current_last_used < last_used: collection[resource_id] = last_used @@ -74,7 +76,8 @@ def get_resources_pipeline(self, resource_type, excluded_ids, } ] - def get_snapshots_used_by_images(self, now, cloud_config): + @staticmethod + def _get_snapshots_used_by_images_from_cloud(now, cloud_config): adapter = CloudAdapter.get_adapter(cloud_config) images = [] snapshot_ids = set() @@ -95,6 +98,25 @@ def get_snapshots_used_by_images(self, now, cloud_config): snapshot_ids.add(snapshot_id) return {s: now for s in snapshot_ids} + def _get_snapshots_used_by_images_from_db(self, now, cloud_config): + snapshot_ids = self.mongo_client.restapi.resources.find({ + 'cloud_account_id': cloud_config['id'], + 'resource_type': 'Image', + 'active': True}, {'meta.snapshot_id': 1}) + return {s['meta.snapshot_id']: now for s in snapshot_ids + if 'meta.snapshot_id' in s} + + def get_snapshots_used_by_images(self, now, cloud_config): + cloud_funcs = { + 'alibaba_cnr': self._get_snapshots_used_by_images_from_cloud, + 'aws_cnr': self._get_snapshots_used_by_images_from_cloud, + 'gcp_cnr': self._get_snapshots_used_by_images_from_db, + } + func = cloud_funcs.get(cloud_config.get('type')) + if not func: + return {} + return func(now, cloud_config) + def get_snapshots_used_by_volumes(self, now, cloud_account_id, obsolete_threshold): last_seen = int((now - obsolete_threshold).timestamp()) diff --git a/bumiworker/bumiworker/modules/recommendations/obsolete_ips.py b/bumiworker/bumiworker/modules/recommendations/obsolete_ips.py index b5707276b..fd986a433 100644 --- a/bumiworker/bumiworker/modules/recommendations/obsolete_ips.py +++ b/bumiworker/bumiworker/modules/recommendations/obsolete_ips.py @@ -7,6 +7,7 @@ 'aws_cnr', 'azure_cnr', 'alibaba_cnr', + 'gcp_cnr', 'nebius' ] diff --git a/bumiworker/bumiworker/modules/recommendations/obsolete_snapshots.py b/bumiworker/bumiworker/modules/recommendations/obsolete_snapshots.py index 611584c69..a85e50d7a 100644 --- a/bumiworker/bumiworker/modules/recommendations/obsolete_snapshots.py +++ b/bumiworker/bumiworker/modules/recommendations/obsolete_snapshots.py @@ -7,6 +7,7 @@ AWS_CLOUD = 'aws_cnr' SUPPORTED_CLOUDS = [ 'aws_cnr', + 'gcp_cnr', 'nebius', ] @@ -32,10 +33,8 @@ def get_supported_clouds(self): def get_obsolete_resources(self, now, cloud_account_id, config, obsolete_threshold): - snapshots_used_by_images = {} - if config.get('type') == AWS_CLOUD: - snapshots_used_by_images = self.get_snapshots_used_by_images( - now, config) + snapshots_used_by_images = self.get_snapshots_used_by_images( + now, config) snapshots_used_by_volumes = self.get_snapshots_used_by_volumes( now, cloud_account_id, obsolete_threshold) diff --git a/diworker/diworker/importers/gcp.py b/diworker/diworker/importers/gcp.py index 4898ddddf..1f5ea9aea 100644 --- a/diworker/diworker/importers/gcp.py +++ b/diworker/diworker/importers/gcp.py @@ -165,6 +165,8 @@ def _get_resource_type_and_name(expense): if cost_type == 'regular': if 'Snapshot' in sku: r_type = 'Snapshot' + elif 'Image' in sku: + r_type = 'Image' elif 'PD Capacity' in sku: r_type = 'Volume' elif 'Storage' in sku: diff --git a/diworker/diworker/migrations/2024111309000000_gcp_image_resource_type.py b/diworker/diworker/migrations/2024111309000000_gcp_image_resource_type.py new file mode 100644 index 000000000..a95e14aac --- /dev/null +++ b/diworker/diworker/migrations/2024111309000000_gcp_image_resource_type.py @@ -0,0 +1,49 @@ +import logging +from pymongo import UpdateOne +from optscale_client.rest_api_client.client_v2 import Client as RestClient +from diworker.diworker.migrations.base import BaseMigration +""" +Change resource type from "Bucket" to "Image" for GCP images +""" +CHUNK_SIZE = 1000 +LOG = logging.getLogger(__name__) + + +class Migration(BaseMigration): + @property + def mongo_resources(self): + return self.db.resources + + @property + def rest_cl(self): + if self._rest_cl is None: + self._rest_cl = RestClient( + url=self.config_cl.restapi_url(), + secret=self.config_cl.cluster_secret()) + return self._rest_cl + + def get_cloud_accs(self): + cloud_accounts_ids = set() + _, organizations = self.rest_cl.organization_list({ + "with_connected_accounts": True, "is_demo": False}) + for org in organizations["organizations"]: + _, accounts = self.rest_cl.cloud_account_list( + org['id'], type="gcp_cnr") + for cloud_account in accounts["cloud_accounts"]: + if cloud_account["auto_import"]: + cloud_accounts_ids.add(cloud_account["id"]) + return cloud_accounts_ids + + def upgrade(self): + cloud_accs = self.get_cloud_accs() + for i, cloud_acc_id in enumerate(list(cloud_accs)): + LOG.info("Starting processing for cloud account %s (%s/%s)" % ( + cloud_acc_id, i + 1, len(cloud_accs))) + self.mongo_resources.update_many({ + "cloud_account_id": cloud_acc_id, + "resource_type": "Bucket", + "name": {"$regex": "Storage(.*) Image(.*)"} + }, {"$set": {"resource_type": "Image"}}) + + def downgrade(self): + pass diff --git a/docker_images/cleanmongodb/clean-mongo-db.py b/docker_images/cleanmongodb/clean-mongo-db.py index 9f9ff40b0..ee3bc053f 100644 --- a/docker_images/cleanmongodb/clean-mongo-db.py +++ b/docker_images/cleanmongodb/clean-mongo-db.py @@ -39,6 +39,8 @@ def __init__(self): self.mongo_client.arcee.artifact: ROWS_LIMIT, # linked to task_id self.mongo_client.arcee.run: ROWS_LIMIT, + # linked to organization_id + self.mongo_client.keeper.event: ROWS_LIMIT, # linked to profiling_token.token self.mongo_client.arcee.task: ROWS_LIMIT, self.mongo_client.arcee.dataset: ROWS_LIMIT, @@ -319,6 +321,9 @@ def _delete_by_organization(self, org_id, token, infra_token): if not token: self.update_cleaned_at(organization_id=org_id) return + keeper_collections = [ + self.mongo_client.keeper.event + ] # delete clusters resources restapi_collections = [self.mongo_client.restapi.resources] # delete ml objects @@ -331,6 +336,9 @@ def _delete_by_organization(self, org_id, token, infra_token): self.mongo_client.bulldozer.runset, self.mongo_client.bulldozer.runner] LOG.info('Start processing objects for organization %s', org_id) + for collection in keeper_collections: + self.limits[collection] = self.delete_in_chunks( + collection, 'organization_id', org_id) for collection in restapi_collections: self.limits[collection] = self.delete_in_chunks( collection, 'organization_id', org_id) @@ -375,7 +383,7 @@ def delete_by_organization(self): org_id, token, infra_token = info self._delete_by_organization(org_id, token, infra_token) info = self.get_deleted_organization_info() - LOG.info('Organizations ML objects processing is completed') + LOG.info('Organizations objects processing is completed') def _delete_by_cloud_account(self, cloud_account_id, is_demo): restapi_collections = [self.mongo_client.restapi.raw_expenses, diff --git a/herald/modules/email_generator/templates/verify_email.html b/herald/modules/email_generator/templates/verify_email.html new file mode 100644 index 000000000..9321fd65e --- /dev/null +++ b/herald/modules/email_generator/templates/verify_email.html @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/herald/send_templates.py b/herald/send_templates.py index 86a77046a..f833e1e2c 100755 --- a/herald/send_templates.py +++ b/herald/send_templates.py @@ -1526,6 +1526,20 @@ }, } }, + 'verify_email': { + 'email': ['serviceuser@hystax.com'], + 'subject': 'OptScale email verification', + 'template_type': 'verify_email', + 'template_params': { + 'texts': { + 'code': 263308 + }, + 'links': { + 'verify_button': 'https://172.22.20.8/email-verification' + '?email=serviceuser%40hystax.com&code=263308' + } + } + }, } REGEX_EMAIL = '^[a-z0-9!#$%&\'*+/=?`{|}~\^\-\+_()]+(\.[a-z0-9!#$%&\'*+/=' \ diff --git a/ngui/ui/package.json b/ngui/ui/package.json index 560b9615b..da13aa633 100644 --- a/ngui/ui/package.json +++ b/ngui/ui/package.json @@ -77,13 +77,6 @@ "vite": "^5.4.6", "vite-tsconfig-paths": "^5.0.1" }, - "pnpm": { - "overrides": { - "braces@<3.0.3": ">=3.0.3", - "ws@>=8.0.0 <8.17.1": ">=8.17.1", - "micromatch@<4.0.8": ">=4.0.8" - } - }, "scripts": { "start": "vite", "start:ci": "pnpm install --frozen-lockfile --ignore-scripts && pnpm start", diff --git a/ngui/ui/pnpm-lock.yaml b/ngui/ui/pnpm-lock.yaml index c8966a9b3..d086090a7 100644 --- a/ngui/ui/pnpm-lock.yaml +++ b/ngui/ui/pnpm-lock.yaml @@ -4,11 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - braces@<3.0.3: '>=3.0.3' - ws@>=8.0.0 <8.17.1: '>=8.17.1' - micromatch@<4.0.8: '>=4.0.8' - dependencies: '@analytics/google-analytics': specifier: ^1.0.5 diff --git a/ngui/ui/src/api/auth/actionCreators.ts b/ngui/ui/src/api/auth/actionCreators.ts index 548e987bd..0113af409 100644 --- a/ngui/ui/src/api/auth/actionCreators.ts +++ b/ngui/ui/src/api/auth/actionCreators.ts @@ -15,15 +15,15 @@ import { UPDATE_USER } from "./actionTypes"; -import { onSuccessSignIn, onSuccessCreateUser, onSuccessGetToken } from "./handlers"; +import { onSuccessSignIn, onSuccessGetToken } from "./handlers"; export const API_URL = getApiUrl("auth"); -export const getToken = ({ email, password, code }) => +export const getToken = ({ email, password, code, isTokenTemporary }) => apiAction({ url: `${API_URL}/tokens`, onSuccess: onSuccessGetToken({ - isTemporary: !!code + isTokenTemporary }), label: GET_TOKEN, params: { email, password, verification_code: code } @@ -40,7 +40,7 @@ export const signIn = (provider, params) => export const createUser = (name, email, password) => apiAction({ url: `${API_URL}/users`, - onSuccess: onSuccessCreateUser, + onSuccess: handleSuccess(CREATE_USER), label: CREATE_USER, params: { display_name: name, email, password } }); diff --git a/ngui/ui/src/api/auth/handlers.ts b/ngui/ui/src/api/auth/handlers.ts index 1e4f78980..8ef321414 100644 --- a/ngui/ui/src/api/auth/handlers.ts +++ b/ngui/ui/src/api/auth/handlers.ts @@ -1,12 +1,12 @@ import { GET_TOKEN, SET_TOKEN } from "./actionTypes"; export const onSuccessGetToken = - ({ isTemporary }) => + ({ isTokenTemporary }) => (data) => ({ type: SET_TOKEN, payload: { ...data, - isTemporary + isTokenTemporary }, label: GET_TOKEN }); @@ -16,13 +16,3 @@ export const onSuccessSignIn = (data) => ({ payload: data, label: GET_TOKEN }); - -export const onSuccessCreateUser = ({ id, token, email }) => ({ - type: SET_TOKEN, - payload: { - user_id: id, - token, - user_email: email - }, - label: GET_TOKEN -}); diff --git a/ngui/ui/src/api/auth/reducer.ts b/ngui/ui/src/api/auth/reducer.ts index 41f7a95d8..cb1d494ea 100644 --- a/ngui/ui/src/api/auth/reducer.ts +++ b/ngui/ui/src/api/auth/reducer.ts @@ -6,7 +6,7 @@ export const AUTH = "auth"; const reducer = (state = {}, action) => { switch (action.type) { case SET_TOKEN: { - const { token, user_id: userId, user_email: userEmail, isTemporary } = action.payload; + const { token, user_id: userId, user_email: userEmail, isTokenTemporary } = action.payload; const caveats = macaroon.processCaveats(macaroon.deserialize(token).getCaveats()); @@ -19,7 +19,7 @@ const reducer = (state = {}, action) => { * The use of a temporary token is a security measure to ensure that users update their passwords before gaining full access to the application. * This prevents users from accessing other parts of the application until their password has been successfully changed. */ - [isTemporary ? "temporaryToken" : "token"]: token, + [isTokenTemporary ? "temporaryToken" : "token"]: token, ...caveats } }; diff --git a/ngui/ui/src/api/index.ts b/ngui/ui/src/api/index.ts index a59b1c84c..09e4a5f4c 100644 --- a/ngui/ui/src/api/index.ts +++ b/ngui/ui/src/api/index.ts @@ -229,7 +229,8 @@ import { deleteMlArtifact, getMlDatasetLabels, getMlTaskTags, - restorePassword + restorePassword, + verifyEmail } from "./restapi"; import { RESTAPI } from "./restapi/reducer"; @@ -463,7 +464,8 @@ export { getMlDatasetLabels, getMlTaskTags, restorePassword, - updateUser + updateUser, + verifyEmail }; export { RESTAPI, AUTH, JIRA_BUS }; diff --git a/ngui/ui/src/api/restapi/actionCreators.ts b/ngui/ui/src/api/restapi/actionCreators.ts index 57a43e8c2..07f9211b2 100644 --- a/ngui/ui/src/api/restapi/actionCreators.ts +++ b/ngui/ui/src/api/restapi/actionCreators.ts @@ -327,7 +327,8 @@ import { GET_ML_DATASET_LABELS, SET_ML_TASK_TAGS, GET_ML_TASK_TAGS, - RESTORE_PASSWORD + RESTORE_PASSWORD, + VERIFY_EMAIL } from "./actionTypes"; import { onUpdateOrganizationOption, @@ -2912,3 +2913,13 @@ export const restorePassword = (email) => email } }); + +export const verifyEmail = (email) => + apiAction({ + url: `${API_URL}/verify_email`, + method: "POST", + label: VERIFY_EMAIL, + params: { + email + } + }); diff --git a/ngui/ui/src/api/restapi/actionTypes.ts b/ngui/ui/src/api/restapi/actionTypes.ts index 1f2529495..8f70dd069 100644 --- a/ngui/ui/src/api/restapi/actionTypes.ts +++ b/ngui/ui/src/api/restapi/actionTypes.ts @@ -455,3 +455,5 @@ export const UPDATE_LAYOUT = "UPDATE_LAYOUT"; export const DELETE_LAYOUT = "DELETE_LAYOUT"; export const RESTORE_PASSWORD = "RESTORE_PASSWORD"; + +export const VERIFY_EMAIL = "VERIFY_EMAIL"; diff --git a/ngui/ui/src/api/restapi/index.ts b/ngui/ui/src/api/restapi/index.ts index 777d5874c..56de3d857 100644 --- a/ngui/ui/src/api/restapi/index.ts +++ b/ngui/ui/src/api/restapi/index.ts @@ -215,7 +215,8 @@ import { deleteMlArtifact, getMlDatasetLabels, getMlTaskTags, - restorePassword + restorePassword, + verifyEmail } from "./actionCreators"; export { @@ -435,5 +436,6 @@ export { deleteMlArtifact, getMlDatasetLabels, getMlTaskTags, - restorePassword + restorePassword, + verifyEmail }; diff --git a/ngui/ui/src/components/AlreadyHaveAnAccountSignInMessage/AlreadyHaveAnAccountSignInMessage.tsx b/ngui/ui/src/components/AlreadyHaveAnAccountSignInMessage/AlreadyHaveAnAccountSignInMessage.tsx new file mode 100644 index 000000000..e97b8c0e2 --- /dev/null +++ b/ngui/ui/src/components/AlreadyHaveAnAccountSignInMessage/AlreadyHaveAnAccountSignInMessage.tsx @@ -0,0 +1,15 @@ +import Link from "@mui/material/Link"; +import Typography from "@mui/material/Typography"; +import { FormattedMessage } from "react-intl"; +import { Link as RouterLink } from "react-router-dom"; +import { LOGIN } from "urls"; + +const AlreadyHaveAnAccountSignInMessage = () => ( + + + + + +); + +export default AlreadyHaveAnAccountSignInMessage; diff --git a/ngui/ui/src/components/AlreadyHaveAnAccountSignInMessage/index.ts b/ngui/ui/src/components/AlreadyHaveAnAccountSignInMessage/index.ts new file mode 100644 index 000000000..3ba2d5367 --- /dev/null +++ b/ngui/ui/src/components/AlreadyHaveAnAccountSignInMessage/index.ts @@ -0,0 +1,3 @@ +import AlreadyHaveAnAccountSignInMessage from "./AlreadyHaveAnAccountSignInMessage"; + +export default AlreadyHaveAnAccountSignInMessage; diff --git a/ngui/ui/src/components/EmailVerification/EmailVerification.tsx b/ngui/ui/src/components/EmailVerification/EmailVerification.tsx new file mode 100644 index 000000000..b3dc2a1e3 --- /dev/null +++ b/ngui/ui/src/components/EmailVerification/EmailVerification.tsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import { Typography } from "@mui/material"; +import Link from "@mui/material/Link"; +import { Stack } from "@mui/system"; +import { FormattedMessage } from "react-intl"; +import { Link as RouterLink } from "react-router-dom"; +import Greeter from "components/Greeter"; +import ConfirmEmailVerificationCodeContainer from "containers/ConfirmEmailVerificationCodeContainer"; +import { HOME } from "urls"; +import { SPACING_2 } from "utils/layouts"; + +const CONFIRM_VERIFICATION_CODE = 0; +const EMAIL_VERIFICATION_SUCCESS = 1; + +const EmailVerification = () => { + const [step, setStep] = useState(CONFIRM_VERIFICATION_CODE); + + const stepContent = { + [CONFIRM_VERIFICATION_CODE]: ( + setStep(EMAIL_VERIFICATION_SUCCESS)} /> + ), + [EMAIL_VERIFICATION_SUCCESS]: ( + +
+ + + +
+
+ + + + + +
+
+ ) + }[step]; + + return ; +}; + +export default EmailVerification; diff --git a/ngui/ui/src/components/EmailVerification/index.ts b/ngui/ui/src/components/EmailVerification/index.ts new file mode 100644 index 000000000..3c3a1294b --- /dev/null +++ b/ngui/ui/src/components/EmailVerification/index.ts @@ -0,0 +1,3 @@ +import EmailVerification from "./EmailVerification"; + +export default EmailVerification; diff --git a/ngui/ui/src/components/SendVerificationCodeAgainCountdownMessage/SendVerificationCodeAgainCountdownMessage.tsx b/ngui/ui/src/components/SendVerificationCodeAgainCountdownMessage/SendVerificationCodeAgainCountdownMessage.tsx new file mode 100644 index 000000000..d18b265c6 --- /dev/null +++ b/ngui/ui/src/components/SendVerificationCodeAgainCountdownMessage/SendVerificationCodeAgainCountdownMessage.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; +import { Typography } from "@mui/material"; +import Link from "@mui/material/Link"; +import { FormattedMessage } from "react-intl"; +import { MILLISECONDS_IN_SECOND, SECONDS_IN_MINUTE } from "utils/datetime"; + +type CountdownMessageProps = { + onCountdownEnd: () => void; +}; + +type SendVerificationCodeAgainMessageProps = { + onSend: () => Promise; + sendingVerificationCode?: boolean; +}; + +const CountdownMessage = ({ onCountdownEnd }: CountdownMessageProps) => { + const [secondsLeft, setSecondsLeft] = useState(SECONDS_IN_MINUTE); + + useEffect(() => { + const timerId = setInterval(() => { + setSecondsLeft((prevSeconds) => { + if (prevSeconds <= 1) { + clearInterval(timerId); + onCountdownEnd(); + return 0; + } + return prevSeconds - 1; + }); + }, MILLISECONDS_IN_SECOND); + + return () => clearInterval(timerId); + }, [onCountdownEnd]); + + return ( + {chunks} }} + /> + ); +}; + +const SendVerificationCodeAgainMessage = ({ + onSend, + sendingVerificationCode = false +}: SendVerificationCodeAgainMessageProps) => { + const [codeCanBeSent, setCodeCanBeSent] = useState(false); + + const renderText = () => { + if (sendingVerificationCode) { + return ; + } + + if (codeCanBeSent) { + return ; + } + + return setCodeCanBeSent(true)} />; + }; + + return ( + + onSend().then(() => setCodeCanBeSent(false))} + component="button" + type="button" + sx={ + sendingVerificationCode || !codeCanBeSent + ? { + fontWeight: "normal", + color: (theme) => theme.palette.text.primary, + "&:hover": { + textDecoration: "none" + }, + cursor: "default" + } + : undefined + } + > + {renderText()} + + + ); +}; + +export default SendVerificationCodeAgainMessage; diff --git a/ngui/ui/src/components/SendVerificationCodeAgainCountdownMessage/index.ts b/ngui/ui/src/components/SendVerificationCodeAgainCountdownMessage/index.ts new file mode 100644 index 000000000..1a38c1c6d --- /dev/null +++ b/ngui/ui/src/components/SendVerificationCodeAgainCountdownMessage/index.ts @@ -0,0 +1,3 @@ +import SendVerificationCodeAgainMessage from "./SendVerificationCodeAgainCountdownMessage"; + +export default SendVerificationCodeAgainMessage; diff --git a/ngui/ui/src/components/SideModal/SideModal.tsx b/ngui/ui/src/components/SideModal/SideModal.tsx index e18f9cab4..1eb17c9c8 100644 --- a/ngui/ui/src/components/SideModal/SideModal.tsx +++ b/ngui/ui/src/components/SideModal/SideModal.tsx @@ -1,4 +1,4 @@ -import { ReactNode, SyntheticEvent, useState } from "react"; +import { ReactNode, SyntheticEvent, useEffect, useState } from "react"; import Drawer from "@mui/material/Drawer"; import SideModalHeader from "components/SideModalHeader"; import { SideModalHeaderProps } from "components/SideModalHeader/SideModalHeader"; @@ -24,6 +24,11 @@ const DrawerContent = ({ headerProps, handleClose, children }: DrawerContentProp const [isExpanded, setIsExpanded] = useState(showExpand); + useEffect(() => { + // Reset the expanded state when opening a new modal from an already open modal + setIsExpanded(showExpand); + }, [showExpand]); + const { classes, cx } = useStyles(); return ( diff --git a/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/ConfirmEmailVerificationCodeForm.tsx b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/ConfirmEmailVerificationCodeForm.tsx new file mode 100644 index 000000000..41dcba596 --- /dev/null +++ b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/ConfirmEmailVerificationCodeForm.tsx @@ -0,0 +1,32 @@ +import { FormProvider, useForm } from "react-hook-form"; +import AlreadyHaveAnAccountSignInMessage from "components/AlreadyHaveAnAccountSignInMessage"; +import SendEmailVerificationCodeAgainContainer from "containers/SendEmailVerificationCodeAgainContainer"; +import { getQueryParams } from "utils/network"; +import { FIELD_NAMES } from "./constants"; +import { CodeField, FormButtons } from "./FormElements"; +import { FormValues, ConfirmEmailVerificationCodeFormProps } from "./types"; + +const ConfirmEmailVerificationCodeForm = ({ onSubmit, isLoading = false }: ConfirmEmailVerificationCodeFormProps) => { + const { code } = getQueryParams() as { code: string }; + + const methods = useForm({ + defaultValues: { + [FIELD_NAMES.CODE]: code ?? "" + } + }); + + const { handleSubmit } = methods; + + return ( + +
+ + + + + +
+ ); +}; + +export default ConfirmEmailVerificationCodeForm; diff --git a/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/FormElements/CodeField.tsx b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/FormElements/CodeField.tsx new file mode 100644 index 000000000..361ef46ff --- /dev/null +++ b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/FormElements/CodeField.tsx @@ -0,0 +1,9 @@ +import { FormattedMessage } from "react-intl"; +import { TextInput } from "components/forms/common/fields"; +import { FIELD_NAMES } from "../constants"; + +const FIELD_NAME = FIELD_NAMES.CODE; + +const CodeField = () => } />; + +export default CodeField; diff --git a/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/FormElements/FormButtons.tsx b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/FormElements/FormButtons.tsx new file mode 100644 index 000000000..e57b84ebd --- /dev/null +++ b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/FormElements/FormButtons.tsx @@ -0,0 +1,19 @@ +import ButtonLoader from "components/ButtonLoader"; +import FormButtonsWrapper from "components/FormButtonsWrapper"; + +const FormButtons = ({ isLoading = false }) => ( + + + +); + +export default FormButtons; diff --git a/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/FormElements/index.ts b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/FormElements/index.ts new file mode 100644 index 000000000..facf8e054 --- /dev/null +++ b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/FormElements/index.ts @@ -0,0 +1,4 @@ +import CodeField from "./CodeField"; +import FormButtons from "./FormButtons"; + +export { CodeField, FormButtons }; diff --git a/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/constants.ts b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/constants.ts new file mode 100644 index 000000000..36894b48a --- /dev/null +++ b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/constants.ts @@ -0,0 +1,3 @@ +export const FIELD_NAMES = Object.freeze({ + CODE: "code" +}); diff --git a/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/index.ts b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/index.ts new file mode 100644 index 000000000..77bbf720c --- /dev/null +++ b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/index.ts @@ -0,0 +1,3 @@ +import ConfirmEmailVerificationCodeForm from "./ConfirmEmailVerificationCodeForm"; + +export default ConfirmEmailVerificationCodeForm; diff --git a/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/types.ts b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/types.ts new file mode 100644 index 000000000..0cdf3fd1b --- /dev/null +++ b/ngui/ui/src/components/forms/ConfirmEmailVerificationCodeForm/types.ts @@ -0,0 +1,10 @@ +import { FIELD_NAMES } from "./constants"; + +export type FormValues = { + [FIELD_NAMES.CODE]: string; +}; + +export type ConfirmEmailVerificationCodeFormProps = { + onSubmit: (data: FormValues) => void; + isLoading?: boolean; +}; diff --git a/ngui/ui/src/containers/ConfirmEmailVerificationCodeContainer/ConfirmEmailVerificationCodeContainer.tsx b/ngui/ui/src/containers/ConfirmEmailVerificationCodeContainer/ConfirmEmailVerificationCodeContainer.tsx new file mode 100644 index 000000000..c8d2486fd --- /dev/null +++ b/ngui/ui/src/containers/ConfirmEmailVerificationCodeContainer/ConfirmEmailVerificationCodeContainer.tsx @@ -0,0 +1,53 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { FormattedMessage } from "react-intl"; +import ConfirmEmailVerificationCodeForm from "components/forms/ConfirmEmailVerificationCodeForm"; +import { useNewAuthorization } from "hooks/useNewAuthorization"; +import VerifyEmailService from "services/VerifyEmailService"; +import { getQueryParams } from "utils/network"; + +type ConfirmEmailVerificationCodeContainerProps = { + onSuccess: () => void; +}; + +const ConfirmEmailVerificationCodeContainer = ({ onSuccess }: ConfirmEmailVerificationCodeContainerProps) => { + const { email } = getQueryParams() as { email: string }; + + const { useGetEmailVerificationCodeToken } = VerifyEmailService(); + + const { onGet: onGetEmailVerificationCodeToken, isLoading: isLoadingGetEmailVerificationCodeToken } = + useGetEmailVerificationCodeToken(); + + const { activateScope, isGetOrganizationsLoading, isCreateOrganizationLoading } = useNewAuthorization(); + + return ( + + + {chunks}, + email + }} + /> + + + {email} + + + onGetEmailVerificationCodeToken(email, code) + .then(() => + activateScope(email, { + getOnSuccessRedirectionPath: () => undefined + }) + ) + .then(onSuccess) + } + isLoading={isLoadingGetEmailVerificationCodeToken || isGetOrganizationsLoading || isCreateOrganizationLoading} + /> + + ); +}; + +export default ConfirmEmailVerificationCodeContainer; diff --git a/ngui/ui/src/containers/ConfirmEmailVerificationCodeContainer/index.ts b/ngui/ui/src/containers/ConfirmEmailVerificationCodeContainer/index.ts new file mode 100644 index 000000000..0218e16c4 --- /dev/null +++ b/ngui/ui/src/containers/ConfirmEmailVerificationCodeContainer/index.ts @@ -0,0 +1,3 @@ +import ConfirmEmailVerificationCodeContainer from "./ConfirmEmailVerificationCodeContainer"; + +export default ConfirmEmailVerificationCodeContainer; diff --git a/ngui/ui/src/containers/CreateNewPasswordContainer/CreateNewPasswordContainer.tsx b/ngui/ui/src/containers/CreateNewPasswordContainer/CreateNewPasswordContainer.tsx index 6dbc47f8e..a2de9f37d 100644 --- a/ngui/ui/src/containers/CreateNewPasswordContainer/CreateNewPasswordContainer.tsx +++ b/ngui/ui/src/containers/CreateNewPasswordContainer/CreateNewPasswordContainer.tsx @@ -2,6 +2,7 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { FormattedMessage } from "react-intl"; import CreateNewPasswordForm from "components/forms/CreateNewPasswordForm"; +import { useNewAuthorization } from "hooks/useNewAuthorization"; import ResetPasswordServices from "services/ResetPasswordServices"; import { getQueryParams } from "utils/network"; @@ -17,6 +18,8 @@ const CreateNewPasswordContainer = ({ onSuccess }: CreateNewPasswordContainerPro const { onUpdate: onUpdateUserPassword, isLoading: isUpdateUserPasswordLoading } = useUpdateUserPassword(); const { onGet: onGetNewToken, isLoading: isGetNewTokenLoading } = useGetNewToken(); + const { activateScope, isGetOrganizationsLoading, isCreateOrganizationLoading } = useNewAuthorization(); + return ( @@ -26,9 +29,16 @@ const CreateNewPasswordContainer = ({ onSuccess }: CreateNewPasswordContainerPro onSubmit={({ newPassword }) => onUpdateUserPassword(newPassword) .then(() => onGetNewToken(email, newPassword)) + .then(() => + activateScope(email, { + getOnSuccessRedirectionPath: () => undefined + }) + ) .then(() => onSuccess()) } - isLoading={isUpdateUserPasswordLoading || isGetNewTokenLoading} + isLoading={ + isUpdateUserPasswordLoading || isGetNewTokenLoading || isGetOrganizationsLoading || isCreateOrganizationLoading + } /> ); diff --git a/ngui/ui/src/containers/RecommendationsOverviewContainer/recommendations/ObsoleteIps.tsx b/ngui/ui/src/containers/RecommendationsOverviewContainer/recommendations/ObsoleteIps.tsx index b202ce9ce..e8d3a1112 100644 --- a/ngui/ui/src/containers/RecommendationsOverviewContainer/recommendations/ObsoleteIps.tsx +++ b/ngui/ui/src/containers/RecommendationsOverviewContainer/recommendations/ObsoleteIps.tsx @@ -3,9 +3,15 @@ import FormattedMoney from "components/FormattedMoney"; import RecommendationListItemResourceLabel from "components/RecommendationListItemResourceLabel"; import ObsoleteIpsModal from "components/SideModalManager/SideModals/recommendations/ObsoleteIpsModal"; import TextWithDataTestId from "components/TextWithDataTestId"; -import { ALIBABA_ECS_VPC, AWS_EC2_VPC, AZURE_NETWORK, NEBIUS_SERVICE } from "hooks/useRecommendationServices"; +import { + ALIBABA_ECS_VPC, + AWS_EC2_VPC, + AZURE_NETWORK, + GCP_COMPUTE_ENGINE, + NEBIUS_SERVICE +} from "hooks/useRecommendationServices"; import { detectedAt, possibleMonthlySavings, resource, resourceLocation } from "utils/columns"; -import { ALIBABA_CNR, AWS_CNR, AZURE_CNR, FORMATTED_MONEY_TYPES, NEBIUS } from "utils/constants"; +import { ALIBABA_CNR, AWS_CNR, AZURE_CNR, FORMATTED_MONEY_TYPES, GCP_CNR, NEBIUS } from "utils/constants"; import { EN_FULL_FORMAT, unixTimestampToDateTime } from "utils/datetime"; import BaseRecommendation, { CATEGORY_COST } from "./BaseRecommendation"; @@ -63,9 +69,9 @@ class ObsoleteIps extends BaseRecommendation { return { daysThreshold }; } - services = [AWS_EC2_VPC, AZURE_NETWORK, ALIBABA_ECS_VPC, NEBIUS_SERVICE]; + services = [AWS_EC2_VPC, AZURE_NETWORK, ALIBABA_ECS_VPC, NEBIUS_SERVICE, GCP_COMPUTE_ENGINE]; - appliedDataSources = [ALIBABA_CNR, AWS_CNR, AZURE_CNR, NEBIUS]; + appliedDataSources = [ALIBABA_CNR, AWS_CNR, AZURE_CNR, NEBIUS, GCP_CNR]; categories = [CATEGORY_COST]; diff --git a/ngui/ui/src/containers/RecommendationsOverviewContainer/recommendations/ObsoleteSnapshots.tsx b/ngui/ui/src/containers/RecommendationsOverviewContainer/recommendations/ObsoleteSnapshots.tsx index b70761904..3356653ad 100644 --- a/ngui/ui/src/containers/RecommendationsOverviewContainer/recommendations/ObsoleteSnapshots.tsx +++ b/ngui/ui/src/containers/RecommendationsOverviewContainer/recommendations/ObsoleteSnapshots.tsx @@ -1,9 +1,9 @@ import FormattedMoney from "components/FormattedMoney"; import RecommendationListItemResourceLabel from "components/RecommendationListItemResourceLabel"; import ObsoleteSnapshotsModal from "components/SideModalManager/SideModals/recommendations/ObsoleteSnapshotsModal"; -import { AWS_EC2, NEBIUS_SERVICE } from "hooks/useRecommendationServices"; +import { AWS_EC2, GCP_COMPUTE_ENGINE, NEBIUS_SERVICE } from "hooks/useRecommendationServices"; import { detectedAt, firstSeenOn, lastSeenUsed, possibleMonthlySavings, resource, resourceLocation } from "utils/columns"; -import { AWS_CNR, FORMATTED_MONEY_TYPES, NEBIUS } from "utils/constants"; +import { AWS_CNR, FORMATTED_MONEY_TYPES, GCP_CNR, NEBIUS } from "utils/constants"; import BaseRecommendation, { CATEGORY_COST } from "./BaseRecommendation"; const columns = [ @@ -41,9 +41,9 @@ class ObsoleteSnapshots extends BaseRecommendation { return { daysThreshold }; } - services = [AWS_EC2, NEBIUS_SERVICE]; + services = [AWS_EC2, NEBIUS_SERVICE, GCP_COMPUTE_ENGINE]; - appliedDataSources = [AWS_CNR, NEBIUS]; + appliedDataSources = [AWS_CNR, NEBIUS, GCP_CNR]; categories = [CATEGORY_COST]; diff --git a/ngui/ui/src/containers/SendEmailVerificationCodeAgainContainer/SendEmailVerificationCodeAgainContainer.tsx b/ngui/ui/src/containers/SendEmailVerificationCodeAgainContainer/SendEmailVerificationCodeAgainContainer.tsx new file mode 100644 index 000000000..bf589ef50 --- /dev/null +++ b/ngui/ui/src/containers/SendEmailVerificationCodeAgainContainer/SendEmailVerificationCodeAgainContainer.tsx @@ -0,0 +1,15 @@ +import SendVerificationCodeAgainMessage from "components/SendVerificationCodeAgainCountdownMessage"; +import VerifyEmailService from "services/VerifyEmailService"; +import { getQueryParams } from "utils/network"; + +const SendEmailVerificationCodeAgainContainer = () => { + const { email } = getQueryParams() as { email: string }; + + const { useSendEmailVerificationCode } = VerifyEmailService(); + + const { onSend, isLoading } = useSendEmailVerificationCode(); + + return onSend(email)} sendingVerificationCode={isLoading} />; +}; + +export default SendEmailVerificationCodeAgainContainer; diff --git a/ngui/ui/src/containers/SendEmailVerificationCodeAgainContainer/index.ts b/ngui/ui/src/containers/SendEmailVerificationCodeAgainContainer/index.ts new file mode 100644 index 000000000..23e337600 --- /dev/null +++ b/ngui/ui/src/containers/SendEmailVerificationCodeAgainContainer/index.ts @@ -0,0 +1,3 @@ +import SendEmailVerificationCodeAgainContainer from "./SendEmailVerificationCodeAgainContainer"; + +export default SendEmailVerificationCodeAgainContainer; diff --git a/ngui/ui/src/containers/SendVerificationCodeAgainContainer/SendVerificationCodeAgainContainer.tsx b/ngui/ui/src/containers/SendVerificationCodeAgainContainer/SendVerificationCodeAgainContainer.tsx index bcd60de12..9a2f89fa5 100644 --- a/ngui/ui/src/containers/SendVerificationCodeAgainContainer/SendVerificationCodeAgainContainer.tsx +++ b/ngui/ui/src/containers/SendVerificationCodeAgainContainer/SendVerificationCodeAgainContainer.tsx @@ -1,52 +1,7 @@ -import { useEffect, useRef, useState } from "react"; -import { Typography } from "@mui/material"; -import Link from "@mui/material/Link"; -import { FormattedMessage } from "react-intl"; +import SendVerificationCodeAgainMessage from "components/SendVerificationCodeAgainCountdownMessage"; import ResetPasswordServices from "services/ResetPasswordServices"; -import { SECONDS_IN_MINUTE } from "utils/datetime"; import { getQueryParams } from "utils/network"; -type CountdownMessageProps = { - onCountdownEnd: () => void; -}; - -const CountdownMessage = ({ onCountdownEnd }: CountdownMessageProps) => { - const [secondsLeft, setSecondsLeft] = useState(SECONDS_IN_MINUTE); - - const timerRef = useRef(null); - - useEffect(() => { - timerRef.current = setInterval(() => { - setSecondsLeft((prev) => prev - 1); - }, 1000); - - return () => { - if (timerRef.current !== null) { - clearInterval(timerRef.current); - timerRef.current = null; - } - }; - }, []); - - useEffect(() => { - if (secondsLeft <= 0) { - if (timerRef.current !== null) { - clearInterval(timerRef.current); - timerRef.current = null; - } - - onCountdownEnd(); - } - }, [onCountdownEnd, secondsLeft]); - - return ( - {chunks} }} - /> - ); -}; - const SendVerificationCodeAgainContainer = () => { const { email } = getQueryParams() as { email: string }; @@ -54,44 +9,7 @@ const SendVerificationCodeAgainContainer = () => { const { onSend, isLoading } = useSendVerificationCode(); - const [codeCanBeSent, setCodeCanBeSent] = useState(false); - - const renderText = () => { - if (isLoading) { - return ; - } - - if (codeCanBeSent) { - return ; - } - - return setCodeCanBeSent(true)} />; - }; - - return ( - - onSend(email).then(() => setCodeCanBeSent(false))} - component="button" - type="button" - sx={ - isLoading || !codeCanBeSent - ? { - fontWeight: "normal", - color: (theme) => theme.palette.text.primary, - "&:hover": { - textDecoration: "none" - }, - cursor: "default" - } - : undefined - } - > - {renderText()} - - - ); + return onSend(email)} sendingVerificationCode={isLoading} />; }; export default SendVerificationCodeAgainContainer; diff --git a/ngui/ui/src/hooks/useNewAuthorization.ts b/ngui/ui/src/hooks/useNewAuthorization.ts index 222a55aa9..910afb113 100644 --- a/ngui/ui/src/hooks/useNewAuthorization.ts +++ b/ngui/ui/src/hooks/useNewAuthorization.ts @@ -3,12 +3,14 @@ import { useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; import { getToken, getOrganizations, getInvitations, createOrganization, signIn, createUser, AUTH, RESTAPI } from "api"; import { GET_TOKEN, SIGN_IN, CREATE_USER } from "api/auth/actionTypes"; -import { GET_ORGANIZATIONS, GET_INVITATIONS, CREATE_ORGANIZATION } from "api/restapi/actionTypes"; +import { API } from "api/reducer"; +import { GET_ORGANIZATIONS, GET_INVITATIONS, CREATE_ORGANIZATION, VERIFY_EMAIL } from "api/restapi/actionTypes"; import { setScopeId } from "containers/OrganizationSelectorContainer/actionCreators"; import { SCOPE_ID } from "containers/OrganizationSelectorContainer/reducer"; -import { ACCEPT_INVITATIONS, HOME } from "urls"; +import VerifyEmailService from "services/VerifyEmailService"; +import { ACCEPT_INVITATIONS, EMAIL_VERIFICATION, HOME } from "urls"; import { trackEvent, GA_EVENT_CATEGORIES } from "utils/analytics"; -import { checkError } from "utils/api"; +import { checkError, isError } from "utils/api"; import { isEmpty } from "utils/arrays"; import { formQueryString, getQueryParams } from "utils/network"; import { useApiState } from "./useApiState"; @@ -18,6 +20,8 @@ export const PROVIDERS = Object.freeze({ MICROSOFT: "microsoft" }); +const EMAIL_NOT_VERIFIED_ERROR_CODE = "OA0073"; + // TODO - after Live Demo auth is updated: // - remove useAuthorization and rename this one // - refactor/generalize @@ -36,6 +40,9 @@ export const useNewAuthorization = () => { const { isLoading: isCreateUserLoading } = useApiState(CREATE_USER); const { isLoading: isSignInLoading } = useApiState(SIGN_IN); + const { useSendEmailVerificationCode } = VerifyEmailService(); + const { onSend: sendEmailVerificationCode } = useSendEmailVerificationCode(); + const redirectOnSuccess = useCallback( (to) => { navigate(to); @@ -84,6 +91,10 @@ export const useNewAuthorization = () => { ? getOnSuccessRedirectionPath({ userEmail: email }) : getQueryParams().next || HOME; + if (!redirectPath) { + return Promise.resolve(); + } + return redirectOnSuccess(redirectPath); }) .then(() => { @@ -106,7 +117,31 @@ export const useNewAuthorization = () => { setIsAuthInProgress(true); dispatch((_, getState) => dispatch(getToken({ email, password })) - .then(() => checkError(GET_TOKEN, getState())) + .then(() => { + const state = getState(); + + if (isError(GET_TOKEN, getState())) { + const { error_code: errorCode } = state?.[API]?.[GET_TOKEN]?.status?.response?.data?.error ?? {}; + + if (errorCode === EMAIL_NOT_VERIFIED_ERROR_CODE) { + return sendEmailVerificationCode(email).then(() => { + if (isError(VERIFY_EMAIL, getState())) { + return Promise.reject(); + } + + navigate( + `${EMAIL_VERIFICATION}?${formQueryString({ + email + })}` + ); + return Promise.reject(); + }); + } + + return Promise.reject(); + } + return Promise.resolve(); + }) .then(() => dispatch(getInvitations())) .then(() => checkError(GET_INVITATIONS, getState())) .then(() => getState()?.[RESTAPI]?.[GET_INVITATIONS]) @@ -123,32 +158,39 @@ export const useNewAuthorization = () => { }) ); }, - [dispatch, activateScope, navigate] + [dispatch, sendEmailVerificationCode, navigate, activateScope] ); const register = useCallback( - ({ name, email, password }, { getOnSuccessRedirectionPath }) => { + ({ name, email, password }) => { setIsRegistrationInProgress(true); dispatch((_, getState) => dispatch(createUser(name, email, password)) .then(() => checkError(CREATE_USER, getState())) - .then(() => dispatch(getInvitations())) - .then(() => checkError(GET_INVITATIONS, getState())) - .then(() => getState()?.[RESTAPI]?.[GET_INVITATIONS]) - .then((pendingInvitations) => { - if (isEmpty(pendingInvitations)) { - const { userEmail } = getState()?.[AUTH]?.[GET_TOKEN] ?? {}; - Promise.resolve(activateScope(userEmail, { getOnSuccessRedirectionPath })); - } else { - navigate(`${ACCEPT_INVITATIONS}?${formQueryString(getQueryParams())}`); - } + .then(() => { + trackEvent({ category: GA_EVENT_CATEGORIES.USER, action: "Registered", label: "optscale" }); + return Promise.resolve(); }) + .then(() => + sendEmailVerificationCode(email).then(() => { + if (isError(VERIFY_EMAIL, getState())) { + return Promise.reject(); + } + + navigate( + `${EMAIL_VERIFICATION}?${formQueryString({ + email + })}` + ); + return Promise.reject(); + }) + ) .catch(() => { setIsRegistrationInProgress(false); }) ); }, - [dispatch, activateScope, navigate] + [dispatch, navigate, sendEmailVerificationCode] ); const thirdPartySignIn = useCallback( diff --git a/ngui/ui/src/pages/EmailVerification/EmailVerification.tsx b/ngui/ui/src/pages/EmailVerification/EmailVerification.tsx new file mode 100644 index 000000000..f9f69628b --- /dev/null +++ b/ngui/ui/src/pages/EmailVerification/EmailVerification.tsx @@ -0,0 +1,5 @@ +import EmailVerificationComponent from "components/EmailVerification"; + +const EmailVerification = () => ; + +export default EmailVerification; diff --git a/ngui/ui/src/pages/EmailVerification/index.ts b/ngui/ui/src/pages/EmailVerification/index.ts new file mode 100644 index 000000000..3c3a1294b --- /dev/null +++ b/ngui/ui/src/pages/EmailVerification/index.ts @@ -0,0 +1,3 @@ +import EmailVerification from "./EmailVerification"; + +export default EmailVerification; diff --git a/ngui/ui/src/services/ResetPasswordServices.ts b/ngui/ui/src/services/ResetPasswordServices.ts index 9451b3e3f..958658732 100644 --- a/ngui/ui/src/services/ResetPasswordServices.ts +++ b/ngui/ui/src/services/ResetPasswordServices.ts @@ -37,7 +37,8 @@ const useGetVerificationCodeToken = () => { dispatch( getToken({ email, - code + code, + isTokenTemporary: true }) ).then(() => { if (!isError(GET_TOKEN, getState())) { diff --git a/ngui/ui/src/services/VerifyEmailService.ts b/ngui/ui/src/services/VerifyEmailService.ts new file mode 100644 index 000000000..0f48c255f --- /dev/null +++ b/ngui/ui/src/services/VerifyEmailService.ts @@ -0,0 +1,64 @@ +import { useCallback } from "react"; +import { useDispatch } from "react-redux"; +import { getToken, verifyEmail } from "api"; +import { GET_TOKEN } from "api/auth/actionTypes"; +import { VERIFY_EMAIL } from "api/restapi/actionTypes"; +import { useApiState } from "hooks/useApiState"; +import { isError } from "utils/api"; + +const useSendEmailVerificationCode = () => { + const dispatch = useDispatch(); + + const { isLoading } = useApiState(VERIFY_EMAIL); + + const onSend = useCallback( + (email: string) => + new Promise((resolve, reject) => { + dispatch((_, getState) => { + dispatch(verifyEmail(email)).then(() => { + if (!isError(VERIFY_EMAIL, getState())) { + return resolve(); + } + return reject(); + }); + }); + }), + [dispatch] + ); + + return { onSend, isLoading }; +}; + +const useGetEmailVerificationCodeToken = () => { + const dispatch = useDispatch(); + + const { isLoading } = useApiState(GET_TOKEN); + + const onGet = (email: string, code: string) => + new Promise((resolve, reject) => { + dispatch((_, getState) => { + dispatch( + getToken({ + email, + code + }) + ).then(() => { + if (!isError(GET_TOKEN, getState())) { + return resolve(); + } + return reject(); + }); + }); + }); + + return { onGet, isLoading }; +}; + +function VerifyEmailService() { + return { + useSendEmailVerificationCode, + useGetEmailVerificationCodeToken + }; +} + +export default VerifyEmailService; diff --git a/ngui/ui/src/translations/en-US/app.json b/ngui/ui/src/translations/en-US/app.json index 65d79b0da..fef2176da 100644 --- a/ngui/ui/src/translations/en-US/app.json +++ b/ngui/ui/src/translations/en-US/app.json @@ -628,6 +628,8 @@ "edit{}": "Edit {value}", "eitherMinOrMaxMustBeDefined": "Either minimimum or maximum must be defined", "email": "Email", + "emailVerificationDescription": "To verify your email, please enter the verification code sent to:", + "emailVerifiedSuccessfully": "Email has been verified successfully!", "employee": "Employee", "enabled": "Enabled", "endDate": "End date", diff --git a/ngui/ui/src/translations/en-US/errors.json b/ngui/ui/src/translations/en-US/errors.json index f085d6141..a5344ebbb 100644 --- a/ngui/ui/src/translations/en-US/errors.json +++ b/ngui/ui/src/translations/en-US/errors.json @@ -22,6 +22,7 @@ "OA0061": "Database error: {0}", "OA0062": "This resource requires an authorization token", "OA0071": "Email or code is invalid", + "OA0073": "Email is not verified. Check your inbox for a verification link.", "OE0002": "{0} {1} is not found", "OE0003": "Forbidden", "OE0004": "Type error: {0}", diff --git a/ngui/ui/src/urls.ts b/ngui/ui/src/urls.ts index 2320ccab8..3f30f914c 100644 --- a/ngui/ui/src/urls.ts +++ b/ngui/ui/src/urls.ts @@ -37,6 +37,7 @@ export const INVITED = "/invited"; export const ACCEPT_INVITATION = "/accept-invitation"; export const ACCEPT_INVITATIONS = "/accept-invitations"; export const PASSWORD_RECOVERY = "/password-recovery"; +export const EMAIL_VERIFICATION = "/email-verification"; export const POOL_BASE = "pool"; export const POOLS_BASE = "pools"; diff --git a/ngui/ui/src/utils/datetime.ts b/ngui/ui/src/utils/datetime.ts index 74bfe3449..8e9ac694d 100644 --- a/ngui/ui/src/utils/datetime.ts +++ b/ngui/ui/src/utils/datetime.ts @@ -63,7 +63,7 @@ import { capitalize } from "./strings"; * @property {number} endDate - end date timestamp in seconds */ -const MILLISECONDS_IN_SECOND = 1000; +export const MILLISECONDS_IN_SECOND = 1000; const MILLISECONDS_IN_MINUTE = 60 * MILLISECONDS_IN_SECOND; const MILLISECONDS_IN_HOUR = 60 * MILLISECONDS_IN_MINUTE; const MILLISECONDS_IN_DAY = 24 * MILLISECONDS_IN_HOUR; diff --git a/ngui/ui/src/utils/routes/emailVerificationRoute.ts b/ngui/ui/src/utils/routes/emailVerificationRoute.ts new file mode 100644 index 000000000..9ccdd6d7f --- /dev/null +++ b/ngui/ui/src/utils/routes/emailVerificationRoute.ts @@ -0,0 +1,14 @@ +import { EMAIL_VERIFICATION } from "urls"; +import BaseRoute from "./baseRoute"; + +class EmailVerificationRoute extends BaseRoute { + isTokenRequired = false; + + page = "EmailVerification"; + + link = EMAIL_VERIFICATION; + + layout = null; +} + +export default new EmailVerificationRoute(); diff --git a/ngui/ui/src/utils/routes/index.ts b/ngui/ui/src/utils/routes/index.ts index 4d9e2dd24..ccd826f96 100644 --- a/ngui/ui/src/utils/routes/index.ts +++ b/ngui/ui/src/utils/routes/index.ts @@ -30,6 +30,7 @@ import editAssignmentRuleRoute from "./editAssignmentRuleRoute"; import editBIExportRoute from "./editBIExportRoute"; import editMlMetricRoute from "./editMlMetricRoute"; import editPowerScheduleRoute from "./editPowerScheduleRoute"; +import emailVerificationRoute from "./emailVerificationRoute"; import environmentsRoute from "./environmentsRoute"; import eventsRoute from "./eventsRoute"; import expensesMapRoute from "./expensesMapRoute"; @@ -190,6 +191,7 @@ export const routes = [ mlEditRunArtifactRoute, mlCreateRunArtifactRoute, mlEditArtifactRoute, + emailVerificationRoute, // React router 6.x does not require the not found route (*) to be at the end, // but the matchPath hook that is used in the DocsPanel component seems to honor the order. // Moving it to the bottom for "safety" reasons. diff --git a/optscale_client/auth_client/client_v2.py b/optscale_client/auth_client/client_v2.py index 1269b3980..d760858aa 100644 --- a/optscale_client/auth_client/client_v2.py +++ b/optscale_client/auth_client/client_v2.py @@ -93,12 +93,13 @@ def token_meta_get(self, digests: list): return code, response def user_create(self, email, password, display_name, is_active=True, - id=None): + id=None, verified=False): body = { "email": email, "password": password, "display_name": display_name, - "is_active": is_active + "is_active": is_active, + "verified": verified } if id is None: return self.post(self.user_url(), body) diff --git a/optscale_client/rest_api_client/client_v2.py b/optscale_client/rest_api_client/client_v2.py index 98a83de2b..f1049ab47 100644 --- a/optscale_client/rest_api_client/client_v2.py +++ b/optscale_client/rest_api_client/client_v2.py @@ -2213,3 +2213,11 @@ def profiling_token_info_url(profiling_token): def profiling_token_info_get(self, profiling_token): return self.get(self.profiling_token_info_url(profiling_token)) + + @staticmethod + def verify_email_url(): + return 'verify_email' + + def verify_email(self, email): + url = self.verify_email_url() + return self.post(url, {'email': email}) diff --git a/rest_api/rest_api_server/constants.py b/rest_api/rest_api_server/constants.py index f7ab0e011..5fa614cbf 100644 --- a/rest_api/rest_api_server/constants.py +++ b/rest_api/rest_api_server/constants.py @@ -228,6 +228,7 @@ class UrlsV2(Urls): r"tasks/(?P[^/]+)/tags", 'restore_password': r"%s/restore_password", 'profiling_token_info': r"%s/profiling_tokens/(?P[^/]+)", + 'verify_email': r"%s/verify_email", }) diff --git a/rest_api/rest_api_server/controllers/base.py b/rest_api/rest_api_server/controllers/base.py index c76a75f5e..ba0419fbf 100644 --- a/rest_api/rest_api_server/controllers/base.py +++ b/rest_api/rest_api_server/controllers/base.py @@ -566,9 +566,10 @@ def get_user_info(self): raise WrongArgumentsException(Err.OE0435, [str(ex)]) return user - def create_auth_user(self, email, password, name): + def create_auth_user(self, email, password, name, verified=False): try: - _, user = self.auth_client.user_create(email, password, name) + _, user = self.auth_client.user_create( + email, password, name, verified=verified) except requests.exceptions.HTTPError as ex: err_code = ex.response.json()['error']['error_code'] if err_code == 'OA0042': diff --git a/rest_api/rest_api_server/controllers/live_demo.py b/rest_api/rest_api_server/controllers/live_demo.py index 70da711a7..00080429f 100644 --- a/rest_api/rest_api_server/controllers/live_demo.py +++ b/rest_api/rest_api_server/controllers/live_demo.py @@ -36,6 +36,7 @@ from rest_api.rest_api_server.utils import ( gen_id, encode_config, timestamp_to_day_start) from optscale_client.herald_client.client_v2 import Client as HeraldClient +from optscale_client.auth_client.client_v2 import Client as AuthClient LOG = logging.getLogger(__name__) @@ -1218,7 +1219,8 @@ def _bind_auth_users(self, organization_id, ignore_list, auth_users_data): if not auth_user: email, name, password = self._get_auth_user_params( auth_user_data) - auth_user = self.create_auth_user(email, password, name) + auth_user = self.create_auth_user( + email, password, name, verified=True) employee_user_bindings.append({ Employee.id.name: new_employee_id, @@ -1416,7 +1418,7 @@ def _create(self, pregenerate=False): return live_demo org_name, name, email, password = self._get_basic_params() LOG.info('%s %s %s %s' % (org_name, name, email, password)) - auth_user = self.create_auth_user(email, password, name) + auth_user = self.create_auth_user(email, password, name, verified=True) organization, employee = RegisterController( self.session, self._config, self.token).add_organization( org_name, auth_user, is_demo=True) diff --git a/rest_api/rest_api_server/controllers/verify_email.py b/rest_api/rest_api_server/controllers/verify_email.py new file mode 100644 index 000000000..84edec0ab --- /dev/null +++ b/rest_api/rest_api_server/controllers/verify_email.py @@ -0,0 +1,38 @@ +import logging +from rest_api.rest_api_server.controllers.restore_password import RestorePasswordController +from rest_api.rest_api_server.controllers.base_async import BaseAsyncControllerWrapper +from rest_api.rest_api_server.utils import query_url +from optscale_client.herald_client.client_v2 import Client as HeraldClient + +LOG = logging.getLogger(__name__) + + +class VerifyEmailController(RestorePasswordController): + + def _generate_link(self, email, code): + host = self._config.public_ip() + params = query_url(email=email, code=code) + return f'https://{host}/email-verification{params}' + + def send_verification_email(self, email, code): + link = self._generate_link(email, code) + HeraldClient( + url=self._config.herald_url(), + secret=self._config.cluster_secret() + ).email_send( + [email], 'OptScale email verification', + template_type="verify_email", + template_params={ + 'texts': { + 'code': code, + }, + 'links': { + 'verify_button': link + } + } + ) + + +class VerifyEmailAsyncController(BaseAsyncControllerWrapper): + def _get_controller_class(self): + return VerifyEmailController diff --git a/rest_api/rest_api_server/handlers/v2/__init__.py b/rest_api/rest_api_server/handlers/v2/__init__.py index 8522c9394..6e028378e 100644 --- a/rest_api/rest_api_server/handlers/v2/__init__.py +++ b/rest_api/rest_api_server/handlers/v2/__init__.py @@ -80,3 +80,4 @@ import rest_api.rest_api_server.handlers.v2.offer_breakdowns import rest_api.rest_api_server.handlers.v2.ri_group_breakdowns import rest_api.rest_api_server.handlers.v2.restore_passwords +import rest_api.rest_api_server.handlers.v2.verify_emails diff --git a/rest_api/rest_api_server/handlers/v2/verify_emails.py b/rest_api/rest_api_server/handlers/v2/verify_emails.py new file mode 100644 index 000000000..72332242f --- /dev/null +++ b/rest_api/rest_api_server/handlers/v2/verify_emails.py @@ -0,0 +1,49 @@ +from rest_api.rest_api_server.handlers.v2.restore_passwords import ( + RestorePasswordAsyncCollectionHandler) +from rest_api.rest_api_server.controllers.verify_email import ( + VerifyEmailAsyncController) + + +class VerifyEmailAsyncCollectionHandler(RestorePasswordAsyncCollectionHandler): + + def _get_controller_class(self): + return VerifyEmailAsyncController + + async def post(self): + """ + --- + description: Initialize email verification flow + tags: [verify_email] + summary: Initialize email verification flow + parameters: + - in: body + name: body + description: email verification parameters + required: true + schema: + type: object + properties: + email: + type: string + description: Contact email + required: true + example: example@mail.com + responses: + 201: + description: Flow initialized and email sent + schema: + type: object + example: + status: ok + email: example@email.com + 400: + description: | + Wrong arguments: + - OE0212: Unexpected parameters + - OE0214: Argument should be a string + - OE0215: Wrong argument's length + - OE0216: Argument is not provided + - OE0218: Argument has incorrect format + - OE0416: Argument should not contain only whitespaces + """ + await super().post() diff --git a/rest_api/rest_api_server/server.py b/rest_api/rest_api_server/server.py index d7ea918ff..36a179ad2 100644 --- a/rest_api/rest_api_server/server.py +++ b/rest_api/rest_api_server/server.py @@ -537,6 +537,9 @@ def get_handlers(handler_kwargs, version=None): (urls_v2.restore_password, h_v2.restore_passwords.RestorePasswordAsyncCollectionHandler, handler_kwargs), + (urls_v2.verify_email, + h_v2.verify_emails.VerifyEmailAsyncCollectionHandler, + handler_kwargs), *profiling_urls, ]) return result diff --git a/rest_api/rest_api_server/tests/unittests/test_verify_email.py b/rest_api/rest_api_server/tests/unittests/test_verify_email.py new file mode 100644 index 000000000..b7ba28cb8 --- /dev/null +++ b/rest_api/rest_api_server/tests/unittests/test_verify_email.py @@ -0,0 +1,89 @@ +from requests import HTTPError +from requests.models import Response +from unittest.mock import patch, ANY +from rest_api.rest_api_server.tests.unittests.test_api_base import TestApiBase + + +class TestVerifyEmail(TestApiBase): + def setUp(self, version='v2'): + patch('optscale_client.config_client.client.Client.cluster_secret' + ).start() + patch('optscale_client.config_client.client.Client.auth_url').start() + patch('optscale_client.config_client.client.Client.herald_url').start() + super().setUp(version) + + def test_verify(self): + email = 'example@email.com' + p_auth = patch('optscale_client.auth_client.client_v2.Client' + '.verification_code_create', + return_value=(201, {'email': email})).start() + p_herald = patch('optscale_client.herald_client.client_v2.Client' + '.email_send').start() + code, resp = self.client.verify_email(email) + self.assertEqual(code, 201) + self.assertEqual(resp['status'], 'ok') + self.assertEqual(resp['email'], email) + p_auth.assert_called_once_with(email, ANY) + p_herald.assert_called_once_with( + [email], 'OptScale email verification', + template_type='verify_email', + template_params={ + 'texts': {'code': ANY}, + 'links': {'verify_button': ANY}} + ) + + def test_invalid_email(self): + for body in [{'email': None}, {}]: + code, response = self.client.post('verify_email', body) + self.assertEqual(code, 400) + self.verify_error_code(response, 'OE0216') + for email in ['', ''.join('x' for _ in range(0, 256))]: + code, response = self.client.post( + 'verify_email', {'email': email}) + self.assertEqual(code, 400) + self.verify_error_code(response, 'OE0215') + for email, expected_error_code in { + 'invalid@format': 'OE0218', + ' ': 'OE0416', + 123: 'OE0214' + }.items(): + code, response = self.client.post( + 'verify_email', {'email': email}) + self.assertEqual(code, 400) + self.verify_error_code(response, expected_error_code) + + def test_unexpected_parameters(self): + code, response = self.client.post('verify_email', { + 'email': 'example@email.com', + 'another': 'parameter' + }) + self.assertEqual(code, 400) + self.verify_error_code(response, 'OE0212') + + def test_method_not_allowed(self): + for method in ['get', 'patch', 'delete', 'put']: + func = getattr(self.client, method) + code, response = func('verify_email', {}) + self.assertEqual(code, 405) + self.verify_error_code(response, 'OE0245') + self.assertEqual(response['error']['params'], [method.upper()]) + + def test_auth_error(self): + def raise_404(*_args, **_kwargs): + err = HTTPError('Email not found') + err.response = Response() + err.response.status_code = 409 + raise err + + email = 'example@email.com' + p_auth = patch('optscale_client.auth_client.client_v2.Client' + '.verification_code_create', + side_effect=raise_404).start() + p_herald = patch('optscale_client.herald_client.client_v2.Client' + '.email_send').start() + code, resp = self.client.verify_email(email) + self.assertEqual(code, 201) + self.assertEqual(resp['status'], 'ok') + self.assertEqual(resp['email'], email) + p_auth.assert_called_once_with(email, ANY) + p_herald.assert_not_called() diff --git a/tools/cloud_adapter/clouds/gcp.py b/tools/cloud_adapter/clouds/gcp.py index d4d81b226..9992738b3 100644 --- a/tools/cloud_adapter/clouds/gcp.py +++ b/tools/cloud_adapter/clouds/gcp.py @@ -367,7 +367,8 @@ def __init__(self, cloud_volume: compute.Disk, cloud_adapter): size=gbs_to_bytes(cloud_volume.size_gb), volume_type=type_, attached=attached, - zone_id=zone_id + zone_id=zone_id, + snapshot_id=cloud_volume.source_snapshot_id ) def _new_labels_request(self, key, value): @@ -395,6 +396,49 @@ def post_discover(self): return GcpResource.post_discover(self) +class GcpImage(tools.cloud_adapter.model.ImageResource, GcpResource): + def _get_console_link(self): + name = self._cloud_object.name + project_id = self._get_project_id() + return ( + f"{BASE_CONSOLE_LINK}/compute/imagesDetail/" + f"projects/{project_id}/global/images/{name}" + ) + + def __init__(self, cloud_image: compute.Image, cloud_adapter): + GcpResource.__init__(self, cloud_image, cloud_adapter) + super().__init__( + **self._common_fields, + disk_size=cloud_image.disk_size_gb, + cloud_created_at=self._gcp_date_to_timestamp( + cloud_image.creation_timestamp), + snapshot_id=(cloud_image.source_snapshot_id + if cloud_image.source_snapshot_id else None) + ) + + def _new_labels_request(self, key, value): + labels = self._cloud_object.labels + labels[key] = value + labels_request = compute.GlobalSetLabelsRequest( + label_fingerprint=self._cloud_object.label_fingerprint, + labels=labels, + ) + return labels_request + + def _set_tag(self, key, value): + labels_request = self._new_labels_request(key, value) + self._cloud_adapter.compute_images_client.set_labels( + project=self._cloud_adapter.project_id, + resource=self._cloud_object.name, + global_set_labels_request_resource=labels_request, + **DEFAULT_KWARGS, + ) + + def post_discover(self): + # Need to explicitly specify which parent's implementation to use + return GcpResource.post_discover(self) + + class GcpSnapshot(tools.cloud_adapter.model.SnapshotResource, GcpResource): def _get_console_link(self): name = self._cloud_object.name @@ -429,11 +473,11 @@ def _new_labels_request(self, key, value): return labesl_request def _set_tag(self, key, value): - labesl_request = self._new_labels_request(key, value) + labels_request = self._new_labels_request(key, value) self._cloud_adapter.compute_snapshots_client.set_labels( project=self._cloud_adapter.project_id, resource=self._cloud_object.name, - global_set_labels_request_resource=labesl_request, + global_set_labels_request_resource=labels_request, **DEFAULT_KWARGS, ) @@ -486,7 +530,8 @@ def __init__(self, cloud_address: compute.Address, cloud_adapter): available = cloud_address.status == "RESERVED" instance_id = None if not available: - instance_id = cloud_adapter.get_instance_id_for_address(cloud_address) + instance_id = cloud_adapter.get_instance_id_for_address( + cloud_address) super().__init__( **self._common_fields, available=available, @@ -496,9 +541,28 @@ def __init__(self, cloud_address: compute.Address, cloud_adapter): def _get_console_link(self): return "https://console.cloud.google.com/networking/addresses/list" + def _new_labels_request(self, key, value): + labels = self._cloud_object.labels + labels[key] = value + labels_request = compute.RegionSetLabelsRequest( + label_fingerprint=self._cloud_object.label_fingerprint, + labels=labels, + ) + return labels_request + + def _set_tag(self, key, value): + labels_request = self._new_labels_request(key, value) + self._cloud_adapter.compute_addresses_client.set_labels( + project=self._cloud_adapter.project_id, + resource=self._cloud_object.name, + region=self._last_path_element(self._cloud_object.region), + region_set_labels_request_resource=labels_request, + **DEFAULT_KWARGS, + ) + def post_discover(self): - # GCP does not support labels for IP addresses - pass + # Need to explicitly specify which parent's implementation to use + return GcpResource.post_discover(self) class Gcp(CloudBase): @@ -551,6 +615,7 @@ def discovery_calls_map(self): tools.cloud_adapter.model.SnapshotResource: self.snapshot_discovery_calls, tools.cloud_adapter.model.IpAddressResource: self.ip_address_discovery_calls, tools.cloud_adapter.model.BucketResource: self.bucket_discovery_calls, + tools.cloud_adapter.model.ImageResource: self.image_discovery_calls, } @property @@ -638,6 +703,12 @@ def compute_snapshots_client(self): self.credentials, ) + @cached_property + def compute_images_client(self): + return compute.ImagesClient.from_service_account_info( + self.credentials, + ) + @cached_property def compute_addresses_client(self): return compute.AddressesClient.from_service_account_info( @@ -907,6 +978,20 @@ def discover_snapshots(self): def snapshot_discovery_calls(self): return [(self.discover_snapshots, ())] + ###################################################################################### + # IMAGE DISCOVERY + ###################################################################################### + + def discover_images(self): + for image in self.discover_entities( + self.compute_images_client.list, + compute.ListImagesRequest + ): + yield GcpImage(image, self) + + def image_discovery_calls(self): + return [(self.discover_images, ())] + ###################################################################################### # BUCKET DISCOVERY ###################################################################################### diff --git a/tools/cloud_adapter/model.py b/tools/cloud_adapter/model.py index 60f4beb45..d4c73a744 100644 --- a/tools/cloud_adapter/model.py +++ b/tools/cloud_adapter/model.py @@ -400,29 +400,34 @@ def meta(self): class ImageResource(CloudResource): __slots__ = ('name', 'block_device_mappings', 'cloud_created_at', - 'folder_id') + 'folder_id', 'snapshot_id', 'disk_size') def __init__(self, name=None, block_device_mappings=None, - cloud_created_at=None, folder_id=None, **kwargs): + cloud_created_at=None, folder_id=None, snapshot_id=None, + disk_size=None, **kwargs): super().__init__(**kwargs) self.name = name self.block_device_mappings = block_device_mappings or [] self.cloud_created_at = cloud_created_at self.folder_id = folder_id + self.snapshot_id = snapshot_id + self.disk_size = disk_size def __repr__(self): return ( 'Image {0} name={1} block_device_mappings={2} ' - 'cloud_created_at={3}'.format( + 'cloud_created_at={3} snapshot_id={4} disk_size={5}'.format( self.cloud_resource_id, self.name, self.block_device_mappings, - self.cloud_created_at)) + self.cloud_created_at, self.snapshot_id, self.disk_size)) @property def meta(self): meta = super().meta meta.update({ 'block_device_mappings': self.block_device_mappings, - 'folder_id': self.folder_id + 'folder_id': self.folder_id, + 'snapshot_id': self.snapshot_id, + 'disk_size': self.disk_size, }) return meta