diff --git a/.circleci/config.yml b/.circleci/config.yml index fc721a8c..f8fc9cfb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,7 +2,7 @@ version: 2 jobs: build: docker: - - image: circleci/node:12.16 + - image: circleci/node:14 environment: DATABASE_URL: postgres://postgres@localhost/osm-teams-test diff --git a/.env.sample b/.env.sample index c01fc381..d25f0d81 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,3 @@ OSM_CONSUMER_KEY= OSM_CONSUMER_SECRET= -DSN=postgres://postgres@host.docker.internal/osm-teams?sslmode=disable \ No newline at end of file +DSN=postgres://postgres@dev-db/osm-teams?sslmode=disable \ No newline at end of file diff --git a/.gitignore b/.gitignore index de597447..cc6430e4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ node_modules.nosync .idea hydra-config/prod/prod.yml .nyc_output -coverage \ No newline at end of file +coverage +docker-data/* \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index ae52e494..8351c193 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v12.16 +14 diff --git a/Dockerfile b/Dockerfile index 69584068..0160e504 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:lts +FROM node:14 # Create app directory WORKDIR /usr/src/app diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d24c62be --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Development Seed + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 002367cf..477a54e6 100644 --- a/README.md +++ b/README.md @@ -16,53 +16,30 @@ Check the beta 👉 https://mapping.team +## Development -## Installation +Install requirements: -### Requirements +- [nvm](https://github.com/creationix/nvm) +- [Docker](https://www.docker.com) -- [PostgreSQL](https://www.postgresql.org). On OS X, the easiest is to install [Postgres.app](https://postgresapp.com/). -- [Node.js](https://nodejs.org) v12.16+ -- [Docker](https://www.docker.com/) & Docker Compose +Visit your [OpenStreetMap settings](https://www.openstreetmap.org/account/edit) page and register an OAuth1 Client App: -### Setting up Hydra +![OSM Client App](oauth1-osm-client-app.png "OAuth1 page at OSM Website") -1. Create the database for tokens - - ```bash - createdb osm-teams - createdb osm-teams-test - ``` - - For the rest of this documentation, we will assume that the database location is `postgres://postgres@localhost/osm-teams?sslmode=disable` on your local machine. Inside docker, that location is `postgres://postgres@host.docker.internal/osm-teams?sslmode=disable` - -1. Create an `.env` file by copying `.env.sample` and replacing the values as needed. `OSM_CONSUMER_KEY` and `OSM_CONSUMER_SECRET` are values obtained by creating a new OAuth app on openstreetmap.org. The .env file can contain: +Create an `.env` file by copying `.env.sample` and replacing the values as needed. `OSM_CONSUMER_KEY` and `OSM_CONSUMER_SECRET` are values available at the OAuth app page on openstreetmap.org. The .env file should contain: ```bash OSM_CONSUMER_KEY= OSM_CONSUMER_SECRET= - DSN=postgres://postgres@host.docker.internal/osm-teams?sslmode=disable - ``` - -1. Build the docker images: - - ```bash - docker-compose -f compose.yml build - ``` - -1. Start Hydra and the server - - ```bash - docker-compose -f compose.yml -f compose.dev.yml up + DSN=postgres://postgres@dev-db/osm-teams?sslmode=disable ``` -⚠️ In development, `docker-compose -f compose.yml -f compose.dev.yml up` enables hot module reloading while you make modifications to the code. `docker-compose up` should be used for production/staging deployments. - -This will start hydra where the token issuer is at `http://localhost:4444` and the admin interface is at `http://localhost:4445`. This also sets up the consent and login interface at `http://localhost:8989` (where we will create a first-party oauth app) +Start Hydra and PostgreSQL with Docker: -### Setting up the OSM-teams app + docker-compose -f compose.dev.yml up --build -Create the [first-party](https://auth0.com/docs/applications/concepts/app-types-first-third-party) "manage" app +On a separate terminal, create the [first-party](https://auth0.com/docs/applications/concepts/app-types-first-third-party) "manage" app: ```bash docker-compose exec hydra hydra clients create --endpoint http://localhost:4445 \ @@ -74,6 +51,22 @@ docker-compose exec hydra hydra clients create --endpoint http://localhost:4445 --callbacks http://localhost:8989/login/accept ``` +Install Node.js the required version (see [.nvmrc](.nvmrc) file): + + nvm i + +Install Node.js modules: + + yarn + +Migrate `dev-db` database: + + yarn migrate + +Start development server: + + yarn dev + ✨ You can now login to the app at http://localhost:8989 diff --git a/app/db/knexfile.js b/app/db/knexfile.js index 3a1155c1..ae159414 100644 --- a/app/db/knexfile.js +++ b/app/db/knexfile.js @@ -1,16 +1,12 @@ -let DATABASE_URL +let DATABASE_URL = process.env.DSN || process.env.DATABASE_URL -if (!process.env.NODE_ENV) { - process.env.NODE_ENV = 'development' -} - -if (process.env.DSN) { - DATABASE_URL = process.env.DSN -} else { - if (process.env.NODE_ENV === 'development') { - DATABASE_URL = 'postgres://postgres@localhost/osm-teams?sslmode=disable' +// Apply defaults if no connection string is passed +if (!DATABASE_URL) { + if (!process.env.NODE_ENV || process.env.NODE_ENV === 'development') { + DATABASE_URL = + 'postgres://postgres:postgres@localhost:5433/osm-teams?sslmode=disable' } else if (process.env.NODE_ENV === 'test') { - DATABASE_URL = 'postgres://postgres@localhost/osm-teams-test?sslmode=disable' + DATABASE_URL = 'postgres://postgres:postgres@localhost:5434/osm-teams-test?sslmode=disable' } } diff --git a/app/db/migrations/20220125220402_org-privacy-policy.js b/app/db/migrations/20220125220402_org-privacy-policy.js new file mode 100644 index 00000000..7a788224 --- /dev/null +++ b/app/db/migrations/20220125220402_org-privacy-policy.js @@ -0,0 +1,12 @@ + +exports.up = async function (knex) { + await knex.schema.alterTable('organization', table => { + table.json('privacy_policy') + }) +} + +exports.down = async function (knex) { + await knex.schema.alterTable('organization', table => { + table.dropColumn('privacy_policy') + }) +} diff --git a/app/db/migrations/20220222155039_add_badges.js b/app/db/migrations/20220222155039_add_badges.js new file mode 100644 index 00000000..827f3dca --- /dev/null +++ b/app/db/migrations/20220222155039_add_badges.js @@ -0,0 +1,16 @@ +exports.up = async function (knex) { + await knex.schema.createTable('organization_badge', (table) => { + table.increments('id') + table + .integer('organization_id') + .references('id') + .inTable('organization') + .onDelete('CASCADE') + table.string('name').notNullable() + table.string('color').notNullable() + }) +} + +exports.down = async function (knex) { + await knex.schema.dropTable('organization_badge') +} diff --git a/app/db/migrations/20220302104250_add_user_badges.js b/app/db/migrations/20220302104250_add_user_badges.js new file mode 100644 index 00000000..bff7b27e --- /dev/null +++ b/app/db/migrations/20220302104250_add_user_badges.js @@ -0,0 +1,17 @@ +exports.up = async function (knex) { + await knex.schema.createTable('user_badges', (table) => { + table + .integer('badge_id') + .references('id') + .inTable('organization_badge') + .onDelete('CASCADE') + table.integer('user_id') + table.datetime('assigned_at').defaultTo(knex.fn.now()) + table.datetime('valid_until') + table.unique(['badge_id', 'user_id']) + }) +} + +exports.down = async function (knex) { + await knex.schema.dropTable('user_badges') +} diff --git a/app/db/migrations/20220302135223_key-types.js b/app/db/migrations/20220302135223_key-types.js new file mode 100644 index 00000000..6f57bca3 --- /dev/null +++ b/app/db/migrations/20220302135223_key-types.js @@ -0,0 +1,11 @@ +exports.up = async (knex) => { + return knex.schema.alterTable('profile_keys', table => { + table.text('key_type').defaultTo('text') + }) +} + +exports.down = async (knex) => { + return knex.schema.alterTable('profile_keys', table => { + table.dropColumn('key_type') + }) +} diff --git a/app/db/migrations/20220415114820_invitations.js b/app/db/migrations/20220415114820_invitations.js new file mode 100644 index 00000000..2d7726db --- /dev/null +++ b/app/db/migrations/20220415114820_invitations.js @@ -0,0 +1,13 @@ + +exports.up = async function (knex) { + return knex.schema.createTable('invitations', table => { + table.string('id') + table.integer('team_id').references('id').inTable('team').onDelete('CASCADE') + table.timestamp('created_at').defaultTo(knex.fn.now()) + table.timestamp('expires_at').nullable() + }) +} + +exports.down = async function (knex) { + return knex.schema.dropTable('invitations') +} diff --git a/app/index.js b/app/index.js index 0a1bfdb3..e2e299ca 100644 --- a/app/index.js +++ b/app/index.js @@ -1,3 +1,6 @@ +// Set server timezone to UTC to avoid issues with date parsing +process.env.TZ = 'UTC' + const path = require('path') const express = require('express') const bodyParser = require('body-parser') @@ -55,6 +58,9 @@ async function init () { * Error handler */ app.use(function (err, req, res, next) { + if (err.message === 'Forbidden') { + return nextApp.render(req, res, '/uh-oh') + } res.status(err.status || 500) console.error('error', err) res.boom.internal('An internal error occurred.') diff --git a/app/lib/organization.js b/app/lib/organization.js index 87f9354d..59c6c4e5 100644 --- a/app/lib/organization.js +++ b/app/lib/organization.js @@ -1,6 +1,6 @@ const db = require('../db') const team = require('./team') -const { map, prop, includes } = require('ramda') +const { map, prop, includes, has, isNil } = require('ramda') const { unpack, PropertyRequiredError } = require('./utils') // Organization attributes (without profile) @@ -10,6 +10,7 @@ const orgAttributes = [ 'description', 'privacy', 'teams_can_be_public', + 'privacy_policy', 'created_at', 'updated_at' ] @@ -113,9 +114,10 @@ async function destroy (id) { * @return {promise} */ async function update (id, data) { - if (!data.name) throw new Error('data.name property is required') - const conn = await db() + if (has('name', data) && isNil(prop('name', data))) { + throw new Error('data.name property is required') + } return unpack(conn('organization').where('id', id).update(data).returning(orgAttributes)) } @@ -263,6 +265,20 @@ async function isMemberOrStaff (organizationId, osmId) { return result.length > 0 } +/** + * Checks if an osmId is a moderator of any team inside the org + * @param {int} organizationId - organization id + * @param {int} osmId - id of member we are testing + */ +async function isOrgTeamModerator (organizationId, osmId) { + if (!organizationId) throw new PropertyRequiredError('organization id') + if (!osmId) throw new PropertyRequiredError('osm id') + const conn = await db() + const subquery = conn('organization_team').select('team_id').where('organization_id', organizationId) + const isModeratorOfAny = await conn('moderator').whereIn('team_id', subquery).debug() + return isModeratorOfAny.length > 0 +} + /** * Checks if the osm user is an owner of a team * @param {int} organizationId - organization id @@ -348,6 +364,7 @@ module.exports = { isOwner, isManager, isMember, + isOrgTeamModerator, createOrgTeam, listMyOrganizations, getOrgStaff, diff --git a/app/lib/osm.js b/app/lib/osm.js index a591965a..330192ad 100644 --- a/app/lib/osm.js +++ b/app/lib/osm.js @@ -4,6 +4,7 @@ * Route middleware to interact with OSM OAuth */ const passport = require('passport-light') +const R = require('ramda') const hydra = require('./hydra') const url = require('url') const db = require('../db') @@ -55,11 +56,12 @@ function openstreetmap (req, res) { let conn = await db() let [user] = await conn('users').where('id', profile.id) if (user) { + const newProfile = R.mergeDeepRight(user.profile, profile) await conn('users').where('id', profile.id).update( { 'osmToken': token, 'osmTokenSecret': tokenSecret, - 'profile': JSON.stringify(profile) + 'profile': JSON.stringify(newProfile) } ) } else { @@ -83,7 +85,7 @@ function openstreetmap (req, res) { hydra.acceptLoginRequest(challenge, { subject: user.id, remember: true, - remember_for: 9999 + remember_for: 0 }).then(response => { if (response.redirect_to) { return res.redirect(response.redirect_to) diff --git a/app/lib/profile.js b/app/lib/profile.js index 8ec5f42c..1b7d38d9 100644 --- a/app/lib/profile.js +++ b/app/lib/profile.js @@ -254,6 +254,25 @@ async function getUserManageToken (id) { return unpack(conn('users').select('manageToken').where('id', id).debug()) } +async function getUserBadges (id) { + const conn = await db() + return conn('user_badges') + .select([ + 'id', + 'assigned_at', + 'valid_until', + 'organization_id', + 'name', + 'color' + ]) + .leftJoin( + 'organization_badge', + 'user_badges.badge_id', + 'organization_badge.id' + ) + .where('user_badges.user_id', id) +} + module.exports = { addProfileKeys, modifyProfileKey, @@ -263,5 +282,6 @@ module.exports = { setProfile, getProfile, getTableForProfileType, - getUserManageToken + getUserManageToken, + getUserBadges } diff --git a/app/lib/team.js b/app/lib/team.js index f7951bb0..fceb10b9 100644 --- a/app/lib/team.js +++ b/app/lib/team.js @@ -3,7 +3,7 @@ const knexPostgis = require('knex-postgis') const join = require('url-join') const xml2js = require('xml2js') const { unpack } = require('./utils') -const { prop } = require('ramda') +const { prop, isEmpty } = require('ramda') const request = require('request-promise-native') const { serverRuntimeConfig } = require('../../next.config') @@ -414,6 +414,16 @@ async function associatedOrg (teamId) { ) } +async function isInvitationValid (teamId, invitationId) { + if (!teamId) throw new Error('team id is required as first argument') + if (!invitationId) throw new Error('invitation id is required as second argument') + + const conn = await db() + const invitations = await conn('invitations').where({ team_id: teamId, id: invitationId }) + + return !isEmpty(invitations) +} + module.exports = { get, list, @@ -433,6 +443,7 @@ module.exports = { isModerator, isMember, isPublic, + isInvitationValid, resolveMemberNames, associatedOrg } diff --git a/app/lib/utils.js b/app/lib/utils.js index 04318c3f..bc4b2c96 100644 --- a/app/lib/utils.js +++ b/app/lib/utils.js @@ -30,9 +30,21 @@ function checkRequiredProperties (requiredProperties, object) { }) } +/** + * Converts a date to the browser locale string + * + * @param {Number or String} timestamp + * @returns + */ +function toDateString (timestamp) { + const dateFormat = new Intl.DateTimeFormat(navigator.language).format + return dateFormat(new Date(timestamp)) +} + module.exports = { unpack, ValidationError, PropertyRequiredError, - checkRequiredProperties + checkRequiredProperties, + toDateString } diff --git a/app/manage/badges.js b/app/manage/badges.js new file mode 100644 index 00000000..a20b0e2a --- /dev/null +++ b/app/manage/badges.js @@ -0,0 +1,353 @@ +const db = require('../db') +const yup = require('yup') +const organization = require('../lib/organization') +const profile = require('../lib/profile') +const { routeWrapper } = require('./utils') +const team = require('../lib/team') + +/** + * Get the list of badges of an organization + */ +const listBadges = routeWrapper({ + validate: { + params: yup + .object({ + id: yup.number().required().positive().integer() + }) + .required() + }, + handler: async function (req, reply) { + try { + const conn = await db() + const badges = await conn('organization_badge') + .select('*') + .where('organization_id', req.params.id) + .orderBy('id') + reply.send(badges) + } catch (err) { + console.log(err) + return reply.boom.badRequest(err.message) + } + } +}) + +/** + * Create organization badge + */ +const createBadge = routeWrapper({ + validate: { + params: yup + .object({ + id: yup.number().required().positive().integer() + }) + .required(), + body: yup + .object({ + name: yup.string().required(), + color: yup.string().required() + }) + .required() + }, + handler: async function (req, reply) { + try { + const conn = await db() + const [badge] = await conn('organization_badge') + .insert({ + organization_id: req.params.id, + ...req.body + }) + .returning('*') + reply.send(badge) + } catch (err) { + console.log(err) + return reply.boom.badRequest(err.message) + } + } +}) + +/** + * Get organization badge + */ +const getBadge = routeWrapper({ + validate: { + params: yup + .object({ + id: yup.number().required().positive().integer(), + badgeId: yup.number().required().positive().integer() + }) + .required() + }, + handler: async function (req, reply) { + try { + const conn = await db() + const [badge] = await conn('organization_badge') + .select('*') + .where('id', req.params.badgeId) + .returning('*') + + let users = await conn('user_badges') + .select({ + id: 'user_badges.user_id', + assignedAt: 'user_badges.assigned_at', + validUntil: 'user_badges.valid_until' + }) + .leftJoin( + 'organization_badge', + 'user_badges.badge_id', + 'organization_badge.id' + ) + .where('badge_id', req.params.badgeId) + .returning('*') + + if (users.length > 0) { + // Get user profiles + const userProfiles = ( + await team.resolveMemberNames(users.map((u) => u.id)) + ).reduce((acc, u) => { + acc[u.id] = u + return acc + }, {}) + + users = users.map((u) => ({ + id: u.id, + assignedAt: u.assignedAt, + validUntil: u.validUntil, + displayName: userProfiles[u.id] ? userProfiles[u.id].name : '' + })) + } + + reply.send({ + ...badge, + users + }) + } catch (err) { + console.log(err) + return reply.boom.badRequest(err.message) + } + } +}) + +/** + * Edit organization badge + */ +const patchBadge = routeWrapper({ + validate: { + params: yup + .object({ + id: yup.number().required().positive().integer(), + badgeId: yup.number().required().positive().integer() + }) + .required(), + body: yup + .object({ + name: yup.string().optional(), + color: yup.string().optional() + }) + .required() + }, + handler: async function (req, reply) { + try { + const conn = await db() + const [badge] = await conn('organization_badge') + .update(req.body) + .where('id', req.params.badgeId) + .returning('*') + reply.send(badge) + } catch (err) { + console.log(err) + return reply.boom.badRequest(err.message) + } + } +}) + +/** + * Delete organization badge + */ +const deleteBadge = routeWrapper({ + validate: { + params: yup + .object({ + badgeId: yup.number().required().positive().integer() + }) + .required() + }, + handler: async function (req, reply) { + try { + const conn = await db() + await conn('organization_badge').delete().where('id', req.params.badgeId) + return reply.send({ + status: 200, + message: `Badge ${req.params.badgeId} deleted successfully.` + }) + } catch (err) { + console.log(err) + return reply.boom.badRequest(err.message) + } + } +}) + +/** + * Assign organization badge to an user + */ +const assignUserBadge = routeWrapper({ + validate: { + params: yup + .object({ + id: yup.number().required().positive().integer(), + badgeId: yup.number().required().positive().integer(), + userId: yup.number().required().positive().integer() + }) + .required(), + body: yup.object({ + assigned_at: yup.date().required(), + valid_until: yup.date().nullable() + }) + }, + handler: async function (req, reply) { + try { + const conn = await db() + + // user is related to org? + const isMemberOrStaff = await organization.isMemberOrStaff( + req.params.id, + req.params.userId + ) + + if (!isMemberOrStaff) { + return reply.boom.badRequest('User is not part of the organization.') + } + + // assign badge + const { assigned_at, valid_until } = req.body + const [badge] = await conn('user_badges') + .insert({ + user_id: req.params.userId, + badge_id: req.params.badgeId, + assigned_at: assigned_at.toISOString(), + valid_until: valid_until ? valid_until.toISOString() : null + }) + .returning('*') + + reply.send(badge) + } catch (err) { + console.log(err) + if (err.code === '23505') { + return reply.boom.badRequest('User is already assigned to badge.') + } else { + return reply.boom.badRequest( + 'Unexpected error, please try again later.' + ) + } + } + } +}) + +/** + * List badges of an user + */ +const listUserBadges = routeWrapper({ + validate: { + params: yup + .object({ + userId: yup.number().required().positive().integer() + }) + .required() + }, + handler: async function (req, reply) { + try { + const badges = await profile.getUserBadges(req.params.userId) + reply.send({ badges }) + } catch (err) { + console.log(err) + return reply.boom.badRequest(err.message) + } + } +}) + +/** + * Update a badge assigned to an user + */ +const updateUserBadge = routeWrapper({ + validate: { + params: yup + .object({ + badgeId: yup.number().required().positive().integer(), + userId: yup.number().required().positive().integer() + }) + .required(), + body: yup.object({ + assigned_at: yup.date().required(), + valid_until: yup.date().nullable() + }) + }, + handler: async function (req, reply) { + try { + const conn = await db() + + const { assigned_at, valid_until } = req.body + + // Yup validation returns time-zoned dates, update query use UTC strings + // to avoid that. + const [badge] = await conn('user_badges') + .update({ + assigned_at: assigned_at.toISOString(), + valid_until: valid_until ? valid_until.toISOString() : null + }) + .where({ + user_id: req.params.userId, + badge_id: req.params.badgeId + }) + .returning('*') + + reply.send(badge) + } catch (err) { + console.log(err) + return reply.boom.badRequest(err.message) + } + } +}) + +/** + * Remove badge assign to an user + */ +const removeUserBadge = routeWrapper({ + validate: { + params: yup + .object({ + badgeId: yup.number().required().positive().integer(), + userId: yup.number().required().positive().integer() + }) + .required() + }, + handler: async function (req, reply) { + try { + const conn = await db() + + // delete user badge + await conn('user_badges').delete().where({ + user_id: req.params.userId, + badge_id: req.params.badgeId + }) + + return reply.send({ + status: 200, + message: `Badge ${req.params.badgeId} unassigned successfully.` + }) + } catch (err) { + console.log(err) + return reply.boom.badRequest(err.message) + } + } +}) + +module.exports = { + listBadges, + createBadge, + getBadge, + patchBadge, + deleteBadge, + assignUserBadge, + listUserBadges, + updateUserBadge, + removeUserBadge +} diff --git a/app/manage/index.js b/app/manage/index.js index 3e517e03..d2a32aaf 100644 --- a/app/manage/index.js +++ b/app/manage/index.js @@ -20,7 +20,11 @@ const { removeMember, removeModerator, updateMembers, - updateTeam + updateTeam, + getJoinInvitations, + createJoinInvitation, + deleteJoinInvitation, + acceptJoinInvitation } = require('./teams') const { @@ -39,6 +43,18 @@ const { getOrgStaff } = require('./organizations') +const { + createBadge, + getBadge, + patchBadge, + deleteBadge, + listBadges, + assignUserBadge, + listUserBadges, + updateUserBadge, + removeUserBadge +} = require('./badges') + const { getUserTeamProfile, createProfileKeys, @@ -53,7 +69,8 @@ const { } = require('./profiles') const { getUserManageToken } = require('../lib/profile') -const organization = require('../lib/organization') +const orgModel = require('../lib/organization') +const teamModel = require('../lib/team') /** * The manageRouter handles all routes related to the first party @@ -108,6 +125,14 @@ function manageRouter (nextApp) { router.put('/api/teams/:id/assignModerator/:osmId', can('team:edit'), assignModerator) router.put('/api/teams/:id/removeModerator/:osmId', can('team:edit'), removeModerator) + /** + * Manage inviations to teams + */ + router.get('/api/teams/:id/invitations', can('team:edit'), getJoinInvitations) + router.post('/api/teams/:id/invitations', can('team:edit'), createJoinInvitation) + router.delete('/api/teams/:id/invitations/:uuid', can('team:edit'), deleteJoinInvitation) + router.post('/api/teams/:id/invitations/:uuid/accept', can('public:authenticated'), acceptJoinInvitation) + /** * List, Create, Read, Update, Delete operations on orgs */ @@ -126,7 +151,61 @@ function manageRouter (nextApp) { router.put('/api/organizations/:id/removeManager/:osmId', can('organization:edit'), removeManager) router.post('/api/organizations/:id/teams', can('organization:create-team'), createOrgTeam) - router.get('/api/organizations/:id/teams', getOrgTeams) + router.get('/api/organizations/:id/teams', can('organization:view-members'), getOrgTeams) + + /** + * Manage organization badges + */ + router.get( + '/api/organizations/:id/badges', + can('organization:edit'), + listBadges + ) + router.post( + '/api/organizations/:id/badges', + can('organization:edit'), + createBadge + ) + router.get( + '/api/organizations/:id/badges/:badgeId', + can('organization:edit'), + getBadge + ) + router.patch( + '/api/organizations/:id/badges/:badgeId', + can('organization:edit'), + patchBadge + ) + router.delete( + '/api/organizations/:id/badges/:badgeId', + can('organization:edit'), + deleteBadge + ) + + /** + * Manage user badges + */ + router.post( + '/api/organizations/:id/badges/:badgeId/assign/:userId', + can('organization:edit'), + assignUserBadge + ) + router.get( + '/api/user/:userId/badges', + can('public:authenticated'), + listUserBadges + ) + router.patch( + `/api/organizations/:id/member/:userId/badge/:badgeId`, + can('organization:edit'), + updateUserBadge + ) + + router.delete( + `/api/organizations/:id/member/:userId/badge/:badgeId`, + can('organization:edit'), + removeUserBadge + ) /** * List, Create, Read, Update, Delete operations on profiles @@ -142,7 +221,7 @@ function manageRouter (nextApp) { router.get('/api/profiles/keys/organizations/:id', can('organization:edit'), getProfileKeys('org', 'org')) router.post('/api/profiles/keys/organizations/:id', can('organization:edit'), createProfileKeys('org', 'org')) - router.get('/api/profiles/keys/organizations/:id/teams', can('organization:edit'), getProfileKeys('org', 'team')) + router.get('/api/profiles/keys/organizations/:id/teams', can('organization:view-team-keys'), getProfileKeys('org', 'team')) router.post('/api/profiles/keys/organizations/:id/teams', can('organization:edit'), createProfileKeys('org', 'team')) router.get('/api/profiles/keys/organizations/:id/users', can('organization:member'), getProfileKeys('org', 'user')) @@ -171,7 +250,7 @@ function manageRouter (nextApp) { }) router.get('/teams/create', can('public:authenticated'), async (req, res) => { - const staff = await organization.getOrgStaff({ osmId: Number(res.locals.user_id) }) + const staff = await orgModel.getOrgStaff({ osmId: Number(res.locals.user_id) }) return nextApp.render(req, res, '/team-create', { staff }) }) @@ -191,6 +270,19 @@ function manageRouter (nextApp) { return nextApp.render(req, res, '/profile-form', { id: req.params.id, formType: 'team' }) }) + router.get('/teams/:id/invitations/:uuid', async (req, res) => { + const teamId = req.params.id + const invitationId = req.params.uuid + const isInvitationValid = await teamModel.isInvitationValid(teamId, invitationId) + + if (!isInvitationValid) { + return res.sendStatus(404) + } + + const teamData = await teamModel.get(req.params.id) + return nextApp.render(req, res, '/invitation', { team_id: req.params.id, invitation_id: req.params.uuid, team: teamData }) + }) + router.get('/organizations/create', can('public:authenticated'), (req, res) => { return nextApp.render(req, res, '/org-create') }) @@ -207,14 +299,53 @@ function manageRouter (nextApp) { return nextApp.render(req, res, '/org-edit-profile', { id: req.params.id }) }) + router.get('/organizations/:id/edit-privacy-policy', can('organization:edit'), (req, res) => { + return nextApp.render(req, res, '/org-edit-privacy-policy', { id: req.params.id }) + }) + router.get('/organizations/:id/profile', can('organization:member'), (req, res) => { return nextApp.render(req, res, '/profile-form', { id: req.params.id, formType: 'org' }) }) - router.get('/organizations/:id/edit-team-profiles', can('organization:member'), (req, res) => { + router.get('/organizations/:id/edit-team-profiles', can('organization:edit'), (req, res) => { return nextApp.render(req, res, '/org-edit-team-profile', { id: req.params.id }) }) + /** + * Badge pages + * */ + router.get( + '/organizations/:id/badges/add', + can('organization:edit'), + (req, res) => { + return nextApp.render(req, res, '/badges/add', { id: req.params.id }) + } + ) + router.get( + '/organizations/:id/badges/:badgeId', + can('organization:edit'), + (req, res) => { + return nextApp.render(req, res, '/badges/edit', { + id: req.params.id, + badgeId: req.params.badgeId + }) + } + ) + + // New badge assignment + router.get( + '/organizations/:id/badges/assign/:userId', + can('organization:edit'), + (req, res) => nextApp.render(req, res, '/badges-assignment/new', req.params) + ) + + // Edit badge assignment + router.get( + '/organizations/:id/badges/:badgeId/assign/:userId', + can('organization:edit'), + (req, res) => nextApp.render(req, res, '/badges-assignment/edit', req.params) + ) + return router } diff --git a/app/manage/permissions/clients.js b/app/manage/permissions/clients.js index 5444fe4a..e2adaf8d 100644 --- a/app/manage/permissions/clients.js +++ b/app/manage/permissions/clients.js @@ -11,10 +11,14 @@ const db = require('../../db') * @returns {boolean} can the request go through? */ async function clients (uid) { - let conn = await db() - const [user] = await conn('users').where('id', uid) - if (user) { - return true + try { + let conn = await db() + const [user] = await conn('users').where('id', uid) + if (user) { + return true + } + } catch (error) { + throw Error('Forbidden') } } diff --git a/app/manage/permissions/index.js b/app/manage/permissions/index.js index 54295dfe..2cc6361a 100644 --- a/app/manage/permissions/index.js +++ b/app/manage/permissions/index.js @@ -26,7 +26,8 @@ const organizationPermissions = { 'organization:edit': require('./edit-org'), 'organization:create-team': require('./create-org-team'), 'organization:member': require('./member-org'), - 'organization:view-members': require('./view-org-members') + 'organization:view-members': require('./view-org-members'), + 'organization:view-team-keys': require('./view-org-team-keys') } const clientPermissions = { @@ -43,6 +44,10 @@ const permissions = mergeAll([ organizationPermissions ]) +function isApiRequest ({ path }) { + return path.indexOf('/api') === 0 +} + /** * Check if a user has a specific permission * @@ -159,18 +164,27 @@ function check (ability) { if (allowed) { next() } else { - res.boom.unauthorized('Forbidden') + if (isApiRequest(req)) { + res.boom.unauthorized('Forbidden') + } else { + next(new Error('Forbidden')) + } } } catch (e) { console.error('error checking permission', e) - // An error occurred checking the permissions - // if user id is missing it's an authentication problem - if (e.message.includes('osm id is required')) { - return res.boom.unauthorized('Forbidden') - } - // otherwise it could be the resource not existing, we send 404 - res.boom.notFound('Could not find resource') + if (isApiRequest(req)) { + // Handle API request errors + if (e.message.includes('osm id is required')) { + return res.boom.unauthorized('Forbidden') + } + + // otherwise it could be the resource not existing, we send 404 + res.boom.notFound('Could not find resource') + } else { + // This should be web page errors, which are handled at app/index.js#L60 + next(new Error('Forbidden')) + } } } } diff --git a/app/manage/permissions/view-org-team-keys.js b/app/manage/permissions/view-org-team-keys.js new file mode 100644 index 00000000..de3c5047 --- /dev/null +++ b/app/manage/permissions/view-org-team-keys.js @@ -0,0 +1,19 @@ +const { isOwner, isOrgTeamModerator } = require('../../lib/organization') + +/** + * organization:view-team-keys + * + * To edit an organization or delete it, the authenticated user needs + * to be an owner in the organization + * + * @param {int} uid - user id + * @param {Object} params - request parameters + * @param {int} params.id - organization id + * @returns {Promise} + */ +async function editOrg (uid, { id }) { + const teamModerator = await isOrgTeamModerator(id, uid) + return teamModerator || isOwner(id, uid) +} + +module.exports = editOrg diff --git a/app/manage/permissions/view-team-members.js b/app/manage/permissions/view-team-members.js index 28dedc8b..7386387a 100644 --- a/app/manage/permissions/view-team-members.js +++ b/app/manage/permissions/view-team-members.js @@ -1,4 +1,5 @@ -const { isPublic, isMember } = require('../../lib/team') +const { isPublic, isMember, associatedOrg } = require('../../lib/team') +const { isOwner } = require('../../lib/organization') /** * team:view-members @@ -15,7 +16,11 @@ async function viewTeamMembers (uid, { id }) { if (publicTeam) return publicTeam try { - return await isMember(id, uid) + const org = await associatedOrg(id) + const ownerOfTeam = org && (await isOwner(org.organization_id, uid)) + + // You can view the members if you're part of the team, or in case of an org team if you're the owner + return ownerOfTeam || await isMember(id, uid) } catch (e) { return false } diff --git a/app/manage/profiles.js b/app/manage/profiles.js index ba740009..7c3c46b1 100644 --- a/app/manage/profiles.js +++ b/app/manage/profiles.js @@ -271,13 +271,14 @@ function createProfileKeys (ownerType, profileType) { } try { - const attributesToAdd = body.map(({ name, description, required, visibility }) => { + const attributesToAdd = body.map(({ name, description, required, visibility, key_type }) => { return { name, description, required, visibility, - profileType + profileType, + key_type } }) diff --git a/app/manage/teams.js b/app/manage/teams.js index 149eaf16..bd5885aa 100644 --- a/app/manage/teams.js +++ b/app/manage/teams.js @@ -1,4 +1,8 @@ const team = require('../lib/team') +const db = require('../db') +const yup = require('yup') +const crypto = require('crypto') +const { routeWrapper } = require('./utils') const { prop, map, dissoc } = require('ramda') const urlRegex = require('url-regex') const { teamsMembersModeratorsHelper } = require('./utils') @@ -118,8 +122,11 @@ async function updateTeam (req, reply) { await profile.setProfile(tags, 'team', id) } const teamData = dissoc('tags', body) - const data = await team.update(id, teamData) - reply.send(data) + let updatedTeam = {} + if (teamData) { + updatedTeam = await team.update(id, teamData) + } + reply.send(updatedTeam) } catch (err) { console.log(err) return reply.boom.badRequest(err.message) @@ -253,6 +260,100 @@ async function removeMember (req, reply) { } } +const getJoinInvitations = routeWrapper({ + validate: { + params: yup.object({ + id: yup.number().required().positive().integer() + }).required() + }, + handler: async function (req, reply) { + try { + const conn = await db() + const invitations = await conn('invitations') + .select().where('team_id', req.params.id).orderBy('created_at', 'desc') // Most recent first + + reply.send(invitations) + } catch (e) { + console.error(e) + reply.boom.badRequest(e.message) + } + } +}) + +const createJoinInvitation = routeWrapper({ + validate: { + params: yup.object({ + id: yup.number().required().positive().integer() + }).required() + }, + handler: async function (req, reply) { + try { + const conn = await db() + const uuid = crypto.randomUUID() + const [invitation] = await conn('invitations').insert({ + id: uuid, + team_id: req.params.id + }).returning('*') + reply.send(invitation) + } catch (err) { + console.log(err) + return reply.boom.badRequest(err.message) + } + } +}) + +const deleteJoinInvitation = routeWrapper({ + validate: { + params: yup.object({ + id: yup.number().required().positive().integer(), + uuid: yup.string().uuid().required() + }).required() + }, + handler: async function (req, reply) { + try { + const conn = await db() + await conn('invitations').where({ + team_id: req.params.id, + id: req.params.uuid + }).del() + reply.sendStatus(200) + } catch (err) { + console.log(err) + return reply.boom.badRequest(err.message) + } + } +}) + +const acceptJoinInvitation = routeWrapper({ + validate: { + params: yup.object({ + id: yup.number().required().positive().integer(), + uuid: yup.string().uuid().required() + }).required() + }, + handler: async (req, reply) => { + const user = reply.locals.user_id + try { + const conn = await db() + const [invitation] = await conn('invitations').where({ + team_id: req.params.id, + id: req.params.uuid + }) + + // If this invitation doesn't exist, then it's not valid + if (!invitation) { + return reply.sendStatus(404) + } else { + team.addMember(req.params.id, user) + return reply.sendStatus(200) + } + } catch (err) { + console.log(err) + return reply.boom.badRequest(err.message) + } + } +}) + async function joinTeam (req, reply) { const { id } = req.params const osmId = reply.locals.user_id @@ -287,5 +388,9 @@ module.exports = { removeMember, removeModerator, updateMembers, - updateTeam + updateTeam, + getJoinInvitations, + createJoinInvitation, + deleteJoinInvitation, + acceptJoinInvitation } diff --git a/app/manage/utils.js b/app/manage/utils.js index 28eab9b1..6f144bf0 100644 --- a/app/manage/utils.js +++ b/app/manage/utils.js @@ -28,6 +28,34 @@ async function teamsMembersModeratorsHelper (teamsData) { }) } +/** + * Route wrapper to perform validation before processing + * the request. + * @param {function} config.validate Yup validation schema + * @param {function} config.handler Handler to execute if validation pass + * + * @returns {function} Route middleware function + */ +function routeWrapper (config) { + const { validate, handler } = config + return async (req, reply) => { + try { + if (validate.params) { + req.params = await validate.params.validate(req.params) + } + + if (validate.body) { + req.body = await validate.body.validate(req.body) + } + } catch (error) { + console.log(error) + reply.boom.badRequest(error) + } + await handler(req, reply) + } +} + module.exports = { - teamsMembersModeratorsHelper + teamsMembersModeratorsHelper, + routeWrapper } diff --git a/app/tests/api/badges-api.test.js b/app/tests/api/badges-api.test.js new file mode 100644 index 00000000..ea994f4f --- /dev/null +++ b/app/tests/api/badges-api.test.js @@ -0,0 +1,422 @@ +const test = require('ava') +const sinon = require('sinon') + +const db = require('../../db') +const hydra = require('../../lib/hydra') + +const { resetDb } = require('../utils') + +let app +let dbClient +let org1 +let orgTeam1 +let orgOwner = { + id: 1 +} +let orgManager = { + id: 2 +} +let orgTeamMember = { + id: 3 +} +let notOrgMember = { + id: 4 +} +let badge1, badge2, badge3 + +let introspectStub = sinon.stub(hydra, 'introspect') + +async function createUserAgent ({ id }) { + // Add user to db + await dbClient('users').insert({ id }) + + // Mock hydra auth + introspectStub.withArgs(`user${id}`).returns({ + active: true, + sub: `${id}` + }) + + // Return agent with auth token + return require('supertest') + .agent(app) + .set('Authorization', `Bearer user${id}`) +} + +test.before(async () => { + console.log('Connecting to test database...') + dbClient = await db() + + await resetDb(dbClient) + + console.log('Starting server...') + app = await require('../../index')() + + // Create user agents + console.log('Creating agents...') + orgOwner.agent = await createUserAgent(orgOwner) + orgManager.agent = await createUserAgent(orgManager) + orgTeamMember.agent = await createUserAgent(orgTeamMember) + notOrgMember.agent = await createUserAgent(notOrgMember) + + // Create organization + org1 = ( + await orgOwner.agent + .post('/api/organizations') + .send({ name: 'Organization 1' }) + .expect(200) + ).body + + // Create a team + orgTeam1 = ( + await orgOwner.agent + .post(`/api/organizations/${org1.id}/teams`) + .send({ name: 'Organization 1 - Team 1' }) + .expect(200) + ).body + + // Add team member + await orgOwner.agent + .put(`/api/teams/add/${orgTeam1.id}/${orgTeamMember.id}`) + .expect(200) + + // Add manager + await orgOwner.agent + .put(`/api/organizations/${org1.id}/addManager/${orgManager.id}`) + .expect(200) +}) + +test.after.always(async () => { + dbClient.destroy() +}) + +/** + * CREATE BADGE + */ +test('Create badge', async (t) => { + // Owners can create badges + badge1 = ( + await orgOwner.agent + .post(`/api/organizations/${org1.id}/badges`) + .send({ name: 'badge 1', color: 'red' }) + .expect(200) + ).body + + t.deepEqual(badge1, { + id: 1, + organization_id: 1, + name: 'badge 1', + color: 'red' + }) + + // Manager are not allowed + await orgManager.agent + .post(`/api/organizations/${org1.id}/badges`) + .send({ name: 'badge 1', color: 'red' }) + .expect(401) + + // Org Team Members are not allowed + await orgTeamMember.agent + .post(`/api/organizations/${org1.id}/badges`) + .send({ name: 'badge 1', color: 'red' }) + .expect(401) + + // Non-members are not-allowed + await notOrgMember.agent + .post(`/api/organizations/${org1.id}/badges`) + .send({ name: 'badge 1', color: 'red' }) + .expect(401) +}) + +/** + * PATCH BADGE + */ +test('Patch badge', async (t) => { + // Allow owners + let patchedBadge = ( + await orgOwner.agent + .patch(`/api/organizations/${org1.id}/badges/${badge1.id}`) + .send({ name: 'badge number 1', color: 'blue' }) + .expect(200) + ).body + + t.deepEqual(patchedBadge, { + id: 1, + organization_id: 1, + name: 'badge number 1', + color: 'blue' + }) + + // Disallow managers + await orgManager.agent + .patch(`/api/organizations/${org1.id}/badges/${badge1.id}`) + .send({ name: 'badge 1', color: 'red' }) + .expect(401) + + // Disallow org team Members + await orgTeamMember.agent + .patch(`/api/organizations/${org1.id}/badges/${badge1.id}`) + .send({ name: 'badge 1', color: 'red' }) + .expect(401) + + // Disallow non-members + await notOrgMember.agent + .patch(`/api/organizations/${org1.id}/badges/${badge1.id}`) + .send({ name: 'badge 1', color: 'red' }) + .expect(401) +}) + +/** + * LIST BADGES + */ +test('List badges', async (t) => { + // Add more badges + badge2 = (await orgOwner.agent + .post(`/api/organizations/${org1.id}/badges`) + .send({ name: 'badge number 2', color: 'green' }) + .expect(200)).body + + // Add more badges + badge3 = (await orgOwner.agent + .post(`/api/organizations/${org1.id}/badges`) + .send({ name: 'badge number 3', color: 'yellow' }) + .expect(200)).body + + // Add more badges + await orgOwner.agent + .post(`/api/organizations/${org1.id}/badges`) + .send({ name: 'badge number 4', color: 'pink' }) + .expect(200) + + // Owners can list badges + const badgesList = ( + await orgOwner.agent.get(`/api/organizations/${org1.id}/badges`).expect(200) + ).body + + t.deepEqual(badgesList, [ + { + id: 1, + organization_id: 1, + name: 'badge number 1', + color: 'blue' + }, + { + id: 2, + organization_id: 1, + name: 'badge number 2', + color: 'green' + }, + { + id: 3, + organization_id: 1, + name: 'badge number 3', + color: 'yellow' + }, + { + id: 4, + organization_id: 1, + name: 'badge number 4', + color: 'pink' + } + ]) +}) + +/** + * DELETE BADGE + */ +test('Delete badge', async (t) => { + // Disallow managers + await orgManager.agent + .delete(`/api/organizations/${org1.id}/badges/${badge1.id}`) + .expect(401) + + // Disallow org team Members + await orgTeamMember.agent + .delete(`/api/organizations/${org1.id}/badges/${badge1.id}`) + .expect(401) + + // Disallow non-members + await notOrgMember.agent + .delete(`/api/organizations/${org1.id}/badges/${badge1.id}`) + .expect(401) + + // Allow owners + await orgOwner.agent + .delete(`/api/organizations/${org1.id}/badges/${badge1.id}`) + .expect(200) + + // Check if badge list has changed + const badgesList = ( + await orgOwner.agent.get(`/api/organizations/${org1.id}/badges`).expect(200) + ).body + + t.deepEqual(badgesList, [ + { + id: 2, + organization_id: 1, + name: 'badge number 2', + color: 'green' + }, + { + id: 3, + organization_id: 1, + name: 'badge number 3', + color: 'yellow' + }, + { + id: 4, + organization_id: 1, + name: 'badge number 4', + color: 'pink' + } + ]) +}) + +/** + * ASSIGN BADGE + */ +test('Assign badge', async (t) => { + const assignBadgeRoute = `/api/organizations/${org1.id}/badges/${badge2.id}/assign/${orgTeamMember.id}` + + // Disallow managers + await orgManager.agent + .post(assignBadgeRoute) + .expect(401) + + // Disallow org team Members + await orgTeamMember.agent + .post(assignBadgeRoute) + .expect(401) + + // Disallow non-members + await notOrgMember.agent + .post(assignBadgeRoute) + .expect(401) + + // Allow owners + const badgeAssignment = (await orgOwner.agent + .post(`/api/organizations/${org1.id}/badges/${badge2.id}/assign/${orgTeamMember.id}`) + .send({ assigned_at: '2020-02-02T00:00:00.000Z', valid_until: '2020-05-05T00:00:00.000Z' }) + .expect(200)).body + + t.like(badgeAssignment, { + badge_id: badge2.id, + user_id: orgTeamMember.id, + assigned_at: '2020-02-02T00:00:00.000Z', + valid_until: '2020-05-05T00:00:00.000Z' + }) + t.falsy(badgeAssignment.assignedAt) +}) + +/** + * LIST USER BADGES + */ +test('List user badges', async (t) => { + // Assign badge 3 + await orgOwner.agent + .post(`/api/organizations/${org1.id}/badges/${badge3.id}/assign/${orgTeamMember.id}`) + .send({ assigned_at: '2020-07-07T00:00:00.000Z', valid_until: '2020-08-08T00:00:00.000Z' }) + .expect(200) + + const badges = (await orgManager.agent + .get(`/api/user/${orgTeamMember.id}/badges`) + .expect(200)).body + + t.like(badges, { badges: [ + { + id: 2, + organization_id: 1, + name: 'badge number 2', + color: 'green', + assigned_at: '2020-02-02T00:00:00.000Z', + valid_until: '2020-05-05T00:00:00.000Z' + }, + { + id: 3, + organization_id: 1, + name: 'badge number 3', + color: 'yellow', + assigned_at: '2020-07-07T00:00:00.000Z', + valid_until: '2020-08-08T00:00:00.000Z' + } + ] }) +}) + +/** + * UPDATE BADGE + */ +test('Update badge', async (t) => { + const updateBadgeRoute = `/api/organizations/${org1.id}/member/${orgTeamMember.id}/badge/${badge2.id}` + + // Disallow managers + await orgManager.agent + .patch(updateBadgeRoute) + .expect(401) + + // Disallow org team Members + await orgTeamMember.agent + .patch(updateBadgeRoute) + .expect(401) + + // Disallow non-members + await notOrgMember.agent + .patch(updateBadgeRoute) + .expect(401) + + // Allow owners + const badgeAssignment = (await orgOwner.agent + .patch(updateBadgeRoute) + .send({ + assigned_at: '2020-01-01Z', + valid_until: '2021-01-01Z' + }) + .expect(200)).body + + t.like(badgeAssignment, { + badge_id: badge2.id, + user_id: orgTeamMember.id, + assigned_at: '2020-01-01T00:00:00.000Z', + valid_until: '2021-01-01T00:00:00.000Z' + }) +}) + +/** + * REMOVE BADGE + */ +test('Remove badge', async (t) => { + const removeBadgeRoute = `/api/organizations/${org1.id}/member/${orgTeamMember.id}/badge/${badge2.id}` + + // Disallow managers + await orgManager.agent + .delete(removeBadgeRoute) + .expect(401) + + // Disallow org team Members + await orgTeamMember.agent + .delete(removeBadgeRoute) + .expect(401) + + // Disallow non-members + await notOrgMember.agent + .delete(removeBadgeRoute) + .expect(401) + + // Allow owners + await orgOwner.agent + .delete(removeBadgeRoute) + .expect(200) + + const badges = (await orgManager.agent + .get(`/api/user/${orgTeamMember.id}/badges`) + .expect(200)).body + + t.like(badges, { badges: [ + { + id: 3, + organization_id: 1, + name: 'badge number 3', + color: 'yellow', + assigned_at: '2020-07-07T00:00:00.000Z', + valid_until: '2020-08-08T00:00:00.000Z' + } + ] }) +}) diff --git a/app/tests/api/organization-api.test.js b/app/tests/api/organization-api.test.js index f6d88400..41388338 100644 --- a/app/tests/api/organization-api.test.js +++ b/app/tests/api/organization-api.test.js @@ -1,19 +1,18 @@ -const path = require('path') const test = require('ava') const sinon = require('sinon') const db = require('../../db') +const { resetDb } = require('../utils') const team = require('../../lib/team') const organization = require('../../lib/organization') const permissions = require('../../manage/permissions') -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - let agent test.before(async () => { const conn = await db() - await conn.migrate.latest({ directory: migrationsDirectory }) + + await resetDb(conn) // seed await conn('users').insert({ id: 1 }) @@ -36,12 +35,6 @@ test.before(async () => { agent = require('supertest').agent(await require('../../index')()) }) -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - /** * Test create an organization */ diff --git a/app/tests/api/organization-model.test.js b/app/tests/api/organization-model.test.js index 913e842b..372acfde 100644 --- a/app/tests/api/organization-model.test.js +++ b/app/tests/api/organization-model.test.js @@ -1,15 +1,14 @@ -const path = require('path') const test = require('ava') const { prop, map, contains } = require('ramda') const db = require('../../db') const organization = require('../../lib/organization') const team = require('../../lib/team') - -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') +const { resetDb } = require('../utils') test.before(async () => { const conn = await db() - await conn.migrate.latest({ directory: migrationsDirectory }) + + await resetDb(conn) // seed await conn('users').insert({ id: 1 }) @@ -18,12 +17,6 @@ test.before(async () => { await conn('users').insert({ id: 4 }) }) -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - /** * Test organization creation * An organization is created by a user. diff --git a/app/tests/api/profile-api.test.js b/app/tests/api/profile-api.test.js index 2be3ff1d..e1f386b6 100644 --- a/app/tests/api/profile-api.test.js +++ b/app/tests/api/profile-api.test.js @@ -1,4 +1,3 @@ -const path = require('path') const test = require('ava') const sinon = require('sinon') @@ -7,16 +6,16 @@ const team = require('../../lib/team') const org = require('../../lib/organization') const permissions = require('../../manage/permissions') const profile = require('../../lib/profile') +const { resetDb } = require('../utils') const { prop, concat, includes, propEq, find } = require('ramda') -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - let agent test.before(async () => { const conn = await db() - await conn.migrate.latest({ directory: migrationsDirectory }) + + await resetDb(conn) // seed await conn('users').insert({ id: 1 }) @@ -39,12 +38,6 @@ test.before(async () => { agent = require('supertest').agent(await require('../../index')()) }) -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - /** * Get a team user profile with correct visibility */ diff --git a/app/tests/api/profile-model.test.js b/app/tests/api/profile-model.test.js index 9cea4307..6449fd40 100644 --- a/app/tests/api/profile-model.test.js +++ b/app/tests/api/profile-model.test.js @@ -1,18 +1,16 @@ - -const path = require('path') const { range, map, contains, prop, propEq, find, includes } = require('ramda') const test = require('ava') const db = require('../../db') const organization = require('../../lib/organization') const team = require('../../lib/team') const profile = require('../../lib/profile') +const { resetDb } = require('../utils') const { ValidationError, PropertyRequiredError } = require('../../lib/utils') -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - test.before(async () => { const conn = await db() - await conn.migrate.latest({ directory: migrationsDirectory }) + + await resetDb(conn) // seed await conn('users').insert({ id: 1 }) @@ -26,12 +24,6 @@ test.before(async () => { await conn('users').insert({ id: 9 }) }) -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - test('add attributes for a public user profile', async (t) => { const name = 'Age' const visibility = 'public' diff --git a/app/tests/api/team-api.test.js b/app/tests/api/team-api.test.js index aed3475a..59364047 100644 --- a/app/tests/api/team-api.test.js +++ b/app/tests/api/team-api.test.js @@ -1,4 +1,3 @@ -const path = require('path') const test = require('ava') const sinon = require('sinon') const { any } = require('ramda') @@ -6,13 +5,13 @@ const { any } = require('ramda') const db = require('../../db') const team = require('../../lib/team') const permissions = require('../../manage/permissions') - -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') +const { resetDb } = require('../utils') let agent test.before(async () => { const conn = await db() - await conn.migrate.latest({ directory: migrationsDirectory }) + + await resetDb(conn) // seed await conn('users').insert({ id: 1 }) @@ -39,12 +38,6 @@ test.before(async () => { agent = require('supertest').agent(await require('../../index')()) }) -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - test('create a team', async t => { let res = await agent.post('/api/teams') .send({ name: 'road team 1' }) diff --git a/app/tests/api/team-model.test.js b/app/tests/api/team-model.test.js index faaebd81..faccf33e 100644 --- a/app/tests/api/team-model.test.js +++ b/app/tests/api/team-model.test.js @@ -1,14 +1,13 @@ -const path = require('path') const test = require('ava') const db = require('../../db') const team = require('../../lib/team') const { prop } = require('ramda') - -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') +const { resetDb } = require('../utils') test.before(async () => { const conn = await db() - await conn.migrate.latest({ directory: migrationsDirectory }) + + await resetDb(conn) // seed await conn('users').insert({ id: 1 }) @@ -17,12 +16,6 @@ test.before(async () => { await conn('users').insert({ id: 4 }) }) -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - test('create a team', async (t) => { const data = await team.create({ name: 'map team 1' }, 1) const members = await team.getMembers(data.id) diff --git a/app/tests/utils.js b/app/tests/utils.js new file mode 100644 index 00000000..763e5733 --- /dev/null +++ b/app/tests/utils.js @@ -0,0 +1,32 @@ +const path = require('path') + +const migrationsDirectory = path.join( + __dirname, + '..', + 'db', + 'migrations' +) + +async function resetDb (db) { + console.log('Dropping tables...') + const pgres = await db.raw(` + SELECT + 'drop table "' || tablename || '" cascade;' AS drop + FROM + pg_tables + WHERE + schemaname = 'public' + AND tablename != 'spatial_ref_sys' + `) + + for (const r of pgres.rows) { + await db.raw(r.drop) + } + + console.log('Migrating...') + await db.migrate.latest({ directory: migrationsDirectory }) +} + +module.exports = { + resetDb +} diff --git a/components/button.js b/components/button.js index 77472078..e903b44a 100644 --- a/components/button.js +++ b/components/button.js @@ -57,6 +57,10 @@ const style = css` backgroundColor: #777777; border: 2px solid #555; color: ${theme.colors.baseColor}; + transition: none; + opacity: 0.68; + box-shadow: 0 0; + cursor: not-allowed; } .button.danger { @@ -72,13 +76,16 @@ const style = css` ` export default function Button ({ name, id, value, variant, type, disabled, href, onClick, children, size }) { + let classes = [`button`, variant, size] + if (disabled) classes.push('disabled') + let classNames = classes.join(' ') if (type === 'submit') { - return + return } if (href) { let fullUrl (href.startsWith('http')) ? (fullUrl = href) : (fullUrl = join(publicRuntimeConfig.APP_URL, href)) - return {children} + return {children || value} } - return
{children}
+ return
{children}
} diff --git a/components/edit-team-form.js b/components/edit-team-form.js index c7e6e1aa..dd3e7228 100644 --- a/components/edit-team-form.js +++ b/components/edit-team-form.js @@ -29,7 +29,7 @@ function renderErrors (errors) { }) } -export default function EditTeamForm ({ initialValues, onSubmit, staff, isCreateForm, extraTags = [], profileValues }) { +export default function EditTeamForm ({ initialValues, onSubmit, staff, isCreateForm, orgTeamTags = [], teamTags = [], profileValues }) { if (profileValues) { initialValues.tags = {} profileValues.forEach(({ id, value }) => { @@ -42,14 +42,34 @@ export default function EditTeamForm ({ initialValues, onSubmit, staff, isCreate onSubmit={onSubmit} render={({ status, isSubmitting, submitForm, values, errors, setFieldValue, setErrors, setStatus }) => { let uniqueOrgs - let extraFields + let extraOrgTeamFields = [] + let extraTeamFields = [] if (staff && isCreateForm) { uniqueOrgs = uniqBy(prop('organization_id'), staff.map(({ name, organization_id }) => { return { name, organization_id } })) } - if (extraTags.length > 0) { - extraFields = extraTags.map(({ id, name, required, description }) => { + if (orgTeamTags.length > 0) { + extraOrgTeamFields = orgTeamTags.map(({ id, name, required, description }) => { + return ( +
+ + +
+ ) + }) + } + + if (teamTags.length > 0) { + extraTeamFields = teamTags.map(({ id, name, required, description }) => { return (
+
+ + + + + + + + + + + +