├── .env ├── .env.development ├── .env.test ├── .eslintrc ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.json ├── CHANGELOG.md ├── DEPLOYMENT.md ├── LICENSE ├── README.md ├── app ├── lib │ ├── hydra.js │ ├── osm.js │ └── utils.js ├── manage │ ├── badges.js │ ├── client.js │ ├── index.js │ ├── login.js │ ├── organizations.js │ ├── permissions │ │ ├── clients.js │ │ ├── delete-client.js │ │ ├── edit-key.js │ │ ├── edit-org.js │ │ ├── edit-team.js │ │ ├── edit-user.js │ │ ├── index.js │ │ ├── join-team.js │ │ ├── member-org.js │ │ ├── member-team.js │ │ └── view-org-team-keys.js │ ├── profiles.js │ ├── sessions.js │ ├── teams.js │ └── utils.js └── oauth │ ├── consent.js │ ├── index.js │ └── login.js ├── ava.config.js ├── compose.yml ├── cypress.config.js ├── cypress ├── e2e │ ├── auth.cy.js │ ├── dashboard.cy.js │ ├── organizations │ │ ├── badges.cy.js │ │ ├── pagination.cy.js │ │ └── permissions.cy.js │ └── teams │ │ ├── index.cy.js │ │ ├── invitations.cy.js │ │ └── view.cy.js └── support │ ├── commands │ └── login.js │ ├── e2e.js │ └── index.js ├── hydra-config └── dev │ └── hydra.yml ├── knexfile.js ├── migrations ├── 20190417173534_init.js ├── 20190730183820_team-locations.js ├── 20190805171532_team-name-required.js ├── 20190822135832_editing_policy.js ├── 20200130150202_team-moderator-unique.js ├── 20200130155125_team-member-unique.js ├── 20200326113917_organization.js ├── 20210624112124_team_profiles.js ├── 20211208161927_org_visibility.js ├── 20220105182919_org_staff_visibility.js ├── 20220125220402_org-privacy-policy.js ├── 20220222155039_add_badges.js ├── 20220302104250_add_user_badges.js ├── 20220302135223_key-types.js ├── 20220415114820_invitations.js ├── 20221121094350_drop-hashtag-uniqueness.js └── 20221216122421_username_table.js ├── next-swagger-doc.json ├── next.config.js ├── package.json ├── public └── static │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── ds-logo-pos.svg │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── favicon.png │ ├── grid-map.svg │ ├── guide │ ├── badge-assign.png │ ├── badge-assignment.png │ ├── badge-edit.png │ ├── dashboard.png │ ├── explore.png │ ├── new_badge.png │ ├── new_org-team.png │ ├── new_org.png │ ├── new_team.png │ ├── org-edit-members.png │ ├── org-edit-privacy.png │ ├── org-edit-teams.png │ ├── org-edit.png │ ├── org-page.png │ ├── org-staff.png │ ├── team-delete.png │ ├── team-edit.png │ ├── team-join-link.png │ ├── team-member-actions.png │ ├── team-member-attributes.png │ ├── team-member-profile.png │ ├── team-own-profile.png │ ├── team-page.png │ └── team_add-member.png │ ├── neis-one-logo.png │ ├── osm_logo.png │ ├── osmcha-logo.svg │ ├── osmteams_logo--neg.png │ ├── osmteams_logo--neg.svg │ ├── osmteams_logo--pos.png │ ├── osmteams_logo--pos.svg │ ├── scoreboard-logo.svg │ ├── site.webmanifest │ └── youthmappers-logo.jpeg ├── src ├── components │ ├── Link.js │ ├── add-member-form.js │ ├── add-member-modal.js │ ├── badge.js │ ├── banner.js │ ├── clients.js │ ├── edit-org-form.js │ ├── edit-team-form.js │ ├── error-boundary.js │ ├── external-profile-button.js │ ├── form-map.js │ ├── inpage-header.js │ ├── join-link.js │ ├── list-map.js │ ├── page-footer.js │ ├── page-header.js │ ├── pagination.js │ ├── privacy-policy-form.js │ ├── profile-attribute-form.js │ ├── profile-form.js │ ├── profile-modal.js │ ├── tables │ │ ├── members-table.js │ │ ├── search-input.js │ │ ├── table.js │ │ ├── teams.js │ │ └── users.js │ └── team-map.js ├── hooks │ └── use-fetch-list.js ├── lib │ ├── api-client.js │ ├── badges-api.js │ ├── db.js │ ├── logger.js │ ├── org-api.js │ ├── profiles-api.js │ ├── teams-api.js │ └── utils.js ├── middleware.js ├── middlewares │ ├── base-handler.js │ ├── can │ │ ├── authenticated.js │ │ ├── create-org-team.js │ │ ├── edit-team.js │ │ ├── view-org-members.js │ │ ├── view-org-teams.js │ │ └── view-team-members.js │ └── validation.js ├── models │ ├── badge.js │ ├── organization.js │ ├── profile.js │ ├── team-invitation.js │ ├── team.js │ └── users.js ├── pages │ ├── 404.js │ ├── _app.js │ ├── _document.js │ ├── about.js │ ├── api │ │ ├── [[...slug]].js │ │ ├── auth │ │ │ └── [...nextauth].js │ │ ├── introspect.js │ │ ├── my │ │ │ └── teams.js │ │ ├── organizations │ │ │ └── [orgId] │ │ │ │ ├── locations.js │ │ │ │ ├── members.js │ │ │ │ ├── staff.js │ │ │ │ └── teams.js │ │ ├── swagger.js │ │ ├── teams │ │ │ └── [teamId] │ │ │ │ ├── index.js │ │ │ │ ├── members.js │ │ │ │ └── moderators.js │ │ └── users │ │ │ └── index.js │ ├── clients.js │ ├── consent.js │ ├── dashboard.js │ ├── developers.js │ ├── docs │ │ └── api.js │ ├── guide.js │ ├── index.js │ ├── organizations │ │ ├── [id] │ │ │ ├── badges │ │ │ │ ├── [badgeId] │ │ │ │ │ ├── assign │ │ │ │ │ │ └── [userId].js │ │ │ │ │ └── index.js │ │ │ │ ├── add.js │ │ │ │ └── assign │ │ │ │ │ └── [userId].js │ │ │ ├── edit-privacy-policy.js │ │ │ ├── edit-profiles.js │ │ │ ├── edit-team-profiles.js │ │ │ ├── edit.js │ │ │ ├── index.js │ │ │ ├── maintenance.js │ │ │ └── profile.js │ │ └── create.js │ ├── privacy.js │ ├── signin.js │ └── teams │ │ ├── [id] │ │ ├── edit-profiles.js │ │ ├── edit.js │ │ ├── index.js │ │ ├── invitations │ │ │ └── [invitationId].js │ │ └── profile.js │ │ ├── create.js │ │ └── index.js └── styles │ └── theme.js ├── tests ├── api │ ├── badges-api.test.js │ ├── cache.test.js │ ├── organization-api.test.js │ ├── organization-model.test.js │ ├── profile-api.test.js │ ├── profile-model.test.js │ ├── team-api.test.js │ ├── team-model.test.js │ └── users.test.js ├── permissions │ ├── add-org-team.test.js │ ├── create-team.test.js │ ├── delete-team.test.js │ ├── edit-organization.test.js │ ├── update-team.test.js │ ├── view-team-members.test.js │ └── view-team.test.js └── utils │ ├── create-agent.js │ ├── db-helpers.js │ └── get-session-token.js └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | NEXTAUTH_URL=http://127.0.0.1:3000 2 | DATABASE_URL=postgres://postgres:postgres@localhost:5433/osm-teams?sslmode=disable -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | NEXTAUTH_SECRET=next-auth-development-secret -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | NEXTAUTH_URL=http://127.0.0.1:3000 2 | NEXTAUTH_SECRET=next-auth-cypress-secret 3 | DATABASE_URL=postgres://postgres:postgres@localhost:5434/osm-teams-test 4 | TESTING=true 5 | LOG_LEVEL=silent 6 | AUTH_URL=http://127.0.0.1:3000 -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true 4 | }, 5 | "extends": [ 6 | "eslint:recommended", 7 | "next/core-web-vitals", 8 | "plugin:prettier/recommended", 9 | "plugin:cypress/recommended" 10 | ], 11 | "rules": { 12 | "no-console": 2 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'develop' 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | - reopened 12 | - ready_for_review 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | if: github.event.pull_request.draft == false 18 | timeout-minutes: 30 19 | steps: 20 | - name: Cancel Previous Runs 21 | uses: styfle/cancel-workflow-action@0.11.0 22 | with: 23 | access_token: ${{ github.token }} 24 | 25 | - uses: actions/checkout@v2 26 | with: 27 | ref: ${{ github.event.pull_request.head.sha }} 28 | 29 | - name: Checkout 30 | uses: actions/checkout@v2 31 | 32 | - name: Setup node from node version file 33 | uses: actions/setup-node@v2 34 | with: 35 | node-version-file: '.nvmrc' 36 | cache: 'npm' 37 | 38 | - name: Install 39 | if: steps.cache-node-modules.outputs.cache-hit != 'true' 40 | run: yarn install 41 | 42 | - name: Lint 43 | run: yarn lint 44 | 45 | - name: Validate Swagger Docs 46 | run: yarn docs:validate 47 | 48 | - name: Docker - Pull 49 | run: docker-compose pull 50 | 51 | - name: Docker - Start Test DB 52 | run: docker-compose up --build -d test-db 53 | 54 | - name: Migrate database 55 | run: for i in {1..6}; do yarn migrate:test && break || sleep 10; done # retries up to 6 times every 10 s if db is not available 56 | 57 | - name: Run API tests 58 | run: yarn test 59 | 60 | - name: Run Cypress tests 61 | run: yarn e2e 62 | 63 | - name: Docker Cleanup 64 | run: docker-compose kill 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | tmp 4 | *.log* 5 | .next 6 | .vscode 7 | *.db 8 | # ignore node_modules symlink 9 | node_modules.nosync 10 | .idea 11 | hydra-config/prod/prod.yml 12 | .nyc_output 13 | coverage 14 | docker-data/* 15 | public/swagger.json 16 | .env*.local 17 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 2, 4 | "jsxSingleQuote": true, 5 | "semi": false 6 | } 7 | -------------------------------------------------------------------------------- /DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # Production Deployment Documentation 2 | 3 | ## Requirements 4 | 5 | In production, you will need either a subdomain or a namespace for the hydra token issuer. Nginx, Apache or Caddyserver could be used as a reverse proxy server to capture the URL and forward to either the OSM Teams application or hydra. For this example, let's take the use case of mapping.team. 6 | 7 | ## Setting up Hydra URLs 8 | 9 | 1. Copy over `hydra-config/dev/hydra.yml` to `hydra-config/prod/hydra.yml` 10 | 2. Modify the `urls` values in `hydra.yml` 11 | 12 | For example, for `mapping.team` we modify the urls to be the following: 13 | 14 | ```yaml 15 | urls: 16 | self: 17 | issuer: https://mapping.team/hyauth 18 | consent: https://mapping.team/oauth/consent 19 | login: https://mapping.team/oauth/login 20 | logout: https://mapping.team/oauth/logout 21 | ``` 22 | 23 | This is because we set the token provider at `https://mapping.team/hyauth` and use a reverse proxy (nginx): 24 | 25 | ```nginx 26 | server { 27 | server_name mapping.team dev.mapping.team; 28 | rewrite ^(.*\b(docs)\b.*[^/])$ $1/ redirect; 29 | 30 | location /hyauth/ { 31 | proxy_set_header host $host; 32 | proxy_pass http://127.0.0.1:4444/; 33 | } 34 | 35 | location / { 36 | proxy_set_header host $host; 37 | proxy_pass http://127.0.0.1:3000/; 38 | } 39 | } 40 | ``` 41 | 42 | 3. Modify `hydra.yml` system secret and others according to the [configuration spec](https://www.ory.sh/hydra/docs/reference/configuration/) 43 | 44 | ### Env variables 45 | 46 | Similarly to local development, we have to add `OSM_CONSUMER_KEY`, `OSM_CONSUMER_SECRET` and `DSN` to `.env` 47 | 48 | We also have to add a few environment variables so that the tokens are issued by the proper URL: 49 | 50 | ```bash 51 | HYDRA_ADMIN_HOST=http://hydra:4445 52 | HYDRA_TOKEN_HOST=http://hydra:4444 53 | HYDRA_TOKEN_PATH=/oauth2/token 54 | HYDRA_AUTHZ_HOST=https://mapping.team 55 | HYDRA_AUTHZ_PATH=/hyauth/oauth2/auth 56 | ``` 57 | 58 | - `HYDRA_ADMIN_HOST` and `HYDRA_TOKEN_HOST` are internal URLs accessed by docker to issue tokens, so they use the `hydra` service name. 59 | - `HYDRA_TOKEN_PATH` uses the default internal URL for hydra tokens 60 | - `HYDRA_AUTHZ_HOST` is the domain where the browser will initiate the authorization request 61 | - `HYDRA_AUTHZ_PATH` is the path where the browser initiates the authorization request 62 | 63 | Finally we should set `APP_URL` to be the new domain, for example `https://mapping.team` 64 | 65 | If you are using a sub-path of a domain you should set the `BASE_PATH` environment variable 66 | 67 | `.env` will now look like: 68 | 69 | ```sh 70 | OSM_CONSUMER_KEY= 71 | OSM_CONSUMER_SECRET= 72 | APP_URL=https://mapping.team 73 | DSN= 74 | BASE_PATH=/example 75 | HYDRA_ADMIN_HOST=http://hydra:4445 76 | HYDRA_TOKEN_HOST=http://hydra:4444 77 | HYDRA_TOKEN_PATH=/oauth2/token 78 | HYDRA_AUTHZ_HOST=https://mapping.team 79 | HYDRA_AUTHZ_PATH=/hyauth/oauth2/auth 80 | ``` 81 | 82 | ## Deployment 83 | 84 | Once the environment variables, `hydra-config/hydra.yml` and the reverse proxy are created, we can then run: 85 | 86 | ```docker 87 | docker-compose -f compose.yml -f compose.prod.yml up 88 | ``` 89 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Development Seed 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OSM Teams 🤝 2 | 3 | ## Development 4 | 5 | Install requirements: 6 | 7 | - [nvm](https://github.com/creationix/nvm) 8 | - [Docker](https://www.docker.com) 9 | 10 | Setup local authentication: 11 | 12 | - Visit [auth.mapping.team](https://auth.mapping.team) and sign in 13 | - Go to clients page at 14 | - Create a new app with the following settings: 15 | - Name: `OSM Teams Dev` (or another name of your preference) 16 | - Redirect URIs: `http://127.0.0.1:3000/api/auth/callback/osm-teams` 17 | - Copy client id and secret to a newly created file named `.env.local` in the repository root, following this example: 18 | 19 | ```sh 20 | OSM_CONSUMER_KEY= 21 | OSM_CONSUMER_SECRET= 22 | ``` 23 | 24 | Start development and test databases with Docker: 25 | 26 | docker-compose up --build 27 | 28 | Install Node.js the required version (see [.nvmrc](.nvmrc) file): 29 | 30 | nvm i 31 | 32 | Install Node.js modules: 33 | 34 | yarn 35 | 36 | Migrate `dev-db` database: 37 | 38 | yarn migrate 39 | 40 | Start development server: 41 | 42 | yarn dev 43 | 44 | 45 | 46 | ✨ You can now login to the app at http://127.0.0.1:3000 47 | 48 | 49 | 50 | ## Testing 51 | 52 | Migrate `test-db` database: 53 | 54 | yarn migrate:test 55 | 56 | This project uses Cypress for end-to-end testing. To run once: 57 | 58 | yarn e2e 59 | 60 | To open Cypress dashboard for interactive development: 61 | 62 | yarn e2e:dev 63 | 64 | By default, logging level in testing environment is set to 'silent'. Please refer to pino docs for the full [list of log levels](https://getpino.io/#/docs/api?id=level-string). 65 | 66 | ## API 67 | 68 | The API docs can be accessed at . 69 | 70 | All API routes should include descriptions in [OpenAPI 3.0 format](https://swagger.io/specification). 71 | 72 | Run the following command to validate the API docs: 73 | 74 | yarn docs:validate 75 | 76 | ## Acknowledgments 77 | 78 | - This app is based off of [OSM/Hydra](https://github.com/kamicut/osmhydra) 79 | 80 | ## LICENSE 81 | 82 | [MIT](LICENSE) 83 | -------------------------------------------------------------------------------- /app/lib/utils.js: -------------------------------------------------------------------------------- 1 | const { head, has } = require('ramda') 2 | 3 | async function unpack(promise) { 4 | return promise.then(head) 5 | } 6 | 7 | class ValidationError extends Error { 8 | constructor(message) { 9 | super(message) 10 | this.name = 'ValidationError' 11 | } 12 | } 13 | class PropertyRequiredError extends ValidationError { 14 | constructor(property) { 15 | super('No property: ' + property) 16 | this.name = 'PropertyRequiredError' 17 | this.property = property 18 | } 19 | } 20 | 21 | /** 22 | * @param {string[]} requiredProperties required keys in an object 23 | * @param {Object} object object to check 24 | */ 25 | function checkRequiredProperties(requiredProperties, object) { 26 | requiredProperties.forEach((key) => { 27 | if (!has(key)(object)) { 28 | throw new PropertyRequiredError(key) 29 | } 30 | }) 31 | } 32 | 33 | /** 34 | * Converts a date to the browser locale string 35 | * 36 | * @param {Number or String} timestamp 37 | * @returns 38 | */ 39 | function toDateString(timestamp) { 40 | const dateFormat = new Intl.DateTimeFormat(navigator.language).format 41 | return dateFormat(new Date(timestamp)) 42 | } 43 | 44 | /* Transform string into title case */ 45 | function makeTitleCase(text) { 46 | return text 47 | .split(' ') 48 | .map((word) => `${word[0].toUpperCase()}${word.slice(1)}`) 49 | .join(' ') 50 | } 51 | 52 | module.exports = { 53 | unpack, 54 | ValidationError, 55 | PropertyRequiredError, 56 | checkRequiredProperties, 57 | toDateString, 58 | makeTitleCase, 59 | } 60 | -------------------------------------------------------------------------------- /app/manage/client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Routes to create / read / delete OAuth clients 3 | */ 4 | 5 | const hydra = require('../lib/hydra') 6 | const { serverRuntimeConfig } = require('../../next.config') 7 | const manageId = serverRuntimeConfig.OSM_HYDRA_ID 8 | 9 | /** 10 | * Get OAuth clients from Hydra 11 | * @param {*} req 12 | * @param {*} res 13 | */ 14 | async function getClients(req, res) { 15 | const { 16 | session: { user_id }, 17 | } = req 18 | let clients = await hydra.getClients() 19 | 20 | // Remove first party client from list & exclude clients the user does not own 21 | let filteredClients = clients.filter( 22 | (c) => c.client_id !== manageId && c.owner === user_id 23 | ) 24 | 25 | return res.send({ clients: filteredClients }) 26 | } 27 | 28 | /** 29 | * Create OAuth client 30 | * 31 | * @param {*} req 32 | * @param {*} res 33 | */ 34 | async function createClient(req, res) { 35 | let toCreate = Object.assign({}, req.body) 36 | toCreate['scope'] = 'openid offline' 37 | toCreate['response_types'] = ['code', 'id_token'] 38 | toCreate['grant_types'] = ['refresh_token', 'authorization_code'] 39 | toCreate['owner'] = req.session.user_id 40 | let client = await hydra.createClient(toCreate) 41 | return res.send({ client }) 42 | } 43 | 44 | /** 45 | * Delete OAuth client 46 | * @param {*} req 47 | * @param {*} res 48 | */ 49 | function deleteClient(req, res) { 50 | hydra.deleteClient(req.params.id).then(() => res.status(200)) 51 | } 52 | 53 | module.exports = { 54 | getClients, 55 | createClient, 56 | deleteClient, 57 | } 58 | -------------------------------------------------------------------------------- /app/manage/login.js: -------------------------------------------------------------------------------- 1 | const { serverRuntimeConfig } = require('../../next.config') 2 | const jwt = require('jsonwebtoken') 3 | const db = require('../../src/lib/db') 4 | const logger = require('../../src/lib/logger') 5 | 6 | const APP_URL = process.env.APP_URL 7 | 8 | const credentials = { 9 | client: { 10 | id: serverRuntimeConfig.OSM_HYDRA_ID, 11 | secret: serverRuntimeConfig.OSM_HYDRA_SECRET, 12 | }, 13 | auth: { 14 | tokenHost: serverRuntimeConfig.HYDRA_TOKEN_HOST, 15 | tokenPath: serverRuntimeConfig.HYDRA_TOKEN_PATH, 16 | authorizeHost: 17 | serverRuntimeConfig.HYDRA_AUTHZ_HOST || 18 | serverRuntimeConfig.HYDRA_TOKEN_HOST, 19 | authorizePath: serverRuntimeConfig.HYDRA_AUTHZ_PATH, 20 | }, 21 | } 22 | 23 | const oauth2 = require('simple-oauth2').create(credentials) 24 | 25 | var generateState = function (length) { 26 | var text = '' 27 | var possible = 28 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 29 | for (var i = 0; i < length; i++) { 30 | text += possible.charAt(Math.floor(Math.random() * possible.length)) 31 | } 32 | return text 33 | } 34 | 35 | function login(req, res) { 36 | let state = generateState(24) 37 | const authorizationUri = oauth2.authorizationCode.authorizeURL({ 38 | redirect_uri: `${APP_URL}/login/accept`, 39 | scope: 'openid clients', 40 | state, 41 | }) 42 | req.session.login_csrf = state 43 | 44 | res.redirect(authorizationUri) 45 | } 46 | 47 | async function loginAccept(req, res) { 48 | const { code, state } = req.query 49 | /** 50 | * Token exchange with CSRF handling 51 | */ 52 | if (state !== req.session.login_csrf) { 53 | req.session.destroy(function (err) { 54 | if (err) logger.error(err) 55 | return res.status(500).json('State does not match') 56 | }) 57 | } else { 58 | // Flush csrf 59 | req.session.login_csrf = null 60 | 61 | // Create options for token exchange 62 | const options = { 63 | code, 64 | redirect_uri: `${APP_URL}/login/accept`, 65 | } 66 | 67 | try { 68 | const result = await oauth2.authorizationCode.getToken(options) 69 | const { sub } = jwt.decode(result.id_token) 70 | 71 | // Store access token and refresh token 72 | await db('users') 73 | .where('id', sub) 74 | .update({ 75 | manageToken: JSON.stringify(result), 76 | }) 77 | 78 | // Store id token in session 79 | req.session.idToken = result.id_token 80 | return res.redirect(`${APP_URL}/dashboard`) 81 | } catch (error) { 82 | logger.error(error) 83 | return res.status(500).json('Authentication failed') 84 | } 85 | } 86 | } 87 | 88 | /** 89 | * Logout deletes the session from the manage app 90 | * @param {*} req 91 | * @param {*} res 92 | */ 93 | function logout(req, res) { 94 | req.session.destroy(function (err) { 95 | if (err) logger.error(err) 96 | res.redirect(APP_URL) 97 | }) 98 | } 99 | 100 | module.exports = { 101 | login, 102 | loginAccept, 103 | logout, 104 | } 105 | -------------------------------------------------------------------------------- /app/manage/permissions/clients.js: -------------------------------------------------------------------------------- 1 | const db = require('../../../src/lib/db') 2 | 3 | /** 4 | * clients 5 | * 6 | * To access the clients API, requests need to be authenticated 7 | * with a signed up user 8 | * 9 | * @param {string} uid user id 10 | * @param {Object} params request parameters 11 | * @returns {boolean} can the request go through? 12 | */ 13 | async function clients(uid) { 14 | try { 15 | const [user] = await db('users').where('id', uid) 16 | if (user) { 17 | return true 18 | } 19 | } catch (error) { 20 | throw Error('Forbidden') 21 | } 22 | } 23 | 24 | module.exports = clients 25 | -------------------------------------------------------------------------------- /app/manage/permissions/delete-client.js: -------------------------------------------------------------------------------- 1 | const db = require('../../../src/lib/db') 2 | 3 | /** 4 | * client:delete 5 | * 6 | * To delete a client, an authenticated user must own this client 7 | * 8 | * 9 | * @param uid 10 | * @returns {undefined} 11 | */ 12 | async function deleteClient(uid, { id }) { 13 | const [client] = await db('hydra_client').where('id', id) 14 | return client.owner === uid 15 | } 16 | 17 | module.exports = deleteClient 18 | -------------------------------------------------------------------------------- /app/manage/permissions/edit-key.js: -------------------------------------------------------------------------------- 1 | const profile = require('../../../src/models/profile') 2 | const organization = require('../../../src/models/organization') 3 | const team = require('../../../src/models/team') 4 | const R = require('ramda') 5 | 6 | /** 7 | * key:edit 8 | * 9 | * Only the same owner of a key can edit it 10 | * 11 | * @param {string} uid user id 12 | * @param {Object} params request parameters 13 | * @returns {Promise} can the request go through? 14 | */ 15 | async function editKey(uid, { id }) { 16 | // user has to be authenticated 17 | if (!uid) return false 18 | 19 | const key = await profile.getProfileKey(id) 20 | 21 | let owners = [] 22 | 23 | if (key.owner_user) { 24 | return uid.toString() === key.owner_user.toString() 25 | } 26 | 27 | if (key.owner_team) { 28 | owners = await team.getModerators(key.owner_team) 29 | } 30 | 31 | if (key.owner_org) { 32 | owners = await organization.getOwners(key.owner_org) 33 | } 34 | let osmIds = owners.map((owner) => R.prop('osm_id', owner).toString()) 35 | return osmIds.includes(uid.toString()) 36 | } 37 | 38 | module.exports = editKey 39 | -------------------------------------------------------------------------------- /app/manage/permissions/edit-org.js: -------------------------------------------------------------------------------- 1 | const { isOwner } = require('../../../src/models/organization') 2 | 3 | /** 4 | * organization:edit 5 | * 6 | * To edit an organization or delete it, the authenticated user needs 7 | * to be an owner in the organization 8 | * 9 | * @param {int} uid - user id 10 | * @param {Object} params - request parameters 11 | * @param {int} params.id - organization id 12 | * @returns {Promise} 13 | */ 14 | async function editOrg(uid, { id }) { 15 | if (!uid) { 16 | return false 17 | } 18 | return isOwner(id, uid) 19 | } 20 | 21 | module.exports = editOrg 22 | -------------------------------------------------------------------------------- /app/manage/permissions/edit-team.js: -------------------------------------------------------------------------------- 1 | const { isModerator, associatedOrg } = require('../../../src/models/team') 2 | const { isOwner } = require('../../../src/models/organization') 3 | 4 | /** 5 | * team:update 6 | * 7 | * To update a team, the authenticated user needs to 8 | * be a moderator of the team or an owner of the organization 9 | * that the team is associated to 10 | * 11 | * @param {string} uid user id 12 | * @param {Object} params request parameters 13 | * @returns {Promise} can the request go through? 14 | */ 15 | async function updateTeam(uid, { id }) { 16 | // user has to be authenticated 17 | if (!uid) return false 18 | 19 | // check if user is an owner of this team through an organization 20 | const org = await associatedOrg(id) 21 | const ownerOfTeam = org && (await isOwner(org.organization_id, uid)) 22 | 23 | return ownerOfTeam || isModerator(id, uid) 24 | } 25 | 26 | module.exports = updateTeam 27 | -------------------------------------------------------------------------------- /app/manage/permissions/edit-user.js: -------------------------------------------------------------------------------- 1 | /** 2 | * user:edit 3 | * 4 | * Only the same user id can update its profile 5 | * 6 | * @param {string} uid user id 7 | * @param {Object} params request parameters 8 | * @returns {Promise} can the request go through? 9 | */ 10 | async function updateUser(uid, { id }) { 11 | // user has to be authenticated 12 | if (!uid) return false 13 | 14 | return uid === id 15 | } 16 | 17 | module.exports = updateUser 18 | -------------------------------------------------------------------------------- /app/manage/permissions/index.js: -------------------------------------------------------------------------------- 1 | const Boom = require('boom') 2 | const { mergeAll, isNil } = require('ramda') 3 | 4 | const metaPermissions = { 5 | 'public:authenticated': (uid) => !isNil(uid), // User needs to be authenticated 6 | } 7 | 8 | const userPermissions = { 9 | 'user:edit': require('./edit-user'), 10 | } 11 | 12 | const keyPermissions = { 13 | 'key:edit': require('./edit-key'), 14 | } 15 | 16 | const teamPermissions = { 17 | 'team:edit': require('./edit-team'), 18 | 'team:join': require('./join-team'), 19 | 'team:member': require('./member-team'), 20 | } 21 | 22 | const organizationPermissions = { 23 | 'organization:edit': require('./edit-org'), 24 | 'organization:member': require('./member-org'), 25 | 'organization:view-team-keys': require('./view-org-team-keys'), 26 | } 27 | 28 | const clientPermissions = { 29 | clients: require('./clients'), 30 | 'client:delete': require('./delete-client'), 31 | } 32 | 33 | const permissions = mergeAll([ 34 | metaPermissions, 35 | userPermissions, 36 | keyPermissions, 37 | teamPermissions, 38 | clientPermissions, 39 | organizationPermissions, 40 | ]) 41 | 42 | /** 43 | * Check if a user has a specific permission 44 | * 45 | * @param {Object} req Request object 46 | * @param {Object} res Response object 47 | * @param {String} ability String representing a specific permission, for example: `team:create` 48 | */ 49 | async function checkPermission(req, res, ability) { 50 | return permissions[ability](req.session?.user_id, req.params) 51 | } 52 | 53 | /** 54 | * Given a permission, check if the user is allowed to perform the action 55 | * @param {string} ability the permission 56 | */ 57 | function check(ability) { 58 | return async function (req, res, next) { 59 | /** 60 | * Permissions decision function 61 | * @param {string} uid user id 62 | * @param {Object} params request parameters 63 | * @returns {boolean} can the request go through? 64 | */ 65 | let allowed = await checkPermission(req, res, ability) 66 | 67 | if (allowed) { 68 | next() 69 | } else { 70 | throw Boom.unauthorized('Forbidden') 71 | } 72 | } 73 | } 74 | 75 | module.exports = { 76 | can: (ability) => { 77 | return [check(ability)] 78 | }, 79 | check, 80 | } 81 | -------------------------------------------------------------------------------- /app/manage/permissions/join-team.js: -------------------------------------------------------------------------------- 1 | const { isPublic, isMember } = require('../../../src/models/team') 2 | 3 | /** 4 | * team:join 5 | * 6 | * To join a team, the team must be public 7 | * 8 | * @param {string} uid user id 9 | * @param {Object} params request parameters 10 | * @returns {boolean} can the request go through? 11 | */ 12 | async function joinTeam(uid, { id }) { 13 | // User has to be authenticated 14 | if (!uid) { 15 | return false 16 | } 17 | 18 | const publicTeam = await isPublic(id) 19 | const member = await isMember(id, uid) 20 | return publicTeam && !member 21 | } 22 | 23 | module.exports = joinTeam 24 | -------------------------------------------------------------------------------- /app/manage/permissions/member-org.js: -------------------------------------------------------------------------------- 1 | const { isMemberOrStaff } = require('../../../src/models/organization') 2 | 3 | /** 4 | * org:member 5 | * 6 | * Scope if member of org 7 | * 8 | * @param {string} uid user id 9 | * @param {Object} params request parameters 10 | * @returns {boolean} can the request go through? 11 | */ 12 | async function memberOrg(uid, { id }) { 13 | try { 14 | return await isMemberOrStaff(id, uid) 15 | } catch (e) { 16 | return false 17 | } 18 | } 19 | 20 | module.exports = memberOrg 21 | -------------------------------------------------------------------------------- /app/manage/permissions/member-team.js: -------------------------------------------------------------------------------- 1 | const { isMember } = require('../../../src/models/team') 2 | 3 | /** 4 | * team:member 5 | * 6 | * Scope if member of team 7 | * 8 | * @param {string} uid user id 9 | * @param {Object} params request parameters 10 | * @returns {boolean} can the request go through? 11 | */ 12 | async function memberTeam(uid, { id }) { 13 | try { 14 | return await isMember(id, uid) 15 | } catch (e) { 16 | return false 17 | } 18 | } 19 | 20 | module.exports = memberTeam 21 | -------------------------------------------------------------------------------- /app/manage/permissions/view-org-team-keys.js: -------------------------------------------------------------------------------- 1 | const { 2 | isOwner, 3 | isOrgTeamModerator, 4 | } = require('../../../src/models/organization') 5 | 6 | /** 7 | * organization:view-team-keys 8 | * 9 | * To edit an organization or delete it, the authenticated user needs 10 | * to be an owner in the organization 11 | * 12 | * @param {int} uid - user id 13 | * @param {Object} params - request parameters 14 | * @param {int} params.id - organization id 15 | * @returns {Promise} 16 | */ 17 | async function editOrg(uid, { id }) { 18 | const teamModerator = await isOrgTeamModerator(id, uid) 19 | return teamModerator || isOwner(id, uid) 20 | } 21 | 22 | module.exports = editOrg 23 | -------------------------------------------------------------------------------- /app/manage/sessions.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken') 2 | const session = require('express-session') 3 | const SessionStore = require('connect-session-knex')(session) 4 | const knex = require('knex') 5 | const connections = require('../../knexfile') 6 | 7 | const { serverRuntimeConfig } = require('../../next.config') 8 | const knexConfig = connections[process.env.NODE_ENV] 9 | 10 | /** 11 | * Configure the session 12 | */ 13 | const SESSION_SECRET = 14 | serverRuntimeConfig.SESSION_SECRET || 'super-secret-sessions' 15 | let sessionConfig = { 16 | name: 'osm-teams.sid', 17 | secret: SESSION_SECRET, 18 | resave: false, 19 | saveUninitialized: true, 20 | store: new SessionStore({ 21 | knex: knex(knexConfig), 22 | tableName: 'app_sessions', 23 | }), 24 | } 25 | 26 | /** 27 | * Returns true if a jwt is still valid 28 | * 29 | * @param {jwt} decoded the decoded jwt token 30 | */ 31 | function assertAlive(decoded) { 32 | const now = Date.now().valueOf() / 1000 33 | if (typeof decoded.exp !== 'undefined' && decoded.exp < now) { 34 | return false 35 | } 36 | if (typeof decoded.nbf !== 'undefined' && decoded.nbf > now) { 37 | return false 38 | } 39 | return true 40 | } 41 | 42 | /** 43 | * After we've logged in, we should have a jwt token in the session 44 | * We attach the information from the jwt token to the session 45 | */ 46 | function attachUser() { 47 | return function (req, res, next) { 48 | if (req.session) { 49 | if (req.session.idToken) { 50 | // We have an id_token, let's check if it's still valid 51 | const decoded = jwt.decode(req.session.idToken) 52 | if (assertAlive(decoded)) { 53 | req.session.user_id = decoded.sub 54 | req.session.user = decoded.preferred_username 55 | req.session.user_picture = decoded.picture 56 | return next() 57 | } else { 58 | // no longer alive, let's flush the session 59 | req.session.destroy(function (err) { 60 | if (err) next(err) 61 | return next() 62 | }) 63 | } 64 | } 65 | } 66 | next() 67 | } 68 | } 69 | 70 | const sessionMiddleware = [session(sessionConfig), attachUser()] 71 | module.exports = sessionMiddleware 72 | -------------------------------------------------------------------------------- /app/manage/utils.js: -------------------------------------------------------------------------------- 1 | const logger = require('../../src/lib/logger') 2 | 3 | /** 4 | * Route wrapper to perform validation before processing 5 | * the request. 6 | * @param {function} config.validate Yup validation schema 7 | * @param {function} config.handler Handler to execute if validation pass 8 | * 9 | * @returns {function} Route middleware function 10 | */ 11 | function routeWrapper(config) { 12 | const { validate, handler } = config 13 | return async (req, reply) => { 14 | try { 15 | if (validate.params) { 16 | req.params = await validate.params.validate(req.params) 17 | } 18 | 19 | if (validate.body) { 20 | req.body = await validate.body.validate(req.body) 21 | } 22 | } catch (error) { 23 | logger.error(error) 24 | reply.boom.badRequest(error) 25 | } 26 | await handler(req, reply) 27 | } 28 | } 29 | 30 | module.exports = { 31 | routeWrapper, 32 | } 33 | -------------------------------------------------------------------------------- /app/oauth/consent.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Consent provider 3 | */ 4 | 5 | const hydra = require('../lib/hydra') 6 | const url = require('url') 7 | const db = require('../../src/lib/db') 8 | const { serverRuntimeConfig } = require('../../next.config') 9 | const { path } = require('ramda') 10 | const logger = require('../../src/lib/logger') 11 | 12 | async function idTokenExtraParams(sub) { 13 | const [user] = await db('users').where('id', sub) 14 | const { profile } = user 15 | const displayName = profile.displayName || sub 16 | const picture = 17 | path(['_xml2json', 'user', 'img', '@', 'href'], profile) || 18 | `https://www.gravatar.com/avatar/${sub}?d=identicon` 19 | return { 20 | preferred_username: displayName, 21 | picture, 22 | } 23 | } 24 | 25 | function getConsent(app) { 26 | return async (req, res, next) => { 27 | const query = url.parse(req.url, true).query 28 | const challenge = query.consent_challenge 29 | 30 | try { 31 | let consent = await hydra.getConsentRequest(challenge) // Check for challenge success 32 | let idToken = await idTokenExtraParams(consent.subject) 33 | 34 | // We can skip if skip is set to yes or if the requesting app is the management UI 35 | if ( 36 | consent.skip || 37 | consent.client.client_id === serverRuntimeConfig.OSM_HYDRA_ID 38 | ) { 39 | let accept = await hydra.acceptConsentRequest(challenge, { 40 | grant_scope: consent.requested_scope, 41 | grant_access_token_audience: consent.requested_access_token_audience, 42 | session: { 43 | id_token: idToken, 44 | }, 45 | }) 46 | 47 | res.redirect(accept.redirect_to) 48 | } else { 49 | app.render(req, res, '/consent', { 50 | challenge: challenge, 51 | requested_scope: consent.requested_scope, 52 | user: idToken.preferred_username, 53 | client: consent.client, 54 | }) 55 | } 56 | } catch (e) { 57 | next(e) 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * Process the reply of the user, whether they 64 | * consent the client to access their information 65 | */ 66 | function postConsent() { 67 | return async (req, res, next) => { 68 | const challenge = req.body.challenge 69 | if (req.body.submit === 'Deny access') { 70 | try { 71 | let reject = await hydra.rejectConsentRequest(challenge, { 72 | error: 'access_denied', 73 | error_description: 'The resource owner denied the request', 74 | }) 75 | res.redirect(reject.redirect_to) 76 | } catch (e) { 77 | next(e) 78 | } 79 | } else { 80 | let grant_scope = req.body.grant_scope 81 | if (!Array.isArray(grant_scope)) { 82 | grant_scope = [grant_scope] 83 | } 84 | 85 | try { 86 | const consent = await hydra.getConsentRequest(challenge) 87 | let idToken = await idTokenExtraParams(consent.subject) 88 | let accept = await hydra.acceptConsentRequest(challenge, { 89 | grant_scope, 90 | session: { 91 | id_token: idToken, 92 | }, 93 | grant_access_token_audience: consent.requested_access_token_audience, 94 | remember: Boolean(req.body.remember), 95 | remember_for: 3600, 96 | }) 97 | res.redirect(accept.redirect_to) 98 | } catch (e) { 99 | logger.error(e) 100 | next(e) 101 | } 102 | } 103 | } 104 | } 105 | 106 | module.exports = { 107 | getConsent, 108 | postConsent, 109 | } 110 | -------------------------------------------------------------------------------- /app/oauth/index.js: -------------------------------------------------------------------------------- 1 | const router = require('express-promise-router')() 2 | const expressPino = require('express-pino-logger') 3 | const bodyParser = require('body-parser') 4 | 5 | const { getLogin } = require('./login') 6 | const { getConsent, postConsent } = require('./consent') 7 | const { openstreetmap } = require('../lib/osm') 8 | const logger = require('../lib/logger') 9 | 10 | /** 11 | * The oauthRouter handles the oauth flow and displaying login and 12 | * consent dialogs 13 | * 14 | * @param {Object} nextApp the NextJS Server 15 | */ 16 | function oauthRouter(nextApp) { 17 | router.use( 18 | expressPino({ 19 | logger: logger.child({ module: 'oauth' }), 20 | }) 21 | ) 22 | 23 | /** 24 | * Redirecting to openstreetmp 25 | */ 26 | router.get('/openstreetmap', openstreetmap) 27 | router.get('/openstreetmap/callback', openstreetmap) 28 | 29 | /** 30 | * Consent & Login dialogs 31 | */ 32 | router.get('/login', getLogin(nextApp)) 33 | router.get('/consent', getConsent(nextApp)) 34 | router.post( 35 | '/consent', 36 | bodyParser.urlencoded({ extended: false }), 37 | postConsent(nextApp) 38 | ) 39 | 40 | return router 41 | } 42 | 43 | module.exports = oauthRouter 44 | -------------------------------------------------------------------------------- /app/oauth/login.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Login Provider 3 | */ 4 | 5 | const hydra = require('../lib/hydra') 6 | const url = require('url') 7 | 8 | function getLogin(app) { 9 | return async function login(req, res, next) { 10 | const query = url.parse(req.url, true).query 11 | const challenge = query.login_challenge 12 | if (!challenge) return next() 13 | 14 | try { 15 | let { subject, skip } = await hydra.getLoginRequest(challenge) 16 | 17 | // TODO check if the user has revoked their OSM token 18 | 19 | if (skip) { 20 | const { redirect_to } = await hydra.acceptLoginRequest(challenge, { 21 | subject, 22 | }) 23 | res.redirect(redirect_to) 24 | } else { 25 | app.render(req, res, '/login', { 26 | challenge: challenge, 27 | }) 28 | } 29 | } catch (e) { 30 | next(e) 31 | } 32 | } 33 | } 34 | 35 | module.exports = { 36 | getLogin, 37 | } 38 | -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | files: ['tests/**/*.test.js'], 3 | concurrency: 1, 4 | verbose: true, 5 | serial: true, 6 | } 7 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | dev-db: 5 | platform: linux/amd64 6 | image: mdillon/postgis:9.6-alpine 7 | restart: 'always' 8 | ports: 9 | - 5433:5432 10 | environment: 11 | - ALLOW_IP_RANGE=0.0.0.0/0 12 | - POSTGRES_DB=osm-teams 13 | - PGDATA=/opt/postgres/data 14 | volumes: 15 | - ./docker-data/dev-db:/opt/postgres/data 16 | 17 | test-db: 18 | platform: linux/amd64 19 | image: mdillon/postgis:9.6-alpine 20 | restart: 'always' 21 | ports: 22 | - 5434:5432 23 | environment: 24 | - ALLOW_IP_RANGE=0.0.0.0/0 25 | - POSTGRES_DB=osm-teams-test 26 | - PGDATA=/opt/postgres/data 27 | volumes: 28 | - ./docker-data/test-db:/opt/postgres/data 29 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | const db = require('./src/lib/db') 3 | const Team = require('./src/models/team') 4 | const Organization = require('./src/models/organization') 5 | const TeamInvitation = require('./src/models/team-invitation') 6 | const Badge = require('./src/models/badge') 7 | const { pick } = require('ramda') 8 | 9 | module.exports = defineConfig({ 10 | e2e: { 11 | baseUrl: 'http://127.0.0.1:3000/', 12 | video: false, 13 | setupNodeEvents(on) { 14 | on('task', { 15 | 'db:reset': async () => { 16 | await db.raw('TRUNCATE TABLE team RESTART IDENTITY CASCADE') 17 | await db.raw('TRUNCATE TABLE organization RESTART IDENTITY CASCADE') 18 | await db.raw('TRUNCATE TABLE users RESTART IDENTITY CASCADE') 19 | await db.raw('TRUNCATE TABLE osm_users RESTART IDENTITY CASCADE') 20 | await db.raw( 21 | 'TRUNCATE TABLE organization_badge RESTART IDENTITY CASCADE' 22 | ) 23 | await db.raw('TRUNCATE TABLE user_badges RESTART IDENTITY CASCADE') 24 | return null 25 | }, 26 | 'db:seed:create-teams': async ({ teams, moderatorId }) => { 27 | let createdTeams = [] 28 | for (let i = 0; i < teams.length; i++) { 29 | const team = teams[i] 30 | createdTeams.push(await Team.create(team, moderatorId)) 31 | } 32 | return createdTeams 33 | }, 34 | 'db:seed:add-member-to-teams': async ({ memberId, teams }) => { 35 | for (let i = 0; i < teams.length; i++) { 36 | const team = teams[i] 37 | await Team.addMember(team.id, memberId) 38 | } 39 | return null 40 | }, 41 | 'db:seed:add-members-to-team': async ({ teamId, members }) => { 42 | for (let i = 0; i < members.length; i++) { 43 | const member = members[i] 44 | await Team.addMember(teamId, member.id) 45 | } 46 | return null 47 | }, 48 | 'db:seed:create-team-invitations': async (teamInvitations) => { 49 | return Promise.all(teamInvitations.map(TeamInvitation.create)) 50 | }, 51 | 'db:seed:create-organizations': async (orgs) => { 52 | for (let i = 0; i < orgs.length; i++) { 53 | const org = orgs[i] 54 | await Organization.create( 55 | pick(['name', 'privacy'], org), 56 | org.ownerId 57 | ) 58 | } 59 | return null 60 | }, 61 | 'db:seed:create-organization-teams': async ({ 62 | orgId, 63 | teams, 64 | managerId, 65 | }) => { 66 | for (let i = 0; i < teams.length; i++) { 67 | const team = teams[i] 68 | await Organization.createOrgTeam( 69 | orgId, 70 | pick(['id', 'name', 'privacy'], team), 71 | managerId 72 | ) 73 | } 74 | return null 75 | }, 76 | 'db:seed:add-organization-managers': async ({ orgId, managerIds }) => { 77 | for (let i = 0; i < managerIds.length; i++) { 78 | const managerId = managerIds[i] 79 | await Organization.addManager(orgId, managerId) 80 | } 81 | return null 82 | }, 83 | 'db:seed:create-organization-badges': async ({ orgId, badges }) => { 84 | for (let i = 0; i < badges.length; i++) { 85 | const badge = badges[i] 86 | await db('organization_badge').insert({ 87 | organization_id: orgId, 88 | ...pick(['id', 'name', 'color'], badge), 89 | }) 90 | } 91 | return null 92 | }, 93 | 'db:seed:assign-badge-to-users': async ({ badgeId, users }) => { 94 | for (let i = 0; i < users.length; i++) { 95 | const user = users[i] 96 | await Badge.assignUserBadge(badgeId, user.id, new Date()) 97 | } 98 | return null 99 | }, 100 | }) 101 | }, 102 | }, 103 | screenshotOnRunFailure: false, 104 | env: { 105 | NEXTAUTH_SECRET: 'next-auth-cypress-secret', 106 | }, 107 | }) 108 | -------------------------------------------------------------------------------- /cypress/e2e/auth.cy.js: -------------------------------------------------------------------------------- 1 | describe('Check public routes', () => { 2 | it(`Route / is public`, () => { 3 | cy.visit('/') 4 | cy.get('body').should('contain', 'Create teams') 5 | }) 6 | }) 7 | 8 | describe('Check protected routes', () => { 9 | const protectedRoutes = [ 10 | '/clients', 11 | '/organizations/1', 12 | '/organizations/1/edit-privacy-policy', 13 | '/organizations/1/edit-profiles', 14 | '/organizations/1/edit-team-profiles', 15 | '/organizations/1/edit', 16 | '/organizations/1/profile', 17 | '/organizations/create', 18 | '/dashboard', 19 | ] 20 | 21 | protectedRoutes.forEach((testRoute) => { 22 | it(`Route ${testRoute} needs authentication`, () => { 23 | cy.visit(testRoute) 24 | cy.get('body').should('contain', 'Sign in') 25 | }) 26 | }) 27 | 28 | protectedRoutes.forEach((testRoute) => { 29 | it(`Route ${testRoute} is displayed when authenticated`, () => { 30 | // Authorized visit, should redirect to sign in 31 | cy.login({ 32 | id: 1, 33 | display_name: 'User 001', 34 | }) 35 | cy.visit(testRoute) 36 | cy.get('body').should('not.contain', 'Sign in') 37 | }) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /cypress/e2e/dashboard.cy.js: -------------------------------------------------------------------------------- 1 | const { generateSequenceArray } = require('../../src/lib/utils') 2 | 3 | const user1 = { 4 | id: 1, 5 | display_name: 'User 001', 6 | } 7 | 8 | const user2 = { 9 | id: 2, 10 | display_name: 'User 002', 11 | } 12 | 13 | const user3 = { 14 | id: 3, 15 | display_name: 'User 003', 16 | } 17 | 18 | /** 19 | * Generate 3 sets of teams, one for each user as moderator 20 | */ 21 | const TEAMS_COUNT = 25 22 | const user1teams = generateSequenceArray(TEAMS_COUNT).map((i) => ({ 23 | id: i, 24 | name: `Team ${i}`, 25 | })) 26 | const user2teams = generateSequenceArray(TEAMS_COUNT, TEAMS_COUNT).map((i) => ({ 27 | id: i, 28 | name: `Team ${i}`, 29 | })) 30 | const user3teams = generateSequenceArray(TEAMS_COUNT, 2 * TEAMS_COUNT).map( 31 | (i) => ({ 32 | id: i, 33 | name: `Team ${i}`, 34 | }) 35 | ) 36 | 37 | describe('Dashboard page', () => { 38 | before(() => { 39 | cy.task('db:reset') 40 | }) 41 | 42 | it('Display message when user has no teams', () => { 43 | cy.login(user1) 44 | 45 | // Check state when no teams are available 46 | cy.visit('/dashboard') 47 | cy.get('[data-cy=my-teams-table]').contains( 48 | 'You are not part of a team yet.' 49 | ) 50 | cy.get('[data-cy=my-teams-table-pagination]').should('not.exist') 51 | }) 52 | 53 | it('Teams list is paginated', () => { 54 | // Add teams with user1 as creator 55 | cy.task('db:seed:create-teams', { 56 | teams: user1teams, 57 | moderatorId: user1.id, 58 | }) 59 | 60 | // Add teams with user2 and make user1 member 61 | cy.task('db:seed:create-teams', { 62 | teams: user2teams, 63 | moderatorId: user2.id, 64 | }) 65 | cy.task('db:seed:add-member-to-teams', { 66 | teams: user2teams, 67 | memberId: user1.id, 68 | }) 69 | 70 | // Add teams with user3 with no relation with user 1 71 | cy.task('db:seed:create-teams', { 72 | teams: user3teams, 73 | moderatorId: user3.id, 74 | }) 75 | 76 | // Log in and visit dashboard 77 | cy.login(user1) 78 | cy.visit('/dashboard') 79 | 80 | // Check page and total count 81 | cy.get('[data-cy=my-teams-table-pagination]').contains('Showing 1-10 of 50') 82 | 83 | // Click last page button 84 | cy.get('[data-cy=my-teams-table-pagination]').within(() => { 85 | cy.get('[data-cy=last-page-button]').click() 86 | }) 87 | 88 | // Last item is present 89 | cy.get('[data-cy=my-teams-table]').contains('Team 9') 90 | 91 | // Click page 2 button 92 | cy.get('[data-cy=my-teams-table-pagination]').within(() => { 93 | cy.get('[data-cy=page-2-button]').click() 94 | }) 95 | 96 | // Item from page 2 is present 97 | cy.get('[data-cy=my-teams-table]').contains('Team 2') 98 | 99 | // Click next page button 100 | cy.get('[data-cy=my-teams-table-pagination]').within(() => { 101 | cy.get('[data-cy=next-page-button]').click() 102 | }) 103 | 104 | // Item from page 3 is present 105 | cy.get('[data-cy=my-teams-table]').contains('Team 3') 106 | 107 | // Click previous page button 108 | cy.get('[data-cy=my-teams-table-pagination]').within(() => { 109 | cy.get('[data-cy=previous-page-button]').click() 110 | }) 111 | 112 | // Item from page 2 is present 113 | cy.get('[data-cy=my-teams-table]').contains('Team 2') 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /cypress/e2e/organizations/badges.cy.js: -------------------------------------------------------------------------------- 1 | const { 2 | generateSequenceArray, 3 | addZeroPadding, 4 | } = require('../../../src/lib/utils') 5 | 6 | // Generate org member 7 | const org1Members = generateSequenceArray(30, 1).map((i) => ({ 8 | id: i, 9 | name: `User ${addZeroPadding(i, 3)}`, 10 | })) 11 | 12 | const [user1, ...org1Team1Members] = org1Members 13 | 14 | // Organization meta 15 | const org1 = { 16 | id: 1, 17 | name: 'Org 1', 18 | ownerId: user1.id, 19 | } 20 | 21 | const org1Team1 = { 22 | id: 1, 23 | name: 'Org 1 Team 1', 24 | } 25 | 26 | const BADGES_COUNT = 30 27 | 28 | const org1Badges = generateSequenceArray(BADGES_COUNT, 1).map((i) => ({ 29 | id: i, 30 | name: `Badge ${addZeroPadding(i, 3)}`, 31 | color: `rgba(255,0,0,${1 - i / BADGES_COUNT})`, 32 | })) 33 | 34 | const [org1Badge1, org1Badge2, org1Badge3, org1Badge4, org1Badge5, org1Badge6] = 35 | org1Badges 36 | 37 | describe('Organization page', () => { 38 | before(() => { 39 | cy.task('db:reset') 40 | 41 | // Create organization 42 | cy.task('db:seed:create-organizations', [org1]) 43 | 44 | // Add org teams 45 | cy.task('db:seed:create-organization-teams', { 46 | orgId: org1.id, 47 | teams: [org1Team1], 48 | managerId: user1.id, 49 | }) 50 | 51 | // Add members to org team 1 52 | cy.task('db:seed:add-members-to-team', { 53 | teamId: org1Team1.id, 54 | members: org1Team1Members, 55 | }) 56 | 57 | // Create org badges 58 | cy.task('db:seed:create-organization-badges', { 59 | orgId: org1.id, 60 | badges: org1Badges, 61 | }) 62 | 63 | // Assign badges to overlapping groups of users 64 | cy.task('db:seed:assign-badge-to-users', { 65 | badgeId: org1Badge1.id, 66 | users: org1Team1Members.slice(0, 4), 67 | }) 68 | cy.task('db:seed:assign-badge-to-users', { 69 | badgeId: org1Badge2.id, 70 | users: org1Team1Members.slice(2, 7), 71 | }) 72 | cy.task('db:seed:assign-badge-to-users', { 73 | badgeId: org1Badge3.id, 74 | users: org1Team1Members.slice(2, 7), 75 | }) 76 | cy.task('db:seed:assign-badge-to-users', { 77 | badgeId: org1Badge4.id, 78 | users: org1Team1Members.slice(2, 7), 79 | }) 80 | cy.task('db:seed:assign-badge-to-users', { 81 | badgeId: org1Badge5.id, 82 | users: org1Team1Members.slice(4, 9), 83 | }) 84 | cy.task('db:seed:assign-badge-to-users', { 85 | badgeId: org1Badge6.id, 86 | users: org1Team1Members.slice(4, 9), 87 | }) 88 | }) 89 | 90 | it('Organization members table display badges', () => { 91 | cy.login(user1) 92 | 93 | cy.visit('/organizations/1') 94 | 95 | cy.get('[data-cy=org-members-table]') 96 | .find('tbody tr:nth-child(6) td:nth-child(2)') 97 | .contains('Badge 002') 98 | cy.get('[data-cy=org-members-table]') 99 | .find('tbody tr:nth-child(6) td:nth-child(2)') 100 | .contains('Badge 003') 101 | cy.get('[data-cy=org-members-table]') 102 | .find('tbody tr:nth-child(10) td:nth-child(2)') 103 | .contains('Badge 005') 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /cypress/e2e/teams/index.cy.js: -------------------------------------------------------------------------------- 1 | const { 2 | generateSequenceArray, 3 | addZeroPadding, 4 | } = require('../../../src/lib/utils') 5 | 6 | // Moderator user 7 | const user1 = { 8 | id: 1, 9 | } 10 | 11 | // Generate teams 12 | const TEAMS_COUNT = 35 13 | const teams = generateSequenceArray(TEAMS_COUNT, 1).map((i) => ({ 14 | id: i, 15 | name: `Team ${addZeroPadding(i, 3)}`, 16 | location: { 17 | type: 'Point', 18 | // Fake coords under 30 degrees 19 | coordinates: [(30 / TEAMS_COUNT) * i, (30 / TEAMS_COUNT) * i], 20 | }, 21 | })) 22 | 23 | describe('Teams page', () => { 24 | before(() => { 25 | cy.task('db:reset') 26 | cy.task('db:seed:create-teams', { 27 | teams, 28 | moderatorId: user1.id, 29 | }) 30 | }) 31 | 32 | it('Teams index is public and list teams', () => { 33 | cy.visit('/teams') 34 | 35 | cy.get('body').should('contain', 'Team 001') 36 | cy.get("[data-cy='teams-table-pagination']").should('exist') 37 | cy.get('[data-cy=teams-table-pagination]').contains('Showing 1-10 of 35') 38 | 39 | // Sort by team name 40 | cy.get('[data-cy=teams-table-head-column-name]').click() 41 | cy.get('[data-cy=teams-table]') 42 | .find('tbody tr:nth-child(1) td:nth-child(2)') 43 | .contains('Team 035') 44 | cy.get('[data-cy=teams-table]') 45 | .find('tbody tr:nth-child(10) td:nth-child(2)') 46 | .contains('Team 026') 47 | 48 | // Search by team name 49 | cy.get('[data-cy=teams-table-search-input]').type('02') 50 | cy.get('[data-cy=teams-table-search-submit]').click() 51 | cy.get('[data-cy=teams-table]') 52 | .find('tbody tr:nth-child(5) td:nth-child(2)') 53 | .contains('Team 025') 54 | cy.get('[data-cy=teams-table-pagination]').contains('Showing 1-10 of 11') 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /cypress/e2e/teams/invitations.cy.js: -------------------------------------------------------------------------------- 1 | const user1 = { 2 | id: 1, 3 | display_name: 'User 001', 4 | } 5 | 6 | const expiredInvitation = { 7 | uuid: '0a875c3c-ba7c-4132-b08e-427a965177f5', 8 | teamId: 1, 9 | createdAt: '2000-01-01', 10 | expiresAt: '2001-01-01', 11 | } 12 | 13 | const validInvitation = { 14 | uuid: 'f89e8459-3066-43e3-86d5-f621ded69d60', 15 | teamId: 1, 16 | } 17 | 18 | const anotherValidInvitation = { 19 | uuid: '6d30b44f-e94d-4d40-8dc1-3973d28cb182', 20 | teamId: 1, 21 | } 22 | 23 | const nonexistentInvitation = { 24 | uuid: '981b595a-92a4-442e-ab39-3a418c20343f', 25 | teamId: 999, 26 | } 27 | 28 | describe('Team invitation page', () => { 29 | before(() => { 30 | cy.task('db:reset') 31 | cy.task('db:seed:create-teams', { 32 | teams: [ 33 | { 34 | name: 'Team 1', 35 | }, 36 | { 37 | name: 'Team 2', 38 | }, 39 | ], 40 | moderatorId: user1.id, 41 | }) 42 | 43 | cy.task('db:seed:create-team-invitations', [ 44 | expiredInvitation, 45 | validInvitation, 46 | anotherValidInvitation, 47 | ]) 48 | }) 49 | 50 | it('Invalid route displays error', () => { 51 | cy.visit(`/teams/1/invitations/invalid-route`) 52 | cy.get('body').contains('Invalid team invitation.') 53 | }) 54 | 55 | it('Nonexistent invitation displays error', () => { 56 | cy.visit( 57 | `/teams/${nonexistentInvitation.teamId}/invitations/${nonexistentInvitation.uuid}` 58 | ) 59 | cy.get('body').contains('Invalid team invitation.') 60 | }) 61 | 62 | it('Expired invitation displays error', () => { 63 | cy.visit( 64 | `/teams/${expiredInvitation.teamId}/invitations/${expiredInvitation.uuid}` 65 | ) 66 | cy.get('body').contains('Team invitation has expired.') 67 | }) 68 | 69 | it('Valid invitation - user is not authenticated', () => { 70 | cy.visit( 71 | `/teams/${validInvitation.teamId}/invitations/${validInvitation.uuid}` 72 | ) 73 | cy.get('body').contains('Please sign in') 74 | }) 75 | 76 | it('Valid invitation - user is authenticated', () => { 77 | cy.login({ 78 | id: 1, 79 | display_name: 'User 001', 80 | }) 81 | cy.visit( 82 | `/teams/${validInvitation.teamId}/invitations/${validInvitation.uuid}` 83 | ) 84 | cy.get('[data-cy=invite-accepted]').contains( 85 | 'Invitation accepted successfully' 86 | ) 87 | 88 | cy.visit( 89 | `/teams/${anotherValidInvitation.teamId}/invitations/${anotherValidInvitation.uuid}` 90 | ) 91 | cy.get('[data-cy=invite-accepted]').contains( 92 | 'Invitation accepted successfully' 93 | ) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /cypress/e2e/teams/view.cy.js: -------------------------------------------------------------------------------- 1 | const { 2 | generateSequenceArray, 3 | addZeroPadding, 4 | } = require('../../../src/lib/utils') 5 | 6 | const team1Members = generateSequenceArray(25, 1).map((i) => ({ 7 | id: i, 8 | name: `User ${addZeroPadding(i, 3)}`, 9 | })) 10 | 11 | const [user1] = team1Members 12 | 13 | const nonMemberUser = { 14 | id: 999, 15 | name: `User 999`, 16 | } 17 | 18 | const team1 = { 19 | id: 1, 20 | name: 'Team 1', 21 | privacy: 'public', 22 | } 23 | 24 | const team2 = { 25 | id: 2, 26 | name: 'Team 2', 27 | privacy: 'private', 28 | } 29 | 30 | describe('Teams page', () => { 31 | before(() => { 32 | cy.task('db:reset') 33 | cy.task('db:seed:create-teams', { 34 | teams: [team1, team2], 35 | moderatorId: user1.id, 36 | }) 37 | }) 38 | 39 | it('Do not list members on public access to private team pages', () => { 40 | // Team 1 is public, should display member list 41 | cy.visit('/teams/1') 42 | cy.get('body').should('contain', 'Team 1') 43 | cy.get("[data-cy='team-members-section']").should('exist') 44 | 45 | // Team 2 is private, should NOT display member list 46 | cy.visit('/teams/2') 47 | cy.get('body').should('contain', 'Team 2') 48 | cy.get("[data-cy='team-members-section']").should('not.exist') 49 | }) 50 | 51 | it('List members on member access to private team pages', () => { 52 | // Sign in as team member 53 | cy.login({ 54 | id: 1, 55 | display_name: 'User 001', 56 | }) 57 | 58 | // Team 1 is public, should display member list 59 | cy.visit('/teams/1') 60 | cy.get('body').should('contain', 'Team 1') 61 | cy.get("[data-cy='team-members-section']").should('exist') 62 | 63 | // Team 2 is private, should display member list 64 | cy.visit('/teams/2') 65 | cy.get('body').should('contain', 'Team 2') 66 | cy.get("[data-cy='team-members-section']").should('exist') 67 | }) 68 | 69 | it('Do not list members on non-member access to private team pages', () => { 70 | // Signed in as non-team member 71 | cy.login(nonMemberUser) 72 | 73 | // Team 1 is public, should display member list 74 | cy.visit('/teams/1') 75 | cy.get('body').should('contain', 'Team 1') 76 | cy.get("[data-cy='team-members-section']").should('exist') 77 | 78 | // Team 2 is private, should display member list 79 | cy.visit('/teams/2') 80 | cy.get('body').should('contain', 'Team 2') 81 | cy.get("[data-cy='team-members-section']").should('not.exist') 82 | }) 83 | 84 | it('Public team has a paginated team member table', () => { 85 | cy.task('db:seed:add-members-to-team', { 86 | teamId: team1.id, 87 | members: team1Members, 88 | }) 89 | 90 | // Team 1 is public, should display member list 91 | cy.visit('/teams/1') 92 | cy.get('body').should('contain', 'Team 1') 93 | cy.get("[data-cy='team-members-section']").should('exist') 94 | cy.get("[data-cy='team-members-table-pagination']").should('exist') 95 | cy.get('[data-cy=team-members-table-pagination]').contains( 96 | 'Showing 1-10 of 25' 97 | ) 98 | 99 | // Perform sort by username 100 | cy.get('[data-cy=team-members-table-head-column-name]').click() 101 | cy.get('[data-cy=team-members-table]').contains('User 025') 102 | cy.get('[data-cy=team-members-table]').contains('User 016') 103 | 104 | // Perform search by username 105 | cy.get('[data-cy=team-members-table-search-input]').type('USER 02') 106 | cy.get('[data-cy=team-members-table-search-submit]').click() 107 | cy.get('[data-cy=team-members-table-pagination]').contains( 108 | 'Showing 1-6 of 6' 109 | ) 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /cypress/support/commands/login.js: -------------------------------------------------------------------------------- 1 | const getSessionToken = require('../../../tests/utils/get-session-token') 2 | 3 | Cypress.Commands.add('login', (user) => { 4 | // Generate and set a valid cookie from the fixture that next-auth can decrypt 5 | cy.wrap(null) 6 | .then(() => { 7 | return getSessionToken(user, Cypress.env('NEXTAUTH_SECRET')) 8 | }) 9 | .then((encryptedToken) => 10 | cy.setCookie('next-auth.session-token', encryptedToken) 11 | ) 12 | }) 13 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | import './commands/login' 2 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | Cypress.Screenshot.defaults({ 2 | screenshotOnRunFailure: false, 3 | }) 4 | -------------------------------------------------------------------------------- /hydra-config/dev/hydra.yml: -------------------------------------------------------------------------------- 1 | serve: 2 | cookies: 3 | same_site_mode: Lax 4 | 5 | urls: 6 | self: 7 | issuer: http://localhost:4444 8 | consent: http://localhost:3000/auth/consent 9 | login: http://localhost:3000/auth/signin 10 | logout: http://localhost:3000/auth/signout 11 | 12 | secrets: 13 | system: 14 | - youReallyNeedToChangeThis 15 | 16 | oidc: 17 | subject_identifiers: 18 | supported_types: 19 | - pairwise 20 | - public 21 | pairwise: 22 | salt: youReallyNeedToChangeThis 23 | 24 | ttl: 25 | access_token: 876600h 26 | refresh_token: 876600h 27 | 28 | log: 29 | format: json 30 | level: trace -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | const { loadEnvConfig } = require('@next/env') 2 | 3 | // Load configuration from env files using Next.js 4 | const projectDir = process.cwd() 5 | loadEnvConfig(projectDir) 6 | 7 | // Get database connection 8 | const DATABASE_URL = process.env.DATABASE_URL 9 | 10 | module.exports = { 11 | client: 'postgresql', 12 | connection: DATABASE_URL, 13 | migrations: { 14 | tableName: 'knex_migrations', 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /migrations/20190417173534_init.js: -------------------------------------------------------------------------------- 1 | const logger = require('../src/lib/logger') 2 | 3 | exports.up = async (knex) => { 4 | await knex.schema.createTable('users', (table) => { 5 | table.integer('id').primary() 6 | table.json('profile') 7 | table.json('manageToken') 8 | table.text('osmToken') 9 | table.text('osmTokenSecret') 10 | }) 11 | 12 | await knex.schema.createTable('team', (table) => { 13 | table.increments('id') 14 | table.string('name').unique() 15 | table.string('hashtag').unique() 16 | table.string('bio') 17 | table.enum('privacy', ['public', 'private', 'unlisted']).defaultTo('public') 18 | table.boolean('require_join_request').defaultTo(false) 19 | table.timestamps(false, true) 20 | }) 21 | 22 | await knex.schema.createTable('moderator', (table) => { 23 | table.increments('id') 24 | table 25 | .integer('team_id') 26 | .references('id') 27 | .inTable('team') 28 | .onDelete('CASCADE') 29 | table.integer('osm_id') 30 | }) 31 | 32 | await knex.schema.createTable('member', (table) => { 33 | table.increments('id') 34 | table 35 | .integer('team_id') 36 | .references('id') 37 | .inTable('team') 38 | .onDelete('CASCADE') 39 | table.integer('osm_id') 40 | }) 41 | 42 | await knex.schema.createTable('join_request', (table) => { 43 | table.increments('id') 44 | table 45 | .integer('team_id') 46 | .references('id') 47 | .inTable('team') 48 | .onDelete('CASCADE') 49 | table.integer('osm_id') 50 | }) 51 | } 52 | 53 | exports.down = async (knex) => { 54 | try { 55 | await knex.schema.dropTable('moderator') 56 | await knex.schema.dropTable('member') 57 | await knex.schema.dropTable('join_request') 58 | await knex.schema.dropTable('team') 59 | await knex.schema.dropTable('users') 60 | } catch (e) { 61 | logger.error(e) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /migrations/20190730183820_team-locations.js: -------------------------------------------------------------------------------- 1 | const logger = require('../src/lib/logger') 2 | 3 | exports.up = async function (knex) { 4 | try { 5 | await knex.raw('CREATE EXTENSION IF NOT EXISTS "postgis"') 6 | await knex.raw('ALTER TABLE team ADD COLUMN location geometry(POINT, 4326)') 7 | } catch (e) { 8 | logger.error(e) 9 | } 10 | } 11 | 12 | exports.down = async function (knex) { 13 | try { 14 | await knex.raw('ALTER TABLE team DROP COLUMN location') 15 | await knex.schema.raw('DROP EXTENSION "postgis" CASCADE') 16 | } catch (e) { 17 | logger.error(e) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /migrations/20190805171532_team-name-required.js: -------------------------------------------------------------------------------- 1 | const logger = require('../src/lib/logger') 2 | 3 | exports.up = async (knex) => { 4 | try { 5 | await knex.schema.alterTable('team', function (t) { 6 | t.string('name').notNullable().alter() 7 | }) 8 | } catch (e) { 9 | logger.error(e) 10 | } 11 | } 12 | 13 | exports.down = async (knex) => { 14 | try { 15 | await knex.schema.alterTable('team', function (t) { 16 | t.string('name').nullable().alter() 17 | }) 18 | } catch (e) { 19 | logger.error(e) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /migrations/20190822135832_editing_policy.js: -------------------------------------------------------------------------------- 1 | const logger = require('../src/lib/logger') 2 | 3 | exports.up = async (knex) => { 4 | try { 5 | await knex.schema.alterTable('team', function (t) { 6 | t.string('editing_policy') 7 | }) 8 | } catch (e) { 9 | logger.error(e) 10 | } 11 | } 12 | 13 | exports.down = async (knex) => { 14 | try { 15 | await knex.schema.alterTable('team', function (t) { 16 | t.dropColumn('editing_policy') 17 | }) 18 | } catch (e) { 19 | logger.error(e) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /migrations/20200130150202_team-moderator-unique.js: -------------------------------------------------------------------------------- 1 | const logger = require('../src/lib/logger') 2 | 3 | /* Add a unique constraint on the moderator table so the same team_id and 4 | osm_id cannot be added more than once. */ 5 | 6 | const tableName = 'moderator' 7 | const columns = ['team_id', 'osm_id'] 8 | const keyName = 'moderator_team_id_osm_id_key' 9 | 10 | // in postgresql: `alter table moderator add unique (team_id, osm_id);` 11 | // creates unique constraint named "moderator_team_id_osm_id_key" 12 | exports.up = async (knex) => { 13 | try { 14 | await knex.schema.alterTable(tableName, (table) => 15 | table.unique(columns, keyName) 16 | ) 17 | } catch (e) { 18 | logger.error(e) 19 | } 20 | } 21 | 22 | // in posgresql: `alter table moderator drop constraint moderator_team_id_osm_id_key;` 23 | // drops the unique constraint. 24 | exports.down = async (knex) => { 25 | try { 26 | await knex.schema.alterTable(tableName, (table) => 27 | table.dropUnique(columns, keyName) 28 | ) 29 | } catch (e) { 30 | logger.error(e) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /migrations/20200130155125_team-member-unique.js: -------------------------------------------------------------------------------- 1 | const logger = require('../src/lib/logger') 2 | 3 | /* Add a unique constraint on the member table so the same team_id and 4 | osm_id cannot be added more than once. */ 5 | 6 | const tableName = 'member' 7 | const columns = ['team_id', 'osm_id'] 8 | const keyName = 'member_team_id_osm_id_key' 9 | 10 | // in postgresql: `alter table member add unique (team_id, osm_id);` 11 | // creates unique constraint named "member_team_id_osm_id_key" 12 | exports.up = async (knex) => { 13 | try { 14 | await knex.schema.alterTable(tableName, (table) => 15 | table.unique(columns, keyName) 16 | ) 17 | } catch (e) { 18 | logger.error(e) 19 | } 20 | } 21 | 22 | // in posgresql: `alter table member drop constraint member_team_id_osm_id_key;` 23 | // drops the unique constraint. 24 | exports.down = async (knex) => { 25 | try { 26 | await knex.schema.alterTable(tableName, (table) => 27 | table.dropUnique(columns, keyName) 28 | ) 29 | } catch (e) { 30 | logger.error(e) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /migrations/20200326113917_organization.js: -------------------------------------------------------------------------------- 1 | exports.up = async (knex) => { 2 | await knex.schema.createTable('organization', (table) => { 3 | table.increments('id') 4 | table.string('name').notNullable().unique() 5 | table.text('description') 6 | table.timestamps(false, true) 7 | }) 8 | 9 | await knex.schema.createTable('organization_owner', (table) => { 10 | table.increments('id') 11 | table 12 | .integer('organization_id') 13 | .references('id') 14 | .inTable('organization') 15 | .onDelete('CASCADE') 16 | table.integer('osm_id') 17 | table.unique(['organization_id', 'osm_id']) 18 | }) 19 | 20 | await knex.schema.createTable('organization_manager', (table) => { 21 | table.increments('id') 22 | table 23 | .integer('organization_id') 24 | .references('id') 25 | .inTable('organization') 26 | .onDelete('CASCADE') 27 | table.integer('osm_id') 28 | table.unique(['organization_id', 'osm_id']) 29 | }) 30 | 31 | await knex.schema.createTable('organization_team', (table) => { 32 | table.increments('id') 33 | table 34 | .integer('team_id') 35 | .references('id') 36 | .inTable('team') 37 | .onDelete('CASCADE') 38 | table 39 | .integer('organization_id') 40 | .references('id') 41 | .inTable('organization') 42 | .onDelete('CASCADE') 43 | table.unique(['organization_id', 'team_id']) 44 | }) 45 | } 46 | 47 | exports.down = async (knex) => { 48 | await knex.schema.dropTable('organization_team') 49 | await knex.schema.dropTable('organization_manager') 50 | await knex.schema.dropTable('organization_owner') 51 | await knex.schema.dropTable('organization') 52 | } 53 | -------------------------------------------------------------------------------- /migrations/20210624112124_team_profiles.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The purpose of this migration file is to create user, team and organization profiles 3 | */ 4 | 5 | exports.up = async (knex) => { 6 | await knex.schema.createTable('profile_keys', (table) => { 7 | table.increments('id') 8 | table.string('name').notNullable() 9 | table 10 | .integer('owner_user') 11 | .references('id') 12 | .inTable('users') 13 | .nullable() 14 | .onDelete('CASCADE') 15 | table 16 | .integer('owner_team') 17 | .references('id') 18 | .inTable('team') 19 | .nullable() 20 | .onDelete('CASCADE') 21 | table 22 | .integer('owner_org') 23 | .references('id') 24 | .inTable('organization') 25 | .nullable() 26 | .onDelete('CASCADE') 27 | table.enum('profile_type', ['org', 'team', 'user']) 28 | table.text('description') 29 | table.boolean('required').defaultTo('false') 30 | table.enum('visibility', ['public', 'team', 'org']).defaultTo('public') 31 | table.unique(['name', 'owner_user']) 32 | table.unique(['name', 'owner_team']) 33 | table.unique(['name', 'owner_org']) 34 | }) 35 | 36 | await knex.schema.alterTable('users', (table) => { 37 | table.timestamps(false, true) 38 | }) 39 | 40 | await knex.schema.alterTable('team', (table) => { 41 | table.json('profile') 42 | }) 43 | 44 | await knex.schema.alterTable('organization', (table) => { 45 | table.json('profile') 46 | }) 47 | } 48 | 49 | exports.down = async (knex) => { 50 | await knex.schema.alterTable('organization', (table) => { 51 | table.dropColumn('profile') 52 | }) 53 | await knex.schema.alterTable('team', (table) => { 54 | table.dropColumn('profile') 55 | }) 56 | await knex.schema.raw('DROP TABLE if exists profile_keys CASCADE') 57 | } 58 | -------------------------------------------------------------------------------- /migrations/20211208161927_org_visibility.js: -------------------------------------------------------------------------------- 1 | exports.up = async (knex) => { 2 | return knex.schema.alterTable('organization', (table) => { 3 | table.enum('privacy', ['public', 'private', 'unlisted']).defaultTo('public') 4 | table.boolean('teams_can_be_public').defaultTo(true) 5 | }) 6 | } 7 | 8 | exports.down = async (knex) => { 9 | return knex.schema.alterTable('organization', (table) => { 10 | table.dropColumn('privacy') 11 | table.dropColumn('teams_can_be_public') 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /migrations/20220105182919_org_staff_visibility.js: -------------------------------------------------------------------------------- 1 | const constraintName = 'profile_keys_visibility_check' 2 | 3 | exports.up = async (knex) => { 4 | await knex.raw( 5 | `ALTER TABLE profile_keys DROP CONSTRAINT IF EXISTS ${constraintName};` 6 | ) 7 | await knex.raw( 8 | `ALTER TABLE profile_keys ADD CONSTRAINT ${constraintName} CHECK (visibility = ANY (ARRAY['public'::text, 'team'::text, 'org'::text, 'org_staff'::text]))` 9 | ) 10 | } 11 | 12 | exports.down = async (knex) => { 13 | await knex.raw( 14 | `ALTER TABLE profile_keys DROP CONSTRAINT IF EXISTS ${constraintName};` 15 | ) 16 | await knex.raw( 17 | `ALTER TABLE profile_keys ADD CONSTRAINT ${constraintName} CHECK (visibility = ANY (ARRAY['public'::text, 'team'::text, 'org'::text]))` 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /migrations/20220125220402_org-privacy-policy.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | await knex.schema.alterTable('organization', (table) => { 3 | table.json('privacy_policy') 4 | }) 5 | } 6 | 7 | exports.down = async function (knex) { 8 | await knex.schema.alterTable('organization', (table) => { 9 | table.dropColumn('privacy_policy') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /migrations/20220222155039_add_badges.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | await knex.schema.createTable('organization_badge', (table) => { 3 | table.increments('id') 4 | table 5 | .integer('organization_id') 6 | .references('id') 7 | .inTable('organization') 8 | .onDelete('CASCADE') 9 | table.string('name').notNullable() 10 | table.string('color').notNullable() 11 | }) 12 | } 13 | 14 | exports.down = async function (knex) { 15 | await knex.schema.dropTable('organization_badge') 16 | } 17 | -------------------------------------------------------------------------------- /migrations/20220302104250_add_user_badges.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | await knex.schema.createTable('user_badges', (table) => { 3 | table 4 | .integer('badge_id') 5 | .references('id') 6 | .inTable('organization_badge') 7 | .onDelete('CASCADE') 8 | table.integer('user_id') 9 | table.datetime('assigned_at').defaultTo(knex.fn.now()) 10 | table.datetime('valid_until') 11 | table.unique(['badge_id', 'user_id']) 12 | }) 13 | } 14 | 15 | exports.down = async function (knex) { 16 | await knex.schema.dropTable('user_badges') 17 | } 18 | -------------------------------------------------------------------------------- /migrations/20220302135223_key-types.js: -------------------------------------------------------------------------------- 1 | exports.up = async (knex) => { 2 | return knex.schema.alterTable('profile_keys', (table) => { 3 | table.text('key_type').defaultTo('text') 4 | }) 5 | } 6 | 7 | exports.down = async (knex) => { 8 | return knex.schema.alterTable('profile_keys', (table) => { 9 | table.dropColumn('key_type') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /migrations/20220415114820_invitations.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | return knex.schema.createTable('invitations', (table) => { 3 | table.string('id') 4 | table 5 | .integer('team_id') 6 | .references('id') 7 | .inTable('team') 8 | .onDelete('CASCADE') 9 | table.timestamp('created_at').defaultTo(knex.fn.now()) 10 | table.timestamp('expires_at').nullable() 11 | }) 12 | } 13 | 14 | exports.down = async function (knex) { 15 | return knex.schema.dropTable('invitations') 16 | } 17 | -------------------------------------------------------------------------------- /migrations/20221121094350_drop-hashtag-uniqueness.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex) { 2 | return knex.schema.alterTable('team', function (table) { 3 | table.dropUnique('hashtag') 4 | }) 5 | } 6 | 7 | exports.down = function (knex) { 8 | return knex.schema.alterTable('team', function (table) { 9 | table.unique('hashtag') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /migrations/20221216122421_username_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | await knex.schema.createTable('osm_users', (table) => { 3 | table.integer('id').primary() 4 | table.text('name') 5 | table.text('image') 6 | table.datetime('updated_at').defaultTo(knex.fn.now()) 7 | }) 8 | } 9 | 10 | exports.down = async function (knex) { 11 | await knex.schema.dropTable('osm_users') 12 | } 13 | -------------------------------------------------------------------------------- /next-swagger-doc.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiFolder": "src", 3 | "schemaFolders": [ 4 | "src" 5 | ], 6 | "definition": { 7 | "openapi": "3.0.0", 8 | "info": { 9 | "title": "OSM Teams API Docs", 10 | "version": "2.1.1" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const vercelUrl = 2 | process.env.NEXT_PUBLIC_VERCEL_URL && 3 | `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` 4 | 5 | module.exports = { 6 | async headers() { 7 | return [ 8 | { 9 | // matching all API routes 10 | // link: https://vercel.com/guides/how-to-enable-cors#enabling-cors-in-a-next.js-app 11 | source: '/api/:path*', 12 | headers: [ 13 | { key: 'Access-Control-Allow-Credentials', value: 'true' }, 14 | { key: 'Access-Control-Allow-Origin', value: '*' }, 15 | { 16 | key: 'Access-Control-Allow-Methods', 17 | value: 'GET,OPTIONS,PATCH,DELETE,POST,PUT', 18 | }, 19 | { 20 | key: 'Access-Control-Allow-Headers', 21 | value: '*', 22 | }, 23 | ], 24 | }, 25 | ] 26 | }, 27 | env: { 28 | DEFAULT_PAGE_SIZE: 10, 29 | basePath: process.env.BASE_PATH || '', 30 | OSM_API: 31 | process.env.OSM_API || 32 | process.env.OSM_DOMAIN || 33 | 'https://www.openstreetmap.org', 34 | OSM_DOMAIN: process.env.OSM_DOMAIN || 'https://www.openstreetmap.org', 35 | APP_URL: process.env.APP_URL || vercelUrl || 'http://127.0.0.1:3000', 36 | OSM_NAME: process.env.OSM_NAME || 'OSM', 37 | BASE_PATH: process.env.BASE_PATH || '', 38 | HYDRA_URL: process.env.HYDRA_URL || 'https://auth.mapping.team/hyauth', 39 | AUTH_URL: process.env.AUTH_URL || 'https://auth.mapping.team', 40 | OSMCHA_URL: process.env.OSMCH_URL || 'https://osmcha.org', 41 | SCOREBOARD_URL: process.env.SCOREBOARD_URL || '', 42 | HDYC_URL: process.env.HDYC_URL || 'https://hdyc.neis-one.org', 43 | }, 44 | eslint: { 45 | dirs: [ 46 | 'app', 47 | 'pages', 48 | 'components', 49 | 'lib', 50 | 'tests', 51 | 'migrations', 52 | 'styles', 53 | 'src', 54 | 'cypress', 55 | ], 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /public/static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/apple-touch-icon.png -------------------------------------------------------------------------------- /public/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/favicon-16x16.png -------------------------------------------------------------------------------- /public/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/favicon-32x32.png -------------------------------------------------------------------------------- /public/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/favicon.ico -------------------------------------------------------------------------------- /public/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/favicon.png -------------------------------------------------------------------------------- /public/static/guide/badge-assign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/badge-assign.png -------------------------------------------------------------------------------- /public/static/guide/badge-assignment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/badge-assignment.png -------------------------------------------------------------------------------- /public/static/guide/badge-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/badge-edit.png -------------------------------------------------------------------------------- /public/static/guide/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/dashboard.png -------------------------------------------------------------------------------- /public/static/guide/explore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/explore.png -------------------------------------------------------------------------------- /public/static/guide/new_badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/new_badge.png -------------------------------------------------------------------------------- /public/static/guide/new_org-team.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/new_org-team.png -------------------------------------------------------------------------------- /public/static/guide/new_org.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/new_org.png -------------------------------------------------------------------------------- /public/static/guide/new_team.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/new_team.png -------------------------------------------------------------------------------- /public/static/guide/org-edit-members.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/org-edit-members.png -------------------------------------------------------------------------------- /public/static/guide/org-edit-privacy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/org-edit-privacy.png -------------------------------------------------------------------------------- /public/static/guide/org-edit-teams.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/org-edit-teams.png -------------------------------------------------------------------------------- /public/static/guide/org-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/org-edit.png -------------------------------------------------------------------------------- /public/static/guide/org-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/org-page.png -------------------------------------------------------------------------------- /public/static/guide/org-staff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/org-staff.png -------------------------------------------------------------------------------- /public/static/guide/team-delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/team-delete.png -------------------------------------------------------------------------------- /public/static/guide/team-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/team-edit.png -------------------------------------------------------------------------------- /public/static/guide/team-join-link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/team-join-link.png -------------------------------------------------------------------------------- /public/static/guide/team-member-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/team-member-actions.png -------------------------------------------------------------------------------- /public/static/guide/team-member-attributes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/team-member-attributes.png -------------------------------------------------------------------------------- /public/static/guide/team-member-profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/team-member-profile.png -------------------------------------------------------------------------------- /public/static/guide/team-own-profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/team-own-profile.png -------------------------------------------------------------------------------- /public/static/guide/team-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/team-page.png -------------------------------------------------------------------------------- /public/static/guide/team_add-member.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/guide/team_add-member.png -------------------------------------------------------------------------------- /public/static/neis-one-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/neis-one-logo.png -------------------------------------------------------------------------------- /public/static/osm_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/osm_logo.png -------------------------------------------------------------------------------- /public/static/osmcha-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/static/osmteams_logo--neg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/osmteams_logo--neg.png -------------------------------------------------------------------------------- /public/static/osmteams_logo--neg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/static/osmteams_logo--pos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/osmteams_logo--pos.png -------------------------------------------------------------------------------- /public/static/osmteams_logo--pos.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/static/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OSM Teams", 3 | "short_name": "OSM Teams", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#1E2D72", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /public/static/youthmappers-logo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/osm-teams/01cfdc0adb915effac4617c98d7c433524074cdc/public/static/youthmappers-logo.jpeg -------------------------------------------------------------------------------- /src/components/Link.js: -------------------------------------------------------------------------------- 1 | import { withRouter } from 'next/router' 2 | import Link from 'next/link' 3 | import React, { Children } from 'react' 4 | import parse from 'url-parse' 5 | 6 | const NavLink = withRouter(({ children, href, passHref, legacyBehavior }) => { 7 | return ( 8 | 14 | {children} 15 | 16 | ) 17 | }) 18 | 19 | const ActiveLink = withRouter(({ router, children, ...props }) => { 20 | const { href, as, passHref, legacyBehavior } = props 21 | const hrefPathname = parse(href).pathname 22 | const routerPathname = parse(router.asPath).pathname 23 | 24 | const child = Children.only(children) 25 | 26 | let className = child.props.className || null 27 | if (routerPathname === hrefPathname && props.activeClassName) { 28 | className = `${className !== null ? className : ''} ${ 29 | props.activeClassName 30 | }`.trim() 31 | } 32 | 33 | delete props.activeClassName 34 | 35 | return ( 36 | 44 | {React.cloneElement(child, { className })} 45 | 46 | ) 47 | }) 48 | 49 | export default NavLink 50 | -------------------------------------------------------------------------------- /src/components/add-member-modal.js: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Heading, 4 | Modal, 5 | ModalBody, 6 | ModalCloseButton, 7 | ModalContent, 8 | ModalFooter, 9 | ModalHeader, 10 | ModalOverlay, 11 | } from '@chakra-ui/react' 12 | import { AddMemberByIdForm, AddMemberByUsernameForm } from './add-member-form' 13 | 14 | export function AddMemberModal({ isOpen, onClose, onSubmit }) { 15 | return ( 16 | 22 | 23 | 24 | 25 | 26 | Add Member 27 | 28 | onClose()} /> 29 | 30 | 31 | 32 | 33 | OSM ID 34 | 35 | 36 | 37 | 38 | 39 | OSM Username 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/components/badge.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Tag } from '@chakra-ui/react' 3 | const hexToRgb = (hex) => 4 | hex 5 | .replace( 6 | /^#?([a-f\d])([a-f\d])([a-f\d])$/i, 7 | (m, r, g, b) => '#' + r + r + g + g + b + b 8 | ) 9 | .substring(1) 10 | .match(/.{2}/g) 11 | .map((x) => parseInt(x, 16)) 12 | .join() 13 | 14 | export default function Badge({ color, dot, children }) { 15 | return ( 16 | 37 | {children} 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/components/banner.js: -------------------------------------------------------------------------------- 1 | import { Flex, Text } from '@chakra-ui/react' 2 | import React from 'react' 3 | 4 | export default function PageBanner({ content, variant }) { 5 | return ( 6 | 13 | 14 | {content} 15 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/edit-org-form.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Formik, Field, Form } from 'formik' 3 | import { 4 | Button, 5 | Heading, 6 | VStack, 7 | FormControl, 8 | FormLabel, 9 | FormHelperText, 10 | FormErrorMessage, 11 | Input, 12 | Textarea, 13 | Select, 14 | } from '@chakra-ui/react' 15 | 16 | function validateName(value) { 17 | if (!value) return 'Name field is required' 18 | } 19 | 20 | function renderError(text) { 21 | return {text} 22 | } 23 | 24 | function renderErrors(errors) { 25 | const keys = Object.keys(errors) 26 | return keys.map((key) => { 27 | return renderError(errors[key]) 28 | }) 29 | } 30 | 31 | const defaultValues = { 32 | name: '', 33 | description: '', 34 | } 35 | 36 | export default function EditOrgForm({ 37 | initialValues = defaultValues, 38 | onSubmit, 39 | }) { 40 | return ( 41 | { 53 | return ( 54 | 55 | 56 | Details 57 | 58 | 59 | Name 60 | 70 | {errors.name && renderError(errors.name)} 71 | 72 | 73 | Description 74 | 80 | 81 | 82 | Visibility 83 | 89 | 90 | 91 | 92 | 93 | A private organization does not show its member list or team 94 | details to non-members. 95 | 96 | 97 | 98 | 99 | Teams can be public 100 | 101 | 106 | 107 | 108 | 109 | 110 | This overrides the organization teams visibility setting. 111 | 112 | 113 | 114 | 129 | {status && status.errors && renderErrors(status.errors)} 130 | 131 | 132 | ) 133 | }} 134 | /> 135 | ) 136 | } 137 | -------------------------------------------------------------------------------- /src/components/error-boundary.js: -------------------------------------------------------------------------------- 1 | import { Box, Button, Container, Heading, Text } from '@chakra-ui/react' 2 | import React from 'react' 3 | import InpageHeader from './inpage-header' 4 | 5 | class ErrorBoundary extends React.Component { 6 | constructor(props) { 7 | super(props) 8 | 9 | // Define a state variable to track whether is an error or not 10 | this.state = { hasError: false } 11 | } 12 | // eslint-disable-next-line no-unused-vars 13 | static getDerivedStateFromError(error) { 14 | // Update state so the next render will show the fallback UI 15 | 16 | return { hasError: true } 17 | } 18 | componentDidCatch(error, errorInfo) { 19 | // You can use your own error logging service here 20 | // eslint-disable-next-line no-console 21 | console.error({ error, errorInfo }) 22 | } 23 | render() { 24 | // Check if the error is thrown 25 | if (this.state.hasError) { 26 | // You can render any custom fallback UI 27 | return ( 28 | 29 | 30 | 31 | Application error 32 | 33 | 34 | 35 | 36 | 37 | Sorry, there was an error loading this page 38 | 39 | 42 | 43 | Still having problems? Try logging out and back in or contacting 44 | a system administrator. 45 | 46 | 47 | 48 | 49 | ) 50 | } 51 | 52 | // Return children components in case of no error 53 | 54 | return this.props.children 55 | } 56 | } 57 | 58 | export default ErrorBoundary 59 | -------------------------------------------------------------------------------- /src/components/external-profile-button.js: -------------------------------------------------------------------------------- 1 | import { Button } from '@chakra-ui/react' 2 | import join from 'url-join' 3 | const URL = process.env.APP_URL 4 | const OSM_DOMAIN = process.env.OSM_DOMAIN 5 | const OSMCHA_URL = process.env.OSMCHA_URL 6 | const SCOREBOARD_URL = process.env.SCOREBOARD_URL 7 | const HDYC_URL = process.env.HDYC_URL 8 | 9 | const ExternalProfileButton = ({ type, userId }) => { 10 | let targetLink 11 | let title 12 | let label 13 | let altText 14 | let logoImg 15 | 16 | switch (type) { 17 | case 'osm-profile': 18 | targetLink = join(OSM_DOMAIN, `/user/${userId}`) 19 | title = 'View profile on OSM' 20 | label = 'OSM' 21 | altText = 'OSM Logo' 22 | logoImg = 'osm_logo.png' 23 | break 24 | case 'hdyc': 25 | targetLink = join(HDYC_URL, `/?${userId}`) 26 | title = 'View profile on HDYC' 27 | label = 'HDYC' 28 | altText = 'How Do You Contribute Logo' 29 | logoImg = 'neis-one-logo.png' 30 | break 31 | case 'scoreboard': 32 | targetLink = join(SCOREBOARD_URL, `/users/${userId}`) 33 | title = 'View user profile on Scoreboard' 34 | label = 'Scoreboard' 35 | altText = 'Scoreboard Logo' 36 | logoImg = 'scoreboard-logo.svg' 37 | break 38 | case 'osmcha': 39 | targetLink = join( 40 | OSMCHA_URL, 41 | `/?filters={"users":[{"label":"${userId}","value":"${userId}"}]}` 42 | ) 43 | title = 'View profile on OSMCha' 44 | label = 'OSMCha' 45 | altText = 'OSMCha Logo' 46 | logoImg = 'osmcha-logo.svg' 47 | break 48 | default: 49 | return null 50 | } 51 | 52 | return ( 53 | 74 | ) 75 | } 76 | 77 | export default ExternalProfileButton 78 | -------------------------------------------------------------------------------- /src/components/form-map.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Map, CircleMarker, TileLayer } from 'react-leaflet' 3 | import { reverse } from 'ramda' 4 | import Geocoder from 'leaflet-control-geocoder' 5 | import 'leaflet-gesture-handling' 6 | 7 | export default class FormMap extends Component { 8 | constructor(props) { 9 | super(props) 10 | this.map = React.createRef() 11 | this.state = { 12 | zoom: 10, 13 | } 14 | } 15 | 16 | setZoom(zoom) { 17 | this.setState({ zoom }) 18 | } 19 | 20 | componentDidMount() { 21 | if (this.map && !this.geocoder) { 22 | this.geocoder = new Geocoder({ 23 | defaultMarkGeocode: false, 24 | }) 25 | 26 | this.geocoder.on('markgeocode', (e) => { 27 | const bbox = e.geocode.bbox 28 | this.map.current.leafletElement.fitBounds(bbox) 29 | }) 30 | 31 | this.map.current.leafletElement.addControl(this.geocoder) 32 | } 33 | } 34 | 35 | render() { 36 | let centerGeojson = 37 | this.props.value || 38 | '{ "type": "Point", "coordinates": [-73.968056,40.749444] }' 39 | let center = reverse(JSON.parse(centerGeojson).coordinates) 40 | 41 | return ( 42 | { 48 | let toGeojson = `{ 49 | "type": "Point", 50 | "coordinates": [${center[1]},${center[0]}] 51 | }` 52 | this.setZoom(zoom) 53 | this.props.onChange(this.props.name, toGeojson) 54 | }} 55 | gestureHandling={true} 56 | > 57 | 61 | 62 | 63 | ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/components/inpage-header.js: -------------------------------------------------------------------------------- 1 | import { Container, Box } from '@chakra-ui/react' 2 | 3 | export default function InpageHeader({ children }) { 4 | return ( 5 | 17 | 18 | {children} 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/join-link.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import join from 'url-join' 3 | import { 4 | Button, 5 | Input, 6 | InputGroup, 7 | InputLeftAddon, 8 | InputRightAddon, 9 | useClipboard, 10 | } from '@chakra-ui/react' 11 | import { 12 | createTeamJoinInvitation, 13 | getTeamJoinInvitations, 14 | } from '../lib/teams-api' 15 | import { toast } from 'react-toastify' 16 | import logger from '../lib/logger' 17 | import { CopyIcon } from '@chakra-ui/icons' 18 | const APP_URL = process.env.APP_URL 19 | 20 | export default function JoinLink({ id }) { 21 | const [joinLink, setJoinLinks] = useState(null) 22 | const { onCopy, hasCopied } = useClipboard(joinLink) 23 | 24 | const getTeamJoinLink = async function () { 25 | try { 26 | const invitations = await getTeamJoinInvitations(id) 27 | if (invitations.length) { 28 | setJoinLinks( 29 | join(APP_URL, `teams/${id}/invitations/${invitations[0].id}`) 30 | ) 31 | } 32 | } catch (e) { 33 | logger.error(e) 34 | toast.error(e) 35 | } 36 | } 37 | useEffect(() => { 38 | getTeamJoinLink() 39 | }, []) 40 | 41 | const createJoinLink = async function () { 42 | try { 43 | await createTeamJoinInvitation(id) 44 | getTeamJoinLink() 45 | } catch (e) { 46 | logger.error(e) 47 | toast.error(e) 48 | } 49 | } 50 | return ( 51 |
52 | {joinLink ? ( 53 | 54 | 55 | Join Link: 56 | 57 | 63 | 64 | 73 | 74 | 75 | ) : ( 76 | 79 | )} 80 |
81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /src/components/list-map.js: -------------------------------------------------------------------------------- 1 | import React, { Component, createRef } from 'react' 2 | import { Map, CircleMarker, TileLayer, Tooltip } from 'react-leaflet' 3 | import Router from 'next/router' 4 | import join from 'url-join' 5 | 6 | const APP_URL = process.env.APP_URL 7 | 8 | export default class ListMap extends Component { 9 | constructor(props) { 10 | super(props) 11 | 12 | this.map = createRef() 13 | } 14 | render() { 15 | const markers = this.props.markers.map((marker) => ( 16 | { 22 | Router.push(join(APP_URL, `/teams/${marker.id}`)) 23 | }} 24 | > 25 | {marker.name} 26 | 27 | )) 28 | 29 | return ( 30 | { 36 | const bounds = this.map.current.leafletElement.getBounds() 37 | const { _southWest: sw, _northEast: ne } = bounds 38 | this.props.onBoundsChange([sw.lng, sw.lat, ne.lng, ne.lat]) // xmin, ymin, xmax, ymax 39 | }} 40 | > 41 | 45 | {markers} 46 | 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/page-footer.js: -------------------------------------------------------------------------------- 1 | import { Box, Container, Flex, Text } from '@chakra-ui/react' 2 | 3 | import NavLink from '../components/Link' 4 | 5 | const Links = [ 6 | { url: '/about', name: 'About' }, 7 | { url: '/guide', name: 'User guide' }, 8 | { url: '/developers', name: 'Developers' }, 9 | { url: '/privacy', name: 'Privacy Policy' }, 10 | ] 11 | 12 | export default function PageFooter() { 13 | return ( 14 | 15 | 23 | 29 | 30 | osm_teams 31 | 32 | {Links.map((link) => ( 33 | 34 | 35 | {link.name} 36 | 37 | 38 | ))} 39 | 40 | 47 | 48 | © Development Seed {new Date().getFullYear()} 49 | 50 | 51 | 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/components/pagination.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import T from 'prop-types' 3 | import { Flex, Button, Text, IconButton } from '@chakra-ui/react' 4 | import { 5 | FiChevronLeft, 6 | FiChevronsLeft, 7 | FiChevronRight, 8 | FiChevronsRight, 9 | } from 'react-icons/fi' 10 | const PAGE_INDEX_START = 1 11 | 12 | function listPageOptions(page, lastPage) { 13 | let pageOptions = [1] 14 | if (lastPage === PAGE_INDEX_START) { 15 | return pageOptions 16 | } 17 | if (page === PAGE_INDEX_START || page > lastPage) { 18 | return pageOptions.concat([2, '...', lastPage]) 19 | } 20 | if (lastPage > 5) { 21 | if (page < 3) { 22 | return pageOptions.concat([2, 3, '...', lastPage]) 23 | } 24 | if (page === 3) { 25 | return pageOptions.concat([2, 3, 4, '...', lastPage]) 26 | } 27 | if (page === lastPage) { 28 | return pageOptions.concat(['...', page - 2, page - 1, lastPage]) 29 | } 30 | if (page === lastPage - 1) { 31 | return pageOptions.concat(['...', page - 1, page, lastPage]) 32 | } 33 | if (page === lastPage - 2) { 34 | return pageOptions.concat(['...', page - 1, page, page + 1, lastPage]) 35 | } 36 | return pageOptions.concat([ 37 | '...', 38 | page - 1, 39 | page, 40 | page + 1, 41 | '...', 42 | lastPage, 43 | ]) 44 | } else { 45 | let range = [] 46 | for (let i = 1; i <= lastPage; i++) { 47 | range.push(i) 48 | } 49 | return range 50 | } 51 | } 52 | 53 | function Pagination({ pagination, setPage, 'data-cy': dataCy }) { 54 | let { perPage, total, currentPage, lastPage } = pagination 55 | currentPage = Number(currentPage) 56 | 57 | const maxPages = Number(lastPage) 58 | const pages = listPageOptions(currentPage + 1, maxPages) 59 | 60 | return ( 61 | 68 | setPage(PAGE_INDEX_START)} 72 | isDisabled={currentPage === PAGE_INDEX_START} 73 | icon={} 74 | variant='outline' 75 | size='sm' 76 | /> 77 | setPage(currentPage - 1)} 81 | isDisabled={currentPage === PAGE_INDEX_START} 82 | icon={} 83 | variant='outline' 84 | size='sm' 85 | /> 86 | {pages.map((page) => { 87 | return ( 88 | 98 | ) 99 | })} 100 | setPage(currentPage + 1)} 104 | isDisabled={currentPage === maxPages} 105 | icon={} 106 | variant='outline' 107 | size='sm' 108 | /> 109 | setPage(maxPages)} 113 | isDisabled={currentPage === maxPages} 114 | icon={} 115 | variant='outline' 116 | size='sm' 117 | /> 118 | 119 | Showing {(currentPage - 1) * perPage + 1}- 120 | {Intl.NumberFormat().format( 121 | Number(currentPage) === Number(maxPages) 122 | ? total 123 | : currentPage * perPage 124 | )}{' '} 125 | of {Intl.NumberFormat().format(total)} 126 | 127 | 128 | ) 129 | } 130 | 131 | Pagination.propTypes = { 132 | perPage: T.number.isRequired, 133 | currentPage: T.number.isRequired, 134 | total: T.number.isRequired, 135 | setPage: T.func.isRequired, 136 | } 137 | 138 | export default Pagination 139 | -------------------------------------------------------------------------------- /src/components/privacy-policy-form.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Formik, Field, Form } from 'formik' 3 | import { 4 | Button, 5 | FormControl, 6 | FormErrorMessage, 7 | FormLabel, 8 | Textarea, 9 | VStack, 10 | } from '@chakra-ui/react' 11 | 12 | function validateBody(value) { 13 | if (!value) return 'Body of privacy policy is required' 14 | } 15 | 16 | function validateConsentText(value) { 17 | if (!value) return 'Consent Text of privacy policy is required' 18 | } 19 | 20 | function renderError(text) { 21 | return {text} 22 | } 23 | 24 | function renderErrors(errors) { 25 | const keys = Object.keys(errors) 26 | return keys.map((key) => { 27 | return renderError(errors[key]) 28 | }) 29 | } 30 | 31 | export default function PrivacyPolicyForm({ initialValues, onSubmit }) { 32 | return ( 33 | { 45 | return ( 46 | 47 | 48 | Body 49 | 61 | 62 | 63 | Consent Text 64 | 76 | 77 | 78 | 93 | {status && status.errors && renderErrors(status.errors)} 94 | 95 | 96 | ) 97 | }} 98 | /> 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /src/components/profile-modal.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { isEmpty } from 'ramda' 3 | import { 4 | Avatar, 5 | Flex, 6 | Heading, 7 | Text, 8 | Wrap, 9 | ModalContent, 10 | ModalHeader, 11 | ModalBody, 12 | ModalCloseButton, 13 | ModalOverlay, 14 | Modal, 15 | ModalFooter, 16 | } from '@chakra-ui/react' 17 | import Badge from './badge' 18 | 19 | function renderBadges(badges) { 20 | if (!badges || badges.length === 0) { 21 | return null 22 | } 23 | 24 | return ( 25 | 26 | User Badges 27 | 28 | {badges.map((b) => ( 29 | 30 | {b.name} 31 | 32 | ))} 33 | 34 | 35 | ) 36 | } 37 | 38 | export default function ProfileModal({ 39 | user, 40 | attributes, 41 | badges, 42 | onClose, 43 | isOpen, 44 | }) { 45 | let profileContent =
User does not have a profile
46 | if (!isEmpty(attributes)) { 47 | profileContent = ( 48 | 49 | {attributes && 50 | attributes.map((attribute) => { 51 | if (attribute.value) { 52 | return ( 53 | 54 | 60 | {attribute.name}: 61 | 62 | {attribute.value} 63 | 64 | ) 65 | } 66 | })} 67 | 68 | ) 69 | } 70 | return ( 71 | 77 | 78 | 84 | 85 | 86 | 92 | 93 | {user?.name} 94 | 95 | 96 | onClose()} /> 97 | 98 | {profileContent} 99 | {renderBadges(badges)} 100 | 101 | 102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /src/components/tables/search-input.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import { Field, Form, Formik, useFormikContext } from 'formik' 3 | import { Flex, IconButton, Input } from '@chakra-ui/react' 4 | import { Search2Icon } from '@chakra-ui/icons' 5 | 6 | /** 7 | * This is a helper component to auto-submit search values after a timeout 8 | */ 9 | const AutoSubmitSearch = () => { 10 | const timerRef = useRef(null) 11 | 12 | const { values, touched, submitForm } = useFormikContext() 13 | 14 | useEffect(() => { 15 | // Check if input was touched. Formik behavior is to update 'touched' 16 | // flag on input blur or submit, but we want to submit changes also on 17 | // key press. 'touched' is not a useEffect dependency because it is 18 | // constantly updated without value changes. 19 | const isTouched = touched.search || values?.search.length > 0 20 | 21 | // If search is touched 22 | if (isTouched) { 23 | // Clear previous timeout, if exists 24 | if (timerRef.current) { 25 | clearTimeout(timerRef.current) 26 | } 27 | // Define new timeout 28 | timerRef.current = setTimeout(submitForm, 1000) 29 | } 30 | 31 | // Clear timeout on unmount 32 | return () => timerRef.current && clearTimeout(timerRef.current) 33 | }, [values]) 34 | 35 | return null 36 | } 37 | 38 | /** 39 | * The search input 40 | */ 41 | const SearchInput = ({ onSearch, placeholder, 'data-cy': dataCy }) => { 42 | return ( 43 | onSearch(search)} 46 | > 47 | 48 | 57 | } 61 | aria-label='Search' 62 | /> 63 | 64 | 65 | 66 | ) 67 | } 68 | 69 | export default SearchInput 70 | -------------------------------------------------------------------------------- /src/components/tables/teams.js: -------------------------------------------------------------------------------- 1 | import T from 'prop-types' 2 | import Table from './table' 3 | import Router from 'next/router' 4 | import join from 'url-join' 5 | import { useFetchList } from '../../hooks/use-fetch-list' 6 | import { useState } from 'react' 7 | import Pagination from '../pagination' 8 | import SearchInput from './search-input' 9 | import qs from 'qs' 10 | 11 | const APP_URL = process.env.APP_URL 12 | 13 | function TeamsTable({ type, orgId, bbox }) { 14 | const [page, setPage] = useState(1) 15 | const [search, setSearch] = useState(null) 16 | const [sort, setSort] = useState({ 17 | key: 'name', 18 | direction: 'asc', 19 | }) 20 | 21 | const querystring = qs.stringify( 22 | { 23 | search, 24 | page, 25 | sort: sort.key, 26 | order: sort.direction, 27 | bbox: bbox, 28 | }, 29 | { arrayFormat: 'comma' } 30 | ) 31 | 32 | let apiBasePath 33 | let emptyMessage 34 | 35 | switch (type) { 36 | case 'all-teams': 37 | apiBasePath = '/teams' 38 | break 39 | case 'my-teams': 40 | apiBasePath = '/my/teams' 41 | emptyMessage = 'You are not part of a team yet.' 42 | break 43 | case 'org-teams': 44 | apiBasePath = `/organizations/${orgId}/teams` 45 | emptyMessage = 'This organization has no teams.' 46 | break 47 | default: 48 | break 49 | } 50 | 51 | const { 52 | result: { data, pagination }, 53 | isLoading, 54 | } = useFetchList(`${apiBasePath}?${querystring}`) 55 | 56 | const columns = [ 57 | { key: 'name', sortable: true }, 58 | { key: 'members', sortable: true }, 59 | ] 60 | 61 | return ( 62 | <> 63 | { 66 | // Reset to page 1 and search 67 | setPage(1) 68 | setSearch(search) 69 | }} 70 | placeholder='Search by team name' 71 | /> 72 | { 80 | Router.push(join(APP_URL, `/teams/${row.id}`)) 81 | }} 82 | /> 83 | {pagination?.total > 0 && ( 84 | 89 | )} 90 | 91 | ) 92 | } 93 | 94 | TeamsTable.propTypes = { 95 | type: T.oneOf(['all-teams', 'org-teams', 'my-teams']).isRequired, 96 | orgId: T.number, 97 | } 98 | 99 | export default TeamsTable 100 | -------------------------------------------------------------------------------- /src/components/team-map.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Map, CircleMarker, TileLayer } from 'react-leaflet' 3 | import 'leaflet-gesture-handling' 4 | 5 | export default class TeamMap extends Component { 6 | render() { 7 | return ( 8 | 14 | 18 | 23 | 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/use-fetch-list.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | import ApiClient from '../lib/api-client' 3 | const apiClient = new ApiClient() 4 | 5 | export const useFetchList = (baseUrl) => { 6 | const request = useRef() 7 | 8 | const [status, setStatus] = useState('idle') 9 | const [error, setError] = useState() 10 | const [result, setResult] = useState([]) 11 | 12 | function fetch() { 13 | setStatus('loading') 14 | request.current = setTimeout(() => { 15 | apiClient 16 | .get(baseUrl) 17 | .then((res) => { 18 | setResult(res) 19 | setStatus('success') 20 | }) 21 | .catch(({ message }) => { 22 | setError(message) 23 | setStatus('error') 24 | }) 25 | }, 250) 26 | } 27 | 28 | // fetch request on page load, clear on unmount 29 | useEffect(() => { 30 | fetch() 31 | return () => clearTimeout(request.current) 32 | }, [baseUrl]) // eslint-disable-line react-hooks/exhaustive-deps 33 | 34 | return { fetch, result, status, isLoading: status === 'loading', error } 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/api-client.js: -------------------------------------------------------------------------------- 1 | const APP_URL = process.env.APP_URL 2 | class ApiClient { 3 | constructor() { 4 | this.defaultOptions = { 5 | headers: { 6 | 'Content-Type': 'application/json', 7 | }, 8 | } 9 | } 10 | 11 | baseUrl(subpath) { 12 | return `${APP_URL}/api${subpath}` 13 | } 14 | 15 | async fetch(method, path, data, config = {}) { 16 | const url = this.baseUrl(path) 17 | 18 | const defaultConfig = { 19 | format: 'json', 20 | } 21 | const requestConfig = { 22 | ...defaultConfig, 23 | ...config, 24 | } 25 | 26 | const { format, headers } = requestConfig 27 | var requestHeaders = this.defaultOptions.headers 28 | for (const key in headers) { 29 | requestHeaders[key] = headers[key] 30 | } 31 | 32 | const options = { 33 | ...this.defaultOptions, 34 | method, 35 | format, 36 | headers: requestHeaders, 37 | } 38 | 39 | if (data) { 40 | options.body = JSON.stringify(data) 41 | } 42 | 43 | // Fetch data and let errors to be handle by the caller 44 | // Fetch data and let errors to be handle by the caller 45 | const res = await fetchJSON(url, options) 46 | return res.body 47 | } 48 | 49 | get(path, config) { 50 | return this.fetch('GET', path, null, config) 51 | } 52 | 53 | post(path, data, config) { 54 | return this.fetch('POST', path, data, config) 55 | } 56 | 57 | patch(path, data, config) { 58 | return this.fetch('PATCH', path, data, config) 59 | } 60 | 61 | delete(path, config) { 62 | return this.fetch('DELETE', path, config) 63 | } 64 | } 65 | 66 | export default ApiClient 67 | 68 | /** 69 | * Performs a request to the given url returning the response in json format 70 | * or throwing an error. 71 | * 72 | * @param {string} url Url to query 73 | * @param {object} options Options for fetch 74 | */ 75 | export async function fetchJSON(url, options) { 76 | let response 77 | options = options || {} 78 | const format = options.format || 'json' 79 | let data 80 | try { 81 | response = await fetch(url, options) 82 | if (format === 'json') { 83 | data = await response.json() 84 | } else if (format === 'binary') { 85 | data = await response.arrayBuffer() 86 | } else { 87 | data = await response.text() 88 | } 89 | 90 | if (response.status >= 400) { 91 | const err = new Error(data.message) 92 | err.statusCode = response.status 93 | err.data = data 94 | throw err 95 | } 96 | 97 | return { body: data, headers: response.headers } 98 | } catch (error) { 99 | error.statusCode = response ? response.status || null : null 100 | throw error 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/lib/badges-api.js: -------------------------------------------------------------------------------- 1 | const { default: ApiClient } = require('./api-client') 2 | const logger = require('./logger') 3 | 4 | const apiClient = new ApiClient() 5 | 6 | /** 7 | * Get badges from an organization 8 | * 9 | * @param {int} orgId - id of the organization 10 | * @returns {array[badges]} 11 | */ 12 | export async function getOrgBadges(orgId) { 13 | try { 14 | const badges = await apiClient.get(`/organizations/${orgId}/badges`) 15 | return badges 16 | } catch (e) { 17 | if (e.statusCode === 401) { 18 | logger.error("User doesn't have access to organization badges.") 19 | } else { 20 | logger.error(e) 21 | } 22 | } 23 | } 24 | 25 | export async function getUserBadges(userId) { 26 | try { 27 | const badges = await apiClient.get(`/user/${userId}/badges`) 28 | return badges 29 | } catch (e) { 30 | if (e.statusCode === 401) { 31 | logger.error("User doesn't have access to organization badges.") 32 | } else { 33 | logger.error(e) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/db.js: -------------------------------------------------------------------------------- 1 | const knex = require('knex') 2 | const config = require('../../knexfile') 3 | const { attachPaginate } = require('knex-paginate') 4 | 5 | // Get db instance 6 | const db = knex(config) 7 | 8 | // Add pagination helper, if not already available 9 | if (!db.queryBuilder().paginate) { 10 | attachPaginate() 11 | } 12 | 13 | module.exports = db 14 | -------------------------------------------------------------------------------- /src/lib/logger.js: -------------------------------------------------------------------------------- 1 | const Pino = require('pino') 2 | 3 | /** 4 | * Create logger instance. Level is set to 'silent' when testing. 5 | */ 6 | const logger = Pino({ 7 | prettyPrint: true, 8 | level: process.env.LOG_LEVEL || 'info', 9 | }) 10 | 11 | module.exports = logger 12 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | function getRandomColor() { 2 | var letters = '0123456789ABCDEF' 3 | var color = '#' 4 | for (var i = 0; i < 6; i++) { 5 | color += letters[Math.floor(Math.random() * 16)] 6 | } 7 | return color 8 | } 9 | 10 | /** 11 | * Converts a date to the browser locale string 12 | * 13 | * @param {Number or String} timestamp 14 | * @returns 15 | */ 16 | function toDateString(timestamp) { 17 | const dateFormat = new Intl.DateTimeFormat(navigator.language).format 18 | return dateFormat(new Date(timestamp)) 19 | } 20 | 21 | /** 22 | * Generates an Array containing a sequence of integers 23 | * 24 | * @param {Number} length Array length 25 | * @param {Number} initialValue Initial value 26 | * @returns 27 | */ 28 | function generateSequenceArray(length, initialValue = 0) { 29 | return Array.from({ length }, (_, i) => i + initialValue) 30 | } 31 | 32 | /** 33 | * Add leading zeroes to a number 34 | * @param {*} n the number 35 | * @param {*} width final length 36 | * @returns zero-padded number 37 | */ 38 | function addZeroPadding(n, width) { 39 | return String(n).padStart(width, '0') 40 | } 41 | 42 | module.exports = { 43 | getRandomColor, 44 | toDateString, 45 | generateSequenceArray, 46 | addZeroPadding, 47 | } 48 | -------------------------------------------------------------------------------- /src/middleware.js: -------------------------------------------------------------------------------- 1 | export { default } from 'next-auth/middleware' 2 | 3 | export const config = { 4 | matcher: ['/clients', '/organizations/:path*', '/dashboard'], 5 | } 6 | -------------------------------------------------------------------------------- /src/middlewares/base-handler.js: -------------------------------------------------------------------------------- 1 | import nc from 'next-connect' 2 | import logger from '../lib/logger' 3 | import { getToken } from 'next-auth/jwt' 4 | import Boom from '@hapi/boom' 5 | 6 | /** 7 | * This file contains the base handler to be used in all API routes. 8 | * 9 | * It should catch any error occurred in the route handlers and return 10 | * error responses consistently with Boom. The routes handlers should avoid 11 | * using try..catch blocks and let the errors to be handled here. 12 | * 13 | * It should also add the session to the request so the handlers can access 14 | * the requesting user metadata. 15 | */ 16 | 17 | /** 18 | * @swagger 19 | * components: 20 | * schemas: 21 | * ResponseError: 22 | * properties: 23 | * statusCode: 24 | * type: integer 25 | * example: 401 26 | * error: 27 | * type: string 28 | * message: 29 | * type: string 30 | */ 31 | 32 | export function createBaseHandler() { 33 | const baseHandler = nc({ 34 | attachParams: true, 35 | onError: (err, req, res) => { 36 | logger.error(err) 37 | 38 | // Handle Boom errors 39 | if (err.isBoom) { 40 | const { statusCode, payload } = err.output 41 | return res.status(statusCode).json(payload) 42 | } 43 | 44 | // Handle Yup validation errors 45 | if (err.name === 'ValidationError') { 46 | return res.status(400).json({ 47 | statusCode: 400, 48 | error: 'Validation Error', 49 | message: err.message, 50 | }) 51 | } 52 | 53 | // Generic error 54 | return res.status(500).json({ 55 | statusCode: 500, 56 | error: 'Internal Server Error', 57 | message: 'An internal server error occurred', 58 | }) 59 | }, 60 | onNoMatch: (req, res) => { 61 | if (req.method === 'OPTIONS') { 62 | logger.info('OPTIONS request') 63 | return res.status(200).end() 64 | } 65 | 66 | res.status(404).json({ 67 | statusCode: 404, 68 | error: 'Not Found', 69 | message: 'missing', 70 | }) 71 | }, 72 | }) 73 | 74 | // Add session to request 75 | baseHandler.use(async (req, res, next) => { 76 | /** Handle authorization using either Bearer token auth or 77 | * using the next-auth session 78 | */ 79 | if (req.headers.authorization) { 80 | // introspect the token 81 | const [type, token] = req.headers.authorization.split(' ') 82 | if (type !== 'Bearer') { 83 | throw Boom.badRequest( 84 | 'Authorization scheme not supported. Only Bearer scheme is supported' 85 | ) 86 | } else { 87 | const result = await fetch(`${process.env.AUTH_URL}/api/introspect`, { 88 | method: 'POST', 89 | headers: { 90 | Accept: 'application/json', 91 | 'Content-Type': 'application/json', 92 | }, 93 | body: JSON.stringify({ 94 | token: token, 95 | }), 96 | }).then((response) => { 97 | return response.json() 98 | }) 99 | if (result && result.active) { 100 | req.session = { user_id: result.sub } 101 | } else { 102 | throw Boom.unauthorized('Invalid token') 103 | } 104 | } 105 | } else { 106 | const token = await getToken({ req }) 107 | if (token) { 108 | req.session = { user_id: token.userId || token.sub } 109 | } 110 | } 111 | next() 112 | }) 113 | 114 | return baseHandler 115 | } 116 | -------------------------------------------------------------------------------- /src/middlewares/can/authenticated.js: -------------------------------------------------------------------------------- 1 | import Boom from '@hapi/boom' 2 | 3 | /** 4 | * Authenticated 5 | * 6 | * To view this route you must be authenticated 7 | * 8 | * @returns {Promise} 9 | */ 10 | export default async function isAuthenticated(req, res, next) { 11 | const userId = req.session?.user_id 12 | 13 | // Must be owner or manager 14 | if (!userId) { 15 | throw Boom.unauthorized() 16 | } else { 17 | next() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/middlewares/can/create-org-team.js: -------------------------------------------------------------------------------- 1 | import Boom from '@hapi/boom' 2 | import { isManager, isOwner } from '../../models/organization' 3 | 4 | /** 5 | * organization:create-team 6 | * 7 | * To create a team within an organization, you have to be 8 | * a manager of the organization 9 | * 10 | * @param {int} uid - user id 11 | * @param {Object} params - request parameters 12 | * @param {int} params.id - organization id 13 | * @returns {Promise} 14 | */ 15 | export default async function canCreateOrgTeam(req, res, next) { 16 | const { orgId } = req.query 17 | const userId = req.session?.user_id 18 | 19 | if (!userId || !orgId) { 20 | throw Boom.badRequest('could not identify organization or user') 21 | } 22 | 23 | // Must be owner or manager 24 | if (!(await isOwner(orgId, userId)) && !(await isManager(orgId, userId))) { 25 | throw Boom.unauthorized() 26 | } else { 27 | next() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/middlewares/can/edit-team.js: -------------------------------------------------------------------------------- 1 | const { isModerator, associatedOrg } = require('../../../src/models/team') 2 | const { isOwner } = require('../../../src/models/organization') 3 | const Boom = require('@hapi/boom') 4 | 5 | /** 6 | * team:update 7 | * 8 | * To update a team, the authenticated user needs to 9 | * be a moderator of the team or an owner of the organization 10 | * that the team is associated to 11 | * 12 | * @returns {Promise} 13 | */ 14 | async function canEditTeam(req, res, next) { 15 | const { teamId } = req.query 16 | // user has to be authenticated 17 | const userId = req.session?.user_id 18 | if (!userId) throw Boom.unauthorized() 19 | 20 | // check if user is an owner of this team through an organization 21 | const org = await associatedOrg(teamId) 22 | const ownerOfTeam = org && (await isOwner(org.organization_id, userId)) 23 | 24 | if (ownerOfTeam || (await isModerator(teamId, userId))) { 25 | return next() 26 | } else { 27 | throw Boom.unauthorized() 28 | } 29 | } 30 | 31 | module.exports = canEditTeam 32 | -------------------------------------------------------------------------------- /src/middlewares/can/view-org-members.js: -------------------------------------------------------------------------------- 1 | import Boom from '@hapi/boom' 2 | import { isMemberOrStaff, isPublic } from '../../models/organization' 3 | 4 | /** 5 | * Validation middleware 6 | * @param {function} config.schema Yup validation schema 7 | * @param {function} config.handler Handler to execute if validation pass 8 | * 9 | * @returns {function} Route middleware function 10 | */ 11 | export default async function canViewOrgMembers(req, res, next) { 12 | const { orgId } = req.query 13 | const userId = req.session?.user_id 14 | 15 | if (!orgId) { 16 | throw Boom.badRequest('organization id not provided') 17 | } 18 | 19 | if (await isPublic(orgId)) { 20 | // Can view if org is public 21 | return next() 22 | } else if (userId && (await isMemberOrStaff(orgId, userId))) { 23 | // Can view if is member or staff 24 | return next() 25 | } else { 26 | throw Boom.unauthorized() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/middlewares/can/view-org-teams.js: -------------------------------------------------------------------------------- 1 | import Boom from '@hapi/boom' 2 | import Organization from '../../models/organization' 3 | 4 | /** 5 | * Permission middleware to check if user can view organization teams. 6 | * Add permission flags to request object (isMember, isManager, isOwner) 7 | * @param {object} req The request 8 | * @param {object} res The response 9 | * @param {function} next The next middleware 10 | * @returns The results of the next middleware 11 | */ 12 | export default async function canViewOrgTeams(req, res, next) { 13 | const { orgId } = req.query 14 | 15 | if (!orgId) { 16 | throw Boom.badRequest('organization id not provided') 17 | } 18 | 19 | let org = await Organization.get(orgId) 20 | 21 | const userId = req.session?.user_id 22 | 23 | if (userId) { 24 | let [isMember, isManager, isOwner] = await Promise.all([ 25 | Organization.isMember(orgId, userId), 26 | Organization.isManager(orgId, userId), 27 | Organization.isOwner(orgId, userId), 28 | ]) 29 | if (org?.privacy === 'public' || isMember || isManager || isOwner) { 30 | // Add org and permission flags to request 31 | req.org = { ...org, isMember, isManager, isOwner } 32 | return next() 33 | } 34 | } else { 35 | if (org?.privacy === 'public') { 36 | req.org = { ...org } 37 | return next() 38 | } 39 | } 40 | 41 | throw Boom.unauthorized() 42 | } 43 | -------------------------------------------------------------------------------- /src/middlewares/can/view-team-members.js: -------------------------------------------------------------------------------- 1 | const { isPublic, isMember, associatedOrg } = require('../../models/team') 2 | const { isOwner } = require('../../models/organization') 3 | import Boom from '@hapi/boom' 4 | 5 | /** 6 | * Permission middleware to view a team's members 7 | * 8 | * To view a team's members, the team needs to be either public 9 | * or the user should be a member of the team 10 | * 11 | * @param {object} req The request 12 | * @param {object} res The response 13 | * @param {function} next The next middleware 14 | * @returns the results of the next middleware 15 | */ 16 | export default async function canViewTeamMembers(req, res, next) { 17 | const { teamId: id } = req.query 18 | if (!id) { 19 | throw Boom.badRequest('team id not provided') 20 | } 21 | 22 | const publicTeam = await isPublic(id) 23 | if (publicTeam) return next() 24 | 25 | const userId = req.session?.user_id 26 | 27 | if (!userId) { 28 | throw Boom.unauthorized() 29 | } 30 | 31 | const org = await associatedOrg(id) 32 | const ownerOfTeam = org && (await isOwner(org.organization_id, userId)) 33 | 34 | // You can view the members if you're part of the team, or in case of an org team if you're the owner 35 | if (ownerOfTeam || (await isMember(id, userId))) { 36 | return next() 37 | } else { 38 | throw Boom.unauthorized() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/middlewares/validation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Validation middleware 3 | * @param {function} config.schema Yup validation schema 4 | * @param {function} config.handler Handler to execute if validation pass 5 | * 6 | * @returns {function} Route middleware function 7 | */ 8 | export function validate(schema) { 9 | return async (req, res, next) => { 10 | if (schema.query) { 11 | req.query = await schema.query.validate(req.query) 12 | } 13 | if (schema.body) { 14 | req.body = await schema.body.validate(req.body) 15 | } 16 | next() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/models/badge.js: -------------------------------------------------------------------------------- 1 | const db = require('../lib/db') 2 | 3 | /** 4 | * 5 | * Assign existing badge to an user. 6 | * 7 | * @param {int} userId - User id 8 | * @param {int} badgeId - Badge id 9 | * @param {Date} assignedAt - Badge assignment date 10 | * @param {Date} validUntil - Badge expiration date 11 | * @returns 12 | */ 13 | async function assignUserBadge(badgeId, userId, assignedAt, validUntil) { 14 | const [badge] = await db('user_badges') 15 | .insert({ 16 | user_id: userId, 17 | badge_id: badgeId, 18 | assigned_at: assignedAt, 19 | valid_until: validUntil ? validUntil : null, 20 | }) 21 | .returning('*') 22 | return badge 23 | } 24 | 25 | module.exports = { 26 | assignUserBadge, 27 | } 28 | -------------------------------------------------------------------------------- /src/models/team-invitation.js: -------------------------------------------------------------------------------- 1 | const db = require('../lib/db') 2 | const crypto = require('crypto') 3 | 4 | async function create({ uuid, teamId, createdAt, expiresAt }) { 5 | const [invitation] = await db('invitations') 6 | .insert({ 7 | id: uuid || crypto.randomUUID(), 8 | team_id: teamId, 9 | created_at: createdAt, 10 | expires_at: expiresAt, 11 | }) 12 | .returning('*') 13 | return invitation 14 | } 15 | 16 | async function get({ id, teamId }) { 17 | const [invitation] = await db('invitations') 18 | .select() 19 | .where({ id, team_id: teamId }) 20 | return invitation 21 | } 22 | 23 | module.exports = { 24 | create, 25 | get, 26 | } 27 | -------------------------------------------------------------------------------- /src/models/users.js: -------------------------------------------------------------------------------- 1 | const db = require('../lib/db') 2 | 3 | /** 4 | * Get paginated list of teams 5 | * 6 | * @param options 7 | * @param {username} options.username - filter by OSM username 8 | * @return {Promise[Array]} 9 | **/ 10 | async function list(options = {}) { 11 | // Apply search 12 | let query = await db('osm_users') 13 | .select('id', 'name') 14 | .whereILike('name', `%${options.username}%`) 15 | 16 | return query 17 | } 18 | 19 | module.exports = { 20 | list, 21 | } 22 | -------------------------------------------------------------------------------- /src/pages/404.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Box, Container, Heading, Text } from '@chakra-ui/react' 3 | import InpageHeader from '../components/inpage-header' 4 | 5 | export default class UhOh extends Component { 6 | render() { 7 | return ( 8 | 9 | 10 | 11 | 404 - Page Not Found 12 | 13 | 14 | 15 | 16 | 17 | Sorry, the page you are looking for is not available. 18 | 19 | 20 | Still having problems? Try logging out and back in or contacting a 21 | system administrator. 22 | 23 | 24 | 25 | 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/_app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ChakraProvider } from '@chakra-ui/react' 3 | import '@fontsource/work-sans/400.css' 4 | import '@fontsource/work-sans/700.css' 5 | import '@fontsource/inconsolata/400.css' 6 | import '@fontsource/inconsolata/700.css' 7 | import 'leaflet-gesture-handling/dist/leaflet-gesture-handling.css' 8 | 9 | import Head from 'next/head' 10 | import { Box } from '@chakra-ui/react' 11 | import { ToastContainer } from 'react-toastify' 12 | import { SessionProvider } from 'next-auth/react' 13 | 14 | import theme from '../styles/theme' 15 | import PageHeader from '../components/page-header' 16 | import PageFooter from '../components/page-footer' 17 | import ErrorBoundary from '../components/error-boundary' 18 | const BASE_PATH = process.env.BASE_PATH || '' 19 | 20 | export default function App({ 21 | Component, 22 | pageProps: { session, ...pageProps }, 23 | }) { 24 | return ( 25 | 26 | 27 | 28 | OSM Teams 29 | 30 | 31 | 32 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/pages/_document.js: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | import join from 'url-join' 3 | const APP_URL = process.env.APP_URL 4 | 5 | export default function Document() { 6 | return ( 7 | 8 | 9 | 13 | 19 | 23 | 27 | 28 | 33 | 38 | 44 | 50 | 51 | 52 | 53 |
54 | 55 | 56 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/pages/api/[[...slug]].js: -------------------------------------------------------------------------------- 1 | import packageJson from '../../../package.json' 2 | import manageRouter from '../../../app/manage' 3 | import { createBaseHandler } from '../../middlewares/base-handler' 4 | 5 | /** 6 | * This is a catch all handler for the API routes from v1 that weren't 7 | * migrated to the /src/page/api folder. The v1 routes are located in 8 | * /app/manage folder and should be migrated to v2 approach when possible. 9 | */ 10 | 11 | const handler = createBaseHandler() 12 | 13 | handler.get('api/', (_, res) => { 14 | res.status(200).json({ version: packageJson.version }) 15 | }) 16 | 17 | handler.options('api/*', (_, res) => { 18 | res.status(200).end() 19 | }) 20 | 21 | manageRouter(handler) 22 | 23 | export default handler 24 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].js: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth' 2 | import { mergeDeepRight } from 'ramda' 3 | const db = require('../../../lib/db') 4 | 5 | export const authOptions = { 6 | // Configure one or more authentication providers 7 | providers: [ 8 | { 9 | id: 'osm-teams', 10 | name: 'OSM Teams', 11 | type: 'oauth', 12 | wellKnown: `${process.env.HYDRA_URL}/.well-known/openid-configuration`, 13 | authorization: { params: { scope: 'openid offline' } }, 14 | idToken: true, 15 | async profile(profile) { 16 | return { 17 | id: profile.sub, 18 | name: profile.preferred_username, 19 | image: profile.picture, 20 | } 21 | }, 22 | clientId: process.env.OSM_TEAMS_CLIENT_ID, 23 | clientSecret: process.env.OSM_TEAMS_CLIENT_SECRET, 24 | }, 25 | ], 26 | callbacks: { 27 | async jwt({ token, account, profile }) { 28 | // Persist the OAuth access_token and or the user id to the token right after signin 29 | if (account) { 30 | token.accessToken = account.access_token 31 | token.userId = profile.sub 32 | } 33 | return token 34 | }, 35 | async session({ session, token }) { 36 | // Send properties to the client, like an access_token and user id from a provider. 37 | session.accessToken = token.accessToken 38 | session.user_id = token.userId 39 | return session 40 | }, 41 | }, 42 | 43 | pages: { 44 | signIn: '/signin', 45 | }, 46 | 47 | events: { 48 | async signIn({ profile }) { 49 | // On successful sign in we should persist the user to the database 50 | let [user] = await db('users').where('id', profile.id) 51 | if (user) { 52 | const newProfile = mergeDeepRight(user.profile, profile) 53 | await db('users') 54 | .where('id', profile.id) 55 | .update({ 56 | profile: JSON.stringify(newProfile), 57 | }) 58 | } else { 59 | await db('users').insert({ 60 | id: profile.id, 61 | profile: JSON.stringify(profile), 62 | }) 63 | } 64 | }, 65 | }, 66 | } 67 | 68 | export default NextAuth(authOptions) 69 | -------------------------------------------------------------------------------- /src/pages/api/introspect.js: -------------------------------------------------------------------------------- 1 | const { decode } = require('next-auth/jwt') 2 | /** 3 | * !! This function is only used for testing 4 | * purposes, it mocks the hydra access token introspection 5 | */ 6 | export default async function handler(req, res) { 7 | if (req.method === 'POST') { 8 | // Process a POST request 9 | const { token } = req.body 10 | const decodedToken = await decode({ 11 | token, 12 | secret: process.env.NEXT_AUTH_SECRET, 13 | }) 14 | 15 | const result = { 16 | active: true, 17 | sub: decodedToken.userId, 18 | } 19 | 20 | res.status(200).json(result) 21 | } else { 22 | res.status(400) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/api/my/teams.js: -------------------------------------------------------------------------------- 1 | import * as Yup from 'yup' 2 | import Team from '../../../models/team' 3 | import { createBaseHandler } from '../../../middlewares/base-handler' 4 | import { validate } from '../../../middlewares/validation' 5 | import isAuthenticated from '../../../middlewares/can/authenticated' 6 | 7 | const handler = createBaseHandler() 8 | 9 | /** 10 | * @swagger 11 | * /my/teams: 12 | * get: 13 | * summary: Get list of teams the logged user is member or moderator 14 | * tags: 15 | * - teams 16 | * responses: 17 | * 200: 18 | * description: A list of teams. 19 | * content: 20 | * application/json: 21 | * schema: 22 | * type: object 23 | * properties: 24 | * pagination: 25 | * $ref: '#/components/schemas/Pagination' 26 | * data: 27 | * $ref: '#/components/schemas/ArrayOfTeams' 28 | */ 29 | handler.get( 30 | isAuthenticated, 31 | validate({ 32 | query: Yup.object({ 33 | page: Yup.number().min(0).integer(), 34 | }), 35 | }), 36 | async function (req, res) { 37 | const { page } = req.query 38 | const userId = req.session?.user_id 39 | 40 | return res.send( 41 | await Team.paginatedList({ 42 | osmId: Number(userId), 43 | page, 44 | includePrivate: true, 45 | }) 46 | ) 47 | } 48 | ) 49 | 50 | export default handler 51 | -------------------------------------------------------------------------------- /src/pages/api/organizations/[orgId]/locations.js: -------------------------------------------------------------------------------- 1 | import { createBaseHandler } from '../../../../middlewares/base-handler' 2 | import { validate } from '../../../../middlewares/validation' 3 | import Team from '../../../../models/team' 4 | import * as Yup from 'yup' 5 | import canViewOrgTeams from '../../../../middlewares/can/view-org-teams' 6 | 7 | const handler = createBaseHandler() 8 | 9 | /** 10 | * @swagger 11 | * /organizations/{id}/teams: 12 | * get: 13 | * summary: Get locations of teams of an organization 14 | * tags: 15 | * - organizations 16 | * parameters: 17 | * - in: path 18 | * name: id 19 | * required: true 20 | * description: Numeric ID of the organization the teams are part of. 21 | * schema: 22 | * type: integer 23 | * responses: 24 | * 200: 25 | * description: A list of teams. 26 | * content: 27 | * application/json: 28 | * schema: 29 | * type: object 30 | * properties: 31 | * data: 32 | * $ref: '#/components/schemas/ArrayOfTeams' 33 | */ 34 | handler.get( 35 | canViewOrgTeams, 36 | validate({ 37 | query: Yup.object({ 38 | orgId: Yup.number().required().positive().integer(), 39 | }).required(), 40 | }), 41 | async function (req, res) { 42 | const { orgId } = req.query 43 | const { 44 | org: { isMember, isOwner, isManager }, 45 | } = req 46 | const teamList = await Team.list({ 47 | organizationId: orgId, 48 | includePrivate: isMember || isManager || isOwner, 49 | }) 50 | 51 | return res.send({ data: teamList }) 52 | } 53 | ) 54 | 55 | export default handler 56 | -------------------------------------------------------------------------------- /src/pages/api/organizations/[orgId]/members.js: -------------------------------------------------------------------------------- 1 | import { createBaseHandler } from '../../../../middlewares/base-handler' 2 | import { validate } from '../../../../middlewares/validation' 3 | import Organization from '../../../../models/organization' 4 | import Team from '../../../../models/team' 5 | import * as Yup from 'yup' 6 | import canViewOrgMembers from '../../../../middlewares/can/view-org-members' 7 | import { map, prop } from 'ramda' 8 | 9 | const handler = createBaseHandler() 10 | 11 | /** 12 | * @swagger 13 | * /organizations/{id}/members: 14 | * get: 15 | * summary: Get list of members of an organization 16 | * tags: 17 | * - organizations 18 | * parameters: 19 | * - in: path 20 | * name: id 21 | * required: true 22 | * description: Organization id 23 | * schema: 24 | * type: integer 25 | * responses: 26 | * 200: 27 | * description: A list of members 28 | * content: 29 | * application/json: 30 | * schema: 31 | * type: object 32 | * properties: 33 | * pagination: 34 | * $ref: '#/components/schemas/Pagination' 35 | * data: 36 | * $ref: '#/components/schemas/TeamMemberList' 37 | */ 38 | handler.get( 39 | canViewOrgMembers, 40 | validate({ 41 | query: Yup.object({ 42 | orgId: Yup.number().required().positive().integer(), 43 | page: Yup.number().min(0).integer(), 44 | perPage: Yup.number().min(1).max(100).integer(), 45 | search: Yup.string(), 46 | sort: Yup.mixed().oneOf(['name', 'id']), 47 | order: Yup.mixed().oneOf(['asc', 'desc']), 48 | }).required(), 49 | }), 50 | async function (req, res) { 51 | const { orgId, page, search, sort, order } = req.query 52 | 53 | let members = await Organization.getMembersPaginated(orgId, { 54 | page, 55 | search, 56 | sort, 57 | order, 58 | }) 59 | 60 | const memberIds = map(prop('osm_id'), members) 61 | if (memberIds.length > 0) { 62 | members = await Team.resolveMemberNames(memberIds) 63 | } 64 | 65 | return res.send(members) 66 | } 67 | ) 68 | 69 | export default handler 70 | -------------------------------------------------------------------------------- /src/pages/api/organizations/[orgId]/staff.js: -------------------------------------------------------------------------------- 1 | import { createBaseHandler } from '../../../../middlewares/base-handler' 2 | import { validate } from '../../../../middlewares/validation' 3 | import Organization from '../../../../models/organization' 4 | import * as Yup from 'yup' 5 | import canViewOrgMembers from '../../../../middlewares/can/view-org-members' 6 | 7 | const handler = createBaseHandler() 8 | 9 | /** 10 | * @swagger 11 | * /organizations/{id}/staff: 12 | * get: 13 | * summary: Get list of staff of an organization 14 | * tags: 15 | * - organizations 16 | * parameters: 17 | * - in: path 18 | * name: id 19 | * required: true 20 | * description: Organization id 21 | * schema: 22 | * type: integer 23 | * responses: 24 | * 200: 25 | * description: A list of members 26 | * content: 27 | * application/json: 28 | * schema: 29 | * type: object 30 | * properties: 31 | * pagination: 32 | * $ref: '#/components/schemas/Pagination' 33 | * data: 34 | * $ref: '#/components/schemas/TeamMemberList' 35 | */ 36 | handler.get( 37 | canViewOrgMembers, 38 | validate({ 39 | query: Yup.object({ 40 | orgId: Yup.number().required().positive().integer().required(), 41 | page: Yup.number().min(0).integer(), 42 | perPage: Yup.number().min(1).max(100).integer(), 43 | search: Yup.string(), 44 | sort: Yup.mixed().oneOf(['id', 'name', 'type']), 45 | order: Yup.mixed().oneOf(['asc', 'desc']), 46 | }).required(), 47 | }), 48 | async function (req, res) { 49 | const { orgId, page, perPage, search, sort, order } = req.query 50 | 51 | const staff = await Organization.getOrgStaffPaginated(orgId, { 52 | page, 53 | perPage, 54 | search, 55 | sort, 56 | order, 57 | }) 58 | 59 | return res.send(staff) 60 | } 61 | ) 62 | 63 | export default handler 64 | -------------------------------------------------------------------------------- /src/pages/api/organizations/[orgId]/teams.js: -------------------------------------------------------------------------------- 1 | import { createBaseHandler } from '../../../../middlewares/base-handler' 2 | import { validate } from '../../../../middlewares/validation' 3 | import Organization from '../../../../models/organization' 4 | import Team from '../../../../models/team' 5 | import Boom from '@hapi/boom' 6 | import * as Yup from 'yup' 7 | import canCreateOrgTeam from '../../../../middlewares/can/create-org-team' 8 | import canViewOrgTeams from '../../../../middlewares/can/view-org-teams' 9 | 10 | const handler = createBaseHandler() 11 | 12 | /** 13 | * @swagger 14 | * /organizations/{id}/teams: 15 | * post: 16 | * summary: Add a team to this organization. Only owners and managers can add new teams. 17 | * tags: 18 | * - organizations 19 | * parameters: 20 | * - in: path 21 | * name: id 22 | * required: true 23 | * description: Numeric ID of the organization the teams are part of. 24 | * schema: 25 | * type: integer 26 | * requestBody: 27 | * required: true 28 | * content: 29 | * application/json: 30 | * schema: 31 | * $ref: '#/components/schemas/NewTeam' 32 | * responses: 33 | * 200: 34 | * description: Team was added successfully 35 | * 400: 36 | * description: error creating team for organization 37 | * content: 38 | * application/json: 39 | * schema: 40 | * $ref: '#/components/schemas/ResponseError' 41 | */ 42 | handler.post( 43 | canCreateOrgTeam, 44 | validate({ 45 | query: Yup.object({ 46 | orgId: Yup.number().required().positive().integer(), 47 | }).required(), 48 | body: Yup.object({ 49 | name: Yup.string().required(), 50 | location: Yup.string(), 51 | }).required(), 52 | }), 53 | async (req, res) => { 54 | const { orgId } = req.query 55 | const { user_id: userId } = req.session 56 | const { name, location } = req.body 57 | 58 | const data = await Organization.createOrgTeam( 59 | orgId, 60 | { name, location }, 61 | userId 62 | ) 63 | return res.send(data) 64 | } 65 | ) 66 | 67 | /** 68 | * @swagger 69 | * /organizations/{id}/teams: 70 | * get: 71 | * summary: Get list of teams of an organization 72 | * tags: 73 | * - organizations 74 | * parameters: 75 | * - in: path 76 | * name: id 77 | * required: true 78 | * description: Numeric ID of the organization the teams are part of. 79 | * schema: 80 | * type: integer 81 | * responses: 82 | * 200: 83 | * description: A list of teams. 84 | * content: 85 | * application/json: 86 | * schema: 87 | * type: object 88 | * properties: 89 | * pagination: 90 | * $ref: '#/components/schemas/Pagination' 91 | * data: 92 | * $ref: '#/components/schemas/ArrayOfTeams' 93 | */ 94 | handler.get( 95 | canViewOrgTeams, 96 | validate({ 97 | query: Yup.object({ 98 | orgId: Yup.number().required().positive().integer(), 99 | page: Yup.number().min(0).integer(), 100 | perPage: Yup.number().min(1).max(100).integer(), 101 | search: Yup.string(), 102 | sort: Yup.mixed().oneOf(['name', 'members']), 103 | order: Yup.mixed().oneOf(['asc', 'desc']), 104 | }).required(), 105 | }), 106 | async function (req, res) { 107 | const { orgId, page, perPage, search, sort, order, bbox } = req.query 108 | const { 109 | org: { isMember, isOwner, isManager }, 110 | } = req 111 | 112 | let bounds = bbox || null 113 | if (bbox) { 114 | bounds = bbox.split(',').map((num) => parseFloat(num)) 115 | if (bounds.length !== 4) { 116 | throw Boom.badRequest('error in bbox param') 117 | } 118 | } 119 | 120 | return res.send( 121 | await Team.paginatedList({ 122 | organizationId: orgId, 123 | page, 124 | perPage, 125 | bbox: bounds, 126 | search, 127 | sort, 128 | order, 129 | includePrivate: isMember || isManager || isOwner, 130 | }) 131 | ) 132 | } 133 | ) 134 | 135 | export default handler 136 | -------------------------------------------------------------------------------- /src/pages/api/teams/[teamId]/index.js: -------------------------------------------------------------------------------- 1 | import { createBaseHandler } from '../../../../middlewares/base-handler' 2 | import { validate } from '../../../../middlewares/validation' 3 | import Team from '../../../../models/team' 4 | import Profile from '../../../../models/profile' 5 | import Boom from '@hapi/boom' 6 | import * as Yup from 'yup' 7 | import { prop, dissoc } from 'ramda' 8 | import canEditTeam from '../../../../middlewares/can/edit-team' 9 | 10 | const handler = createBaseHandler() 11 | 12 | /** 13 | * @swagger 14 | * /teams/{id}: 15 | * get: 16 | * summary: Get a team's metadata 17 | * tags: 18 | * - teams 19 | * parameters: 20 | * - in: path 21 | * name: id 22 | * required: true 23 | * description: Team id 24 | * schema: 25 | * type: integer 26 | * responses: 27 | * 200: 28 | * description: A list of members 29 | * content: 30 | * application/json: 31 | * schema: 32 | * $ref: '#/components/schemas/Team' 33 | * 34 | */ 35 | handler.get( 36 | validate({ 37 | query: Yup.object({ 38 | teamId: Yup.number().required().positive().integer(), 39 | }).required(), 40 | }), 41 | async function (req, res) { 42 | const { teamId } = req.query 43 | const teamData = await Team.get(teamId) 44 | 45 | if (!teamData) { 46 | throw Boom.notFound() 47 | } 48 | 49 | const associatedOrg = await Team.associatedOrg(teamId) 50 | let responseObject = Object.assign({}, teamData, { org: associatedOrg }) 51 | responseObject.requesterIsMember = false 52 | 53 | const requesterId = req.session?.user_id 54 | if (requesterId) { 55 | const isMember = await Team.isMember(teamId, requesterId) 56 | if (isMember) { 57 | responseObject.requesterIsMember = true 58 | } 59 | } 60 | 61 | return res.send(responseObject) 62 | } 63 | ) 64 | 65 | /** 66 | * @swagger 67 | * /teams/{id}: 68 | * put: 69 | * summary: Update team metadata 70 | * tags: 71 | * - teams 72 | * parameters: 73 | * - in: path 74 | * name: id 75 | * required: true 76 | * description: Team id 77 | * schema: 78 | * type: integer 79 | * responses: 80 | * 200: 81 | * description: A list of members 82 | * content: 83 | * application/json: 84 | * schema: 85 | * $ref: '#/components/schemas/Team' 86 | * 87 | */ 88 | handler.put( 89 | canEditTeam, 90 | validate({ 91 | query: Yup.object({ 92 | teamId: Yup.number().required().positive().integer(), 93 | }).required(), 94 | body: Yup.object({ 95 | editing_policy: Yup.string().url(), 96 | }), 97 | }), 98 | async function updateTeam(req, res) { 99 | const { teamId } = req.query 100 | const body = req.body 101 | const tags = prop('tags', body) 102 | if (tags) { 103 | await Profile.setProfile(tags, 'team', teamId) 104 | } 105 | const teamData = dissoc('tags', body) 106 | let updatedTeam = {} 107 | if (teamData) { 108 | updatedTeam = await Team.update(teamId, teamData) 109 | } 110 | res.send(updatedTeam) 111 | } 112 | ) 113 | 114 | /** 115 | * @swagger 116 | * /teams/{id}: 117 | * delete: 118 | * summary: Update team metadata 119 | * tags: 120 | * - teams 121 | * parameters: 122 | * - in: path 123 | * name: id 124 | * required: true 125 | * description: Team id 126 | * schema: 127 | * type: integer 128 | * responses: 129 | * 200: 130 | * description: The resource was deleted successfully 131 | */ 132 | handler.delete( 133 | canEditTeam, 134 | validate({ 135 | query: Yup.object({ 136 | teamId: Yup.number().required().positive().integer(), 137 | }).required(), 138 | }), 139 | async function destroyTeam(req, res) { 140 | const { teamId } = req.query 141 | await Team.destroy(teamId) 142 | return res.status(200).send() 143 | } 144 | ) 145 | 146 | export default handler 147 | -------------------------------------------------------------------------------- /src/pages/api/teams/[teamId]/members.js: -------------------------------------------------------------------------------- 1 | import { createBaseHandler } from '../../../../middlewares/base-handler' 2 | import { validate } from '../../../../middlewares/validation' 3 | import Team from '../../../../models/team' 4 | import * as Yup from 'yup' 5 | import { split, includes } from 'ramda' 6 | import canViewTeamMembers from '../../../../middlewares/can/view-team-members' 7 | import canEditTeam from '../../../../middlewares/can/edit-team' 8 | import Boom from '@hapi/boom' 9 | import logger from '../../../../lib/logger' 10 | 11 | const handler = createBaseHandler() 12 | 13 | const fieldsTransform = Yup.string().transform(split(',')) 14 | 15 | /** 16 | * @swagger 17 | * /teams/{id}/members: 18 | * get: 19 | * summary: Get members in a team 20 | * tags: 21 | * - teams 22 | * parameters: 23 | * - in: path 24 | * name: id 25 | * required: true 26 | * description: Team id 27 | * schema: 28 | * type: integer 29 | * responses: 30 | * 200: 31 | * description: A list of members 32 | * content: 33 | * application/json: 34 | * schema: 35 | * type: object 36 | * properties: 37 | * pagination: 38 | * $ref: '#/components/schemas/Pagination' 39 | * data: 40 | * $ref: '#/components/schemas/TeamMemberList' 41 | */ 42 | handler.get( 43 | canViewTeamMembers, 44 | validate({ 45 | query: Yup.object({ 46 | teamId: Yup.number().required().positive().integer(), 47 | fields: fieldsTransform.isType(Yup.array().of(Yup.string().min(1))), 48 | }).required(), 49 | }), 50 | async function getMembers(req, res) { 51 | const { teamId, fields, page, search, sort, order } = req.query 52 | 53 | const parsedFields = split(',', fields || '') 54 | const includeBadges = parsedFields && includes('badges', parsedFields) 55 | const members = await Team.getMembersPaginated(teamId, { 56 | badges: includeBadges, 57 | page, 58 | search, 59 | sort, 60 | order, 61 | }) 62 | 63 | let responseObject = Object.assign({}, { teamId }, { members }) 64 | 65 | return res.send(responseObject) 66 | } 67 | ) 68 | 69 | /** 70 | * @swagger 71 | * /teams/{id}/members: 72 | * patch: 73 | * summary: Update members in a team 74 | * tags: 75 | * - teams 76 | * parameters: 77 | * - in: path 78 | * name: id 79 | * required: true 80 | * description: Team id 81 | * schema: 82 | * type: integer 83 | * responses: 84 | * 200: 85 | * description: Members updated successfully 86 | */ 87 | handler.patch( 88 | canEditTeam, 89 | validate({ 90 | query: Yup.object({ 91 | teamId: Yup.number().required().positive().integer(), 92 | }), 93 | body: Yup.object({ 94 | add: Yup.array(), 95 | remove: Yup.array(), 96 | }).test( 97 | 'at-least-add-or-remove', 98 | 'You must provide osm ids', 99 | (value) => !!(value.add || value.remove) 100 | ), 101 | }), 102 | async function updateMembers(req, res) { 103 | const { teamId } = req.query 104 | const { add, remove } = req.body 105 | 106 | try { 107 | let members = [] 108 | if (add) { 109 | members = members.concat(add) 110 | } 111 | if (remove) { 112 | members = members.concat(remove) 113 | } 114 | // Check if these are OSM users 115 | await Team.resolveMemberNames(members) 116 | 117 | await Team.updateMembers(teamId, add, remove) 118 | return res.status(200).send() 119 | } catch (err) { 120 | logger.error(err) 121 | throw Boom.badRequest(err.message) 122 | } 123 | } 124 | ) 125 | 126 | export default handler 127 | -------------------------------------------------------------------------------- /src/pages/api/teams/[teamId]/moderators.js: -------------------------------------------------------------------------------- 1 | import { createBaseHandler } from '../../../../middlewares/base-handler' 2 | import { validate } from '../../../../middlewares/validation' 3 | import Team from '../../../../models/team' 4 | import * as Yup from 'yup' 5 | import canViewTeamMembers from '../../../../middlewares/can/view-team-members' 6 | const handler = createBaseHandler() 7 | 8 | /** 9 | * @swagger 10 | * /teams/{id}/moderators: 11 | * get: 12 | * summary: Get moderators in a team 13 | * tags: 14 | * - teams 15 | * parameters: 16 | * - in: path 17 | * name: id 18 | * required: true 19 | * description: Team id 20 | * schema: 21 | * type: integer 22 | * responses: 23 | * 200: 24 | * description: A list of moderators 25 | * content: 26 | * application/json: 27 | * schema: 28 | * type: object 29 | * properties: 30 | * pagination: 31 | * $ref: '#/components/schemas/Pagination' 32 | * data: 33 | * $ref: '#/components/schemas/TeamMemberList' 34 | */ 35 | handler.get( 36 | canViewTeamMembers, 37 | validate({ 38 | query: Yup.object({ 39 | teamId: Yup.number().required().positive().integer(), 40 | }).required(), 41 | }), 42 | async function (req, res) { 43 | const { teamId } = req.query 44 | 45 | const moderators = await Team.getModerators(teamId) 46 | 47 | return res.send(moderators) 48 | } 49 | ) 50 | 51 | export default handler 52 | -------------------------------------------------------------------------------- /src/pages/api/users/index.js: -------------------------------------------------------------------------------- 1 | var fetch = require('node-fetch') 2 | const join = require('url-join') 3 | import * as Yup from 'yup' 4 | 5 | import Users from '../../../models/users' 6 | import { createBaseHandler } from '../../../middlewares/base-handler' 7 | import { validate } from '../../../middlewares/validation' 8 | import isAuthenticated from '../../../middlewares/can/authenticated' 9 | 10 | const handler = createBaseHandler() 11 | 12 | /** 13 | * @swagger 14 | * /users: 15 | * get: 16 | * summary: Get OSM users by username 17 | * tags: 18 | * - users 19 | * responses: 20 | * 200: 21 | * description: A list of OSM users 22 | * content: 23 | * application/json: 24 | * schema: 25 | * type: object 26 | * properties: 27 | * users: 28 | * $ref: '#/components/schemas/TeamMemberList' 29 | */ 30 | handler.get( 31 | isAuthenticated, 32 | validate({ 33 | query: Yup.object({ 34 | search: Yup.string().required(), 35 | }).required(), 36 | }), 37 | async function getUsers(req, res) { 38 | const { search } = req.query 39 | let users = await Users.list({ username: search }) 40 | 41 | if (!users.length) { 42 | const resp = await fetch( 43 | join( 44 | process.env.OSM_API, 45 | `/api/0.6/changesets.json?display_name=${search}` 46 | ) 47 | ) 48 | if ([200, 304].includes(resp.status)) { 49 | const data = await resp.json() 50 | if (data.changesets) { 51 | const changeset = data.changesets[0] 52 | users = [ 53 | { 54 | id: changeset.uid, 55 | name: changeset.user, 56 | }, 57 | ] 58 | } 59 | } 60 | } 61 | 62 | let responseObject = Object.assign({}, { users }) 63 | 64 | return res.send(responseObject) 65 | } 66 | ) 67 | 68 | export default handler 69 | -------------------------------------------------------------------------------- /src/pages/clients.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import Clients from '../components/clients' 3 | 4 | export default class Profile extends Component { 5 | static async getInitialProps({ query }) { 6 | if (query) { 7 | return { 8 | token: query.access_token, 9 | } 10 | } 11 | } 12 | 13 | render() { 14 | return 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/dashboard.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Router from 'next/router' 3 | import { Box, Heading, Container, useToken } from '@chakra-ui/react' 4 | import join from 'url-join' 5 | import { useSession } from 'next-auth/react' 6 | import { getServerSession } from 'next-auth/next' 7 | 8 | import Table from '../components/tables/table' 9 | import Badge from '../components/badge' 10 | import { assoc, flatten, propEq, find } from 'ramda' 11 | import { listMyOrganizations } from '../models/organization' 12 | import TeamsTable from '../components/tables/teams' 13 | import { authOptions } from './api/auth/[...nextauth]' 14 | import InpageHeader from '../components/inpage-header' 15 | import { makeTitleCase } from '../../app/lib/utils' 16 | 17 | const URL = process.env.APP_URL 18 | 19 | function OrganizationsSection({ orgs }) { 20 | const [brand500, brand700, red600, red700, blue400] = useToken('colors', [ 21 | 'brand.500', 22 | 'brand.700', 23 | 'red.500', 24 | 'red.700', 25 | 'blue.400', 26 | ]) 27 | 28 | const roleBgColor = { 29 | member: brand500, 30 | moderator: red600, 31 | manager: brand700, 32 | owner: red700, 33 | undefined: blue400, 34 | } 35 | 36 | if (orgs.length === 0) { 37 | return

No orgs

38 | } 39 | 40 | const memberOrgs = orgs.memberOrgs.map(assoc('role', 'member')) 41 | const managerOrgs = orgs.managerOrgs.map(assoc('role', 'manager')) 42 | const ownerOrgs = orgs.ownerOrgs.map(assoc('role', 'owner')) 43 | 44 | let allOrgs = ownerOrgs 45 | managerOrgs.forEach((org) => { 46 | if (!find(propEq('id', org.id))(allOrgs)) { 47 | allOrgs.push(org) 48 | } 49 | }) 50 | memberOrgs.forEach((org) => { 51 | if (!find(propEq('id', org.id))(allOrgs)) { 52 | allOrgs.push(org) 53 | } 54 | }) 55 | 56 | return ( 57 |
( 66 | 67 | {makeTitleCase(role)} 68 | 69 | ), 70 | }, 71 | ]} 72 | onRowClick={(row) => { 73 | Router.push( 74 | join(URL, `/organizations?id=${row.id}`), 75 | join(URL, `/organizations/${row.id}`) 76 | ) 77 | }} 78 | /> 79 | ) 80 | } 81 | 82 | export default function Profile({ orgs }) { 83 | const { data: session, status } = useSession({ 84 | required: true, 85 | onUnauthenticated() { 86 | Router.push('/') 87 | }, 88 | }) 89 | 90 | if (status === 'loading') return null 91 | 92 | const hasOrgs = flatten(Object.values(orgs)).length > 0 93 | 94 | return ( 95 | 96 | 97 | 98 | Welcome, {session?.user.name} 99 | 100 | 101 | 102 | 103 | My Teams 104 | 105 | 106 | {hasOrgs ? ( 107 | 108 | My Organizations 109 | 110 | 111 | ) : null} 112 | 113 | 114 | ) 115 | } 116 | 117 | export async function getServerSideProps(ctx) { 118 | const session = await getServerSession(ctx.req, ctx.res, authOptions) 119 | const userId = session.user_id 120 | 121 | // Get orgs 122 | const orgs = await listMyOrganizations(userId) 123 | 124 | // Make sure response is JSON 125 | return JSON.parse(JSON.stringify({ props: { orgs } })) 126 | } 127 | -------------------------------------------------------------------------------- /src/pages/developers.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Box, Container, Heading, List, ListItem, Text } from '@chakra-ui/react' 3 | import InpageHeader from '../components/inpage-header' 4 | import Link from 'next/link' 5 | 6 | const URL = process.env.APP_URL 7 | 8 | class Developers extends Component { 9 | render() { 10 | return ( 11 | 12 | 13 | 14 | OSM Teams Developers Guide 15 | 16 | 17 | 18 | 19 | 20 | OSM Teams API builds a second authentication layer on top of the 21 | OSM id, providing OAuth2 access to a user’s teams. A user signs in 22 | through your app and clicks a “Connect Teams” button that will 23 | start the OAuth flow, sending them to our API site to grant access 24 | to their teams, returning with an access token your app can use to 25 | authenticate with the API 26 | 27 | Resources 28 | 29 | 30 | API Docs 31 | 32 | 33 | 38 | Example Node Integration 39 | 40 | 41 | 42 | 47 | Example Python Integration 48 | 49 | 50 | 51 | 52 | 53 | 54 | ) 55 | } 56 | } 57 | 58 | export default Developers 59 | -------------------------------------------------------------------------------- /src/pages/docs/api.js: -------------------------------------------------------------------------------- 1 | import { createSwaggerSpec } from 'next-swagger-doc' 2 | import dynamic from 'next/dynamic' 3 | import 'swagger-ui-react/swagger-ui.css' 4 | import nextSwaggerDocSpec from '../../../next-swagger-doc.json' 5 | 6 | const SwaggerUI = dynamic(import('swagger-ui-react'), { ssr: false }) 7 | 8 | function ApiDoc({ spec }) { 9 | return 10 | } 11 | 12 | export const getStaticProps = async () => { 13 | const spec = createSwaggerSpec(nextSwaggerDocSpec) 14 | 15 | return { 16 | props: { 17 | spec, 18 | }, 19 | } 20 | } 21 | 22 | export default ApiDoc 23 | -------------------------------------------------------------------------------- /src/pages/organizations/[id]/edit-privacy-policy.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import join from 'url-join' 3 | import { prop } from 'ramda' 4 | import Router from 'next/router' 5 | import { getOrg, updateOrgPrivacyPolicy } from '../../../lib/org-api' 6 | import PrivacyPolicyForm from '../../../components/privacy-policy-form' 7 | import logger from '../../../lib/logger' 8 | import Link from 'next/link' 9 | import { Box, Container, Heading } from '@chakra-ui/react' 10 | import InpageHeader from '../../../components/inpage-header' 11 | 12 | const APP_URL = process.env.APP_URL 13 | 14 | export default class OrgPrivacyPolicy extends Component { 15 | static async getInitialProps({ query }) { 16 | if (query) { 17 | return { 18 | // Organization id 19 | id: query.id, 20 | } 21 | } 22 | } 23 | 24 | constructor(props) { 25 | super(props) 26 | this.state = { 27 | loading: true, 28 | error: undefined, 29 | } 30 | 31 | this.getProfileForm = this.getProfileForm.bind(this) 32 | } 33 | 34 | async componentDidMount() { 35 | this.getProfileForm() 36 | } 37 | 38 | async getProfileForm() { 39 | const { id } = this.props 40 | try { 41 | let org = await getOrg(id) 42 | let privacyPolicy = prop('privacy_policy', org) 43 | this.setState({ 44 | orgId: id, 45 | privacyPolicy, 46 | loading: false, 47 | }) 48 | } catch (e) { 49 | logger.error(e) 50 | this.setState({ 51 | error: e, 52 | orgId: null, 53 | profileForm: [], 54 | loading: false, 55 | }) 56 | } 57 | } 58 | 59 | render() { 60 | const { privacyPolicy, orgId } = this.state 61 | if (!orgId) return null 62 | 63 | const defaultValues = { 64 | body: 'OSM Teams has the ability to collect additional information on registered users of OpenStreetMap. Exactly which types of information are collected is determined by Organization and/or Team moderators. OSM Teams will never sell or share user information directly from the database. The use of any information submitted by a member of a team or organization is at the full discretion of the team or organization moderator.', 65 | consentText: 66 | 'I understand the associated risks of using and entering my information on OSM Teams.', 67 | } 68 | 69 | let initialValues = privacyPolicy || defaultValues 70 | 71 | return ( 72 | 73 | 74 | 75 | ← Back to Edit Organization 76 | 77 | Editing Organization Privacy Policy 78 | 79 | 80 | 81 | { 84 | try { 85 | await updateOrgPrivacyPolicy(orgId, values) 86 | actions.setSubmitting(false) 87 | Router.push(join(APP_URL, `/organizations/${orgId}/edit`)) 88 | } catch (e) { 89 | logger.error(e) 90 | actions.setSubmitting(false) 91 | actions.setStatus(e.message) 92 | } 93 | }} 94 | /> 95 | 96 | 97 | 98 | ) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/pages/organizations/[id]/maintenance.js: -------------------------------------------------------------------------------- 1 | export default function MaintenancePage() { 2 | return
OSM Teams is under maintenance, please come back soon.
3 | } 4 | -------------------------------------------------------------------------------- /src/pages/organizations/[id]/profile.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ProfileForm from '../../../components/profile-form' 3 | 4 | export default class EditProfileForm extends Component { 5 | static async getInitialProps({ query }) { 6 | if (query) { 7 | return { 8 | id: query.id, 9 | formType: 'org', 10 | } 11 | } 12 | } 13 | 14 | constructor(props) { 15 | super(props) 16 | this.state = { 17 | loading: true, 18 | error: undefined, 19 | } 20 | } 21 | 22 | render() { 23 | return 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/organizations/create.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import join from 'url-join' 3 | import Router from 'next/router' 4 | import { Box, Container, Heading } from '@chakra-ui/react' 5 | import EditOrgForm from '../../components/edit-org-form' 6 | import InpageHeader from '../../components/inpage-header' 7 | import { createOrg } from '../../lib/org-api' 8 | import logger from '../../lib/logger' 9 | 10 | const APP_URL = process.env.APP_URL 11 | 12 | export default class OrgCreate extends Component { 13 | render() { 14 | return ( 15 | 16 | 17 | Create New Organization 18 | 19 | 20 | 21 | { 23 | try { 24 | const org = await createOrg(values) 25 | actions.setSubmitting(false) 26 | Router.push(join(APP_URL, `organizations/${org.id}`)) 27 | } catch (e) { 28 | logger.error(e) 29 | actions.setSubmitting(false) 30 | // set the form errors actions.setErrors(e) 31 | actions.setStatus(e.message) 32 | } 33 | }} 34 | /> 35 | 36 | 37 | 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/signin.js: -------------------------------------------------------------------------------- 1 | import { signIn, getProviders } from 'next-auth/react' 2 | import { getServerSession } from 'next-auth/next' 3 | import { Box, Container, Heading, Text, Button } from '@chakra-ui/react' 4 | import InpageHeader from '../components/inpage-header' 5 | import { authOptions } from './api/auth/[...nextauth]' 6 | 7 | export default function SignIn() { 8 | return ( 9 | <> 10 | 11 | 12 | 13 | You are not signed in. 14 | 15 | 16 | 17 | 18 | 19 | Sorry, you need to be signed in to view this page. 20 | 21 | 24 | Still having problems? Contact a system administrator. 25 | 26 | 27 | 28 | 29 | ) 30 | } 31 | 32 | export async function getServerSideProps(context) { 33 | const session = await getServerSession(context.req, context.res, authOptions) 34 | 35 | // If the user is already logged in, redirect. 36 | // Note: Make sure not to redirect to the same page 37 | // To avoid an infinite loop! 38 | if (session) { 39 | return { redirect: { destination: '/' } } 40 | } 41 | 42 | const providers = await getProviders() 43 | 44 | return { 45 | props: { providers: providers ?? [] }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/pages/teams/[id]/profile.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ProfileForm from '../../../components/profile-form' 3 | 4 | export default class EditProfileForm extends Component { 5 | static async getInitialProps({ query }) { 6 | if (query) { 7 | return { 8 | id: query.id, 9 | formType: 'team', 10 | } 11 | } 12 | } 13 | 14 | constructor(props) { 15 | super(props) 16 | this.state = { 17 | loading: true, 18 | error: undefined, 19 | } 20 | } 21 | 22 | render() { 23 | return 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/teams/create.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import join from 'url-join' 3 | import Router from 'next/router' 4 | import { createTeam, createOrgTeam } from '../../lib/teams-api' 5 | import { dissoc } from 'ramda' 6 | import EditTeamForm from '../../components/edit-team-form' 7 | import { getServerSession } from 'next-auth/next' 8 | import { authOptions } from '../api/auth/[...nextauth]' 9 | import { getOrgStaff } from '../../models/organization' 10 | import logger from '../../lib/logger' 11 | import { Box, Container, Heading } from '@chakra-ui/react' 12 | import InpageHeader from '../../components/inpage-header' 13 | 14 | const APP_URL = process.env.APP_URL 15 | 16 | export default function TeamCreate({ staff }) { 17 | return ( 18 | 19 | 20 | Create New Team 21 | 22 | 23 | 24 | { 31 | try { 32 | let team 33 | if (values.organization) { 34 | team = await createOrgTeam( 35 | values.organization, 36 | dissoc('organization', values) 37 | ) 38 | } else { 39 | team = await createTeam(values) 40 | } 41 | actions.setSubmitting(false) 42 | Router.push(join(APP_URL, `/teams/${team.id}`)) 43 | } catch (e) { 44 | logger.error(e) 45 | actions.setSubmitting(false) 46 | // set the form errors actions.setErrors(e) 47 | actions.setStatus(e.message) 48 | } 49 | }} 50 | /> 51 | 52 | 53 | 54 | ) 55 | } 56 | 57 | export async function getServerSideProps(ctx) { 58 | const session = await getServerSession(ctx.req, ctx.res, authOptions) 59 | 60 | // Get organizations the user is part of 61 | const staff = await getOrgStaff({ osmId: session.user_id }) 62 | 63 | return { props: { staff } } 64 | } 65 | -------------------------------------------------------------------------------- /tests/api/cache.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const db = require('../../src/lib/db') 3 | const createAgent = require('../utils/create-agent') 4 | const { resetDb, disconnectDb } = require('../utils/db-helpers') 5 | 6 | let user1Agent 7 | test.before(async () => { 8 | await resetDb() 9 | user1Agent = await createAgent({ id: 1 }) 10 | }) 11 | 12 | test.after.always(disconnectDb) 13 | 14 | test('cache is filled when requesting team members', async (t) => { 15 | let team1 = await user1Agent 16 | .post('/api/teams') 17 | .send({ name: 'cache team 1' }) 18 | .expect(200) 19 | 20 | const team1Id = team1.body.id 21 | 22 | // Make a request to getMembers 23 | await user1Agent.get(`/api/teams/${team1Id}/members`).expect(200) 24 | 25 | const usernames = await db('osm_users').select() 26 | t.true(usernames.length > 0) 27 | }) 28 | -------------------------------------------------------------------------------- /tests/api/users.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { resetDb, disconnectDb } = require('../utils/db-helpers') 3 | const createAgent = require('../utils/create-agent') 4 | 5 | let user1Agent 6 | test.before(async () => { 7 | await resetDb() 8 | user1Agent = await createAgent({ id: 1 }) 9 | }) 10 | 11 | test.after.always(disconnectDb) 12 | 13 | test('list users', async (t) => { 14 | let res = await user1Agent.get('/api/users?search=wille').expect(200) 15 | 16 | t.deepEqual(res.body.users, [{ id: 360183, name: 'wille' }]) 17 | }) 18 | 19 | test('list users return empty array if user does not exist', async (t) => { 20 | let res = await user1Agent 21 | .get('/api/users?search=3562hshgh23123wille') 22 | .expect(200) 23 | 24 | t.deepEqual(res.body.users, []) 25 | }) 26 | -------------------------------------------------------------------------------- /tests/permissions/add-org-team.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const createAgent = require('../utils/create-agent') 3 | const { resetDb, disconnectDb } = require('../utils/db-helpers') 4 | 5 | let user1Agent 6 | let user2Agent 7 | let user3Agent 8 | let org1 9 | test.before(async () => { 10 | await resetDb() 11 | user1Agent = await createAgent({ id: 1 }) 12 | user2Agent = await createAgent({ id: 2 }) 13 | user3Agent = await createAgent({ id: 3 }) 14 | org1 = ( 15 | await user1Agent 16 | .post('/api/organizations') 17 | .send({ name: 'permissions org' }) 18 | ).body 19 | }) 20 | 21 | test.after.always(disconnectDb) 22 | 23 | /** 24 | * An org owner can create a team 25 | * We create an org with the user1 user 26 | * We check that user1 can create a team in the org 27 | * 28 | */ 29 | test('org owner can create a team', async (t) => { 30 | const teamName = 'org owner can create a team - team' 31 | 32 | const res = await user1Agent 33 | .post(`/api/organizations/${org1.id}/teams`) 34 | .send({ name: teamName }) 35 | 36 | t.is(res.statusCode, 200) 37 | }) 38 | 39 | /** 40 | * An org manager can create a team 41 | * We create an org with the user1 user and assign user2 42 | * as a manager. We check that user2 can create a team in the org 43 | * 44 | */ 45 | test('org manager can create a team', async (t) => { 46 | const teamName = 'org manager can create a team - team' 47 | 48 | // We create a manager role for user 2 49 | await user1Agent.put(`/api/organizations/${org1.id}/addManager/2`).expect(200) 50 | 51 | const res = await user2Agent 52 | .post(`/api/organizations/${org1.id}/teams`) 53 | .send({ name: teamName }) 54 | t.is(res.statusCode, 200) 55 | }) 56 | 57 | /** 58 | * An non-org manager cannot create a team 59 | * We create an org with the user1 user and check that a non 60 | * 61 | */ 62 | test('non-org manager cannot create a team', async (t) => { 63 | const teamName = 'non-org manager cannot create a team - team' 64 | 65 | const res = await user3Agent 66 | .post(`/api/organizations/${org1.id}/teams`) 67 | .send({ name: teamName }) 68 | 69 | t.is(res.statusCode, 401) 70 | }) 71 | -------------------------------------------------------------------------------- /tests/permissions/create-team.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const createAgent = require('../utils/create-agent') 3 | const { resetDb, disconnectDb } = require('../utils/db-helpers') 4 | 5 | test.before(resetDb) 6 | 7 | test.after.always(disconnectDb) 8 | 9 | test('an authenticated user can create a team', async (t) => { 10 | const agent = await createAgent({ id: 1 }) 11 | let res = await agent 12 | .post('/api/teams') 13 | .send({ name: 'road team 1' }) 14 | .expect(200) 15 | 16 | t.is(res.body.name, 'road team 1') 17 | }) 18 | 19 | test('an unauthenticated user cannot create a team', async (t) => { 20 | const agent = await createAgent() 21 | let res = await agent.post('/api/teams').send({ name: 'road team 2' }) 22 | 23 | t.is(res.status, 401) 24 | }) 25 | -------------------------------------------------------------------------------- /tests/permissions/delete-team.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const createAgent = require('../utils/create-agent') 3 | const { resetDb, disconnectDb } = require('../utils/db-helpers') 4 | 5 | let user1Agent 6 | let user2Agent 7 | test.before(async () => { 8 | await resetDb() 9 | user1Agent = await createAgent({ id: 1 }) 10 | user2Agent = await createAgent({ id: 2 }) 11 | }) 12 | 13 | test.after.always(disconnectDb) 14 | 15 | test('a team moderator can delete a team', async (t) => { 16 | let res = await user1Agent 17 | .post('/api/teams') 18 | .send({ name: 'road team 1' }) 19 | .expect(200) 20 | 21 | let res2 = await user1Agent.delete(`/api/teams/${res.body.id}`) 22 | 23 | t.is(res2.status, 200) 24 | }) 25 | 26 | test('a non-team moderator cannot delete a team', async (t) => { 27 | let res = await user1Agent 28 | .post('/api/teams') 29 | .send({ name: 'road team 2' }) 30 | .expect(200) 31 | 32 | let res2 = await user2Agent.delete(`/api/teams/${res.body.id}`) 33 | 34 | t.is(res2.status, 401) 35 | }) 36 | -------------------------------------------------------------------------------- /tests/permissions/edit-organization.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const db = require('../../src/lib/db') 3 | const createAgent = require('../utils/create-agent') 4 | const { resetDb, disconnectDb } = require('../utils/db-helpers') 5 | 6 | // Seed data 7 | const user1 = { id: 1 } 8 | const user2 = { id: 2 } 9 | 10 | // Helper variables 11 | let user1Agent 12 | let user2Agent 13 | let org1 14 | 15 | // Prepare environment 16 | test.before(async () => { 17 | await resetDb() 18 | 19 | // Create agents 20 | user1Agent = await createAgent(user1) 21 | user2Agent = await createAgent(user2) 22 | 23 | // Add seed data to db 24 | await db('users').insert(user1) 25 | await db('users').insert(user2) 26 | org1 = ( 27 | await user1Agent 28 | .post('/api/organizations') 29 | .send({ name: 'permissions org' }) 30 | ).body 31 | }) 32 | 33 | // Disconnect db 34 | test.after.always(disconnectDb) 35 | 36 | /** 37 | * An org owner can update the org 38 | * We create an org with the user100 and then 39 | * see if they can edit the organization 40 | * 41 | */ 42 | test('org owner can update an org', async (t) => { 43 | const orgName2 = 'org owner can update an org - org 2' 44 | const res = await user1Agent 45 | .put(`/api/organizations/${org1.id}`) 46 | .send({ name: orgName2 }) 47 | 48 | t.is(res.status, 200) 49 | }) 50 | 51 | /** 52 | * An org manager cannot update the org 53 | * We now add a manager and see if they can edit the organization 54 | * 55 | */ 56 | test('org manager cannot update an org', async (t) => { 57 | const orgName2 = 'org manager cannot update an org - org 2' 58 | 59 | // We create a manager role for user 2 60 | await user1Agent 61 | .put(`/api/organizations/${org1.id}/addManager/${user2.id}`) 62 | .expect(200) 63 | 64 | const res = await user2Agent 65 | .put(`/api/organizations/${org1.id}`) 66 | .send({ name: orgName2 }) 67 | 68 | t.is(res.status, 401) 69 | }) 70 | 71 | /** 72 | * regular user cannot update the org 73 | * We create an org and see if a user without a role can update it 74 | * 75 | */ 76 | test('no-role user cannot update an org', async (t) => { 77 | const orgName2 = 'no-role user cannot update an org - org 2' 78 | 79 | const res = await user2Agent 80 | .put(`/api/organizations/${org1.id}`) 81 | .send({ name: orgName2 }) 82 | 83 | t.is(res.status, 401) 84 | }) 85 | -------------------------------------------------------------------------------- /tests/permissions/update-team.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const createAgent = require('../utils/create-agent') 3 | const { resetDb, disconnectDb } = require('../utils/db-helpers') 4 | 5 | let user1Agent 6 | let user2Agent 7 | let user3Agent 8 | test.before(async () => { 9 | await resetDb() 10 | user1Agent = await createAgent({ id: 1 }) 11 | user2Agent = await createAgent({ id: 2 }) 12 | user3Agent = await createAgent({ id: 3 }) 13 | }) 14 | test.after.always(disconnectDb) 15 | 16 | test('a team moderator can update a team', async (t) => { 17 | let res = await user1Agent 18 | .post('/api/teams') 19 | .send({ name: 'road team 1' }) 20 | .expect(200) 21 | 22 | let res2 = await user1Agent 23 | .put(`/api/teams/${res.body.id}`) 24 | .send({ name: 'building team 1' }) 25 | 26 | t.is(res2.status, 200) 27 | }) 28 | 29 | test('a non-team moderator cannot update a team', async (t) => { 30 | let res = await user1Agent 31 | .post('/api/teams') 32 | .send({ name: 'road team 2' }) 33 | .expect(200) 34 | 35 | let res2 = await user2Agent 36 | .put(`/api/teams/${res.body.id}`) 37 | .send({ name: 'building team 2' }) 38 | 39 | t.is(res2.status, 401) 40 | }) 41 | 42 | test('an org team cannot be updated by non-org user', async (t) => { 43 | // Let's create an organization, user1 is the owner 44 | const res = await user1Agent 45 | .post('/api/organizations') 46 | .send({ name: 'org team cannot be updated by non-org user' }) 47 | .expect(200) 48 | 49 | // Let's set user2 to be a manager of this organization and create a 50 | // team in the organization 51 | await user1Agent 52 | .put(`/api/organizations/${res.body.id}/addManager/2`) 53 | .expect(200) 54 | 55 | const res2 = await user2Agent 56 | .post(`/api/organizations/${res.body.id}/teams`) 57 | .send({ name: 'org team cannot be updated by non-org user - team' }) 58 | .expect(200) 59 | 60 | // Use a different user from 1 or 2 to update the team 61 | const res3 = await user3Agent 62 | .put(`/api/teams/${res2.body.id}`) 63 | .send({ name: 'org team cannot be updated by non-org user - team2' }) 64 | 65 | t.is(res3.status, 401) 66 | }) 67 | 68 | test('an org team can be updated by the the org manager', async (t) => { 69 | // Let's create an organization, user1 is the owner 70 | const res = await user1Agent 71 | .post('/api/organizations') 72 | .send({ name: 'org manager can update team' }) 73 | .expect(200) 74 | 75 | // Let's set user2 to be a manager of this organization and create a 76 | // team in the organization 77 | await user1Agent 78 | .put(`/api/organizations/${res.body.id}/addManager/2`) 79 | .expect(200) 80 | 81 | const res2 = await user2Agent 82 | .post(`/api/organizations/${res.body.id}/teams`) 83 | .send({ name: 'org team can be updated by manager - team' }) 84 | .expect(200) 85 | 86 | // Use the manager to update the team 87 | const res3 = await user2Agent 88 | .put(`/api/teams/${res2.body.id}`) 89 | .send({ name: 'org team can be updated by manager - team2' }) 90 | 91 | t.is(res3.status, 200) 92 | }) 93 | 94 | test('an org team can be updated by the owner of the org', async (t) => { 95 | // Let's create an organization, user1 is the owner 96 | const res = await user1Agent 97 | .post('/api/organizations') 98 | .send({ name: 'org owner can update team' }) 99 | .expect(200) 100 | 101 | // Let's set user2 to be a manager of this organization and create a 102 | // team in the organization 103 | await user1Agent 104 | .put(`/api/organizations/${res.body.id}/addManager/2`) 105 | .expect(200) 106 | 107 | const res2 = await user2Agent 108 | .post(`/api/organizations/${res.body.id}/teams`) 109 | .send({ name: 'org team can be updated by owner - team' }) 110 | .expect(200) 111 | 112 | // user2 is the moderator and manager, but user1 should be able 113 | // to edit this team 114 | const res3 = await user1Agent 115 | .put(`/api/teams/${res2.body.id}`) 116 | .send({ name: 'org team can be updated by owner - team2' }) 117 | 118 | t.is(res3.status, 200) 119 | }) 120 | -------------------------------------------------------------------------------- /tests/permissions/view-team-members.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const createAgent = require('../utils/create-agent') 3 | const { resetDb, disconnectDb } = require('../utils/db-helpers') 4 | const Team = require('../../src/models/team') 5 | 6 | // Seed data 7 | const user1 = { id: 1 } 8 | 9 | // Helper variables 10 | let publicAgent 11 | let user1Agent 12 | let publicTeam 13 | let privateTeam 14 | 15 | // Prepare test environment 16 | test.before(async () => { 17 | await resetDb() 18 | 19 | // Create agents 20 | publicAgent = await createAgent() 21 | user1Agent = await createAgent(user1) 22 | 23 | // Create teams 24 | publicTeam = await Team.create({ name: 'public team' }, user1.id) 25 | privateTeam = await Team.create( 26 | { name: 'private team', privacy: 'private' }, 27 | user1.id 28 | ) 29 | }) 30 | 31 | // Close db connection 32 | test.after.always(disconnectDb) 33 | 34 | test('public team members are visible to unauthenticated users', async (t) => { 35 | let res = await publicAgent.get(`/api/teams/${publicTeam.id}/members`) 36 | t.is(res.status, 200) 37 | }) 38 | 39 | test('private team members are not visible to unauthenticated users', async (t) => { 40 | let res = await publicAgent.get(`/api/teams/${privateTeam.id}/members`) 41 | t.is(res.status, 401) 42 | }) 43 | 44 | test('private team members are visible to team moderators', async (t) => { 45 | let res = await user1Agent.get(`/api/teams/${privateTeam.id}/members`) 46 | t.is(res.status, 200) 47 | }) 48 | -------------------------------------------------------------------------------- /tests/permissions/view-team.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const createAgent = require('../utils/create-agent') 3 | const { resetDb, disconnectDb } = require('../utils/db-helpers') 4 | const Team = require('../../src/models/team') 5 | 6 | // Seed data 7 | const user1 = { id: 1 } 8 | 9 | // Helper variables 10 | let publicAgent 11 | let user1Agent 12 | let publicTeam 13 | let privateTeam 14 | 15 | // Prepare test environment 16 | test.before(async () => { 17 | await resetDb() 18 | 19 | // Create agents 20 | publicAgent = await createAgent() 21 | user1Agent = await createAgent(user1) 22 | 23 | // Create teams 24 | publicTeam = await Team.create({ name: 'public team' }, user1.id) 25 | privateTeam = await Team.create( 26 | { name: 'private team', privacy: 'private' }, 27 | user1.id 28 | ) 29 | }) 30 | 31 | // Close db connection 32 | test.after.always(disconnectDb) 33 | 34 | test('public teams are visible to unauthenticated users', async (t) => { 35 | let res = await publicAgent.get(`/api/teams/${publicTeam.id}`) 36 | t.is(res.status, 200) 37 | }) 38 | 39 | test('private team metadata are visible to unauthenticated users', async (t) => { 40 | let res = await publicAgent.get(`/api/teams/${privateTeam.id}`) 41 | t.is(res.status, 200) 42 | }) 43 | 44 | test('private team metadata are visible to team moderators', async (t) => { 45 | let res = await user1Agent.get(`/api/teams/${privateTeam.id}`) 46 | t.is(res.status, 200) 47 | }) 48 | -------------------------------------------------------------------------------- /tests/utils/create-agent.js: -------------------------------------------------------------------------------- 1 | const getSessionToken = require('./get-session-token') 2 | 3 | async function createAgent(user, http = false) { 4 | const agent = require('supertest').agent('http://localhost:3000') 5 | 6 | if (user) { 7 | const encryptedToken = await getSessionToken( 8 | user, 9 | process.env.NEXTAUTH_SECRET 10 | ) 11 | if (http) { 12 | agent.set('Authorization', `Bearer ${encryptedToken}`) 13 | } 14 | agent.set('Cookie', [`next-auth.session-token=${encryptedToken}`]) 15 | } 16 | 17 | return agent 18 | } 19 | 20 | module.exports = createAgent 21 | -------------------------------------------------------------------------------- /tests/utils/db-helpers.js: -------------------------------------------------------------------------------- 1 | const db = require('../../src/lib/db') 2 | 3 | async function resetDb() { 4 | await db.raw('TRUNCATE TABLE organization RESTART IDENTITY CASCADE') 5 | await db.raw('TRUNCATE TABLE team RESTART IDENTITY CASCADE') 6 | await db.raw('TRUNCATE TABLE users RESTART IDENTITY CASCADE') 7 | } 8 | 9 | async function disconnectDb() { 10 | return db.destroy() 11 | } 12 | 13 | module.exports = { 14 | resetDb, 15 | disconnectDb, 16 | } 17 | -------------------------------------------------------------------------------- /tests/utils/get-session-token.js: -------------------------------------------------------------------------------- 1 | const { hkdf } = require('@panva/hkdf') 2 | const { EncryptJWT } = require('jose') 3 | 4 | async function getSessionToken(userObj, secret) { 5 | const token = { ...userObj, userId: userObj.id } 6 | 7 | // Function logic derived from https://github.com/nextauthjs/next-auth/blob/5c1826a8d1f8d8c2d26959d12375704b0a693bfc/packages/next-auth/src/jwt/index.ts#L113-L121 8 | const encryptionSecret = await hkdf( 9 | 'sha256', 10 | secret, 11 | '', 12 | 'NextAuth.js Generated Encryption Key', 13 | 32 14 | ) 15 | 16 | // Function logic derived from https://github.com/nextauthjs/next-auth/blob/5c1826a8d1f8d8c2d26959d12375704b0a693bfc/packages/next-auth/src/jwt/index.ts#L16-L25 17 | const maxAge = 30 * 24 * 60 * 60 // 30 days 18 | return await new EncryptJWT(token) 19 | .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) 20 | .setIssuedAt() 21 | .setExpirationTime(Math.round(Date.now() / 1000 + maxAge)) 22 | .setJti('test') 23 | .encrypt(encryptionSecret) 24 | } 25 | 26 | module.exports = getSessionToken 27 | --------------------------------------------------------------------------------