From c2c3aeff36c6b2c2f1c19036078f6215713949a8 Mon Sep 17 00:00:00 2001 From: MoritzWeber Date: Tue, 30 Apr 2024 08:58:27 +0200 Subject: [PATCH] feat: Share sessions with other users --- .../49f51db92903_add_session_sharing.py | 40 +++ .../capellacollab/core/database/migration.py | 8 +- backend/capellacollab/sessions/crud.py | 24 ++ backend/capellacollab/sessions/exceptions.py | 24 +- .../sessions/hooks/persistent_workspace.py | 4 +- backend/capellacollab/sessions/hooks/t4c.py | 15 +- backend/capellacollab/sessions/injectables.py | 34 ++- backend/capellacollab/sessions/models.py | 44 +++ backend/capellacollab/sessions/routes.py | 91 +++++- backend/capellacollab/sessions/util.py | 6 + .../modelsources/t4c/repositories/crud.py | 29 +- backend/capellacollab/tools/models.py | 13 + backend/tests/projects/toolmodels/conftest.py | 27 -- backend/tests/projects/toolmodels/fixtures.py | 14 + backend/tests/projects/users/fixtures.py | 4 +- backend/tests/sessions/fixtures.py | 4 +- .../hooks/test_persistent_workspace.py | 2 +- backend/tests/sessions/hooks/test_t4c_hook.py | 279 ++++++++++++++++++ backend/tests/sessions/test_session_routes.py | 10 + .../tests/sessions/test_session_sharing.py | 240 +++++++++++++++ backend/tests/settings/conftest.py | 18 -- .../tests/settings/teamforcapella/conftest.py | 25 -- .../tests/settings/teamforcapella/fixtures.py | 41 +++ .../teamforcapella/test_t4c_instances.py | 12 +- backend/tests/tools/fixtures.py | 31 +- backend/tests/tools/versions/fixtures.py | 34 +++ backend/tests/users/fixtures.py | 15 +- docs/docs/admin/tools/configuration.md | 4 +- .../user/sessions/jupyter/collaboration.md | 28 +- docs/docs/user/sessions/sharing.md | 30 ++ docs/mkdocs.yml | 1 + frontend/Makefile | 9 +- .../src/app/general/footer/footer.docs.mdx | 2 +- .../src/app/general/header/header.stories.ts | 7 +- .../src/app/openapi/.openapi-generator/FILES | 4 + .../src/app/openapi/api/sessions.service.ts | 172 +++++++++++ .../guacamole-connection-method-input.ts | 2 + .../guacamole-connection-method-output.ts | 2 + .../model/http-connection-method-input.ts | 2 + .../model/http-connection-method-output.ts | 2 + frontend/src/app/openapi/model/models.ts | 4 + .../src/app/openapi/model/session-sharing.ts | 19 ++ frontend/src/app/openapi/model/session.ts | 2 + .../openapi/model/share-session-request.ts | 17 ++ ...-session-connection-input-methods-inner.ts | 2 + .../model/tool-session-connection-method.ts | 2 + ...session-connection-output-methods-inner.ts | 2 + ...ool-session-sharing-configuration-input.ts | 20 ++ ...ol-session-sharing-configuration-output.ts | 20 ++ .../trigger-pipeline.stories.ts | 5 +- .../active-sessions.component.docs.mdx | 11 + .../active-sessions.component.html | 87 ++++-- .../active-sessions.component.ts | 13 + .../active-sessions.stories.ts | 104 ++++++- .../connection-dialog.component.html | 39 +-- .../connection-dialog.component.ts | 3 +- .../connection-dialog.docs.mdx | 28 ++ .../connection-dialog.stories.ts | 102 +++++++ .../file-browser-dialog.component.html | 2 +- .../session-sharing-dialog.component.html | 115 ++++++++ .../session-sharing-dialog.component.ts | 188 ++++++++++++ .../session-sharing-dialog.stories.ts | 51 ++++ frontend/src/storybook/session.ts | 2 + frontend/src/storybook/tool.ts | 3 + frontend/src/storybook/user.ts | 12 +- frontend/src/styles.css | 5 + frontend/tailwind.config.js | 1 + 67 files changed, 1982 insertions(+), 230 deletions(-) create mode 100644 backend/capellacollab/alembic/versions/49f51db92903_add_session_sharing.py create mode 100644 backend/tests/sessions/hooks/test_t4c_hook.py create mode 100644 backend/tests/sessions/test_session_sharing.py delete mode 100644 backend/tests/settings/conftest.py create mode 100644 backend/tests/settings/teamforcapella/fixtures.py create mode 100644 backend/tests/tools/versions/fixtures.py create mode 100644 docs/docs/user/sessions/sharing.md create mode 100644 frontend/src/app/openapi/model/session-sharing.ts create mode 100644 frontend/src/app/openapi/model/share-session-request.ts create mode 100644 frontend/src/app/openapi/model/tool-session-sharing-configuration-input.ts create mode 100644 frontend/src/app/openapi/model/tool-session-sharing-configuration-output.ts create mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.docs.mdx create mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.stories.ts create mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.component.html create mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.component.ts create mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.stories.ts diff --git a/backend/capellacollab/alembic/versions/49f51db92903_add_session_sharing.py b/backend/capellacollab/alembic/versions/49f51db92903_add_session_sharing.py new file mode 100644 index 000000000..b07a5faf5 --- /dev/null +++ b/backend/capellacollab/alembic/versions/49f51db92903_add_session_sharing.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Add session sharing + +Revision ID: 49f51db92903 +Revises: aa88e6d1333b +Create Date: 2024-05-29 14:25:34.801756 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "49f51db92903" +down_revision = "aa88e6d1333b" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "shared_sessions", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["session_id"], + ["sessions.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id", "session_id", "user_id"), + ) + op.create_index( + op.f("ix_shared_sessions_id"), "shared_sessions", ["id"], unique=True + ) diff --git a/backend/capellacollab/core/database/migration.py b/backend/capellacollab/core/database/migration.py index 0c7134465..32ba76fc4 100644 --- a/backend/capellacollab/core/database/migration.py +++ b/backend/capellacollab/core/database/migration.py @@ -160,10 +160,13 @@ def get_eclipse_session_configuration() -> ( else "{CAPELLACOLLAB_ORIGIN_BASE_URL}" ), }, - redirect_url="{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/", + redirect_url="{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&sharing=1&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/", cookies={ "token": "{CAPELLACOLLAB_SESSION_TOKEN}", }, + sharing=tools_models.ToolSessionSharingConfiguration( + enabled=True + ), ), ] ), @@ -287,6 +290,9 @@ def create_jupyter_tool(db: orm.Session) -> tools_models.DatabaseTool: description="The only available connection method for Jupyter.", ports=tools_models.HTTPPorts(http=8888, metrics=9118), redirect_url="{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}{CAPELLACOLLAB_SESSIONS_BASE_PATH}/lab?token={CAPELLACOLLAB_SESSION_TOKEN}", + sharing=tools_models.ToolSessionSharingConfiguration( + enabled=True + ), ), ] ), diff --git a/backend/capellacollab/sessions/crud.py b/backend/capellacollab/sessions/crud.py index 3cc456f1e..4cbb4a417 100644 --- a/backend/capellacollab/sessions/crud.py +++ b/backend/capellacollab/sessions/crud.py @@ -7,6 +7,8 @@ import sqlalchemy as sa from sqlalchemy import orm +from capellacollab.users import models as users_models + from . import models @@ -14,6 +16,14 @@ def get_sessions(db: orm.Session) -> abc.Sequence[models.DatabaseSession]: return db.execute(sa.select(models.DatabaseSession)).scalars().all() +def create_shared_session( + db: orm.Session, shared_session: models.DatabaseSharedSession +) -> models.DatabaseSharedSession: + db.add(shared_session) + db.commit() + return shared_session + + def get_sessions_for_user( db: orm.Session, username: str ) -> abc.Sequence[models.DatabaseSession]: @@ -28,6 +38,20 @@ def get_sessions_for_user( ) +def get_shared_sessions_for_user( + db: orm.Session, user: users_models.DatabaseUser +) -> abc.Sequence[models.DatabaseSession]: + return ( + db.execute( + sa.select(models.DatabaseSession) + .join(models.DatabaseSharedSession) + .where(models.DatabaseSharedSession.user == user) + ) + .scalars() + .all() + ) + + def get_session_by_id( db: orm.Session, session_id: str ) -> models.DatabaseSession | None: diff --git a/backend/capellacollab/sessions/exceptions.py b/backend/capellacollab/sessions/exceptions.py index 17ef32f9a..938f8b61a 100644 --- a/backend/capellacollab/sessions/exceptions.py +++ b/backend/capellacollab/sessions/exceptions.py @@ -30,6 +30,16 @@ def __init__(self, session_id: str): ) +class SessionAlreadySharedError(core_exceptions.BaseError): + def __init__(self, username: str): + super().__init__( + status_code=status.HTTP_409_CONFLICT, + title="Session already shared with user", + reason=(f"The session is already shared with user '{username}'. "), + err_code="SESSION_ALREADY_SHARED", + ) + + class SessionForbiddenError(core_exceptions.BaseError): def __init__(self): super().__init__( @@ -59,6 +69,18 @@ def __init__( ) +class SessionSharingNotSupportedError(core_exceptions.BaseError): + def __init__(self, tool_name: str, connection_method_name: str): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + title="Session sharing not supported", + reason=( + f"The connection method '{connection_method_name}' of tool '{tool_name}' doesn't support session sharing.'." + ), + err_code="SESSION_SHARING_UNSUPPORTED", + ) + + class ConflictingSessionError(core_exceptions.BaseError): def __init__( self, @@ -109,7 +131,7 @@ def __init__( ) -class WorkspaceMountingNotAllowed(core_exceptions.BaseError): +class WorkspaceMountingNotAllowedError(core_exceptions.BaseError): def __init__( self, tool_name: str, diff --git a/backend/capellacollab/sessions/hooks/persistent_workspace.py b/backend/capellacollab/sessions/hooks/persistent_workspace.py index fd9818d3c..9e570a084 100644 --- a/backend/capellacollab/sessions/hooks/persistent_workspace.py +++ b/backend/capellacollab/sessions/hooks/persistent_workspace.py @@ -54,7 +54,9 @@ def _check_that_persistent_workspace_is_allowed( self, tool: tools_models.DatabaseTool ): if not tool.config.persistent_workspaces.mounting_enabled: - raise sessions_exceptions.WorkspaceMountingNotAllowed(tool.name) + raise sessions_exceptions.WorkspaceMountingNotAllowedError( + tool.name + ) def _get_volume_name(self, username: str) -> str: return "persistent-session-" + self._normalize_username(username) diff --git a/backend/capellacollab/sessions/hooks/t4c.py b/backend/capellacollab/sessions/hooks/t4c.py index 85705d522..6f51cb7f9 100644 --- a/backend/capellacollab/sessions/hooks/t4c.py +++ b/backend/capellacollab/sessions/hooks/t4c.py @@ -39,7 +39,6 @@ def configuration_hook( # type: ignore db: orm.Session, user: users_models.DatabaseUser, tool_version: tools_models.DatabaseVersion, - username: str, session_type: sessions_models.SessionType, **kwargs, ) -> interface.ConfigurationHookResult: @@ -49,11 +48,8 @@ def configuration_hook( # type: ignore warnings: list[core_models.Message] = [] - # When using a different tool with TeamForCapella support (e.g., Capella + pure::variants), - # the version ID doesn't match the version from the T4C integration. - # We have to find the matching Capella version by name. t4c_repositories = repo_crud.get_user_t4c_repositories( - db, tool_version.name, user + db, tool_version, user ) t4c_json = json.dumps( @@ -93,7 +89,7 @@ def configuration_hook( # type: ignore password=environment["T4C_PASSWORD"], is_admin=auth_injectables.RoleVerification( required_role=users_models.Role.ADMIN, verify=False - )(username, db), + )(user.name, db), ) except requests.RequestException: warnings.append( @@ -130,11 +126,16 @@ def pre_session_termination_hook( # type: ignore def session_connection_hook( # type: ignore[override] self, db_session: sessions_models.DatabaseSession, + user: users_models.DatabaseUser, **kwargs, ) -> interface.SessionConnectionHookResult: if db_session.type != sessions_models.SessionType.PERSISTENT: return interface.SessionConnectionHookResult() + if db_session.owner != user: + # The session is shared, don't provide the T4C token. + return interface.SessionConnectionHookResult() + return interface.SessionConnectionHookResult( t4c_token=db_session.environment.get("T4C_PASSWORD") ) @@ -145,7 +146,7 @@ def _revoke_session_tokens( session: sessions_models.DatabaseSession, ): for repository in repo_crud.get_user_t4c_repositories( - db, session.version.name, session.owner + db, session.version, session.owner ): try: repo_interface.remove_user_from_repository( diff --git a/backend/capellacollab/sessions/injectables.py b/backend/capellacollab/sessions/injectables.py index 1284a5f16..079f80f3f 100644 --- a/backend/capellacollab/sessions/injectables.py +++ b/backend/capellacollab/sessions/injectables.py @@ -6,23 +6,49 @@ from capellacollab.core import database from capellacollab.core.authentication import injectables as auth_injectables +from capellacollab.users import injectables as users_injectables from capellacollab.users import models as users_models -from . import crud, exceptions, models +from . import crud, exceptions, models, util def get_existing_session( session_id: str, db: orm.Session = fastapi.Depends(database.get_db), - username: str = fastapi.Depends(auth_injectables.get_username), + user: users_models.DatabaseUser = fastapi.Depends( + users_injectables.get_own_user + ), +) -> models.DatabaseSession: + """Get a session by its ID, ensuring that the user is the owner or an admin.""" + + if not (session := crud.get_session_by_id(db, session_id)): + raise exceptions.SessionNotFoundError(session_id) + if not ( + session.owner_name == user.name + or auth_injectables.RoleVerification( + required_role=users_models.Role.ADMIN, verify=False + )(user.name, db) + ): + raise exceptions.SessionNotOwnedError(session_id) + + return session + + +def get_existing_session_including_shared( + session_id: str, + db: orm.Session = fastapi.Depends(database.get_db), + user: users_models.DatabaseUser = fastapi.Depends( + users_injectables.get_own_user + ), ) -> models.DatabaseSession: if not (session := crud.get_session_by_id(db, session_id)): raise exceptions.SessionNotFoundError(session_id) if not ( - session.owner_name == username + session.owner_name == user.name + or util.is_session_shared_with_user(session, user) or auth_injectables.RoleVerification( required_role=users_models.Role.ADMIN, verify=False - )(username, db) + )(user.name, db) ): raise exceptions.SessionNotOwnedError(session_id) diff --git a/backend/capellacollab/sessions/models.py b/backend/capellacollab/sessions/models.py index 49a8b6ca5..a356b72a6 100644 --- a/backend/capellacollab/sessions/models.py +++ b/backend/capellacollab/sessions/models.py @@ -64,6 +64,11 @@ class PostSessionRequest(core_pydantic.BaseModel): provisioning: list[SessionProvisioningRequest] = pydantic.Field(default=[]) +class SessionSharing(core_pydantic.BaseModel): + user: users_models.BaseUser + created_at: datetime.datetime + + class Session(core_pydantic.BaseModel): id: str type: SessionType @@ -79,6 +84,8 @@ class Session(core_pydantic.BaseModel): connection_method_id: str connection_method: tools_models.ToolSessionConnectionMethod | None = None + shared_with: list[SessionSharing] = pydantic.Field(default=[]) + _validate_created_at = pydantic.field_serializer("created_at")( core_pydantic.datetime_serializer ) @@ -103,6 +110,10 @@ def add_warnings_and_last_seen(self) -> t.Any: return self +class ShareSessionRequest(core_pydantic.BaseModel): + username: str + + class SessionConnectionInformation(core_pydantic.BaseModel): """Information about the connection to the session.""" @@ -161,3 +172,36 @@ class DatabaseSession(database.Base): config: orm.Mapped[dict[str, str]] = orm.mapped_column( nullable=False, default_factory=dict ) + + shared_with: orm.Mapped[list[DatabaseSharedSession]] = orm.relationship( + "DatabaseSharedSession", + back_populates="session", + init=False, + cascade="all, delete-orphan", + ) + + +class DatabaseSharedSession(database.Base): + __tablename__ = "shared_sessions" + + id: orm.Mapped[int] = orm.mapped_column( + sa.Integer, + init=False, + primary_key=True, + index=True, + unique=True, + autoincrement=True, + ) + created_at: orm.Mapped[datetime.datetime] + + session_id: orm.Mapped[str] = orm.mapped_column( + sa.ForeignKey("sessions.id"), primary_key=True, init=False + ) + session: orm.Mapped[DatabaseSession] = orm.relationship( + back_populates="shared_with" + ) + + user_id: orm.Mapped[str] = orm.mapped_column( + sa.ForeignKey("users.id"), primary_key=True, init=False + ) + user: orm.Mapped[DatabaseUser] = orm.relationship() diff --git a/backend/capellacollab/sessions/routes.py b/backend/capellacollab/sessions/routes.py index 0507c4012..54139fbd5 100644 --- a/backend/capellacollab/sessions/routes.py +++ b/backend/capellacollab/sessions/routes.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 +import datetime import hmac import logging import typing as t @@ -20,6 +21,7 @@ from capellacollab.tools import exceptions as tools_exceptions from capellacollab.tools import injectables as tools_injectables from capellacollab.tools import models as tools_models +from capellacollab.users import crud as users_crud from capellacollab.users import exceptions as users_exceptions from capellacollab.users import injectables as users_injectables from capellacollab.users import models as users_models @@ -35,8 +37,7 @@ required_role=users_models.Role.USER ) ) - ], - responses=responses.api_exceptions(minimum_role=users_models.Role.USER), + ] ) router_without_authentication = fastapi.APIRouter() @@ -48,7 +49,7 @@ required_role=users_models.Role.USER ) ) - ], + ] ) @@ -69,7 +70,7 @@ exceptions.InvalidConnectionMethodIdentifierError( tool_name="test", connection_method_id="default" ), - exceptions.WorkspaceMountingNotAllowed(tool_name="test"), + exceptions.WorkspaceMountingNotAllowedError(tool_name="test"), exceptions.TooManyModelsRequestedToProvisionError( max_number_of_models=1 ), @@ -250,6 +251,76 @@ def get_all_sessions( return crud.get_sessions(db) +@router.get( + "/{session_id}", + response_model=models.Session, + responses=responses.api_exceptions( + [ + exceptions.SessionNotFoundError(session_id="test"), + exceptions.SessionNotOwnedError(session_id="test"), + ] + ), +) +def get_session( + session: models.DatabaseSession = fastapi.Depends( + injectables.get_existing_session + ), +): + return session + + +@router.post( + "/{session_id}/shares", + response_model=models.SessionSharing, + responses=responses.api_exceptions( + [ + exceptions.InvalidConnectionMethodIdentifierError( + tool_name="test", connection_method_id="default" + ), + exceptions.SessionNotFoundError(session_id="test"), + exceptions.SessionNotOwnedError(session_id="test"), + exceptions.SessionAlreadySharedError(username="test"), + exceptions.SessionSharingNotSupportedError( + tool_name="test", connection_method_name="test" + ), + users_exceptions.UserNotFoundError(username="test"), + ], + ), +) +def share_session( + body: models.ShareSessionRequest, + session: models.DatabaseSession = fastapi.Depends( + injectables.get_existing_session + ), + db: orm.Session = fastapi.Depends(database.get_db), +): + user_to_share_with = users_crud.get_user_by_name(db, body.username) + if not user_to_share_with: + raise users_exceptions.UserNotFoundError(username=body.username) + if ( + session.owner == user_to_share_with + or util.is_session_shared_with_user(session, user_to_share_with) + ): + raise exceptions.SessionAlreadySharedError(user_to_share_with.name) + + connection_method = util.get_connection_method( + tool=session.tool, connection_method_id=session.connection_method_id + ) + if not connection_method.sharing.enabled: + raise exceptions.SessionSharingNotSupportedError( + tool_name=session.tool.name, + connection_method_name=connection_method.name, + ) + + session_share = models.DatabaseSharedSession( + created_at=datetime.datetime.now(datetime.UTC), + session=session, + user=user_to_share_with, + ) + + return crud.create_shared_session(db, session_share) + + @router.get( "/{session_id}/connection", response_model=core_models.PayloadResponseModel[ @@ -268,16 +339,13 @@ def get_all_sessions( def get_session_connection_information( db: orm.Session = fastapi.Depends(database.get_db), session: models.DatabaseSession = fastapi.Depends( - injectables.get_existing_session + injectables.get_existing_session_including_shared ), user: users_models.DatabaseUser = fastapi.Depends( users_injectables.get_own_user ), logger: logging.LoggerAdapter = fastapi.Depends(log.get_request_logger), ): - if session.owner != user: - raise exceptions.SessionNotOwnedError(session.id) - connection_method = util.get_connection_method( session.tool, session.connection_method_id ) @@ -381,6 +449,7 @@ def get_sessions_for_user( current_user: users_models.DatabaseUser = fastapi.Depends( users_injectables.get_own_user ), + db: orm.Session = fastapi.Depends(database.get_db), ): if ( user != current_user @@ -388,6 +457,6 @@ def get_sessions_for_user( ): raise exceptions.SessionForbiddenError() - return [ - models.Session.model_validate(session) for session in user.sessions - ] + return user.sessions + list( + crud.get_shared_sessions_for_user(db, current_user) + ) diff --git a/backend/capellacollab/sessions/util.py b/backend/capellacollab/sessions/util.py index 80ebe760d..8abe6876f 100644 --- a/backend/capellacollab/sessions/util.py +++ b/backend/capellacollab/sessions/util.py @@ -196,3 +196,9 @@ def get_connection_method( raise exceptions.InvalidConnectionMethodIdentifierError( tool.name, connection_method_id ) + + +def is_session_shared_with_user( + session: models.DatabaseSession, user: users_models.DatabaseUser +) -> bool: + return user in [shared.user for shared in session.shared_with] diff --git a/backend/capellacollab/settings/modelsources/t4c/repositories/crud.py b/backend/capellacollab/settings/modelsources/t4c/repositories/crud.py index f3654666f..174ba4d4e 100644 --- a/backend/capellacollab/settings/modelsources/t4c/repositories/crud.py +++ b/backend/capellacollab/settings/modelsources/t4c/repositories/crud.py @@ -15,6 +15,7 @@ from capellacollab.settings.modelsources.t4c import ( models as settings_t4c_models, ) +from capellacollab.tools import crud as tools_crud from capellacollab.tools import models as tools_models from capellacollab.users import models as users_models @@ -47,11 +48,19 @@ def exist_repo_for_name_and_instance( def get_user_t4c_repositories( - db: orm.Session, version_name: str, user: users_models.DatabaseUser + db: orm.Session, + tool_version: tools_models.DatabaseVersion, + user: users_models.DatabaseUser, ) -> abc.Sequence[models.DatabaseT4CRepository]: + tool_versions = [ + tool_version + ] + tools_crud.get_compatible_versions_for_tool_versions( + db, tool_version=tool_version + ) + if user.role == users_models.Role.ADMIN: - return _get_admin_t4c_repositories(db, version_name) - return _get_user_write_t4c_repositories(db, version_name, user) + return _get_admin_t4c_repositories(db, tool_versions) + return _get_user_write_t4c_repositories(db, tool_versions, user) def create_t4c_repository( @@ -76,14 +85,18 @@ def delete_4c_repository( def _get_user_write_t4c_repositories( - db: orm.Session, version_name: str, user: users_models.DatabaseUser + db: orm.Session, + tool_versions: list[tools_models.DatabaseVersion], + user: users_models.DatabaseUser, ) -> abc.Sequence[models.DatabaseT4CRepository]: stmt = ( sa.select(models.DatabaseT4CRepository) .join(models.DatabaseT4CRepository.models) .join(t4c_models.DatabaseT4CModel.model) .join(toolmodels_models.DatabaseToolModel.version) - .where(tools_models.DatabaseVersion.name == version_name) + .where( + tools_models.DatabaseVersion.id.in_([v.id for v in tool_versions]) + ) .join(toolmodels_models.DatabaseToolModel.project) .where(projects_models.DatabaseProject.is_archived.is_(False)) .join(projects_models.DatabaseProject.users) @@ -98,14 +111,16 @@ def _get_user_write_t4c_repositories( def _get_admin_t4c_repositories( - db: orm.Session, version_name: str + db: orm.Session, tool_versions: list[tools_models.DatabaseVersion] ) -> abc.Sequence[models.DatabaseT4CRepository]: stmt = ( sa.select(models.DatabaseT4CRepository) .join(models.DatabaseT4CRepository.models) .join(t4c_models.DatabaseT4CModel.model) .join(toolmodels_models.DatabaseToolModel.version) - .where(tools_models.DatabaseVersion.name == version_name) + .where( + tools_models.DatabaseVersion.id.in_([v.id for v in tool_versions]) + ) .join(toolmodels_models.DatabaseToolModel.project) .where(projects_models.DatabaseProject.is_archived.is_(False)) ) diff --git a/backend/capellacollab/tools/models.py b/backend/capellacollab/tools/models.py index a7fa691fc..20f041d96 100644 --- a/backend/capellacollab/tools/models.py +++ b/backend/capellacollab/tools/models.py @@ -74,6 +74,16 @@ class ToolSessionEnvironment(core_pydantic.BaseModel): ) +class ToolSessionSharingConfiguration(core_pydantic.BaseModel): + enabled: bool = pydantic.Field( + default=False, + description=( + "Allow sharing of a session container with other users. " + "The tool / connection method has to support multiple connections to the same container. " + ), + ) + + class ToolSessionConnectionMethod(core_pydantic.BaseModel): id: str = pydantic.Field(default_factory=uuid_factory) type: str @@ -87,6 +97,9 @@ class ToolSessionConnectionMethod(core_pydantic.BaseModel): "Check the global environment field for more information. " ), ) + sharing: ToolSessionSharingConfiguration = pydantic.Field( + default=ToolSessionSharingConfiguration() + ) class GuacamoleConnectionMethod(ToolSessionConnectionMethod): diff --git a/backend/tests/projects/toolmodels/conftest.py b/backend/tests/projects/toolmodels/conftest.py index adda0a49a..9fec519b1 100644 --- a/backend/tests/projects/toolmodels/conftest.py +++ b/backend/tests/projects/toolmodels/conftest.py @@ -10,16 +10,10 @@ from aioresponses import aioresponses from sqlalchemy import orm -import capellacollab.projects.toolmodels.models as toolmodels_models -import capellacollab.projects.toolmodels.modelsources.t4c.crud as models_t4c_crud -import capellacollab.projects.toolmodels.modelsources.t4c.models as models_t4c_models import capellacollab.settings.modelsources.git.crud as git_crud import capellacollab.settings.modelsources.git.models as git_models -import capellacollab.settings.modelsources.t4c.crud as settings_t4c_crud import capellacollab.settings.modelsources.t4c.models as t4c_models -import capellacollab.settings.modelsources.t4c.repositories.crud as settings_t4c_repositories_crud import capellacollab.settings.modelsources.t4c.repositories.interface as t4c_repositories_interface -import capellacollab.settings.modelsources.t4c.repositories.models as settings_t4c_repositories_models from capellacollab.core import credentials @@ -231,27 +225,6 @@ def fixture_mock_git_get_commit_information_api( ) -@pytest.fixture(name="t4c_repository") -def fixture_t4c_repository( - db: orm.Session, -) -> settings_t4c_repositories_models.DatabaseT4CRepository: - t4c_instance = settings_t4c_crud.get_t4c_instances(db)[0] - return settings_t4c_repositories_crud.create_t4c_repository( - db=db, repo_name="test", instance=t4c_instance - ) - - -@pytest.fixture(name="t4c_model") -def fixture_t4c_model( - db: orm.Session, - capella_model: toolmodels_models.DatabaseToolModel, - t4c_repository: settings_t4c_repositories_models.DatabaseT4CRepository, -) -> models_t4c_models.DatabaseT4CModel: - return models_t4c_crud.create_t4c_model( - db, capella_model, t4c_repository, "default" - ) - - @pytest.fixture(name="mock_add_user_to_t4c_repository") def fixture_mock_add_user_to_t4c_repository(monkeypatch: pytest.MonkeyPatch): def mock_add_user_to_repository( diff --git a/backend/tests/projects/toolmodels/fixtures.py b/backend/tests/projects/toolmodels/fixtures.py index fcf9bd5ab..42223f5c0 100644 --- a/backend/tests/projects/toolmodels/fixtures.py +++ b/backend/tests/projects/toolmodels/fixtures.py @@ -11,6 +11,9 @@ import capellacollab.projects.toolmodels.models as toolmodels_models import capellacollab.projects.toolmodels.modelsources.git.crud as project_git_crud import capellacollab.projects.toolmodels.modelsources.git.models as project_git_models +import capellacollab.projects.toolmodels.modelsources.t4c.crud as models_t4c_crud +import capellacollab.projects.toolmodels.modelsources.t4c.models as models_t4c_models +import capellacollab.settings.modelsources.t4c.repositories.models as settings_t4c_repositories_models import capellacollab.tools.models as tools_models @@ -63,3 +66,14 @@ def fixture_git_model( return project_git_crud.add_git_model_to_capellamodel( db, capella_model, git_model ) + + +@pytest.fixture(name="t4c_model") +def fixture_t4c_model( + db: orm.Session, + capella_model: toolmodels_models.DatabaseToolModel, + t4c_repository: settings_t4c_repositories_models.DatabaseT4CRepository, +) -> models_t4c_models.DatabaseT4CModel: + return models_t4c_crud.create_t4c_model( + db, capella_model, t4c_repository, "default" + ) diff --git a/backend/tests/projects/users/fixtures.py b/backend/tests/projects/users/fixtures.py index 515ca617a..d39373440 100644 --- a/backend/tests/projects/users/fixtures.py +++ b/backend/tests/projects/users/fixtures.py @@ -34,11 +34,11 @@ def fixture_project_user( project: projects_models.DatabaseProject, user: users_models.DatabaseUser, ) -> users_models.DatabaseUser: - projects_users_crud.add_user_to_project( + project_user = projects_users_crud.add_user_to_project( db, project=project, user=user, role=projects_users_models.ProjectUserRole.USER, permission=projects_users_models.ProjectUserPermission.WRITE, ) - return user + return project_user diff --git a/backend/tests/sessions/fixtures.py b/backend/tests/sessions/fixtures.py index d00701e19..45efab586 100644 --- a/backend/tests/sessions/fixtures.py +++ b/backend/tests/sessions/fixtures.py @@ -18,7 +18,7 @@ @pytest.fixture(name="session") def fixture_session( db: orm.Session, - user: users_models.DatabaseUser, + basic_user: users_models.DatabaseUser, tool: tools_models.DatabaseTool, tool_version: tools_models.DatabaseVersion, ) -> sessions_models.DatabaseSession: @@ -27,7 +27,7 @@ def fixture_session( created_at=datetime.datetime.now(), type=sessions_models.SessionType.PERSISTENT, environment={"CAPELLACOLLAB_SESSION_TOKEN": "thisisarandomtoken"}, - owner=user, + owner=basic_user, tool=tool, version=tool_version, connection_method_id=tool.config.connection.methods[0].id, diff --git a/backend/tests/sessions/hooks/test_persistent_workspace.py b/backend/tests/sessions/hooks/test_persistent_workspace.py index c8a2cfb53..b47227026 100644 --- a/backend/tests/sessions/hooks/test_persistent_workspace.py +++ b/backend/tests/sessions/hooks/test_persistent_workspace.py @@ -17,7 +17,7 @@ def test_persistent_workspace_mounting_not_allowed( ): tool.config.persistent_workspaces.mounting_enabled = False - with pytest.raises(sessions_exceptions.WorkspaceMountingNotAllowed): + with pytest.raises(sessions_exceptions.WorkspaceMountingNotAllowedError): persistent_workspace.PersistentWorkspaceHook().configuration_hook( operator=operators.KubernetesOperator(), user=user, diff --git a/backend/tests/sessions/hooks/test_t4c_hook.py b/backend/tests/sessions/hooks/test_t4c_hook.py new file mode 100644 index 000000000..9945294ee --- /dev/null +++ b/backend/tests/sessions/hooks/test_t4c_hook.py @@ -0,0 +1,279 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import json + +import pytest +import responses +from sqlalchemy import orm + +from capellacollab.projects import models as projects_models +from capellacollab.projects.users import models as projects_users_models +from capellacollab.sessions import models as sessions_models +from capellacollab.sessions.hooks import interface as sessions_hooks_interface +from capellacollab.sessions.hooks import t4c +from capellacollab.settings.modelsources.t4c import models as t4c_models +from capellacollab.tools import crud as tools_crud +from capellacollab.tools import models as tools_models +from capellacollab.users import crud as users_crud +from capellacollab.users import models as users_models + + +@pytest.fixture(name="mock_add_user_to_repository") +def fixture_mock_add_user_to_repository( + t4c_instance: t4c_models.DatabaseT4CInstance, +) -> responses.BaseResponse: + return responses.add( + responses.POST, + f"{t4c_instance.rest_api}/users?repositoryName=test", + status=200, + json={}, + ) + + +@pytest.fixture(name="mock_add_user_to_repository_failed") +def fixture_mock_add_user_to_repository_failed( + t4c_instance: t4c_models.DatabaseT4CInstance, +) -> responses.BaseResponse: + return responses.add( + responses.POST, + f"{t4c_instance.rest_api}/users?repositoryName=test", + status=500, + ) + + +@responses.activate +@pytest.mark.usefixtures("t4c_model", "project_user") +def test_t4c_configuration_hook( + db: orm.Session, + user: users_models.DatabaseUser, + capella_tool_version: tools_models.DatabaseVersion, + mock_add_user_to_repository: responses.BaseResponse, +): + result = t4c.T4CIntegration().configuration_hook( + db=db, + user=user, + tool_version=capella_tool_version, + session_type=sessions_models.SessionType.PERSISTENT, + ) + + assert result["environment"]["T4C_LICENCE_SECRET"] + assert len(json.loads(result["environment"]["T4C_JSON"])) == 1 + assert result["environment"]["T4C_USERNAME"] == user.name + assert result["environment"]["T4C_PASSWORD"] + assert not result["warnings"] + assert mock_add_user_to_repository.call_count == 1 + + +@responses.activate +@pytest.mark.usefixtures("t4c_model") +def test_t4c_configuration_hook_as_admin( + db: orm.Session, + admin: users_models.DatabaseUser, + capella_tool_version: tools_models.DatabaseVersion, + mock_add_user_to_repository: responses.BaseResponse, +): + result = t4c.T4CIntegration().configuration_hook( + db=db, + user=admin, + tool_version=capella_tool_version, + session_type=sessions_models.SessionType.PERSISTENT, + ) + + assert result["environment"]["T4C_LICENCE_SECRET"] + assert len(json.loads(result["environment"]["T4C_JSON"])) == 1 + assert result["environment"]["T4C_USERNAME"] == admin.name + assert result["environment"]["T4C_PASSWORD"] + assert not result["warnings"] + assert mock_add_user_to_repository.call_count == 1 + + +@responses.activate +@pytest.mark.usefixtures("t4c_model", "project_user") +def test_t4c_configuration_hook_failure( + db: orm.Session, + user: users_models.DatabaseUser, + capella_tool_version: tools_models.DatabaseVersion, + mock_add_user_to_repository_failed: responses.BaseResponse, +): + result = t4c.T4CIntegration().configuration_hook( + db=db, + user=user, + tool_version=capella_tool_version, + session_type=sessions_models.SessionType.PERSISTENT, + ) + + assert result["environment"]["T4C_LICENCE_SECRET"] + assert len(json.loads(result["environment"]["T4C_JSON"])) == 1 + assert len(result["warnings"]) == 1 + assert result["environment"]["T4C_USERNAME"] == user.name + assert result["environment"]["T4C_PASSWORD"] + assert mock_add_user_to_repository_failed.call_count == 1 + + +@responses.activate +@pytest.mark.usefixtures("t4c_model", "project_user") +def test_configuration_hook_for_archived_project( + project: projects_models.DatabaseProject, + db: orm.Session, + user: users_models.DatabaseUser, + capella_tool_version: tools_models.DatabaseVersion, + mock_add_user_to_repository: responses.BaseResponse, +): + project.is_archived = True + db.commit() + + result = t4c.T4CIntegration().configuration_hook( + db=db, + user=user, + tool_version=capella_tool_version, + session_type=sessions_models.SessionType.PERSISTENT, + ) + + assert not result["environment"]["T4C_LICENCE_SECRET"] + assert len(json.loads(result["environment"]["T4C_JSON"])) == 0 + assert result["environment"]["T4C_USERNAME"] == user.name + assert result["environment"]["T4C_PASSWORD"] + assert not result["warnings"] + assert mock_add_user_to_repository.call_count == 0 + + +@responses.activate +@pytest.mark.usefixtures("t4c_model", "project_user") +def test_configuration_hook_as_rw_user( + db: orm.Session, + user: users_models.DatabaseUser, + capella_tool_version: tools_models.DatabaseVersion, + mock_add_user_to_repository: responses.BaseResponse, + project_user: projects_users_models.ProjectUserAssociation, +): + project_user.permission = projects_users_models.ProjectUserPermission.READ + db.commit() + + result = t4c.T4CIntegration().configuration_hook( + db=db, + user=user, + tool_version=capella_tool_version, + session_type=sessions_models.SessionType.PERSISTENT, + ) + + assert not result["environment"]["T4C_LICENCE_SECRET"] + assert len(json.loads(result["environment"]["T4C_JSON"])) == 0 + assert result["environment"]["T4C_USERNAME"] == user.name + assert result["environment"]["T4C_PASSWORD"] + assert not result["warnings"] + assert mock_add_user_to_repository.call_count == 0 + + +@responses.activate +@pytest.mark.usefixtures("t4c_model", "project_user") +def test_configuration_hook_for_compatible_took( + db: orm.Session, + user: users_models.DatabaseUser, + capella_tool_version: tools_models.DatabaseVersion, + mock_add_user_to_repository: responses.BaseResponse, +): + custom_tool = tools_crud.create_tool( + db, tools_models.CreateTool(name="custom") + ) + create_compatible_tool_version = tools_models.CreateToolVersion( + name="compatible_tool_version", + config=tools_models.ToolVersionConfiguration( + compatible_versions=[capella_tool_version.id] + ), + ) + compatible_tool_version = tools_crud.create_version( + db, custom_tool, create_compatible_tool_version + ) + + result = t4c.T4CIntegration().configuration_hook( + db=db, + user=user, + tool_version=compatible_tool_version, + session_type=sessions_models.SessionType.PERSISTENT, + ) + + assert result["environment"]["T4C_LICENCE_SECRET"] + assert len(json.loads(result["environment"]["T4C_JSON"])) == 1 + assert result["environment"]["T4C_USERNAME"] == user.name + assert result["environment"]["T4C_PASSWORD"] + assert not result["warnings"] + assert mock_add_user_to_repository.call_count == 1 + + +def test_t4c_configuration_hook_non_persistent( + db: orm.Session, + user: users_models.DatabaseUser, + tool_version: tools_models.DatabaseVersion, +): + result = t4c.T4CIntegration().configuration_hook( + db=db, + user=user, + tool_version=tool_version, + session_type=sessions_models.SessionType.READONLY, + ) + + assert result == sessions_hooks_interface.ConfigurationHookResult() + + +def test_t4c_connection_hook_non_persistent( + user: users_models.DatabaseUser, + session: sessions_models.DatabaseSession, +): + session.type = sessions_models.SessionType.READONLY + result = t4c.T4CIntegration().session_connection_hook( + db_session=session, + user=user, + ) + + assert result == sessions_hooks_interface.SessionConnectionHookResult() + + +def test_t4c_connection_hook_shared_session( + db: orm.Session, + user: users_models.DatabaseUser, + session: sessions_models.DatabaseSession, +): + user2 = users_crud.create_user( + db, "shared_with_user", users_models.Role.USER + ) + session.owner = user2 + result = t4c.T4CIntegration().session_connection_hook( + db_session=session, + user=user, + ) + + assert result == sessions_hooks_interface.SessionConnectionHookResult() + + +def test_t4c_connection_hook( + user: users_models.DatabaseUser, + session: sessions_models.DatabaseSession, +): + session.environment = {"T4C_PASSWORD": "test"} + result = t4c.T4CIntegration().session_connection_hook( + db_session=session, + user=user, + ) + + assert result["t4c_token"] == "test" + + +@responses.activate +@pytest.mark.usefixtures("t4c_model", "project_user") +def test_t4c_termination_hook( + db: orm.Session, + session: sessions_models.DatabaseSession, + user: users_models.DatabaseUser, + t4c_instance: t4c_models.DatabaseT4CInstance, + capella_tool_version: tools_models.DatabaseVersion, +): + session.version = capella_tool_version + rsp = responses.delete( + f"{t4c_instance.rest_api}/users/{user.name}?repositoryName=test", + status=200, + ) + + t4c.T4CIntegration().pre_session_termination_hook(db=db, session=session) + + assert rsp.call_count == 1 diff --git a/backend/tests/sessions/test_session_routes.py b/backend/tests/sessions/test_session_routes.py index 6cb18ed53..ca2663e68 100644 --- a/backend/tests/sessions/test_session_routes.py +++ b/backend/tests/sessions/test_session_routes.py @@ -248,6 +248,16 @@ def test_get_all_sessions( assert len(response.json()) == 1 +@pytest.mark.usefixtures("user") +def test_get_session_by_id( + client: testclient.TestClient, + session: sessions_models.DatabaseSession, +): + response = client.get(f"/api/v1/sessions/{session.id}") + assert response.is_success + assert response.json()["id"] == session.id + + def test_own_sessions( db: orm.Session, client: testclient.TestClient, diff --git a/backend/tests/sessions/test_session_sharing.py b/backend/tests/sessions/test_session_sharing.py new file mode 100644 index 000000000..5a9fe27ed --- /dev/null +++ b/backend/tests/sessions/test_session_sharing.py @@ -0,0 +1,240 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import datetime + +import fastapi +import pytest +from fastapi import testclient +from sqlalchemy import orm + +from capellacollab.__main__ import app +from capellacollab.core.authentication import jwt_bearer +from capellacollab.sessions import crud as sessions_crud +from capellacollab.sessions import models as sessions_models +from capellacollab.tools import models as tools_models +from capellacollab.users import crud as users_crud +from capellacollab.users import injectables as users_injectables +from capellacollab.users import models as users_models + + +@pytest.fixture(name="enable_tool_session_sharing") +def fixture_enable_tool_session_sharing( + tool: tools_models.DatabaseTool, db: orm.Session +): + tool.config.connection.methods[0].sharing.enabled = True + orm.attributes.flag_modified(tool, "config") + db.commit() + + +@pytest.fixture(name="shared_with_user") +def fixture_shared_with_user(db: orm.Session) -> users_models.DatabaseUser: + user2 = users_crud.create_user( + db, "shared_with_user", users_models.Role.USER + ) + return user2 + + +@pytest.fixture(name="act_as_shared_with_user") +def fixture_act_as_shared_with_user( + shared_with_user: users_models.DatabaseUser, +): + def get_mock_own_user(): + return shared_with_user + + app.dependency_overrides[users_injectables.get_own_user] = ( + get_mock_own_user + ) + yield shared_with_user + del app.dependency_overrides[users_injectables.get_own_user] + + +@pytest.fixture(name="shared_session") +def fixture_shared_session( + session: sessions_models.DatabaseSession, + db: orm.Session, + shared_with_user: users_models.DatabaseUser, +) -> sessions_models.DatabaseSession: + sessions_crud.create_shared_session( + db, + sessions_models.DatabaseSharedSession( + created_at=datetime.datetime.now(), + session=session, + user=shared_with_user, + ), + ) + return session + + +def test_share_session_with_owner_fails( + session: sessions_models.DatabaseSession, + user: users_models.DatabaseUser, + client: testclient.TestClient, +): + response = client.post( + f"/api/v1/sessions/{session.id}/shares", + json={ + "username": user.name, + }, + ) + + assert response.status_code == 409 + assert response.json()["detail"]["err_code"] == "SESSION_ALREADY_SHARED" + + +@pytest.mark.usefixtures("enable_tool_session_sharing") +def test_session_is_already_shared( + session: sessions_models.DatabaseSession, + client: testclient.TestClient, + db: orm.Session, +): + user2 = users_crud.create_user(db, "user2", users_models.Role.USER) + response = client.post( + f"/api/v1/sessions/{session.id}/shares", + json={ + "username": user2.name, + }, + ) + + assert response.status_code == 200 + + # Try to share the session a second time + response = client.post( + f"/api/v1/sessions/{session.id}/shares", + json={ + "username": user2.name, + }, + ) + assert response.status_code == 409 + assert response.json()["detail"]["err_code"] == "SESSION_ALREADY_SHARED" + + +def test_user_to_share_with_doesnt_exist( + session: sessions_models.DatabaseSession, + client: testclient.TestClient, +): + response = client.post( + f"/api/v1/sessions/{session.id}/shares", + json={ + "username": "invalid-user", + }, + ) + + assert response.status_code == 404 + assert response.json()["detail"]["err_code"] == "USER_NOT_FOUND" + + +@pytest.mark.usefixtures( + "act_as_shared_with_user", "enable_tool_session_sharing" +) +def test_share_session_not_owned( + db: orm.Session, + shared_session: sessions_models.DatabaseSession, + client: testclient.TestClient, +): + user3 = users_crud.create_user(db, "user3", users_models.Role.USER) + + response = client.post( + f"/api/v1/sessions/{shared_session.id}/shares", + json={ + "username": user3.name, + }, + ) + assert response.status_code == 403 + assert response.json()["detail"]["err_code"] == "SESSION_NOT_OWNED" + + +@pytest.mark.usefixtures("act_as_shared_with_user") +def test_terminate_session_not_owned( + shared_session: sessions_models.DatabaseSession, + client: testclient.TestClient, +): + response = client.delete( + f"/api/v1/sessions/{shared_session.id}", + ) + + assert response.status_code == 403 + assert response.json()["detail"]["err_code"] == "SESSION_NOT_OWNED" + + +@pytest.mark.usefixtures("enable_tool_session_sharing") +def test_share_session( + session: sessions_models.DatabaseSession, + client: testclient.TestClient, + db: orm.Session, +): + user2 = users_crud.create_user(db, "user2", users_models.Role.USER) + response = client.post( + f"/api/v1/sessions/{session.id}/shares", + json={ + "username": user2.name, + }, + ) + + assert response.status_code == 200 + + response = client.get(f"/api/v1/sessions/{session.id}") + + assert response.status_code == 200 + assert len(response.json()["shared_with"]) == 1 + assert response.json()["shared_with"][0]["user"]["name"] == user2.name + + +@pytest.mark.usefixtures("act_as_shared_with_user") +def test_connect_to_shared_session( + shared_session: sessions_models.DatabaseSession, + client: testclient.TestClient, +): + response = client.get( + f"/api/v1/sessions/{shared_session.id}/connection", + ) + + assert response.status_code == 200 + + +@pytest.mark.usefixtures( + "act_as_shared_with_user", "enable_tool_session_sharing" +) +def test_connect_to_unshared_session_fails( + session: sessions_models.DatabaseSession, + client: testclient.TestClient, +): + response = client.get( + f"/api/v1/sessions/{session.id}/connection", + ) + + assert response.status_code == 403 + assert response.json()["detail"]["err_code"] == "SESSION_NOT_OWNED" + + +@pytest.mark.usefixtures("act_as_shared_with_user") +def test_shared_session_in_user_sessions( + shared_session: sessions_models.DatabaseSession, + client: testclient.TestClient, + shared_with_user: users_models.DatabaseUser, + basic_user: users_models.DatabaseUser, +): + response = client.get(f"/api/v1/users/{shared_with_user.id}/sessions") + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["id"] == shared_session.id + assert response.json()[0]["owner"]["id"] == basic_user.id + + +def test_tool_doesnt_support_sharing( + session: sessions_models.DatabaseSession, + db: orm.Session, + client: testclient.TestClient, +): + user2 = users_crud.create_user(db, "user2", users_models.Role.USER) + response = client.post( + f"/api/v1/sessions/{session.id}/shares", + json={ + "username": user2.name, + }, + ) + + assert response.status_code == 400 + assert ( + response.json()["detail"]["err_code"] == "SESSION_SHARING_UNSUPPORTED" + ) diff --git a/backend/tests/settings/conftest.py b/backend/tests/settings/conftest.py deleted file mode 100644 index 1e72f84de..000000000 --- a/backend/tests/settings/conftest.py +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -# SPDX-License-Identifier: Apache-2.0 - -import pytest -from sqlalchemy import orm - -from capellacollab.tools import crud as tools_crud -from capellacollab.tools import models as tools_models -from capellacollab.users import crud as users_crud -from capellacollab.users import models as users_models - - -@pytest.fixture(name="test_tool_version") -def fixture_test_tool_version(db: orm.Session) -> tools_models.DatabaseVersion: - tool = tools_crud.create_tool(db, tools_models.CreateTool(name="Test")) - return tools_crud.create_version( - db, tool, tools_models.CreateToolVersion(name="test") - ) diff --git a/backend/tests/settings/teamforcapella/conftest.py b/backend/tests/settings/teamforcapella/conftest.py index 50cea17cd..423bda78c 100644 --- a/backend/tests/settings/teamforcapella/conftest.py +++ b/backend/tests/settings/teamforcapella/conftest.py @@ -4,31 +4,6 @@ import pytest import responses from fastapi import status -from sqlalchemy import orm - -from capellacollab.settings.modelsources.t4c import crud as t4c_crud -from capellacollab.settings.modelsources.t4c import models as t4c_models -from capellacollab.tools import models as tools_models - - -@pytest.fixture(name="t4c_instance") -def fixture_t4c_instance( - db: orm.Session, - test_tool_version: tools_models.DatabaseVersion, -) -> t4c_models.DatabaseT4CInstance: - server = t4c_models.DatabaseT4CInstance( - name="test server", - license="lic", - host="localhost", - usage_api="http://localhost:8086", - rest_api="http://localhost:8080/api/v1.0", - username="user", - password="pass", - protocol=t4c_models.Protocol.tcp, - version=test_tool_version, - ) - - return t4c_crud.create_t4c_instance(db, server) @pytest.fixture(name="mock_license_server") diff --git a/backend/tests/settings/teamforcapella/fixtures.py b/backend/tests/settings/teamforcapella/fixtures.py new file mode 100644 index 000000000..eef937409 --- /dev/null +++ b/backend/tests/settings/teamforcapella/fixtures.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from sqlalchemy import orm + +import capellacollab.settings.modelsources.t4c.repositories.crud as t4c_repositories_crud +import capellacollab.settings.modelsources.t4c.repositories.models as t4c_repositories_models +from capellacollab.settings.modelsources.t4c import crud as t4c_crud +from capellacollab.settings.modelsources.t4c import models as t4c_models +from capellacollab.tools import models as tools_models + + +@pytest.fixture(name="t4c_instance") +def fixture_t4c_instance( + db: orm.Session, + tool_version: tools_models.DatabaseVersion, +) -> t4c_models.DatabaseT4CInstance: + server = t4c_models.DatabaseT4CInstance( + name="test server", + license="lic", + host="localhost", + usage_api="http://localhost:8086", + rest_api="http://localhost:8080/api/v1.0", + username="user", + password="pass", + protocol=t4c_models.Protocol.tcp, + version=tool_version, + ) + + return t4c_crud.create_t4c_instance(db, server) + + +@pytest.fixture(name="t4c_repository") +def fixture_t4c_repository( + t4c_instance: t4c_models.DatabaseT4CInstance, + db: orm.Session, +) -> t4c_repositories_models.DatabaseT4CRepository: + return t4c_repositories_crud.create_t4c_repository( + db=db, repo_name="test", instance=t4c_instance + ) diff --git a/backend/tests/settings/teamforcapella/test_t4c_instances.py b/backend/tests/settings/teamforcapella/test_t4c_instances.py index 0b1ff2dd1..99f6911c6 100644 --- a/backend/tests/settings/teamforcapella/test_t4c_instances.py +++ b/backend/tests/settings/teamforcapella/test_t4c_instances.py @@ -23,7 +23,7 @@ def test_create_t4c_instance( client: testclient.TestClient, db: orm.Session, - test_tool_version: tools_models.DatabaseVersion, + tool_version: tools_models.DatabaseVersion, ): response = client.post( "/api/v1/settings/modelsources/t4c", @@ -37,7 +37,7 @@ def test_create_t4c_instance( "username": "admin", "protocol": "tcp", "name": "Test integration", - "version_id": test_tool_version.id, + "version_id": tool_version.id, "password": "secret-password", }, ) @@ -55,7 +55,7 @@ def test_create_t4c_instance( def test_create_t4c_instance_already_existing_name( client: testclient.TestClient, t4c_instance: t4c_models.DatabaseT4CInstance, - test_tool_version: tools_models.DatabaseVersion, + tool_version: tools_models.DatabaseVersion, ): response = client.post( "/api/v1/settings/modelsources/t4c", @@ -69,7 +69,7 @@ def test_create_t4c_instance_already_existing_name( "rest_api": "http://localhost:8080", "username": "admin", "protocol": "tcp", - "version_id": test_tool_version.id, + "version_id": tool_version.id, "password": "secret-password", }, ) @@ -208,7 +208,7 @@ def test_unarchive_t4c_instance( def test_patch_t4c_instance_already_existing_name( client: testclient.TestClient, t4c_instance: t4c_models.DatabaseT4CInstance, - test_tool_version: tools_models.DatabaseVersion, + tool_version: tools_models.DatabaseVersion, ): instance_name_1 = t4c_instance.name instance_name_2 = instance_name_1 + "-2" @@ -225,7 +225,7 @@ def test_patch_t4c_instance_already_existing_name( "rest_api": "http://localhost:8080", "username": "admin", "protocol": "tcp", - "version_id": test_tool_version.id, + "version_id": tool_version.id, "password": "secret-password", }, ) diff --git a/backend/tests/tools/fixtures.py b/backend/tests/tools/fixtures.py index 27f311cfb..452db8eba 100644 --- a/backend/tests/tools/fixtures.py +++ b/backend/tests/tools/fixtures.py @@ -20,40 +20,15 @@ def fixture_tool( config=database_migration.get_eclipse_session_configuration(), ) - tool = tools_crud.create_tool(db, tool) + database_tool = tools_crud.create_tool(db, tool) def mock_get_existing_tool(*args, **kwargs) -> tools_models.DatabaseTool: - return tool + return database_tool monkeypatch.setattr( tools_injectables, "get_existing_tool", mock_get_existing_tool ) - return tool - - -@pytest.fixture(name="tool_version") -def fixture_tool_version( - db: orm.Session, - monkeypatch: pytest.MonkeyPatch, - tool: tools_models.DatabaseTool, -) -> tools_models.DatabaseVersion: - tool_version = tools_models.CreateToolVersion( - name="test", config=tools_models.ToolVersionConfiguration() - ) - - version = tools_crud.create_version(db, tool, tool_version) - - def get_existing_tool_version( - *args, **kwargs - ) -> tools_models.DatabaseVersion: - return version - - monkeypatch.setattr( - tools_injectables, - "get_existing_tool_version", - get_existing_tool_version, - ) - return version + return database_tool @pytest.fixture(name="tool_nature") diff --git a/backend/tests/tools/versions/fixtures.py b/backend/tests/tools/versions/fixtures.py new file mode 100644 index 000000000..cd8ac96af --- /dev/null +++ b/backend/tests/tools/versions/fixtures.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from sqlalchemy import orm + +from capellacollab.tools import crud as tools_crud +from capellacollab.tools import injectables as tools_injectables +from capellacollab.tools import models as tools_models + + +@pytest.fixture(name="tool_version") +def fixture_tool_version( + db: orm.Session, + monkeypatch: pytest.MonkeyPatch, + tool: tools_models.DatabaseTool, +) -> tools_models.DatabaseVersion: + tool_version = tools_models.CreateToolVersion( + name="test", config=tools_models.ToolVersionConfiguration() + ) + + version = tools_crud.create_version(db, tool, tool_version) + + def get_existing_tool_version( + *args, **kwargs + ) -> tools_models.DatabaseVersion: + return version + + monkeypatch.setattr( + tools_injectables, + "get_existing_tool_version", + get_existing_tool_version, + ) + return version diff --git a/backend/tests/users/fixtures.py b/backend/tests/users/fixtures.py index db8de9a4b..a7220650c 100644 --- a/backend/tests/users/fixtures.py +++ b/backend/tests/users/fixtures.py @@ -34,19 +34,24 @@ def fixture_unique_username() -> str: return str(uuid.uuid1()) +@pytest.fixture(name="basic_user") +def fixture_basic_user( + db: orm.Session, executor_name: str +) -> users_models.DatabaseUser: + return users_crud.create_user(db, executor_name, users_models.Role.USER) + + @pytest.fixture(name="user") def fixture_user( - db: orm.Session, executor_name: str + basic_user: users_models.DatabaseUser, ) -> t.Generator[users_models.DatabaseUser, None, None]: - user = users_crud.create_user(db, executor_name, users_models.Role.USER) - def get_mock_own_user(): - return user + return basic_user app.dependency_overrides[users_injectables.get_own_user] = ( get_mock_own_user ) - yield user + yield basic_user del app.dependency_overrides[users_injectables.get_own_user] diff --git a/docs/docs/admin/tools/configuration.md b/docs/docs/admin/tools/configuration.md index 957541778..5f17da362 100644 --- a/docs/docs/admin/tools/configuration.md +++ b/docs/docs/admin/tools/configuration.md @@ -364,7 +364,7 @@ which we provide as part of our XPRA_SUBPATH: "{CAPELLACOLLAB_SESSIONS_BASE_PATH}" CONNECTION_METHOD: xpra XPRA_CSP_ORIGIN_HOST: "{CAPELLACOLLAB_ORIGIN_BASE_URL}" - redirect_url: "{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/" + redirect_url: "{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&sharing=1&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/" cookies: token: "{CAPELLACOLLAB_SESSION_TOKEN}" monitoring: @@ -411,6 +411,8 @@ which we provide as part of our environment: {} redirect_url: "{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}{CAPELLACOLLAB_SESSIONS_BASE_PATH}/lab?token={CAPELLACOLLAB_SESSION_TOKEN}" cookies: {} + sharing: + enabled: True monitoring: prometheus: path: /prometheus diff --git a/docs/docs/user/sessions/jupyter/collaboration.md b/docs/docs/user/sessions/jupyter/collaboration.md index c75b921dd..595d517cc 100644 --- a/docs/docs/user/sessions/jupyter/collaboration.md +++ b/docs/docs/user/sessions/jupyter/collaboration.md @@ -5,29 +5,6 @@ # Collaboration with Jupyter Notebooks -Collaborating on Jupyter notebooks is a common requirement in various -workflows. This guide describes two main approaches to collaboration: -individual sessions and project-level collaboration. - -## Collaboration in Individual Sessions - -If you have a notebook in your personal workspace and another user wants to -connect to your sessions, follow these steps: - -### Sharing a Notebook Session - -1. **Request a Jupyter Session**: As usual, request a Jupyter session to start. -2. **Connect to the Session**: Open the requested session. -3. **Share the Session**: Click on the "Share" button on the top right of the - Jupyter session interface. -4. **Include the Token**: Tick the option "Include the token in the URL". -5. **Warning! Security Risk**: Be aware that anyone with access to the URL can - access your Jupyter session until termination. To revoke access, terminate - your session in the Capella Collaboration Manager. After restarting it, a - new token will be generated, and old sessions will no longer have access. -6. **Share the Link**: Distribute the link to all users who should have access. - They will be able to open your notebooks and concurrently edit them. - ## Collaboration on Project Level If you need a shared workspace for notebooks at the project level, you can @@ -44,9 +21,8 @@ create a shared space accessible by all project members. model. A dedicated workspace will be created automatically. During model creation, you don't have to link any sources or repositories. 2. **Access the Workspace**: The workspace is mounted into all newly created - Jupyter sessions to `/workspace/notebooks//`. - You'll see it in the Jupyter file explorer under - `/`. + Jupyter sessions to `/shared//`. You'll see it in + the Jupyter file explorer under `shared//`. ### Delete Project Notebook Share diff --git a/docs/docs/user/sessions/sharing.md b/docs/docs/user/sessions/sharing.md new file mode 100644 index 000000000..09d2f9167 --- /dev/null +++ b/docs/docs/user/sessions/sharing.md @@ -0,0 +1,30 @@ + + +It's possible to share a session with other users. Session sharing can be used +to collaborate with other users on the same session in real-time. + +The invited users will get full access to your session and can act on your +behalf. Make sure to trust the entered users and to monitor the session at any +time. The session will be shared until it is terminated. To revoke session +access for a user, terminate your session. + +1. Navigate to your Active Sessions +2. Find the session and select `Share`. + + !!! warning + + If you don't see the `Share` button, the tool does not support session sharing. + +3. To add users, enter their username in the input field. You add multiple + users by confirming with `Enter` after each username. The list with users + will be in the pending state. To invite the users, read and confirm the + safety information and confirm with `Submit`. +4. Once shared, the session will appear in the user's active sessions list. + The invited users can connect to the session as known from own sessions. + + !!! info + + The invited users can't terminate the session or share the session to other users. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 43fdb60b2..795d5b5c6 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -46,6 +46,7 @@ nav: - Read-Only: user/sessions/types/read-only.md - Request Session: user/sessions/request.md - (Re-)Connect to Session: user/sessions/reconnect.md + - Session sharing: user/sessions/sharing.md - Terminate Session: user/sessions/terminate.md - Taking Screenshots: user/sessions/screenshots/index.md - Files Browser: user/sessions/files/index.md diff --git a/frontend/Makefile b/frontend/Makefile index 93d2ae0c2..c4c307d44 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -19,12 +19,12 @@ storybook: ng run capellacollab:storybook openapi: - rm -rf src/app/openapi && mkdir -p src/app/openapi python postprocess_openapi_schema.py + mkdir -p /tmp/openapi docker run --rm \ -v /tmp/openapi.json:/tmp/openapi.json \ -v $$(pwd)/openapi_templates:/tmp/openapi_templates \ - -v $$(pwd)/src/app/openapi:/tmp/output \ + -v /tmp/openapi:/tmp/output \ -u $$(id -u $${USER}):$$(id -g $${USER}) \ openapitools/openapi-generator-cli:v7.5.0 generate \ -i /tmp/openapi.json \ @@ -33,5 +33,6 @@ openapi: --additional-properties=fileNaming=kebab-case,legacyDiscriminatorBehavior=false \ -g typescript-angular \ -o /tmp/output - cp ../LICENSES/.license_header_cc0.txt src/app/openapi/.openapi-generator/FILES.license - cp ../LICENSES/.license_header_cc0.txt src/app/openapi/.openapi-generator/VERSION.license + cp ../LICENSES/.license_header_cc0.txt /tmp/openapi/.openapi-generator/FILES.license + cp ../LICENSES/.license_header_cc0.txt /tmp/openapi/.openapi-generator/VERSION.license + rsync -avh --delete /tmp/openapi/ $$(pwd)/src/app/openapi diff --git a/frontend/src/app/general/footer/footer.docs.mdx b/frontend/src/app/general/footer/footer.docs.mdx index 8656f8b9d..d42c0eb4c 100644 --- a/frontend/src/app/general/footer/footer.docs.mdx +++ b/frontend/src/app/general/footer/footer.docs.mdx @@ -20,4 +20,4 @@ This is how the footer components looks like on desktop devices: On mobile devices, the part in the middle is not centered: - + diff --git a/frontend/src/app/general/header/header.stories.ts b/frontend/src/app/general/header/header.stories.ts index bfb0a1667..a065ce329 100644 --- a/frontend/src/app/general/header/header.stories.ts +++ b/frontend/src/app/general/header/header.stories.ts @@ -5,7 +5,7 @@ import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; import { UserWrapperService } from 'src/app/services/user/user.service'; -import { MockUserService } from 'src/storybook/user'; +import { mockUser, MockUserService } from 'src/storybook/user'; import { HeaderComponent } from './header.component'; const meta: Meta = { @@ -23,7 +23,7 @@ export const NormalUser: Story = { providers: [ { provide: UserWrapperService, - useFactory: () => new MockUserService('user'), + useFactory: () => new MockUserService(mockUser), }, ], }), @@ -40,7 +40,8 @@ export const Administrator: Story = { providers: [ { provide: UserWrapperService, - useFactory: () => new MockUserService('administrator'), + useFactory: () => + new MockUserService({ ...mockUser, role: 'administrator' }), }, ], }), diff --git a/frontend/src/app/openapi/.openapi-generator/FILES b/frontend/src/app/openapi/.openapi-generator/FILES index bc5a3b373..5f5c90eca 100644 --- a/frontend/src/app/openapi/.openapi-generator/FILES +++ b/frontend/src/app/openapi/.openapi-generator/FILES @@ -124,10 +124,12 @@ model/session-monitoring-input.ts model/session-monitoring-output.ts model/session-ports.ts model/session-provisioning-request.ts +model/session-sharing.ts model/session-tool-configuration-input.ts model/session-tool-configuration-output.ts model/session-type.ts model/session.ts +model/share-session-request.ts model/simple-t4-c-model.ts model/status-response.ts model/submit-t4-c-model.ts @@ -154,6 +156,8 @@ model/tool-session-connection-output.ts model/tool-session-environment-input.ts model/tool-session-environment-output.ts model/tool-session-environment-stage.ts +model/tool-session-sharing-configuration-input.ts +model/tool-session-sharing-configuration-output.ts model/tool-version-configuration-input.ts model/tool-version-configuration-output.ts model/tool-version-with-tool.ts diff --git a/frontend/src/app/openapi/api/sessions.service.ts b/frontend/src/app/openapi/api/sessions.service.ts index e63b08b4b..b0d21ba7c 100644 --- a/frontend/src/app/openapi/api/sessions.service.ts +++ b/frontend/src/app/openapi/api/sessions.service.ts @@ -28,6 +28,10 @@ import { PayloadResponseModelSessionConnectionInformation } from '../model/paylo import { PostSessionRequest } from '../model/post-session-request'; // @ts-ignore import { Session } from '../model/session'; +// @ts-ignore +import { SessionSharing } from '../model/session-sharing'; +// @ts-ignore +import { ShareSessionRequest } from '../model/share-session-request'; // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; @@ -350,6 +354,83 @@ export class SessionsService { ); } + /** + * Get Session + * @param sessionId + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getSession(sessionId: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getSession(sessionId: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getSession(sessionId: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getSession(sessionId: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (sessionId === null || sessionId === undefined) { + throw new Error('Required parameter sessionId was null or undefined when calling getSession.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (PersonalAccessToken) required + localVarCredential = this.configuration.lookupCredential('PersonalAccessToken'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Basic ' + localVarCredential); + } + + // authentication (JWTBearer) required + localVarCredential = this.configuration.lookupCredential('JWTBearer'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Bearer ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/v1/sessions/${this.configuration.encodeParam({name: "sessionId", value: sessionId, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + /** * Get Session Connection Information * @param sessionId @@ -602,6 +683,97 @@ export class SessionsService { ); } + /** + * Share Session + * @param sessionId + * @param shareSessionRequest + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public shareSession(sessionId: string, shareSessionRequest: ShareSessionRequest, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public shareSession(sessionId: string, shareSessionRequest: ShareSessionRequest, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public shareSession(sessionId: string, shareSessionRequest: ShareSessionRequest, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public shareSession(sessionId: string, shareSessionRequest: ShareSessionRequest, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (sessionId === null || sessionId === undefined) { + throw new Error('Required parameter sessionId was null or undefined when calling shareSession.'); + } + if (shareSessionRequest === null || shareSessionRequest === undefined) { + throw new Error('Required parameter shareSessionRequest was null or undefined when calling shareSession.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (PersonalAccessToken) required + localVarCredential = this.configuration.lookupCredential('PersonalAccessToken'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Basic ' + localVarCredential); + } + + // authentication (JWTBearer) required + localVarCredential = this.configuration.lookupCredential('JWTBearer'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Bearer ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/v1/sessions/${this.configuration.encodeParam({name: "sessionId", value: sessionId, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/shares`; + return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: shareSessionRequest, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + /** * Upload Files * @param sessionId diff --git a/frontend/src/app/openapi/model/guacamole-connection-method-input.ts b/frontend/src/app/openapi/model/guacamole-connection-method-input.ts index 1659b8afd..5e85b13be 100644 --- a/frontend/src/app/openapi/model/guacamole-connection-method-input.ts +++ b/frontend/src/app/openapi/model/guacamole-connection-method-input.ts @@ -10,6 +10,7 @@ */ import { Environment } from './environment'; +import { ToolSessionSharingConfigurationInput } from './tool-session-sharing-configuration-input'; import { RDPPortsInput } from './rdp-ports-input'; @@ -23,6 +24,7 @@ export interface GuacamoleConnectionMethodInput { * Connection method specific environment variables. Check the global environment field for more information. */ environment?: object; + sharing?: ToolSessionSharingConfigurationInput; } export namespace GuacamoleConnectionMethodInput { export type TypeEnum = 'guacamole'; diff --git a/frontend/src/app/openapi/model/guacamole-connection-method-output.ts b/frontend/src/app/openapi/model/guacamole-connection-method-output.ts index f3b9781d1..00bff79e0 100644 --- a/frontend/src/app/openapi/model/guacamole-connection-method-output.ts +++ b/frontend/src/app/openapi/model/guacamole-connection-method-output.ts @@ -9,6 +9,7 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ +import { ToolSessionSharingConfigurationOutput } from './tool-session-sharing-configuration-output'; import { RDPPortsOutput } from './rdp-ports-output'; import { Environment1 } from './environment1'; @@ -23,6 +24,7 @@ export interface GuacamoleConnectionMethodOutput { * Connection method specific environment variables. Check the global environment field for more information. */ environment: object; + sharing: ToolSessionSharingConfigurationOutput; } export namespace GuacamoleConnectionMethodOutput { export type TypeEnum = 'guacamole'; diff --git a/frontend/src/app/openapi/model/http-connection-method-input.ts b/frontend/src/app/openapi/model/http-connection-method-input.ts index 509d5dc4d..29604c02b 100644 --- a/frontend/src/app/openapi/model/http-connection-method-input.ts +++ b/frontend/src/app/openapi/model/http-connection-method-input.ts @@ -10,6 +10,7 @@ */ import { Environment } from './environment'; +import { ToolSessionSharingConfigurationInput } from './tool-session-sharing-configuration-input'; import { HTTPPortsInput } from './http-ports-input'; @@ -23,6 +24,7 @@ export interface HTTPConnectionMethodInput { * Connection method specific environment variables. Check the global environment field for more information. */ environment?: object; + sharing?: ToolSessionSharingConfigurationInput; redirect_url?: string; /** * Cookies, which are required to connect to the session. diff --git a/frontend/src/app/openapi/model/http-connection-method-output.ts b/frontend/src/app/openapi/model/http-connection-method-output.ts index 34ee4e8df..cead73c4d 100644 --- a/frontend/src/app/openapi/model/http-connection-method-output.ts +++ b/frontend/src/app/openapi/model/http-connection-method-output.ts @@ -9,6 +9,7 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ +import { ToolSessionSharingConfigurationOutput } from './tool-session-sharing-configuration-output'; import { HTTPPortsOutput } from './http-ports-output'; import { Environment1 } from './environment1'; @@ -23,6 +24,7 @@ export interface HTTPConnectionMethodOutput { * Connection method specific environment variables. Check the global environment field for more information. */ environment: object; + sharing: ToolSessionSharingConfigurationOutput; redirect_url: string; /** * Cookies, which are required to connect to the session. diff --git a/frontend/src/app/openapi/model/models.ts b/frontend/src/app/openapi/model/models.ts index e4fc630db..87570ecea 100644 --- a/frontend/src/app/openapi/model/models.ts +++ b/frontend/src/app/openapi/model/models.ts @@ -108,9 +108,11 @@ export * from './session-monitoring-input'; export * from './session-monitoring-output'; export * from './session-ports'; export * from './session-provisioning-request'; +export * from './session-sharing'; export * from './session-tool-configuration-input'; export * from './session-tool-configuration-output'; export * from './session-type'; +export * from './share-session-request'; export * from './simple-t4-c-model'; export * from './status-response'; export * from './submit-t4-c-model'; @@ -138,6 +140,8 @@ export * from './tool-session-connection-output-methods-inner'; export * from './tool-session-environment-input'; export * from './tool-session-environment-output'; export * from './tool-session-environment-stage'; +export * from './tool-session-sharing-configuration-input'; +export * from './tool-session-sharing-configuration-output'; export * from './tool-version'; export * from './tool-version-configuration-input'; export * from './tool-version-configuration-output'; diff --git a/frontend/src/app/openapi/model/session-sharing.ts b/frontend/src/app/openapi/model/session-sharing.ts new file mode 100644 index 000000000..4f988a1a0 --- /dev/null +++ b/frontend/src/app/openapi/model/session-sharing.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + +import { BaseUser } from './base-user'; + + +export interface SessionSharing { + user: BaseUser; + created_at: string; +} + diff --git a/frontend/src/app/openapi/model/session.ts b/frontend/src/app/openapi/model/session.ts index df96d53d4..4a2a34835 100644 --- a/frontend/src/app/openapi/model/session.ts +++ b/frontend/src/app/openapi/model/session.ts @@ -14,6 +14,7 @@ import { SessionType } from './session-type'; import { Message } from './message'; import { ToolVersionWithTool } from './tool-version-with-tool'; import { ToolSessionConnectionMethod } from './tool-session-connection-method'; +import { SessionSharing } from './session-sharing'; export interface Session { @@ -27,6 +28,7 @@ export interface Session { last_seen: string; connection_method_id: string; connection_method: ToolSessionConnectionMethod | null; + shared_with: Array; } export namespace Session { } diff --git a/frontend/src/app/openapi/model/share-session-request.ts b/frontend/src/app/openapi/model/share-session-request.ts new file mode 100644 index 000000000..4e6c4b556 --- /dev/null +++ b/frontend/src/app/openapi/model/share-session-request.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export interface ShareSessionRequest { + username: string; +} + diff --git a/frontend/src/app/openapi/model/tool-session-connection-input-methods-inner.ts b/frontend/src/app/openapi/model/tool-session-connection-input-methods-inner.ts index 4a42b6584..d11ba4365 100644 --- a/frontend/src/app/openapi/model/tool-session-connection-input-methods-inner.ts +++ b/frontend/src/app/openapi/model/tool-session-connection-input-methods-inner.ts @@ -12,6 +12,7 @@ import { GuacamoleConnectionMethodInput } from './guacamole-connection-method-input'; import { Environment } from './environment'; import { HTTPConnectionMethodInput } from './http-connection-method-input'; +import { ToolSessionSharingConfigurationInput } from './tool-session-sharing-configuration-input'; import { HTTPPortsInput } from './http-ports-input'; @@ -25,6 +26,7 @@ export interface ToolSessionConnectionInputMethodsInner { * Connection method specific environment variables. Check the global environment field for more information. */ environment?: { [key: string]: Environment; }; + sharing?: ToolSessionSharingConfigurationInput; redirect_url?: string; /** * Cookies, which are required to connect to the session. diff --git a/frontend/src/app/openapi/model/tool-session-connection-method.ts b/frontend/src/app/openapi/model/tool-session-connection-method.ts index d9104d2aa..6482f3ed9 100644 --- a/frontend/src/app/openapi/model/tool-session-connection-method.ts +++ b/frontend/src/app/openapi/model/tool-session-connection-method.ts @@ -10,6 +10,7 @@ */ import { SessionPorts } from './session-ports'; +import { ToolSessionSharingConfigurationOutput } from './tool-session-sharing-configuration-output'; import { Environment1 } from './environment1'; @@ -23,5 +24,6 @@ export interface ToolSessionConnectionMethod { * Connection method specific environment variables. Check the global environment field for more information. */ environment: object; + sharing: ToolSessionSharingConfigurationOutput; } diff --git a/frontend/src/app/openapi/model/tool-session-connection-output-methods-inner.ts b/frontend/src/app/openapi/model/tool-session-connection-output-methods-inner.ts index 8d9363967..3d3f06102 100644 --- a/frontend/src/app/openapi/model/tool-session-connection-output-methods-inner.ts +++ b/frontend/src/app/openapi/model/tool-session-connection-output-methods-inner.ts @@ -9,6 +9,7 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ +import { ToolSessionSharingConfigurationOutput } from './tool-session-sharing-configuration-output'; import { HTTPConnectionMethodOutput } from './http-connection-method-output'; import { GuacamoleConnectionMethodOutput } from './guacamole-connection-method-output'; import { HTTPPortsOutput } from './http-ports-output'; @@ -25,6 +26,7 @@ export interface ToolSessionConnectionOutputMethodsInner { * Connection method specific environment variables. Check the global environment field for more information. */ environment: { [key: string]: Environment1; }; + sharing: ToolSessionSharingConfigurationOutput; redirect_url: string; /** * Cookies, which are required to connect to the session. diff --git a/frontend/src/app/openapi/model/tool-session-sharing-configuration-input.ts b/frontend/src/app/openapi/model/tool-session-sharing-configuration-input.ts new file mode 100644 index 000000000..cf79363ce --- /dev/null +++ b/frontend/src/app/openapi/model/tool-session-sharing-configuration-input.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export interface ToolSessionSharingConfigurationInput { + /** + * Allow sharing of a session container with other users. The tool / connection method has to support multiple connections to the same container. + */ + enabled?: boolean; +} + diff --git a/frontend/src/app/openapi/model/tool-session-sharing-configuration-output.ts b/frontend/src/app/openapi/model/tool-session-sharing-configuration-output.ts new file mode 100644 index 000000000..58a6debbd --- /dev/null +++ b/frontend/src/app/openapi/model/tool-session-sharing-configuration-output.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export interface ToolSessionSharingConfigurationOutput { + /** + * Allow sharing of a session container with other users. The tool / connection method has to support multiple connections to the same container. + */ + enabled: boolean; +} + diff --git a/frontend/src/app/projects/models/backup-settings/trigger-pipeline/trigger-pipeline.stories.ts b/frontend/src/app/projects/models/backup-settings/trigger-pipeline/trigger-pipeline.stories.ts index 90ca40eea..12cc36686 100644 --- a/frontend/src/app/projects/models/backup-settings/trigger-pipeline/trigger-pipeline.stories.ts +++ b/frontend/src/app/projects/models/backup-settings/trigger-pipeline/trigger-pipeline.stories.ts @@ -15,7 +15,7 @@ import { UserWrapperService } from 'src/app/services/user/user.service'; import { dialogWrapper } from 'src/storybook/decorators'; import { mockPrimaryGitModel } from 'src/storybook/git'; import { mockTeamForCapellaRepository } from 'src/storybook/t4c'; -import { MockUserService } from 'src/storybook/user'; +import { MockUserService, mockUser } from 'src/storybook/user'; const meta: Meta = { title: 'Pipeline Components / Trigger Pipeline', @@ -129,7 +129,8 @@ export const ForcePipelineDeletion: Story = { }, { provide: UserWrapperService, - useFactory: () => new MockUserService('administrator'), + useFactory: () => + new MockUserService({ ...mockUser, role: 'administrator' }), }, ], }), diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.docs.mdx b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.docs.mdx index 8e8008907..bc3334096 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.docs.mdx +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.docs.mdx @@ -36,3 +36,14 @@ in an unknown state that looks like this: The only difference between a persistent session and a read-only session is the title. Since we only covered persistent sessions above, this is a read-only session: + +## Session sharing + +When the tool supports session sharing, the session owner can see a share button: + + +When the session is shared, the names of users who have access to the session are displayed: + + +For the user who has access to the shared session, the session looks like this: + diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html index 3c76e1570..d5ce376fa 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html @@ -47,7 +47,7 @@

Read-only session

{{ sessionService.beautifyState(session.state).text }} @@ -56,36 +56,83 @@

Read-only session

The session was created {{ beautifyService.beatifyDate(session.created_at) }}

- +
Tool: {{ session.version.tool.name }} ({{ session.version.name }})
Connection Method: {{ session.connection_method?.name }} - +
+ @if (session.shared_with.length > 0 && !isSessionShared(session)) { +
+ Shared with: + @for ( + sharedSession of session.shared_with; + track sharedSession.user.id + ) { + + {{ sharedSession.user.name }} + + @if (!$last) { + + + } + } +
+ } + @if (isSessionShared(session)) { +
+ share +
+ This session is shared with you and was created by
+ {{ session.owner.name }} + +
+
+ }
-
- +
+ @if (!isSessionShared(session)) { + + } + - + @if (!isSessionShared(session)) { + + } + @if (!isSessionShared(session)) { + + }
} diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.ts index 420ded2cd..ed9714bfe 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.ts @@ -14,7 +14,9 @@ import { RouterLink } from '@angular/router'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { Session } from 'src/app/openapi'; import { BeautifyService } from 'src/app/services/beatify/beautify.service'; +import { UserWrapperService } from 'src/app/services/user/user.service'; import { ConnectionDialogComponent } from 'src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.component'; +import { SessionSharingDialogComponent } from 'src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.component'; import { DeleteSessionDialogComponent } from '../../delete-session-dialog/delete-session-dialog.component'; import { SessionService, @@ -49,6 +51,7 @@ export class ActiveSessionsComponent { public sessionService: SessionService, public beautifyService: BeautifyService, public userSessionService: UserSessionService, + private userWrapperService: UserWrapperService, private dialog: MatDialog, ) {} @@ -68,7 +71,17 @@ export class ActiveSessionsComponent { }); } + openShareDialog(session: Session): void { + this.dialog.open(SessionSharingDialogComponent, { + data: session, + }); + } + uploadFileDialog(session: Session): void { this.dialog.open(FileBrowserDialogComponent, { data: session }); } + + isSessionShared(session: Session): boolean { + return session.owner.id != this.userWrapperService.user?.id; + } } diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts index 34b158cdf..d0f5149a5 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts @@ -3,14 +3,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { + Meta, + StoryObj, + componentWrapperDecorator, + moduleMetadata, +} from '@storybook/angular'; import { Observable, of } from 'rxjs'; import { Session } from 'src/app/openapi'; +import { UserWrapperService } from 'src/app/services/user/user.service'; import { createPersistentSessionWithState, mockSuccessReadonlySession, } from 'src/storybook/session'; +import { mockHttpConnectionMethod } from 'src/storybook/tool'; +import { MockUserService, mockUser } from 'src/storybook/user'; import { UserSessionService } from '../../service/user-session.service'; import { ActiveSessionsComponent } from './active-sessions.component'; @@ -29,6 +37,19 @@ class MockUserSessionService implements Partial { const meta: Meta = { title: 'Session Components / Active Sessions', component: ActiveSessionsComponent, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: UserWrapperService, + useFactory: () => new MockUserService(mockUser), + }, + ], + }), + componentWrapperDecorator( + (story) => `
${story}
`, + ), + ], }; export default meta; @@ -144,3 +165,84 @@ export const ReadonlySessionSuccessStateStory: Story = { }), ], }; + +export const SessionSharingEnabled: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: UserSessionService, + useFactory: () => + new MockUserSessionService({ + ...createPersistentSessionWithState('Started'), + connection_method: { + ...mockHttpConnectionMethod, + sharing: { enabled: true }, + }, + }), + }, + ], + }), + ], +}; + +export const SessionSharedWithUser: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: UserSessionService, + useFactory: () => + new MockUserSessionService({ + ...createPersistentSessionWithState('Started'), + connection_method: { + ...mockHttpConnectionMethod, + sharing: { enabled: true }, + }, + shared_with: [ + { + user: { + id: 1, + name: 'user_1', + role: 'administrator', + }, + created_at: '2024-04-29T15:00:00Z', + }, + { + user: { + id: 2, + name: 'user_2', + role: 'user', + }, + created_at: '2024-04-29T15:00:00Z', + }, + ], + }), + }, + ], + }), + ], +}; + +export const SharedSession: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: UserSessionService, + useFactory: () => + new MockUserSessionService({ + ...createPersistentSessionWithState('Started'), + }), + }, + { + provide: UserWrapperService, + useFactory: () => new MockUserService({ ...mockUser, id: 2 }), + }, + ], + }), + ], +}; diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.component.html b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.component.html index 8217683f5..acbe46018 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.component.html +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.component.html @@ -3,34 +3,35 @@ ~ SPDX-License-Identifier: Apache-2.0 --> -
+

Connect to Session

+@if ( + isPersistentSessionAlias(session) && session.version.tool.integrations.t4c +) {
TeamForCapella session token
- If you'd like to connect to TeamForCapella while working with Capella, -
- please copy your session token and enter it when prompted in your Capella - session. -
- You can always return to this dialog by clicking on "Connect" in the "Active - sessions" overview.
-
+ @if (connectionInfo?.t4c_token) { + If you'd like to connect to TeamForCapella while working with Capella, +
+ please copy your session token and enter it when prompted in your Capella + session. +
+ You can always return to this dialog by clicking on "Connect" in the + "Active sessions" overview.
-
-
- No session token was generated for your session. -
+ } @else { + @if (userService.user?.id && userService.user?.id !== session.owner.id) { + You can't access the TeamForCapella token of a shared session. + } @else { + No session token was generated for your session. + } + }

-
+}
Connect to the session
diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.component.ts index eea89d524..1f27f70e0 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.component.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.component.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { NgIf } from '@angular/common'; import { Component, Inject } from '@angular/core'; import { MatButton } from '@angular/material/button'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; @@ -22,7 +21,7 @@ import { DisplayValueComponent } from '../../../../helpers/display-value/display templateUrl: './connection-dialog.component.html', styleUrls: ['./connection-dialog.component.css'], standalone: true, - imports: [NgIf, DisplayValueComponent, MatButton], + imports: [DisplayValueComponent, MatButton], }) export class ConnectionDialogComponent { isPersistentSessionAlias = isPersistentSession; diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.docs.mdx b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.docs.mdx new file mode 100644 index 000000000..28dfde123 --- /dev/null +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.docs.mdx @@ -0,0 +1,28 @@ +{/* + SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + SPDX-License-Identifier: Apache-2.0 +*/} + +import * as ConnectionDialogComponent from './connection-dialog.stories.ts' +import { Meta, Title, Story, Canvas, Unstyled } from '@storybook/blocks' + + + + + +When the TeamForCapella integration is disabled, +the corresponding section is not displayed: + +<Story of={ConnectionDialogComponent.WithoutTeamForCapella} /> + +When the TeamForCapella integration is enabled and a token is generated, it is displayed: + +<Story of={ConnectionDialogComponent.SharedSession} /> + +When the session is shared, the token is not exposed: + +<Story of={ConnectionDialogComponent.WithSessionToken} /> + +In other cases where no token is generated, another message is displayed: + +<Story of={ConnectionDialogComponent.WithoutSessionToken} /> diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.stories.ts new file mode 100644 index 000000000..3c56a08b1 --- /dev/null +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.stories.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { UserWrapperService } from 'src/app/services/user/user.service'; +import { dialogWrapper } from 'src/storybook/decorators'; +import { startedSession } from 'src/storybook/session'; +import { mockTool } from 'src/storybook/tool'; +import { MockUserService, mockUser } from 'src/storybook/user'; +import { ConnectionDialogComponent } from './connection-dialog.component'; + +const meta: Meta<ConnectionDialogComponent> = { + title: 'Session Components / Connection Dialog', + component: ConnectionDialogComponent, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: startedSession, + }, + ], + }), + dialogWrapper, + ], +}; + +export default meta; +type Story = StoryObj<ConnectionDialogComponent>; + +export const WithoutTeamForCapella: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + ...startedSession, + version: { + ...startedSession.version, + tool: { ...mockTool, integrations: { t4c: false } }, + }, + }, + }, + ], + }), + ], +}; + +export const SharedSession: Story = { + args: { + connectionInfo: { + local_storage: {}, + cookies: {}, + t4c_token: '', + redirect_url: 'https://example.com', + }, + }, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + ...startedSession, + owner: { ...mockUser, id: '2' }, + }, + }, + { + provide: UserWrapperService, + useFactory: () => new MockUserService(mockUser), + }, + ], + }), + ], +}; + +export const WithSessionToken: Story = { + args: { + connectionInfo: { + local_storage: {}, + cookies: {}, + t4c_token: 'sessiontoken', + redirect_url: 'https://example.com', + }, + }, +}; + +export const WithoutSessionToken: Story = { + args: { + connectionInfo: { + local_storage: {}, + cookies: {}, + t4c_token: '', + redirect_url: 'https://example.com', + }, + }, +}; diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.html b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.html index 09a1263e9..aa6d1d6ef 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.html +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.html @@ -4,7 +4,7 @@ --> <div class="dialog"> - <h1>File browser</h1> + <h1 class="text-lg font-bold">Session File Browser</h1> <mat-progress-bar mode="indeterminate" *ngIf="loadingFiles"> </mat-progress-bar> diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.component.html b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.component.html new file mode 100644 index 000000000..dde66fcb6 --- /dev/null +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.component.html @@ -0,0 +1,115 @@ +<!-- + ~ SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + ~ SPDX-License-Identifier: Apache-2.0 + --> + +<div class="m-m-card max-h-[95vh]"> + <h1 class="text-lg font-bold"> + Share your {{ session.version.tool.name }} session with others + </h1> + <ul class="my-2 ml-6 list-outside list-disc text-base leading-relaxed"> + <li> + Others will get <b>full access to your session</b> and can act on your + behalf. Make sure to <b>trust the entered users</b> and to monitor the + session at any time. + </li> + <li> + The session will be shared until it is terminated. + <b>To revoke session access for a user, terminate your session.</b> + </li> + <li> + Once shared, the session will appear in the user's active sessions list. + The invited users can connect to the session as known from own sessions. + </li> + <li> + The invited users can't terminate the session or share the session to + other users. + </li> + </ul> + + <form [formGroup]="form" (submit)="submit()"> + <div class="mt-1"> + <div class="mb-3"> + Enter the usernames you want to share the session with: + </div> + <mat-form-field + subscriptSizing="dynamic" + appearance="outline" + class="w-full" + > + <mat-label>Usernames</mat-label> + <mat-chip-grid #chipGrid formControlName="username" tabIndex="-1"> + @for (user of users; track user.username) { + <mat-chip-row + [ngClass]="{ + '!bg-green-300': user.state === 'success', + '!bg-red-300': user.state === 'error', + }" + [removable]="user.state !== 'success'" + (removed)="removeUser(user.username)" + [disableRipple]="true" + [matTooltip]="user.tooltip" + > + <mat-icon matChipAvatar> + @switch (user.state) { + @case ("success") { + check + } + @case ("pending") { + hourglass_empty + } + @case ("error") { + error + } + } + </mat-icon> + {{ user.username }} + @if (user.state !== "success") { + <button matChipRemove aria-label="Remove user"> + <mat-icon>cancel</mat-icon> + </button> + } + </mat-chip-row> + } + <input + placeholder="New username..." + class="!h-[40px]" + [matChipInputFor]="chipGrid" + [matChipInputAddOnBlur]="true" + (matChipInputTokenEnd)="addUser($event)" + /> + </mat-chip-grid> + </mat-form-field> + <div class="mx-1 my-2 text-sm italic text-gray-500"> + For privacy reasons, we're not able to offer auto-completion for + usernames. Make sure to enter the correct username (case-sensitive). + <br /> + To determine the username of another user, ask the user to navigate to + "Menu" > "Profile". + </div> + </div> + + <div class="flex flex-wrap justify-between gap-2 pb-2"> + <div> + <mat-checkbox formControlName="confirmation" color="primary"> + <span + [ngClass]="{ + 'text-warn': !form.controls.confirmation.value, + }" + > + I've read the above warnings and take full responsibility for my + session. + </span> + </mat-checkbox> + </div> + <button + type="submit" + color="primary" + mat-stroked-button + [disabled]="!this.form.valid || loading" + > + Submit <mat-icon>send</mat-icon> + </button> + </div> + </form> +</div> diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.component.ts new file mode 100644 index 000000000..74cdce84d --- /dev/null +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.component.ts @@ -0,0 +1,188 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { Component, Inject } from '@angular/core'; +import { + AbstractControl, + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + ValidationErrors, + ValidatorFn, + Validators, +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { catchError, combineLatest, of, tap } from 'rxjs'; +import { DisplayValueComponent } from 'src/app/helpers/display-value/display-value.component'; +import { ToastService } from 'src/app/helpers/toast/toast.service'; +import { Session, SessionsService } from 'src/app/openapi'; + +@Component({ + selector: 'app-session-sharing-dialog', + standalone: true, + imports: [ + CommonModule, + MatCheckboxModule, + DisplayValueComponent, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + FormsModule, + ReactiveFormsModule, + MatChipsModule, + MatTooltipModule, + ], + templateUrl: './session-sharing-dialog.component.html', + styles: ` + :host { + display: block; + } + `, +}) +export class SessionSharingDialogComponent { + form = new FormGroup({ + username: new FormControl('', [ + Validators.required, + this.usersSelectedValidator(), + ]), + confirmation: new FormControl(false, Validators.requiredTrue), + }); + + loading = false; + users: Array<AddedUser> = []; + + constructor( + @Inject(MAT_DIALOG_DATA) public session: Session, + private toastService: ToastService, + private dialogRef: MatDialogRef<SessionSharingDialogComponent>, + private sessionsService: SessionsService, + ) { + for (const session of this.session.shared_with) { + this.users.push({ + username: session.user.name, + state: 'success', + tooltip: 'The session is already shared with this user.', + }); + } + } + + usersSelectedValidator(): ValidatorFn { + return (_: AbstractControl): ValidationErrors | null => { + if (this.users === undefined) { + return null; + } + const users = this.users.filter((user) => user.state !== 'success'); + if (!users.length) { + return { required: true }; + } + return null; + }; + } + + submit() { + if (this.form.invalid || this.loading) { + return; + } + + const observables = []; + let errorCount = 0; + this.loading = true; + + for (const user of this.users) { + if (user.state === 'success') { + continue; + } + const username = user.username; + observables.push( + this.sessionsService.shareSession(this.session.id, { username }).pipe( + tap({ + next: () => { + this.toastService.showSuccess( + 'Session sucessfully shared', + `The session has been shared with user ${username}. ` + + 'The user should be able to see the session in their personal list of sessions.', + ); + this.updateState(username, 'success'); + this.updateTooltip( + username, + 'The session has been shared with the user.', + ); + }, + error: (err) => { + errorCount++; + this.updateState(username, 'error'); + if (err.error.detail?.reason) { + this.updateTooltip(username, err.error.detail?.reason); + } else { + this.updateTooltip(username, "The user couldn't be added."); + } + }, + }), + catchError((err) => of(err)), + ), + ); + } + + combineLatest(observables).subscribe({ + next: () => { + this.loading = false; + if (errorCount == 0) { + this.dialogRef.close(); + } + }, + }); + } + + removeUser(username: string) { + const index = this.users.map((user) => user.username).indexOf(username); + if (index >= 0) { + this.users.splice(index, 1); + } + } + + addUser(event: MatChipInputEvent): void { + const value = (event.value || '').trim(); + + if (value && !this.users.find((user) => user.username === value)) { + this.users.push({ + username: value, + state: 'pending', + tooltip: 'Submit the form to add the user.', + }); + } + + event.chipInput!.clear(); + } + + updateState(username: string, state: 'success' | 'pending' | 'error') { + const user = this.users.find((user) => user.username === username); + if (user) { + user.state = state; + } + } + + updateTooltip(username: string, tooltip: string) { + const user = this.users.find((user) => user.username === username); + if (user) { + user.tooltip = tooltip; + } + } +} + +type AddedUser = { + username: string; + state: 'success' | 'pending' | 'error'; + tooltip: string; +}; diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.stories.ts new file mode 100644 index 000000000..9825c914d --- /dev/null +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.stories.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { dialogWrapper } from 'src/storybook/decorators'; +import { createPersistentSessionWithState } from 'src/storybook/session'; +import { SessionSharingDialogComponent } from './session-sharing-dialog.component'; + +const meta: Meta<SessionSharingDialogComponent> = { + title: 'Session Components / Session Sharing Dialog', + component: SessionSharingDialogComponent, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: createPersistentSessionWithState('running'), + }, + ], + }), + dialogWrapper, + ], +}; + +export default meta; +type Story = StoryObj<SessionSharingDialogComponent>; + +export const Default: Story = { + args: { + users: [ + { + username: 'user-already-added', + state: 'success', + tooltip: 'The session has been shared with the user.', + }, + { + username: 'user-adding-failed', + state: 'error', + tooltip: "The user couldn't be added.", + }, + { + username: 'user-creation-pending', + state: 'pending', + tooltip: 'Submit the form to add the user.', + }, + ], + }, +}; diff --git a/frontend/src/storybook/session.ts b/frontend/src/storybook/session.ts index 6baf6de0a..222f2da4c 100644 --- a/frontend/src/storybook/session.ts +++ b/frontend/src/storybook/session.ts @@ -22,6 +22,7 @@ export function createPersistentSessionWithState(state: string): Session { connection_method: mockHttpConnectionMethod, warnings: [], connection_method_id: 'default', + shared_with: [], }; } @@ -45,4 +46,5 @@ export const mockSuccessReadonlySession: Readonly<ReadonlySession> = { connection_method: mockHttpConnectionMethod, warnings: [], connection_method_id: 'default', + shared_with: [], }; diff --git a/frontend/src/storybook/tool.ts b/frontend/src/storybook/tool.ts index 0ea94e479..2ea2b9997 100644 --- a/frontend/src/storybook/tool.ts +++ b/frontend/src/storybook/tool.ts @@ -18,6 +18,9 @@ export const mockHttpConnectionMethod: Readonly<HTTPConnectionMethodOutput> = { environment: {}, redirect_url: 'https://example.com', cookies: {}, + sharing: { + enabled: false, + }, }; export const mockToolVersion: Readonly<ToolVersion> = { diff --git a/frontend/src/storybook/user.ts b/frontend/src/storybook/user.ts index 5582e499d..e1b21f559 100644 --- a/frontend/src/storybook/user.ts +++ b/frontend/src/storybook/user.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { BehaviorSubject } from 'rxjs'; import { User } from 'src/app/openapi'; import { UserRole, @@ -18,14 +19,17 @@ export const mockUser: Readonly<User> = { }; export class MockUserService implements Partial<UserWrapperService> { - role: UserRole; + _user = new BehaviorSubject<User | undefined>(undefined); + user$ = this._user.asObservable(); + user: User | undefined = mockUser; - constructor(role: UserRole) { - this.role = role; + constructor(user: User) { + this._user.next(user); + this.user = user; } validateUserRole(requiredRole: UserRole): boolean { const roles = ['user', 'administrator']; - return roles.indexOf(requiredRole) <= roles.indexOf(this.role); + return roles.indexOf(requiredRole) <= roles.indexOf(this.user!.role); } } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index c51c8117d..5b2da56e7 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -346,6 +346,11 @@ Angular Material Card Styles @apply !tracking-normal; } +.mdc-evolution-chip--with-avatar.mdc-evolution-chip--with-primary-graphic + .mdc-evolution-chip__graphic { + padding-right: 0px !important; +} + /* Option cards */ diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 194a9f0fe..398e7a8ed 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -17,6 +17,7 @@ module.exports = { hover: "var(--hover-color)", archived: "#D1D5DB", url: "#2563eb", + warn: "#f44336", }, spacing: { button: "0.5rem",