From 51de80b8f3b745fd0600e0c0158b4cc434248d54 Mon Sep 17 00:00:00 2001 From: Wille Marcel Date: Mon, 26 Jun 2023 16:17:14 -0300 Subject: [PATCH 1/4] OSM-Teams Integration --- backend/__init__.py | 12 + backend/api/system/authentication.py | 92 +++- backend/api/teams/resources.py | 4 + backend/config.py | 11 + backend/models/dtos/team_dto.py | 4 + backend/models/postgis/team.py | 4 + backend/services/team_service.py | 2 + example.env | 11 + frontend/.env.expand | 2 + frontend/src/components/formInputs.js | 9 +- frontend/src/components/svgIcons/index.js | 1 + .../src/components/svgIcons/user-group.js | 16 + .../src/components/teamsAndOrgs/members.js | 10 +- .../src/components/teamsAndOrgs/messages.js | 101 ++++ .../src/components/teamsAndOrgs/teamSync.js | 521 ++++++++++++++++++ frontend/src/components/teamsAndOrgs/teams.js | 11 +- frontend/src/config/index.js | 4 + frontend/src/hooks/UseOSMTeams.js | 57 ++ frontend/src/network/genericJSONRequest.js | 15 +- frontend/src/store/actions/auth.js | 16 + frontend/src/store/reducers/auth.js | 4 + frontend/src/utils/login.js | 27 + frontend/src/utils/teamMembersDiff.js | 7 + frontend/src/views/authorized.js | 31 +- frontend/src/views/teams.js | 114 +++- migrations/versions/52a67f6cef20_.py | 28 + 26 files changed, 1078 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/svgIcons/user-group.js create mode 100644 frontend/src/components/teamsAndOrgs/teamSync.js create mode 100644 frontend/src/hooks/UseOSMTeams.js create mode 100644 migrations/versions/52a67f6cef20_.py diff --git a/backend/__init__.py b/backend/__init__.py index 3c64e02ecc..f4e631c63e 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -80,6 +80,10 @@ def format_url(endpoint): scope=EnvironmentConfig.OAUTH_SCOPE, redirect_uri=EnvironmentConfig.OAUTH_REDIRECT_URI, ) +osm_teams = OAuth2Session( + client_id=EnvironmentConfig.OSM_TEAMS_CLIENT_ID, + scope="openid offline", +) # Import all models so that they are registered with SQLAlchemy from backend.models.postgis import * # noqa @@ -375,6 +379,8 @@ def add_api_endpoints(app): SystemAuthenticationEmailAPI, SystemAuthenticationLoginAPI, SystemAuthenticationCallbackAPI, + OSMTeamsAuthenticationCallbackAPI, + OSMTeamsAuthenticationAPI ) from backend.api.system.applications import SystemApplicationsRestAPI from backend.api.system.image_upload import SystemImageUploadRestAPI @@ -923,9 +929,15 @@ def add_api_endpoints(app): api.add_resource( SystemAuthenticationLoginAPI, format_url("system/authentication/login/") ) + api.add_resource( + OSMTeamsAuthenticationAPI, format_url("system/osm-teams-authentication/login/") + ) api.add_resource( SystemAuthenticationCallbackAPI, format_url("system/authentication/callback/") ) + api.add_resource( + OSMTeamsAuthenticationCallbackAPI, format_url("system/osm-teams-authentication/callback/") + ) api.add_resource( SystemAuthenticationEmailAPI, format_url("system/authentication/email/") ) diff --git a/backend/api/system/authentication.py b/backend/api/system/authentication.py index bab51832d5..1bc07bb189 100644 --- a/backend/api/system/authentication.py +++ b/backend/api/system/authentication.py @@ -2,7 +2,7 @@ from flask_restful import Resource from oauthlib.oauth2.rfc6749.errors import InvalidGrantError -from backend import osm +from backend import osm, osm_teams from backend.config import EnvironmentConfig from backend.services.users.authentication_service import ( AuthenticationService, @@ -43,6 +43,33 @@ def get(self): return {"auth_url": login_url, "state": state}, 200 +class OSMTeamsAuthenticationAPI(Resource): + def get(self): + """ + Returns URL to allow authentication in OSM Teams + --- + tags: + - system + produces: + - application/json + parameters: + - in: query + name: redirect_uri + description: Route to redirect user once authenticated + type: string + default: /take/me/here + responses: + 200: + description: oauth2 params + """ + state = AuthenticationService.generate_random_state() + osm_teams.state = state + login_url, state = osm_teams.authorization_url( + EnvironmentConfig.OSM_TEAMS_AUTH_URL + ) + return {"auth_url": login_url, "state": state}, 200 + + class SystemAuthenticationCallbackAPI(Resource): def get(self): """ @@ -126,6 +153,69 @@ def get(self): return {"Error": "Unable to authenticate", "SubCode": "AuthError"}, 500 +class OSMTeamsAuthenticationCallbackAPI(Resource): + def get(self): + """ + Handles the OSM Teams OAuth callback + --- + tags: + - system + produces: + - application/json + parameters: + - in: query + name: redirect_uri + description: Route to redirect user once authenticated + type: string + default: /take/me/here + required: false + - in: query + name: code + description: Code obtained after user authorization + type: string + required: true + - in: query + name: email_address + description: Email address to used for email notifications from TM. + type: string + required: false + responses: + 302: + description: Redirects to login page, or login failed page + 500: + description: A problem occurred authenticating the user + 502: + description: A problem occurred negotiating with the OSM API + """ + + authorization_code = request.args.get("code", None) + if authorization_code is None: + return {"Subcode": "InvalidData", "Error": "Missing code parameter"}, 500 + + try: + osm_teams_response = osm_teams.fetch_token( + token_url=EnvironmentConfig.OSM_TEAMS_TOKEN_URL, + client_secret=EnvironmentConfig.OSM_TEAMS_CLIENT_SECRET, + code=authorization_code, + ) + except InvalidGrantError: + return { + "Error": "The provided authorization grant is invalid, expired or revoked", + "SubCode": "InvalidGrantError", + }, 400 + if osm_teams_response is None: + current_app.logger.critical("Couldn't obtain token from OSM Teams.") + return { + "Subcode": "TokenFetchError", + "Error": "Couldn't fetch token from OSM Teams.", + }, 502 + + try: + return osm_teams_response, 200 + except AuthServiceError: + return {"Error": "Unable to authenticate", "SubCode": "AuthError"}, 500 + + class SystemAuthenticationEmailAPI(Resource): def get(self): """ diff --git a/backend/api/teams/resources.py b/backend/api/teams/resources.py index 5bd768c35d..4643349aea 100644 --- a/backend/api/teams/resources.py +++ b/backend/api/teams/resources.py @@ -45,6 +45,8 @@ def patch(self, team_id): name: type: string default: HOT - Mappers + osm_teams_id: + type: integer logo: type: string default: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg @@ -349,6 +351,8 @@ def post(self): organisation_id: type: integer default: 1 + osm_teams_id: + type: integer description: type: string visibility: diff --git a/backend/config.py b/backend/config.py index a76256e6d4..322d9655f6 100644 --- a/backend/config.py +++ b/backend/config.py @@ -237,6 +237,17 @@ class EnvironmentConfig: # Sentry backend DSN SENTRY_BACKEND_DSN = os.getenv("TM_SENTRY_BACKEND_DSN", None) + # OSM Teams + OSM_TEAMS_CLIENT_ID = os.getenv("OSM_TEAMS_CLIENT_ID", None) + OSM_TEAMS_CLIENT_SECRET = os.getenv("OSM_TEAMS_CLIENT_SECRET", None) + OSM_TEAMS_AUTH_DOMAIN = os.getenv("OSM_TEAMS_AUTH_DOMAIN", None) + OSM_TEAMS_TOKEN_DOMAIN = os.getenv("OSM_TEAMS_TOKEN_DOMAIN", OSM_TEAMS_AUTH_DOMAIN) + OSM_TEAMS_AUTH_PATH = os.getenv("OSM_TEAMS_AUTH_PATH", "/hyauth/oauth2/auth") + OSM_TEAMS_TOKEN_PATH = os.getenv("OSM_TEAMS_TOKEN_PATH", "/hyauth/oauth2/token") + OSM_TEAMS_AUTH_URL = f"{OSM_TEAMS_AUTH_DOMAIN}{OSM_TEAMS_AUTH_PATH}" + OSM_TEAMS_TOKEN_URL = f"{OSM_TEAMS_TOKEN_DOMAIN}{OSM_TEAMS_TOKEN_PATH}" + OSM_TEAMS_API_URL = os.getenv("OSM_TEAMS_API_URL", None) + class TestEnvironmentConfig(EnvironmentConfig): POSTGRES_TEST_DB = os.getenv("POSTGRES_TEST_DB", None) diff --git a/backend/models/dtos/team_dto.py b/backend/models/dtos/team_dto.py index 58f2ee692b..30cf5a16d6 100644 --- a/backend/models/dtos/team_dto.py +++ b/backend/models/dtos/team_dto.py @@ -92,6 +92,7 @@ def __init__(self): """ Describes JSON model for a team """ team_id = IntType(serialized_name="teamId") organisation_id = IntType(required=True) + osm_teams_id = IntType(required=False) organisation = StringType(required=True) organisation_slug = StringType(serialized_name="organisationSlug") name = StringType(required=True) @@ -131,6 +132,7 @@ class TeamDTO(Model): members = ListType(ModelType(TeamMembersDTO)) members_count = IntType(serialized_name="membersCount", required=False) managers_count = IntType(serialized_name="managersCount", required=False) + osm_teams_id = IntType(required=False) class TeamsListDTO(Model): @@ -150,6 +152,7 @@ class NewTeamDTO(Model): creator = LongType(required=True) organisation_id = IntType(required=True) name = StringType(required=True) + osm_teams_id = IntType() description = StringType() join_method = StringType( required=True, @@ -166,6 +169,7 @@ class UpdateTeamDTO(Model): creator = LongType() team_id = IntType() + osm_teams_id = IntType() organisation = StringType() organisation_id = IntType() name = StringType() diff --git a/backend/models/postgis/team.py b/backend/models/postgis/team.py index f224965c56..072f631c04 100644 --- a/backend/models/postgis/team.py +++ b/backend/models/postgis/team.py @@ -78,6 +78,7 @@ class Team(db.Model): visibility = db.Column( db.Integer, default=TeamVisibility.PUBLIC.value, nullable=False ) + osm_teams_id = db.Column(db.BigInteger, nullable=True) organisation = db.relationship(Organisation, backref="teams") @@ -95,6 +96,7 @@ def create_from_dto(cls, new_team_dto: NewTeamDTO): new_team.description = new_team_dto.description new_team.join_method = TeamJoinMethod[new_team_dto.join_method].value new_team.visibility = TeamVisibility[new_team_dto.visibility].value + new_team.osm_teams_id = new_team_dto.osm_teams_id org = Organisation.get(new_team_dto.organisation_id) new_team.organisation = org @@ -195,6 +197,7 @@ def as_dto(self): team_dto.name = self.name team_dto.organisation = self.organisation.name team_dto.organisation_id = self.organisation.id + team_dto.osm_teams_id = self.osm_teams_id team_dto.logo = self.organisation.logo team_dto.visibility = TeamVisibility(self.visibility).name return team_dto @@ -205,6 +208,7 @@ def as_dto_inside_org(self): team_dto.team_id = self.id team_dto.name = self.name team_dto.description = self.description + team_dto.osm_teams_id = self.osm_teams_id team_dto.join_method = TeamJoinMethod(self.join_method).name team_dto.members = self._get_team_members() team_dto.visibility = TeamVisibility(self.visibility).name diff --git a/backend/services/team_service.py b/backend/services/team_service.py index b1df1f3329..218ee34268 100644 --- a/backend/services/team_service.py +++ b/backend/services/team_service.py @@ -302,6 +302,7 @@ def get_all_teams(search_dto: TeamSearchDTO) -> TeamsListDTO: team_dto.join_method = TeamJoinMethod(team.join_method).name team_dto.visibility = TeamVisibility(team.visibility).name team_dto.description = team.description + team_dto.osm_teams_id = team.osm_teams_id team_dto.logo = team.organisation.logo team_dto.organisation = team.organisation.name team_dto.organisation_id = team.organisation.id @@ -341,6 +342,7 @@ def get_team_as_dto( team_dto.join_method = TeamJoinMethod(team.join_method).name team_dto.visibility = TeamVisibility(team.visibility).name team_dto.description = team.description + team_dto.osm_teams_id = team.osm_teams_id team_dto.logo = team.organisation.logo team_dto.organisation = team.organisation.name team_dto.organisation_id = team.organisation.id diff --git a/example.env b/example.env index b02511f8f1..22d77843f9 100644 --- a/example.env +++ b/example.env @@ -204,3 +204,14 @@ TM_DEFAULT_LOCALE=en # Sentry.io DSN Config (optional) # TM_SENTRY_BACKEND_DSN=https://foo.ingest.sentry.io/1234567 # TM_SENTRY_FRONTEND_DSN=https://bar.ingest.sentry.io/8901234 + +# OSM Teams +OSM_TEAMS_AUTH_DOMAIN='https://auth.mapping.team' +OSM_TEAMS_AUTH_PATH='/hyauth/oauth2/auth' +# The TOKEN domain only needs to be set if some network restriction blocks getting a token from the AUTH domain +# If it is not configured, TM will use the AUTH domain. +OSM_TEAMS_TOKEN_DOMAIN='https://auth.mapping.team' +OSM_TEAMS_TOKEN_PATH='/hyauth/oauth2/token' +OSM_TEAMS_API_URL='https://mapping.team' +# OSM_TEAMS_CLIENT_ID=foo +# OSM_TEAMS_CLIENT_SECRET=foo \ No newline at end of file diff --git a/frontend/.env.expand b/frontend/.env.expand index df7cb9d0ba..28ee666a05 100644 --- a/frontend/.env.expand +++ b/frontend/.env.expand @@ -43,3 +43,5 @@ REACT_APP_SENTRY_FRONTEND_DSN=$TM_SENTRY_FRONTEND_DSN REACT_APP_ENVIRONMENT=$TM_ENVIRONMENT REACT_APP_TM_DEFAULT_CHANGESET_COMMENT=$TM_DEFAULT_CHANGESET_COMMENT REACT_APP_RAPID_EDITOR_URL=$RAPID_EDITOR_URL +REACT_APP_OSM_TEAMS_API_URL=$OSM_TEAMS_API_URL +REACT_APP_OSM_TEAMS_CLIENT_ID=$OSM_TEAMS_CLIENT_ID diff --git a/frontend/src/components/formInputs.js b/frontend/src/components/formInputs.js index e9b7157224..13f035ff58 100644 --- a/frontend/src/components/formInputs.js +++ b/frontend/src/components/formInputs.js @@ -9,7 +9,13 @@ import { formatCountryList } from '../utils/countries'; import { fetchLocalJSONAPI } from '../network/genericJSONRequest'; import { CheckIcon, SearchIcon, CloseIcon } from './svgIcons'; -export const RadioField = ({ name, value, className, required = false }: Object) => ( +export const RadioField = ({ + name, + value, + className, + required = false, + disabled = false, +}: Object) => ( ); diff --git a/frontend/src/components/svgIcons/index.js b/frontend/src/components/svgIcons/index.js index b59491f78d..78332c8007 100644 --- a/frontend/src/components/svgIcons/index.js +++ b/frontend/src/components/svgIcons/index.js @@ -82,3 +82,4 @@ export { CutIcon } from './cut'; export { FileImportIcon } from './fileImport'; export { CalendarIcon } from './calendar'; export { CommentIcon } from './comment'; +export { UserGroupIcon } from './user-group'; diff --git a/frontend/src/components/svgIcons/user-group.js b/frontend/src/components/svgIcons/user-group.js new file mode 100644 index 0000000000..1998a20ccb --- /dev/null +++ b/frontend/src/components/svgIcons/user-group.js @@ -0,0 +1,16 @@ +import React from 'react'; + +// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/ +// License: CC-By 4.0 +export class UserGroupIcon extends React.PureComponent { + render() { + return ( + + + + ); + } +} diff --git a/frontend/src/components/teamsAndOrgs/members.js b/frontend/src/components/teamsAndOrgs/members.js index a87358bb69..e69f49b580 100644 --- a/frontend/src/components/teamsAndOrgs/members.js +++ b/frontend/src/components/teamsAndOrgs/members.js @@ -24,6 +24,7 @@ export function Members({ setMemberJoinTeamError, managerJoinTeamError, setManagerJoinTeamError, + disableEdit, }: Object) { const token = useSelector((state) => state.auth.token); const [editMode, setEditMode] = useState(false); @@ -82,8 +83,15 @@ export function Members({

{title}

- + {!disableEdit && + + }
+ {disableEdit && +
+ +
+ }
{editMode && ( ( + + OSM Teams + +); + +const reSyncUsers = ({ + tmTeamId, + members, + managers, + osmTeamsId, + osmteams_token, + token, + forceUpdate, + setErrors, +}) => { + setErrors(false); + Promise.all([ + fetchExternalJSONAPI( + new URL(`/api/teams/${osmTeamsId}/members`, OSM_TEAMS_API_URL), + `Bearer ${osmteams_token}`, + 'GET', + ), + fetchExternalJSONAPI( + new URL(`/api/teams/${osmTeamsId}/moderators`, OSM_TEAMS_API_URL), + `Bearer ${osmteams_token}`, + 'GET', + ), + ]).then(([osmTeamsUsers, osmTeamsModerators]) => { + const { members: osmTeamsMembers, managers: osmTeamsManagers } = filterOSMTeamsMembers( + osmTeamsUsers.members.data, + osmTeamsModerators, + ); + const { usersAdded, usersRemoved } = getMembersDiff( + members, + osmTeamsMembers.map((user) => ({ username: user.name, function: 'MEMBER', active: true })), + false, + ); + const { usersAdded: managersAdded, usersRemoved: managersRemoved } = getMembersDiff( + managers, + osmTeamsManagers.map((user) => ({ username: user.name, function: 'MANAGER', active: true })), + true, + ); + const errors = []; + Promise.all([ + ...managersRemoved.map((user) => leaveTeamRequest(tmTeamId, user, 'MANAGER', token)), + ...usersRemoved.map((user) => leaveTeamRequest(tmTeamId, user, 'MEMBER', token)), + ...managersAdded.map((user) => + joinTeamRequest(tmTeamId, user, 'MANAGER', token).catch((e) => + errors.push({ username: user, function: 'MANAGER' }), + ), + ), + ...usersAdded.map((user) => + joinTeamRequest(tmTeamId, user, 'MEMBER', token).catch((e) => + errors.push({ username: user, function: 'MEMBER' }), + ), + ), + ]); + setErrors(errors); + forceUpdate(); + }); +}; + +export const TeamSync = ({ + osmTeamsId, + setOsmTeamsId, + setManagers, + setMembers, + managers, + members, + tmTeamId, + updateMode, + forceUpdate, + updateTeam, +}) => { + const intl = useIntl(); + let [searchParams, setSearchParams] = useSearchParams(); + const osmteams_token = useSelector((state) => state.auth.osmteams_token); + const token = useSelector((state) => state.auth.token); + const [errors, setErrors] = useState(searchParams?.get('syncUsersErrors')); + const [showSelectionModal, setShowSelectionModal] = useState(false); + const reSyncParams = { + tmTeamId, + members, + managers, + osmTeamsId, + osmteams_token, + token, + setManagers, + setMembers, + forceUpdate, + setErrors, + }; + + return ( +
+
+

+ +

+
+ , + }} + /> +
+
+ {osmteams_token ? ( + osmTeamsId ? ( + <> + {osmTeamsId && } + {updateMode && ( + <> + + {errors?.length > 0 && ( +
setErrors(false)} + title={intl.formatMessage(messages.dismiss)} + > + + u.username).join(' ,'), + number: errors.length, + }} + /> +

+ +

+
+
+ )} + + )} + + ) : !showSelectionModal ? ( + setShowSelectionModal(true)} + > + + + ) : ( + setShowSelectionModal(false)} + /> + ) + ) : ( + + )} + {searchParams.get('access_token') && ( + { + const newSearchParams = { ...searchParams }; + delete newSearchParams.access_token; + setSearchParams(newSearchParams); + }} + /> + )} +
+ ); +}; + +const TeamBasicInfo = ({ teamId }) => { + const intl = useIntl(); + const [error, isLoading, team] = useOSMTeamInfo(teamId); + + if (teamId && error) { + return ( +
+ +
+ ); + } + + return ( + +
+

OSM Teams #{team.id}

+

+ {team.name} + + + +

+
+
+ ); +}; + +const TeamInfo = ({ members, managers, teamId, isLoadingMembers }) => { + const intl = useIntl(); + const [error, isLoading, team] = useOSMTeamInfo(teamId); + if (error) + return ( +
+ +
+ ); + + return ( +
+

+ +

+ +
+

+ {team.name} + + + +

+

+ {team.bio} +

+
+ +
+
+ {managers.map((user) => ( + + ))} +
+
+ +
+
+ {members.map((user) => ( + + ))} +
+
+
+
+ ); +}; + +export const SelectOSMTeamsModal = ({ + osmTeamsId, + setOsmTeamsId, + setManagers, + setMembers, + tmTeamId, + updateTeam, + forceUpdate, + closeSelectionModal, +}) => { + const token = useSelector((state) => state.auth.token); + const [error, isLoading, myTeams] = useOSMTeams(); + const [selectedTeamId, setSelectedTeamId] = useState(); + const [syncStatus, setSyncStatus] = useState(); + const [teamMembersError, teamMembersIsLoading, teamMembers] = useOSMTeamUsers( + osmTeamsId || selectedTeamId, + ); + const [teamModeratorsError, teamModeratorsIsLoading, teamModerators] = useOSMTeamModerators( + osmTeamsId || selectedTeamId, + ); + const { members, managers } = filterOSMTeamsMembers( + teamMembers?.members?.data || [], + teamModerators?.length ? teamModerators : [], + ); + + const syncToExistingTeam = () => { + setSyncStatus('started'); + updateTeam(selectedTeamId); + setSyncStatus('waiting'); + const errors = []; + managers.map((user) => + joinTeamRequest(tmTeamId, user.name, 'MANAGER', token).catch((e) => + errors.push({ username: user.name, function: 'MANAGER' }), + ), + ); + members.map((user) => + joinTeamRequest(tmTeamId, user.name, 'MEMBER', token).catch((e) => + errors.push({ username: user.name, function: 'MEMBER' }), + ), + ); + forceUpdate(); + setOsmTeamsId(selectedTeamId); + }; + + const syncToNewTeam = () => { + setSyncStatus('started'); + setOsmTeamsId(selectedTeamId); + setManagers(managers.map((user) => ({ username: user.name }))); + setMembers(members.map((user) => ({ username: user.name }))); + setSyncStatus('finished'); + }; + + return ( + closeSelectionModal()}> + {(close) => ( + <> +
+ {osmTeamsId || selectedTeamId ? ( +
+ + {(teamMembersError || teamModeratorsError) && ( + + )} +
+ ) : ( + <> +

+ +

+
+ {error ? ( + + ) : ( + + + + + } + > + {myTeams?.data?.map((team) => ( + + ))} + + )} +
+ + )} +
+
+ {(selectedTeamId || osmTeamsId) && ( + + )} +
+ + {(osmTeamsId || selectedTeamId) && ( + + )} +
+
+ + )} +
+ ); +}; + +const OSMTeamCard = ({ team, selectTeam }) => ( +
selectTeam(team.id)} className="w-50-ns w-100 dib"> +
+
+
+ + + +
+
+

{team.name}

+
+
+); + +const OSMTeamsAuthButton = () => { + const location = useLocation(); + const [debouncedCreateLoginWindow] = useDebouncedCallback( + (redirectTo) => createOSMTeamsLoginWindow(redirectTo), + 3000, + { leading: true }, + ); + + return ( + <> +

+ +

+ + + ); +}; + +const SuccessfulAuthenticationModal = ({ onCloseFn }) => { + return ( + onCloseFn()}> + {(close) => ( +
+

+ +

+

+ +

+ +
+ )} +
+ ); +}; diff --git a/frontend/src/components/teamsAndOrgs/teams.js b/frontend/src/components/teamsAndOrgs/teams.js index 7ec2f241e8..7906cc0b55 100644 --- a/frontend/src/components/teamsAndOrgs/teams.js +++ b/frontend/src/components/teamsAndOrgs/teams.js @@ -158,7 +158,7 @@ export function TeamCard({ team }: Object) { ); } -export function TeamInformation(props) { +export function TeamInformation({ disableJoinMethodField }) { const intl = useIntl(); const labelClasses = 'db pt3 pb2'; const fieldClasses = 'blue-grey w-100 pv3 ph2 input-reset ba b--grey-light bg-transparent'; @@ -195,7 +195,12 @@ export function TeamInformation(props) { {Object.keys(joinMethods).map((method) => (
- + @@ -271,7 +276,7 @@ export function TeamForm(props) {
- +
diff --git a/frontend/src/config/index.js b/frontend/src/config/index.js index ac4e8f07c0..cccd6515e7 100644 --- a/frontend/src/config/index.js +++ b/frontend/src/config/index.js @@ -63,6 +63,10 @@ export const POTLATCH2_EDITOR_URL = export const RAPID_EDITOR_URL = process.env.REACT_APP_RAPID_EDITOR_URL || 'https://mapwith.ai/rapid'; +// OSM Teams integration +export const OSM_TEAMS_API_URL = process.env.REACT_APP_OSM_TEAMS_API_URL || 'https://mapping.team'; +export const OSM_TEAMS_CLIENT_ID = process.env.REACT_APP_OSM_TEAMS_CLIENT_ID || ''; + export const TASK_COLOURS = { READY: '#fff', LOCKED_FOR_MAPPING: '#fff', diff --git a/frontend/src/hooks/UseOSMTeams.js b/frontend/src/hooks/UseOSMTeams.js new file mode 100644 index 0000000000..0cabd049c0 --- /dev/null +++ b/frontend/src/hooks/UseOSMTeams.js @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { OSM_TEAMS_API_URL } from '../config'; +import { fetchExternalJSONAPI } from '../network/genericJSONRequest'; + +const useFetchExternal = (url, trigger = true, token) => { + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [data, setData] = useState({}); + + useEffect(() => { + (async () => { + if (trigger) { + setLoading(true); + try { + // replace in locale is needed because the backend uses underscore instead of dash + const response = await fetchExternalJSONAPI( + url, + token, + 'GET', + ); + setData(response); + setLoading(false); + } catch (e) { + setError(e); + setLoading(false); + } + } + })(); + }, [url, token, trigger]); + return [error, loading, data]; +}; + +export const useOSMTeams = () => { + const osmTeamsToken = useSelector((state) => state.auth.osmteams_token); + const myTeamsURL = new URL('/api/my/teams', OSM_TEAMS_API_URL); + return useFetchExternal(myTeamsURL.href, osmTeamsToken, `Bearer ${osmTeamsToken}`); +}; + +export const useOSMTeamUsers = (teamId) => { + const osmTeamsToken = useSelector((state) => state.auth.osmteams_token); + const myTeamsURL = new URL(`/api/teams/${teamId}/members`, OSM_TEAMS_API_URL); + return useFetchExternal(myTeamsURL.href, Boolean(teamId), `Bearer ${osmTeamsToken}`); +}; + +export const useOSMTeamModerators = (teamId) => { + const osmTeamsToken = useSelector((state) => state.auth.osmteams_token); + const myTeamsURL = new URL(`/api/teams/${teamId}/moderators`, OSM_TEAMS_API_URL); + return useFetchExternal(myTeamsURL.href, Boolean(teamId), `Bearer ${osmTeamsToken}`); +}; + +export const useOSMTeamInfo = (teamId) => { + const osmTeamsToken = useSelector((state) => state.auth.osmteams_token); + const myTeamsURL = new URL(`/api/teams/${teamId}`, OSM_TEAMS_API_URL); + return useFetchExternal(myTeamsURL.href, Boolean(teamId), `Bearer ${osmTeamsToken}`); +}; diff --git a/frontend/src/network/genericJSONRequest.js b/frontend/src/network/genericJSONRequest.js index 9af29cbfe2..f7b662cfb1 100644 --- a/frontend/src/network/genericJSONRequest.js +++ b/frontend/src/network/genericJSONRequest.js @@ -1,13 +1,14 @@ import { handleErrors } from '../utils/promise'; import { API_URL } from '../config'; -export function fetchExternalJSONAPI(url): Promise<*> { - return fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) +export function fetchExternalJSONAPI(url, token): Promise<*> { + const headers = { + 'Content-Type': 'application/json', + }; + if (token) { + headers['Authorization'] = token; + } + return fetch(url, { method: 'GET', headers }) .then(handleErrors) .then((res) => { return res.json(); diff --git a/frontend/src/store/actions/auth.js b/frontend/src/store/actions/auth.js index 75556a10d3..5916561e5e 100644 --- a/frontend/src/store/actions/auth.js +++ b/frontend/src/store/actions/auth.js @@ -11,6 +11,7 @@ export const types = { UPDATE_OSM_INFO: 'UPDATE_OSM_INFO', GET_USER_DETAILS: 'GET_USER_DETAILS', SET_TOKEN: 'SET_TOKEN', + SET_OSM_TEAMS_TOKEN: 'SET_OSM_TEAMS_TOKEN', SET_SESSION: 'SET_SESSION', CLEAR_SESSION: 'CLEAR_SESSION', }; @@ -43,6 +44,8 @@ export const logout = () => (dispatch) => { safeStorage.removeItem('token'); safeStorage.removeItem('action'); safeStorage.removeItem('osm_oauth_token'); + safeStorage.removeItem('osmteams_token'); + safeStorage.removeItem('osmteams_refresh_token'); safeStorage.removeItem('tasksSortOrder'); dispatch(clearUserDetails()); }; @@ -82,6 +85,13 @@ export function updateToken(token) { }; } +export function updateOSMTeamsToken(osmteams_token) { + return { + type: types.SET_OSM_TEAMS_TOKEN, + osmteams_token: osmteams_token, + }; +} + export function updateSession(session) { return { type: types.SET_SESSION, @@ -103,6 +113,12 @@ export const setAuthDetails = (username, token, osm_oauth_token) => (dispatch) = dispatch(setUserDetails(username, encoded_token)); }; +export const setOSMTeamsDetails = (osmteams_token, refresh_token) => (dispatch) => { + safeStorage.setItem('osmteams_token', osmteams_token); + safeStorage.setItem('osmteams_refresh_token', refresh_token); + dispatch(updateOSMTeamsToken(osmteams_token)); +}; + // UPDATES OSM INFORMATION OF THE USER export const setUserDetails = (username, encodedToken, update = false) => diff --git a/frontend/src/store/reducers/auth.js b/frontend/src/store/reducers/auth.js index 30656f6b40..2a21d7a481 100644 --- a/frontend/src/store/reducers/auth.js +++ b/frontend/src/store/reducers/auth.js @@ -3,6 +3,7 @@ import { types } from '../actions/auth'; const initialState = { userDetails: {}, token: '', + osmteams_token: '', session: {}, osm: {}, organisations: [], @@ -26,6 +27,9 @@ export function authorizationReducer(state = initialState, action) { case types.SET_TOKEN: { return { ...state, token: action.token }; } + case types.SET_OSM_TEAMS_TOKEN: { + return { ...state, osmteams_token: action.osmteams_token }; + } case types.SET_SESSION: { return { ...state, session: action.session }; } diff --git a/frontend/src/utils/login.js b/frontend/src/utils/login.js index 0c674c0929..ee77c6cf4b 100644 --- a/frontend/src/utils/login.js +++ b/frontend/src/utils/login.js @@ -54,3 +54,30 @@ export const createLoginWindow = (redirectTo) => { }; }); }; + +export const createOSMTeamsLoginWindow = (redirectTo) => { + const popup = createPopup('OSM auth', ''); + let url = `system/osm-teams-authentication/login/`; + fetchLocalJSONAPI(url).then((resp) => { + popup.location = resp.auth_url; + // Perform token exchange. + + window.authComplete = (authCode, state) => { + let callback_url = `system/osm-teams-authentication/callback/?code=${authCode}`; + + if (resp.state === state) { + fetchLocalJSONAPI(callback_url).then((res) => { + const params = new URLSearchParams({ + access_token: res.access_token, + refresh_token: res.refresh_token, + redirect_to: redirectTo, + }).toString(); + let redirectUrl = `/osmteams-authorized/?${params}`; + window.location.href = redirectUrl; + }); + } else { + throw new Error('States do not match'); + } + }; + }); +}; diff --git a/frontend/src/utils/teamMembersDiff.js b/frontend/src/utils/teamMembersDiff.js index 0873ae3c96..dff5c3a8ff 100644 --- a/frontend/src/utils/teamMembersDiff.js +++ b/frontend/src/utils/teamMembersDiff.js @@ -35,3 +35,10 @@ export function formatMemberObject(user, manager = false) { active: true, }; } + +export const filterOSMTeamsMembers = (members, moderators) => { + const managersIds = moderators.map((user) => user.osm_id); + const managers = members.filter((user) => managersIds.includes(user.id)); + members = members.filter((user) => !managersIds.includes(user.id)); + return { managers, members }; +} \ No newline at end of file diff --git a/frontend/src/views/authorized.js b/frontend/src/views/authorized.js index 6e474ed673..fd6ec2fab8 100644 --- a/frontend/src/views/authorized.js +++ b/frontend/src/views/authorized.js @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useDispatch } from 'react-redux'; -import { setAuthDetails } from '../store/actions/auth'; +import { setAuthDetails, setOSMTeamsDetails } from '../store/actions/auth'; export function Authorized(props) { const navigate = useNavigate(); @@ -32,3 +32,32 @@ export function Authorized(props) { return <>{!isReadyToRedirect ? null :
redirecting
}; } + +export function OSMTeamsAuthorized(props) { + const navigate = useNavigate(); + const location = useLocation(); + const dispatch = useDispatch(); + const [isReadyToRedirect, setIsReadyToRedirect] = useState(false); + + useEffect(() => { + const params = new URLSearchParams(location.search); + let authCode = params.get('code'); + let state = params.get('state'); + if (authCode !== null) { + window.opener.authComplete(authCode, state); + window.close(); + return; + } + const sessionToken = params.get('access_token'); + const refreshToken = params.get('refresh_token'); + dispatch(setOSMTeamsDetails(sessionToken, refreshToken)); + setIsReadyToRedirect(true); + const redirectUrl = + params.get('redirect_to') + ? `${params.get('redirect_to')}?access_token=${params.get('access_token')}` + : '/manage/teams'; + navigate(redirectUrl); + }, [dispatch, location.search, navigate]); + + return <>{isReadyToRedirect ? null :
redirecting
}; +} diff --git a/frontend/src/views/teams.js b/frontend/src/views/teams.js index 5c1fd66556..227df89954 100644 --- a/frontend/src/views/teams.js +++ b/frontend/src/views/teams.js @@ -16,6 +16,7 @@ import toast from 'react-hot-toast'; import Popup from 'reactjs-popup'; import messages from './messages'; +import { OSM_TEAMS_CLIENT_ID } from '../config'; import { useFetch } from '../hooks/UseFetch'; import { useEditTeamAllowed } from '../hooks/UsePermissions'; import { useSetTitleTag } from '../hooks/UseMetaTags'; @@ -45,6 +46,9 @@ import { NotFound } from './notFound'; import { PaginatorLine } from '../components/paginator'; import { updateEntity } from '../utils/management'; import { EntityError } from '../components/alert'; +import { TeamSync } from '../components/teamsAndOrgs/teamSync'; + +const ENABLE_OSM_TEAMS_INTEGRATION = Boolean(OSM_TEAMS_CLIENT_ID); export function ManageTeams() { useSetTitleTag('Manage teams'); @@ -137,7 +141,7 @@ export function ListTeams({ managementView = false }: Object) { ); } -const joinTeamRequest = (team_id, username, role, token) => { +export const joinTeamRequest = (team_id, username, role, token) => { return pushToLocalJSONAPI( `teams/${team_id}/actions/add/`, JSON.stringify({ username: username, role: role }), @@ -146,7 +150,7 @@ const joinTeamRequest = (team_id, username, role, token) => { ); }; -const leaveTeamRequest = (team_id, username, role, token) => { +export const leaveTeamRequest = (team_id, username, role, token) => { return pushToLocalJSONAPI( `teams/${team_id}/actions/leave/`, JSON.stringify({ username: username, role: role }), @@ -168,26 +172,47 @@ export function CreateTeam() { } = useModifyMembers([{ username: userDetails.username, pictureUrl: userDetails.pictureUrl }]); const { members, setMembers, addMember, removeMember } = useModifyMembers([]); const [isError, setIsError] = useState(false); + const [osmTeamsId, setOsmTeamsId] = useState(); + + useEffect(() => { + if (userDetails && userDetails.username && managers.length === 0) { + setManagers([{ username: userDetails.username, pictureUrl: userDetails.pictureUrl }]); + } + }, [userDetails, managers, setManagers]); const createTeam = (payload) => { delete payload['organisation']; setIsError(false); + payload.osm_teams_id = osmTeamsId; pushToLocalJSONAPI('teams/', JSON.stringify(payload), token, 'POST') .then((result) => { - managers - .filter((user) => user.username !== userDetails.username) - .map((user) => joinTeamRequest(result.teamId, user.username, 'MANAGER', token)); - members.map((user) => joinTeamRequest(result.teamId, user.username, 'MEMBER', token)); - toast.success( - , - ); - navigate(`/manage/teams/${result.teamId}`); - }) + const errors = []; + Promise.all([ + ...managers + .filter((user) => user.username !== userDetails.username) + .map((user) => + joinTeamRequest(result.teamId, user.username, 'MANAGER', token).catch((e) => + errors.push({ username: user.username, function: 'MANAGER' }), + ), + ), + ...members.map((user) => + joinTeamRequest(result.teamId, user.username, 'MEMBER', token).catch((e) => + errors.push({ username: user.username, function: 'MEMBER' }), + ), + ), + ]).then(() => { + const additionalSearchParam = errors.length ? `?syncUsersErrors=${errors.length}` : ''; + toast.success( + , + ); + navigate(`/manage/teams/${result.teamId}${additionalSearchParam}`); + }); + }) .catch(() => setIsError(true)); }; @@ -196,6 +221,7 @@ export function CreateTeam() { onSubmit={(values) => createTeam(values)} initialValues={{ visibility: 'PUBLIC' }} render={({ handleSubmit, pristine, submitting, values }) => { + if (osmTeamsId) values.joinMethod = 'BY_INVITE'; return (
@@ -207,7 +233,7 @@ export function CreateTeam() {

- +
{isError && }
@@ -218,7 +244,7 @@ export function CreateTeam() { removeMembers={removeManager} members={managers} resetMembersFn={setManagers} - creationMode={true} + disableEdit={osmTeamsId} />
@@ -227,10 +253,20 @@ export function CreateTeam() { removeMembers={removeMember} members={members} resetMembersFn={setMembers} - creationMode={true} + disableEdit={osmTeamsId} type={'members'} />
+ {ENABLE_OSM_TEAMS_INTEGRATION && ( +
+ +
+ )}
@@ -258,7 +294,7 @@ export function CreateTeam() { ); } -export function EditTeam(props) { +export function EditTeam() { const { id } = useParams(); const userDetails = useSelector((state) => state.auth.userDetails); const token = useSelector((state) => state.auth.token); @@ -272,12 +308,15 @@ export function EditTeam(props) { const [memberJoinTeamError, setMemberJoinTeamError] = useState(null); const [managerJoinTeamError, setManagerJoinTeamError] = useState(null); const [isError, setIsError] = useState(false); + const [osmTeamsId, setOsmTeamsId] = useState(); + useSetTitleTag(`Edit ${team.name}`); useEffect(() => { if (!initManagers && team && team.members) { setManagers(filterActiveManagers(team.members)); setMembers(filterActiveMembers(team.members)); setRequests(filterInactiveMembersAndManagers(team.members)); + setOsmTeamsId(team.osm_teams_id); setInitManagers(true); } }, [team, managers, initManagers]); @@ -289,8 +328,6 @@ export function EditTeam(props) { } }, [team]); - useSetTitleTag(`Edit ${team.name}`); - const addManagers = (values) => { const newValues = values .filter((newUser) => !managers.map((i) => i.username).includes(newUser.username)) @@ -369,6 +406,10 @@ export function EditTeam(props) { if (payload.joinMethod !== 'BY_INVITE') { payload.visibility = 'PUBLIC'; } + payload.osm_teams_id = osmTeamsId; + // force teams synced with OSM Teams to have BY_INVITE join method + if (osmTeamsId) payload.joinMethod = 'BY_INVITE'; + updateEntity(`teams/${id}/`, 'team', payload, token, forceUpdate, onUpdateTeamFailure); }; @@ -401,6 +442,7 @@ export function EditTeam(props) { joinMethod: team.joinMethod, visibility: team.visibility, organisation_id: team.organisation_id, + osm_teams_id: team.osm_teams_id, }} updateTeam={updateTeam} disabledForm={error || loading} @@ -416,6 +458,7 @@ export function EditTeam(props) { members={managers} managerJoinTeamError={managerJoinTeamError} setManagerJoinTeamError={setManagerJoinTeamError} + disableEdit={osmTeamsId} />
+ {ENABLE_OSM_TEAMS_INTEGRATION && + (osmTeamsId || (members.length < 1 && managers.length < 2)) && ( +
+ + pushToLocalJSONAPI( + `teams/${id}/`, + JSON.stringify({ osm_teams_id: selectedTeamId, joinMethod: 'BY_INVITE' }), + token, + 'PATCH', + ) + } + /> +
+ )} +
Date: Wed, 5 Jul 2023 16:33:59 -0300 Subject: [PATCH 2/4] Add tests to OSM Teams integration --- backend/__init__.py | 2 +- backend/api/teams/resources.py | 1 + backend/models/dtos/team_dto.py | 1 + backend/models/postgis/statuses.py | 1 + .../src/components/teamsAndOrgs/management.js | 1 + .../src/components/teamsAndOrgs/members.js | 8 +- .../src/components/teamsAndOrgs/messages.js | 17 ++- .../src/components/teamsAndOrgs/teamSync.js | 19 ++- frontend/src/components/teamsAndOrgs/teams.js | 5 +- frontend/src/hooks/UseOSMTeams.js | 6 +- .../src/network/tests/mockData/osmTeams.js | 79 +++++++++++ frontend/src/network/tests/mockData/teams.js | 2 +- frontend/src/network/tests/server-handlers.js | 20 ++- frontend/src/routes.js | 3 +- frontend/src/views/teams.js | 18 +-- frontend/src/views/tests/teams.test.js | 127 +++++++++++++++++- 16 files changed, 274 insertions(+), 36 deletions(-) create mode 100644 frontend/src/network/tests/mockData/osmTeams.js diff --git a/backend/__init__.py b/backend/__init__.py index f4e631c63e..88d8848bbe 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -106,7 +106,7 @@ def create_app(env="backend.config.EnvironmentConfig"): env = "backend.config.TestEnvironmentConfig" app.config.from_object(env) # Enable logging to files - initialise_logger(app) + # initialise_logger(app) app.logger.info("Starting up a new Tasking Manager application") # Connect to database diff --git a/backend/api/teams/resources.py b/backend/api/teams/resources.py index 4643349aea..b2494d723c 100644 --- a/backend/api/teams/resources.py +++ b/backend/api/teams/resources.py @@ -366,6 +366,7 @@ def post(self): - "ANY" - "BY_REQUEST" - "BY_INVITE" + - "OSM_TEAMS" responses: 201: description: Team created successfully diff --git a/backend/models/dtos/team_dto.py b/backend/models/dtos/team_dto.py index 30cf5a16d6..d0b266ab60 100644 --- a/backend/models/dtos/team_dto.py +++ b/backend/models/dtos/team_dto.py @@ -39,6 +39,7 @@ def validate_team_join_method(value): f"{TeamJoinMethod.ANY.name}, " f"{TeamJoinMethod.BY_INVITE.name}, " f"{TeamJoinMethod.BY_REQUEST.name}" + f"{TeamJoinMethod.OSM_TEAMS.name}" ) diff --git a/backend/models/postgis/statuses.py b/backend/models/postgis/statuses.py index ef78498948..57c70b7651 100644 --- a/backend/models/postgis/statuses.py +++ b/backend/models/postgis/statuses.py @@ -130,6 +130,7 @@ class TeamJoinMethod(Enum): ANY = 0 BY_REQUEST = 1 BY_INVITE = 2 + OSM_TEAMS = 3 class TeamRoles(Enum): diff --git a/frontend/src/components/teamsAndOrgs/management.js b/frontend/src/components/teamsAndOrgs/management.js index c35dd62c44..47d073aaff 100644 --- a/frontend/src/components/teamsAndOrgs/management.js +++ b/frontend/src/components/teamsAndOrgs/management.js @@ -54,6 +54,7 @@ export function JoinMethodBox(props) { ANY: 'anyoneCanJoin', BY_REQUEST: 'byRequest', BY_INVITE: 'byInvite', + OSM_TEAMS: 'OSMTeams', }; return (
diff --git a/frontend/src/components/teamsAndOrgs/members.js b/frontend/src/components/teamsAndOrgs/members.js index e69f49b580..4a174bee1c 100644 --- a/frontend/src/components/teamsAndOrgs/members.js +++ b/frontend/src/components/teamsAndOrgs/members.js @@ -83,15 +83,13 @@ export function Members({

{title}

- {!disableEdit && - - } + {!disableEdit && }
- {disableEdit && + {disableEdit && (
- } + )}
{editMode && ( state.auth.token); const [errors, setErrors] = useState(searchParams?.get('syncUsersErrors')); const [showSelectionModal, setShowSelectionModal] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); const reSyncParams = { tmTeamId, members, @@ -141,11 +142,16 @@ export const TeamSync = ({ <> - {errors?.length > 0 && ( + {errors && (
u.username).join(' ,'), + users: + typeof errors === 'object' + ? errors.map((u) => u.username).join(', ') + : [], number: errors.length, }} /> diff --git a/frontend/src/components/teamsAndOrgs/teams.js b/frontend/src/components/teamsAndOrgs/teams.js index 7906cc0b55..6732478f13 100644 --- a/frontend/src/components/teamsAndOrgs/teams.js +++ b/frontend/src/components/teamsAndOrgs/teams.js @@ -167,6 +167,7 @@ export function TeamInformation({ disableJoinMethodField }) { ANY: 'anyoneCanJoin', BY_REQUEST: 'byRequest', BY_INVITE: 'byInvite', + OSM_TEAMS: 'OSMTeams', }; return ( @@ -199,7 +200,7 @@ export function TeamInformation({ disableJoinMethodField }) { name="joinMethod" value={method} required - disabled={disableJoinMethodField} + disabled={disableJoinMethodField || method === 'OSM_TEAMS'} /> @@ -214,7 +215,7 @@ export function TeamInformation({ disableJoinMethodField }) {
))}
- {formState.values.joinMethod === 'BY_INVITE' && ( + {['BY_INVITE', 'OSM_TEAMS'].includes(formState.values.joinMethod) && (
@@ -463,3 +484,59 @@ export const TeamBox = ({ team, className }: Object) => (
); + +export const TeamDetailPageFooter = ({ team, isMember, joinTeamFn, leaveTeamFn }) => { + return ( +
+
+ + + + + +
+
+ {isMember ? ( + + + + } + modal + closeOnEscape + > + {(close) => ( + + )} + + ) : ( + team.joinMethod !== 'BY_INVITE' && ( + joinTeamFn()} + disabled={team.joinMethod === 'OSM_TEAMS'} + > + + + ) + )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/teamsAndOrgs/tests/teams.test.js b/frontend/src/components/teamsAndOrgs/tests/teams.test.js index 88015c7897..de0162f2eb 100644 --- a/frontend/src/components/teamsAndOrgs/tests/teams.test.js +++ b/frontend/src/components/teamsAndOrgs/tests/teams.test.js @@ -3,6 +3,7 @@ import TestRenderer from 'react-test-renderer'; import { render, screen, waitFor, act } from '@testing-library/react'; import { FormattedMessage } from 'react-intl'; import { MemoryRouter } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; import { createComponentWithIntl, @@ -11,7 +12,7 @@ import { renderWithRouter, createComponentWithMemoryRouter, } from '../../../utils/testWithIntl'; -import { TeamBox, TeamsBoxList, TeamsManagement, Teams, TeamCard, TeamSideBar } from '../teams'; +import { TeamBox, TeamsBoxList, TeamsManagement, Teams, TeamCard, TeamSideBar, TeamDetailPageFooter } from '../teams'; import { store } from '../../../store'; import { teams, team } from '../../../network/tests/mockData/teams'; @@ -401,4 +402,149 @@ describe('TeamSideBar component', () => { }), ).not.toBeInTheDocument(); }); + + it('when OSM Teams sync is enabled, it should show a message', () => { + const teamWithOSMTeams = {...team}; + teamWithOSMTeams.osm_teams_id = 1234; + teamWithOSMTeams.joinMethod = 'OSM_TEAMS'; + renderWithRouter( + + + , + ); + + expect( + screen.getByText( + 'The members and managers of this team are configured through the OSM Teams platform.' + ) + ).toBeInTheDocument(); + expect( + screen.getByText('Open on OSM Teams').href.endsWith('/teams/1234') + ).toBeTruthy(); + }); }); + + +describe('TeamDetailPageFooter component', () => { + const joinTeamFn = jest.fn(); + const leaveTeamFn = jest.fn(); + + it('has Join team button enabled for ANY joinMethod if user is not member', async () => { + renderWithRouter( + + + + ); + expect(screen.getByText('Join team').disabled).toBeFalsy(); + await userEvent.click(screen.getByText('Join team')); + expect(joinTeamFn).toHaveBeenCalledTimes(1); + expect( + screen.getByRole('link').href.endsWith('/contributions/teams') + ).toBeTruthy(); + }); + + it('has Leave team button enabled for ANY joinMethod if user is a member', async () => { + renderWithRouter( + + + + ); + expect(screen.getByText('Leave team').disabled).toBeFalsy(); + await userEvent.click(screen.getByText('Leave team')); + await userEvent.click(screen.getByText('Leave')); + expect(leaveTeamFn).toHaveBeenCalledTimes(1); + expect( + screen.getByRole('link').href.endsWith('/contributions/teams') + ).toBeTruthy(); + }); + + it('has Join team button enabled for BY_REQUEST joinMethod if user is not member', async () => { + renderWithRouter( + + + + ); + expect(screen.getByText('Join team').disabled).toBeFalsy(); + await userEvent.click(screen.getByText('Join team')); + expect(joinTeamFn).toHaveBeenCalledTimes(1); + }); + + it('has Leave team button enabled for BY_REQUEST joinMethod if user is a member', async () => { + renderWithRouter( + + + + ); + expect(screen.getByText('Leave team').disabled).toBeFalsy(); + await userEvent.click(screen.getByText('Leave team')); + await userEvent.click(screen.getByText('Leave')); + expect(leaveTeamFn).toHaveBeenCalledTimes(1); + }); + + it('has Leave team button enabled for BY_INVITE joinMethod if user is a member', async () => { + renderWithRouter( + + + + ); + expect(screen.getByText('Leave team').disabled).toBeFalsy(); + await userEvent.click(screen.getByText('Leave team')); + await userEvent.click(screen.getByText('Leave')); + expect(leaveTeamFn).toHaveBeenCalledTimes(1); + }); + + it('has Join team button disabled for OSM_TEAMS joinMethod if user is not a member', async () => { + renderWithRouter( + + + + ); + expect(screen.getByText('Join team').disabled).toBeTruthy(); + await userEvent.click(screen.getByText('Join team')); + expect(joinTeamFn).toHaveBeenCalledTimes(0); + }); + + it('has Leave team button disabled for OSM_TEAMS joinMethod if user is a member', async () => { + renderWithRouter( + + + + ); + expect(screen.getByText('Leave team').disabled).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/frontend/src/views/messages.js b/frontend/src/views/messages.js index 207d265491..b99129bacb 100644 --- a/frontend/src/views/messages.js +++ b/frontend/src/views/messages.js @@ -133,22 +133,6 @@ export default defineMessages({ id: 'teamsAndOrgs.management.campaign.button.create', defaultMessage: 'Create campaign', }, - myTeams: { - id: 'teamsAndOrgs.management.button.my_teams', - defaultMessage: 'My teams', - }, - joinTeam: { - id: 'teamsAndOrgs.management.button.join_team', - defaultMessage: 'Join team', - }, - cancelRequest: { - id: 'teamsAndOrgs.management.button.cancel_request', - defaultMessage: 'Cancel request', - }, - leaveTeam: { - id: 'teamsAndOrgs.management.button.leave_team', - defaultMessage: 'Leave team', - }, cancel: { id: 'teamsAndOrgs.management.button.cancel', defaultMessage: 'Cancel', diff --git a/frontend/src/views/teams.js b/frontend/src/views/teams.js index b1227db2c2..702970afa3 100644 --- a/frontend/src/views/teams.js +++ b/frontend/src/views/teams.js @@ -13,7 +13,6 @@ import { } from 'use-query-params'; import { stringify } from 'query-string'; import toast from 'react-hot-toast'; -import Popup from 'reactjs-popup'; import messages from './messages'; import { OSM_TEAMS_CLIENT_ID } from '../config'; @@ -36,10 +35,10 @@ import { TeamForm, TeamsManagement, TeamSideBar, + TeamDetailPageFooter, } from '../components/teamsAndOrgs/teams'; import { MessageMembers } from '../components/teamsAndOrgs/messageMembers'; import { Projects } from '../components/teamsAndOrgs/projects'; -import { LeaveTeamConfirmationAlert } from '../components/teamsAndOrgs/leaveTeamConfirmationAlert'; import { FormSubmitButton, CustomButton } from '../components/button'; import { DeleteModal } from '../components/deleteModal'; import { NotFound } from './notFound'; @@ -591,55 +590,12 @@ export function TeamDetail() { />
-
-
- - - - - -
-
- {isMember ? ( - - - - } - modal - closeOnEscape - > - {(close) => ( - - )} - - ) : ( - team.joinMethod !== 'BY_INVITE' && ( - joinTeam()} - > - - - ) - )} -
-
+ ); } diff --git a/frontend/src/views/tests/teams.test.js b/frontend/src/views/tests/teams.test.js index 2dd3ba66dd..a6d7a3324f 100644 --- a/frontend/src/views/tests/teams.test.js +++ b/frontend/src/views/tests/teams.test.js @@ -1,5 +1,5 @@ import '@testing-library/jest-dom'; -import { screen, waitFor, within, act, render } from '@testing-library/react'; +import { screen, waitFor, within, act } from '@testing-library/react'; import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6'; import { QueryParamProvider } from 'use-query-params'; import userEvent from '@testing-library/user-event'; @@ -14,7 +14,6 @@ import { ManageTeams, EditTeam, CreateTeam, MyTeams } from '../teams'; import { store } from '../../store'; import { setupFaultyHandlers } from '../../network/tests/server'; import * as config from '../../config'; -import { teamWithOSMTeams } from '../../network/tests/mockData/teams'; jest.mock('react-hot-toast', () => ({ success: jest.fn(), diff --git a/migrations/versions/52a67f6cef20_.py b/migrations/versions/52a67f6cef20_.py index 99337cf00a..b685ba0e80 100644 --- a/migrations/versions/52a67f6cef20_.py +++ b/migrations/versions/52a67f6cef20_.py @@ -1,8 +1,8 @@ """empty message Revision ID: 52a67f6cef20 -Revises: a9cbd2c6c213 -Create Date: 2023-01-12 12:26:39.420411 +Revises: 42c45e74752b +Create Date: 2023-07-06 12:26:39.420411 """ from alembic import op @@ -10,19 +10,19 @@ # revision identifiers, used by Alembic. -revision = '52a67f6cef20' -down_revision = 'a9cbd2c6c213' +revision = "52a67f6cef20" +down_revision = "42c45e74752b" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('teams', sa.Column('osm_teams_id', sa.BigInteger(), nullable=True)) + op.add_column("teams", sa.Column("osm_teams_id", sa.BigInteger(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('teams', 'osm_teams_id') + op.drop_column("teams", "osm_teams_id") # ### end Alembic commands ### diff --git a/tests/backend/integration/api/teams/test_resources.py b/tests/backend/integration/api/teams/test_resources.py index a410ea11bb..a9dc47904b 100644 --- a/tests/backend/integration/api/teams/test_resources.py +++ b/tests/backend/integration/api/teams/test_resources.py @@ -248,6 +248,7 @@ def test_get_teams_authorised_passes(self): "organisationId": 23, "description": None, "joinMethod": "ANY", + "osm_teams_id": None, "logo": None, "managersCount": 0, "members": [], From 013dd364d8aee92e8cedaa54e66c707007329f84 Mon Sep 17 00:00:00 2001 From: Wille Marcel Date: Fri, 21 Jul 2023 14:16:24 -0300 Subject: [PATCH 4/4] Solve problem with css modules import --- frontend/src/routes.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes.js b/frontend/src/routes.js index 8c7be8f00a..4f78ba47bc 100644 --- a/frontend/src/routes.js +++ b/frontend/src/routes.js @@ -45,7 +45,7 @@ export const router = createBrowserRouter( path="projects/:id" lazy={async () => { const { ProjectDetailPage } = await import( - './views/project' /* webpackChunkName: "project" */ + './views/project' /* webpackChunkName: "projectDetail" */ ); return { Component: ProjectDetailPage }; }} @@ -54,7 +54,7 @@ export const router = createBrowserRouter( path="projects/:id/tasks" lazy={async () => { const { SelectTask } = await import( - './views/taskSelection' /* webpackChunkName: "taskSelection" */ + './views/taskSelection' /* webpackChunkName: "projectDetail" */ ); return { Component: SelectTask }; }} @@ -349,7 +349,7 @@ export const router = createBrowserRouter( path="projects/:id" lazy={async () => { const { ProjectEdit } = await import( - './views/projectEdit' /* webpackChunkName: "projectEdit" */ + './views/projectEdit' /* webpackChunkName: "project" */ ); return { Component: ProjectEdit }; }}