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">
+
+
+);
+
+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 (