├── .nvmrc
├── cypress
├── support
│ ├── e2e.js
│ ├── index.js
│ └── commands
│ │ └── login.js
└── e2e
│ ├── auth.cy.js
│ ├── teams
│ ├── index.cy.js
│ ├── invitations.cy.js
│ └── view.cy.js
│ ├── organizations
│ └── badges.cy.js
│ └── dashboard.cy.js
├── .env.development
├── public
└── static
│ ├── favicon.ico
│ ├── favicon.png
│ ├── osm_logo.png
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── guide
│ ├── explore.png
│ ├── new_org.png
│ ├── dashboard.png
│ ├── new_badge.png
│ ├── new_team.png
│ ├── org-edit.png
│ ├── org-page.png
│ ├── org-staff.png
│ ├── team-edit.png
│ ├── team-page.png
│ ├── badge-assign.png
│ ├── badge-edit.png
│ ├── new_org-team.png
│ ├── team-delete.png
│ ├── org-edit-teams.png
│ ├── team-join-link.png
│ ├── badge-assignment.png
│ ├── org-edit-members.png
│ ├── org-edit-privacy.png
│ ├── team-own-profile.png
│ ├── team_add-member.png
│ ├── team-member-actions.png
│ ├── team-member-profile.png
│ └── team-member-attributes.png
│ ├── neis-one-logo.png
│ ├── apple-touch-icon.png
│ ├── osmteams_logo--neg.png
│ ├── osmteams_logo--pos.png
│ ├── youthmappers-logo.jpeg
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── osmcha-logo.svg
│ ├── site.webmanifest
│ ├── osmteams_logo--neg.svg
│ └── osmteams_logo--pos.svg
├── .env
├── .prettierrc.json
├── ava.config.js
├── src
├── middleware.js
├── pages
│ ├── organizations
│ │ ├── [id]
│ │ │ ├── maintenance.js
│ │ │ ├── profile.js
│ │ │ └── edit-privacy-policy.js
│ │ └── create.js
│ ├── clients.js
│ ├── docs
│ │ └── api.js
│ ├── teams
│ │ ├── [id]
│ │ │ └── profile.js
│ │ └── create.js
│ ├── api
│ │ ├── introspect.js
│ │ ├── [[...slug]].js
│ │ ├── my
│ │ │ └── teams.js
│ │ ├── teams
│ │ │ └── [teamId]
│ │ │ │ ├── moderators.js
│ │ │ │ ├── members.js
│ │ │ │ └── index.js
│ │ ├── organizations
│ │ │ └── [orgId]
│ │ │ │ ├── locations.js
│ │ │ │ ├── staff.js
│ │ │ │ ├── members.js
│ │ │ │ └── teams.js
│ │ ├── users
│ │ │ └── index.js
│ │ └── auth
│ │ │ └── [...nextauth].js
│ ├── 404.js
│ ├── signin.js
│ ├── _app.js
│ ├── _document.js
│ ├── developers.js
│ ├── dashboard.js
│ └── consent.js
├── lib
│ ├── logger.js
│ ├── db.js
│ ├── badges-api.js
│ ├── utils.js
│ └── api-client.js
├── middlewares
│ ├── can
│ │ ├── authenticated.js
│ │ ├── view-org-members.js
│ │ ├── create-org-team.js
│ │ ├── edit-team.js
│ │ ├── view-team-members.js
│ │ └── view-org-teams.js
│ ├── validation.js
│ └── base-handler.js
├── models
│ ├── users.js
│ ├── team-invitation.js
│ └── badge.js
├── components
│ ├── banner.js
│ ├── inpage-header.js
│ ├── team-map.js
│ ├── badge.js
│ ├── Link.js
│ ├── add-member-modal.js
│ ├── list-map.js
│ ├── page-footer.js
│ ├── error-boundary.js
│ ├── form-map.js
│ ├── tables
│ │ ├── search-input.js
│ │ └── teams.js
│ ├── external-profile-button.js
│ ├── join-link.js
│ ├── profile-modal.js
│ ├── privacy-policy-form.js
│ ├── pagination.js
│ └── edit-org-form.js
└── hooks
│ └── use-fetch-list.js
├── .env.test
├── next-swagger-doc.json
├── .eslintrc
├── .gitignore
├── migrations
├── 20221121094350_drop-hashtag-uniqueness.js
├── 20220302135223_key-types.js
├── 20220125220402_org-privacy-policy.js
├── 20221216122421_username_table.js
├── 20211208161927_org_visibility.js
├── 20220415114820_invitations.js
├── 20190822135832_editing_policy.js
├── 20220222155039_add_badges.js
├── 20190805171532_team-name-required.js
├── 20220302104250_add_user_badges.js
├── 20190730183820_team-locations.js
├── 20220105182919_org_staff_visibility.js
├── 20200130155125_team-member-unique.js
├── 20200130150202_team-moderator-unique.js
├── 20200326113917_organization.js
├── 20210624112124_team_profiles.js
└── 20190417173534_init.js
├── knexfile.js
├── app
├── manage
│ ├── permissions
│ │ ├── delete-client.js
│ │ ├── edit-user.js
│ │ ├── member-team.js
│ │ ├── member-org.js
│ │ ├── edit-org.js
│ │ ├── clients.js
│ │ ├── join-team.js
│ │ ├── view-org-team-keys.js
│ │ ├── edit-team.js
│ │ ├── edit-key.js
│ │ └── index.js
│ ├── utils.js
│ ├── client.js
│ ├── sessions.js
│ └── login.js
├── oauth
│ ├── login.js
│ ├── index.js
│ └── consent.js
└── lib
│ └── utils.js
├── tests
├── utils
│ ├── db-helpers.js
│ ├── create-agent.js
│ └── get-session-token.js
├── permissions
│ ├── create-team.test.js
│ ├── delete-team.test.js
│ ├── view-team.test.js
│ ├── view-team-members.test.js
│ ├── add-org-team.test.js
│ ├── edit-organization.test.js
│ └── update-team.test.js
└── api
│ ├── users.test.js
│ └── cache.test.js
├── hydra-config
└── dev
│ └── hydra.yml
├── compose.yml
├── LICENSE
├── .github
└── workflows
│ └── test.yml
├── next.config.js
├── README.md
├── DEPLOYMENT.md
└── cypress.config.js
/.nvmrc:
--------------------------------------------------------------------------------
1 | 18
2 |
--------------------------------------------------------------------------------
/cypress/support/e2e.js:
--------------------------------------------------------------------------------
1 | import './commands/login'
2 |
--------------------------------------------------------------------------------
/.env.development:
--------------------------------------------------------------------------------
1 | NEXTAUTH_SECRET=next-auth-development-secret
--------------------------------------------------------------------------------
/cypress/support/index.js:
--------------------------------------------------------------------------------
1 | Cypress.Screenshot.defaults({
2 | screenshotOnRunFailure: false,
3 | })
4 |
--------------------------------------------------------------------------------
/public/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/favicon.ico
--------------------------------------------------------------------------------
/public/static/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/favicon.png
--------------------------------------------------------------------------------
/public/static/osm_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/osm_logo.png
--------------------------------------------------------------------------------
/public/static/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/favicon-16x16.png
--------------------------------------------------------------------------------
/public/static/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/favicon-32x32.png
--------------------------------------------------------------------------------
/public/static/guide/explore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/explore.png
--------------------------------------------------------------------------------
/public/static/guide/new_org.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/new_org.png
--------------------------------------------------------------------------------
/public/static/neis-one-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/neis-one-logo.png
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | NEXTAUTH_URL=http://127.0.0.1:3000
2 | DATABASE_URL=postgres://postgres:postgres@localhost:5433/osm-teams?sslmode=disable
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "tabWidth": 2,
4 | "jsxSingleQuote": true,
5 | "semi": false
6 | }
7 |
--------------------------------------------------------------------------------
/public/static/guide/dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/dashboard.png
--------------------------------------------------------------------------------
/public/static/guide/new_badge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/new_badge.png
--------------------------------------------------------------------------------
/public/static/guide/new_team.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/new_team.png
--------------------------------------------------------------------------------
/public/static/guide/org-edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/org-edit.png
--------------------------------------------------------------------------------
/public/static/guide/org-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/org-page.png
--------------------------------------------------------------------------------
/public/static/guide/org-staff.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/org-staff.png
--------------------------------------------------------------------------------
/public/static/guide/team-edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/team-edit.png
--------------------------------------------------------------------------------
/public/static/guide/team-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/team-page.png
--------------------------------------------------------------------------------
/public/static/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/static/guide/badge-assign.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/badge-assign.png
--------------------------------------------------------------------------------
/public/static/guide/badge-edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/badge-edit.png
--------------------------------------------------------------------------------
/public/static/guide/new_org-team.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/new_org-team.png
--------------------------------------------------------------------------------
/public/static/guide/team-delete.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/team-delete.png
--------------------------------------------------------------------------------
/public/static/osmteams_logo--neg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/osmteams_logo--neg.png
--------------------------------------------------------------------------------
/public/static/osmteams_logo--pos.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/osmteams_logo--pos.png
--------------------------------------------------------------------------------
/public/static/youthmappers-logo.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/youthmappers-logo.jpeg
--------------------------------------------------------------------------------
/public/static/guide/org-edit-teams.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/org-edit-teams.png
--------------------------------------------------------------------------------
/public/static/guide/team-join-link.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/team-join-link.png
--------------------------------------------------------------------------------
/ava.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | files: ['tests/**/*.test.js'],
3 | concurrency: 1,
4 | verbose: true,
5 | serial: true,
6 | }
7 |
--------------------------------------------------------------------------------
/public/static/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/static/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/static/guide/badge-assignment.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/badge-assignment.png
--------------------------------------------------------------------------------
/public/static/guide/org-edit-members.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/org-edit-members.png
--------------------------------------------------------------------------------
/public/static/guide/org-edit-privacy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/org-edit-privacy.png
--------------------------------------------------------------------------------
/public/static/guide/team-own-profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/team-own-profile.png
--------------------------------------------------------------------------------
/public/static/guide/team_add-member.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/team_add-member.png
--------------------------------------------------------------------------------
/public/static/guide/team-member-actions.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/team-member-actions.png
--------------------------------------------------------------------------------
/public/static/guide/team-member-profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/team-member-profile.png
--------------------------------------------------------------------------------
/public/static/guide/team-member-attributes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/osm-teams/HEAD/public/static/guide/team-member-attributes.png
--------------------------------------------------------------------------------
/src/middleware.js:
--------------------------------------------------------------------------------
1 | export { default } from 'next-auth/middleware'
2 |
3 | export const config = {
4 | matcher: ['/clients', '/organizations/:path*', '/dashboard'],
5 | }
6 |
--------------------------------------------------------------------------------
/src/pages/organizations/[id]/maintenance.js:
--------------------------------------------------------------------------------
1 | export default function MaintenancePage() {
2 | return
OSM Teams is under maintenance, please come back soon.
3 | }
4 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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-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 |
--------------------------------------------------------------------------------
/public/static/osmcha-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
24 | )
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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.
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
47 | )
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
63 | )
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/static/osmteams_logo--neg.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/public/static/osmteams_logo--pos.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | }
69 | onClick={onCopy}
70 | >
71 | {hasCopied ? 'Copied!' : 'Copy'}
72 |
73 |
74 |
75 | ) : (
76 |
79 | )}
80 |
81 | )
82 | }
83 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/pages/consent.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Button } from '@chakra-ui/react'
3 | import Link from 'next/link'
4 |
5 | class Consent extends Component {
6 | static async getInitialProps({ query }) {
7 | if (query) {
8 | return {
9 | user: query.user,
10 | client: query.client,
11 | challenge: query.challenge,
12 | requested_scope: query.requested_scope,
13 | }
14 | }
15 | }
16 |
17 | render() {
18 | const { user, client, requested_scope, challenge } = this.props
19 |
20 | if (!client) {
21 | return (
22 |
23 | Invalid parameters, go back home?
24 |
25 | )
26 | }
27 |
28 | const clientDisplayName = client.client_name || client.client_id
29 | return (
30 |
135 | )
136 | }
137 | }
138 |
139 | export default Consent
140 |
--------------------------------------------------------------------------------