diff --git a/.circleci/config.yml b/.circleci/config.yml index 78f17c04b6..736253e4cf 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,7 +30,7 @@ jobs: name: Run yarn test command: | cd ${CIRCLE_WORKING_DIRECTORY}/frontend/ - CI=true yarn test -w 1 + CI=true REACT_APP_OSM_TEAMS_CLIENT_ID=boo yarn test -w 1 CI=true GENERATE_SOURCEMAP=false yarn build backend-code-check-PEP8: diff --git a/backend/__init__.py b/backend/__init__.py index 096405c00d..ea6d9ce389 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,16 @@ 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 8af49e840f..3561a8cf7b 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 be6dc41483..c103f1cd55 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 @@ -327,6 +329,8 @@ def post(self): organisation_id: type: integer default: 1 + osm_teams_id: + type: integer description: type: string visibility: @@ -340,6 +344,7 @@ def post(self): - "ANY" - "BY_REQUEST" - "BY_INVITE" + - "OSM_TEAMS" responses: 201: description: Team created successfully 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..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}" ) @@ -92,6 +93,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 +133,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 +153,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 +170,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/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/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/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 a87358bb69..4a174bee1c 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,13 @@ 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 [isSyncing, setIsSyncing] = useState(false); + const reSyncParams = { + tmTeamId, + members, + managers, + osmTeamsId, + osmteams_token, + token, + setManagers, + setMembers, + forceUpdate, + setErrors, + }; + + return ( +
+
+

+ +

+
+ , + }} + /> +
+
+ {osmteams_token ? ( + osmTeamsId ? ( + <> + {osmTeamsId && } + {updateMode && ( + <> + + {errors && ( +
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..a305d4b2c6 100644 --- a/frontend/src/components/teamsAndOrgs/teams.js +++ b/frontend/src/components/teamsAndOrgs/teams.js @@ -7,13 +7,17 @@ import { Form, Field, useFormState } from 'react-final-form'; import ReactTooltip from 'react-tooltip'; import messages from './messages'; -import { InfoIcon } from '../svgIcons'; +import { ExternalLinkIcon, InfoIcon } from '../svgIcons'; import { useEditTeamAllowed } from '../../hooks/UsePermissions'; import { UserAvatar, UserAvatarList } from '../user/avatar'; import { AddButton, ViewAllLink, Management, VisibilityBox, JoinMethodBox } from './management'; import { RadioField, OrganisationSelectInput, TextField } from '../formInputs'; -import { Button, EditButton } from '../button'; +import { Button, CustomButton, EditButton } from '../button'; import { nCardPlaceholders } from './teamsPlaceholder'; +import { OSM_TEAMS_API_URL } from '../../config'; +import { Alert } from '../alert'; +import Popup from 'reactjs-popup'; +import { LeaveTeamConfirmationAlert } from './leaveTeamConfirmationAlert'; export function TeamsManagement({ teams, @@ -158,7 +162,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'; @@ -167,6 +171,7 @@ export function TeamInformation(props) { ANY: 'anyoneCanJoin', BY_REQUEST: 'byRequest', BY_INVITE: 'byInvite', + OSM_TEAMS: 'OSMTeams', }; return ( @@ -195,7 +200,12 @@ export function TeamInformation(props) { {Object.keys(joinMethods).map((method) => (
- + @@ -209,7 +219,7 @@ export function TeamInformation(props) {
))}
- {formState.values.joinMethod === 'BY_INVITE' && ( + {['BY_INVITE', 'OSM_TEAMS'].includes(formState.values.joinMethod) && (
@@ -410,6 +420,23 @@ export function TeamSideBar({ team, members, managers, requestedToJoin }: Object )}
+ {team.osm_teams_id && ( + + {' '} + + + + + + )}
@@ -457,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/config/index.js b/frontend/src/config/index.js index 1b0845da0a..43720bd7e2 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..7bdaa1d656 --- /dev/null +++ b/frontend/src/hooks/UseOSMTeams.js @@ -0,0 +1,53 @@ +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/network/tests/mockData/osmTeams.js b/frontend/src/network/tests/mockData/osmTeams.js new file mode 100644 index 0000000000..561f7013a1 --- /dev/null +++ b/frontend/src/network/tests/mockData/osmTeams.js @@ -0,0 +1,79 @@ +export const myTeams = { + data: [ + { + id: 1, + name: 'OSM Teams Developers', + hashtag: null, + bio: null, + privacy: 'private', + require_join_request: false, + updated_at: '2023-06-11T15:37:57.793Z', + created_at: '2022-05-06T16:10:18.452Z', + location: '{"type":"Point","coordinates":[-77.02438,38.906337]}', + members: '8', + }, + { + id: 10, + name: 'SOTMUS 2023', + hashtag: '#sotmus', + bio: 'Attendees of State of the Map 2023 in Richmond, VA', + privacy: 'public', + require_join_request: false, + updated_at: '2023-06-09T20:00:51.108Z', + created_at: '2023-06-09T17:01:41.376Z', + location: '{"type":"Point","coordinates":[-77.4508325,37.548201459]}', + members: '27', + }, + { + id: 20, + name: 'My Friends', + hashtag: null, + bio: null, + privacy: 'private', + require_join_request: false, + updated_at: '2022-11-17T15:32:58.615Z', + created_at: '2022-11-17T15:32:58.615Z', + location: null, + members: '2', + }, + ], + pagination: { total: 3, lastPage: 1, perPage: 10, currentPage: 1, from: 0, to: 3 }, +}; + +export const osmTeam1 = { + id: 73, + name: 'OSM Teams Developers', + hashtag: '#OSMDevs', + bio: 'OSM Team Developers', + privacy: 'private', + require_join_request: false, + updated_at: '2023-03-13T18:05:23.679Z', + created_at: '2022-05-06T16:10:18.452Z', + location: null, + org: { organization_id: 5, name: 'Development Seed' }, + requesterIsMember: true, +}; + +export const osmTeamMembers = { + teamId: 73, + members: { + data: [ + { id: 146675, name: 'geohacker' }, + { id: 2454337, name: 'kamicut' }, + { id: 2206554, name: 'LsGoodman' }, + { id: 10139859, name: 'MarcFarra' }, + { id: 261012, name: 'sanjayb' }, + { id: 62817, name: 'vgeorge' }, + { id: 15547551, name: 'Vgeorge2' }, + { id: 360183, name: 'wille' }, + ], + pagination: { total: 8, lastPage: 1, perPage: 10, currentPage: 1, from: 0, to: 8 }, + }, +}; + +export const osmTeamModerators = [ + { id: 64, team_id: 73, osm_id: 2454337 }, + { id: 443, team_id: 73, osm_id: 15547551 }, + { id: 459, team_id: 73, osm_id: 146675 }, + { id: 464, team_id: 73, osm_id: 2206554 }, +]; diff --git a/frontend/src/network/tests/mockData/teams.js b/frontend/src/network/tests/mockData/teams.js index f8aaf1fbb8..2559a8c23f 100644 --- a/frontend/src/network/tests/mockData/teams.js +++ b/frontend/src/network/tests/mockData/teams.js @@ -51,7 +51,7 @@ export const teamCreationSuccess = { teamId: 123, }; -export const teamUpdationSuccess = { +export const teamUpdateSuccess = { Status: 'Updated', }; diff --git a/frontend/src/network/tests/server-handlers.js b/frontend/src/network/tests/server-handlers.js index 993e02e77e..68ff46349e 100644 --- a/frontend/src/network/tests/server-handlers.js +++ b/frontend/src/network/tests/server-handlers.js @@ -55,14 +55,14 @@ import { teams, team, teamCreationSuccess, - teamUpdationSuccess, + teamUpdateSuccess, teamDeletionSuccess, } from './mockData/teams'; import { userTasks } from './mockData/tasksStats'; import { homepageStats } from './mockData/homepageStats'; import { banner, countries, josmRemote, systemStats } from './mockData/miscellaneous'; import tasksGeojson from '../../utils/tests/snippets/tasksGeometry'; -import { API_URL } from '../../config'; +import { API_URL, OSM_TEAMS_API_URL } from '../../config'; import { notifications, ownCountUnread } from './mockData/notifications'; import { authLogin, setUser, userRegister } from './mockData/auth'; import { @@ -74,6 +74,7 @@ import { submitValidationTask, userLockedTasks, } from './mockData/taskHistory'; +import { myTeams, osmTeam1, osmTeamMembers, osmTeamModerators } from './mockData/osmTeams'; const handlers = [ rest.get(API_URL + 'projects/:id/queries/summary/', async (req, res, ctx) => { @@ -242,7 +243,7 @@ const handlers = [ return res(ctx.json(teamCreationSuccess)); }), rest.patch(API_URL + 'teams/:id/', (req, res, ctx) => { - return res(ctx.json(teamUpdationSuccess)); + return res(ctx.json(teamUpdateSuccess)); }), rest.delete(API_URL + 'teams/:id', (req, res, ctx) => { return res(ctx.json(teamDeletionSuccess)); @@ -357,6 +358,19 @@ const handlers = [ rest.get('http://127.0.0.1:8111/version', (req, res, ctx) => { return res(ctx.json(josmRemote)); }), + // OSM Teams + rest.get(OSM_TEAMS_API_URL + '/api/my/teams', (req, res, ctx) => { + return res(ctx.json(myTeams)); + }), + rest.get(OSM_TEAMS_API_URL + '/api/teams/:id', (req, res, ctx) => { + return res(ctx.json(osmTeam1)); + }), + rest.get(OSM_TEAMS_API_URL + '/api/teams/:id/members', (req, res, ctx) => { + return res(ctx.json(osmTeamMembers)); + }), + rest.get(OSM_TEAMS_API_URL + '/api/teams/:id/moderators', (req, res, ctx) => { + return res(ctx.json(osmTeamModerators)); + }), ]; const failedToConnectError = (req, res, ctx) => { diff --git a/frontend/src/routes.js b/frontend/src/routes.js index 2c3548dcd0..4f78ba47bc 100644 --- a/frontend/src/routes.js +++ b/frontend/src/routes.js @@ -1,7 +1,7 @@ import { createBrowserRouter, createRoutesFromElements, Route } from 'react-router-dom'; import { Root } from './views/root'; -import { Authorized } from './views/authorized'; +import { Authorized, OSMTeamsAuthorized } from './views/authorized'; import { NotFound } from './views/notFound'; import { FallbackComponent } from './views/fallback'; import { Redirect } from './components/redirect'; @@ -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 }; }} @@ -198,6 +198,7 @@ export const router = createBrowserRouter( }} /> } /> + } /> { @@ -348,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 }; }} 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/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 5c1fd66556..702970afa3 100644 --- a/frontend/src/views/teams.js +++ b/frontend/src/views/teams.js @@ -13,9 +13,9 @@ 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'; import { useFetch } from '../hooks/UseFetch'; import { useEditTeamAllowed } from '../hooks/UsePermissions'; import { useSetTitleTag } from '../hooks/UseMetaTags'; @@ -35,16 +35,19 @@ 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'; 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 +140,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 +149,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,25 +171,46 @@ 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 +220,7 @@ export function CreateTeam() { onSubmit={(values) => createTeam(values)} initialValues={{ visibility: 'PUBLIC' }} render={({ handleSubmit, pristine, submitting, values }) => { + if (osmTeamsId) values.joinMethod = 'OSM_TEAMS'; return (
@@ -207,7 +232,7 @@ export function CreateTeam() {

- +
{isError && } @@ -218,7 +243,7 @@ export function CreateTeam() { removeMembers={removeManager} members={managers} resetMembersFn={setManagers} - creationMode={true} + disableEdit={osmTeamsId} />
@@ -227,10 +252,20 @@ export function CreateTeam() { removeMembers={removeMember} members={members} resetMembersFn={setMembers} - creationMode={true} + disableEdit={osmTeamsId} type={'members'} />
+ {ENABLE_OSM_TEAMS_INTEGRATION && ( +
+ +
+ )}
@@ -258,7 +293,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 +307,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 +327,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)) @@ -366,9 +402,13 @@ export function EditTeam(props) { const onUpdateTeamFailure = () => setIsError(true); const updateTeam = (payload) => { - if (payload.joinMethod !== 'BY_INVITE') { + if (['ANY', 'BY_REQUEST'].includes(payload.joinMethod)) { payload.visibility = 'PUBLIC'; } + payload.osm_teams_id = osmTeamsId; + // force teams synced with OSM Teams to have OSM_TEAMS join method + if (osmTeamsId) payload.joinMethod = 'OSM_TEAMS'; + updateEntity(`teams/${id}/`, 'team', payload, token, forceUpdate, onUpdateTeamFailure); }; @@ -401,6 +441,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 +457,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: 'OSM_TEAMS' }), + token, + 'PATCH', + ) + } + /> +
+ )} +
-
-
- - - - - -
-
- {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 84545fa4b0..a6d7a3324f 100644 --- a/frontend/src/views/tests/teams.test.js +++ b/frontend/src/views/tests/teams.test.js @@ -2,6 +2,7 @@ import '@testing-library/jest-dom'; 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'; import toast from 'react-hot-toast'; import { @@ -12,12 +13,19 @@ import { import { ManageTeams, EditTeam, CreateTeam, MyTeams } from '../teams'; import { store } from '../../store'; import { setupFaultyHandlers } from '../../network/tests/server'; +import * as config from '../../config'; jest.mock('react-hot-toast', () => ({ success: jest.fn(), error: jest.fn(), })); +// Set OSM_TEAMS_CLIENT_ID to be able to test OSM Teams integration components +Object.defineProperty(config, 'OSM_TEAMS_CLIENT_ID', { + value: () => '123abc', + writable: true, +}); + describe('List Teams', () => { it('should show loading placeholder when teams are being fetched', async () => { const { container } = renderWithRouter( @@ -119,6 +127,79 @@ describe('Create Team', () => { }); }); +describe('Create Team with OSM Teams integration', () => { + it('Sync with OSM Teams section is present', () => { + renderWithRouter( + + + , + ); + act(() => { + store.dispatch({ + type: 'SET_USER_DETAILS', + userDetails: { id: 122, username: 'test_user', role: 'ADMIN' }, + }); + store.dispatch({ type: 'SET_TOKEN', token: 'validToken' }); + }); + expect(screen.getByText('Sync with OSM Teams')).toBeInTheDocument(); + expect(screen.getByText('Authenticate OSM Teams')).toBeInTheDocument(); + }); + it('Setting osmteams_token enables team selection', async () => { + renderWithRouter( + + + , + ); + act(() => { + store.dispatch({ + type: 'SET_USER_DETAILS', + userDetails: { id: 122, username: 'test_user', role: 'ADMIN' }, + }); + store.dispatch({ type: 'SET_TOKEN', token: 'validToken' }); + store.dispatch({ + type: 'SET_OSM_TEAMS_TOKEN', + osmteams_token: 'abc123', + }); + }); + expect(screen.queryByText('Authenticate OSM Teams')).not.toBeInTheDocument(); + expect(screen.getByText('Select a team from OSM Teams')).toBeInTheDocument(); + // Open OSM Teams selection modal + await userEvent.click(screen.getByText('Select a team from OSM Teams')); + await waitFor(() => expect(screen.getByText('OSM Teams Developers')).toBeInTheDocument()); + expect(screen.getByText('SOTMUS 2023')).toBeInTheDocument(); + expect(screen.getByText('My Friends')).toBeInTheDocument(); + expect(screen.getAllByText('Cancel').length).toBe(2); + // Select a team + await userEvent.click(screen.getByText('OSM Teams Developers')); + await waitFor(() => expect(screen.getByTitle('geohacker')).toBeInTheDocument()); + expect(screen.getByText('Selected team')).toBeInTheDocument(); + expect(screen.getByText('Back')).toBeInTheDocument(); + expect(screen.getAllByText('Managers').length).toBe(2); + expect(screen.getAllByText('Members').length).toBe(2); + await userEvent.click(screen.getByText('Confirm selection')); + // Modal closes + await waitFor(() => + expect(screen.getByTitle('Open on OSM Teams').href.endsWith('/teams/1')).toBeTruthy() + ); + expect(screen.queryByText('Selected team')).not.toBeInTheDocument(); + expect(screen.getByTitle('kamicut')).toBeInTheDocument(); + expect(screen.getByTitle('geohacker')).toBeInTheDocument(); + expect(screen.getByTitle('wille')).toBeInTheDocument(); + expect(screen.getByTitle('sanjayb')).toBeInTheDocument(); + expect(screen.getByText('OSM Teams Developers')).toBeInTheDocument(); + const radios = screen.getAllByRole('radio'); + expect(radios.length).toBe(6); + // OSM Teams join method is checked and disabled + expect(radios.slice(0, 2).filter((i) => i.checked)).toEqual([]); + expect(radios[3].checked).toBeTruthy(); + expect(radios[3].disabled).toBeTruthy(); + // Privacy options are enabled and public is selected + expect(radios[4].checked).toBeTruthy(); + expect(radios[4].disabled).toBeFalsy(); + expect(radios[5].disabled).toBeFalsy(); + }); +}); + describe('Edit Team', () => { it('should display default details of the team before editing', async () => { renderWithRouter( @@ -193,6 +274,49 @@ describe('Edit Team', () => { ).toBeInTheDocument(), ); }); + + it('Enable OSM Teams sync', async () => { + renderWithRouter( + + + , + ); + act(() => { + store.dispatch({ + type: 'SET_USER_DETAILS', + userDetails: { id: 122, username: 'test_user', role: 'ADMIN' }, + }); + store.dispatch({ type: 'SET_TOKEN', token: 'validToken' }); + store.dispatch({ + type: 'SET_OSM_TEAMS_TOKEN', + osmteams_token: 'abc123', + }); + }); + // Open OSM Teams selection modal + await userEvent.click(screen.getByText('Select a team from OSM Teams')); + await waitFor(() => expect(screen.getByText('OSM Teams Developers')).toBeInTheDocument()); + expect(screen.getByText('SOTMUS 2023')).toBeInTheDocument(); + expect(screen.getByText('My Friends')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + // Select a team + await userEvent.click(screen.getByText('OSM Teams Developers')); + await waitFor(() => expect(screen.getByTitle('geohacker')).toBeInTheDocument()); + expect(screen.getByText('Selected team')).toBeInTheDocument(); + expect(screen.getByText('Back')).toBeInTheDocument(); + expect(screen.getAllByText('Managers').length).toBe(2); + expect(screen.getAllByText('Members').length).toBe(2); + await userEvent.click(screen.getByText('Confirm selection')); + // Modal closes + await waitFor(() => + expect(screen.getByTitle('Open on OSM Teams').href.endsWith('/teams/1')).toBeTruthy() + ); + expect(screen.queryByText('Selected team')).not.toBeInTheDocument(); + expect(screen.getByTitle('kamicut')).toBeInTheDocument(); + expect(screen.getByTitle('geohacker')).toBeInTheDocument(); + expect(screen.getByTitle('wille')).toBeInTheDocument(); + expect(screen.getByTitle('sanjayb')).toBeInTheDocument(); + expect(screen.getByText('OSM Teams Developers')).toBeInTheDocument(); + }); }); describe('Delete Team', () => { diff --git a/migrations/versions/52a67f6cef20_.py b/migrations/versions/52a67f6cef20_.py new file mode 100644 index 0000000000..b685ba0e80 --- /dev/null +++ b/migrations/versions/52a67f6cef20_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 52a67f6cef20 +Revises: 42c45e74752b +Create Date: 2023-07-06 12:26:39.420411 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +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)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + 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": [],