diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index f8fc9cfb..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: 2 -jobs: - build: - docker: - - image: circleci/node:14 - - environment: - DATABASE_URL: postgres://postgres@localhost/osm-teams-test - - image: mdillon/postgis:9.6-alpine - environment: - POSTGRES_USER: postgres - POSTGRES_DB: osm-teams-test - working_directory: ~/project - steps: - - checkout - - restore_cache: - keys: - - scoreboard-cache-{{ checksum "package.json" }} - - run: - name: Install - command: yarn - - save_cache: - key: scoreboard-cache-{{ checksum "package.json" }} - paths: - - ~/project/node_modules - - run: - name: Test - command: yarn test - - run: - name: Lint - command: yarn lint diff --git a/.env b/.env new file mode 100644 index 00000000..6d59e511 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +NEXTAUTH_URL=http://127.0.0.1:3000 +DATABASE_URL=postgres://postgres:postgres@localhost:5433/osm-teams?sslmode=disable \ No newline at end of file diff --git a/.env.development b/.env.development new file mode 100644 index 00000000..478c95b7 --- /dev/null +++ b/.env.development @@ -0,0 +1 @@ +NEXTAUTH_SECRET=next-auth-development-secret \ No newline at end of file diff --git a/.env.sample b/.env.sample deleted file mode 100644 index d25f0d81..00000000 --- a/.env.sample +++ /dev/null @@ -1,3 +0,0 @@ -OSM_CONSUMER_KEY= -OSM_CONSUMER_SECRET= -DSN=postgres://postgres@dev-db/osm-teams?sslmode=disable \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..911e8140 --- /dev/null +++ b/.env.test @@ -0,0 +1,4 @@ +NEXTAUTH_URL=http://127.0.0.1:3000 +NEXTAUTH_SECRET=next-auth-cypress-secret +DATABASE_URL=postgres://postgres:postgres@localhost:5434/osm-teams-test +TESTING=true diff --git a/.eslintrc b/.eslintrc index fb965520..3cd70ec9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,10 +1,11 @@ { - "root": true, - "extends": ["devseed-standard"], - "rules": { - "camelcase": "off", - "node/no-deprecated-api": ["error", { - "ignoreModuleItems": ["url.parse"] - }] - } + "env": { + "es6": true + }, + "extends": [ + "eslint:recommended", + "next/core-web-vitals", + "plugin:prettier/recommended", + "plugin:cypress/recommended" + ] } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..ef72c6fd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,58 @@ +name: Test + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + +jobs: + test: + runs-on: ubuntu-latest + if: github.event.pull_request.draft == false + timeout-minutes: 30 + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + with: + access_token: ${{ github.token }} + + - uses: actions/checkout@v2 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup node from node version file + uses: actions/setup-node@v2 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: yarn install + + - name: Lint + run: yarn lint + + - name: Docker - Pull + run: docker-compose pull + + - name: Docker - Start Test DB + run: docker-compose up --build -d test-db + + - name: Migrate database + 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 + + - name: Run API tests + run: yarn test + + - name: Run Cypress tests + run: yarn e2e + + - name: Docker Cleanup + run: docker-compose kill diff --git a/.gitignore b/.gitignore index cc6430e4..a03cf330 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ node_modules tmp *.log* .next -*.env .vscode *.db # ignore node_modules symlink @@ -12,4 +11,6 @@ node_modules.nosync hydra-config/prod/prod.yml .nyc_output coverage -docker-data/* \ No newline at end of file +docker-data/* +public/swagger.json +.env*.local diff --git a/.nvmrc b/.nvmrc index 8351c193..3c032078 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -14 +18 diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..ce82efd2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "tabWidth": 2, + "jsxSingleQuote": true, + "semi": false +} diff --git a/CHANGELOG.md b/CHANGELOG.md index ac0d9f65..3ba0a0cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## beta2 +## [v2.0.0] - 2022-11-22 ### Added -- Model for collections of teams called "organizations" -- New roles for organizations, "organization owner" and "organization manager" -- Shell scripts for managing organizations, owners, and managers +- Middleware to catch errors consistently with Boom module +- Cypress configuration +- Authentication with next-auth +### Changed -## beta1 +- Upgraded Next.js to v13 +- Upgraded Node.js to v18 +- ESLint rules +- Make table rows slightly more compact #291 +- Use Next.js default port instead of `:8989` -### TODO complete this +### Fixed + +- Scroll bar +- Reinstate API docs with `next-swagger-doc` + +### Breaking + +- Hydra configuration is temporarily broken, meaning it is not possible to create new clients \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index ff2fdd54..4709e74f 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,13 +1,16 @@ -# Production Deployment Documentation +# Production Deployment Documentation ## Requirements -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. + +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. ## Setting up Hydra URLs + 1. Copy over `hydra-config/dev/hydra.yml` to `hydra-config/prod/hydra.yml` -2. Modify the `urls` values in `hydra.yml` +2. Modify the `urls` values in `hydra.yml` For example, for `mapping.team` we modify the urls to be the following: + ```yaml urls: self: @@ -18,6 +21,7 @@ urls: ``` This is because we set the token provider at `https://mapping.team/hyauth` and use a reverse proxy (nginx): + ```nginx server { server_name mapping.team dev.mapping.team; @@ -30,7 +34,7 @@ This is because we set the token provider at `https://mapping.team/hyauth` and u location / { proxy_set_header host $host; - proxy_pass http://127.0.0.1:8989/; + proxy_pass http://127.0.0.1:3000/; } } ``` @@ -38,9 +42,11 @@ This is because we set the token provider at `https://mapping.team/hyauth` and u 3. Modify `hydra.yml` system secret and others according to the [configuration spec](https://www.ory.sh/hydra/docs/reference/configuration/) ### Env variables + Similarly to local development, we have to add `OSM_CONSUMER_KEY`, `OSM_CONSUMER_SECRET` and `DSN` to `.env` We also have to add a few environment variables so that the tokens are issued by the proper URL: + ```bash HYDRA_ADMIN_HOST=http://hydra:4445 HYDRA_TOKEN_HOST=http://hydra:4444 @@ -56,12 +62,16 @@ HYDRA_AUTHZ_PATH=/hyauth/oauth2/auth Finally we should set `APP_URL` to be the new domain, for example `https://mapping.team` +If you are using a sub-path of a domain you should set the `BASE_PATH` environment variable + `.env` will now look like: + ```sh OSM_CONSUMER_KEY= OSM_CONSUMER_SECRET= APP_URL=https://mapping.team DSN= +BASE_PATH=/example HYDRA_ADMIN_HOST=http://hydra:4445 HYDRA_TOKEN_HOST=http://hydra:4444 HYDRA_TOKEN_PATH=/oauth2/token @@ -70,8 +80,9 @@ HYDRA_AUTHZ_PATH=/hyauth/oauth2/auth ``` ## Deployment + Once the environment variables, `hydra.yml` and the reverse proxy are created, we can then run: ```docker docker-compose -f compose.yml -f compose.prod.yml up -``` \ No newline at end of file +``` diff --git a/Dockerfile b/Dockerfile index 0160e504..47bb0bfe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,5 +14,5 @@ COPY . . RUN npm run build -EXPOSE 8989 +EXPOSE 3000 CMD [ "npm", "start"] diff --git a/README.md b/README.md index 477a54e6..418529cc 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,4 @@ -# osm-teams 🤝 - - - -Check the beta 👉 https://mapping.team - +# OSM Teams 🤝 ## Development @@ -23,33 +7,25 @@ Install requirements: - [nvm](https://github.com/creationix/nvm) - [Docker](https://www.docker.com) -Visit your [OpenStreetMap settings](https://www.openstreetmap.org/account/edit) page and register an OAuth1 Client App: +Visit your [OpenStreetMap settings](https://www.openstreetmap.org/account/edit) page and [register an OAuth2 app](https://www.openstreetmap.org/oauth2/applications) with the following settings: -![OSM Client App](oauth1-osm-client-app.png "OAuth1 page at OSM Website") +- Name: `OSM Teams Dev` (or another name of your preference) +- Redirect URIs: `http://127.0.0.1:3000/api/auth/callback/openstreetmap` +- Confidential application: `false` +- Permissions: `Read user preferences` only -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: +Example: - ```bash - OSM_CONSUMER_KEY= - OSM_CONSUMER_SECRET= - DSN=postgres://postgres@dev-db/osm-teams?sslmode=disable - ``` +![OSM Client App](oauth2-osm-client-app.png "OAuth 2 page at OSM Website") -Start Hydra and PostgreSQL with Docker: +Create an `.env.local` file and add environment variables `OSM_CONSUMER_KEY` and `OSM_CONSUMER_SECRET` obtained at OAuth2 page at OpenStreetMap website. The `.env.local` file should be like the following: - docker-compose -f compose.dev.yml up --build + OSM_CONSUMER_KEY= + OSM_CONSUMER_SECRET= -On a separate terminal, create the [first-party](https://auth0.com/docs/applications/concepts/app-types-first-third-party) "manage" app: +Start development and test databases with Docker: -```bash -docker-compose exec hydra hydra clients create --endpoint http://localhost:4445 \ - --id manage \ - --secret manage-secret \ - --response-types code,id_token \ - --grant-types refresh_token,authorization_code \ - --scope openid,offline,clients \ - --callbacks http://localhost:8989/login/accept -``` + docker-compose up --build Install Node.js the required version (see [.nvmrc](.nvmrc) file): @@ -68,9 +44,37 @@ Start development server: yarn dev -✨ You can now login to the app at http://localhost:8989 +✨ You can now login to the app at http://127.0.0.1:3000 +## Testing + +Migrate `test-db` database: + + yarn migrate:test + +This project uses Cypress for end-to-end testing. To run once: + + yarn e2e + +To open Cypress dashboard for interactive development: + + yarn e2e:dev + +## API + +The API docs can be accessed at . + +All API routes should include descriptions in [OpenAPI 3.0 format](https://swagger.io/specification). + +Run the following command to validate the API docs: + + yarn docs:validate + ## Acknowledgments - This app is based off of [OSM/Hydra](https://github.com/kamicut/osmhydra) + +## LICENSE + +[MIT](LICENSE) diff --git a/app/db/index.js b/app/db/index.js deleted file mode 100644 index 7e7653cb..00000000 --- a/app/db/index.js +++ /dev/null @@ -1,12 +0,0 @@ -const knex = require('knex') -const connections = require('./knexfile') - -const config = connections[process.env.NODE_ENV] - -let cachedKnex -module.exports = async function () { - if (!cachedKnex) { - cachedKnex = knex(config) - } - return cachedKnex -} diff --git a/app/db/knexfile.js b/app/db/knexfile.js deleted file mode 100644 index ae159414..00000000 --- a/app/db/knexfile.js +++ /dev/null @@ -1,58 +0,0 @@ -let DATABASE_URL = process.env.DSN || process.env.DATABASE_URL - -// 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:postgres@localhost:5434/osm-teams-test?sslmode=disable' - } -} - -module.exports = { - test: { - client: 'postgresql', - connection: DATABASE_URL, - pool: { - min: 2, - max: 10 - }, - migrations: { - tableName: 'knex_migrations' - } - }, - development: { - client: 'postgresql', - connection: DATABASE_URL, - pool: { - min: 2, - max: 10 - }, - migrations: { - tableName: 'knex_migrations' - } - }, - staging: { - client: 'postgresql', - connection: DATABASE_URL, - pool: { - min: 2, - max: 10 - }, - migrations: { - tableName: 'knex_migrations' - } - }, - production: { - client: 'postgresql', - connection: DATABASE_URL, - pool: { - min: 2, - max: 10 - }, - migrations: { - tableName: 'knex_migrations' - } - } -} diff --git a/app/db/migrations/20220105182919_org_staff_visibility.js b/app/db/migrations/20220105182919_org_staff_visibility.js deleted file mode 100644 index 9a456af7..00000000 --- a/app/db/migrations/20220105182919_org_staff_visibility.js +++ /dev/null @@ -1,11 +0,0 @@ -const constraintName = 'profile_keys_visibility_check' - -exports.up = async (knex) => { - await knex.raw(`ALTER TABLE profile_keys DROP CONSTRAINT IF EXISTS ${constraintName};`) - await knex.raw(`ALTER TABLE profile_keys ADD CONSTRAINT ${constraintName} CHECK (visibility = ANY (ARRAY['public'::text, 'team'::text, 'org'::text, 'org_staff'::text]))`) -} - -exports.down = async (knex) => { - await knex.raw(`ALTER TABLE profile_keys DROP CONSTRAINT IF EXISTS ${constraintName};`) - await knex.raw(`ALTER TABLE profile_keys ADD CONSTRAINT ${constraintName} CHECK (visibility = ANY (ARRAY['public'::text, 'team'::text, 'org'::text]))`) -} diff --git a/app/index.js b/app/index.js index 4923871d..0ed864b6 100644 --- a/app/index.js +++ b/app/index.js @@ -1,14 +1,11 @@ // 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') const compression = require('compression') const boom = require('express-boom') const next = require('next') -const YAML = require('yamljs') -const swaggerUi = require('swagger-ui-express') const cors = require('cors') const manageRouter = require('./manage') @@ -17,8 +14,6 @@ const oauthRouter = require('./oauth') const dev = process.env.NODE_ENV !== 'production' const PORT = process.env.PORT || 8989 -const swaggerDocument = YAML.load(path.join(__dirname, '..', '/docs/api.yml')) - const nextApp = next({ dev }) const app = express() @@ -32,7 +27,7 @@ app.use(boom()) /** * Initialize subapps after nextJS initializes */ -async function init () { +async function init() { await nextApp.prepare() // On maintenance mode, render maintenance page @@ -49,11 +44,6 @@ async function init () { app.use('/', cors(), manageRouter(nextApp)) app.use('/oauth', oauthRouter(nextApp)) - /** - * Docs endpoints - */ - app.use(['/api', '/api/docs'], swaggerUi.serve, swaggerUi.setup(swaggerDocument)) - /** * Handle all other route GET with nextjs */ @@ -65,7 +55,7 @@ async function init () { /** * Error handler */ - app.use(function (err, req, res, next) { + app.use(function (err, req, res) { if (err.message === 'Forbidden') { return nextApp.render(req, res, '/uh-oh') } @@ -79,7 +69,7 @@ async function init () { /* script run */ if (require.main === module) { - init().then(app => { + init().then((app) => { app.listen(PORT, () => { console.log(`Starting server on port ${PORT}`) }) diff --git a/app/lib/hydra.js b/app/lib/hydra.js index 6d3fb1c5..2c8dbf32 100644 --- a/app/lib/hydra.js +++ b/app/lib/hydra.js @@ -16,32 +16,31 @@ var mockTlsTermination = {} if (process.env.MOCK_TLS_TERMINATION) { mockTlsTermination = { - 'X-Forwarded-Proto': 'https' + 'X-Forwarded-Proto': 'https', } } // A little helper that takes type (can be "login" or "consent") and a challenge and returns the response from ORY Hydra. -function get (flow, challenge) { +function get(flow, challenge) { const url = new URL(`/oauth2/auth/requests/${flow}`, hydraUrl) url.search = querystring.stringify({ [`${flow}_challenge`]: challenge }) - return fetch(url.toString()) - .then(function (res) { - if (res.status < 200 || res.status > 302) { - // This will handle any errors that aren't network related (network related errors are handled automatically) - return res.json().then(function (body) { - if (res.status !== 404) { - console.error('An error occurred while making a HTTP request: ', body) - } - return Promise.reject(new Error(body.error.message)) - }) - } - - return res.json() - }) + return fetch(url.toString()).then(function (res) { + if (res.status < 200 || res.status > 302) { + // This will handle any errors that aren't network related (network related errors are handled automatically) + return res.json().then(function (body) { + if (res.status !== 404) { + console.error('An error occurred while making a HTTP request: ', body) + } + return Promise.reject(new Error(body.error.message)) + }) + } + + return res.json() + }) } // A little helper that takes type (can be "login" or "consent"), the action (can be "accept" or "reject") and a challenge and returns the response from ORY Hydra. -function put (flow, action, challenge, body) { +function put(flow, action, challenge, body) { const url = new URL(`/oauth2/auth/requests/${flow}/${action}`, hydraUrl) url.search = querystring.stringify({ [`${flow}_challenge`]: challenge }) return fetch( @@ -52,89 +51,77 @@ function put (flow, action, challenge, body) { body: JSON.stringify(body), headers: { 'Content-Type': 'application/json', - ...mockTlsTermination - } + ...mockTlsTermination, + }, } - ) - .then(function (res) { - if (res.status < 200 || res.status > 302) { - // This will handle any errors that aren't network related (network related errors are handled automatically) - return res.json().then(function (body) { - if (res.status !== 404) { - console.error('An error occurred while making a HTTP request: ', body) - } - return Promise.reject(new Error(body.error.message)) - }) - } - - return res.json() - }) + ).then(function (res) { + if (res.status < 200 || res.status > 302) { + // This will handle any errors that aren't network related (network related errors are handled automatically) + return res.json().then(function (body) { + if (res.status !== 404) { + console.error('An error occurred while making a HTTP request: ', body) + } + return Promise.reject(new Error(body.error.message)) + }) + } + + return res.json() + }) } -function getClients () { - return fetch( - uj(hydraUrl, '/clients'), - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...mockTlsTermination - } +function getClients() { + return fetch(uj(hydraUrl, '/clients'), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...mockTlsTermination, + }, + }).then(function (res) { + if (res.status < 200 || res.status > 302) { + // This will handle any errors that aren't network related (network related errors are handled automatically) + return res.json().then(function (body) { + if (res.status !== 404) { + console.error('An error occurred while making a HTTP request: ', body) + } + return Promise.reject(new Error(body.error.message)) + }) } - ) - .then(function (res) { - if (res.status < 200 || res.status > 302) { - // This will handle any errors that aren't network related (network related errors are handled automatically) - return res.json().then(function (body) { - if (res.status !== 404) { - console.error('An error occurred while making a HTTP request: ', body) - } - return Promise.reject(new Error(body.error.message)) - }) - } - - return res.json() - }) + + return res.json() + }) } -function createClient (body) { - return fetch( - uj(hydraUrl, '/clients'), - { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'Content-Type': 'application/json', - ...mockTlsTermination - } +function createClient(body) { + return fetch(uj(hydraUrl, '/clients'), { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + ...mockTlsTermination, + }, + }).then(function (res) { + if (res.status < 200 || res.status > 302) { + // This will handle any errors that aren't network related (network related errors are handled automatically) + return res.json().then(function (body) { + if (res.status !== 404) { + console.error('An error occurred while making a HTTP request: ', body) + } + return Promise.reject(new Error(body.error.message)) + }) } - ) - .then(function (res) { - if (res.status < 200 || res.status > 302) { - // This will handle any errors that aren't network related (network related errors are handled automatically) - return res.json().then(function (body) { - if (res.status !== 404) { - console.error('An error occurred while making a HTTP request: ', body) - } - return Promise.reject(new Error(body.error.message)) - }) - } - - return res.json() - }) + + return res.json() + }) } -function deleteClient (id) { - return fetch( - uj(hydraUrl, `/clients/${id}`), - { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - ...mockTlsTermination - } - } - ) +function deleteClient(id) { + return fetch(uj(hydraUrl, `/clients/${id}`), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...mockTlsTermination, + }, + }) } /** @@ -142,19 +129,18 @@ function deleteClient (id) { * * @param {String} token Access Token */ -function introspect (token) { +function introspect(token) { const body = qs.stringify({ token }) - return fetch( - uj(hydraUrl, '/oauth2/introspect'), { - method: 'POST', - body, - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - ...mockTlsTermination - } - }) - .then(r => r.json()) + return fetch(uj(hydraUrl, '/oauth2/introspect'), { + method: 'POST', + body, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + ...mockTlsTermination, + }, + }) + .then((r) => r.json()) .then((body) => { return body }) @@ -188,7 +174,7 @@ var hydra = { createClient, deleteClient, getClients, - introspect + introspect, } module.exports = hydra diff --git a/app/lib/osm.js b/app/lib/osm.js index 330192ad..5c72583f 100644 --- a/app/lib/osm.js +++ b/app/lib/osm.js @@ -7,105 +7,136 @@ const passport = require('passport-light') const R = require('ramda') const hydra = require('./hydra') const url = require('url') -const db = require('../db') +const db = require('../../src/lib/db') const xml2js = require('xml2js') const InternalOAuthError = require('passport-oauth').InternalOAuthError const OSMStrategy = require('passport-openstreetmap').Strategy -const { serverRuntimeConfig, publicRuntimeConfig } = require('../../next.config') +const { serverRuntimeConfig } = require('../../next.config') // get an authentication token pair from openstreetmap -function openstreetmap (req, res) { - const { OSM_CONSUMER_KEY, OSM_CONSUMER_SECRET, OSM_API, OSM_DOMAIN } = serverRuntimeConfig +function openstreetmap(req, res) { + const { OSM_CONSUMER_KEY, OSM_CONSUMER_SECRET, OSM_API, OSM_DOMAIN } = + serverRuntimeConfig const query = url.parse(req.url, true).query const challenge = query.login_challenge /** * override the userProfile method of OSMStrategy to allow for custom osm endpoints */ - OSMStrategy.prototype.userProfile = function (token, tokenSecret, params, done) { - this._oauth.get(`${OSM_API}/api/0.6/user/details`, token, tokenSecret, function (err, body, res) { - if (err) { return done(new InternalOAuthError('failed to fetch user profile', err)) } + OSMStrategy.prototype.userProfile = function ( + token, + tokenSecret, + params, + done + ) { + this._oauth.get( + `${OSM_API}/api/0.6/user/details`, + token, + tokenSecret, + function (err, body) { + if (err) { + return done( + new InternalOAuthError('failed to fetch user profile', err) + ) + } - var parser = new xml2js.Parser() - parser.parseString(body, function (err, xml) { - if (err) { return done(err) }; + var parser = new xml2js.Parser() + parser.parseString(body, function (err, xml) { + if (err) { + return done(err) + } - var profile = { provider: 'openstreetmap' } - const user = xml.osm.user[0] - profile.id = user['$'].id - profile.displayName = user['$'].display_name + var profile = { provider: 'openstreetmap' } + const user = xml.osm.user[0] + profile.id = user['$'].id + profile.displayName = user['$'].display_name - profile._raw = body - profile._xml2json = - profile._xml2js = xml + profile._raw = body + profile._xml2json = profile._xml2js = xml - done(null, profile) - }) - }) + done(null, profile) + }) + } + ) } - const strategy = new OSMStrategy({ - requestTokenURL: `${OSM_API}/oauth/request_token`, - accessTokenURL: `${OSM_API}/oauth/access_token`, - userAuthorizationURL: `${OSM_DOMAIN}/oauth/authorize`, - consumerKey: OSM_CONSUMER_KEY, - consumerSecret: OSM_CONSUMER_SECRET, - callbackURL: `${publicRuntimeConfig.APP_URL}/oauth/openstreetmap/callback?login_challenge=${encodeURIComponent(challenge)}` - }, async (token, tokenSecret, profile, done) => { - 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(newProfile) - } - ) - } else { - await conn('users').insert( - { - 'id': profile.id, - 'osmToken': token, - 'osmTokenSecret': tokenSecret, - profile: JSON.stringify(profile) - } - ) + const strategy = new OSMStrategy( + { + requestTokenURL: `${OSM_API}/oauth/request_token`, + accessTokenURL: `${OSM_API}/oauth/access_token`, + userAuthorizationURL: `${OSM_DOMAIN}/oauth/authorize`, + consumerKey: OSM_CONSUMER_KEY, + consumerSecret: OSM_CONSUMER_SECRET, + callbackURL: `${ + process.env.APP_URL + }/oauth/openstreetmap/callback?login_challenge=${encodeURIComponent( + challenge + )}`, + }, + async (token, tokenSecret, profile, done) => { + let [user] = await db('users').where('id', profile.id) + if (user) { + const newProfile = R.mergeDeepRight(user.profile, profile) + await db('users') + .where('id', profile.id) + .update({ + osmToken: token, + osmTokenSecret: tokenSecret, + profile: JSON.stringify(newProfile), + }) + } else { + await db('users').insert({ + id: profile.id, + osmToken: token, + osmTokenSecret: tokenSecret, + profile: JSON.stringify(profile), + }) + } + done(null, profile) } - done(null, profile) - }) + ) passport.authenticate(strategy, { req: req, - redirect: function (url, status) { res.redirect(url) }, + redirect: function (url) { + res.redirect(url) + }, success: function (user) { if (challenge) { - hydra.acceptLoginRequest(challenge, { - subject: user.id, - remember: true, - remember_for: 0 - }).then(response => { - if (response.redirect_to) { - return res.redirect(response.redirect_to) - } else { + hydra + .acceptLoginRequest(challenge, { + subject: user.id, + remember: true, + remember_for: 0, + }) + .then((response) => { + if (response.redirect_to) { + return res.redirect(response.redirect_to) + } else { + return res.redirect('/') + } + }) + .catch((e) => { + console.error(e) return res.redirect('/') - } - }).catch(e => { - console.error(e) - return res.redirect('/') - }) + }) } else { return res.redirect('/') } }, - pass: function () { res.sendStatus(401) }, - fail: function (challenge, status) { res.status(status).send(challenge) }, - error: function (err) { res.status(500).send(err) } + pass: function () { + return res.status(401) + }, + fail: function (challenge, status) { + res.status(status).send(challenge) + }, + error: function (err) { + res.status(500).send(err) + }, }) } module.exports = { - openstreetmap + openstreetmap, } diff --git a/app/lib/utils.js b/app/lib/utils.js index bc4b2c96..02772baf 100644 --- a/app/lib/utils.js +++ b/app/lib/utils.js @@ -1,17 +1,17 @@ const { head, has } = require('ramda') -async function unpack (promise) { +async function unpack(promise) { return promise.then(head) } class ValidationError extends Error { - constructor (message) { + constructor(message) { super(message) this.name = 'ValidationError' } } class PropertyRequiredError extends ValidationError { - constructor (property) { + constructor(property) { super('No property: ' + property) this.name = 'PropertyRequiredError' this.property = property @@ -22,8 +22,8 @@ class PropertyRequiredError extends ValidationError { * @param {string[]} requiredProperties required keys in an object * @param {Object} object object to check */ -function checkRequiredProperties (requiredProperties, object) { - requiredProperties.forEach(key => { +function checkRequiredProperties(requiredProperties, object) { + requiredProperties.forEach((key) => { if (!has(key)(object)) { throw new PropertyRequiredError(key) } @@ -36,7 +36,7 @@ function checkRequiredProperties (requiredProperties, object) { * @param {Number or String} timestamp * @returns */ -function toDateString (timestamp) { +function toDateString(timestamp) { const dateFormat = new Intl.DateTimeFormat(navigator.language).format return dateFormat(new Date(timestamp)) } @@ -46,5 +46,5 @@ module.exports = { ValidationError, PropertyRequiredError, checkRequiredProperties, - toDateString + toDateString, } diff --git a/app/manage/badges.js b/app/manage/badges.js index a20b0e2a..dbef3cab 100644 --- a/app/manage/badges.js +++ b/app/manage/badges.js @@ -1,9 +1,10 @@ -const db = require('../db') +const db = require('../../src/lib/db') const yup = require('yup') -const organization = require('../lib/organization') -const profile = require('../lib/profile') +const organization = require('../../src/models/organization') +const profile = require('../../src/models/profile') const { routeWrapper } = require('./utils') -const team = require('../lib/team') +const team = require('../../src/models/team') +const Boom = require('@hapi/boom') /** * Get the list of badges of an organization @@ -12,23 +13,22 @@ const listBadges = routeWrapper({ validate: { params: yup .object({ - id: yup.number().required().positive().integer() + id: yup.number().required().positive().integer(), }) - .required() + .required(), }, handler: async function (req, reply) { try { - const conn = await db() - const badges = await conn('organization_badge') + const badges = await db('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) + throw Boom.badRequest(err.message) } - } + }, }) /** @@ -38,31 +38,30 @@ const createBadge = routeWrapper({ validate: { params: yup .object({ - id: yup.number().required().positive().integer() + id: yup.number().required().positive().integer(), }) .required(), body: yup .object({ name: yup.string().required(), - color: yup.string().required() + color: yup.string().required(), }) - .required() + .required(), }, handler: async function (req, reply) { try { - const conn = await db() - const [badge] = await conn('organization_badge') + const [badge] = await db('organization_badge') .insert({ organization_id: req.params.id, - ...req.body + ...req.body, }) .returning('*') reply.send(badge) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } - } + }, }) /** @@ -73,23 +72,22 @@ const getBadge = routeWrapper({ params: yup .object({ id: yup.number().required().positive().integer(), - badgeId: yup.number().required().positive().integer() + badgeId: yup.number().required().positive().integer(), }) - .required() + .required(), }, handler: async function (req, reply) { try { - const conn = await db() - const [badge] = await conn('organization_badge') + const [badge] = await db('organization_badge') .select('*') .where('id', req.params.badgeId) .returning('*') - let users = await conn('user_badges') + let users = await db('user_badges') .select({ id: 'user_badges.user_id', assignedAt: 'user_badges.assigned_at', - validUntil: 'user_badges.valid_until' + validUntil: 'user_badges.valid_until', }) .leftJoin( 'organization_badge', @@ -112,19 +110,19 @@ const getBadge = routeWrapper({ id: u.id, assignedAt: u.assignedAt, validUntil: u.validUntil, - displayName: userProfiles[u.id] ? userProfiles[u.id].name : '' + displayName: userProfiles[u.id] ? userProfiles[u.id].name : '', })) } reply.send({ ...badge, - users + users, }) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } - } + }, }) /** @@ -135,29 +133,28 @@ const patchBadge = routeWrapper({ params: yup .object({ id: yup.number().required().positive().integer(), - badgeId: yup.number().required().positive().integer() + badgeId: yup.number().required().positive().integer(), }) .required(), body: yup .object({ name: yup.string().optional(), - color: yup.string().optional() + color: yup.string().optional(), }) - .required() + .required(), }, handler: async function (req, reply) { try { - const conn = await db() - const [badge] = await conn('organization_badge') + const [badge] = await db('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) + throw Boom.badRequest(err.message) } - } + }, }) /** @@ -167,23 +164,22 @@ const deleteBadge = routeWrapper({ validate: { params: yup .object({ - badgeId: yup.number().required().positive().integer() + badgeId: yup.number().required().positive().integer(), }) - .required() + .required(), }, handler: async function (req, reply) { try { - const conn = await db() - await conn('organization_badge').delete().where('id', req.params.badgeId) + await db('organization_badge').delete().where('id', req.params.badgeId) return reply.send({ status: 200, - message: `Badge ${req.params.badgeId} deleted successfully.` + message: `Badge ${req.params.badgeId} deleted successfully.`, }) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } - } + }, }) /** @@ -195,18 +191,16 @@ const assignUserBadge = routeWrapper({ .object({ id: yup.number().required().positive().integer(), badgeId: yup.number().required().positive().integer(), - userId: 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() - }) + 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, @@ -214,17 +208,17 @@ const assignUserBadge = routeWrapper({ ) if (!isMemberOrStaff) { - return reply.boom.badRequest('User is not part of the organization.') + throw Boom.badRequest('User is not part of the organization.') } // assign badge const { assigned_at, valid_until } = req.body - const [badge] = await conn('user_badges') + const [badge] = await db('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 + valid_until: valid_until ? valid_until.toISOString() : null, }) .returning('*') @@ -232,14 +226,12 @@ const assignUserBadge = routeWrapper({ } catch (err) { console.log(err) if (err.code === '23505') { - return reply.boom.badRequest('User is already assigned to badge.') + throw Boom.badRequest('User is already assigned to badge.') } else { - return reply.boom.badRequest( - 'Unexpected error, please try again later.' - ) + throw Boom.badRequest('Unexpected error, please try again later.') } } - } + }, }) /** @@ -249,9 +241,9 @@ const listUserBadges = routeWrapper({ validate: { params: yup .object({ - userId: yup.number().required().positive().integer() + userId: yup.number().required().positive().integer(), }) - .required() + .required(), }, handler: async function (req, reply) { try { @@ -259,9 +251,9 @@ const listUserBadges = routeWrapper({ reply.send({ badges }) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } - } + }, }) /** @@ -272,39 +264,37 @@ const updateUserBadge = routeWrapper({ params: yup .object({ badgeId: yup.number().required().positive().integer(), - userId: 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() - }) + 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') + const [badge] = await db('user_badges') .update({ assigned_at: assigned_at.toISOString(), - valid_until: valid_until ? valid_until.toISOString() : null + valid_until: valid_until ? valid_until.toISOString() : null, }) .where({ user_id: req.params.userId, - badge_id: req.params.badgeId + badge_id: req.params.badgeId, }) .returning('*') reply.send(badge) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } - } + }, }) /** @@ -315,29 +305,27 @@ const removeUserBadge = routeWrapper({ params: yup .object({ badgeId: yup.number().required().positive().integer(), - userId: yup.number().required().positive().integer() + userId: yup.number().required().positive().integer(), }) - .required() + .required(), }, handler: async function (req, reply) { try { - const conn = await db() - // delete user badge - await conn('user_badges').delete().where({ + await db('user_badges').delete().where({ user_id: req.params.userId, - badge_id: req.params.badgeId + badge_id: req.params.badgeId, }) return reply.send({ status: 200, - message: `Badge ${req.params.badgeId} unassigned successfully.` + message: `Badge ${req.params.badgeId} unassigned successfully.`, }) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } - } + }, }) module.exports = { @@ -349,5 +337,5 @@ module.exports = { assignUserBadge, listUserBadges, updateUserBadge, - removeUserBadge + removeUserBadge, } diff --git a/app/manage/client.js b/app/manage/client.js index d68db755..8545f2a0 100644 --- a/app/manage/client.js +++ b/app/manage/client.js @@ -11,13 +11,16 @@ const manageId = serverRuntimeConfig.OSM_HYDRA_ID * @param {*} req * @param {*} res */ -async function getClients (req, res) { - const { session: { user_id } } = req +async function getClients(req, res) { + const { + session: { user_id }, + } = req let clients = await hydra.getClients() // Remove first party client from list & exclude clients the user does not own - let filteredClients = clients - .filter(c => c.client_id !== manageId && c.owner === user_id) + let filteredClients = clients.filter( + (c) => c.client_id !== manageId && c.owner === user_id + ) return res.send({ clients: filteredClients }) } @@ -28,7 +31,7 @@ async function getClients (req, res) { * @param {*} req * @param {*} res */ -async function createClient (req, res) { +async function createClient(req, res) { let toCreate = Object.assign({}, req.body) toCreate['scope'] = 'openid offline' toCreate['response_types'] = ['code', 'id_token'] @@ -43,12 +46,12 @@ async function createClient (req, res) { * @param {*} req * @param {*} res */ -function deleteClient (req, res) { - hydra.deleteClient(req.params.id).then(() => res.sendStatus(200)) +function deleteClient(req, res) { + hydra.deleteClient(req.params.id).then(() => res.status(200)) } module.exports = { getClients, createClient, - deleteClient + deleteClient, } diff --git a/app/manage/index.js b/app/manage/index.js index d2a32aaf..0a8b0516 100644 --- a/app/manage/index.js +++ b/app/manage/index.js @@ -1,12 +1,4 @@ -const router = require('express-promise-router')() -const expressPino = require('express-pino-logger') -const { path } = require('ramda') - -const { getClients, createClient, deleteClient } = require('./client') -const { login, loginAccept, logout } = require('./login') const { can } = require('./permissions') -const sessionMiddleware = require('./sessions') -const logger = require('../lib/logger') const { addMember, assignModerator, @@ -24,7 +16,7 @@ const { getJoinInvitations, createJoinInvitation, deleteJoinInvitation, - acceptJoinInvitation + acceptJoinInvitation, } = require('./teams') const { @@ -40,7 +32,7 @@ const { getOrgTeams, getOrgMembers, listMyOrgs, - getOrgStaff + getOrgStaff, } = require('./organizations') const { @@ -52,7 +44,7 @@ const { assignUserBadge, listUserBadges, updateUserBadge, - removeUserBadge + removeUserBadge, } = require('./badges') const { @@ -65,118 +57,144 @@ const { setProfile, deleteProfileKey, getUserOrgProfile, - getTeamProfile + getTeamProfile, } = require('./profiles') -const { getUserManageToken } = require('../lib/profile') -const orgModel = require('../lib/organization') -const teamModel = require('../lib/team') - /** - * The manageRouter handles all routes related to the first party - * management client + * Add routes to next-connect handler. * - * @param {Object} nextApp the NextJS Server + * @param {Object} handle next-connect handler */ -function manageRouter (nextApp) { - if (process.env.NODE_ENV !== 'test') { - router.use('/api', expressPino({ - logger: logger.child({ module: 'manage' }) - })) - } - - router.use(sessionMiddleware) - - /** - * Home page - */ - router.get('/', (req, res) => { - return nextApp.render(req, res, '/', { user: path(['session', 'user'], req) }) - }) - - /** - * Logging in to manage app - */ - router.get('/login', login) - router.get('/login/accept', loginAccept) - router.get('/logout', logout) - - /** - * List / Create / Delete clients - */ - router.get('/api/clients', can('clients'), getClients) - router.post('/api/clients', can('clients'), createClient) - router.delete('/api/clients/:id', can('client:delete'), deleteClient) - +function manageRouter(handler) { /** * List, Create, Read, Update, Delete operations on teams. */ - router.get('/api/teams', listTeams) - router.get('/api/my/teams', can('public:authenticated'), listMyTeams) - router.post('/api/teams', can('public:authenticated'), createTeam) - router.get('/api/teams/:id', can('team:view'), getTeam) - router.get('/api/teams/:id/members', can('team:view-members'), getTeamMembers) - router.put('/api/teams/:id', can('team:edit'), updateTeam) - router.delete('/api/teams/:id', can('team:edit'), destroyTeam) - router.put('/api/teams/add/:id/:osmId', can('team:edit'), addMember) - router.put('/api/teams/remove/:id/:osmId', can('team:edit'), removeMember) - router.patch('/api/teams/:id/members', can('team:edit'), updateMembers) - router.put('/api/teams/:id/join', can('team:join'), joinTeam) - router.put('/api/teams/:id/assignModerator/:osmId', can('team:edit'), assignModerator) - router.put('/api/teams/:id/removeModerator/:osmId', can('team:edit'), removeModerator) + handler.get('api/teams', listTeams) + handler.get('/api/my/teams', can('public:authenticated'), listMyTeams) + handler.post('/api/teams', can('public:authenticated'), createTeam) + handler.get('/api/teams/:id', can('team:view'), getTeam) + handler.get( + '/api/teams/:id/members', + can('team:view-members'), + getTeamMembers + ) + handler.put('/api/teams/:id', can('team:edit'), updateTeam) + handler.delete('/api/teams/:id', can('team:edit'), destroyTeam) + handler.put('/api/teams/add/:id/:osmId', can('team:edit'), addMember) + handler.put('/api/teams/remove/:id/:osmId', can('team:edit'), removeMember) + handler.patch('/api/teams/:id/members', can('team:edit'), updateMembers) + handler.put('/api/teams/:id/join', can('team:join'), joinTeam) + handler.put( + '/api/teams/:id/assignModerator/:osmId', + can('team:edit'), + assignModerator + ) + handler.put( + '/api/teams/:id/removeModerator/:osmId', + can('team:edit'), + removeModerator + ) /** - * Manage inviations to teams + * Manage invitations 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) + handler.get( + '/api/teams/:id/invitations', + can('team:edit'), + getJoinInvitations + ) + handler.post( + '/api/teams/:id/invitations', + can('team:edit'), + createJoinInvitation + ) + handler.delete( + '/api/teams/:id/invitations/:uuid', + can('team:edit'), + deleteJoinInvitation + ) + handler.post( + '/api/teams/:id/invitations/:uuid/accept', + can('public:authenticated'), + acceptJoinInvitation + ) /** * List, Create, Read, Update, Delete operations on orgs */ - router.get('/api/my/organizations', can('public:authenticated'), listMyOrgs) - router.post('/api/organizations', can('public:authenticated'), createOrg) - router.get('/api/organizations/:id', can('public:authenticated'), getOrg) - router.put('/api/organizations/:id', can('organization:edit'), updateOrg) - router.delete('/api/organizations/:id', can('organization:edit'), destroyOrg) - router.get('/api/organizations/:id/staff', can('organization:view-members'), getOrgStaff) - router.get('/api/organizations/:id/members', can('organization:view-members'), getOrgMembers) + handler.get('/api/my/organizations', can('public:authenticated'), listMyOrgs) + handler.post('/api/organizations', can('public:authenticated'), createOrg) + handler.get('/api/organizations/:id', can('public:authenticated'), getOrg) + handler.put('/api/organizations/:id', can('organization:edit'), updateOrg) + handler.delete('/api/organizations/:id', can('organization:edit'), destroyOrg) + handler.get( + '/api/organizations/:id/staff', + can('organization:view-members'), + getOrgStaff + ) + handler.get( + '/api/organizations/:id/members', + can('organization:view-members'), + getOrgMembers + ) - router.put('/api/organizations/:id/addOwner/:osmId', can('organization:edit'), addOwner) - router.put('/api/organizations/:id/removeOwner/:osmId', can('organization:edit'), removeOwner) + handler.put( + '/api/organizations/:id/addOwner/:osmId', + can('organization:edit'), + addOwner + ) + handler.put( + '/api/organizations/:id/removeOwner/:osmId', + can('organization:edit'), + removeOwner + ) - router.put('/api/organizations/:id/addManager/:osmId', can('organization:edit'), addManager) - router.put('/api/organizations/:id/removeManager/:osmId', can('organization:edit'), removeManager) + handler.put( + '/api/organizations/:id/addManager/:osmId', + can('organization:edit'), + addManager + ) + handler.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', can('organization:view-members'), getOrgTeams) + handler.post( + '/api/organizations/:id/teams', + can('organization:create-team'), + createOrgTeam + ) + handler.get( + '/api/organizations/:id/teams', + can('organization:view-members'), + getOrgTeams + ) /** * Manage organization badges */ - router.get( + handler.get( '/api/organizations/:id/badges', can('organization:edit'), listBadges ) - router.post( + handler.post( '/api/organizations/:id/badges', can('organization:edit'), createBadge ) - router.get( + handler.get( '/api/organizations/:id/badges/:badgeId', can('organization:edit'), getBadge ) - router.patch( + handler.patch( '/api/organizations/:id/badges/:badgeId', can('organization:edit'), patchBadge ) - router.delete( + handler.delete( '/api/organizations/:id/badges/:badgeId', can('organization:edit'), deleteBadge @@ -185,23 +203,23 @@ function manageRouter (nextApp) { /** * Manage user badges */ - router.post( + handler.post( '/api/organizations/:id/badges/:badgeId/assign/:userId', can('organization:edit'), assignUserBadge ) - router.get( + handler.get( '/api/user/:userId/badges', can('public:authenticated'), listUserBadges ) - router.patch( + handler.patch( `/api/organizations/:id/member/:userId/badge/:badgeId`, can('organization:edit'), updateUserBadge ) - router.delete( + handler.delete( `/api/organizations/:id/member/:userId/badge/:badgeId`, can('organization:edit'), removeUserBadge @@ -210,143 +228,87 @@ function manageRouter (nextApp) { /** * List, Create, Read, Update, Delete operations on profiles */ - router.get('/api/profiles/teams/:id/:osmId', can('public:authenticated'), getUserTeamProfile) - router.get('/api/profiles/organizations/:id/:osmId', can('public:authenticated'), getUserOrgProfile) - router.get('/api/my/profiles', can('public:authenticated'), getMyProfile) - router.post('/api/my/profiles', can('public:authenticated'), setMyProfile) - - router.put('/api/profiles/keys/:id', can('key:edit'), modifyProfileKey) - router.delete('/api/profiles/keys/:id', can('key:edit'), deleteProfileKey) - - 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: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')) - router.post('/api/profiles/keys/organizations/:id/users', can('organization:edit'), createProfileKeys('org', 'user')) - - router.get('/api/profiles/keys/teams/:id', can('team:edit'), getProfileKeys('team', 'team')) - router.get('/api/profiles/keys/teams/:id/users', can('team:member'), getProfileKeys('team', 'user')) - router.post('/api/profiles/keys/teams/:id', can('team:edit'), createProfileKeys('team', 'team')) - router.post('/api/profiles/keys/teams/:id/users', can('team:edit'), createProfileKeys('team', 'user')) - - router.get('/api/profiles/teams/:id', can('public:authenticated'), getTeamProfile) - router.post('/api/profiles/teams/:id', can('team:edit'), setProfile('team')) - router.post('/api/profiles/organizations/:id', can('organization:edit'), setProfile('org')) - - /** - * Page renders - */ - router.get('/clients', can('clients'), async (req, res) => { - const { manageToken } = await getUserManageToken(res.locals.user_id) - const access_token = manageToken.access_token - return nextApp.render(req, res, '/clients', { access_token }) - }) - - router.get('/profile', can('clients'), (req, res) => { - return nextApp.render(req, res, '/profile') - }) - - router.get('/teams/create', can('public:authenticated'), async (req, res) => { - const staff = await orgModel.getOrgStaff({ osmId: Number(res.locals.user_id) }) - return nextApp.render(req, res, '/team-create', { staff }) - }) - - router.get('/teams/:id', can('team:view'), async (req, res) => { - return nextApp.render(req, res, '/team', { id: req.params.id }) - }) - - router.get('/teams/:id/edit', can('team:edit'), (req, res) => { - return nextApp.render(req, res, '/team-edit', { id: req.params.id }) - }) - - router.get('/teams/:id/edit-profiles', can('team:edit'), (req, res) => { - return nextApp.render(req, res, '/team-edit-profile', { id: req.params.id }) - }) - - router.get('/teams/:id/profile', can('team:member'), (req, res) => { - 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') - }) - - router.get('/organizations/:id', can('public:authenticated'), async (req, res) => { - return nextApp.render(req, res, '/organization', { id: req.params.id }) - }) - - router.get('/organizations/:id/edit', can('organization:edit'), (req, res) => { - return nextApp.render(req, res, '/org-edit', { id: req.params.id }) - }) - - router.get('/organizations/:id/edit-profiles', can('organization:edit'), (req, res) => { - 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' }) - }) + handler.get( + '/api/profiles/teams/:id/:osmId', + can('public:authenticated'), + getUserTeamProfile + ) + handler.get( + '/api/profiles/organizations/:id/:osmId', + can('public:authenticated'), + getUserOrgProfile + ) + handler.get('/api/my/profiles', can('public:authenticated'), getMyProfile) + handler.post('/api/my/profiles', can('public:authenticated'), setMyProfile) - 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 }) - }) + handler.put('/api/profiles/keys/:id', can('key:edit'), modifyProfileKey) + handler.delete('/api/profiles/keys/:id', can('key:edit'), deleteProfileKey) - /** - * Badge pages - * */ - router.get( - '/organizations/:id/badges/add', + handler.get( + '/api/profiles/keys/organizations/:id', can('organization:edit'), - (req, res) => { - return nextApp.render(req, res, '/badges/add', { id: req.params.id }) - } + getProfileKeys('org', 'org') ) - router.get( - '/organizations/:id/badges/:badgeId', + handler.post( + '/api/profiles/keys/organizations/:id', can('organization:edit'), - (req, res) => { - return nextApp.render(req, res, '/badges/edit', { - id: req.params.id, - badgeId: req.params.badgeId - }) - } + createProfileKeys('org', 'org') ) - // New badge assignment - router.get( - '/organizations/:id/badges/assign/:userId', + handler.get( + '/api/profiles/keys/organizations/:id/teams', + can('organization:view-team-keys'), + getProfileKeys('org', 'team') + ) + handler.post( + '/api/profiles/keys/organizations/:id/teams', can('organization:edit'), - (req, res) => nextApp.render(req, res, '/badges-assignment/new', req.params) + createProfileKeys('org', 'team') ) - // Edit badge assignment - router.get( - '/organizations/:id/badges/:badgeId/assign/:userId', + handler.get( + '/api/profiles/keys/organizations/:id/users', + can('organization:member'), + getProfileKeys('org', 'user') + ) + handler.post( + '/api/profiles/keys/organizations/:id/users', can('organization:edit'), - (req, res) => nextApp.render(req, res, '/badges-assignment/edit', req.params) + createProfileKeys('org', 'user') ) - return router + handler.get( + '/api/profiles/keys/teams/:id', + can('team:edit'), + getProfileKeys('team', 'team') + ) + handler.get( + '/api/profiles/keys/teams/:id/users', + can('team:member'), + getProfileKeys('team', 'user') + ) + handler.post( + '/api/profiles/keys/teams/:id', + can('team:edit'), + createProfileKeys('team', 'team') + ) + handler.post( + '/api/profiles/keys/teams/:id/users', + can('team:edit'), + createProfileKeys('team', 'user') + ) + + handler.get( + '/api/profiles/teams/:id', + can('public:authenticated'), + getTeamProfile + ) + handler.post('/api/profiles/teams/:id', can('team:edit'), setProfile('team')) + handler.post( + '/api/profiles/organizations/:id', + can('organization:edit'), + setProfile('org') + ) } module.exports = manageRouter diff --git a/app/manage/login.js b/app/manage/login.js index dba0e54d..bc8f5ed7 100644 --- a/app/manage/login.js +++ b/app/manage/login.js @@ -1,44 +1,49 @@ -const { serverRuntimeConfig, publicRuntimeConfig } = require('../../next.config') +const { serverRuntimeConfig } = require('../../next.config') const jwt = require('jsonwebtoken') -const db = require('../db') +const db = require('../../src/lib/db') + +const APP_URL = process.env.APP_URL const credentials = { client: { id: serverRuntimeConfig.OSM_HYDRA_ID, - secret: serverRuntimeConfig.OSM_HYDRA_SECRET + secret: serverRuntimeConfig.OSM_HYDRA_SECRET, }, auth: { tokenHost: serverRuntimeConfig.HYDRA_TOKEN_HOST, tokenPath: serverRuntimeConfig.HYDRA_TOKEN_PATH, - authorizeHost: serverRuntimeConfig.HYDRA_AUTHZ_HOST || serverRuntimeConfig.HYDRA_TOKEN_HOST, - authorizePath: serverRuntimeConfig.HYDRA_AUTHZ_PATH - } + authorizeHost: + serverRuntimeConfig.HYDRA_AUTHZ_HOST || + serverRuntimeConfig.HYDRA_TOKEN_HOST, + authorizePath: serverRuntimeConfig.HYDRA_AUTHZ_PATH, + }, } const oauth2 = require('simple-oauth2').create(credentials) var generateState = function (length) { var text = '' - var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + var possible = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' for (var i = 0; i < length; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length)) } return text } -function login (req, res) { +function login(req, res) { let state = generateState(24) const authorizationUri = oauth2.authorizationCode.authorizeURL({ - redirect_uri: `${publicRuntimeConfig.APP_URL}/login/accept`, + redirect_uri: `${APP_URL}/login/accept`, scope: 'openid clients', - state + state, }) req.session.login_csrf = state res.redirect(authorizationUri) } -async function loginAccept (req, res) { +async function loginAccept(req, res) { const { code, state } = req.query /** * Token exchange with CSRF handling @@ -55,7 +60,7 @@ async function loginAccept (req, res) { // Create options for token exchange const options = { code, - redirect_uri: `${publicRuntimeConfig.APP_URL}/login/accept` + redirect_uri: `${APP_URL}/login/accept`, } try { @@ -63,14 +68,15 @@ async function loginAccept (req, res) { const { sub } = jwt.decode(result.id_token) // Store access token and refresh token - let conn = await db() - await conn('users').where('id', sub).update({ - manageToken: JSON.stringify(result) - }) + await db('users') + .where('id', sub) + .update({ + manageToken: JSON.stringify(result), + }) // Store id token in session req.session.idToken = result.id_token - return res.redirect(`${publicRuntimeConfig.APP_URL}/profile`) + return res.redirect(`${APP_URL}/profile`) } catch (error) { console.error(error) return res.status(500).json('Authentication failed') @@ -83,15 +89,15 @@ async function loginAccept (req, res) { * @param {*} req * @param {*} res */ -function logout (req, res) { +function logout(req, res) { req.session.destroy(function (err) { if (err) console.error(err) - res.redirect(publicRuntimeConfig.APP_URL) + res.redirect(APP_URL) }) } module.exports = { login, loginAccept, - logout + logout, } diff --git a/app/manage/organizations.js b/app/manage/organizations.js index 8da5d06d..04662da6 100644 --- a/app/manage/organizations.js +++ b/app/manage/organizations.js @@ -1,19 +1,20 @@ -const organization = require('../lib/organization') -const team = require('../lib/team') +const organization = require('../../src/models/organization') +const team = require('../../src/models/team') const { teamsMembersModeratorsHelper } = require('./utils') const { map, prop } = require('ramda') +const Boom = require('@hapi/boom') /** * List organizations that a user is a member of */ -async function listMyOrgs (req, reply) { - const { user_id } = reply.locals +async function listMyOrgs(req, reply) { + const { user_id } = req.session try { const orgs = await organization.listMyOrganizations(user_id) reply.send(orgs) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } @@ -22,16 +23,16 @@ async function listMyOrgs (req, reply) { * Uses the user id in the request and the body to forward * to the organization model */ -async function createOrg (req, reply) { +async function createOrg(req, reply) { const { body } = req - const { user_id } = reply.locals + const { user_id } = req.session try { const data = await organization.create(body, user_id) reply.send(data) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } @@ -39,41 +40,36 @@ async function createOrg (req, reply) { * Get an organization's metadata * Requires id of organization */ -async function getOrg (req, reply) { +async function getOrg(req, reply) { const { id } = req.params - const { user_id } = reply.locals + const { user_id } = req.session if (!id) { - return reply.boom.badRequest('organization id is required') + throw Boom.badRequest('organization id is required') } - try { - let [data, isMemberOfOrg] = await Promise.all([ - organization.get(id), - organization.isMember(id, user_id) - ]) - reply.send({ ...data, isMemberOfOrg }) - } catch (err) { - console.log(err) - return reply.boom.badRequest(err.message) - } + let [data, isMemberOfOrg] = await Promise.all([ + organization.get(id), + organization.isMember(id, user_id), + ]) + reply.send({ ...data, isMemberOfOrg }) } /** * Get an organization's staff * Requires id of organization */ -async function getOrgStaff (req, reply) { +async function getOrgStaff(req, reply) { const { id } = req.params if (!id) { - return reply.boom.badRequest('organization id is required') + throw Boom.badRequest('organization id is required') } try { let [owners, managers] = await Promise.all([ organization.getOwners(id), - organization.getManagers(id) + organization.getManagers(id), ]) const ownerIds = map(prop('osm_id'), owners) const managerIds = map(prop('osm_id'), managers) @@ -87,15 +83,15 @@ async function getOrgStaff (req, reply) { reply.send({ owners, managers }) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } -async function getOrgMembers (req, reply) { +async function getOrgMembers(req, reply) { const { id } = req.params if (!id) { - return reply.boom.badRequest('organization id is required') + throw Boom.badRequest('organization id is required') } let { page } = req.query @@ -110,7 +106,7 @@ async function getOrgMembers (req, reply) { reply.send({ members, page }) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } @@ -118,12 +114,12 @@ async function getOrgMembers (req, reply) { * Update an organization * Requires the id of the organization to modify */ -async function updateOrg (req, reply) { +async function updateOrg(req, reply) { const { id } = req.params const { body } = req if (!id) { - return reply.boom.badRequest('organization id is required') + throw Boom.badRequest('organization id is required') } try { @@ -131,142 +127,137 @@ async function updateOrg (req, reply) { reply.send(data) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } /** * Destroy an organization */ -async function destroyOrg (req, reply) { +async function destroyOrg(req, reply) { const { id } = req.params if (!id) { - return reply.boom.badRequest('organization id is required') + throw Boom.badRequest('organization id is required') } try { await organization.destroy(id) - reply.sendStatus(200) + return reply.status(200).send() } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } /** * Add owner */ -async function addOwner (req, reply) { +async function addOwner(req, reply) { const { id, osmId } = req.params if (!id) { - return reply.boom.badRequest('organization id is required') + throw Boom.badRequest('organization id is required') } if (!osmId) { - return reply.boom.badRequest('osmId to add is required') + throw Boom.badRequest('osmId to add is required') } try { await organization.addOwner(id, Number(osmId)) - reply.sendStatus(200) + return reply.status(200).send() } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } /** * Remove owner */ -async function removeOwner (req, reply) { +async function removeOwner(req, reply) { const { id, osmId } = req.params if (!id) { - return reply.boom.badRequest('organization id is required') + throw Boom.badRequest('organization id is required') } if (!osmId) { - return reply.boom.badRequest('osmId to add is required') + throw Boom.badRequest('osmId to add is required') } try { await organization.removeOwner(id, Number(osmId)) - reply.sendStatus(200) + return reply.status(200).send() } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } /** * Add manager */ -async function addManager (req, reply) { +async function addManager(req, reply) { const { id, osmId } = req.params if (!id) { - return reply.boom.badRequest('organization id is required') + throw Boom.badRequest('organization id is required') } if (!osmId) { - return reply.boom.badRequest('osmId to add is required') + throw Boom.badRequest('osmId to add is required') } - try { - await organization.addManager(id, Number(osmId)) - reply.sendStatus(200) - } catch (err) { - console.log(err) - return reply.boom.badRequest(err.message) - } + await organization.addManager(id, Number(osmId)) + return reply.status(200).send() } /** * Remove manager */ -async function removeManager (req, reply) { +async function removeManager(req, reply) { const { id, osmId } = req.params if (!id) { - return reply.boom.badRequest('organization id is required') + throw Boom.badRequest('organization id is required') } if (!osmId) { - return reply.boom.badRequest('osmId to add is required') + throw Boom.badRequest('osmId to add is required') } try { await organization.removeManager(id, Number(osmId)) - reply.sendStatus(200) + return reply.status(200).send() } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } /** * Create org team */ -async function createOrgTeam (req, reply) { +async function createOrgTeam(req, reply) { const { id } = req.params const { body } = req - const { user_id } = reply.locals + const { user_id } = req.session try { const data = await organization.createOrgTeam(id, body, user_id) reply.send(data) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } /** * List org teams */ -async function getOrgTeams (req, reply) { +async function getOrgTeams(req, reply) { const { id } = req.params try { const data = await team.list({ organizationId: id }) @@ -274,7 +265,7 @@ async function getOrgTeams (req, reply) { reply.send(enhancedData) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } @@ -291,5 +282,5 @@ module.exports = { getOrgTeams, listMyOrgs, getOrgStaff, - getOrgMembers + getOrgMembers, } diff --git a/app/manage/permissions/clients.js b/app/manage/permissions/clients.js index e2adaf8d..5e896894 100644 --- a/app/manage/permissions/clients.js +++ b/app/manage/permissions/clients.js @@ -1,4 +1,4 @@ -const db = require('../../db') +const db = require('../../../src/lib/db') /** * clients @@ -10,10 +10,9 @@ const db = require('../../db') * @param {Object} params request parameters * @returns {boolean} can the request go through? */ -async function clients (uid) { +async function clients(uid) { try { - let conn = await db() - const [user] = await conn('users').where('id', uid) + const [user] = await db('users').where('id', uid) if (user) { return true } diff --git a/app/manage/permissions/create-org-team.js b/app/manage/permissions/create-org-team.js index c4a582c9..17aa2c5a 100644 --- a/app/manage/permissions/create-org-team.js +++ b/app/manage/permissions/create-org-team.js @@ -1,4 +1,4 @@ -const { isOwner, isManager } = require('../../lib/organization') +const { isOwner, isManager } = require('../../../src/models/organization') /** * organization:create-team @@ -11,7 +11,7 @@ const { isOwner, isManager } = require('../../lib/organization') * @param {int} params.id - organization id * @returns {Promise} */ -async function createOrgTeam (uid, { id }) { +async function createOrgTeam(uid, { id }) { return (await isOwner(id, uid)) || isManager(id, uid) } diff --git a/app/manage/permissions/delete-client.js b/app/manage/permissions/delete-client.js index 2a7c7651..6f0c664e 100644 --- a/app/manage/permissions/delete-client.js +++ b/app/manage/permissions/delete-client.js @@ -1,4 +1,4 @@ -const db = require('../../db') +const db = require('../../../src/lib/db') /** * client:delete @@ -9,10 +9,9 @@ const db = require('../../db') * @param uid * @returns {undefined} */ -async function deleteClient (uid, { id }) { - let conn = await db() - const [client] = await conn('hydra_client').where('id', id) - return (client.owner === uid) +async function deleteClient(uid, { id }) { + const [client] = await db('hydra_client').where('id', id) + return client.owner === uid } module.exports = deleteClient diff --git a/app/manage/permissions/edit-key.js b/app/manage/permissions/edit-key.js index 28062791..dc0aeb49 100644 --- a/app/manage/permissions/edit-key.js +++ b/app/manage/permissions/edit-key.js @@ -1,6 +1,6 @@ -const profile = require('../../lib/profile') -const organization = require('../../lib/organization') -const team = require('../../lib/team') +const profile = require('../../../src/models/profile') +const organization = require('../../../src/models/organization') +const team = require('../../../src/models/team') const R = require('ramda') /** @@ -12,7 +12,7 @@ const R = require('ramda') * @param {Object} params request parameters * @returns {Promise} can the request go through? */ -async function editKey (uid, { id }) { +async function editKey(uid, { id }) { // user has to be authenticated if (!uid) return false @@ -21,7 +21,7 @@ async function editKey (uid, { id }) { let owners = [] if (key.owner_user) { - return (uid.toString() === key.owner_user.toString()) + return uid.toString() === key.owner_user.toString() } if (key.owner_team) { @@ -31,7 +31,7 @@ async function editKey (uid, { id }) { if (key.owner_org) { owners = await organization.getOwners(key.owner_org) } - let osmIds = owners.map(owner => (R.prop('osm_id', owner)).toString()) + let osmIds = owners.map((owner) => R.prop('osm_id', owner).toString()) return osmIds.includes(uid.toString()) } diff --git a/app/manage/permissions/edit-org.js b/app/manage/permissions/edit-org.js index f8779f8d..a7a90283 100644 --- a/app/manage/permissions/edit-org.js +++ b/app/manage/permissions/edit-org.js @@ -1,4 +1,4 @@ -const { isOwner } = require('../../lib/organization') +const { isOwner } = require('../../../src/models/organization') /** * organization:edit @@ -11,7 +11,7 @@ const { isOwner } = require('../../lib/organization') * @param {int} params.id - organization id * @returns {Promise} */ -async function editOrg (uid, { id }) { +async function editOrg(uid, { id }) { return isOwner(id, uid) } diff --git a/app/manage/permissions/edit-team.js b/app/manage/permissions/edit-team.js index 15062a67..1bf3ea03 100644 --- a/app/manage/permissions/edit-team.js +++ b/app/manage/permissions/edit-team.js @@ -1,5 +1,5 @@ -const { isModerator, associatedOrg } = require('../../lib/team') -const { isOwner } = require('../../lib/organization') +const { isModerator, associatedOrg } = require('../../../src/models/team') +const { isOwner } = require('../../../src/models/organization') /** * team:update @@ -12,7 +12,7 @@ const { isOwner } = require('../../lib/organization') * @param {Object} params request parameters * @returns {Promise} can the request go through? */ -async function updateTeam (uid, { id }) { +async function updateTeam(uid, { id }) { // user has to be authenticated if (!uid) return false diff --git a/app/manage/permissions/edit-user.js b/app/manage/permissions/edit-user.js index 2e485913..8520aa6b 100644 --- a/app/manage/permissions/edit-user.js +++ b/app/manage/permissions/edit-user.js @@ -7,11 +7,11 @@ * @param {Object} params request parameters * @returns {Promise} can the request go through? */ -async function updateUser (uid, { id }) { +async function updateUser(uid, { id }) { // user has to be authenticated if (!uid) return false - return (uid === id) + return uid === id } module.exports = updateUser diff --git a/app/manage/permissions/index.js b/app/manage/permissions/index.js index 2cc6361a..2b5ed3bb 100644 --- a/app/manage/permissions/index.js +++ b/app/manage/permissions/index.js @@ -1,17 +1,16 @@ -const db = require('../../db') -const hydra = require('../../lib/hydra') +const Boom = require('boom') const { mergeAll, isNil } = require('ramda') const metaPermissions = { - 'public:authenticated': (uid) => (!isNil(uid)) // User needs to be authenticated + 'public:authenticated': (uid) => !isNil(uid), // User needs to be authenticated } const userPermissions = { - 'user:edit': require('./edit-user') + 'user:edit': require('./edit-user'), } const keyPermissions = { - 'key:edit': require('./edit-key') + 'key:edit': require('./edit-key'), } const teamPermissions = { @@ -19,7 +18,7 @@ const teamPermissions = { 'team:view': require('./view-team'), 'team:view-members': require('./view-team-members'), 'team:join': require('./join-team'), - 'team:member': require('./member-team') + 'team:member': require('./member-team'), } const organizationPermissions = { @@ -27,12 +26,12 @@ const organizationPermissions = { 'organization:create-team': require('./create-org-team'), 'organization:member': require('./member-org'), 'organization:view-members': require('./view-org-members'), - 'organization:view-team-keys': require('./view-org-team-keys') + 'organization:view-team-keys': require('./view-org-team-keys'), } const clientPermissions = { - 'clients': require('./clients'), - 'client:delete': require('./delete-client') + clients: require('./clients'), + 'client:delete': require('./delete-client'), } const permissions = mergeAll([ @@ -41,13 +40,9 @@ const permissions = mergeAll([ keyPermissions, teamPermissions, clientPermissions, - organizationPermissions + organizationPermissions, ]) -function isApiRequest ({ path }) { - return path.indexOf('/api') === 0 -} - /** * Check if a user has a specific permission * @@ -55,102 +50,15 @@ function isApiRequest ({ path }) { * @param {Object} res Response object * @param {String} ability String representing a specific permission, for example: `team:create` */ -async function checkPermission (req, res, ability) { - const locals = res.locals || {} - return permissions[ability](locals.user_id, req.params) -} - -/** - * Get token from authorization header or session - * depending on the request - * - * @param {Object} req Request object - * @return {String} token - */ -async function getToken (req) { - let token - if (req.session && req.session.user_id) { - token = await getSessionToken(req) - } else if (req.headers.authorization) { - token = getAuthHeaderToken(req) - } - return token -} - -/** - * Get token from the session - * - * @param {Object} req Request object - * @return {String} token - */ -async function getSessionToken (req) { - try { - let conn = await db() - let [userTokens] = await conn('users').where('id', req.session.user_id) - return userTokens.manageToken.access_token - } catch (err) { - throw err - } -} - -/** - * Get token from the authorization header - * - * @param {Object} req Request object - * @return {String} token - */ -function getAuthHeaderToken (req) { - const [type, token] = req.headers.authorization.split(' ') - if (type !== 'Bearer') throw new Error('Authorization scheme not supported. Only Bearer scheme is supported') - return token -} - -/** - * Takes an access token - * If it's valid, set the user id in the response object res.locals and forward to the next - * middleware. If it's not valid, send a 401 - * - * @param {String} token Access Token - * @param {Object} res Response object - * @param {Function} next Express middleware next - */ -async function acceptToken (token, res, next) { - let result = await hydra.introspect(token) - if (result && result.active) { - res.locals.user_id = result.sub - return next() - } else { - // Delete this accessToken ? - return res.boom.unauthorized('Expired token') - } -} - -/** - * Routes with `authenticate` middleware first check the session for the user, - * Get the associated accessToken from the database, and check for - * the accessToken validity with hydra. If there isn't a session, - * it checks for an Authorization header with a valid access token - */ -async function authenticate (req, res, next) { - try { - const token = await getToken(req) - - // if token exists, move to next middleware to check permissions - if (token) return acceptToken(token, res, next) - - // if no token, check ability in next middleware to see if user has access anyway in the case of public resources - return next() - } catch (e) { - console.log('error getting token', e) - return res.boom.unauthorized('Forbidden') - } +async function checkPermission(req, res, ability) { + return permissions[ability](req.session?.user_id, req.params) } /** * Given a permission, check if the user is allowed to perform the action * @param {string} ability the permission */ -function check (ability) { +function check(ability) { return async function (req, res, next) { /** * Permissions decision function @@ -158,41 +66,19 @@ function check (ability) { * @param {Object} params request parameters * @returns {boolean} can the request go through? */ - try { - let allowed = await checkPermission(req, res, ability) - - if (allowed) { - next() - } else { - if (isApiRequest(req)) { - res.boom.unauthorized('Forbidden') - } else { - next(new Error('Forbidden')) - } - } - } catch (e) { - console.error('error checking permission', e) - - if (isApiRequest(req)) { - // Handle API request errors - if (e.message.includes('osm id is required')) { - return res.boom.unauthorized('Forbidden') - } + let allowed = await checkPermission(req, res, ability) - // 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')) - } + if (allowed) { + next() + } else { + throw Boom.unauthorized('Forbidden') } } } module.exports = { can: (ability) => { - return [authenticate, check(ability)] + return [check(ability)] }, - authenticate, - check + check, } diff --git a/app/manage/permissions/join-team.js b/app/manage/permissions/join-team.js index 91a2ad12..252df760 100644 --- a/app/manage/permissions/join-team.js +++ b/app/manage/permissions/join-team.js @@ -1,4 +1,4 @@ -const { isPublic, isMember } = require('../../lib/team') +const { isPublic, isMember } = require('../../../src/models/team') /** * team:join @@ -9,7 +9,7 @@ const { isPublic, isMember } = require('../../lib/team') * @param {Object} params request parameters * @returns {boolean} can the request go through? */ -async function joinTeam (uid, { id }) { +async function joinTeam(uid, { id }) { // User has to be authenticated if (!uid) { return false diff --git a/app/manage/permissions/member-org.js b/app/manage/permissions/member-org.js index 4d4d33f4..908f8edc 100644 --- a/app/manage/permissions/member-org.js +++ b/app/manage/permissions/member-org.js @@ -1,4 +1,4 @@ -const { isMemberOrStaff } = require('../../lib/organization') +const { isMemberOrStaff } = require('../../../src/models/organization') /** * org:member @@ -9,7 +9,7 @@ const { isMemberOrStaff } = require('../../lib/organization') * @param {Object} params request parameters * @returns {boolean} can the request go through? */ -async function memberOrg (uid, { id }) { +async function memberOrg(uid, { id }) { try { return await isMemberOrStaff(id, uid) } catch (e) { diff --git a/app/manage/permissions/member-team.js b/app/manage/permissions/member-team.js index a4b644ec..9a19e9b8 100644 --- a/app/manage/permissions/member-team.js +++ b/app/manage/permissions/member-team.js @@ -1,4 +1,4 @@ -const { isMember } = require('../../lib/team') +const { isMember } = require('../../../src/models/team') /** * team:member @@ -9,7 +9,7 @@ const { isMember } = require('../../lib/team') * @param {Object} params request parameters * @returns {boolean} can the request go through? */ -async function memberTeam (uid, { id }) { +async function memberTeam(uid, { id }) { try { return await isMember(id, uid) } catch (e) { diff --git a/app/manage/permissions/view-org-members.js b/app/manage/permissions/view-org-members.js index 8bf0d6f1..042146b9 100644 --- a/app/manage/permissions/view-org-members.js +++ b/app/manage/permissions/view-org-members.js @@ -1,4 +1,7 @@ -const { isPublic, isMemberOrStaff } = require('../../lib/organization') +const { + isPublic, + isMemberOrStaff, +} = require('../../../src/models/organization') /** * org:view-members @@ -10,7 +13,7 @@ const { isPublic, isMemberOrStaff } = require('../../lib/organization') * @param {Object} params request parameters * @returns {boolean} can the request go through? */ -async function viewOrgMembers (uid, { id }) { +async function viewOrgMembers(uid, { id }) { try { const publicOrg = await isPublic(id) if (publicOrg) return publicOrg diff --git a/app/manage/permissions/view-org-team-keys.js b/app/manage/permissions/view-org-team-keys.js index de3c5047..aafea06a 100644 --- a/app/manage/permissions/view-org-team-keys.js +++ b/app/manage/permissions/view-org-team-keys.js @@ -1,4 +1,7 @@ -const { isOwner, isOrgTeamModerator } = require('../../lib/organization') +const { + isOwner, + isOrgTeamModerator, +} = require('../../../src/models/organization') /** * organization:view-team-keys @@ -11,7 +14,7 @@ const { isOwner, isOrgTeamModerator } = require('../../lib/organization') * @param {int} params.id - organization id * @returns {Promise} */ -async function editOrg (uid, { id }) { +async function editOrg(uid, { id }) { const teamModerator = await isOrgTeamModerator(id, uid) return teamModerator || isOwner(id, uid) } diff --git a/app/manage/permissions/view-team-members.js b/app/manage/permissions/view-team-members.js index 7386387a..b6c7a2be 100644 --- a/app/manage/permissions/view-team-members.js +++ b/app/manage/permissions/view-team-members.js @@ -1,5 +1,9 @@ -const { isPublic, isMember, associatedOrg } = require('../../lib/team') -const { isOwner } = require('../../lib/organization') +const { + isPublic, + isMember, + associatedOrg, +} = require('../../../src/models/team') +const { isOwner } = require('../../../src/models/organization') /** * team:view-members @@ -11,7 +15,7 @@ const { isOwner } = require('../../lib/organization') * @param {Object} params request parameters * @returns {boolean} can the request go through? */ -async function viewTeamMembers (uid, { id }) { +async function viewTeamMembers(uid, { id }) { const publicTeam = await isPublic(id) if (publicTeam) return publicTeam @@ -20,7 +24,7 @@ async function viewTeamMembers (uid, { 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) + return ownerOfTeam || (await isMember(id, uid)) } catch (e) { return false } diff --git a/app/manage/permissions/view-team.js b/app/manage/permissions/view-team.js index d94a7fe0..fa98317e 100644 --- a/app/manage/permissions/view-team.js +++ b/app/manage/permissions/view-team.js @@ -7,7 +7,7 @@ * * @returns {boolean} can the request go through? */ -async function viewTeam () { +async function viewTeam() { return true } diff --git a/app/manage/profiles.js b/app/manage/profiles.js index 7c3c46b1..4fb6c524 100644 --- a/app/manage/profiles.js +++ b/app/manage/profiles.js @@ -1,295 +1,310 @@ -const profile = require('../lib/profile') -const team = require('../lib/team') -const org = require('../lib/organization') +const profile = require('../../src/models/profile') +const team = require('../../src/models/team') +const org = require('../../src/models/organization') const { pick, prop, assoc } = require('ramda') const { ValidationError, PropertyRequiredError } = require('../lib/utils') +const Boom = require('@hapi/boom') /** * Gets a user profile in an org */ -async function getUserOrgProfile (req, reply) { +async function getUserOrgProfile(req, reply) { const { osmId, id: orgId } = req.params - const { user_id: requesterId } = reply.locals + const { user_id: requesterId } = req.session if (!osmId) { - return reply.boom.badRequest('osmId is required parameter') + throw Boom.badRequest('osmId is required parameter') } - try { - const values = await profile.getProfile('user', osmId) - const tags = prop('tags', values) - if (!values || !tags) { - return reply.sendStatus(404) - } + const values = await profile.getProfile('user', osmId) + const tags = prop('tags', values) + if (!values || !tags) { + throw Boom.notFound() + } + + let visibleKeys = [] + let orgKeys = [] + let requesterIsMemberOfOrg = false + let requesterIsManagerOfOrg = false + let requesterIsOwnerOfOrg = false - let visibleKeys = [] - let orgKeys = [] - let requesterIsMemberOfOrg = false - let requesterIsManagerOfOrg = false - let requesterIsOwnerOfOrg = false - - // Get org keys & visibility - orgKeys = await profile.getProfileKeysForOwner('org', orgId, 'user') - - if (requesterId === osmId) { - const allIds = orgKeys.map(prop('id')) - const allValues = pick(allIds, tags) - const keysToSend = orgKeys.map(key => { - return assoc('value', allValues[key.id], key) - }) - return reply.send(keysToSend) - } else { - requesterIsMemberOfOrg = await org.isMember(orgId, requesterId) - requesterIsManagerOfOrg = await org.isManager(orgId, requesterId) - requesterIsOwnerOfOrg = await org.isOwner(orgId, requesterId) - - // Get visibile keys - orgKeys.forEach((key) => { - const { visibility } = key - switch (visibility) { - case 'public': { + // Get org keys & visibility + orgKeys = await profile.getProfileKeysForOwner('org', orgId, 'user') + + if (requesterId === osmId) { + const allIds = orgKeys.map(prop('id')) + const allValues = pick(allIds, tags) + const keysToSend = orgKeys.map((key) => { + return assoc('value', allValues[key.id], key) + }) + return reply.send(keysToSend) + } else { + requesterIsMemberOfOrg = await org.isMember(orgId, requesterId) + requesterIsManagerOfOrg = await org.isManager(orgId, requesterId) + requesterIsOwnerOfOrg = await org.isOwner(orgId, requesterId) + + // Get visible keys + orgKeys.forEach((key) => { + const { visibility } = key + switch (visibility) { + case 'public': { + visibleKeys.push(key) + break + } + case 'org_staff': { + if (requesterIsOwnerOfOrg || requesterIsManagerOfOrg) { visibleKeys.push(key) - break - } - case 'org_staff': { - if (requesterIsOwnerOfOrg || requesterIsManagerOfOrg) { - visibleKeys.push(key) - } - break } - case 'org': { - if (requesterIsMemberOfOrg || requesterIsOwnerOfOrg || requesterIsManagerOfOrg) { - visibleKeys.push(key) - } - break + break + } + case 'org': { + if ( + requesterIsMemberOfOrg || + requesterIsOwnerOfOrg || + requesterIsManagerOfOrg + ) { + visibleKeys.push(key) } + break } - }) - } + } + }) + } - // Get values for keys - const visibleKeyIds = visibleKeys.map(prop('id')) - const visibleValues = pick(visibleKeyIds, tags) + // Get values for keys + const visibleKeyIds = visibleKeys.map(prop('id')) + const visibleValues = pick(visibleKeyIds, tags) - const keysToSend = visibleKeys.map(key => { - return assoc('value', visibleValues[key.id], key) - }) + const keysToSend = visibleKeys.map((key) => { + return assoc('value', visibleValues[key.id], key) + }) - return reply.send(keysToSend) - } catch (err) { - console.error(err) - return reply.boom.badImplementation() - } + return reply.send(keysToSend) } /** * Gets a team profile */ -async function getTeamProfile (req, reply) { +async function getTeamProfile(req, reply) { const { id: teamId } = req.params - const { user_id: requesterId } = reply.locals + const { user_id: requesterId } = req.session if (!teamId) { reply.boom.badRequest('teamId is required parameter') } - try { - let visibleKeys = [] - let teamKeys = [] - let requesterIsMemberOfTeam = false - let requesterIsMemberOfOrg = false - let requesterIsManagerOfOrg = false - let requesterIsOwnerOfOrg = false - - const values = await profile.getProfile('team', teamId) - const tags = prop('tags', values) - if (!values || !tags) { - return reply.sendStatus(404) - } + // try { + let visibleKeys = [] + let teamKeys = [] + let requesterIsMemberOfTeam = false + let requesterIsMemberOfOrg = false + let requesterIsManagerOfOrg = false + let requesterIsOwnerOfOrg = false + + const values = await profile.getProfile('team', teamId) + const tags = prop('tags', values) + if (!values || !tags) { + throw Boom.notFound() + } - // Get org keys & visibility - const associatedOrg = await team.associatedOrg(teamId) // Is the team part of an organization? + // Get org keys & visibility + const associatedOrg = await team.associatedOrg(teamId) // Is the team part of an organization? - if (!associatedOrg) { - return reply.sendStatus(404) - } + if (!associatedOrg) { + throw Boom.notFound() + } - const isUserModerator = await team.isModerator(teamId, requesterId) - - // Get team attributes from org - teamKeys = await profile.getProfileKeysForOwner('org', associatedOrg.organization_id, 'team') - requesterIsMemberOfTeam = await team.isMember(teamId, requesterId) // Is the requester part of this team? - requesterIsMemberOfOrg = await org.isMemberOrStaff(associatedOrg.organization_id, requesterId) - requesterIsManagerOfOrg = await org.isManager(associatedOrg.organization_id, requesterId) - requesterIsOwnerOfOrg = await org.isOwner(associatedOrg.organization_id, requesterId) - - if (isUserModerator || requesterIsOwnerOfOrg) { - const allIds = teamKeys.map(prop('id')) - const allValues = pick(allIds, tags) - const keysToSend = teamKeys.map(key => { - return assoc('value', allValues[key.id], key) - }) - return reply.send(keysToSend) - } + const isUserModerator = await team.isModerator(teamId, requesterId) + + // Get team attributes from org + teamKeys = await profile.getProfileKeysForOwner( + 'org', + associatedOrg.organization_id, + 'team' + ) + requesterIsMemberOfTeam = await team.isMember(teamId, requesterId) // Is the requester part of this team? + requesterIsMemberOfOrg = await org.isMemberOrStaff( + associatedOrg.organization_id, + requesterId + ) + requesterIsManagerOfOrg = await org.isManager( + associatedOrg.organization_id, + requesterId + ) + requesterIsOwnerOfOrg = await org.isOwner( + associatedOrg.organization_id, + requesterId + ) + + if (isUserModerator || requesterIsOwnerOfOrg) { + const allIds = teamKeys.map(prop('id')) + const allValues = pick(allIds, tags) + const keysToSend = teamKeys.map((key) => { + return assoc('value', allValues[key.id], key) + }) + return reply.send(keysToSend) + } - // Get visibile keys - teamKeys.forEach((key) => { - const { visibility } = key - switch (visibility) { - case 'public': { + // Get visible keys + teamKeys.forEach((key) => { + const { visibility } = key + switch (visibility) { + case 'public': { + visibleKeys.push(key) + break + } + case 'team': { + if (requesterIsMemberOfTeam) { visibleKeys.push(key) - break } - case 'team': { - if (requesterIsMemberOfTeam) { - visibleKeys.push(key) - } - break - } - case 'org_staff': { - if (requesterIsOwnerOfOrg || requesterIsManagerOfOrg) { - visibleKeys.push(key) - } - break + break + } + case 'org_staff': { + if (requesterIsOwnerOfOrg || requesterIsManagerOfOrg) { + visibleKeys.push(key) } - case 'org': { - if (requesterIsMemberOfOrg) { - visibleKeys.push(key) - } - break + break + } + case 'org': { + if (requesterIsMemberOfOrg) { + visibleKeys.push(key) } + break } - }) + } + }) - // Get values for keys - const visibleKeyIds = visibleKeys.map(prop('id')) - const visibleValues = pick(visibleKeyIds, tags) + // Get values for keys + const visibleKeyIds = visibleKeys.map(prop('id')) + const visibleValues = pick(visibleKeyIds, tags) - const keysToSend = visibleKeys.map(key => { - return assoc('value', visibleValues[key.id], key) - }) + const keysToSend = visibleKeys.map((key) => { + return assoc('value', visibleValues[key.id], key) + }) - return reply.send(keysToSend) - } catch (err) { - console.error(err) - return reply.boom.badImplementation() - } + return reply.send(keysToSend) } /** * Gets a user profile in a team */ -async function getUserTeamProfile (req, reply) { +async function getUserTeamProfile(req, reply) { const { osmId, id: teamId } = req.params - const { user_id: requesterId } = reply.locals + const { user_id: requesterId } = req.session if (!osmId) { - return reply.boom.badRequest('osmId is required parameter') + throw Boom.badRequest('osmId is required parameter') } - try { - let visibleKeys = [] - let teamKeys = [] - let requesterIsMemberOfTeam = false - let requesterIsMemberOfOrg = false - let requesterIsOwnerOfOrg = false - - const values = await profile.getProfile('user', osmId) - const tags = prop('tags', values) - if (!values || !tags) { - return reply.sendStatus(404) - } + let visibleKeys = [] + let teamKeys = [] + let requesterIsMemberOfTeam = false + let requesterIsMemberOfOrg = false + let requesterIsOwnerOfOrg = false - // Get team attributes - teamKeys = await profile.getProfileKeysForOwner('team', teamId, 'user') - requesterIsMemberOfTeam = await team.isMember(teamId, requesterId) // Is the requester part of this team? + const values = await profile.getProfile('user', osmId) + const tags = prop('tags', values) + if (!values || !tags) { + throw Boom.notFound() + } - // Get org keys & visibility - const associatedOrg = await team.associatedOrg(teamId) // Is the team part of an organization? - if (associatedOrg) { - requesterIsMemberOfOrg = await org.isMember(associatedOrg.organization_id, requesterId) - requesterIsOwnerOfOrg = await org.isOwner(associatedOrg.organization_id, requesterId) - } + // Get team attributes + teamKeys = await profile.getProfileKeysForOwner('team', teamId, 'user') + requesterIsMemberOfTeam = await team.isMember(teamId, requesterId) // Is the requester part of this team? + + // Get org keys & visibility + const associatedOrg = await team.associatedOrg(teamId) // Is the team part of an organization? + if (associatedOrg) { + requesterIsMemberOfOrg = await org.isMember( + associatedOrg.organization_id, + requesterId + ) + requesterIsOwnerOfOrg = await org.isOwner( + associatedOrg.organization_id, + requesterId + ) + } - if (requesterIsOwnerOfOrg) { - const allIds = teamKeys.map(prop('id')) - const allValues = pick(allIds, tags) - const keysToSend = teamKeys.map(key => { - return assoc('value', allValues[key.id], key) - }) - return reply.send(keysToSend) - } + if (requesterIsOwnerOfOrg) { + const allIds = teamKeys.map(prop('id')) + const allValues = pick(allIds, tags) + const keysToSend = teamKeys.map((key) => { + return assoc('value', allValues[key.id], key) + }) + return reply.send(keysToSend) + } - // Get visibile keys - teamKeys.forEach((key) => { - const { visibility } = key - switch (visibility) { - case 'public': { + // Get visible keys + teamKeys.forEach((key) => { + const { visibility } = key + switch (visibility) { + case 'public': { + visibleKeys.push(key) + break + } + case 'team': { + if (requesterIsMemberOfTeam) { visibleKeys.push(key) - break } - case 'team': { - if (requesterIsMemberOfTeam) { - visibleKeys.push(key) - } - break - } - case 'org': { - if (requesterIsMemberOfOrg) { - visibleKeys.push(key) - } - break + break + } + case 'org': { + if (requesterIsMemberOfOrg) { + visibleKeys.push(key) } + break } - }) + } + }) - // Get values for keys - const visibleKeyIds = visibleKeys.map(prop('id')) - const visibleValues = pick(visibleKeyIds, tags) + // Get values for keys + const visibleKeyIds = visibleKeys.map(prop('id')) + const visibleValues = pick(visibleKeyIds, tags) - const keysToSend = visibleKeys.map(key => { - return assoc('value', visibleValues[key.id], key) - }) + const keysToSend = visibleKeys.map((key) => { + return assoc('value', visibleValues[key.id], key) + }) - return reply.send(keysToSend) - } catch (err) { - console.error(err) - return reply.boom.badImplementation() - } + return reply.send(keysToSend) } /** * Create keys for profile */ -function createProfileKeys (ownerType, profileType) { +function createProfileKeys(ownerType, profileType) { return async function (req, reply) { const { id } = req.params const { body } = req if (!id) { - return reply.boom.badRequest('id is required parameter') + throw Boom.badRequest('id is required parameter') } try { - const attributesToAdd = body.map(({ name, description, required, visibility, key_type }) => { - return { - name, - description, - required, - visibility, - profileType, - key_type + const attributesToAdd = body.map( + ({ name, description, required, visibility, key_type }) => { + return { + name, + description, + required, + visibility, + profileType, + key_type, + } } - }) + ) const data = await profile.addProfileKeys(attributesToAdd, ownerType, id) return reply.send(data) } catch (err) { console.error(err) - if (err instanceof ValidationError || err instanceof PropertyRequiredError) { - return reply.boom.badRequest(err) + if ( + err instanceof ValidationError || + err instanceof PropertyRequiredError + ) { + throw Boom.badRequest(err) } - return reply.boom.badImplementation() + throw Boom.badImplementation() } } } @@ -297,68 +312,81 @@ function createProfileKeys (ownerType, profileType) { /** * Modify profile key */ -async function modifyProfileKey (req, reply) { +async function modifyProfileKey(req, reply) { const { id } = req.params const { body } = req if (!id) { - return reply.boom.badRequest('id is required parameter') + throw Boom.badRequest('id is required parameter') } try { await profile.modifyProfileKey(id, body) - return reply.sendStatus(200) + return reply.status(200).send() } catch (err) { console.error(err) - if (err instanceof ValidationError || err instanceof PropertyRequiredError) { - return reply.boom.badRequest(err) + if ( + err instanceof ValidationError || + err instanceof PropertyRequiredError + ) { + throw Boom.badRequest(err) } - return reply.boom.badImplementation() + throw Boom.badImplementation() } } /** * Delete profile key */ -async function deleteProfileKey (req, reply) { +async function deleteProfileKey(req, reply) { const { id } = req.params if (!id) { - return reply.boom.badRequest('id is required parameter') + throw Boom.badRequest('id is required parameter') } try { await profile.deleteProfileKey(id) - return reply.sendStatus(200) + return reply.status(200).send() } catch (err) { console.error(err) - if (err instanceof ValidationError || err instanceof PropertyRequiredError) { - return reply.boom.badRequest(err) + if ( + err instanceof ValidationError || + err instanceof PropertyRequiredError + ) { + throw Boom.badRequest(err) } - return reply.boom.badImplementation() + throw Boom.badImplementation() } } /** * Get the keys set by an owner */ -function getProfileKeys (ownerType, profileType) { +function getProfileKeys(ownerType, profileType) { return async function (req, reply) { const { id } = req.params if (!id) { - return reply.boom.badRequest('id is required parameter') + throw Boom.badRequest('id is required parameter') } try { - const data = await profile.getProfileKeysForOwner(ownerType, id, profileType) + const data = await profile.getProfileKeysForOwner( + ownerType, + id, + profileType + ) return reply.send(data) } catch (err) { console.error(err) - if (err instanceof ValidationError || err instanceof PropertyRequiredError) { - return reply.boom.badRequest(err) + if ( + err instanceof ValidationError || + err instanceof PropertyRequiredError + ) { + throw Boom.badRequest(err) } - return reply.boom.badImplementation() + throw Boom.badImplementation() } } } @@ -369,21 +397,21 @@ function getProfileKeys (ownerType, profileType) { * * @param {string} profileType - 'user', 'org', 'team' */ -function setProfile (profileType) { +function setProfile(profileType) { return async function (req, reply) { const { id } = req.params const { body } = req if (!id) { - return reply.boom.badRequest('id is required parameter') + throw Boom.badRequest('id is required parameter') } try { await profile.setProfile(body, profileType, id) - reply.sendStatus(200) + reply.status(200).send() } catch (err) { console.error(err) - return reply.boom.badImplementation() + throw Boom.badImplementation() } } } @@ -391,26 +419,26 @@ function setProfile (profileType) { /** * Get a user's profile */ -async function getMyProfile (req, reply) { - const { user_id } = reply.locals +async function getMyProfile(req, reply) { + const { user_id } = req.session try { const data = await profile.getProfile('user', user_id) return reply.send(data) } catch (err) { console.error(err) - return reply.boom.badImplementation() + throw Boom.badImplementation() } } -async function setMyProfile (req, reply) { - const { user_id } = reply.locals +async function setMyProfile(req, reply) { + const { user_id } = req.session const { body } = req try { await profile.setProfile(body, 'user', user_id) - return reply.sendStatus((200)) + return reply.status(200).send() } catch (err) { console.error(err) - return reply.boom.badImplementation() + throw Boom.badImplementation() } } @@ -424,5 +452,5 @@ module.exports = { getMyProfile, setMyProfile, setProfile, - getTeamProfile + getTeamProfile, } diff --git a/app/manage/sessions.js b/app/manage/sessions.js index 6e6c39c5..c71e83c6 100644 --- a/app/manage/sessions.js +++ b/app/manage/sessions.js @@ -2,7 +2,7 @@ const jwt = require('jsonwebtoken') const session = require('express-session') const SessionStore = require('connect-session-knex')(session) const knex = require('knex') -const connections = require('../db/knexfile') +const connections = require('../../knexfile') const { serverRuntimeConfig } = require('../../next.config') const knexConfig = connections[process.env.NODE_ENV] @@ -10,13 +10,17 @@ const knexConfig = connections[process.env.NODE_ENV] /** * Configure the session */ -const SESSION_SECRET = serverRuntimeConfig.SESSION_SECRET || 'super-secret-sessions' +const SESSION_SECRET = + serverRuntimeConfig.SESSION_SECRET || 'super-secret-sessions' let sessionConfig = { name: 'osm-teams.sid', secret: SESSION_SECRET, resave: false, saveUninitialized: true, - store: new SessionStore({ knex: knex(knexConfig), tableName: 'app_sessions' }) + store: new SessionStore({ + knex: knex(knexConfig), + tableName: 'app_sessions', + }), } /** @@ -24,7 +28,7 @@ let sessionConfig = { * * @param {jwt} decoded the decoded jwt token */ -function assertAlive (decoded) { +function assertAlive(decoded) { const now = Date.now().valueOf() / 1000 if (typeof decoded.exp !== 'undefined' && decoded.exp < now) { return false @@ -39,7 +43,7 @@ function assertAlive (decoded) { * After we've logged in, we should have a jwt token in the session * We attach the information from the jwt token to the session */ -function attachUser () { +function attachUser() { return function (req, res, next) { if (req.session) { if (req.session.idToken) { diff --git a/app/manage/teams.js b/app/manage/teams.js index bd5885aa..09033466 100644 --- a/app/manage/teams.js +++ b/app/manage/teams.js @@ -1,21 +1,22 @@ -const team = require('../lib/team') -const db = require('../db') +const team = require('../../src/models/team') +const db = require('../../src/lib/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') -const profile = require('../lib/profile') +const profile = require('../../src/models/profile') +const Boom = require('@hapi/boom') const isUrl = urlRegex({ exact: true }) const getOsmId = prop('osm_id') -async function listTeams (req, reply) { +async function listTeams(req, reply) { const { osmId, bbox } = req.query let bounds = bbox if (bbox) { - bounds = bbox.split(',').map(num => parseFloat(num)) + bounds = bbox.split(',').map((num) => parseFloat(num)) if (bounds.length !== 4) { reply.boom.badRequest('error in bbox param') } @@ -27,31 +28,31 @@ async function listTeams (req, reply) { reply.send(enhancedData) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } -async function listMyTeams (req, reply) { - const { user_id: osmId } = reply.locals +async function listMyTeams(req, reply) { + const { user_id: osmId } = req.session try { const memberOfTeams = await team.list({ osmId }) const moderatorOfTeams = await team.listModeratedBy(osmId) const result = { osmId, member: memberOfTeams, - moderator: moderatorOfTeams + moderator: moderatorOfTeams, } reply.send(result) } catch (err) { - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } -async function getTeam (req, reply) { +async function getTeam(req, reply) { const { id } = req.params if (!id) { - return reply.boom.badRequest('team id is required') + throw Boom.badRequest('Team id is required') } try { @@ -59,40 +60,51 @@ async function getTeam (req, reply) { const associatedOrg = await team.associatedOrg(id) if (!teamData) { - return reply.boom.notFound() + throw Boom.notFound() } return reply.send(Object.assign({}, teamData, { org: associatedOrg })) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } -async function getTeamMembers (req, reply) { - const { id } = req.params +const getTeamMembers = routeWrapper({ + validate: { + params: yup + .object({ + id: yup.number().required().positive().integer(), + }) + .required(), + }, + handler: async (req, reply) => { + const { id } = req.params - if (!id) { - return reply.boom.badRequest('team id is required') - } + if (!id) { + throw Boom.badRequest('team id is required') + } - try { - const memberIds = map(getOsmId, (await team.getMembers(id))) - const members = await team.resolveMemberNames(memberIds) - const moderators = await team.getModerators(id) + try { + const memberIds = map(getOsmId, await team.getMembers(id)) + const members = await team.resolveMemberNames(memberIds) + const moderators = await team.getModerators(id) - return reply.send(Object.assign({}, { teamId: id }, { members, moderators })) - } catch (err) { - console.log(err) - return reply.boom.badRequest(err.message) - } -} + return reply.send( + Object.assign({}, { teamId: id }, { members, moderators }) + ) + } catch (err) { + console.log(err) + throw Boom.badRequest(err.message) + } + }, +}) -async function createTeam (req, reply) { +async function createTeam(req, reply) { const { body } = req - const { user_id } = reply.locals + const { user_id } = req.session if (body.editing_policy && !isUrl.test(body.editing_policy)) { - return reply.boom.badRequest('editing_policy must be a valid url') + throw Boom.badRequest('editing_policy must be a valid url') } try { @@ -100,20 +112,20 @@ async function createTeam (req, reply) { reply.send(data) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } -async function updateTeam (req, reply) { +async function updateTeam(req, reply) { const { id } = req.params const { body } = req if (!id) { - return reply.boom.badRequest('team id is required') + throw Boom.badRequest('team id is required') } if (body.editing_policy && !isUrl.test(body.editing_policy)) { - return reply.boom.badRequest('editing_policy must be a valid url') + throw Boom.badRequest('editing_policy must be a valid url') } try { @@ -129,19 +141,21 @@ async function updateTeam (req, reply) { reply.send(updatedTeam) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } -async function assignModerator (req, reply) { +async function assignModerator(req, reply) { const { id: teamId, osmId } = req.params if (!teamId) { - return reply.boom.badRequest('team id is required') + throw Boom.badRequest('team id is required') } if (!osmId) { - return reply.boom.badRequest('osm id of member to promote to moderator is required') + throw Boom.badRequest( + 'osm id of member to promote to moderator is required' + ) } try { @@ -149,19 +163,21 @@ async function assignModerator (req, reply) { reply.send(data) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } -async function removeModerator (req, reply) { +async function removeModerator(req, reply) { const { id: teamId, osmId } = req.params if (!teamId) { - return reply.boom.badRequest('team id is required') + throw Boom.badRequest('team id is required') } if (!osmId) { - return reply.boom.badRequest('osm id of member to demote from moderator is required') + throw Boom.badRequest( + 'osm id of member to demote from moderator is required' + ) } try { @@ -169,56 +185,56 @@ async function removeModerator (req, reply) { reply.send(data) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } -async function destroyTeam (req, reply) { +async function destroyTeam(req, reply) { const { id } = req.params if (!id) { - return reply.boom.badRequest('team id is required') + throw Boom.badRequest('team id is required') } try { await team.destroy(id) - reply.sendStatus(200) + return reply.status(200).send() } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } -async function addMember (req, reply) { +async function addMember(req, reply) { const { id, osmId } = req.params if (!id) { - return reply.boom.badRequest('team id is required') + throw Boom.badRequest('team id is required') } if (!osmId) { - return reply.boom.badRequest('osm id is required') + throw Boom.badRequest('osm id is required') } try { await team.addMember(id, osmId) - return reply.sendStatus(200) + return reply.status(200).send() } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } -async function updateMembers (req, reply) { +async function updateMembers(req, reply) { const { id } = req.params const { add, remove } = req.body if (!id) { - return reply.boom.badRequest('team id is required') + throw Boom.badRequest('team id is required') } if (!add && !remove) { - return reply.boom.badRequest('osm ids are required') + throw Boom.badRequest('osm ids are required') } try { @@ -233,145 +249,155 @@ async function updateMembers (req, reply) { await team.resolveMemberNames(members) await team.updateMembers(id, add, remove) - return reply.sendStatus(200) + return reply.status(200).send() } catch (err) { console.error(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } -async function removeMember (req, reply) { +async function removeMember(req, reply) { const { id, osmId } = req.params if (!id) { - return reply.boom.badRequest('team id is required') + throw Boom.badRequest('team id is required') } if (!osmId) { - return reply.boom.badRequest('osm id is required') + throw Boom.badRequest('osm id is required') } try { await team.removeMember(id, osmId) - return reply.sendStatus(200) + return reply.status(200).send() } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } const getJoinInvitations = routeWrapper({ validate: { - params: yup.object({ - id: yup.number().required().positive().integer() - }).required() + 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 + const invitations = await db('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() + 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('*') + const [invitation] = await db('invitations') + .insert({ + id: uuid, + team_id: req.params.id, + }) + .returning('*') reply.send(invitation) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } - } + }, }) const deleteJoinInvitation = routeWrapper({ validate: { - params: yup.object({ - id: yup.number().required().positive().integer(), - uuid: yup.string().uuid().required() - }).required() + 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) + await db('invitations') + .where({ + team_id: req.params.id, + id: req.params.uuid, + }) + .del() + return reply.status(200).send() } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } - } + }, }) const acceptJoinInvitation = routeWrapper({ validate: { - params: yup.object({ - id: yup.number().required().positive().integer(), - uuid: yup.string().uuid().required() - }).required() + 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 + const user = req.session.user_id try { - const conn = await db() - const [invitation] = await conn('invitations').where({ + const [invitation] = await db('invitations').where({ team_id: req.params.id, - id: req.params.uuid + id: req.params.uuid, }) // If this invitation doesn't exist, then it's not valid if (!invitation) { - return reply.sendStatus(404) + return reply.status(404).send() } else { team.addMember(req.params.id, user) - return reply.sendStatus(200) + return reply.status(200).send() } } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } - } + }, }) -async function joinTeam (req, reply) { +async function joinTeam(req, reply) { const { id } = req.params - const osmId = reply.locals.user_id + const osmId = req.session.user_id if (!id) { - return reply.boom.badRequest('team id is required') + throw Boom.badRequest('team id is required') } if (!osmId) { - return reply.boom.badRequest('osm id is required') + throw Boom.badRequest('osm id is required') } try { await team.addMember(id, osmId) - return reply.sendStatus(200) + return reply.status(200).send() } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + throw Boom.badRequest(err.message) } } @@ -392,5 +418,5 @@ module.exports = { getJoinInvitations, createJoinInvitation, deleteJoinInvitation, - acceptJoinInvitation + acceptJoinInvitation, } diff --git a/app/manage/utils.js b/app/manage/utils.js index 6f144bf0..af9dafb9 100644 --- a/app/manage/utils.js +++ b/app/manage/utils.js @@ -1,4 +1,4 @@ -const team = require('../lib/team') +const team = require('../../src/models/team') const { prop } = require('ramda') const getId = prop('id') @@ -12,18 +12,18 @@ const getOsmId = prop('osm_id') * @returns {Promise<*>} * @async */ -async function teamsMembersModeratorsHelper (teamsData) { +async function teamsMembersModeratorsHelper(teamsData) { const teamIds = teamsData.map(getId) const [members, moderators] = await Promise.all([ team.listMembers(teamIds), - team.listModerators(teamIds) + team.listModerators(teamIds), ]) - return teamsData.map(team => { + return teamsData.map((team) => { const predicate = ({ team_id }) => team_id === team.id return { ...team, members: members.filter(predicate).map(getOsmId), - moderators: moderators.filter(predicate).map(getOsmId) + moderators: moderators.filter(predicate).map(getOsmId), } }) } @@ -36,7 +36,7 @@ async function teamsMembersModeratorsHelper (teamsData) { * * @returns {function} Route middleware function */ -function routeWrapper (config) { +function routeWrapper(config) { const { validate, handler } = config return async (req, reply) => { try { @@ -57,5 +57,5 @@ function routeWrapper (config) { module.exports = { teamsMembersModeratorsHelper, - routeWrapper + routeWrapper, } diff --git a/app/oauth/consent.js b/app/oauth/consent.js index ef79ffeb..c42bfeb6 100644 --- a/app/oauth/consent.js +++ b/app/oauth/consent.js @@ -4,24 +4,24 @@ const hydra = require('../lib/hydra') const url = require('url') -const db = require('../db') +const db = require('../../src/lib/db') const { serverRuntimeConfig } = require('../../next.config') const { path } = require('ramda') -async function idTokenExtraParams (sub) { - const conn = await db() - const [user] = await conn('users').where('id', sub) +async function idTokenExtraParams(sub) { + const [user] = await db('users').where('id', sub) const { profile } = user const displayName = profile.displayName || sub - const picture = path(['_xml2json', 'user', 'img', '@', 'href'], profile) || + const picture = + path(['_xml2json', 'user', 'img', '@', 'href'], profile) || `https://www.gravatar.com/avatar/${sub}?d=identicon` return { preferred_username: displayName, - picture + picture, } } -function getConsent (app) { +function getConsent(app) { return async (req, res, next) => { const query = url.parse(req.url, true).query const challenge = query.consent_challenge @@ -31,13 +31,16 @@ function getConsent (app) { let idToken = await idTokenExtraParams(consent.subject) // We can skip if skip is set to yes or if the requesting app is the management UI - if (consent.skip || consent.client.client_id === serverRuntimeConfig.OSM_HYDRA_ID) { + if ( + consent.skip || + consent.client.client_id === serverRuntimeConfig.OSM_HYDRA_ID + ) { let accept = await hydra.acceptConsentRequest(challenge, { grant_scope: consent.requested_scope, grant_access_token_audience: consent.requested_access_token_audience, session: { - id_token: idToken - } + id_token: idToken, + }, }) res.redirect(accept.redirect_to) @@ -46,7 +49,7 @@ function getConsent (app) { challenge: challenge, requested_scope: consent.requested_scope, user: idToken.preferred_username, - client: consent.client + client: consent.client, }) } } catch (e) { @@ -59,14 +62,14 @@ function getConsent (app) { * Process the reply of the user, whether they * consent the client to access their information */ -function postConsent (app) { +function postConsent() { return async (req, res, next) => { const challenge = req.body.challenge if (req.body.submit === 'Deny access') { try { let reject = await hydra.rejectConsentRequest(challenge, { error: 'access_denied', - error_description: 'The resource owner denied the request' + error_description: 'The resource owner denied the request', }) res.redirect(reject.redirect_to) } catch (e) { @@ -84,11 +87,11 @@ function postConsent (app) { let accept = await hydra.acceptConsentRequest(challenge, { grant_scope, session: { - id_token: idToken + id_token: idToken, }, grant_access_token_audience: consent.requested_access_token_audience, remember: Boolean(req.body.remember), - remember_for: 3600 + remember_for: 3600, }) res.redirect(accept.redirect_to) } catch (e) { @@ -101,5 +104,5 @@ function postConsent (app) { module.exports = { getConsent, - postConsent + postConsent, } diff --git a/app/oauth/index.js b/app/oauth/index.js index 33e71111..8281fc50 100644 --- a/app/oauth/index.js +++ b/app/oauth/index.js @@ -13,10 +13,12 @@ const logger = require('../lib/logger') * * @param {Object} nextApp the NextJS Server */ -function oauthRouter (nextApp) { - router.use(expressPino({ - logger: logger.child({ module: 'oauth' }) - })) +function oauthRouter(nextApp) { + router.use( + expressPino({ + logger: logger.child({ module: 'oauth' }), + }) + ) /** * Redirecting to openstreetmp @@ -29,9 +31,11 @@ function oauthRouter (nextApp) { */ router.get('/login', getLogin(nextApp)) router.get('/consent', getConsent(nextApp)) - router.post('/consent', + router.post( + '/consent', bodyParser.urlencoded({ extended: false }), - postConsent(nextApp)) + postConsent(nextApp) + ) return router } diff --git a/app/oauth/login.js b/app/oauth/login.js index 37c260a2..06a0674f 100644 --- a/app/oauth/login.js +++ b/app/oauth/login.js @@ -5,8 +5,8 @@ const hydra = require('../lib/hydra') const url = require('url') -function getLogin (app) { - return async function login (req, res, next) { +function getLogin(app) { + return async function login(req, res, next) { const query = url.parse(req.url, true).query const challenge = query.login_challenge if (!challenge) return next() @@ -17,11 +17,13 @@ function getLogin (app) { // TODO check if the user has revoked their OSM token if (skip) { - const { redirect_to } = await hydra.acceptLoginRequest(challenge, { subject }) + const { redirect_to } = await hydra.acceptLoginRequest(challenge, { + subject, + }) res.redirect(redirect_to) } else { app.render(req, res, '/login', { - challenge: challenge + challenge: challenge, }) } } catch (e) { @@ -31,5 +33,5 @@ function getLogin (app) { } module.exports = { - getLogin + getLogin, } diff --git a/app/tests/README.md b/app/tests/README.md deleted file mode 100644 index 00c70e5c..00000000 --- a/app/tests/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Tests - -Tests are run using [ava](https://npmjs.com/ava). - -Linting is performed using [standard](https://npmjs.com/standard) - -## Running tests - -```sh -git clone {this repo} -cd {this repo} -npm install -npm test -``` - -`npm test` runs only the tests. - -### Run the linter - -```sh -npm run lint -``` - -### Run both the tests and the linter - -```sh -npm run test-lint -``` diff --git a/app/tests/api/organization-api.test.js b/app/tests/api/organization-api.test.js deleted file mode 100644 index 41388338..00000000 --- a/app/tests/api/organization-api.test.js +++ /dev/null @@ -1,223 +0,0 @@ -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') - -let agent - -test.before(async () => { - const conn = await db() - - await resetDb(conn) - - // seed - await conn('users').insert({ id: 1 }) - await conn('users').insert({ id: 2 }) - await conn('users').insert({ id: 3 }) - await conn('users').insert({ id: 4 }) - - // Ensure authenticate middleware always goes through with user_id 1 - const middleware = function () { - return function (req, res, next) { - res.locals.user_id = 1 - return next() - } - } - - sinon.stub(permissions, 'can').callsFake(middleware) - sinon.stub(permissions, 'authenticate').callsFake(middleware) - sinon.stub(permissions, 'check').callsFake(middleware) - - agent = require('supertest').agent(await require('../../index')()) -}) - -/** - * Test create an organization - */ -test('create an organization', async t => { - const res = await agent.post('/api/organizations') - .send({ name: 'create an organization' }) - .expect(200) - - t.is(res.body.name, 'create an organization') -}) - -/** - * Test organization requires a name - */ -test('organization requires name', async t => { - const res = await agent.post('/api/organizations') - .send({ }) - .expect(400) - - t.is(res.body.message, 'data.name property is required') -}) - -/** - * Test get an organization - */ -test('get organization', async t => { - const res = await agent.post('/api/organizations') - .send({ name: 'get organization' }) - .expect(200) - - const org = await agent.get(`/api/organizations/${res.body.id}`) - - t.is(org.body.name, 'get organization') -}) - -/** - * Test get an organization's staff - */ -test('get organization staff', async t => { - const res = await agent.post('/api/organizations') - .send({ name: 'get organization staff' }) - .expect(200) - - const org = await agent.get(`/api/organizations/${res.body.id}/staff`) - - t.is(org.body.owners.length, 1) - t.is(org.body.managers.length, 1) -}) - -/** - * Test update organization - */ -test('update organization', async t => { - const res = await agent.post('/api/organizations') - .send({ name: 'update organization' }) - .expect(200) - - const updated = await agent.put(`/api/organizations/${res.body.id}`) - .send({ name: 'update organization 2' }) - .expect(200) - - t.is(updated.body.name, 'update organization 2') -}) - -/** - * Test destroy organization - */ -test('destroy organization', async t => { - const res = await agent.post('/api/organizations') - .send({ name: 'update organization' }) - .expect(200) - - await agent.delete(`/api/organizations/${res.body.id}`) - .expect(200) - - const org = await organization.get(res.body.id) - t.falsy(org) -}) - -/** - * Add owner - */ -test('add owner', async t => { - const res = await agent.post('/api/organizations') - .send({ name: 'add owner' }) - .expect(200) - - await agent.put(`/api/organizations/${res.body.id}/addOwner/2`) - .expect(200) - - const owners = await organization.getOwners(res.body.id) - - t.is(owners.length, 2) -}) - -/** - * Remove owner - */ -test('remove owner', async t => { - const res = await agent.post('/api/organizations') - .send({ name: 'remove owner' }) - .expect(200) - - await organization.addOwner(res.body.id, 2) - - await agent.put(`/api/organizations/${res.body.id}/removeOwner/2`) - .expect(200) - - const owners = await organization.getOwners(res.body.id) - - t.is(owners.length, 1) -}) - -/** - * Add manager - */ -test('add manager', async t => { - const res = await agent.post('/api/organizations') - .send({ name: 'add manager' }) - .expect(200) - - await agent.put(`/api/organizations/${res.body.id}/addManager/2`) - .expect(200) - - const owners = await organization.getManagers(res.body.id) - - t.is(owners.length, 2) -}) - -/** - * Remove manager - */ -test('remove manager', async t => { - const res = await agent.post('/api/organizations') - .send({ name: 'remove manager' }) - .expect(200) - - await organization.addManager(res.body.id, 2) - - await agent.put(`/api/organizations/${res.body.id}/removeManager/2`) - .expect(200) - - const owners = await organization.getManagers(res.body.id) - - t.is(owners.length, 1) -}) - -/** - * Create org team - */ -test('create an on org team', async t => { - const teamName = 'create org team - team 1' - const res = await agent.post('/api/organizations') - .send({ name: 'create org team' }) - .expect(200) - - await agent.post(`/api/organizations/${res.body.id}/teams`) - .send({ name: teamName }) - .expect(200) - - const orgTeams = await team.list({ organizationId: res.body.id }) - t.is(orgTeams.length, 1) -}) - -/** - * Get org teams - */ -test('get org teams', async t => { - const teamName1 = 'get org team - team 1' - const teamName2 = 'get org team - team 2' - const res = await agent.post('/api/organizations') - .send({ name: 'get org team' }) - .expect(200) - - await organization.createOrgTeam(res.body.id, { name: teamName1 }, 1) - await organization.createOrgTeam(res.body.id, { name: teamName2 }, 1) - - const orgTeams = await agent.get(`/api/organizations/${res.body.id}/teams`) - t.is(orgTeams.body.length, 2) - orgTeams.body.forEach(item => { - t.truthy(item.name) - t.truthy(item.id) - t.truthy(item.members.length) - t.truthy(item.moderators.length) - }) -}) diff --git a/app/tests/api/team-api.test.js b/app/tests/api/team-api.test.js deleted file mode 100644 index 59364047..00000000 --- a/app/tests/api/team-api.test.js +++ /dev/null @@ -1,281 +0,0 @@ -const test = require('ava') -const sinon = require('sinon') -const { any } = require('ramda') - -const db = require('../../db') -const team = require('../../lib/team') -const permissions = require('../../manage/permissions') -const { resetDb } = require('../utils') - -let agent -test.before(async () => { - const conn = await db() - - await resetDb(conn) - - // seed - await conn('users').insert({ id: 1 }) - await conn('users').insert({ id: 2 }) - await conn('users').insert({ id: 3 }) - await conn('users').insert({ id: 4 }) - - // Ensure authenticate middleware always goes through with user_id 1 - const middleware = function () { - return function (req, res, next) { - res.locals.user_id = 1 - return next() - } - } - sinon.stub(permissions, 'can').callsFake(middleware) - sinon.stub(permissions, 'authenticate').callsFake(middleware) - sinon.stub(permissions, 'check').callsFake(middleware) - - // Ensure that resolveMemberNames never calls osm - sinon.stub(team, 'resolveMemberNames').callsFake((ids) => { - return ids.map(id => ({ id, name: 'fake name' })) - }) - - agent = require('supertest').agent(await require('../../index')()) -}) - -test('create a team', async t => { - let res = await agent.post('/api/teams') - .send({ name: 'road team 1' }) - .expect(200) - - t.is(res.body.name, 'road team 1') -}) - -test('team requires name column', async (t) => { - let res = await agent.post('/api/teams') - .send({}) - .expect(400) - - t.is(res.body.message, 'data.name property is required') -}) - -test('update a team', async t => { - let res = await agent.post('/api/teams') - .send({ name: 'map team 1' }) - .expect(200) - - t.is(res.body.name, 'map team 1') - - let updated = await agent.put(`/api/teams/${res.body.id}`) - .send({ name: 'poi team 1' }) - .expect(200) - - t.is(updated.body.name, 'poi team 1') -}) - -test('destroy a team', async t => { - let res = await agent.post('/api/teams') - .send({ name: 'map team 2' }) - .expect(200) - - t.is(res.body.name, 'map team 2') - - await agent.delete(`/api/teams/${res.body.id}`) - .expect(200) -}) - -test('team.editing_policy must be a valid url', async t => { - let validUrlRes = await agent.post('/api/teams') - .send({ name: 'road team 200', editing_policy: 'https://roadteam.com/policy' }) - t.is(validUrlRes.status, 200) - - let errorRes = await agent.post('/api/teams') - .send({ name: 'road team 400', editing_policy: 'nope' }) - t.is(errorRes.body.message, 'editing_policy must be a valid url') - t.is(errorRes.status, 400) -}) - -test('get a team', async t => { - let res = await agent.post('/api/teams') - .send({ name: 'map team 3' }) - .expect(200) - - t.is(res.body.name, 'map team 3') - - let team = await agent.get(`/api/teams/${res.body.id}`) - .expect(200) - - t.is(team.body.id, res.body.id) - t.is(team.body.name, res.body.name) - t.is(team.body.members.length, 1) -}) - -test.only('get team list', async t => { - await agent.post('/api/teams') - .send({ name: 'map team 5' }) - .expect(200) - - let teams = await agent.get(`/api/teams`) - .expect(200) - - t.true(teams.body.length > 0) - teams.body.forEach(item => { - t.truthy(item.name) - t.truthy(item.id) - t.truthy(item.members.length) - t.truthy(item.moderators.length) - }) -}) - -test('add member to team', async t => { - let team = await agent.post('/api/teams') - .send({ name: 'map team 6' }) - .expect(200) - - await agent.put(`/api/teams/add/${team.body.id}/1`) - .expect(200) - - let updated = await agent.get(`/api/teams/${team.body.id}`) - .expect(200) - - t.is(updated.body.id, team.body.id) - t.is(updated.body.members.length, 1) - t.is(updated.body.members[0].id, 1) -}) - -test('remove member from team', async t => { - let team = await agent.post('/api/teams') - .send({ name: 'map team 7' }) - .expect(200) - const { id: teamId } = team.body - await agent.put(`/api/teams/add/${teamId}/1`) - .expect(200) - // add a second member, because osm_id 1 is the moderator by default - const osmIdToCheck = 2 - await agent.put(`/api/teams/add/${teamId}/${osmIdToCheck}`) - .expect(200) - await agent.put(`/api/teams/remove/${teamId}/${osmIdToCheck}`) - .expect(200) - let updated = await agent.get(`/api/teams/${teamId}`) - .expect(200) - t.is(updated.body.id, teamId) - const { members } = updated.body - t.is(members.length, 1) - t.false(members[0].osm_id === osmIdToCheck) -}) - -test('updated members in team', async t => { - let team = await agent.post('/api/teams') - .send({ name: 'map team 8' }) - .expect(200) - - await agent.patch(`/api/teams/${team.body.id}/members`) - .send({ add: [1, 2, 3] }) - .expect(200) - - let updated = await agent.get(`/api/teams/${team.body.id}`) - .expect(200) - - t.is(updated.body.id, team.body.id) - t.is(updated.body.members.length, 3) -}) - -test('get list of teams by osm id', async t => { - await agent.post('/api/teams') - .send({ name: 'get list of teams by osm id' }) - .expect(200) - - let teams = await agent.get(`/api/teams?osmId=1`) - .expect(200) - - t.true(teams.body.length > 0) -}) - -test('get list of teams by bbox', async t => { - await agent.post('/api/teams') - .send({ name: 'team with location', location: `{ - "type": "Point", - "coordinates": [0, 0] - }` }) - .expect(200) - - let teams = await agent.get(`/api/teams?bbox=-1,-1,1,1`) - .expect(200) - - t.true(teams.body.length > 0) -}) - -test('assign moderator to team', async t => { - const teamName = 'map team ♾♾' - let team = await agent.post('/api/teams') - .send({ name: teamName }) - .expect(200) - const osmIdToAdd = 2 - await agent.put(`/api/teams/add/${team.body.id}/${osmIdToAdd}`) - .expect(200) - const { id: teamId } = team.body - await agent.put(`/api/teams/${teamId}/assignModerator/${osmIdToAdd}`) - .expect(200) - team = await agent.get(`/api/teams/${teamId}`) - .expect(200) - const { id, name, moderators } = team.body - t.is(id, teamId) - t.is(name, teamName) - const matchOsmIdAssigned = data => data.osm_id === osmIdToAdd - t.true(any(matchOsmIdAssigned, moderators)) -}) - -test('remove moderator from team', async t => { - const teamName = 'map team ♾+1' - const osmId = 2 - let team = await agent.post('/api/teams') - .send({ name: teamName }) - .expect(200) - const { id: teamId } = team.body - await agent.put(`/api/teams/add/${teamId}/${osmId}`) - .expect(200) - await agent.put(`/api/teams/${teamId}/assignModerator/${osmId}`) - .expect(200) - await agent.put(`/api/teams/${teamId}/removeModerator/${osmId}`) - .expect(200) - team = await agent.get(`/api/teams/${teamId}`) - .expect(200) - const { id, name, moderators } = team.body - t.is(id, teamId) - t.is(name, teamName) - const matchOsmIdAssigned = data => data.osm_id === osmId - t.false(any(matchOsmIdAssigned, moderators)) -}) - -test('get my teams list', async t => { - // get previous teams list for checking relative counts within this test. - let response = await agent.get(`/api/my/teams`) - .expect(200) - const { member: prevMember, moderator: prevModerator } = response.body - // create two more teams (osmId = 1) - await agent.post('/api/teams') - .send({ name: 'map team ♾+2' }) - .expect(200) - const team = await agent.post('/api/teams') - .send({ name: 'map team ♾+3' }) - .expect(200) - const { id: teamId } = team.body - // add osmId 2 to one team, and make them moderator - await agent.put(`/api/teams/add/${teamId}/2`) - .expect(200) - await agent.put(`/api/teams/${teamId}/assignModerator/2`) - .expect(200) - // remove osmId 1 from moderator - await agent.put(`/api/teams/${teamId}/removeModerator/1`) - .expect(200) - // check that osmId 1 is now +1 moderator and +2 member - response = await agent.get(`/api/my/teams`) - .expect(200) - const { osmId, member, moderator } = response.body - t.is(osmId, 1) - t.is(moderator.length, prevModerator.length + 1) - t.is(member.length, prevMember.length + 2) - member.forEach(item => { - t.truthy(item.name) - t.truthy(item.id) - }) - moderator.forEach(item => { - t.truthy(item.name) - t.truthy(item.id) - }) -}) diff --git a/app/tests/permissions/add-org-team.test.js b/app/tests/permissions/add-org-team.test.js deleted file mode 100644 index 6339b630..00000000 --- a/app/tests/permissions/add-org-team.test.js +++ /dev/null @@ -1,70 +0,0 @@ -const test = require('ava') -const db = require('../../db') -const path = require('path') -const { initializeContext, createOrg, destroyOrg } = require('./initialization') - -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - -test.before(initializeContext) - -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - -// set up and tear down an org for each test -test.beforeEach(createOrg) -test.afterEach(destroyOrg) - -/** - * An org owner can create a team - * We create an org with the user100 user - * We check that user100 can create a team in the org - * - */ -test('org owner can create a team', async t => { - const teamName = 'org owner can create a team - team' - - let res2 = await t.context.agent.post(`/api/organizations/${t.context.org.id}/teams`) - .set('Authorization', 'Bearer user100') - .send({ name: teamName }) - - t.is(res2.status, 200) -}) - -/** - * An org manager can create a team - * We create an org with the user100 user and assign user101 - * as a manager. We check that user101 can create a team in the org - * - */ -test('org manager can create a team', async t => { - const teamName = 'org manager can create a team - team' - - // We create a manager role for user 101 - await t.context.agent.put(`/api/organizations/${t.context.org.id}/addManager/101`) - .set('Authorization', 'Bearer user100') - .expect(200) - - let res2 = await t.context.agent.post(`/api/organizations/${t.context.org.id}/teams`) - .set('Authorization', 'Bearer user101') - .send({ name: teamName }) - - t.is(res2.status, 200) -}) - -/** - * An non-org manager cannot create a team - * We create an org with the user100 user and check that a non - * - */ -test('non-org manager cannot create a team', async t => { - const teamName = 'non-org manager cannot create a team - team' - - let res2 = await t.context.agent.post(`/api/organizations/${t.context.org.id}/teams`) - .set('Authorization', 'Bearer user101') - .send({ name: teamName }) - - t.is(res2.status, 401) -}) diff --git a/app/tests/permissions/clients.js b/app/tests/permissions/clients.js deleted file mode 100644 index 84dc62fe..00000000 --- a/app/tests/permissions/clients.js +++ /dev/null @@ -1,49 +0,0 @@ -const test = require('ava') -const db = require('../../db') -const path = require('path') -const hydra = require('../../lib/hydra') -const sinon = require('sinon') - -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - -let agent -test.before(async () => { - const conn = await db() - await conn.migrate.latest({ directory: migrationsDirectory }) - - // seed - await conn('users').insert({ id: 100 }) - - // stub hydra introspect - let introspectStub = sinon.stub(hydra, 'introspect') - introspectStub.withArgs('validToken').returns({ - active: true, - sub: '100' - }) - introspectStub.withArgs('invalidToken').returns({ active: false }) - - // stub hydra get clients - let getClientsStub = sinon.stub('hydra', 'getClients') - getClientsStub.returns([]) - - agent = require('supertest').agent(await require('../../index')()) -}) - -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - -test('an authenticated user can view their clients', async t => { - let res = await agent.get('/api/clients') - .set('Authorization', `Bearer validToken`) - - t.is(res.status, 200) -}) - -test('an unauthenticated user cannot view their clients', async t => { - let res = await agent.get('/api/clients') - - t.is(res.status, 401) -}) diff --git a/app/tests/permissions/create-team.test.js b/app/tests/permissions/create-team.test.js deleted file mode 100644 index 83257611..00000000 --- a/app/tests/permissions/create-team.test.js +++ /dev/null @@ -1,30 +0,0 @@ -const test = require('ava') -const db = require('../../db') -const path = require('path') -const { initializeContext } = require('./initialization') - -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - -test.before(initializeContext) - -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - -test('an authenticated user can create a team', async t => { - let res = await t.context.agent.post('/api/teams') - .send({ name: 'road team 1' }) - .set('Authorization', 'Bearer user100') - .expect(200) - - t.is(res.body.name, 'road team 1') -}) - -test('an unauthenticated user cannot create a team', async t => { - let res = await t.context.agent.post('/api/teams') - .send({ name: 'road team 2' }) - - t.is(res.status, 401) -}) diff --git a/app/tests/permissions/delete-client.test.js b/app/tests/permissions/delete-client.test.js deleted file mode 100644 index 659e98b0..00000000 --- a/app/tests/permissions/delete-client.test.js +++ /dev/null @@ -1,61 +0,0 @@ -const test = require('ava') -const db = require('../../db') -const path = require('path') -const hydra = require('../../lib/hydra') -const sinon = require('sinon') - -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - -let agent -test.before(async () => { - const conn = await db() - await conn.migrate.latest({ directory: migrationsDirectory }) - await conn.schema.createTable('hydra_client', t => { - // schema at https://github.com/ory/hydra/blob/master/client/manager_sql.go - t.string('id') - t.string('owner') - }) - - // seed - await conn('hydra_client').insert({ id: 999, owner: '100' }) - await conn('hydra_client').insert({ id: 998, owner: '101' }) - - // stub hydra introspect - let introspectStub = sinon.stub(hydra, 'introspect') - introspectStub.withArgs('validToken').returns({ - active: true, - sub: '100' - }) - introspectStub.withArgs('differentUser').returns({ - active: true, - sub: '101' - }) - introspectStub.withArgs('invalidToken').returns({ active: false }) - - // stub hydra delete client - let deleteClientStub = sinon.stub(hydra, 'deleteClient') - deleteClientStub.returns(Promise.resolve(true)) - - agent = require('supertest').agent(await require('../../index')()) -}) - -test.after.always(async () => { - const conn = await db() - await conn.schema.dropTable('hydra_client') - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - -test('a user can delete a client they created', async t => { - let res = await agent.delete('/api/clients/999') - .set('Authorization', 'Bearer validToken') - - t.is(res.status, 200) -}) - -test("a user can't delete a client they don't own", async t => { - let res = await agent.delete('/api/clients/998') - .set('Authorization', 'Bearer validToken') - - t.is(res.status, 401) -}) diff --git a/app/tests/permissions/delete-team.test.js b/app/tests/permissions/delete-team.test.js deleted file mode 100644 index b97ebb9f..00000000 --- a/app/tests/permissions/delete-team.test.js +++ /dev/null @@ -1,38 +0,0 @@ -const test = require('ava') -const db = require('../../db') -const path = require('path') -const { initializeContext } = require('./initialization') - -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - -test.before(initializeContext) - -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - -test('a team moderator can delete a team', async t => { - let res = await t.context.agent.post('/api/teams') - .send({ name: 'road team 1' }) - .set('Authorization', `Bearer user100`) - .expect(200) - - let res2 = await t.context.agent.delete(`/api/teams/${res.body.id}`) - .set('Authorization', `Bearer user100`) - - t.is(res2.status, 200) -}) - -test('a non-team moderator cannot delete a team', async t => { - let res = await t.context.agent.post('/api/teams') - .send({ name: 'road team 2' }) - .set('Authorization', `Bearer user100`) - .expect(200) - - let res2 = await t.context.agent.delete(`/api/teams/${res.body.id}`) - .set('Authorization', `Bearer user101`) - - t.is(res2.status, 401) -}) diff --git a/app/tests/permissions/edit-organization.test.js b/app/tests/permissions/edit-organization.test.js deleted file mode 100644 index 1a9c3f00..00000000 --- a/app/tests/permissions/edit-organization.test.js +++ /dev/null @@ -1,68 +0,0 @@ -const test = require('ava') -const db = require('../../db') -const path = require('path') -const { initializeContext, createOrg, destroyOrg } = require('./initialization') - -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - -test.before(initializeContext) - -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - -// set up and tear down an org for each test -test.beforeEach(createOrg) -test.afterEach(destroyOrg) - -/** - * An org owner can update the org - * We create an org with the user100 and then - * see if they can edit the organization - * - */ -test('org owner can update an org', async t => { - const orgName2 = 'org owner can update an org - org 2' - const res2 = await t.context.agent.put(`/api/organizations/${t.context.org.id}`) - .set('Authorization', `Bearer user100`) - .send({ name: orgName2 }) - - t.is(res2.status, 200) -}) - -/** - * An org manager cannot update the org - * We now add a manager and see if they can edit the organization - * - */ -test('org manager cannot update an org', async t => { - const orgName2 = 'org manager cannot update an org - org 2' - - // We create a manager role for user 101 - await t.context.agent.post(`/api/organizations/${t.context.org.id}/addManager/101`) - .set('Authorization', `Bearer user100`) - .expect(200) - - const res3 = await t.context.agent.put(`/api/organizations/${t.context.org.id}`) - .set('Authorization', `Bearer user101`) - .send({ name: orgName2 }) - - t.is(res3.status, 401) -}) - -/** - * regular user cannot update the org - * We create an org and see if a user without a role can update it - * - */ -test('no-role user cannot update an org', async t => { - const orgName2 = 'no-role user cannot update an org - org 2' - - const res2 = await t.context.agent.put(`/api/organizations/${t.context.org.id}`) - .set('Authorization', `Bearer user101`) - .send({ name: orgName2 }) - - t.is(res2.status, 401) -}) diff --git a/app/tests/permissions/initialization.js b/app/tests/permissions/initialization.js deleted file mode 100644 index fa0a8856..00000000 --- a/app/tests/permissions/initialization.js +++ /dev/null @@ -1,70 +0,0 @@ -const db = require('../../db') -const sinon = require('sinon') -const hydra = require('../../lib/hydra') -const path = require('path') -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - -/** - * Helper function to create an org - * Sets the org body in the context - * - * @param {object} t - ava test context - */ -async function createOrg (t) { - const res = await t.context.agent.post('/api/organizations') - .send({ name: 'permissions org' }) - .set('Authorization', `Bearer user100`) - - t.context.org = res.body -} - -/** - * Helper function to destroy an org from the context - * @param {object} t - ava test context - */ -async function destroyOrg (t) { - return t.context.agent.delete(`/api/organizations/${t.context.org.id}`) - .send({ name: 'permissions org' }) - .set('Authorization', `Bearer user100`) -} - -/** - * Function to initialize test contexts for permissions - * @param {Object} t - ava test context - */ -async function initializeContext (t) { - const conn = await db() - await conn.migrate.latest({ directory: migrationsDirectory }) - - // seed - await conn('users').insert({ id: 100 }) - await conn('users').insert({ id: 101 }) - - // stub hydra introspect - let introspectStub = sinon.stub(hydra, 'introspect') - introspectStub.withArgs('user100').returns({ - active: true, - sub: '100' - }) - - introspectStub.withArgs('user101').returns({ - active: true, - sub: '101' - }) - - introspectStub.withArgs('user102').returns({ - active: true, - sub: '102' - }) - - introspectStub.withArgs('invalidToken').returns({ active: false }) - - // Initialize context objects - t.context.agent = require('supertest').agent(await require('../../index')()) -} - -module.exports = { - initializeContext, - createOrg, - destroyOrg -} diff --git a/app/tests/permissions/join-team.test.js b/app/tests/permissions/join-team.test.js deleted file mode 100644 index 60df7cd7..00000000 --- a/app/tests/permissions/join-team.test.js +++ /dev/null @@ -1,51 +0,0 @@ -const test = require('ava') -const db = require('../../db') -const path = require('path') -const { initializeContext } = require('./initialization') - -const team = require('../../lib/team') - -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - -test.before(async (t) => { - await initializeContext(t) - - t.context.publicTeam = await team.create({ name: 'public team' }, 100) - t.context.privateTeam = await team.create({ name: 'private team', privacy: 'private' }, 100) -}) - -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - -test('a user can join a public team', async t => { - const team = t.context.publicTeam - let res = await t.context.agent.put(`/api/teams/${team.id}/join`) - .set('Authorization', 'Bearer user101') - t.is(res.status, 200) -}) - -test('a user cannot join a private team', async t => { - const team = t.context.privateTeam - let res = await t.context.agent.put(`/api/teams/${team.id}/join`) - .set('Authorization', 'Bearer user101') - t.is(res.status, 401) -}) - -test('a user cannot join a team they are already in', async t => { - const team = t.context.publicTeam - let res = await t.context.agent.put(`/api/teams/${team.id}/join`) - .set('Authorization', 'Bearer user100') - t.is(res.status, 401) -}) - -test('a user must be authenticated to join a team', async t => { - const team = t.context.publicTeam - let invalidToken = await t.context.agent.put(`/api/teams/${team.id}/join`) - .set('Authorization', 'Bearer invalidToken') - t.is(invalidToken.status, 401) - let unauthenticated = await t.context.agent.put(`/api/teams/${team.id}/join`) - t.is(unauthenticated.status, 401) -}) diff --git a/app/tests/permissions/update-team.test.js b/app/tests/permissions/update-team.test.js deleted file mode 100644 index b2d8fbfb..00000000 --- a/app/tests/permissions/update-team.test.js +++ /dev/null @@ -1,119 +0,0 @@ -const test = require('ava') -const db = require('../../db') -const path = require('path') -const { initializeContext } = require('./initialization') - -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - -test.before(initializeContext) - -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - -test('a team moderator can update a team', async t => { - let res = await t.context.agent.post('/api/teams') - .send({ name: 'road team 1' }) - .set('Authorization', 'Bearer user100') - .expect(200) - - let res2 = await t.context.agent.put(`/api/teams/${res.body.id}`) - .set('Authorization', 'Bearer user100') - .send({ name: 'building team 1' }) - - t.is(res2.status, 200) -}) - -test('a non-team moderator cannot update a team', async t => { - let res = await t.context.agent.post('/api/teams') - .send({ name: 'road team 2' }) - .set('Authorization', 'Bearer user100') - .expect(200) - - let res2 = await t.context.agent.put(`/api/teams/${res.body.id}`) - .set('Authorization', 'Bearer user101') - .send({ name: 'building team 2' }) - - t.is(res2.status, 401) -}) - -test('an org team cannot be updated by non-org user', async t => { - // Let's create an organization, user100 is the owner - const res = await t.context.agent.post('/api/organizations') - .send({ name: 'org team cannot be updated by non-org user' }) - .set('Authorization', 'Bearer user100') - .expect(200) - - // Let's set user101 to be a manager of this organization and create a - // team in the organization - await t.context.agent.put(`/api/organizations/${res.body.id}/addManager/101`) - .set('Authorization', 'Bearer user100') - .expect(200) - - const res2 = await t.context.agent.post(`/api/organizations/${res.body.id}/teams`) - .send({ name: 'org team cannot be updated by non-org user - team' }) - .set('Authorization', 'Bearer user101') - .expect(200) - - // Use a different user from 101 or 100 to update the team - const res3 = await t.context.agent.put(`/api/teams/${res2.body.id}`) - .send({ name: 'org team cannot be updated by non-org user - team2' }) - .set('Authorization', 'Bearer user102') - - t.is(res3.status, 401) -}) - -test('an org team can be updated by the the org manager', async t => { - // Let's create an organization, user100 is the owner - const res = await t.context.agent.post('/api/organizations') - .send({ name: 'org manager can update team' }) - .set('Authorization', 'Bearer user100') - .expect(200) - - // Let's set user101 to be a manager of this organization and create a - // team in the organization - await t.context.agent.put(`/api/organizations/${res.body.id}/addManager/101`) - .set('Authorization', 'Bearer user100') - .expect(200) - - const res2 = await t.context.agent.post(`/api/organizations/${res.body.id}/teams`) - .send({ name: 'org team can be updated by manager - team' }) - .set('Authorization', 'Bearer user101') - .expect(200) - - // Use the manager to update the team - const res3 = await t.context.agent.put(`/api/teams/${res2.body.id}`) - .send({ name: 'org team can be updated by manager - team2' }) - .set('Authorization', 'Bearer user101') - - t.is(res3.status, 200) -}) - -test('an org team can be updated by the owner of the org', async t => { - // Let's create an organization, user100 is the owner - const res = await t.context.agent.post('/api/organizations') - .send({ name: 'org owner can update team' }) - .set('Authorization', 'Bearer user100') - .expect(200) - - // Let's set user101 to be a manager of this organization and create a - // team in the organization - await t.context.agent.put(`/api/organizations/${res.body.id}/addManager/101`) - .set('Authorization', 'Bearer user100') - .expect(200) - - const res2 = await t.context.agent.post(`/api/organizations/${res.body.id}/teams`) - .send({ name: 'org team can be updated by owner - team' }) - .set('Authorization', 'Bearer user101') - .expect(200) - - // user101 is the moderator and manager, but user100 should be able - // to edit this team - const res3 = await t.context.agent.put(`/api/teams/${res2.body.id}`) - .send({ name: 'org team can be updated by owner - team2' }) - .set('Authorization', 'Bearer user100') - - t.is(res3.status, 200) -}) diff --git a/app/tests/permissions/view-team-members.test.js b/app/tests/permissions/view-team-members.test.js deleted file mode 100644 index c8ac3f12..00000000 --- a/app/tests/permissions/view-team-members.test.js +++ /dev/null @@ -1,40 +0,0 @@ -const test = require('ava') -const db = require('../../db') -const path = require('path') -const { initializeContext } = require('./initialization') - -const team = require('../../lib/team') - -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - -test.before(async (t) => { - await initializeContext(t) - - t.context.publicTeam = await team.create({ name: 'public team' }, 100) - t.context.privateTeam = await team.create({ name: 'private team', privacy: 'private' }, 100) -}) - -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - -test('public team members are visible to unauthenticated users', async t => { - const team = t.context.publicTeam - let res = await t.context.agent.get(`/api/teams/${team.id}/members`) - t.is(res.status, 200) -}) - -test('private team members are not visible to unauthenticated users', async t => { - const team = t.context.privateTeam - let res = await t.context.agent.get(`/api/teams/${team.id}/members`) - t.is(res.status, 401) -}) - -test('private team members are visible to team moderators', async t => { - const team = t.context.privateTeam - let res = await t.context.agent.get(`/api/teams/${team.id}/members`) - .set('Authorization', `Bearer user100`) - t.is(res.status, 200) -}) diff --git a/app/tests/permissions/view-team.test.js b/app/tests/permissions/view-team.test.js deleted file mode 100644 index 8813f86a..00000000 --- a/app/tests/permissions/view-team.test.js +++ /dev/null @@ -1,40 +0,0 @@ -const test = require('ava') -const db = require('../../db') -const path = require('path') -const { initializeContext } = require('./initialization') - -const team = require('../../lib/team') - -const migrationsDirectory = path.join(__dirname, '..', '..', 'db', 'migrations') - -test.before(async (t) => { - await initializeContext(t) - - t.context.publicTeam = await team.create({ name: 'public team' }, 100) - t.context.privateTeam = await team.create({ name: 'private team', privacy: 'private' }, 100) -}) - -test.after.always(async () => { - const conn = await db() - await conn.migrate.rollback({ directory: migrationsDirectory }) - conn.destroy() -}) - -test('public teams are visible to unauthenticated users', async t => { - const team = t.context.publicTeam - let res = await t.context.agent.get(`/api/teams/${team.id}`) - t.is(res.status, 200) -}) - -test('private team metadata are visible to unauthenticated users', async t => { - const team = t.context.privateTeam - let res = await t.context.agent.get(`/api/teams/${team.id}`) - t.is(res.status, 200) -}) - -test('private team metadata are visible to team moderators', async t => { - const team = t.context.privateTeam - let res = await t.context.agent.get(`/api/teams/${team.id}`) - .set('Authorization', `Bearer user100`) - t.is(res.status, 200) -}) diff --git a/app/tests/utils.js b/app/tests/utils.js deleted file mode 100644 index 763e5733..00000000 --- a/app/tests/utils.js +++ /dev/null @@ -1,32 +0,0 @@ -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/ava.config.js b/ava.config.js new file mode 100644 index 00000000..0ddd1787 --- /dev/null +++ b/ava.config.js @@ -0,0 +1,6 @@ +module.exports = { + files: ['tests/**/*.test.js'], + concurrency: 1, + verbose: true, + serial: true, +} diff --git a/bin/README.md b/bin/README.md deleted file mode 100644 index 9446fdc8..00000000 --- a/bin/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Managing organizations - -For the following commands, the shell environment variable `DSN` should be set to the osm-teams database connection string. - -## Creating an organization -``` - organizations.sh - - add or remove organizations from osm-teams - - -n | --name : organization name to add/remove - -d | --description : organization description to add (optional) - -r | --remove : remove the organization (requires --name) - - example: - - ./organizations.sh --name "my org" --description "foo" - ./organizations.sh --remove --name "my org" -``` - -## Adding / removing owners -``` - owners.sh - - add or remove owners from osm-teams - - -o | --org-id : organization id to operate on - -u | --user-id : osm/mapedit user id to add/remove - -r | --remove : remove the user from owners list - - example: - - ./owners.sh --user-id 0912311 --org-id 1 - ./owners.sh --remove --user-id 0912311 --org-id 1 -``` - -## Adding / removing managers -``` - managers.sh - - add or remove managers from osm-teams - - -o | --org-id : organization id to operate on - -u | --user-id : osm/mapedit user id to add/remove - -r | --remove : remove the user from managers list - - example: - - ./managers.sh --user-id 0912311 --org-id 1 - ./managers.sh --remove --user-id 0912311 --org-id 1 -``` diff --git a/bin/managers.sh b/bin/managers.sh deleted file mode 100755 index 77ad4cdf..00000000 --- a/bin/managers.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -set -e - -# -# manage osm-teams organization managers via psql. -# - -show_help() { - cat << __HELP__ - managers.sh - - add or remove managers from osm-teams - - -o | --org-id : organization id to operate on - -u | --user-id : osm/mapedit user id to add/remove - -r | --remove : remove the user from managers list - - example: - - ./managers.sh --user-id 0912311 --org-id 1 - ./managers.sh --remove --user-id 0912311 --org-id 1 - -__HELP__ -} - -die() { - printf '%s\n' "$1" >&2 - exit 1 -} - - -if [ -z "$DSN" ]; then - die "error: DSN for postgreql database is missing" -fi - -uid= -oid= -remove= - -while :; do - case $1 in - -h|-\?|--help) - show_help - exit - ;; - -u|--user-id) - if [ "$2" ]; then - uid=$2 - shift - else - die 'error: "--user-id" requires a non-empty option argument.' - fi - ;; - -o|--org-id) - if [ "$2" ]; then - oid=$2 - shift - else - die 'error: "--org-id" requires a non-empty option argument.' - fi - ;; - -r|--remove) - remove=1 - ;; - --) - shift - break - ;; - -?*) - printf 'warn: Unknown option (ignored): %s\n' "$1" >&2 - ;; - *) # Default case: No more options, so break out of the loop. - break - esac - shift -done - -if [ -z "$uid" ]; then - die "missing --user-id of osm/mapedit user to add/remove" -fi - -if [ -z "$oid" ]; then - die "missing --org-id of organization to modify" -fi - -if [ -z "$remove" ]; then - psql $DSN --command "insert into organization_manager (organization_id, osm_id) values ($oid, $uid)" -else - psql $DSN --command "delete from organization_manager where organization_id = $oid and osm_id = $uid" -fi - -psql $DSN --command "select * from organization_manager" diff --git a/bin/organizations.sh b/bin/organizations.sh deleted file mode 100755 index eb0fb4fc..00000000 --- a/bin/organizations.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -set -e - -# -# manage osm-teams organizations via psql. -# - -show_help() { - cat << __HELP__ - organizations.sh - - add or remove organizations from osm-teams - - -n | --name : organization name to add/remove - -d | --description : organization description to add (optional) - -r | --remove : remove the organization (requires --name) - - example: - - ./organizations.sh --name "my org" --description "foo" - ./organizations.sh --remove --name "my org" - -__HELP__ -} - -die() { - printf '%s\n' "$1" >&2 - exit 1 -} - - -if [ -z "$DSN" ]; then - die "error: DSN for postgreql database is missing" -fi - -name= -description= -remove= - -while :; do - case $1 in - -h|-\?|--help) - show_help - exit - ;; - -n|--name) - if [ "$2" ]; then - name=$2 - shift - else - die 'error: "--name" requires a non-empty option argument.' - fi - ;; - - -d|--description) - if [ "$2" ]; then - description=$2 - shift - else - die 'error: "--description" requires a non-empty option argument.' - fi - ;; - -r|--remove) - remove=1 - ;; - --) - shift - break - ;; - -?*) - printf 'warn: Unknown option (ignored): %s\n' "$1" >&2 - ;; - *) # Default case: No more options, so break out of the loop. - break - esac - shift -done - -if [ -z "$name" ]; then - die "missing --name of organization to add/remove" -fi - -if [ -z "$remove" ]; then - org_id=`psql $DSN -t --command "insert into organization (name, description) values ('$name', '$description') returning id"` - echo "created organization id: ${org_id}" - echo -else - psql $DSN --command "delete from organization where name = '$name'" -fi - -psql $DSN --command "select * from organization" diff --git a/bin/owners.sh b/bin/owners.sh deleted file mode 100755 index f9392624..00000000 --- a/bin/owners.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -set -e - -# -# manage osm-teams organization owners via psql queries -# - -show_help() { - cat << __HELP__ - owners.sh - - add or remove owners from osm-teams - - -o | --org-id : organization id to operate on - -u | --user-id : osm/mapedit user id to add/remove - -r | --remove : remove the user from owners list - - example: - - ./owners.sh --user-id 0912311 --org-id 1 - ./owners.sh --remove --user-id 0912311 --org-id 1 - -__HELP__ -} - -die() { - printf '%s\n' "$1" >&2 - exit 1 -} - - -if [ -z "$DSN" ]; then - die "error: DSN for postgreql database is missing" -fi - -uid= -oid= -remove= - -while :; do - case $1 in - -h|-\?|--help) - show_help - exit - ;; - -u|--user-id) - if [ "$2" ]; then - uid=$2 - shift - else - die 'error: "--user-id" requires a non-empty option argument.' - fi - ;; - -o|--org-id) - if [ "$2" ]; then - oid=$2 - shift - else - die 'error: "--org-id" requires a non-empty option argument.' - fi - ;; - -r|--remove) - remove=1 - ;; - --) - shift - break - ;; - -?*) - printf 'warn: Unknown option (ignored): %s\n' "$1" >&2 - ;; - *) # Default case: No more options, so break out of the loop. - break - esac - shift -done - -if [ -z "$uid" ]; then - die "missing --user-id of osm/mapedit user to add/remove" -fi - -if [ -z "$oid" ]; then - die "missing --org-id of organization to modify" -fi - -if [ -z "$remove" ]; then - psql $DSN --command "insert into organization_owner (organization_id, osm_id) values ($oid, $uid)" -else - psql $DSN --command "delete from organization_owner where organization_id = $oid and osm_id = $uid" -fi - -psql $DSN --command "select * from organization_owner" diff --git a/components/button.js b/components/button.js deleted file mode 100644 index e903b44a..00000000 --- a/components/button.js +++ /dev/null @@ -1,91 +0,0 @@ -import React from 'react' -import getConfig from 'next/config' -import join from 'url-join' -import theme from '../styles/theme' -import css from 'styled-jsx/css' - -const { publicRuntimeConfig } = getConfig() - -const style = css` - .button { - display: inline-block; - text-align: center; - white-space: nowrap; - vertical-align: middle; - line-height: 1.5rem; - font-size: 1rem; - min-width: 2rem; - font-family: ${theme.typography.monoFontFamily}; - font-weight: ${theme.typography.baseFontWeight}; - text-transform: uppercase; - letter-spacing: 0.125rem; - padding: 0.75rem calc(${theme.layout.globalSpacing} * 2); - cursor: pointer; - transition: all 0.2s ease; - /* Default Colors */ - background: #FFFFFF; - color: ${theme.colors.primaryColor}; - box-shadow: 2px 2px #FFFFFF, 4px 4px ${theme.colors.primaryColor}; - border: 2px solid ${theme.colors.primaryColor}; - } - - .button:hover, - .button.primary:hover, - .button.submit:hover, - .button.danger:hover { - opacity: 0.68; - box-shadow: 0 0 ; - } - - .button.primary { - color: #FFFFFF; - background: ${theme.colors.primaryColor}; - border: none; - box-shadow: 2px 2px #FFFFFF, - 4px 4px ${theme.colors.primaryColor}; - } - .button.small { - padding: 0.5rem ${theme.layout.globalSpacing}; - font-size: 0.875rem; - } - - .button.submit { - background: ${theme.colors.primaryLite}; - } - - .button.disabled { - backgroundColor: #777777; - border: 2px solid #555; - color: ${theme.colors.baseColor}; - transition: none; - opacity: 0.68; - box-shadow: 0 0; - cursor: not-allowed; - } - - .button.danger { - color: ${theme.colors.baseColor}; - box-shadow: 2px 2px #FFFFFF, - 4px 4px ${theme.colors.secondaryColor}; - border-color: ${theme.colors.secondaryColor}; - } - - .button.fixed-size { - width: 200px; - } -` - -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 - } - if (href) { - let fullUrl - (href.startsWith('http')) ? (fullUrl = href) : (fullUrl = join(publicRuntimeConfig.APP_URL, href)) - return {children || value} - } - return
{children}
-} diff --git a/components/description-popup.js b/components/description-popup.js deleted file mode 100644 index 1867088b..00000000 --- a/components/description-popup.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react' -import Popup from 'reactjs-popup' - -export default function descriptionPopup (description) { - return ( - - - } - > - {description} - - ) -} diff --git a/components/edit-team-form.js b/components/edit-team-form.js deleted file mode 100644 index dd3e7228..00000000 --- a/components/edit-team-form.js +++ /dev/null @@ -1,177 +0,0 @@ -import React from 'react' -import { Formik, Field, Form } from 'formik' -import descriptionPopup from './description-popup' -import urlRegex from 'url-regex' -import Button from '../components/button' -import dynamic from 'next/dynamic' -import { uniqBy, prop } from 'ramda' - -const FormMap = dynamic(() => import('../components/form-map'), { ssr: false }) - -const isUrl = urlRegex({ exact: true }) - -function validateUrl (value) { - if (value && !isUrl.test(value)) return 'Please enter a valid url' -} - -function validateName (value) { - if (!value) return 'Name field is required' -} - -function renderError (text) { - return
{text}
-} - -function renderErrors (errors) { - const keys = Object.keys(errors) - return keys.map((key) => { - return renderError(errors[key]) - }) -} - -export default function EditTeamForm ({ initialValues, onSubmit, staff, isCreateForm, orgTeamTags = [], teamTags = [], profileValues }) { - if (profileValues) { - initialValues.tags = {} - profileValues.forEach(({ id, value }) => { - initialValues.tags[`key-${id}`] = value - }) - } - return ( - { - let uniqueOrgs - let extraOrgTeamFields = [] - let extraTeamFields = [] - if (staff && isCreateForm) { - uniqueOrgs = uniqBy(prop('organization_id'), staff.map(({ name, organization_id }) => { - return { name, organization_id } - })) - } - if (orgTeamTags.length > 0) { - extraOrgTeamFields = orgTeamTags.map(({ id, name, required, description }) => { - return ( -
- - -
- ) - }) - } - - if (teamTags.length > 0) { - extraTeamFields = teamTags.map(({ id, name, required, description }) => { - return ( -
- - -
- ) - }) - } - - return ( -
-

Details

-
- - - {errors.name && renderError(errors.name)} -
-
- - -
-
- - -
-
- - - URL to your team's editing policy if you have one (include http/https) - {errors.editing_policy && renderError(errors.editing_policy)} -
-
- - - - - - A private team does not show its member list or team details to non-members. -
- { staff && isCreateForm - ? ( -
- - - - {uniqueOrgs.map(({ organization_id, name }) => { - return - } - )} - -
- ) - : '' - } - {extraOrgTeamFields.length > 0 - ? <> -

Org Attributes

- {extraOrgTeamFields} - - : '' - } - {extraTeamFields.length > 0 - ? <> -

Other Team Attributes

- {extraTeamFields} - - : '' - } -

Location

-
- -
-
- { (status && status.errors) && (renderErrors(status.errors)) } -
-
- ) - } - } - /> - ) -} diff --git a/components/header.js b/components/header.js deleted file mode 100644 index f419db09..00000000 --- a/components/header.js +++ /dev/null @@ -1,106 +0,0 @@ -import React from 'react' -import join from 'url-join' -import getConfig from 'next/config' -import theme from '../styles/theme' -const { publicRuntimeConfig } = getConfig() - -export default function Header ({ uid, username, picture }) { - return ( -
-
- -

Teams

- { - uid - ? - -

{username}

-
- : Login - } -
- -
- ) -} diff --git a/components/layout.js b/components/layout.js deleted file mode 100644 index fea506c7..00000000 --- a/components/layout.js +++ /dev/null @@ -1,266 +0,0 @@ -import React from 'react' -// import globalStyles from '../styles/global.js' -import theme from '../styles/theme' - -function Layout (props) { - return ( -
- {props.children} - -
- ) -} - -export default Layout diff --git a/components/list-map.js b/components/list-map.js deleted file mode 100644 index fe25d340..00000000 --- a/components/list-map.js +++ /dev/null @@ -1,29 +0,0 @@ -import React, { Component, createRef } from 'react' -import { Map, CircleMarker, TileLayer } from 'react-leaflet' - -export default class ListMap extends Component { - constructor (props) { - super(props) - - this.map = createRef() - } - render () { - const markers = this.props.markers.map(marker => ( - - )) - - return ( - { - const bounds = this.map.current.leafletElement.getBounds() - const { _southWest: sw, _northEast: ne } = bounds - this.props.onBoundsChange([sw.lng, sw.lat, ne.lng, ne.lat]) // xmin, ymin, xmax, ymax - }}> - - {markers} - - ) - } -} diff --git a/components/privacy-policy-form.js b/components/privacy-policy-form.js deleted file mode 100644 index 456577db..00000000 --- a/components/privacy-policy-form.js +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react' -import { Formik, Field, Form } from 'formik' -import Button from '../components/button' - -function validateBody (value) { - if (!value) return 'Body of privacy policy is required' -} - -function validateConsentText (value) { - if (!value) return 'Consent Text of privacy policy is required' -} - -function renderError (text) { - return
{text}
-} - -function renderErrors (errors) { - const keys = Object.keys(errors) - return keys.map((key) => { - return renderError(errors[key]) - }) -} - -export default function PrivacyPolicyForm ({ initialValues, onSubmit }) { - return ( - { - return ( -
-
- - -
-
- - -
-
- {(status && status.errors) && (renderErrors(status.errors))} -
-
- ) - }} - /> - ) -} diff --git a/components/profile-form.js b/components/profile-form.js deleted file mode 100644 index 4cf6c38b..00000000 --- a/components/profile-form.js +++ /dev/null @@ -1,322 +0,0 @@ -import React, { Component } from 'react' -import theme from '../styles/theme' -import CreatableSelect from 'react-select/creatable' -import * as Yup from 'yup' -import Router from 'next/router' -import descriptionPopup from './description-popup' -import { Formik, Field, useField, Form, ErrorMessage } from 'formik' -import { getOrgMemberAttributes, getTeamMemberAttributes, getMyProfile, setMyProfile } from '../lib/profiles-api' -import { getOrg } from '../lib/org-api' -import { getTeam } from '../lib/teams-api' -import Button from '../components/button' -import { propOr, prop } from 'ramda' - -function GenderSelectField (props) { - const [field, meta, { setValue, setTouched }] = useField(props.name) - - const onChange = function (option) { - if (option) { - return setValue(option.value) - } else { - return setValue(null) - } - } - - const options = [ - { value: 'non-binary', label: 'Non-Binary' }, - { value: 'female', label: 'Female' }, - { value: 'male', label: 'Male' }, - { value: 'undisclosed', label: 'I prefer not to say' } - ] - - function findOrCreate (fieldValue) { - let found = options.find((option) => option.value === field.value) - if (!fieldValue) { - return null - } - if (!found) { - return { value: fieldValue, label: fieldValue } - } - return found - } - - const styles = { - control: (provided) => ({ - ...provided, - minWidth: '220px', - width: '220px', - border: `2px solid ${theme.colors.primaryColor}` - }), - option: (provided) => ({ - ...provided, - minWidth: '220px', - width: '220px' - }) - - } - - return
- `Write in ${inputValue}`} - placeholder='Write or select' - defaultValue={findOrCreate(field.value)} - options={options} - onChange={onChange} - onBlur={setTouched} - /> - {meta.touched && meta.error ? ( -
- -
- ) : null} -
-} - -export default class ProfileForm extends Component { - static async getInitialProps ({ query }) { - if (query) { - return { - id: query.id - } - } - } - - constructor (props) { - super(props) - this.state = { - memberAttributes: [], - orgAttributes: [], - profileValues: {}, - consentChecked: true, - loading: true, - error: undefined - } - - this.setConsentChecked = this.setConsentChecked.bind(this) - } - - async componentDidMount () { - this.getProfileForm() - } - - setConsentChecked (checked) { - this.setState({ - consentChecked: checked - }) - } - - async getProfileForm () { - const { id } = this.props - try { - let memberAttributes = [] - let orgAttributes = [] - let org = {} - let consentChecked = true - const returnUrl = `/teams/${this.props.id}` - const team = await getTeam(id) - if (team.org) { - org = await getOrg(team.org.organization_id) - orgAttributes = await getOrgMemberAttributes(team.org.organization_id) - consentChecked = !(org && org.privacy_policy) - } - memberAttributes = await getTeamMemberAttributes(id) - let profileValues = (await getMyProfile()).tags - this.setState({ - id, - returnUrl, - team, - memberAttributes, - consentChecked, - org, - orgAttributes, - profileValues, - loading: false - }) - } catch (e) { - console.error(e) - this.setState({ - error: e, - loading: false - }) - } - } - - render () { - let { memberAttributes, orgAttributes, org, team, profileValues, returnUrl, consentChecked, loading } = this.state - profileValues = profileValues || {} - - if (loading) { - return ( -
-
Loading...
-
- ) - } - - const allAttributes = memberAttributes.concat(orgAttributes) - let initialValues = {} - - let schema = {} - - allAttributes.forEach(attr => { - // Set initial value from profileValues or to empty string - initialValues[attr.id] = propOr('', attr.id, profileValues) - - // Set form validator - switch (attr.key_type) { - case 'email': { - schema[attr.id] = Yup.string().email('Invalid email') - break - } - case 'number': { - schema[attr.id] = Yup.number().typeError('Invalid number') - break - } - case 'url': { - schema[attr.id] = Yup.string().url('Invalid URL') - break - } - case 'date': { - schema[attr.id] = Yup.date('Invalid date') - break - } - case 'tel': { - const phoneRegex = /^((\\+[1-9]{1,4}[ \\-]*)|(\\([0-9]{2,3}\\)[ \\-]*)|([0-9]{2,4})[ \\-]*)*?[0-9]{3,4}?[ \\-]*[0-9]{3,4}?$/ - schema[attr.id] = Yup.string().matches(phoneRegex, 'Invalid phone number') - break - } - case 'color': { - const hexRegex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i - schema[attr.id] = Yup.string().matches(hexRegex, 'Invalid color code') - break - } - default: { - if (attr.required) { - schema[attr.id] = Yup.string().required('This is a required field').nullable() - } else { - schema[attr.id] = Yup.string().nullable() - } - } - } - }) - const yupSchema = Yup.object().shape(schema) - - const teamName = prop('name', team) || 'team' - const orgName = prop('name', org) || 'org' - - return ( -
-

Edit your profile details

- { - const data = Object.keys(values).map(key => ({ - 'key_id': key, - 'value': values[key] - })) - actions.setSubmitting(true) - try { - await setMyProfile(data) - actions.setSubmitting(false) - Router.push(returnUrl) - } catch (e) { - console.error(e) - actions.setSubmitting(false) - actions.setStatus(e.message) - } - }} - render={({ errors, status, isSubmitting, values }) => { - const addProfileText = `Submit ${isSubmitting ? ' 🕙' : ''}` - return ( -
- {orgAttributes.length > 0 - ? <> -

Details for {orgName}

- {orgAttributes.map(attribute => { - return
- - { attribute.key_type === 'gender' - ? - : null - } - { attribute.key_type === 'gender' - ? - : <> - -
- -
- - } -
- })} - - : '' - } -

Details for {teamName}

- { memberAttributes.length > 0 ? memberAttributes.map(attribute => { - return
- - {attribute.key_type === 'gender' - ? - : null - } - {attribute.key_type === 'gender' - ? - : <> - -
- -
- - } -
- }) - : 'No profile form to fill yet' - } - { org && org.privacy_policy - ?
-

Privacy Policy

-
- {org.privacy_policy.body} -
-
- this.setConsentChecked(e.target.checked)} /> - {org.privacy_policy.consentText} -
-
- :
- } - {status && status.msg &&
{status.msg}
} -
- -
- - ) - }} - /> -
- ) - } -} diff --git a/components/profile-modal.js b/components/profile-modal.js deleted file mode 100644 index 18ae1c02..00000000 --- a/components/profile-modal.js +++ /dev/null @@ -1,130 +0,0 @@ -import React from 'react' -import { isEmpty } from 'ramda' -import theme from '../styles/theme' -import Popup from 'reactjs-popup' -import Button from './button' -import SvgSquare from '../components/svg-square' - -function renderActions (actions) { - return ( - ⚙️} - position='left top' - on='click' - closeOnDocumentClick - contentStyle={{ padding: '10px', border: 'none' }} - > -
    - {actions.map(action => { - return
  • action.onClick()} - key={action.name}>{action.name}
  • - })} -
- -
- ) -} - -function renderBadges (badges) { - if (!badges || badges.length === 0) { - return null - } - - return ( - - {badges.map((b) => ( - - - - - ))} -
- - {b.name}
- ) -} - -export default function ProfileModal ({ - user, - attributes, - badges, - onClose, - actions -}) { - actions = actions || [] - let profileContent =
User does not have a profile
- if (!isEmpty(attributes)) { - profileContent = <> -
- { - attributes && attributes.map(attribute => { - if (attribute.value) { - return ( - <> -
{attribute.name}:
-
{attribute.value}
- - ) - } - }) - } -
- - - } - return ( -
- {user.img ? : ''} -

- {user.name} - {!isEmpty(actions) && renderActions(actions)} -

- {profileContent} - {renderBadges(badges)} - -
- ) -} diff --git a/components/section.js b/components/section.js deleted file mode 100644 index 4cf3d824..00000000 --- a/components/section.js +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react' - -export default function Section ({ children }) { - return ( -
- {children} -
- ) -} diff --git a/components/sidebar.js b/components/sidebar.js deleted file mode 100644 index 7d4bff48..00000000 --- a/components/sidebar.js +++ /dev/null @@ -1,281 +0,0 @@ -import React, { Component, Fragment } from 'react' -import join from 'url-join' -import getConfig from 'next/config' -import Router, { withRouter } from 'next/router' -import theme from '../styles/theme' -import Link from '../components/Link' - -const { publicRuntimeConfig } = getConfig() -const URL = publicRuntimeConfig.APP_URL - -const NavLink = withRouter(({ children, router, href }) => { - return {children} -}) - -class Sidebar extends Component { - render () { - const { uid } = this.props - - const additionalMenuItems = ( - -
  • - - - Make New Team - - -
  • -
  • - - - Profile - - -
  • -
  • - - - Connect a new app - - -
  • -
    - ) - return ( -
    -
    -

    -
    - - -
    - ) - } -} - -export default Sidebar diff --git a/components/team.js b/components/team.js deleted file mode 100644 index 7a04b608..00000000 --- a/components/team.js +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react' -import theme from '../styles/theme' - -export function TeamDetailSmall ({ id, name, hashtag }) { - return ( -
  • -
    - {name} -
    {hashtag}
    -
    - -
  • - ) -} diff --git a/compose.dev.yml b/compose.dev.yml deleted file mode 100644 index 10f3052b..00000000 --- a/compose.dev.yml +++ /dev/null @@ -1,60 +0,0 @@ -version: '3.7' - -services: - hydra: - image: oryd/hydra:v1.9.2 - ports: - - 4444:4444 - - 4445:4445 - - 5555:5555 - command: - serve -c /etc/config/hydra/hydra.yml all --dangerous-force-http - volumes: - - - type: bind - source: ./hydra-config/dev - target: /etc/config/hydra - env_file: - .env - depends_on: - - hydra-migrate - restart: always - - hydra-migrate: - image: oryd/hydra:v1.9.2 - command: - migrate -c /etc/config/hydra/hydra.yml sql -e --yes - volumes: - - - type: bind - source: ./hydra-config/dev - target: /etc/config/hydra - env_file: - .env - restart: on-failure - - dev-db: - platform: linux/amd64 - image: mdillon/postgis:9.6-alpine - restart: 'always' - ports: - - 5433:5432 - environment: - - ALLOW_IP_RANGE=0.0.0.0/0 - - POSTGRES_DB=osm-teams - - PGDATA=/opt/postgres/data - volumes: - - ./docker-data/dev-db:/opt/postgres/data - - test-db: - platform: linux/amd64 - image: mdillon/postgis:9.6-alpine - restart: 'always' - ports: - - 5434:5432 - environment: - - ALLOW_IP_RANGE=0.0.0.0/0 - - POSTGRES_DB=osm-teams-test - - PGDATA=/opt/postgres/data - volumes: - - ./docker-data/test-db:/opt/postgres/data \ No newline at end of file diff --git a/compose.prod.yml b/compose.prod.yml deleted file mode 100644 index 25b0fc07..00000000 --- a/compose.prod.yml +++ /dev/null @@ -1,9 +0,0 @@ -version: '3.7' - -services: - hydra: - volumes: - - - type: bind - source: ./hydra-config/prod - target: /etc/config/hydra \ No newline at end of file diff --git a/compose.yml b/compose.yml index 398c7742..e4720bc6 100644 --- a/compose.yml +++ b/compose.yml @@ -1,59 +1,28 @@ version: '3.7' services: - hydra: - image: oryd/hydra:v1.9.2 + dev-db: + platform: linux/amd64 + image: mdillon/postgis:9.6-alpine + restart: 'always' ports: - - 4444:4444 - - 4445:4445 - - 5555:5555 - command: - serve -c /etc/config/hydra/hydra.yml all --dangerous-force-http - volumes: - - - type: bind - source: ./hydra-config/dev - target: /etc/config/hydra - env_file: - .env - depends_on: - - hydra-migrate - restart: always - networks: - - intranet - - hydra-migrate: - image: oryd/hydra:v1.9.2 - command: - migrate -c /etc/config/hydra/hydra.yml sql -e --yes - volumes: - - - type: bind - source: ./hydra-config/dev - target: /etc/config/hydra - env_file: - .env - restart: on-failure - networks: - - intranet - - teams: - build: . - depends_on: - - hydra + - 5433:5432 environment: - - HYDRA_ADMIN_HOST=http://hydra:4445 - - HYDRA_TOKEN_HOST=http://hydra:4444 - - HYDRA_AUTHZ_HOST=http://localhost:4444 - ports: - - 8989:8989 - env_file: - .env - restart: always - command: - sh -c "npm run migrate && npm start" - networks: - - intranet + - ALLOW_IP_RANGE=0.0.0.0/0 + - POSTGRES_DB=osm-teams + - PGDATA=/opt/postgres/data + volumes: + - ./docker-data/dev-db:/opt/postgres/data -networks: - intranet: \ No newline at end of file + test-db: + platform: linux/amd64 + image: mdillon/postgis:9.6-alpine + restart: 'always' + ports: + - 5434:5432 + environment: + - ALLOW_IP_RANGE=0.0.0.0/0 + - POSTGRES_DB=osm-teams-test + - PGDATA=/opt/postgres/data + volumes: + - ./docker-data/test-db:/opt/postgres/data diff --git a/cypress.config.js b/cypress.config.js new file mode 100644 index 00000000..13389282 --- /dev/null +++ b/cypress.config.js @@ -0,0 +1,69 @@ +const { defineConfig } = require('cypress') +const db = require('./src/lib/db') +const Team = require('./src/models/team') +const Organization = require('./src/models/organization') +const TeamInvitation = require('./src/models/team-invitation') +const { pick } = require('ramda') + +const user1 = { + id: 1, +} + +module.exports = defineConfig({ + e2e: { + baseUrl: 'http://127.0.0.1:3000/', + video: false, + setupNodeEvents(on) { + on('task', { + 'db:reset': async () => { + await db.raw('TRUNCATE TABLE team RESTART IDENTITY CASCADE') + await db.raw('TRUNCATE TABLE organization RESTART IDENTITY CASCADE') + return null + }, + 'db:seed': async () => { + // Add teams + await Promise.all( + [ + [ + { + name: 'Team 1', + }, + user1.id, + ], + [ + { + name: 'Team 2', + privacy: 'private', + }, + user1.id, + ], + ].map((args) => Team.create(...args)) + ) + + return null + }, + 'db:seed:team-invitations': async (teamInvitations) => { + return Promise.all(teamInvitations.map(TeamInvitation.create)) + }, + 'db:seed:organizations': async (orgs) => { + return Promise.all( + orgs.map((org) => + Organization.create(pick(['name'], org), org.ownerId) + ) + ) + }, + 'db:seed:organization-teams': async ({ orgId, teams, managerId }) => { + return Promise.all( + teams.map((team) => + Organization.createOrgTeam(orgId, pick(['name'], team), managerId) + ) + ) + }, + }) + }, + }, + screenshotOnRunFailure: false, + env: { + NEXTAUTH_SECRET: 'next-auth-cypress-secret', + }, +}) diff --git a/cypress/e2e/auth.cy.js b/cypress/e2e/auth.cy.js new file mode 100644 index 00000000..1196798b --- /dev/null +++ b/cypress/e2e/auth.cy.js @@ -0,0 +1,39 @@ +describe('Check public routes', () => { + it(`Route / is public`, () => { + cy.visit('/') + cy.get('body').should('contain', 'Create teams') + }) +}) + +describe('Check protected routes', () => { + const protectedRoutes = [ + '/clients', + '/organizations/1', + '/organizations/1/edit-privacy-policy', + '/organizations/1/edit-profiles', + '/organizations/1/edit-team-profiles', + '/organizations/1/edit', + '/organizations/1/profile', + '/organizations/create', + '/profile', + ] + + protectedRoutes.forEach((testRoute) => { + it(`Route ${testRoute} needs authentication`, () => { + cy.visit(testRoute) + cy.get('body').should('contain', 'Sign in with OSM Teams') + }) + }) + + protectedRoutes.forEach((testRoute) => { + it(`Route ${testRoute} is displayed when authenticated`, () => { + // Authorized visit, should redirect to sign in + cy.login({ + id: 1, + display_name: 'User 1', + }) + cy.visit(testRoute) + cy.get('body').should('not.contain', 'Sign in') + }) + }) +}) diff --git a/cypress/e2e/organizations.cy.js b/cypress/e2e/organizations.cy.js new file mode 100644 index 00000000..618cb082 --- /dev/null +++ b/cypress/e2e/organizations.cy.js @@ -0,0 +1,40 @@ +const user1 = { + id: 1, + display_name: 'User 1', +} + +const org1 = { + id: 1, + name: 'My org', + ownerId: user1.id, +} + +const team1 = { + name: 'Team 1', +} + +describe('Organization page', () => { + before(() => { + cy.task('db:reset') + cy.task('db:seed:organizations', [org1]) + }) + + it('List organization teams', () => { + cy.login(user1) + + // Check state when no teams are available + cy.visit('/organizations/1') + cy.get('body').should('contain', 'This organization has no teams.') + + // Seed org teams + cy.task('db:seed:organization-teams', { + orgId: org1.id, + teams: [team1], + managerId: user1.id, + }) + + // Check state when teams are available + cy.visit('/organizations/1') + cy.get('body').should('contain', team1.name) + }) +}) diff --git a/cypress/e2e/team-invitations.cy.js b/cypress/e2e/team-invitations.cy.js new file mode 100644 index 00000000..8b9bc30e --- /dev/null +++ b/cypress/e2e/team-invitations.cy.js @@ -0,0 +1,75 @@ +const expiredInvitation = { + uuid: '0a875c3c-ba7c-4132-b08e-427a965177f5', + teamId: 1, + createdAt: '2000-01-01', + expiresAt: '2001-01-01', +} + +const validInvitation = { + uuid: 'f89e8459-3066-43e3-86d5-f621ded69d60', + teamId: 1, +} + +const anotherValidInvitation = { + uuid: '6d30b44f-e94d-4d40-8dc1-3973d28cb182', + teamId: 1, +} + +const nonexistentInvitation = { + uuid: '981b595a-92a4-442e-ab39-3a418c20343f', + teamId: 999, +} + +describe('Team invitation page', () => { + before(() => { + cy.task('db:reset') + cy.task('db:seed') + cy.task('db:seed:team-invitations', [ + expiredInvitation, + validInvitation, + anotherValidInvitation, + ]) + }) + + it('Invalid route displays error', () => { + cy.visit(`/teams/1/invitations/invalid-route`) + cy.get('body').contains('Invalid team invitation.') + }) + + it('Nonexistent invitation displays error', () => { + cy.visit( + `/teams/${nonexistentInvitation.teamId}/invitations/${nonexistentInvitation.uuid}` + ) + cy.get('body').contains('Invalid team invitation.') + }) + + it('Expired invitation displays error', () => { + cy.visit( + `/teams/${expiredInvitation.teamId}/invitations/${expiredInvitation.uuid}` + ) + cy.get('body').contains('Team invitation has expired.') + }) + + it('Valid invitation - user is not authenticated', () => { + cy.visit( + `/teams/${validInvitation.teamId}/invitations/${validInvitation.uuid}` + ) + cy.get('body').contains('Please sign in') + }) + + it('Valid invitation - user is authenticated', () => { + cy.login({ + id: 1, + display_name: 'User 1', + }) + cy.visit( + `/teams/${validInvitation.teamId}/invitations/${validInvitation.uuid}` + ) + cy.get('body').contains('Invitation accepted successfully.') + + cy.visit( + `/teams/${anotherValidInvitation.teamId}/invitations/${anotherValidInvitation.uuid}` + ) + cy.get('body').contains('Invitation accepted successfully.') + }) +}) diff --git a/cypress/e2e/teams.cy.js b/cypress/e2e/teams.cy.js new file mode 100644 index 00000000..a6ffdf4c --- /dev/null +++ b/cypress/e2e/teams.cy.js @@ -0,0 +1,43 @@ +describe('Teams page', () => { + before(() => { + cy.task('db:reset') + cy.task('db:seed') + }) + + it('Teams index is public and list teams', () => { + cy.visit('/teams') + cy.get('body').should('contain', 'Team 1') + cy.get('body').should('contain', 'Team 2') + }) + + it('Do not list members on public access to private team pages', () => { + cy.visit('/teams/1') + cy.get('body').should('contain', 'Team Members') + cy.visit('/teams/2') + cy.get('body').should('not.contain', 'Team Members') + }) + + it('List members on member access to private team pages', () => { + // Signed in team member + cy.login({ + id: 1, + display_name: 'User 1', + }) + cy.visit('/teams/1') + cy.get('body').should('contain', 'Team Members') + cy.visit('/teams/2') + cy.get('body').should('contain', 'Team Members') + }) + + it('Do not list members on non-member access to private team pages', () => { + // Signed in as non-team member + cy.login({ + id: 2, + display_name: 'User 2', + }) + cy.visit('/teams/1') + cy.get('body').should('contain', 'Team Members') + cy.visit('/teams/2') + cy.get('body').should('not.contain', 'Team Members') + }) +}) diff --git a/cypress/support/commands/login.js b/cypress/support/commands/login.js new file mode 100644 index 00000000..7947f1d4 --- /dev/null +++ b/cypress/support/commands/login.js @@ -0,0 +1,12 @@ +const getSessionToken = require('../../../tests/utils/get-session-token') + +Cypress.Commands.add('login', (user) => { + // Generate and set a valid cookie from the fixture that next-auth can decrypt + cy.wrap(null) + .then(() => { + return getSessionToken(user, Cypress.env('NEXTAUTH_SECRET')) + }) + .then((encryptedToken) => + cy.setCookie('next-auth.session-token', encryptedToken) + ) +}) diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js new file mode 100644 index 00000000..f29e978d --- /dev/null +++ b/cypress/support/e2e.js @@ -0,0 +1 @@ +import './commands/login' diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 00000000..69378f3e --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,3 @@ +Cypress.Screenshot.defaults({ + screenshotOnRunFailure: false, +}) diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index d1571a14..00000000 --- a/docs/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# osm-teams documentation - -- [API](api.md) diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index a37e7742..00000000 --- a/docs/api.md +++ /dev/null @@ -1,431 +0,0 @@ -# OSM Teams -Teams for OpenStreetMap! [Log in to try out requests other than GET](/) - -## Version: 0.0.0 - -### /clients - -#### GET -##### Summary: - -list of clients - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | A JSON array of client objects | - -#### POST -##### Summary: - -create a client - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | oath 2.0 client | - -### /clients/{id} - -#### DELETE -##### Summary: - -delete a client - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [ClientId](#clientid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | client is deleted | - -### /teams - -#### GET -##### Summary: - -list of teams - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | A JSON array of team objects | -| 400 | error getting list of teams | - -#### POST -##### Summary: - -create a team - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | team | - -### /teams/{id} - -#### GET -##### Summary: - -get a team - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [TeamId](#teamid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | team retrieved | -| 400 | error getting list of teams | - -#### PUT -##### Summary: - -update a team - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [TeamId](#teamid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | team updated | -| 400 | error updating team | - -#### DELETE -##### Summary: - -delete a team - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [TeamId](#teamid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | team is deleted | -| 400 | error deleting team | - -### /teams/add/{id}/{osmId} - -#### PUT -##### Summary: - -add a team member to a team - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [TeamId](#teamid) | -| | | | No | [OsmId](#osmid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | team member is added | -| 400 | error adding member to team | - -### /teams/remove/{id}/{osmId} - -#### PUT -##### Summary: - -remove a team member from a team - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [TeamId](#teamid) | -| | | | No | [OsmId](#osmid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | team member is removed | -| 400 | error removing member to team | - -### /teams/{id}/members - -#### PATCH -##### Summary: - -add and remove team members from a team - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [TeamId](#teamid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | team members are added/removed | -| 400 | error updating team members | - -### /teams/{id}/assignModerator/{osmId} - -#### PUT -##### Summary: - -Assign/Promote a member to be moderator of a team. More than one moderator may exist concurrently. Moderators are listed in the TeamModeratorList schema. - - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [TeamId](#teamid) | -| | | | No | [OsmId](#osmid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | member was promoted to moderator | -| 400 | error updating moderator relation | - -### /teams/{id}/removeModerator/{osmId} - -#### PUT -##### Summary: - -Remove/Demote a moderator of a team. At least one moderator must exist for a team. Moderators are listed in the TeamModeratorList schema. - - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [TeamId](#teamid) | -| | | | No | [OsmId](#osmid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | member was demoted from moderator | -| 400 | error updating moderator relation | - -### /organizations - -#### POST -##### Summary: - -create an organization - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | team | - -### /organizations/{id} - -#### GET -##### Summary: - -get an organization - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [OrgId](#orgid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | organization retrieved | -| 400 | error getting list of organizations | - -#### PUT -##### Summary: - -update an organization - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [OrgId](#orgid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | team updated | -| 400 | error updating organization | - -#### DELETE -##### Summary: - -delete an organization - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [OrgId](#orgid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | organization is deleted | -| 400 | error deleting organization | - -### /organizations/{id}/addOwner/{osmId} - -#### PUT -##### Summary: - -Assign/Promote a user to be an owner of an organization. More than one owner may exist concurrently. Owners can manage organizations of an organization. - - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [OrgId](#orgid) | -| | | | No | [OsmId](#osmid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | user is promoted to owner of organization | -| 400 | error updating owner relation | - -### /organizations/{id}/removeOwner/{osmId} - -#### PUT -##### Summary: - -Remove/Demote an owner of an organization. At least one owner must remain in the organization. - - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [OrgId](#orgid) | -| | | | No | [OsmId](#osmid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | user is demoted from owner | -| 400 | error updating owner relation | - -### /organizations/{id}/addManager/{osmId} - -#### PUT -##### Summary: - -Assign/Promote a user to be a manager of an organization. More than one manager may exist concurrently. Managers can create organizations for an organization but cannot update the organization. - - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [OrgId](#orgid) | -| | | | No | [OsmId](#osmid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | user is promoted to manager of organization | -| 400 | error updating owner relation | - -### /organizations/{id}/removeManager/{osmId} - -#### PUT -##### Summary: - -Remove/Demote manager of an organization. An org can have no managers. - - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [OrgId](#orgid) | -| | | | No | [OsmId](#osmid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | user is demoted from manager of organization | -| 400 | error updating owner relation | - -### /organizations/{id}/teams - -#### POST -##### Summary: - -Add a team to this organization. Only owners and managers can add new teams. - - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [OrgId](#orgid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | team was added successfully | -| 400 | error creating team for organization | - -#### GET -##### Summary: - -Get a list of teams for the specified organization - - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ---- | -| | | | No | [OrgId](#orgid) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | A JSON array of team objects | -| 400 | error getting list of teams | diff --git a/docs/api.yml b/docs/api.yml deleted file mode 100644 index 762f3f97..00000000 --- a/docs/api.yml +++ /dev/null @@ -1,736 +0,0 @@ -openapi: 3.0.0 - -info: - title: OSM Teams - description: Teams for OpenStreetMap! [Log in to try out requests other than GET](/) - version: 0.0.0 - -servers: - - url: /api - -tags: - - name: teams - description: Teams - - name: organizations - description: Organizations - - name: clients - description: Clients - -components: - schemas: - Team: - properties: - id: - type: integer - format: int64 - description: unique id of the team - name: - type: string - description: name of the team - hashtag: - type: string - description: hashtag representing the team - bio: - type: string - description: description of the team - privacy: - type: string - enum: [private, public] - description: if `private`, team details/members are not shown in ui unless user is a member of the team - require_join_request: - type: boolean - description: if true, this team requires potential new members to request access - created_at: - type: string - format: date-time - description: the timestamp of the time and date the team was created - updated_at: - type: string - format: date-time - description: the timestamp of the time and date the team was last updated - location: - type: string - description: geojson point - editing_policy: - type: string - format: uri - description: link to organized editing policy of the team - members: - type: array - description: list of team member's osm id - items: - type: number - moderators: - type: array - description: list of team member's osm id - items: - type: number - TeamList: - type: array - items: - $ref: '#/components/schemas/Team' - TeamMember: - properties: - id: - type: string - description: OSM id of user - name: - type: string - description: OSM username - TeamMemberList: - type: array - items: - $ref: '#/components/schemas/TeamMember' - TeamModerator: - properties: - id: - type: integer - description: unique id representing the team moderator relation - osm_id: - type: integer - description: OSM id of user - team_id: - type: integer - description: unique id of the team - TeamModeratorList: - type: array - items: - $ref: '#/components/schemas/TeamModerator' - TeamFullDetail: - allOf: - - $ref: '#/components/schemas/Team' - - type: object - properties: - members: - $ref: '#/components/schemas/TeamModeratorList' - moderators: - $ref: '#/components/schemas/TeamModeratorList' - Organization: - properties: - id: - type: integer - format: int64 - description: unique id of the organization - name: - type: string - description: name of the organization - description: - type: string - description: description of the organization - created_at: - type: string - format: date-time - description: the timestamp of the time and date the organization was created - updated_at: - type: string - format: date-time - description: the timestamp of the time and date the organization was last updated - OrganizationOwner: - properties: - id: - type: integer - description: unique id representing the organization owner relation - osm_id: - type: integer - description: OSM id of user - organization_id: - type: integer - description: unique id of the organization - OrganizationManager: - properties: - id: - type: integer - description: unique id representing the organization manager relation - osm_id: - type: integer - description: OSM id of user - organization_id: - type: integer - description: unique id of the organization - OrganizationTeam: - properties: - id: - type: integer - description: unique id representing the organization team relation - team_id: - type: integer - description: id of team - organization_id: - type: integer - description: unique id of the organization - OrganizationOwnerList: - type: array - items: - $ref: '#/components/schemas/OrganizationOwner' - OrganizationManagerList: - type: array - items: - $ref: '#/components/schemas/OrganizationManager' - OrganizationFullDetail: - allOf: - - $ref: '#/components/schemas/Organization' - - type: object - properties: - members: - $ref: '#/components/schemas/OrganizationOwnerList' - moderators: - $ref: '#/components/schemas/OrganizationManagerList' - Client: - properties: - client_id: - type: string - description: unique id of the client application - client_name: - type: string - description: human-readable name presented to a user during authorization - redirect_uris: - type: array - description: an array of allowed redirect urls for the client - items: - type: string - format: uri - grant_types: - type: array - description: an array of grant types the client is allowed to use - items: - type: string - response_types: - type: array - description: an array of the OAuth 2.0 response type strings that the client can use at the authorization endpoint - items: - type: string - scope: - type: string - description: a string containing a space-separated list of scope values - audience: - type: array - description: a list defining the audiences this client is allowed to request tokens for - items: - type: string - owner: - type: string - description: id of the user that created the client - policy_uri: - type: string - description: a URL string that points to a human-readable privacy policy document that describes how the client collects, uses, retains, and discloses personal data - format: uri - allowed_cors_origins: - type: array - description: one or more URLs that are allowed to make CORS requests to the /oauth/token endpoint - items: - type: string - format: uri - tos_uri: - type: string - description: a URL string that points to a human-readable terms of service document for the client that describes a contractual relationship between the end-user and the client that the end-user accepts when authorizing the client - format: uri - client_uri: - type: string - description: a URL string of a web page providing information about the client - format: uri - logo_uri: - type: string - description: a URL string for the client logo - format: uri - contacts: - type: array - description: array of strings representing ways to contact people responsible for this client, typically email addresses - client_secret_expires_at: - type: integer - description: an integer holding the time at which the client secret will expire or 0 if it will not expire - format: int64 - subject_type: - type: string - description: the subject type requested for responses to the client - enum: [pairwise, public] - token_endpoint_auth_method: - type: string - description: client authentication method for the token endpoint - enum: [client_secret_post, client_secret_basic, private_key_jwt, none] - userinfo_signed_response_alg: - type: string - description: jws algorithm used for signing user info response. if omitted, the response is a UTF-8 encoded JSON object using the application/json content-type - created_at: - type: string - description: the timestamp of the time and date the client was created - format: date-time - updated_at: - type: string - description: the timestamp of the time and date the client was last updated - format: date-time - ResponseError: - properties: - statusCode: - type: integer - error: - type: string - message: - type: string - parameters: - ClientId: - name: id - in: path - description: client id - required: true - schema: - type: integer - format: int64 - TeamId: - name: id - in: path - description: team id - required: true - schema: - type: integer - format: int64 - OrgId: - name: id - in: path - description: organization id - required: true - schema: - type: integer - format: int64 - OsmId: - name: osmId - in: path - description: osm id - required: true - schema: - type: integer - format: int64 - -paths: - /clients: - get: - summary: list of clients - tags: - - clients - responses: - '200': - description: A JSON array of client objects - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Client' - post: - summary: create a client - tags: - - clients - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Client' - responses: - '200': - description: oath 2.0 client - content: - application/json: - schema: - type: object - properties: - client: - $ref: '#/components/schemas/Client' - /clients/{id}: - parameters: - - $ref: '#/components/parameters/ClientId' - delete: - summary: delete a client - tags: - - clients - responses: - '200': - description: client is deleted - /teams: - get: - summary: list of teams - tags: - - teams - responses: - '200': - description: A JSON array of team objects - content: - application/json: - schema: - $ref: '#/components/schemas/TeamList' - '400': - description: error getting list of teams - content: - application/json: - schema: - $ref: '#/components/schemas/Team' - post: - summary: create a team - tags: - - teams - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Team' - responses: - '200': - description: team - content: - application/json: - schema: - $ref: '#/components/schemas/Team' - /teams/{id}: - parameters: - - $ref: '#/components/parameters/TeamId' - get: - summary: get a team - tags: - - teams - responses: - '200': - description: team retrieved - content: - application/json: - schema: - $ref: '#/components/schemas/TeamFullDetail' - '400': - description: error getting list of teams - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - put: - summary: update a team - tags: - - teams - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Team' - responses: - '200': - description: team updated - content: - application/json: - schema: - $ref: '#/components/schemas/TeamFullDetail' - '400': - description: error updating team - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - delete: - summary: delete a team - tags: - - teams - responses: - '200': - description: team is deleted - '400': - description: error deleting team - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - /teams/add/{id}/{osmId}: - parameters: - - $ref: '#/components/parameters/TeamId' - - $ref: '#/components/parameters/OsmId' - put: - summary: add a team member to a team - tags: - - teams - responses: - '200': - description: team member is added - '400': - description: error adding member to team - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - /teams/remove/{id}/{osmId}: - parameters: - - $ref: '#/components/parameters/TeamId' - - $ref: '#/components/parameters/OsmId' - put: - summary: remove a team member from a team - tags: - - teams - responses: - '200': - description: team member is removed - '400': - description: error removing member to team - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - /teams/{id}/members: - parameters: - - $ref: '#/components/parameters/TeamId' - patch: - summary: add and remove team members from a team - tags: - - teams - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - add: - type: array - items: - type: integer - description: osm id - remove: - type: array - items: - type: integer - description: osm id - responses: - '200': - description: team members are added/removed - '400': - description: error updating team members - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - /teams/{id}/assignModerator/{osmId}: - parameters: - - $ref: '#/components/parameters/TeamId' - - $ref: '#/components/parameters/OsmId' - put: - summary: > - Assign/Promote a member to be moderator of a team. More than one - moderator may exist concurrently. Moderators are listed in the - TeamModeratorList schema. - tags: - - teams - responses: - '200': - description: member was promoted to moderator - '400': - description: error updating moderator relation - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - /teams/{id}/removeModerator/{osmId}: - parameters: - - $ref: '#/components/parameters/TeamId' - - $ref: '#/components/parameters/OsmId' - put: - summary: > - Remove/Demote a moderator of a team. At least one moderator must exist - for a team. Moderators are listed in the TeamModeratorList schema. - tags: - - teams - responses: - '200': - description: member was demoted from moderator - '400': - description: error updating moderator relation - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - /organizations: - post: - summary: create an organization - tags: - - organizations - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Organization' - responses: - '200': - description: team - content: - application/json: - schema: - $ref: '#/components/schemas/Organization' - /organizations/{id}: - parameters: - - $ref: '#/components/parameters/OrgId' - get: - summary: get an organization - tags: - - organizations - responses: - '200': - description: organization retrieved - content: - application/json: - schema: - $ref: '#/components/schemas/OrganizationFullDetail' - '400': - description: error getting list of organizations - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - put: - summary: update an organization - tags: - - organizations - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Organization' - responses: - '200': - description: team updated - content: - application/json: - schema: - $ref: '#/components/schemas/Organization' - '400': - description: error updating organization - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - delete: - summary: delete an organization - tags: - - organizations - responses: - '200': - description: organization is deleted - '400': - description: error deleting organization - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - /organizations/{id}/addOwner/{osmId}: - parameters: - - $ref: '#/components/parameters/OrgId' - - $ref: '#/components/parameters/OsmId' - put: - summary: > - Assign/Promote a user to be an owner of an organization. More than one - owner may exist concurrently. Owners can manage organizations of an organization. - tags: - - organizations - responses: - '200': - description: user is promoted to owner of organization - '400': - description: error updating owner relation - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - /organizations/{id}/removeOwner/{osmId}: - parameters: - - $ref: '#/components/parameters/OrgId' - - $ref: '#/components/parameters/OsmId' - put: - summary: > - Remove/Demote an owner of an organization. At least one owner - must remain in the organization. - tags: - - organizations - responses: - '200': - description: user is demoted from owner - '400': - description: error updating owner relation - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - /organizations/{id}/addManager/{osmId}: - parameters: - - $ref: '#/components/parameters/OrgId' - - $ref: '#/components/parameters/OsmId' - put: - summary: > - Assign/Promote a user to be a manager of an organization. More than one - manager may exist concurrently. Managers can create organizations for an organization - but cannot update the organization. - tags: - - organizations - responses: - '200': - description: user is promoted to manager of organization - '400': - description: error updating owner relation - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - /organizations/{id}/removeManager/{osmId}: - parameters: - - $ref: '#/components/parameters/OrgId' - - $ref: '#/components/parameters/OsmId' - put: - summary: > - Remove/Demote manager of an organization. An org can have no managers. - tags: - - organizations - responses: - '200': - description: user is demoted from manager of organization - '400': - description: error updating owner relation - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - /organizations/{id}/teams: - parameters: - - $ref: '#/components/parameters/OrgId' - post: - summary: > - Add a team to this organization. Only owners and managers can add - new teams. - tags: - - organizations - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Team' - responses: - '200': - description: team was added successfully - '400': - description: error creating team for organization - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' - get: - summary: > - Get a list of teams for the specified organization - tags: - - organizations - responses: - '200': - description: A JSON array of team objects - content: - application/json: - schema: - $ref: '#/components/schemas/TeamList' - '400': - description: error getting list of teams - content: - application/json: - schema: - $ref: '#/components/schemas/ResponseError' diff --git a/hydra-config/dev/hydra.yml b/hydra-config/dev/hydra.yml index a58ee14f..9903962c 100644 --- a/hydra-config/dev/hydra.yml +++ b/hydra-config/dev/hydra.yml @@ -5,9 +5,9 @@ serve: urls: self: issuer: http://localhost:4444 - consent: http://localhost:8989/oauth/consent - login: http://localhost:8989/oauth/login - logout: http://localhost:8989/oauth/logout + consent: http://localhost:3000/auth/consent + login: http://localhost:3000/auth/signin + logout: http://localhost:3000/auth/signout secrets: system: diff --git a/knexfile.js b/knexfile.js new file mode 100644 index 00000000..30dcbc21 --- /dev/null +++ b/knexfile.js @@ -0,0 +1,16 @@ +const { loadEnvConfig } = require('@next/env') + +// Load configuration from env files using Next.js +const projectDir = process.cwd() +loadEnvConfig(projectDir) + +// Get database connection +const DATABASE_URL = process.env.DATABASE_URL + +module.exports = { + client: 'postgresql', + connection: DATABASE_URL, + migrations: { + tableName: 'knex_migrations', + }, +} diff --git a/lib/utils.js b/lib/utils.js deleted file mode 100644 index 6a3656ca..00000000 --- a/lib/utils.js +++ /dev/null @@ -1,12 +0,0 @@ -function getRandomColor () { - var letters = '0123456789ABCDEF' - var color = '#' - for (var i = 0; i < 6; i++) { - color += letters[Math.floor(Math.random() * 16)] - } - return color -} - -module.exports = { - getRandomColor -} diff --git a/app/db/migrations/20190417173534_init.js b/migrations/20190417173534_init.js similarity index 75% rename from app/db/migrations/20190417173534_init.js rename to migrations/20190417173534_init.js index e3f04ec2..fb1ef7e1 100644 --- a/app/db/migrations/20190417173534_init.js +++ b/migrations/20190417173534_init.js @@ -1,5 +1,5 @@ exports.up = async (knex) => { - await knex.schema.createTable('users', table => { + await knex.schema.createTable('users', (table) => { table.integer('id').primary() table.json('profile') table.json('manageToken') @@ -19,19 +19,31 @@ exports.up = async (knex) => { await knex.schema.createTable('moderator', (table) => { table.increments('id') - table.integer('team_id').references('id').inTable('team').onDelete('CASCADE') + table + .integer('team_id') + .references('id') + .inTable('team') + .onDelete('CASCADE') table.integer('osm_id') }) await knex.schema.createTable('member', (table) => { table.increments('id') - table.integer('team_id').references('id').inTable('team').onDelete('CASCADE') + table + .integer('team_id') + .references('id') + .inTable('team') + .onDelete('CASCADE') table.integer('osm_id') }) await knex.schema.createTable('join_request', (table) => { table.increments('id') - table.integer('team_id').references('id').inTable('team').onDelete('CASCADE') + table + .integer('team_id') + .references('id') + .inTable('team') + .onDelete('CASCADE') table.integer('osm_id') }) } diff --git a/app/db/migrations/20190730183820_team-locations.js b/migrations/20190730183820_team-locations.js similarity index 99% rename from app/db/migrations/20190730183820_team-locations.js rename to migrations/20190730183820_team-locations.js index 3ed14a2e..7d7a753f 100644 --- a/app/db/migrations/20190730183820_team-locations.js +++ b/migrations/20190730183820_team-locations.js @@ -1,4 +1,3 @@ - exports.up = async function (knex) { try { await knex.raw('CREATE EXTENSION IF NOT EXISTS "postgis"') diff --git a/app/db/migrations/20190805171532_team-name-required.js b/migrations/20190805171532_team-name-required.js similarity index 100% rename from app/db/migrations/20190805171532_team-name-required.js rename to migrations/20190805171532_team-name-required.js diff --git a/app/db/migrations/20190822135832_editing_policy.js b/migrations/20190822135832_editing_policy.js similarity index 100% rename from app/db/migrations/20190822135832_editing_policy.js rename to migrations/20190822135832_editing_policy.js diff --git a/app/db/migrations/20200130150202_team-moderator-unique.js b/migrations/20200130150202_team-moderator-unique.js similarity index 76% rename from app/db/migrations/20200130150202_team-moderator-unique.js rename to migrations/20200130150202_team-moderator-unique.js index eae04571..be14cef1 100644 --- a/app/db/migrations/20200130150202_team-moderator-unique.js +++ b/migrations/20200130150202_team-moderator-unique.js @@ -9,7 +9,9 @@ const keyName = 'moderator_team_id_osm_id_key' // creates unique constraint named "moderator_team_id_osm_id_key" exports.up = async (knex) => { try { - await knex.schema.alterTable(tableName, table => table.unique(columns, keyName)) + await knex.schema.alterTable(tableName, (table) => + table.unique(columns, keyName) + ) } catch (e) { console.error(e) } @@ -19,7 +21,9 @@ exports.up = async (knex) => { // drops the unique constraint. exports.down = async (knex) => { try { - await knex.schema.alterTable(tableName, table => table.dropUnique(columns, keyName)) + await knex.schema.alterTable(tableName, (table) => + table.dropUnique(columns, keyName) + ) } catch (e) { console.error(e) } diff --git a/app/db/migrations/20200130155125_team-member-unique.js b/migrations/20200130155125_team-member-unique.js similarity index 76% rename from app/db/migrations/20200130155125_team-member-unique.js rename to migrations/20200130155125_team-member-unique.js index 626f2b84..fd8f3e6f 100644 --- a/app/db/migrations/20200130155125_team-member-unique.js +++ b/migrations/20200130155125_team-member-unique.js @@ -9,7 +9,9 @@ const keyName = 'member_team_id_osm_id_key' // creates unique constraint named "member_team_id_osm_id_key" exports.up = async (knex) => { try { - await knex.schema.alterTable(tableName, table => table.unique(columns, keyName)) + await knex.schema.alterTable(tableName, (table) => + table.unique(columns, keyName) + ) } catch (e) { console.error(e) } @@ -19,7 +21,9 @@ exports.up = async (knex) => { // drops the unique constraint. exports.down = async (knex) => { try { - await knex.schema.alterTable(tableName, table => table.dropUnique(columns, keyName)) + await knex.schema.alterTable(tableName, (table) => + table.dropUnique(columns, keyName) + ) } catch (e) { console.error(e) } diff --git a/app/db/migrations/20200326113917_organization.js b/migrations/20200326113917_organization.js similarity index 62% rename from app/db/migrations/20200326113917_organization.js rename to migrations/20200326113917_organization.js index 1b29de66..1de937ca 100644 --- a/app/db/migrations/20200326113917_organization.js +++ b/migrations/20200326113917_organization.js @@ -8,22 +8,38 @@ exports.up = async (knex) => { await knex.schema.createTable('organization_owner', (table) => { table.increments('id') - table.integer('organization_id').references('id').inTable('organization').onDelete('CASCADE') + table + .integer('organization_id') + .references('id') + .inTable('organization') + .onDelete('CASCADE') table.integer('osm_id') table.unique(['organization_id', 'osm_id']) }) await knex.schema.createTable('organization_manager', (table) => { table.increments('id') - table.integer('organization_id').references('id').inTable('organization').onDelete('CASCADE') + table + .integer('organization_id') + .references('id') + .inTable('organization') + .onDelete('CASCADE') table.integer('osm_id') table.unique(['organization_id', 'osm_id']) }) - await knex.schema.createTable('organization_team', table => { + await knex.schema.createTable('organization_team', (table) => { table.increments('id') - table.integer('team_id').references('id').inTable('team').onDelete('CASCADE') - table.integer('organization_id').references('id').inTable('organization').onDelete('CASCADE') + table + .integer('team_id') + .references('id') + .inTable('team') + .onDelete('CASCADE') + table + .integer('organization_id') + .references('id') + .inTable('organization') + .onDelete('CASCADE') table.unique(['organization_id', 'team_id']) }) } diff --git a/app/db/migrations/20210624112124_team_profiles.js b/migrations/20210624112124_team_profiles.js similarity index 52% rename from app/db/migrations/20210624112124_team_profiles.js rename to migrations/20210624112124_team_profiles.js index c445ea4e..20e1a70c 100644 --- a/app/db/migrations/20210624112124_team_profiles.js +++ b/migrations/20210624112124_team_profiles.js @@ -3,12 +3,27 @@ */ exports.up = async (knex) => { - await knex.schema.createTable('profile_keys', table => { + await knex.schema.createTable('profile_keys', (table) => { table.increments('id') table.string('name').notNullable() - table.integer('owner_user').references('id').inTable('users').nullable().onDelete('CASCADE') - table.integer('owner_team').references('id').inTable('team').nullable().onDelete('CASCADE') - table.integer('owner_org').references('id').inTable('organization').nullable().onDelete('CASCADE') + table + .integer('owner_user') + .references('id') + .inTable('users') + .nullable() + .onDelete('CASCADE') + table + .integer('owner_team') + .references('id') + .inTable('team') + .nullable() + .onDelete('CASCADE') + table + .integer('owner_org') + .references('id') + .inTable('organization') + .nullable() + .onDelete('CASCADE') table.enum('profile_type', ['org', 'team', 'user']) table.text('description') table.boolean('required').defaultTo('false') @@ -18,24 +33,24 @@ exports.up = async (knex) => { table.unique(['name', 'owner_org']) }) - await knex.schema.alterTable('users', table => { + await knex.schema.alterTable('users', (table) => { table.timestamps(false, true) }) - await knex.schema.alterTable('team', table => { + await knex.schema.alterTable('team', (table) => { table.json('profile') }) - await knex.schema.alterTable('organization', table => { + await knex.schema.alterTable('organization', (table) => { table.json('profile') }) } exports.down = async (knex) => { - await knex.schema.alterTable('organization', table => { + await knex.schema.alterTable('organization', (table) => { table.dropColumn('profile') }) - await knex.schema.alterTable('team', table => { + await knex.schema.alterTable('team', (table) => { table.dropColumn('profile') }) await knex.schema.raw('DROP TABLE if exists profile_keys CASCADE') diff --git a/app/db/migrations/20211208161927_org_visibility.js b/migrations/20211208161927_org_visibility.js similarity index 70% rename from app/db/migrations/20211208161927_org_visibility.js rename to migrations/20211208161927_org_visibility.js index 93b3a7af..57163d32 100644 --- a/app/db/migrations/20211208161927_org_visibility.js +++ b/migrations/20211208161927_org_visibility.js @@ -1,13 +1,12 @@ - exports.up = async (knex) => { - return knex.schema.alterTable('organization', table => { + return knex.schema.alterTable('organization', (table) => { table.enum('privacy', ['public', 'private', 'unlisted']).defaultTo('public') table.boolean('teams_can_be_public').defaultTo(true) }) } exports.down = async (knex) => { - return knex.schema.alterTable('organization', table => { + return knex.schema.alterTable('organization', (table) => { table.dropColumn('privacy') table.dropColumn('teams_can_be_public') }) diff --git a/migrations/20220105182919_org_staff_visibility.js b/migrations/20220105182919_org_staff_visibility.js new file mode 100644 index 00000000..91cfe258 --- /dev/null +++ b/migrations/20220105182919_org_staff_visibility.js @@ -0,0 +1,19 @@ +const constraintName = 'profile_keys_visibility_check' + +exports.up = async (knex) => { + await knex.raw( + `ALTER TABLE profile_keys DROP CONSTRAINT IF EXISTS ${constraintName};` + ) + await knex.raw( + `ALTER TABLE profile_keys ADD CONSTRAINT ${constraintName} CHECK (visibility = ANY (ARRAY['public'::text, 'team'::text, 'org'::text, 'org_staff'::text]))` + ) +} + +exports.down = async (knex) => { + await knex.raw( + `ALTER TABLE profile_keys DROP CONSTRAINT IF EXISTS ${constraintName};` + ) + await knex.raw( + `ALTER TABLE profile_keys ADD CONSTRAINT ${constraintName} CHECK (visibility = ANY (ARRAY['public'::text, 'team'::text, 'org'::text]))` + ) +} diff --git a/app/db/migrations/20220125220402_org-privacy-policy.js b/migrations/20220125220402_org-privacy-policy.js similarity index 57% rename from app/db/migrations/20220125220402_org-privacy-policy.js rename to migrations/20220125220402_org-privacy-policy.js index 7a788224..beada8b8 100644 --- a/app/db/migrations/20220125220402_org-privacy-policy.js +++ b/migrations/20220125220402_org-privacy-policy.js @@ -1,12 +1,11 @@ - exports.up = async function (knex) { - await knex.schema.alterTable('organization', table => { + await knex.schema.alterTable('organization', (table) => { table.json('privacy_policy') }) } exports.down = async function (knex) { - await knex.schema.alterTable('organization', table => { + await knex.schema.alterTable('organization', (table) => { table.dropColumn('privacy_policy') }) } diff --git a/app/db/migrations/20220222155039_add_badges.js b/migrations/20220222155039_add_badges.js similarity index 100% rename from app/db/migrations/20220222155039_add_badges.js rename to migrations/20220222155039_add_badges.js diff --git a/app/db/migrations/20220302104250_add_user_badges.js b/migrations/20220302104250_add_user_badges.js similarity index 100% rename from app/db/migrations/20220302104250_add_user_badges.js rename to migrations/20220302104250_add_user_badges.js diff --git a/app/db/migrations/20220302135223_key-types.js b/migrations/20220302135223_key-types.js similarity index 56% rename from app/db/migrations/20220302135223_key-types.js rename to migrations/20220302135223_key-types.js index 6f57bca3..1e24565b 100644 --- a/app/db/migrations/20220302135223_key-types.js +++ b/migrations/20220302135223_key-types.js @@ -1,11 +1,11 @@ exports.up = async (knex) => { - return knex.schema.alterTable('profile_keys', table => { + return knex.schema.alterTable('profile_keys', (table) => { table.text('key_type').defaultTo('text') }) } exports.down = async (knex) => { - return knex.schema.alterTable('profile_keys', table => { + return knex.schema.alterTable('profile_keys', (table) => { table.dropColumn('key_type') }) } diff --git a/app/db/migrations/20220415114820_invitations.js b/migrations/20220415114820_invitations.js similarity index 60% rename from app/db/migrations/20220415114820_invitations.js rename to migrations/20220415114820_invitations.js index 2d7726db..095de7e9 100644 --- a/app/db/migrations/20220415114820_invitations.js +++ b/migrations/20220415114820_invitations.js @@ -1,8 +1,11 @@ - exports.up = async function (knex) { - return knex.schema.createTable('invitations', table => { + return knex.schema.createTable('invitations', (table) => { table.string('id') - table.integer('team_id').references('id').inTable('team').onDelete('CASCADE') + table + .integer('team_id') + .references('id') + .inTable('team') + .onDelete('CASCADE') table.timestamp('created_at').defaultTo(knex.fn.now()) table.timestamp('expires_at').nullable() }) diff --git a/app/db/migrations/20221121094350_drop-hashtag-uniqueness.js b/migrations/20221121094350_drop-hashtag-uniqueness.js similarity index 100% rename from app/db/migrations/20221121094350_drop-hashtag-uniqueness.js rename to migrations/20221121094350_drop-hashtag-uniqueness.js diff --git a/next-swagger-doc.json b/next-swagger-doc.json new file mode 100644 index 00000000..b123789a --- /dev/null +++ b/next-swagger-doc.json @@ -0,0 +1,13 @@ +{ + "apiFolder": "src/pages/api", + "schemaFolders": [ + "models" + ], + "definition": { + "openapi": "3.0.0", + "info": { + "title": "OSM Teams API Docs", + "version": "2.0.0" + } + } +} diff --git a/next.config.js b/next.config.js index b5ecbb19..91cc6b13 100644 --- a/next.config.js +++ b/next.config.js @@ -1,14 +1,15 @@ -require('dotenv').config() - -const path = require('path') -const Dotenv = require('dotenv-webpack') +const vercelUrl = + process.env.NEXT_PUBLIC_VERCEL_URL && + `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` module.exports = { - assetPrefix: process.env.APP_URL || 'http://localhost:8989', serverRuntimeConfig: { NODE_ENV: process.env.NODE_ENV || 'development', OSM_DOMAIN: process.env.OSM_DOMAIN || 'https://www.openstreetmap.org', - OSM_API: process.env.OSM_API || process.env.OSM_DOMAIN || 'https://www.openstreetmap.org', + OSM_API: + process.env.OSM_API || + process.env.OSM_DOMAIN || + 'https://www.openstreetmap.org', OSM_HYDRA_ID: process.env.OSM_HYDRA_ID || 'manage', OSM_HYDRA_SECRET: process.env.OSM_HYDRA_SECRET || 'manage-secret', OSM_CONSUMER_KEY: process.env.OSM_CONSUMER_KEY, @@ -17,33 +18,25 @@ module.exports = { HYDRA_TOKEN_PATH: process.env.HYDRA_TOKEN_PATH || '/oauth2/token', HYDRA_AUTHZ_HOST: process.env.HYDRA_AUTHZ_HOST || 'http://localhost:4444', HYDRA_AUTHZ_PATH: process.env.HYDRA_AUTHZ_PATH || '/oauth2/auth', - HYDRA_ADMIN_HOST: process.env.HYDRA_ADMIN_HOST || 'http://localhost:4445' + HYDRA_ADMIN_HOST: process.env.HYDRA_ADMIN_HOST || 'http://localhost:4445', }, - publicRuntimeConfig: { - APP_URL: process.env.APP_URL || 'http://localhost:8989', - OSM_NAME: process.env.OSM_NAME || 'OSM' + basePath: process.env.BASE_PATH || '', + env: { + APP_URL: process.env.APP_URL || vercelUrl || 'http://127.0.0.1:3000', + OSM_NAME: process.env.OSM_NAME || 'OSM', + BASE_PATH: process.env.BASE_PATH || '', }, - onDemandEntries: { - websocketPort: 3007 + eslint: { + dirs: [ + 'app', + 'pages', + 'components', + 'lib', + 'tests', + 'migrations', + 'styles', + 'src', + 'cypress', + ], }, - webpack: (config, { isServer }) => { - config.plugins = config.plugins || [] - - if (isServer) { - // Possible drivers for knex - IGNORE - config.externals.push(/m[sy]sql|oracle|pg-.+|sqlite\d?/i) - } - - config.plugins = [ - ...config.plugins, - - // Read the .env file - new Dotenv({ - path: path.join(__dirname, '.env'), - systemvars: true - }) - ] - - return config - } } diff --git a/oauth1-osm-client-app.png b/oauth1-osm-client-app.png deleted file mode 100644 index 0660bae5..00000000 Binary files a/oauth1-osm-client-app.png and /dev/null differ diff --git a/oauth2-osm-client-app.png b/oauth2-osm-client-app.png new file mode 100644 index 00000000..076abef4 Binary files /dev/null and b/oauth2-osm-client-app.png differ diff --git a/package.json b/package.json index 32edd383..8400bf0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "osm-teams", - "version": "1.7.0", + "version": "2.0.0", "description": "Teams for OpenStreetMap!", "homepage": "https://mapping.team", "repository": { @@ -16,16 +16,22 @@ }, "license": "MIT", "scripts": { - "docs:validate": "swagger-cli validate docs/api.yml", - "docs:api": "swagger-markdown -i docs/api.yml -o docs/api.md", - "docs": "npm run docs:validate && npm run docs:api", - "dev": "NODE_ENV=development nodemon --watch app app/index.js", - "migrate": "knex --knexfile app/db/knexfile.js migrate:latest", - "test": "NODE_ENV=test nyc ava app/tests/**/*.test.js -c 1 --serial --verbose", - "lint": "devseed-standard", - "lintfix": "devseed-standard --fix", + "docs:update-version": "node -p \"JSON.stringify({'apiFolder': 'src/pages/api','schemaFolders': ['models'],'definition': {'openapi': '3.0.0','info': {'title': 'OSM Teams API Docs','version': require('./package.json').version}}}, null, 2)\" > next-swagger-doc.json", + "docs:generate": "yarn next-swagger-doc-cli next-swagger-doc.json", + "docs:validate": "yarn docs:update-version && yarn docs:generate && swagger-cli validate public/swagger.json", + "cy:open": "cypress open", + "cy:run": "cypress run", + "e2e:dev": "NODE_ENV=test start-server-and-test dev http://127.0.0.1:3000/api cy:open", + "e2e": "NODE_ENV=test start-server-and-test dev http://127.0.0.1:3000/api cy:run", + "dev": "next dev", + "migrate": "knex migrate:latest", + "migrate:test": "NODE_ENV=test knex migrate:latest", + "test": "NODE_ENV=test start-server-and-test dev http://127.0.0.1:3000/api test:ava", + "test:ava": "NODE_ENV=test ava", + "lint": "next lint", "build": "next build", - "start": "NODE_ENV=production node app/index.js" + "start": "NODE_ENV=production node app/index.js", + "postinstall": "yarn run next telemetry disable > /dev/null" }, "browserify": { "transform": [ @@ -41,6 +47,7 @@ ] }, "dependencies": { + "@hapi/boom": "^10.0.0", "body-parser": "^1.19.1", "chance": "^1.1.8", "compression": "^1.7.3", @@ -48,8 +55,6 @@ "cors": "^2.8.5", "csurf": "^1.11.0", "date-fns": "^2.28.0", - "dotenv": "^6.2.0", - "dotenv-webpack": "^1.8.0", "express": "^4.17.2", "express-boom": "^2.0.0", "express-pino-logger": "^4.0.0", @@ -57,11 +62,14 @@ "express-session": "^1.17.2", "formik": "^2.2.9", "jsonwebtoken": "^8.5.0", - "knex": "^0.95.15", - "knex-postgis": "^0.14.1", + "knex": "^2.3.0", + "knex-postgis": "^0.14.3", "leaflet": "^1.7.1", "leaflet-control-geocoder": "^1.13.0", - "next": "^8.0.1", + "next": "^13.0.1", + "next-auth": "^4.18.4", + "next-connect": "^0.13.0", + "next-swagger-doc": "^0.3.6", "node-fetch": "^2.6.7", "passport-light": "^1.0.1", "passport-oauth": "^1.0.0", @@ -72,8 +80,8 @@ "qs": "^6.10.3", "querystring": "^0.2.1", "ramda": "^0.26.1", - "react": "^16.14.0", - "react-dom": "^16.14.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", "react-leaflet": "^2.8.0", "react-modal": "^3.14.4", "react-select": "^5.2.2", @@ -84,9 +92,9 @@ "session-file-store": "^1.5.0", "simple-oauth2": "^2.5.2", "sinon": "^7.5.0", - "sqlite3": "5", + "sqlite3": "^5.1.2", "supertest": "^4.0.2", - "swagger-ui-express": "^4.3.0", + "swagger-ui-react": "^4.15.5", "url-join": "^4.0.0", "url-parse": "^1.5.9", "url-regex": "^5.0.0", @@ -99,11 +107,19 @@ "@babel/core": "^7.16.7", "@babel/preset-env": "^7.16.8", "@babel/preset-react": "^7.16.7", - "ava": "^3.15.0", + "@panva/hkdf": "^1.0.1", + "ava": "^5.1.0", "babelify": "^10.0.0", - "devseed-standard": "^1.1.0", + "cypress": "^11.1.0", + "eslint": "8.26.0", + "eslint-config-next": "13.0.1", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-cypress": "^2.12.1", + "eslint-plugin-prettier": "^4.2.1", + "jose": "^4.11.0", "nodemon": "^1.19.4", "nyc": "^15.1.0", - "swagger-markdown": "^1.4.6" + "prettier": "2.7.1", + "start-server-and-test": "^1.14.0" } } diff --git a/pages/_app.js b/pages/_app.js deleted file mode 100644 index dc05631e..00000000 --- a/pages/_app.js +++ /dev/null @@ -1,82 +0,0 @@ -import React from 'react' -import App, { Container } from 'next/app' -import Head from 'next/head' -import Sidebar from '../components/sidebar' -import Layout from '../components/layout.js' -import PageBanner from '../components/banner' -import Button from '../components/button' -import { ToastContainer } from 'react-toastify' -import MaintenancePage from './maintenance' - -class OSMHydra extends App { - static async getInitialProps ({ Component, ctx }) { - let pageProps = {} - - if (Component.getInitialProps) { - pageProps = await Component.getInitialProps(ctx) - } - - let userData = { } - if (ctx.req && ctx.req.session) { - userData.uid = ctx.req.session.user_id - userData.username = ctx.req.session.user - userData.picture = ctx.req.session.user_picture - } - - return { pageProps, userData } - } - - render () { - const { Component, pageProps, userData, router } = this.props - let bannerContent - let { uid, username, picture } = userData - - if (router && router.pathname === '/maintenance') { - return - } - - // store the userdata in localstorage if in browser - let authed - if (typeof window !== 'undefined') { - authed = window.sessionStorage.getItem('authed') - if (userData && userData.uid && authed === null) { - window.sessionStorage.setItem('uid', userData.uid) - window.sessionStorage.setItem('username', userData.username) - window.sessionStorage.setItem('picture', userData.picture) - window.sessionStorage.setItem('authed', true) - } - if (authed) { - uid = window.sessionStorage.getItem('uid') - username = window.sessionStorage.getItem('username') - picture = window.sessionStorage.getItem('picture') - } - } - - return ( - - - OSM Teams - - - - - - - - - - { (bannerContent) ? : '' } - - - - - - - - ) - } -} - -export default OSMHydra diff --git a/pages/consent.js b/pages/consent.js deleted file mode 100644 index af57db2c..00000000 --- a/pages/consent.js +++ /dev/null @@ -1,100 +0,0 @@ -import React, { Component } from 'react' -import getConfig from 'next/config' -import Button from '../components/button' -const { publicRuntimeConfig } = getConfig() - -class Consent extends Component { - static async getInitialProps ({ query }) { - if (query) { - return { - user: query.user, - client: query.client, - challenge: query.challenge, - requested_scope: query.requested_scope - } - } - } - - render () { - const { user, client, requested_scope, challenge } = this.props - - if (!client) { - return
    - Invalid parameters, go back home? -
    - } - - const clientDisplayName = client.client_name || client.client_id - return ( -
    -
    - -

    - Hi, {user}, {clientDisplayName} wants to access resources on your behalf and needs the following permissions: -

    - { - requested_scope.map(scope => { - let scopeLabel = '' - switch (scope) { - case 'clients': { - scopeLabel = 'Read and update your OAuth clients' - break - } - case 'offline': { - scopeLabel = 'Offline access to your profile' - break - } - case 'openid': { - scopeLabel = 'Your user profile information' - break - } - default: { - scopeLabel = 'Unknown scope' - break - } - } - return
    - - -
    -
    - }) - } -

    - Do you want to be asked next time when this application wants to access your data? The application will not be able to ask for more permissions without your consent. -

    - -

    - - -

    -

    - - -

    -
    - -
    - ) - } -} - -export default Consent diff --git a/pages/developers.js b/pages/developers.js deleted file mode 100644 index d2a278db..00000000 --- a/pages/developers.js +++ /dev/null @@ -1,25 +0,0 @@ -import React, { Component } from 'react' -import getConfig from 'next/config' - -const { publicRuntimeConfig } = getConfig() -const URL = publicRuntimeConfig.APP_URL - -class Developers extends Component { - render () { - return ( -
    -

    OSM Teams API Guide

    -

    OSM Teams API builds a second authentication layer on top of the OSM id, providing OAuth2 access to a user’s teams. A user signs in through your app and clicks a “Connect Teams” button that will start the OAuth flow, sending them to our API site to grant access to their teams, returning with an access token your app can use to authenticate with the API

    -

    Resources

    - -
    - ) - } -} - -export default Developers diff --git a/pages/index.js b/pages/index.js deleted file mode 100644 index 2246f42d..00000000 --- a/pages/index.js +++ /dev/null @@ -1,229 +0,0 @@ -import React, { Component } from 'react' -import Button from '../components/button' -import Router from 'next/router' -import join from 'url-join' -import getConfig from 'next/config' -import theme from '../styles/theme' - -const { publicRuntimeConfig } = getConfig() -const URL = publicRuntimeConfig.APP_URL - -const title = String.raw` - ____ _____ __ ___ _______________ __ ________ - / __ \/ ___// |/ / /_ __/ ____/ | / |/ / ___/ - / / / /\__ \/ /|_/ / / / / __/ / /| | / /|_/ /\__ \ -/ /_/ /___/ / / / / / / / /___/ ___ |/ / / /___/ / -\____//____/_/ /_/ /_/ /_____/_/ |_/_/ /_//____/ -` - -class Home extends Component { - static async getInitialProps ({ query }) { - if (query.user) { - return { - user: { - username: query.user - } - } - } - } - - render () { - return ( -
    -
    -
    -

    {title}

    -

    - Create teams of {publicRuntimeConfig.OSM_NAME} users and import them into your apps. - Making maps better, together. Enable teams in OpenStreetMap applications, or build your team here. It’s not safe to map alone. -

    - { - this.props.user.username - ? ( -
    -

    Welcome, {this.props.user.username}!

    - - -
    - ) - : - } -
    -
    -
    - -
    - ) - } -} - -export default Home diff --git a/pages/invitation.js b/pages/invitation.js deleted file mode 100644 index 362e4e58..00000000 --- a/pages/invitation.js +++ /dev/null @@ -1,121 +0,0 @@ -import React, { Component } from 'react' -import join from 'url-join' -import Router from 'next/router' -import getConfig from 'next/config' -import { toast } from 'react-toastify' -import Button from '../components/button' -import { acceptTeamJoinInvitation } from '../lib/teams-api' - -const { publicRuntimeConfig } = getConfig() -const URL = publicRuntimeConfig.APP_URL - -export default class Invitation extends Component { - static async getInitialProps ({ query }) { - if (query) { - return { - teamId: query.team_id, - invitationId: query.invitation_id, - team: query.team - } - } - } - - constructor (props) { - super(props) - this.state = { - invitationPending: true, - invitationSuccess: false, - error: undefined - } - } - - async componentDidMount () { - const { team } = this.props - this.setState({ - team - }) - } - - async rejectInvitation () { - Router.push(URL) - } - - async acceptInvitation () { - const { teamId, invitationId } = this.props - try { - const res = await acceptTeamJoinInvitation(teamId, invitationId) - if (res.ok) { - toast.success('Success! You are now part of the team') - } - this.setState({ - invitationSuccess: true, - invitationPending: false - }) - } catch (err) { - console.error(err) - toast.error('There was an error accepting this invitation') - this.setState({ - invitationPending: false - }) - } - } - - render () { - const { team, error } = this.state - if (error) { - if (error.status === 401 || error.status === 403) { - return ( -
    -

    Unauthorized

    -
    - ) - } else if (error.status === 404) { - return ( -
    -

    This invitation was not found or has expired

    -
    - ) - } else { - return ( -
    -

    Error: {error.message}

    -
    - ) - } - } - - if (!team) return null - const userId = this.props.user.uid - if (!userId) { - return
    - You are not logged in. Sign in and come back to this link. -
    - } - return ( -
    - You have been invited to join {team.name} -
    -
    - {this.state.invitationPending ? ( - <> - - - - - - ) : ( - '' - )} - {this.state.invitationSuccess ? ( - - ) : ( - '' - )} -
    - ) - } -} diff --git a/pages/login.js b/pages/login.js deleted file mode 100644 index cb65af22..00000000 --- a/pages/login.js +++ /dev/null @@ -1,28 +0,0 @@ -import Button from '../components/button' -import React, { Component } from 'react' -import getConfig from 'next/config' -const { publicRuntimeConfig } = getConfig() - -class Login extends Component { - static async getInitialProps ({ query }) { - if (query) { - return { - challenge: query.challenge - } - } - } - - render () { - const OSM_NAME = publicRuntimeConfig.OSM_NAME - return ( -
    -

    Login

    -

    Teams uses {OSM_NAME} as your login, connect your {OSM_NAME} account!

    -
    - -
    - ) - } -} - -export default Login diff --git a/pages/org-edit-privacy-policy.js b/pages/org-edit-privacy-policy.js deleted file mode 100644 index 25b5a199..00000000 --- a/pages/org-edit-privacy-policy.js +++ /dev/null @@ -1,87 +0,0 @@ -import React, { Component } from 'react' -import join from 'url-join' -import { prop } from 'ramda' -import getConfig from 'next/config' -import Router from 'next/router' -import { getOrg, updateOrgPrivacyPolicy } from '../lib/org-api' -import PrivacyPolicyForm from '../components/privacy-policy-form' -const { publicRuntimeConfig } = getConfig() - -export default class OrgPrivacyPolicy extends Component { - static async getInitialProps ({ query }) { - if (query) { - return { - // Organization id - id: query.id - } - } - } - - constructor (props) { - super(props) - this.state = { - loading: true, - error: undefined - } - - this.getProfileForm = this.getProfileForm.bind(this) - } - - async componentDidMount () { - this.getProfileForm() - } - - async getProfileForm () { - const { id } = this.props - try { - let org = await getOrg(id) - let privacyPolicy = prop('privacy_policy', org) - this.setState({ - orgId: id, - privacyPolicy, - loading: false - }) - } catch (e) { - console.error(e) - this.setState({ - error: e, - orgId: null, - profileForm: [], - loading: false - }) - } - } - - render () { - const { privacyPolicy, orgId } = this.state - if (!orgId) return null - - const defaultValues = { - body: 'OSM Teams has the ability to collect additional information on registered users of OpenStreetMap. Exactly which types of information is 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.', - consentText: 'I understand the associated risks of using and entering my information on OSM Teams.' - } - - let initialValues = (privacyPolicy || defaultValues) - - return
    -
    -
    -

    Edit Privacy Policy

    -
    - { - try { - await updateOrgPrivacyPolicy(orgId, values) - actions.setSubmitting(false) - Router.push(join(publicRuntimeConfig.APP_URL, `/organizations/${orgId}/edit`)) - } catch (e) { - console.error(e) - actions.setSubmitting(false) - actions.setStatus(e.message) - } - }} - /> -
    -
    - } -} diff --git a/pages/org-edit-profile.js b/pages/org-edit-profile.js deleted file mode 100644 index 907dff51..00000000 --- a/pages/org-edit-profile.js +++ /dev/null @@ -1,219 +0,0 @@ -import React, { Component } from 'react' -import { assoc, isEmpty } from 'ramda' -import Popup from 'reactjs-popup' - -import ProfileAttributeForm from '../components/profile-attribute-form' -import Button from '../components/button' -import Table from '../components/table' -import { addOrgMemberAttributes, getOrgMemberAttributes, modifyAttribute, deleteAttribute } from '../lib/profiles-api' -import theme from '../styles/theme' - -export default class OrgEditProfile extends Component { - static async getInitialProps ({ query }) { - if (query) { - return { - id: query.id - } - } - } - - constructor (props) { - super(props) - this.state = { - isAdding: false, - isModifying: false, - isDeleting: false, - rowToModify: {}, - rowToDelete: {}, - loading: true, - error: undefined - } - - this.renderActions = this.renderActions.bind(this) - } - - async componentDidMount () { - this.getAttributes() - } - - renderActions (row, index, columns) { - return ( - ⚙️} - position='left top' - on='click' - closeOnDocumentClick - contentStyle={{ padding: '10px', border: 'none' }} - > -
      -
    • { - this.setState({ - isModifying: true, - isAdding: false, - isDeleting: false, - rowToModify: assoc( - 'required', - row.required === 'true' ? ['required'] : [], - row - ) - }) - }} - > - Modify -
    • -
    • { - this.setState({ - isModifying: false, - isAdding: false, - isDeleting: true, - rowToDelete: row - }) - } - }> - Delete -
    • -
    - -
    - ) - } - - async getAttributes () { - const { id } = this.props - try { - let memberAttributes = await getOrgMemberAttributes(id) - this.setState({ - orgId: id, - memberAttributes, - loading: false - }) - } catch (e) { - console.error(e) - this.setState({ - error: e, - orgId: null, - memberAttributes: [], - loading: false - }) - } - } - - render () { - const { memberAttributes, orgId } = this.state - const columns = [ - { key: 'name' }, - { key: 'description' }, - { key: 'visibility' }, - { key: 'key_type', label: 'type' }, - { key: 'required' }, - { key: 'actions' } - ] - - let rows = [] - if (memberAttributes) { - rows = memberAttributes.map((attribute) => { - let newAttribute = assoc('actions', this.renderActions, attribute) - newAttribute.required = attribute.required.toString() - return newAttribute - }) - } - - const CancelButton = - - return ( -
    -
    -

    Current Attributes

    -

    Members of your organization will be able to add these attributes to their profile.

    - { - memberAttributes && isEmpty(memberAttributes) - ? "You haven't added any attributes yet!" - : - } - -
    - { - this.state.isModifying - ? <> -

    Modify attribute

    - { - await modifyAttribute(attribute.id, attribute) - this.setState({ isModifying: false }) - return this.getAttributes() - }} - /> - {CancelButton} - - : '' - } - { - this.state.isAdding - ? <> -

    Add an attribute

    -

    Add an attribute to your org member's profile

    - { - await addOrgMemberAttributes(orgId, attributes) - this.setState({ isAdding: false }) - return this.getAttributes() - }} - /> - {CancelButton} - - : (!(this.state.isModifying || this.state.isDeleting) && ) - } - { - this.state.isDeleting - ? <> - - - {CancelButton} - - - : '' - } -
    - - ) - } -} diff --git a/pages/org-edit-team-profile.js b/pages/org-edit-team-profile.js deleted file mode 100644 index 0b960abb..00000000 --- a/pages/org-edit-team-profile.js +++ /dev/null @@ -1,219 +0,0 @@ -import React, { Component } from 'react' -import { assoc, isEmpty } from 'ramda' -import Popup from 'reactjs-popup' - -import ProfileAttributeForm from '../components/profile-attribute-form' -import Button from '../components/button' -import Table from '../components/table' -import { addOrgTeamAttributes, getOrgTeamAttributes, modifyAttribute, deleteAttribute } from '../lib/profiles-api' -import theme from '../styles/theme' - -export default class OrgEditTeamProfile extends Component { - static async getInitialProps ({ query }) { - if (query) { - return { - id: query.id - } - } - } - - constructor (props) { - super(props) - this.state = { - isAdding: false, - isModifying: false, - isDeleting: false, - rowToModify: {}, - rowToDelete: {}, - loading: true, - error: undefined - } - - this.renderActions = this.renderActions.bind(this) - } - - async componentDidMount () { - this.getAttributes() - } - - renderActions (row, index, columns) { - return ( - ⚙️} - position='left top' - on='click' - closeOnDocumentClick - contentStyle={{ padding: '10px', border: 'none' }} - > -
      -
    • { - this.setState({ - isModifying: true, - isAdding: false, - isDeleting: false, - rowToModify: assoc( - 'required', - row.required === 'true' ? ['required'] : [], - row - ) - }) - }} - > - Modify -
    • -
    • { - this.setState({ - isModifying: false, - isAdding: false, - isDeleting: true, - rowToDelete: row - }) - } - }> - Delete -
    • -
    - -
    - ) - } - - async getAttributes () { - const { id } = this.props - try { - let teamAttributes = await getOrgTeamAttributes(id) - this.setState({ - orgId: id, - teamAttributes, - loading: false - }) - } catch (e) { - console.error(e) - this.setState({ - error: e, - orgId: null, - teamAttributes: [], - loading: false - }) - } - } - - render () { - const { teamAttributes, orgId } = this.state - const columns = [ - { key: 'name' }, - { key: 'description' }, - { key: 'visibility' }, - { key: 'key_type', label: 'type' }, - { key: 'required' }, - { key: 'actions' } - ] - - let rows = [] - if (teamAttributes) { - rows = teamAttributes.map((attribute) => { - let newAttribute = assoc('actions', this.renderActions, attribute) - newAttribute.required = attribute.required.toString() - return newAttribute - }) - } - - const CancelButton = - - return ( -
    -
    -

    Current Attributes

    -

    Teams of your organization will be able to add these attributes to their profile.

    - { - teamAttributes && isEmpty(teamAttributes) - ? "You haven't added any attributes yet!" - :
    - } - -
    - { - this.state.isModifying - ? <> -

    Modify attribute

    - { - await modifyAttribute(attribute.id, attribute) - this.setState({ isModifying: false }) - return this.getAttributes() - }} - /> - {CancelButton} - - : '' - } - { - this.state.isAdding - ? <> -

    Add an attribute

    -

    Add an attribute to your org member's profile

    - { - await addOrgTeamAttributes(orgId, attributes) - this.setState({ isAdding: false }) - return this.getAttributes() - }} - /> - {CancelButton} - - : (!(this.state.isModifying || this.state.isDeleting) && ) - } - { - this.state.isDeleting - ? <> - - - {CancelButton} - - - : '' - } -
    - - ) - } -} diff --git a/pages/profile.js b/pages/profile.js deleted file mode 100644 index c11a42cf..00000000 --- a/pages/profile.js +++ /dev/null @@ -1,146 +0,0 @@ -import React, { Component } from 'react' -import Router from 'next/router' -import join from 'url-join' -import getConfig from 'next/config' -import Section from '../components/section' -import SectionHeader from '../components/section-header' -import Table from '../components/table' -import { getTeams } from '../lib/teams-api' -import { getMyOrgs } from '../lib/org-api' -import { assoc, flatten, propEq, find } from 'ramda' - -const { publicRuntimeConfig } = getConfig() -const URL = publicRuntimeConfig.APP_URL - -export default class Profile extends Component { - constructor (props) { - super(props) - - this.state = { - isModalOpen: false, - loading: true, - teams: [], - error: undefined - } - } - - openCreateModal () { - this.setState({ - isModalOpen: true - }) - } - - async refreshProfileInfo () { - try { - let teams = await getTeams({ osmId: this.props.user.uid }) - let orgs = await getMyOrgs({ osmId: this.props.user.id }) - this.setState({ - teams, - orgs, - loading: false - }) - } catch (e) { - console.error(e) - this.setState({ - error: e, - teams: [], - loading: false - }) - } - } - - componentDidMount () { - this.refreshProfileInfo() - } - - renderTeams () { - const { teams } = this.state - if (!teams) return null - - if (teams.length === 0) { - return

    No teams

    - } - - return ( -
    { - Router.push(join(URL, `/team?id=${row.id}`), join(URL, `/teams/${row.id}`)) - }} - /> - ) - } - - renderOrganizations () { - const { orgs } = this.state - if (!orgs) return null - - if (orgs.length === 0) { - return

    No orgs

    - } - - const memberOrgs = orgs.memberOrgs.map(assoc('role', 'member')) - const managerOrgs = orgs.managerOrgs.map(assoc('role', 'manager')) - const ownerOrgs = orgs.ownerOrgs.map(assoc('role', 'owner')) - - let allOrgs = ownerOrgs - managerOrgs.forEach(org => { - if (!find(propEq('id', org.id))(allOrgs)) { - allOrgs.push(org) - } - }) - memberOrgs.forEach(org => { - if (!find(propEq('id', org.id))(allOrgs)) { - allOrgs.push(org) - } - }) - - return ( -
    { - Router.push(join(URL, `/organizations?id=${row.id}`), join(URL, `/organizations/${row.id}`)) - }} - /> - ) - } - - render () { - if (this.state.loading) return
    Loading...
    - if (this.state.error) return
    {this.state.error.message}
    - - const { orgs } = this.state - const hasOrgs = flatten(Object.values(orgs)).length > 0 - - return ( -
    -
    -

    Teams & Organizations

    -
    - { - hasOrgs - ? ( -
    - Your Organizations - {this.renderOrganizations()} -
    ) - : '' - } -
    - Your Teams - {this.renderTeams()} -
    -
    - ) - } -} diff --git a/pages/team-create.js b/pages/team-create.js deleted file mode 100644 index 3b2ab602..00000000 --- a/pages/team-create.js +++ /dev/null @@ -1,46 +0,0 @@ -import React, { Component } from 'react' -import join from 'url-join' -import Router from 'next/router' -import { createTeam, createOrgTeam } from '../lib/teams-api' -import { dissoc } from 'ramda' -import getConfig from 'next/config' -import EditTeamForm from '../components/edit-team-form' -const { publicRuntimeConfig } = getConfig() - -export default class TeamCreate extends Component { - static async getInitialProps ({ query }) { - if (query) { - return { - staff: query.staff - } - } - } - render () { - return ( -
    - { - try { - let team - if (values.organization) { - team = await createOrgTeam(values.organization, dissoc('organization', values)) - } else { - team = await createTeam(values) - } - actions.setSubmitting(false) - Router.push(join(publicRuntimeConfig.APP_URL, `/teams/${team.id}`)) - } catch (e) { - console.error(e) - actions.setSubmitting(false) - // set the form errors actions.setErrors(e) - actions.setStatus(e.message) - } - }} - /> -
    - ) - } -} diff --git a/pages/team-edit-profile.js b/pages/team-edit-profile.js deleted file mode 100644 index f1fd69ea..00000000 --- a/pages/team-edit-profile.js +++ /dev/null @@ -1,217 +0,0 @@ -import React, { Component } from 'react' -import { assoc, isEmpty } from 'ramda' -import Popup from 'reactjs-popup' - -import ProfileAttributeForm from '../components/profile-attribute-form' -import Button from '../components/button' -import Table from '../components/table' -import { addTeamMemberAttributes, getTeamMemberAttributes, modifyAttribute, deleteAttribute } from '../lib/profiles-api' -import theme from '../styles/theme' - -export default class TeamEditProfile extends Component { - static async getInitialProps ({ query }) { - if (query) { - return { - id: query.id - } - } - } - - constructor (props) { - super(props) - this.state = { - isAdding: false, - isModifying: false, - isDeleting: false, - rowToModify: {}, - rowToDelete: {}, - loading: true, - error: undefined - } - - this.renderActions = this.renderActions.bind(this) - } - - async componentDidMount () { - this.getAttributes() - } - - renderActions (row, index, columns) { - return ( - ⚙️} - position='left top' - on='click' - closeOnDocumentClick - contentStyle={{ padding: '10px', border: 'none' }} - > -
      -
    • { - this.setState({ - isModifying: true, - isAdding: false, - isDeleting: false, - rowToModify: assoc( - 'required', - row.required === 'true' ? ['required'] : [], - row - ) - }) - }} - > - Modify -
    • -
    • { - this.setState({ - isModifying: false, - isAdding: false, - isDeleting: true, - rowToDelete: row - }) - } - }> - Delete -
    • -
    - -
    - ) - } - - async getAttributes () { - const { id } = this.props - try { - let memberAttributes = await getTeamMemberAttributes(id) - this.setState({ - teamId: id, - memberAttributes, - loading: false - }) - } catch (e) { - console.error(e) - this.setState({ - error: e, - teamId: null, - memberAttributes: [], - loading: false - }) - } - } - - render () { - const { memberAttributes, teamId } = this.state - const columns = [ - { key: 'name' }, - { key: 'description' }, - { key: 'visibility' }, - { key: 'key_type', header: 'type' }, - { key: 'required' }, - { key: 'actions' } - ] - - let rows = [] - if (memberAttributes) { - rows = memberAttributes.map((attribute) => { - let newAttribute = assoc('actions', this.renderActions, attribute) - newAttribute.required = attribute.required.toString() - return newAttribute - }) - } - - const CancelButton = - - return ( -
    -
    -

    Current Attributes

    -

    Members of your team will be able to add these attributes to their profile.

    - { - memberAttributes && isEmpty(memberAttributes) - ? "You haven't added any attributes yet!" - :
    - } - -
    - { - this.state.isModifying - ? <> -

    Modify attribute

    - { - await modifyAttribute(attribute.id, attribute) - this.setState({ isModifying: false }) - return this.getAttributes() - }} - /> - {CancelButton} - - : '' - } - { - this.state.isAdding - ? <> -

    Add an attribute

    -

    Add an attribute to your team member's profile

    - { - await addTeamMemberAttributes(teamId, attributes) - this.setState({ isAdding: false }) - return this.getAttributes() - }} - /> - {CancelButton} - - : (!(this.state.isModifying || this.state.isDeleting) && ) - } - { - this.state.isDeleting - ? <> - - - {CancelButton} - - - : '' - } -
    - - ) - } -} diff --git a/static/TeamsLogo.svg b/public/static/TeamsLogo.svg similarity index 100% rename from static/TeamsLogo.svg rename to public/static/TeamsLogo.svg diff --git a/static/TeamsLogo_reverse.svg b/public/static/TeamsLogo_reverse.svg similarity index 100% rename from static/TeamsLogo_reverse.svg rename to public/static/TeamsLogo_reverse.svg diff --git a/static/favicon.ico b/public/static/favicon.ico similarity index 100% rename from static/favicon.ico rename to public/static/favicon.ico diff --git a/static/favicon.png b/public/static/favicon.png similarity index 100% rename from static/favicon.png rename to public/static/favicon.png diff --git a/static/grid-map.svg b/public/static/grid-map.svg similarity index 100% rename from static/grid-map.svg rename to public/static/grid-map.svg diff --git a/public/static/icon-close.svg b/public/static/icon-close.svg new file mode 100644 index 00000000..13d29684 --- /dev/null +++ b/public/static/icon-close.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/icon-code.svg b/public/static/icon-code.svg similarity index 100% rename from static/icon-code.svg rename to public/static/icon-code.svg diff --git a/static/icon-gear.svg b/public/static/icon-gear.svg similarity index 100% rename from static/icon-gear.svg rename to public/static/icon-gear.svg diff --git a/static/icon-globe.svg b/public/static/icon-globe.svg similarity index 100% rename from static/icon-globe.svg rename to public/static/icon-globe.svg diff --git a/static/icon-info.svg b/public/static/icon-info.svg similarity index 100% rename from static/icon-info.svg rename to public/static/icon-info.svg diff --git a/static/icon-invader.svg b/public/static/icon-invader.svg similarity index 100% rename from static/icon-invader.svg rename to public/static/icon-invader.svg diff --git a/static/icon-plus.svg b/public/static/icon-plus.svg similarity index 100% rename from static/icon-plus.svg rename to public/static/icon-plus.svg diff --git a/static/icon-profile.svg b/public/static/icon-profile.svg similarity index 100% rename from static/icon-profile.svg rename to public/static/icon-profile.svg diff --git a/static/icon-teams.svg b/public/static/icon-teams.svg similarity index 100% rename from static/icon-teams.svg rename to public/static/icon-teams.svg diff --git a/components/Link.js b/src/components/Link.js similarity index 56% rename from components/Link.js rename to src/components/Link.js index 77cb470b..cf5aaa1d 100644 --- a/components/Link.js +++ b/src/components/Link.js @@ -1,22 +1,20 @@ import { withRouter } from 'next/router' import Link from 'next/link' import React, { Children } from 'react' -import join from 'url-join' import parse from 'url-parse' -import getConfig from 'next/config' -const { publicRuntimeConfig } = getConfig() -const URL = publicRuntimeConfig.APP_URL +const NavLink = withRouter(({ children, href }) => { + return ( + + {children} + + ) +}) -const ActiveLink = ({ router, children, ...props }) => { +const ActiveLink = withRouter(({ router, children, ...props }) => { const { href, as } = props const hrefPathname = parse(href).pathname const routerPathname = parse(router.asPath).pathname - const newHref = join(URL, href) - let newAs - if (as) { - newAs = join(URL, as) - } const child = Children.only(children) @@ -29,7 +27,11 @@ const ActiveLink = ({ router, children, ...props }) => { delete props.activeClassName - return {React.cloneElement(child, { className })} -} + return ( + + {React.cloneElement(child, { className })} + + ) +}) -export default withRouter(ActiveLink) +export default NavLink diff --git a/components/add-member-form.js b/src/components/add-member-form.js similarity index 89% rename from components/add-member-form.js rename to src/components/add-member-form.js index 9730e2d7..0ef2d3a2 100644 --- a/components/add-member-form.js +++ b/src/components/add-member-form.js @@ -2,7 +2,7 @@ import React from 'react' import { Formik, Field, Form } from 'formik' import Button from './button' -export default function AddMemberForm ({ onSubmit }) { +export default function AddMemberForm({ onSubmit }) { return ( - { status && status.msg &&
    {status.msg}
    } + {status && status.msg &&
    {status.msg}
    } diff --git a/components/banner.js b/src/components/banner.js similarity index 63% rename from components/banner.js rename to src/components/banner.js index 0df687be..93131b6f 100644 --- a/components/banner.js +++ b/src/components/banner.js @@ -1,20 +1,22 @@ import React from 'react' import theme from '../styles/theme' -export default function PageBanner ({ content, variant }) { +export default function PageBanner({ content, variant }) { return (
    -

    {content} -

    +

    {content}

    + + ) + } + if (href) { + return ( + + {children || value} + + + ) + } + return ( +
    + {children} + + +
    + ) +} diff --git a/components/card.js b/src/components/card.js similarity index 89% rename from components/card.js rename to src/components/card.js index d2897717..3eb47c46 100644 --- a/components/card.js +++ b/src/components/card.js @@ -1,7 +1,7 @@ import React from 'react' import theme from '../styles/theme' -export default function Card ({ children }) { +export default function Card({ children }) { return (
    {children} diff --git a/components/clients.js b/src/components/clients.js similarity index 57% rename from components/clients.js rename to src/components/clients.js index 77af1e4b..d222465e 100644 --- a/components/clients.js +++ b/src/components/clients.js @@ -3,43 +3,54 @@ import Button from './button' import Card from './card' import theme from '../styles/theme' import join from 'url-join' -import getConfig from 'next/config' -const { publicRuntimeConfig } = getConfig() -function newClient ({ client_id, client_name, client_secret }) { - return
      -
    • {client_id}
    • -
    • {client_name}
    • -
    • {client_secret}
    • - -
    + label { + font-family: ${theme.typography.headingFontFamily}; + font-weight: bold; + } + `} + + + ) } class Clients extends Component { - constructor (props) { + constructor(props) { super(props) this.state = { loading: true, error: undefined, redirectURI: '', clientName: '', - newClient: null + newClient: null, } this.getClients = this.getClients.bind(this) @@ -50,8 +61,8 @@ class Clients extends Component { this.handleClientCallbackChange = this.handleClientCallbackChange.bind(this) } - async getClients () { - let res = await fetch(join(publicRuntimeConfig.APP_URL, '/api/clients')) + async getClients() { + let res = await fetch(join(APP_URL, '/api/clients')) if (res.status === 200) { return res.json() } else { @@ -59,17 +70,17 @@ class Clients extends Component { } } - async createClient (e) { + async createClient(e) { e.preventDefault() - let res = await fetch(join(publicRuntimeConfig.APP_URL, '/api/clients'), { + let res = await fetch(join(APP_URL, '/api/clients'), { method: 'POST', body: JSON.stringify({ client_name: this.state.clientName, - redirect_uris: [this.state.redirectURI] + redirect_uris: [this.state.redirectURI], }), headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) let newClient = {} if (res.status === 200) { @@ -82,95 +93,104 @@ class Clients extends Component { await this.refreshClients() } - async deleteClient (id) { - await fetch(join(publicRuntimeConfig.APP_URL, `/api/clients/${id}`), { method: 'DELETE' }) + async deleteClient(id) { + await fetch(join(APP_URL, `/api/clients/${id}`), { + method: 'DELETE', + }) await this.refreshClients() } - handleClientNameChange (e) { + handleClientNameChange(e) { this.setState({ - clientName: e.target.value + clientName: e.target.value, }) } - handleClientCallbackChange (e) { + handleClientCallbackChange(e) { this.setState({ - redirectURI: e.target.value + redirectURI: e.target.value, }) } - async refreshClients () { + async refreshClients() { try { let { clients } = await this.getClients() this.setState({ clients, - loading: false + loading: false, }) } catch (e) { console.error(e) this.setState({ error: e, clients: [], - loading: false + loading: false, }) } } - componentDidMount () { + componentDidMount() { this.refreshClients() } - render () { + render() { if (this.state.loading) return
    Loading...
    - if (this.state.error) return
    {this.state.error.message}
    + if (this.state.error) + return
    {this.state.error.message}
    let token = this.props.token || '' let clients = this.state.clients let clientSection =

    No clients created

    if (clients.length > 0) { - clientSection = (
      - { - clients.map(client => { + clientSection = ( +
        + {clients.map((client) => { return (
      • {client.client_name}
        ({client.client_id})
        - +
      • ) - }) - } - -
      ) + @media screen and (min-width: ${theme.mediaRanges.medium}) { + .client-item { + align-items: center; + } + } + + .client-item span { + font-weight: bold; + } + `} + +
    + ) } return ( @@ -184,7 +204,8 @@ class Clients extends Component {
    -
    -
    - +
    - { - this.state.newClient - ?
    -

    Newly created client

    -

    ⚠️ Save this information, we won't show it again.

    - {newClient(this.state.newClient)} -
    - :
    - } + {this.state.newClient ? ( +
    +

    Newly created client

    +

    ⚠️ Save this information, we won't show it again.

    + {newClient(this.state.newClient)} +
    + ) : ( +
    + )}

    Your personal access token

    - +

    Your apps

    - { - clientSection - } + {clientSection}
    + + } + > + {description} + + ) +} diff --git a/components/edit-org-form.js b/src/components/edit-org-form.js similarity index 60% rename from components/edit-org-form.js rename to src/components/edit-org-form.js index f3259c37..d547dc86 100644 --- a/components/edit-org-form.js +++ b/src/components/edit-org-form.js @@ -2,15 +2,15 @@ import React from 'react' import { Formik, Field, Form } from 'formik' import Button from '../components/button' -function validateName (value) { +function validateName(value) { if (!value) return 'Name field is required' } -function renderError (text) { +function renderError(text) { return
    {text}
    } -function renderErrors (errors) { +function renderErrors(errors) { const keys = Object.keys(errors) return keys.map((key) => { return renderError(errors[key]) @@ -19,26 +19,50 @@ function renderErrors (errors) { const defaultValues = { name: '', - description: '' + description: '', } -export default function EditOrgForm ({ initialValues = defaultValues, onSubmit }) { +export default function EditOrgForm({ + initialValues = defaultValues, + onSubmit, +}) { return ( { + render={({ + status, + isSubmitting, + submitForm, + values, + errors, + setErrors, + setStatus, + }) => { return (

    Details

    - - + + {errors.name && renderError(errors.name)}
    - +
    @@ -46,7 +70,10 @@ export default function EditOrgForm ({ initialValues = defaultValues, onSubmit } - A private organization does not show its member list or team details to non-members. + + A private organization does not show its member list or team + details to non-members. +
    @@ -54,10 +81,12 @@ export default function EditOrgForm ({ initialValues = defaultValues, onSubmit } - This overrides the org teams visibility setting. + + This overrides the org teams visibility setting. +
    - { (status && status.errors) && (renderErrors(status.errors)) } + {status && status.errors && renderErrors(status.errors)}
    + + ) + }} + /> + ) +} diff --git a/components/form-map.js b/src/components/form-map.js similarity index 85% rename from components/form-map.js rename to src/components/form-map.js index fa337a18..fd3cf9f7 100644 --- a/components/form-map.js +++ b/src/components/form-map.js @@ -4,22 +4,22 @@ import { reverse } from 'ramda' import Geocoder from 'leaflet-control-geocoder' export default class FormMap extends Component { - constructor (props) { + constructor(props) { super(props) this.map = React.createRef() this.state = { - zoom: 15 + zoom: 15, } } - setZoom (zoom) { + setZoom(zoom) { this.setState({ zoom }) } - componentDidMount () { + componentDidMount() { if (this.map && !this.geocoder) { this.geocoder = new Geocoder({ - defaultMarkGeocode: false + defaultMarkGeocode: false, }) this.geocoder.on('markgeocode', (e) => { @@ -31,8 +31,10 @@ export default class FormMap extends Component { } } - render () { - let centerGeojson = this.props.value || '{ "type": "Point", "coordinates": [-73.968056,40.749444] }' + render() { + let centerGeojson = + this.props.value || + '{ "type": "Point", "coordinates": [-73.968056,40.749444] }' let center = reverse(JSON.parse(centerGeojson).coordinates) return ( diff --git a/src/components/header.js b/src/components/header.js new file mode 100644 index 00000000..72c88333 --- /dev/null +++ b/src/components/header.js @@ -0,0 +1,114 @@ +import React from 'react' +import theme from '../styles/theme' +import Link from 'next/link' +import Image from 'next/image' + +export default function Header({ uid, username, picture }) { + return ( +
    +
    + +

    + Teams +

    + {uid ? ( + + +

    {username}

    + + ) : ( + + Login + + )} +
    + +
    + ) +} diff --git a/src/components/layout.js b/src/components/layout.js new file mode 100644 index 00000000..75b9f6ba --- /dev/null +++ b/src/components/layout.js @@ -0,0 +1,268 @@ +import React from 'react' +import css from 'styled-jsx/css' +import theme from '../styles/theme' + +export const globalStyles = css.global` + html { + box-sizing: border-box; + font-size: ${theme.typography.rootFontSize}; + + /* Changes the default tap highlight to be completely transparent in iOS. */ + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + } + + *, + *::before, + *::after { + box-sizing: inherit; + } + + body { + margin: 0; + padding: 0; + background: ${theme.colors.backgroundColor}; + color: ${theme.typography.baseFontColor}; + font-size: ${theme.typography.baseFontSize}; + line-height: ${theme.typography.baseLineHeight}; + font-family: ${theme.typography.baseFontFamily}; + font-weight: ${theme.typography.baseFontWeight}; + font-style: ${theme.typography.baseFontStyle}; + min-width: ${theme.layout.rowMinWidth}; + } + + .app-container { + overflow: hidden; + } + + .page-layout { + display: grid; + position: relative; + grid-template-rows: 4rem 1fr; + grid-template-columns: 100%; + grid-template-areas: + 'sidebar' + 'main' + 'footer'; + height: 100vh; + overflow: overlay; + } + + @media screen and (min-width: ${theme.mediaRanges.small}) { + .page-layout { + grid-template-columns: 4rem 1fr; + grid-template-rows: 1fr; + grid-template-areas: 'sidebar main'; + } + } + + @media screen and (min-width: ${theme.mediaRanges.large}) { + .page-layout { + grid-template-columns: 14rem 1fr; + } + } + + .inner { + margin: 0 auto; + width: 100%; + max-width: ${theme.layout.rowMaxWidth}; + padding: 0 ${theme.layout.globalSpacing}; + } + + .inner.page { + grid-area: main; + margin-top: calc(${theme.layout.globalSpacing} * 4); + margin-bottom: calc(${theme.layout.globalSpacing} * 4); + } + + .page__heading { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: ${theme.layout.globalSpacing}; + } + + .section-actions { + display: flex; + flex-direction: column; + justify-content: space-between; + margin-bottom: ${theme.layout.globalSpacing}; + } + + @media (min-width: ${theme.mediaRanges.medium}) { + .page__heading { + flex-direction: row; + align-items: center; + } + .section-actions { + flex-direction: row; + align-items: center; + } + } + + .alert { + background: white; + padding: 2em; + margin-bottom: 1rem; + box-shadow: 4px 4px 0 ${theme.colors.warningColor}; + } + + /* Typography + ========================================================================== */ + + h1, + h2, + h3 { + font-family: ${theme.typography.headingFontFamily}; + font-weight: ${theme.typography.baseFontWeight}; + color: ${theme.colors.primaryColor}; + margin-top: 0; + margin-bottom: 0; + } + + h1 { + font-size: 2.827rem; + } + + h2 { + font-size: 1.999rem; + } + + h3 { + font-size: 1.414rem; + } + + p { + margin: 0 0 ${theme.layout.globalSpacing} 0; + } + + ::selection { + color: ${theme.colors.backgroundColor}; + background-color: ${theme.colors.primaryColor}; + } + + /* Links + ========================================================================== */ + + a { + cursor: pointer; + color: ${theme.colors.linkColor}; + text-decoration: none; + transition: opacity 0.24s ease 0s; + } + + a:visited { + color: ${theme.colors.linkColor}; + } + + a:hover { + opacity: 0.64; + } + + a:active { + transform: translate(0, 1px); + } + + a.danger { + color: ${theme.colors.secondaryColor}; + } + + /* Forms + ========================================================================== */ + input, + label, + select, + button, + textarea { + font-size: 1rem; + font-family: ${theme.typography.baseFontFamily}; + } + + .form-control { + margin-bottom: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + } + + .form-control__vertical { + flex-direction: column; + align-items: flex-start; + } + + .form-control :global(label) { + font-size: 0.875rem; + margin-bottom: 0.5rem; + } + + .form-control :global(input), + .form-control :global(textarea) { + min-width: 6rem; + padding: 0.5rem 1rem 0.5rem 0.25rem; + margin-right: 1rem; + border: 2px solid ${theme.colors.primaryColor}; + } + + .form-control :global(input[type='color']) { + padding: 3px; + height: 2.5rem; + } + + .status--alert { + font-size: 0.875rem; + color: ${theme.colors.secondaryColor}; + background-color: ${theme.colors.secondaryLite}; + font-family: ${theme.typography.headingFontFamily}; + padding: 1rem; + margin: 1rem 0 2rem; + } + + .form--required { + color: ${theme.colors.secondaryColor}; + font-size: 0.875rem; + font-family: ${theme.typography.headingFontFamily}; + } + + .form--error { + color: ${theme.colors.secondaryColor}; + } + + /* Tether element */ + .tether-element { + z-index: 1000; + } + + .hidden { + display: none; + } + + img { + max-width: 100%; + } + + #feedback { + position: fixed; + right: -3.5rem; + bottom: 12rem; + z-index: 1200; + transform: rotate(-90deg); + background: ${theme.colors.secondaryColor}; + color: white; + overflow: hidden; + } + #feedback:hover { + opacity: 1; + background: #e04d2d; + } +` +function Layout(props) { + return ( +
    + {props.children} + +
    + ) +} + +export default Layout diff --git a/src/components/list-map.js b/src/components/list-map.js new file mode 100644 index 00000000..523dfb22 --- /dev/null +++ b/src/components/list-map.js @@ -0,0 +1,40 @@ +import React, { Component, createRef } from 'react' +import { Map, CircleMarker, TileLayer } from 'react-leaflet' + +export default class ListMap extends Component { + constructor(props) { + super(props) + + this.map = createRef() + } + render() { + const markers = this.props.markers.map((marker) => ( + + )) + + return ( + { + const bounds = this.map.current.leafletElement.getBounds() + const { _southWest: sw, _northEast: ne } = bounds + this.props.onBoundsChange([sw.lng, sw.lat, ne.lng, ne.lat]) // xmin, ymin, xmax, ymax + }} + > + + {markers} + + ) + } +} diff --git a/components/list.js b/src/components/list.js similarity index 73% rename from components/list.js rename to src/components/list.js index 4db056b0..85588b7f 100644 --- a/components/list.js +++ b/src/components/list.js @@ -1,12 +1,10 @@ import React from 'react' +import Link from 'next/link' import theme from '../styles/theme' const Item = ({ item, children }) => { return ( - + {children(item)} - + ) } -export default function List ({ items, children }) { +export default function List({ items, children }) { return (
    {items.map((item, index) => { return ( - + {children} ) diff --git a/components/marker.js b/src/components/marker.js similarity index 54% rename from components/marker.js rename to src/components/marker.js index 4292c8b3..e6d6c6db 100644 --- a/components/marker.js +++ b/src/components/marker.js @@ -1,6 +1,6 @@ import React from 'react' -export default function Marker () { +export default function Marker() { return (
    -
    +
    ) } diff --git a/src/components/privacy-policy-form.js b/src/components/privacy-policy-form.js new file mode 100644 index 00000000..525e84c8 --- /dev/null +++ b/src/components/privacy-policy-form.js @@ -0,0 +1,93 @@ +import React from 'react' +import { Formik, Field, Form } from 'formik' +import Button from '../components/button' + +function validateBody(value) { + if (!value) return 'Body of privacy policy is required' +} + +function validateConsentText(value) { + if (!value) return 'Consent Text of privacy policy is required' +} + +function renderError(text) { + return
    {text}
    +} + +function renderErrors(errors) { + const keys = Object.keys(errors) + return keys.map((key) => { + return renderError(errors[key]) + }) +} + +export default function PrivacyPolicyForm({ initialValues, onSubmit }) { + return ( + { + return ( +
    +
    + + +
    +
    + + +
    +
    + {status && status.errors && renderErrors(status.errors)} +
    + + ) + }} + /> + ) +} diff --git a/components/profile-attribute-form.js b/src/components/profile-attribute-form.js similarity index 79% rename from components/profile-attribute-form.js rename to src/components/profile-attribute-form.js index da2171ef..4a2f81d1 100644 --- a/components/profile-attribute-form.js +++ b/src/components/profile-attribute-form.js @@ -3,7 +3,7 @@ import { Formik, Field, Form } from 'formik' import { assoc } from 'ramda' import Button from './button' -const validate = values => { +const validate = (values) => { const errors = {} if (!values.name || values.name.length < 1) { errors.name = 'Required' @@ -16,12 +16,17 @@ const defaultValues = { description: '', visibility: 'team', required: [], - key_type: 'text' + key_type: 'text', } -export default function ProfileAttributeForm ({ onSubmit, initialValues = defaultValues, formType = 'team' }) { +export default function ProfileAttributeForm({ + onSubmit, + initialValues = defaultValues, + formType = 'team', +}) { if (formType === 'org') { - initialValues['visibility'] = (initialValues['visibility'] === 'public') ? 'public' : 'org' + initialValues['visibility'] = + initialValues['visibility'] === 'public' ? 'public' : 'org' } return ( @@ -31,7 +36,11 @@ export default function ProfileAttributeForm ({ onSubmit, initialValues = defaul onSubmit={async (values, actions) => { actions.setSubmitting(true) - let data = assoc('required', (values.required.includes('required')), values) + let data = assoc( + 'required', + values.required.includes('required'), + values + ) try { await onSubmit(data) actions.setSubmitting(false) @@ -46,16 +55,20 @@ export default function ProfileAttributeForm ({ onSubmit, initialValues = defaul const addAttributeText = `Submit ${isSubmitting ? ' 🕙' : ''}` let typeOption = if (formType === 'org') { - typeOption = <> - - - + typeOption = ( + <> + + + + ) } return (
    - + - {errors.name ?
    {errors.name}
    : null} + {errors.name ? ( +
    {errors.name}
    + ) : null}
    @@ -74,7 +89,6 @@ export default function ProfileAttributeForm ({ onSubmit, initialValues = defaul placeholder='Describe the attribute' value={values.description} /> -
    @@ -110,7 +124,8 @@ export default function ProfileAttributeForm ({ onSubmit, initialValues = defaul
    -
    + {badges.map((b) => ( + + + + + ))} +
    + + {b.name}
    + ) +} + +export default function ProfileModal({ + user, + attributes, + badges, + onClose, + actions, +}) { + actions = actions || [] + let profileContent =
    User does not have a profile
    + if (!isEmpty(attributes)) { + profileContent = ( + <> +
    + {attributes && + attributes.map((attribute) => { + if (attribute.value) { + return ( + <> +
    {attribute.name}:
    +
    {attribute.value}
    + + ) + } + })} +
    + + + ) + } + const ref = useRef() + return ( +
    +
    +
    + {user.img ? ( +
    + +
    + ) : ( +
    + +
    + )} +

    {user.name}

    +
    +
    + {!isEmpty(actions) && renderActions(actions, ref)} + {profileContent} + {renderBadges(badges)} + +
    + ) +} diff --git a/components/section-header.js b/src/components/section-header.js similarity index 75% rename from components/section-header.js rename to src/components/section-header.js index 3ab89360..09e622e7 100644 --- a/components/section-header.js +++ b/src/components/section-header.js @@ -1,18 +1,18 @@ import React from 'react' import theme from '../styles/theme' -export default function SectionHeader ({ children }) { +export default function SectionHeader({ children }) { return (

    {children} - diff --git a/src/components/section.js b/src/components/section.js new file mode 100644 index 00000000..ede0076f --- /dev/null +++ b/src/components/section.js @@ -0,0 +1,5 @@ +import React from 'react' + +export default function Section({ children }) { + return
    {children}
    +} diff --git a/src/components/sidebar.js b/src/components/sidebar.js new file mode 100644 index 00000000..f8a3bda0 --- /dev/null +++ b/src/components/sidebar.js @@ -0,0 +1,297 @@ +import React, { Fragment } from 'react' +import css from 'styled-jsx/css' +import join from 'url-join' +import theme from '../styles/theme' +import NavLink from '../components/Link' +import NextLink from 'next/link' +import { useSession, signIn, signOut } from 'next-auth/react' +import Button from './button' + +const URL = process.env.APP_URL + +const sidebarStyles = css.global` + .page__sidebar { + grid-area: sidebar; + top: 0; + position: sticky; + z-index: 100; + background-color: ${theme.colors.primaryColor}; + color: white; + display: flex; + flex-flow: row nowrap; + flex: 1; + margin: 0; + align-items: center; + justify-content: space-between; + overflow: hidden; + z-index: 1001; + } + + .page__title { + font-size: ${theme.typography.baseFontSize}; + max-width: 12rem; + } + + .page__title a, + .page__title a:visited { + font-size: ${theme.typography.baseFontSize}; + text-transform: uppercase; + color: ${theme.colors.secondaryColor}; + font-weight: bold; + letter-spacing: 0.1rem; + } + + .page__title img { + vertical-align: middle; + } + + .page__sidebar nav { + display: flex; + } + + .global-menu { + flex-flow: row nowrap; + margin-block-start: 0; + margin-block-end: 0; + padding-inline-start: 0; + flex: 1; + display: flex; + list-style: none; + margin: 0; + padding: 0; + align-items: center; + } + + .global-menu__link, + .global-menu__link:visited { + color: ${theme.colors.primaryColor}; + padding: 1.25rem; + display: inline-block; + text-align: center; + white-space: nowrap; + font-family: ${theme.typography.headingFontFamily}; + font-size: 0.8rem; + line-height: 1rem; + text-transform: uppercase; + background-repeat: no-repeat; + background-position: center; + background-size: 40%; + } + + .global-menu li { + line-height: 1; + } + + .global-menu__link.active { + background-color: ${theme.colors.primaryDark}; + } + + .global-menu__link.login { + text-align: center; + background: white; + } + .global-menu__link:active { + background-color: rgba(244, 244, 244, 0.1); + } + + .global-menu__link--make { + background-image: url(${join(URL, '/static/icon-teams.svg')}); + } + + .global-menu__link--profile { + background-image: url(${join(URL, '/static/icon-profile.svg')}); + } + + .global-menu__link--app { + background-image: url(${join(URL, '/static/icon-gear.svg')}); + } + + .global-menu__link--explore { + background-image: url(${join(URL, '/static/icon-globe.svg')}); + } + + .global-menu__link--developers { + background-image: url(${join(URL, '/static/icon-code.svg')}); + } + + .global-menu__link--about { + background-image: url(${join(URL, '/static/icon-info.svg')}); + } + + .global-menu__link span { + display: none; + color: white; + font-size: 0.75rem; + text-align: center; + font-family: ${theme.typography.headingFontFamily}; + transition: opacity 0.2s ease; + } + + .global-menu__link:hover span { + opacity: 1; + } + + @media screen and (min-width: ${theme.mediaRanges.small}) { + .page__sidebar { + flex-flow: column nowrap; + max-height: 100vh; + align-items: center; + justify-content: flex-start; + } + + .page__headline { + margin: 2rem 0; + } + + .page__sidebar nav { + flex: 1; + flex-flow: column nowrap; + } + + .global-menu li { + max-height: 4rem; + } + + .global-menu__link { + padding: 2rem; + background-size: initial; + } + + .global-menu__link.login { + transform: rotate(-90deg) translateX(2rem); + } + + .global-menu { + flex-flow: column nowrap; + } + + .global-menu > li { + margin-bottom: 1.5rem; + margin-right: 0; + } + } + @media screen and (min-width: ${theme.mediaRanges.large}) { + .page__sidebar { + align-items: baseline; + overflow: hidden; + } + + .page__sidebar nav { + width: 100%; + } + + .global-menu { + align-items: baseline; + } + + .global-menu > li { + width: 100%; + } + + .global-menu__link { + display: block; + text-align: left; + background-position: 12% 50%; + padding: 1.5rem 1rem; + } + + .global-menu__link span { + display: inline-block; + margin-left: 3rem; + } + .global-menu__link.login { + transform: none; + margin-bottom: 2rem; + } + } +` + +export default function Sidebar() { + const { status } = useSession() + + const isAuthenticated = status === 'authenticated' + + const additionalMenuItems = ( + +
  • + +
    + Make New Team +
    +
    +
  • +
  • + +
    + Profile +
    +
    +
  • +
  • + +
    + Connect a new app +
    +
    +
  • +
    + ) + return ( +
    +
    +

    + + + +

    +
    + + +
    + ) +} diff --git a/components/svg-square.js b/src/components/svg-square.js similarity index 72% rename from components/svg-square.js rename to src/components/svg-square.js index 0c33d9bc..a4e291c9 100644 --- a/components/svg-square.js +++ b/src/components/svg-square.js @@ -1,6 +1,6 @@ import React from 'react' -export default function SvgSquare ({ color, size = 20 }) { +export default function SvgSquare({ color, size = 20 }) { return ( diff --git a/components/table.js b/src/components/table.js similarity index 70% rename from components/table.js rename to src/components/table.js index 8d86689b..5a11611c 100644 --- a/components/table.js +++ b/src/components/table.js @@ -1,7 +1,7 @@ import React from 'react' import theme from '../styles/theme' -function TableHead ({ columns }) { +function TableHead({ columns }) { return (
    @@ -22,7 +22,7 @@ function TableHead ({ columns }) { ) } -function Row ({ columns, row, index, onRowClick }) { +function Row({ columns, row, index, onRowClick, showRowNumber }) { return (
    { @@ -30,9 +30,16 @@ function Row ({ columns, row, index, onRowClick }) { }} > {columns.map(({ key }) => { - const item = typeof row[key] === 'function' ? row[key](row, index, columns) : row[key] + let item = + typeof row[key] === 'function' + ? row[key](row, index, columns) + : row[key] + if (showRowNumber && key === ' ') { + item = index + 1 + } return ( {item} @@ -43,7 +50,13 @@ function Row ({ columns, row, index, onRowClick }) { ) } -function TableBody ({ columns, rows, onRowClick, emptyPlaceHolder }) { +function TableBody({ + columns, + rows, + onRowClick, + emptyPlaceHolder, + showRowNumbers, +}) { return ( {!rows || rows.length === 0 ? ( @@ -61,6 +74,7 @@ function TableBody ({ columns, rows, onRowClick, emptyPlaceHolder }) { row={row} index={index} onRowClick={onRowClick} + showRowNumber={showRowNumbers} /> ) }) @@ -69,11 +83,24 @@ function TableBody ({ columns, rows, onRowClick, emptyPlaceHolder }) { ) } -export default function Table ({ columns, rows, onRowClick, emptyPlaceHolder }) { +export default function Table({ + columns, + rows, + onRowClick, + emptyPlaceHolder, + showRowNumbers, +}) { + showRowNumbers && columns.unshift({ key: ' ' }) return (
    - +
    ) diff --git a/components/team-map.js b/src/components/team-map.js similarity index 77% rename from components/team-map.js rename to src/components/team-map.js index 7bb3e945..2a21b722 100644 --- a/components/team-map.js +++ b/src/components/team-map.js @@ -2,14 +2,18 @@ import React, { Component } from 'react' import { Map, CircleMarker, TileLayer } from 'react-leaflet' export default class ListMap extends Component { - render () { + render() { return ( - + ) } diff --git a/src/components/team.js b/src/components/team.js new file mode 100644 index 00000000..f821fd13 --- /dev/null +++ b/src/components/team.js @@ -0,0 +1,24 @@ +import React from 'react' +import theme from '../styles/theme' + +export function TeamDetailSmall({ id, name, hashtag }) { + return ( +
  • +
    + {name} +
    {hashtag}
    +
    + +
  • + ) +} diff --git a/lib/api-client.js b/src/lib/api-client.js similarity index 77% rename from lib/api-client.js rename to src/lib/api-client.js index 991a0dea..6acb8a5a 100644 --- a/lib/api-client.js +++ b/src/lib/api-client.js @@ -1,29 +1,26 @@ -import getConfig from 'next/config' - -const { publicRuntimeConfig } = getConfig() - +const APP_URL = process.env.APP_URL class ApiClient { - constructor (props = {}) { + constructor() { this.defaultOptions = { headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, } } - baseUrl (subpath) { - return `${publicRuntimeConfig.APP_URL}/api${subpath}` + baseUrl(subpath) { + return `${APP_URL}/api${subpath}` } - async fetch (method, path, data, config = {}) { + async fetch(method, path, data, config = {}) { const url = this.baseUrl(path) const defaultConfig = { - format: 'json' + format: 'json', } const requestConfig = { ...defaultConfig, - ...config + ...config, } const { format, headers } = requestConfig @@ -36,7 +33,7 @@ class ApiClient { ...this.defaultOptions, method, format, - headers: requestHeaders + headers: requestHeaders, } if (data) { @@ -49,19 +46,19 @@ class ApiClient { return res.body } - get (path, config) { + get(path, config) { return this.fetch('GET', path, null, config) } - post (path, data, config) { + post(path, data, config) { return this.fetch('POST', path, data, config) } - patch (path, data, config) { + patch(path, data, config) { return this.fetch('PATCH', path, data, config) } - delete (path, config) { + delete(path, config) { return this.fetch('DELETE', path, config) } } @@ -75,7 +72,7 @@ export default ApiClient * @param {string} url Url to query * @param {object} options Options for fetch */ -export async function fetchJSON (url, options) { +export async function fetchJSON(url, options) { let response options = options || {} const format = options.format || 'json' diff --git a/src/lib/db.js b/src/lib/db.js new file mode 100644 index 00000000..58317351 --- /dev/null +++ b/src/lib/db.js @@ -0,0 +1,3 @@ +const knex = require('knex') +const config = require('../../knexfile') +module.exports = knex(config) diff --git a/src/lib/logger.js b/src/lib/logger.js new file mode 100644 index 00000000..dac982ce --- /dev/null +++ b/src/lib/logger.js @@ -0,0 +1,3 @@ +import pino from 'pino' + +export default pino({ prettyPrint: true }) diff --git a/lib/org-api.js b/src/lib/org-api.js similarity index 51% rename from lib/org-api.js rename to src/lib/org-api.js index 8dff75bd..c962a218 100644 --- a/lib/org-api.js +++ b/src/lib/org-api.js @@ -1,8 +1,6 @@ -import getConfig from 'next/config' import join from 'url-join' - -const { publicRuntimeConfig } = getConfig() -const URL = join(publicRuntimeConfig.APP_URL, '/api/organizations') +const APP_URL = process.env.APP_URL +const ORG_URL = join(APP_URL, '/api/organizations') /** * createTeam @@ -10,13 +8,13 @@ const URL = join(publicRuntimeConfig.APP_URL, '/api/organizations') * @param {Object} data - Basic team information * @returns {Response} */ -export async function createOrg (data) { - const res = await fetch(URL, { +export async function createOrg(data) { + const res = await fetch(ORG_URL, { method: 'POST', body: JSON.stringify(data), headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) if (res.status === 200) { @@ -31,8 +29,8 @@ export async function createOrg (data) { * get list of organizations * */ -export async function getMyOrgs () { - const res = await fetch(join(publicRuntimeConfig.APP_URL, '/api/my/organizations')) +export async function getMyOrgs() { + const res = await fetch(join(APP_URL, '/api/my/organizations')) if (res.status === 200) { return res.json() @@ -47,8 +45,8 @@ export async function getMyOrgs () { * * @param {integer} id */ -export async function getOrg (id) { - let res = await fetch(join(URL, `${id}`)) +export async function getOrg(id) { + let res = await fetch(join(ORG_URL, `${id}`)) if (res.status === 200) { return res.json() } else { @@ -58,13 +56,35 @@ export async function getOrg (id) { } } +/** + * getOrgTeams + * Get list of teams from the API + * + * @param {integer} id + * @returns {response} + */ +export async function getOrgTeams(id) { + let res = await fetch(join(ORG_URL, `${id}`, 'teams')) + + if (res.status === 200) { + return res.json() + } + if (res.status === 401) { + return { teams: [] } + } else { + const err = new Error('could not retrieve organization teams') + err.status = res.status + throw err + } +} + /** * getOrgStaff * get org staff * @param {integer} id */ -export async function getOrgStaff (id) { - let res = await fetch(join(URL, `${id}`, 'staff')) +export async function getOrgStaff(id) { + let res = await fetch(join(ORG_URL, `${id}`, 'staff')) if (res.status === 200) { return res.json() } @@ -82,8 +102,8 @@ export async function getOrgStaff (id) { * @param {integer} id * @param {integer} page */ -export async function getMembers (id, page) { - let res = await fetch(join(URL, `${id}`, 'members', `?page=${page}`)) +export async function getMembers(id, page) { + let res = await fetch(join(ORG_URL, `${id}`, 'members', `?page=${page}`)) if (res.status === 200) { return res.json() } @@ -101,13 +121,13 @@ export async function getMembers (id, page) { * @param {integer} id id of organization * @param {data} values data to update */ -export async function updateOrg (id, values) { - return fetch(join(URL, `${id}`), { +export async function updateOrg(id, values) { + return fetch(join(ORG_URL, `${id}`), { method: 'PUT', body: JSON.stringify(values), headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -116,13 +136,13 @@ export async function updateOrg (id, values) { * @param {integer} id id of organization * @param {data} values data to update */ -export async function updateOrgPrivacyPolicy (id, privacyPolicy) { - return fetch(join(URL, `${id}`), { +export async function updateOrgPrivacyPolicy(id, privacyPolicy) { + return fetch(join(ORG_URL, `${id}`), { method: 'PUT', - body: JSON.stringify({ 'privacy_policy': privacyPolicy }), + body: JSON.stringify({ privacy_policy: privacyPolicy }), headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -131,9 +151,9 @@ export async function updateOrgPrivacyPolicy (id, privacyPolicy) { * delete an org * @param {integer} id id of organization */ -export async function destroyOrg (id) { - return fetch(join(URL, `${id}`), { - method: 'DELETE' +export async function destroyOrg(id) { + return fetch(join(ORG_URL, `${id}`), { + method: 'DELETE', }) } @@ -142,12 +162,12 @@ export async function destroyOrg (id) { * @param {integer} id id of organization * @param {integer} osmId osmId to add */ -export async function addManager (id, osmId) { - return fetch(join(URL, `${id}`, 'addManager', `${osmId}`), { +export async function addManager(id, osmId) { + return fetch(join(ORG_URL, `${id}`, 'addManager', `${osmId}`), { method: 'PUT', headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -156,12 +176,12 @@ export async function addManager (id, osmId) { * @param {integer} id id of organization * @param {integer} osmId osmId to add */ -export async function removeManager (id, osmId) { - return fetch(join(URL, `${id}`, 'removeManager', `${osmId}`), { +export async function removeManager(id, osmId) { + return fetch(join(ORG_URL, `${id}`, 'removeManager', `${osmId}`), { method: 'PUT', headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -170,12 +190,12 @@ export async function removeManager (id, osmId) { * @param {integer} id id of organization * @param {integer} osmId osmId to add */ -export async function addOwner (id, osmId) { - return fetch(join(URL, `${id}`, 'addOwner', `${osmId}`), { +export async function addOwner(id, osmId) { + return fetch(join(ORG_URL, `${id}`, 'addOwner', `${osmId}`), { method: 'PUT', headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -184,11 +204,11 @@ export async function addOwner (id, osmId) { * @param {integer} id id of organization * @param {integer} osmId osmId to add */ -export async function removeOwner (id, osmId) { - return fetch(join(URL, `${id}`, 'removeOwner', `${osmId}`), { +export async function removeOwner(id, osmId) { + return fetch(join(ORG_URL, `${id}`, 'removeOwner', `${osmId}`), { method: 'PUT', headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } diff --git a/lib/profiles-api.js b/src/lib/profiles-api.js similarity index 53% rename from lib/profiles-api.js rename to src/lib/profiles-api.js index db5d6038..40ac0829 100644 --- a/lib/profiles-api.js +++ b/src/lib/profiles-api.js @@ -1,8 +1,7 @@ -import getConfig from 'next/config' import join from 'url-join' -const { publicRuntimeConfig } = getConfig() -const URL = join(publicRuntimeConfig.APP_URL, '/api/profiles') +const APP_URL = process.env.APP_URL +const PROFILE_URL = join(APP_URL, '/api/profiles') /** * getTeamMemberAttributes @@ -10,12 +9,12 @@ const URL = join(publicRuntimeConfig.APP_URL, '/api/profiles') * * @returns {Array[Object]} - list of team details */ -export async function getTeamMemberAttributes (id) { - let res = await fetch(join(URL, 'keys', 'teams', `${id}`, 'users'), { +export async function getTeamMemberAttributes(id) { + let res = await fetch(join(PROFILE_URL, 'keys', 'teams', `${id}`, 'users'), { method: 'GET', headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) if (res.status === 200) { return res.json() @@ -32,13 +31,16 @@ export async function getTeamMemberAttributes (id) { * * @returns {Array[Object]} - list of team details */ -export async function getOrgMemberAttributes (id) { - let res = await fetch(join(URL, 'keys', 'organizations', `${id}`, 'users'), { - method: 'GET', - headers: { - 'Content-Type': 'application/json; charset=utf-8' +export async function getOrgMemberAttributes(id) { + let res = await fetch( + join(PROFILE_URL, 'keys', 'organizations', `${id}`, 'users'), + { + method: 'GET', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, } - }) + ) if (res.status === 200) { return res.json() } else { @@ -54,13 +56,16 @@ export async function getOrgMemberAttributes (id) { * * @returns {Array[Object]} - list of team details */ -export async function getOrgTeamAttributes (id) { - let res = await fetch(join(URL, 'keys', 'organizations', `${id}`, 'teams'), { - method: 'GET', - headers: { - 'Content-Type': 'application/json; charset=utf-8' +export async function getOrgTeamAttributes(id) { + let res = await fetch( + join(PROFILE_URL, 'keys', 'organizations', `${id}`, 'teams'), + { + method: 'GET', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, } - }) + ) if (res.status === 200) { return res.json() } else { @@ -76,12 +81,12 @@ export async function getOrgTeamAttributes (id) { * * @returns {Array[Object]} - list of team details */ -export async function getTeamAttributes (id) { - let res = await fetch(join(URL, 'keys', 'teams', `${id}`), { +export async function getTeamAttributes(id) { + let res = await fetch(join(PROFILE_URL, 'keys', 'teams', `${id}`), { method: 'GET', headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) if (res.status === 200) { return res.json() @@ -99,13 +104,13 @@ export async function getTeamAttributes (id) { * @param {integer} id - key id * @param {Object} data - new key attributes */ -export async function modifyAttribute (id, data) { - return fetch(join(URL, 'keys', `${id}`), { +export async function modifyAttribute(id, data) { + return fetch(join(PROFILE_URL, 'keys', `${id}`), { method: 'PUT', body: JSON.stringify(data), headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -114,12 +119,12 @@ export async function modifyAttribute (id, data) { * Deletes attribute given a profile key * @param {integer} id - key id */ -export async function deleteAttribute (id) { - return fetch(join(URL, 'keys', `${id}`), { +export async function deleteAttribute(id) { + return fetch(join(PROFILE_URL, 'keys', `${id}`), { method: 'DELETE', headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -127,16 +132,20 @@ export async function deleteAttribute (id) { * getUserOrgProfile * get the profile of a user in a org */ -export async function getUserOrgProfile (orgId, userId) { - const res = await fetch(join(URL, 'organizations', `${orgId}`, `${userId}`), { - method: 'GET', - headers: { - 'Content-Type': 'application/json; charset=utf-8' +export async function getUserOrgProfile(orgId, userId) { + const res = await fetch( + join(PROFILE_URL, 'organizations', `${orgId}`, `${userId}`), + { + method: 'GET', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, } - }) + ) if (res.status === 200) { return res.json() - } if (res.status === 404 || res.status === 401) { + } + if (res.status === 404 || res.status === 401) { return [] } else { const err = new Error('could not retrieve profile') @@ -149,16 +158,20 @@ export async function getUserOrgProfile (orgId, userId) { * getUserTeamProfile * get the profile of a user in a team */ -export async function getUserTeamProfile (teamId, userId) { - const res = await fetch(join(URL, 'teams', `${teamId}`, `${userId}`), { - method: 'GET', - headers: { - 'Content-Type': 'application/json; charset=utf-8' +export async function getUserTeamProfile(teamId, userId) { + const res = await fetch( + join(PROFILE_URL, 'teams', `${teamId}`, `${userId}`), + { + method: 'GET', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, } - }) + ) if (res.status === 200) { return res.json() - } if (res.status === 404 || res.status === 401) { + } + if (res.status === 404 || res.status === 401) { return [] } else { const err = new Error('could not retrieve profile') @@ -174,14 +187,14 @@ export async function getUserTeamProfile (teamId, userId) { * @param {integer} id - team id * @param {Object} data - key attribute */ -export async function addTeamMemberAttributes (id, data) { +export async function addTeamMemberAttributes(id, data) { data = [].concat(data) - return fetch(join(URL, 'keys', 'teams', `${id}`, 'users'), { + return fetch(join(PROFILE_URL, 'keys', 'teams', `${id}`, 'users'), { method: 'POST', body: JSON.stringify(data), headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -192,14 +205,14 @@ export async function addTeamMemberAttributes (id, data) { * @param {integer} id - team id * @param {Object} data - key attribute */ -export async function addOrgMemberAttributes (id, data) { +export async function addOrgMemberAttributes(id, data) { data = [].concat(data) - return fetch(join(URL, 'keys', 'organizations', `${id}`, 'users'), { + return fetch(join(PROFILE_URL, 'keys', 'organizations', `${id}`, 'users'), { method: 'POST', body: JSON.stringify(data), headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -210,14 +223,14 @@ export async function addOrgMemberAttributes (id, data) { * @param {integer} id - team id * @param {Object} data - key attribute */ -export async function addOrgTeamAttributes (id, data) { +export async function addOrgTeamAttributes(id, data) { data = [].concat(data) - return fetch(join(URL, 'keys', 'organizations', `${id}`, 'teams'), { + return fetch(join(PROFILE_URL, 'keys', 'organizations', `${id}`, 'teams'), { method: 'POST', body: JSON.stringify(data), headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -225,12 +238,12 @@ export async function addOrgTeamAttributes (id, data) { * getMyProfile * Get the requesting user's profile */ -export async function getMyProfile () { - let res = await fetch(join(publicRuntimeConfig.APP_URL, '/api/my/profiles'), { +export async function getMyProfile() { + let res = await fetch(join(APP_URL, '/api/my/profiles'), { method: 'GET', headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) if (res.status === 200) { return res.json() @@ -245,26 +258,27 @@ export async function getMyProfile () { * setMyProfile * Set the requesting user's profile */ -export async function setMyProfile (data) { - return fetch(join(publicRuntimeConfig.APP_URL, '/api/my/profiles'), { +export async function setMyProfile(data) { + return fetch(join(APP_URL, '/api/my/profiles'), { method: 'POST', body: JSON.stringify(data), headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } -export async function getTeamProfile (id) { - let res = await fetch(join(publicRuntimeConfig.APP_URL, `/api/profiles/teams/${id}`), { +export async function getTeamProfile(id) { + let res = await fetch(join(APP_URL, `/api/profiles/teams/${id}`), { method: 'GET', headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) if (res.status === 200) { return res.json() - } if (res.status === 404 || res.status === 401) { + } + if (res.status === 404 || res.status === 401) { return [] } else { const err = new Error('could not retrieve profile') diff --git a/lib/teams-api.js b/src/lib/teams-api.js similarity index 57% rename from lib/teams-api.js rename to src/lib/teams-api.js index 98eae9b3..5c652ac3 100644 --- a/lib/teams-api.js +++ b/src/lib/teams-api.js @@ -1,9 +1,8 @@ -import getConfig from 'next/config' import qs from 'qs' import join from 'url-join' -const { publicRuntimeConfig } = getConfig() -const URL = join(publicRuntimeConfig.APP_URL, '/api/teams') +const APP_URL = process.env.APP_URL +const TEAMS_URL = join(APP_URL, '/api/teams') /** * getTeams @@ -11,9 +10,9 @@ const URL = join(publicRuntimeConfig.APP_URL, '/api/teams') * * @returns {Array[Object]} - list of team details */ -export async function getTeams (options) { +export async function getTeams(options) { let str = qs.stringify(options, { arrayFormat: 'comma' }) - const res = await fetch(`${URL}?${str}`) + const res = await fetch(`${TEAMS_URL}?${str}`) if (res.status === 200) { return res.json() @@ -28,13 +27,13 @@ export async function getTeams (options) { * @param {Object} data - Basic team information * @returns {Response} */ -export async function createTeam (data) { - const res = await fetch(URL, { +export async function createTeam(data) { + const res = await fetch(TEAMS_URL, { method: 'POST', body: JSON.stringify(data), headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) if (res.status === 200) { @@ -51,14 +50,17 @@ export async function createTeam (data) { * @param {Object} data - Basic team information * @returns {Response} */ -export async function createOrgTeam (orgId, data) { - const res = await fetch(join(publicRuntimeConfig.APP_URL, 'api', 'organizations', `${orgId}`, 'teams'), { - method: 'POST', - body: JSON.stringify(data), - headers: { - 'Content-Type': 'application/json; charset=utf-8' +export async function createOrgTeam(orgId, data) { + const res = await fetch( + join(APP_URL, 'api', 'organizations', `${orgId}`, 'teams'), + { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, } - }) + ) if (res.status === 200) { return res.json() @@ -74,8 +76,8 @@ export async function createOrgTeam (orgId, data) { * @param id - id of team * @returns {Object} - team details */ -export async function getTeam (id) { - let res = await fetch(join(URL, `${id}`)) +export async function getTeam(id) { + let res = await fetch(join(TEAMS_URL, `${id}`)) if (res.status === 200) { return res.json() } else { @@ -93,13 +95,13 @@ export async function getTeam (id) { * @param values - Basic details { bio, name, hashtag } * @returns {Response} */ -export async function updateTeam (id, values) { - return fetch(join(URL, `${id}`), { +export async function updateTeam(id, values) { + return fetch(join(TEAMS_URL, `${id}`), { method: 'PUT', body: JSON.stringify(values), headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -107,9 +109,9 @@ export async function updateTeam (id, values) { * Delete a team * @param id - Team id */ -export async function destroyTeam (id) { - return fetch(join(URL, `${id}`), { - method: 'DELETE' +export async function destroyTeam(id) { + return fetch(join(TEAMS_URL, `${id}`), { + method: 'DELETE', }) } @@ -120,12 +122,13 @@ export async function destroyTeam (id) { * @param id - Team id * @returns {Response} */ -export async function getTeamMembers (id) { - let res = await fetch(join(URL, `${id}`, 'members')) +export async function getTeamMembers(id) { + let res = await fetch(join(TEAMS_URL, `${id}`, 'members')) if (res.status === 200) { return res.json() } - if (res.status === 401) { // If unauthorized, don't display team members + if (res.status === 401) { + // If unauthorized, don't display team members return { members: [], moderators: [] } } else { const err = new Error('could not retrieve team members') @@ -143,13 +146,13 @@ export async function getTeamMembers (id) { * @param {array} remove - array of osm IDs to remove * @returns {Response} */ -export async function updateMembers (id, add, remove) { - return fetch(join(URL, `${id}`, 'members'), { +export async function updateMembers(id, add, remove) { + return fetch(join(TEAMS_URL, `${id}`, 'members'), { method: 'PATCH', body: JSON.stringify({ add, remove }), headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -161,7 +164,7 @@ export async function updateMembers (id, add, remove) { * @param {int} osmId - user osm id * @returns {Response} */ -export async function addMember (id, osmId) { +export async function addMember(id, osmId) { return updateMembers(id, [osmId], []) } @@ -173,7 +176,7 @@ export async function addMember (id, osmId) { * @param {int} osmId - user osm id * @returns {Response} */ -export async function removeMember (id, osmId) { +export async function removeMember(id, osmId) { return updateMembers(id, [], [osmId]) } @@ -184,12 +187,12 @@ export async function removeMember (id, osmId) { * @param id - Team id * @returns {Response} */ -export async function joinTeam (id) { - return fetch(join(URL, `${id}`, 'join'), { +export async function joinTeam(id) { + return fetch(join(TEAMS_URL, `${id}`, 'join'), { method: 'PUT', headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -199,12 +202,12 @@ export async function joinTeam (id) { * @param {int} id - team id * @param {int} osmId - id of new moderator */ -export async function assignModerator (id, osmId) { - return fetch(join(URL, `${id}`, 'assignModerator', `${osmId}`), { +export async function assignModerator(id, osmId) { + return fetch(join(TEAMS_URL, `${id}`, 'assignModerator', `${osmId}`), { method: 'PUT', headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -214,12 +217,12 @@ export async function assignModerator (id, osmId) { * @param {int} id - team id * @param {int} osmId - id of new moderator */ -export async function removeModerator (id, osmId) { - return fetch(join(URL, `${id}`, 'removeModerator', `${osmId}`), { +export async function removeModerator(id, osmId) { + return fetch(join(TEAMS_URL, `${id}`, 'removeModerator', `${osmId}`), { method: 'PUT', headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) } @@ -229,8 +232,8 @@ export async function removeModerator (id, osmId) { * @param {int} id - team id * @returns {uuid[]} invitation ids */ -export async function getTeamJoinInvitations (id) { - let res = await fetch(join(URL, `${id}`, 'invitations')) +export async function getTeamJoinInvitations(id) { + let res = await fetch(join(TEAMS_URL, `${id}`, 'invitations')) if (res.status === 200) { return res.json() } else { @@ -240,12 +243,12 @@ export async function getTeamJoinInvitations (id) { } } -export async function createTeamJoinInvitation (id) { - const res = await fetch(join(URL, `${id}`, 'invitations'), { +export async function createTeamJoinInvitation(id) { + const res = await fetch(join(TEAMS_URL, `${id}`, 'invitations'), { method: 'POST', headers: { - 'Content-Type': 'application/json; charset=utf-8' - } + 'Content-Type': 'application/json; charset=utf-8', + }, }) if (res.status === 200) { @@ -255,13 +258,16 @@ export async function createTeamJoinInvitation (id) { } } -export async function acceptTeamJoinInvitation (id, uuid) { - const res = await fetch(join(URL, `${id}`, 'invitations', `${uuid}`, 'accept'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json; charset=utf-8' +export async function acceptTeamJoinInvitation(id, uuid) { + const res = await fetch( + join(TEAMS_URL, `${id}`, 'invitations', `${uuid}`, 'accept'), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, } - }) + ) return res } diff --git a/src/lib/utils.js b/src/lib/utils.js new file mode 100644 index 00000000..e7fe625b --- /dev/null +++ b/src/lib/utils.js @@ -0,0 +1,19 @@ +export function getRandomColor() { + var letters = '0123456789ABCDEF' + var color = '#' + for (var i = 0; i < 6; i++) { + color += letters[Math.floor(Math.random() * 16)] + } + return color +} + +/** + * Converts a date to the browser locale string + * + * @param {Number or String} timestamp + * @returns + */ +export function toDateString(timestamp) { + const dateFormat = new Intl.DateTimeFormat(navigator.language).format + return dateFormat(new Date(timestamp)) +} diff --git a/src/middleware.js b/src/middleware.js new file mode 100644 index 00000000..2102605c --- /dev/null +++ b/src/middleware.js @@ -0,0 +1,5 @@ +export { default } from 'next-auth/middleware' + +export const config = { + matcher: ['/clients', '/organizations/:path*', '/profile'], +} diff --git a/app/lib/organization.js b/src/models/organization.js similarity index 58% rename from app/lib/organization.js rename to src/models/organization.js index 59c6c4e5..64a2c824 100644 --- a/app/lib/organization.js +++ b/src/models/organization.js @@ -1,7 +1,7 @@ -const db = require('../db') +const db = require('../lib/db') const team = require('./team') const { map, prop, includes, has, isNil } = require('ramda') -const { unpack, PropertyRequiredError } = require('./utils') +const { unpack, PropertyRequiredError } = require('../../app/lib/utils') // Organization attributes (without profile) const orgAttributes = [ @@ -12,7 +12,7 @@ const orgAttributes = [ 'teams_can_be_public', 'privacy_policy', 'created_at', - 'updated_at' + 'updated_at', ] /** @@ -21,9 +21,8 @@ const orgAttributes = [ * @param {int} id - organization id * @return {promise} */ -async function get (id) { - const conn = await db() - return unpack(conn('organization').select(orgAttributes).where('id', id)) +async function get(id) { + return unpack(db('organization').select(orgAttributes).where('id', id)) } /** @@ -31,24 +30,30 @@ async function get (id) { * * @param {integer} osmId */ -async function listMyOrganizations (osmId) { - const conn = await db() - const memberOrgs = await conn('organization').select(conn.raw('distinct(organization.id), organization.name')) - .join('organization_team', 'organization_team.organization_id', 'organization.id') +async function listMyOrganizations(osmId) { + const memberOrgs = await db('organization') + .select(db.raw('distinct(organization.id), organization.name')) + .join( + 'organization_team', + 'organization_team.organization_id', + 'organization.id' + ) .join('member', 'organization_team.id', 'member.team_id') .where('osm_id', osmId) - const managerOrgs = await conn('organization_manager') + const managerOrgs = await db('organization_manager') .join('organization', 'organization_id', 'organization.id') - .select().where('osm_id', osmId) - const ownerOrgs = await conn('organization_owner') + .select() + .where('osm_id', osmId) + const ownerOrgs = await db('organization_owner') .join('organization', 'organization_id', 'organization.id') - .select().where('osm_id', osmId) + .select() + .where('osm_id', osmId) return { memberOrgs, managerOrgs, - ownerOrgs + ownerOrgs, } } @@ -57,9 +62,8 @@ async function listMyOrganizations (osmId) { * @param {int} id - organization id * @return {promise} */ -async function getOwners (id) { - const conn = await db() - return conn('organization_owner').where('organization_id', id) +async function getOwners(id) { + return db('organization_owner').where('organization_id', id) } /** @@ -67,9 +71,8 @@ async function getOwners (id) { * @param {int} id - organization id * @return {promise} */ -async function getManagers (id) { - const conn = await db() - return conn('organization_manager').where('organization_id', id) +async function getManagers(id) { + return db('organization_manager').where('organization_id', id) } /** @@ -81,16 +84,23 @@ async function getManagers (id) { * @param {int} osmId - osm id of the owner * @return {promise} */ -async function create (data, osmId) { +async function create(data, osmId) { if (!osmId) throw new Error('owner osm id is required as second argument') if (!data.name) throw new Error('data.name property is required') - const conn = await db() - return conn.transaction(async trx => { - const [row] = await trx('organization').insert(data).returning(orgAttributes) - await trx('organization_owner').insert({ organization_id: row.id, osm_id: osmId }) - await trx('organization_manager').insert({ organization_id: row.id, osm_id: osmId }) + return db.transaction(async (trx) => { + const [row] = await trx('organization') + .insert(data) + .returning(orgAttributes) + await trx('organization_owner').insert({ + organization_id: row.id, + osm_id: osmId, + }) + await trx('organization_manager').insert({ + organization_id: row.id, + osm_id: osmId, + }) return row }) } @@ -101,9 +111,8 @@ async function create (data, osmId) { * @param {int} id - organization id * @return {promise} */ -async function destroy (id) { - const conn = await db() - return conn('organization').where('id', id).del() +async function destroy(id) { + return db('organization').where('id', id).del() } /** @@ -113,12 +122,13 @@ async function destroy (id) { * @param {object} data - params for an organization * @return {promise} */ -async function update (id, data) { - const conn = await db() +async function update(id, data) { 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)) + return unpack( + db('organization').where('id', id).update(data).returning(orgAttributes) + ) } /** @@ -128,13 +138,14 @@ async function update (id, data) { * @param {int} osmId - osm id of the owner * @return {promise} */ -async function addOwner (id, osmId) { - const conn = await db() +async function addOwner(id, osmId) { const isAlreadyOwner = await isOwner(id, osmId) // Only ids that are not already in owner list should be added. Duplicate requests should fail silently if (!isAlreadyOwner) { - return unpack(conn('organization_owner').insert({ organization_id: id, osm_id: osmId })) + return unpack( + db('organization_owner').insert({ organization_id: id, osm_id: osmId }) + ) } } @@ -146,17 +157,22 @@ async function addOwner (id, osmId) { * @param {int} osmId - osm id of the owner * @return {promise} */ -async function removeOwner (id, osmId) { - const conn = await db() +async function removeOwner(id, osmId) { const owners = map(prop('osm_id'), await getOwners(id)) if (owners.length === 1) { - throw new Error('cannot remove owner because there must be at least one owner') + throw new Error( + 'cannot remove owner because there must be at least one owner' + ) } // Only ids that are already in owner list can be removed. Requests for nonexistant ids should fail silently if (includes(osmId, owners)) { - return unpack(conn('organization_owner').where({ organization_id: id, osm_id: osmId }).del()) + return unpack( + db('organization_owner') + .where({ organization_id: id, osm_id: osmId }) + .del() + ) } } @@ -167,13 +183,17 @@ async function removeOwner (id, osmId) { * @param {int} osmId - osm id of the manager * @return {promise} */ -async function addManager (id, osmId) { - const conn = await db() +async function addManager(id, osmId) { const isAlreadyManager = await isManager(id, osmId) // Only ids that are not already in manager list should be added. Duplicate requests should fail silently if (!isAlreadyManager) { - return unpack(conn('organization_manager').insert({ organization_id: id, osm_id: osmId })) + return unpack( + db('organization_manager').insert({ + organization_id: id, + osm_id: osmId, + }) + ) } } @@ -185,13 +205,16 @@ async function addManager (id, osmId) { * @param {int} osmId - osm id of the manager * @return {promise} */ -async function removeManager (id, osmId) { - const conn = await db() +async function removeManager(id, osmId) { const managers = map(prop('osm_id'), await getManagers(id)) // Only ids that are already in manager list can be removed. Requests for nonexistant ids should fail silently if (includes(osmId, managers)) { - return unpack(conn('organization_manager').where({ organization_id: id, osm_id: osmId }).del()) + return unpack( + db('organization_manager') + .where({ organization_id: id, osm_id: osmId }) + .del() + ) } } @@ -206,12 +229,13 @@ async function removeManager (id, osmId) { * @param {int} osmId - id of the organization manager * @return {promise} */ -async function createOrgTeam (organizationId, data, osmId) { - const conn = await db() - - return conn.transaction(async trx => { +async function createOrgTeam(organizationId, data, osmId) { + return db.transaction(async (trx) => { const record = await team.create(data, osmId, trx) - await trx('organization_team').insert({ team_id: record.id, organization_id: organizationId }) + await trx('organization_team').insert({ + team_id: record.id, + organization_id: organizationId, + }) return record }) } @@ -221,14 +245,16 @@ async function createOrgTeam (organizationId, data, osmId) { * We get all members of all associated teams with this organization * @param {int} organizationId - organization id */ -async function getMembers (organizationId, page) { +async function getMembers(organizationId, page) { if (!organizationId) throw new PropertyRequiredError('organization id') - const conn = await db() - - const subquery = conn('organization_team').select('team_id').where('organization_id', organizationId) - let query = conn('member').select(conn.raw('array_agg(team_id) as teams, osm_id')) - .where('team_id', 'in', subquery).groupBy('osm_id') + const subquery = db('organization_team') + .select('team_id') + .where('organization_id', organizationId) + let query = db('member') + .select(db.raw('array_agg(team_id) as teams, osm_id')) + .where('team_id', 'in', subquery) + .groupBy('osm_id') if (page) { query = query.limit(50).offset(page * 20) @@ -241,7 +267,7 @@ async function getMembers (organizationId, page) { * @param {int} organizationId - organization id * @param {int} osmId - id of member we are testing */ -async function isMember (organizationId, osmId) { +async function isMember(organizationId, osmId) { if (!osmId) throw new PropertyRequiredError('osm id') const members = await getMembers(organizationId) const memberIds = members.map(prop('osm_id')) @@ -253,14 +279,22 @@ async function isMember (organizationId, osmId) { * @param {int} organizationId - organization id * @param {int} osmId - id of member we are testing */ -async function isMemberOrStaff (organizationId, osmId) { +async function isMemberOrStaff(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 memberQuery = conn('member').select('osm_id').where('team_id', 'in', subquery).andWhere('osm_id', osmId) - const ownerQuery = conn('organization_owner').select('osm_id').where({ organization_id: organizationId, osm_id: osmId }) - const managerQuery = conn('organization_manager').select('osm_id').where({ organization_id: organizationId, osm_id: osmId }) + const subquery = db('organization_team') + .select('team_id') + .where('organization_id', organizationId) + const memberQuery = db('member') + .select('osm_id') + .where('team_id', 'in', subquery) + .andWhere('osm_id', osmId) + const ownerQuery = db('organization_owner') + .select('osm_id') + .where({ organization_id: organizationId, osm_id: osmId }) + const managerQuery = db('organization_manager') + .select('osm_id') + .where({ organization_id: organizationId, osm_id: osmId }) const result = await memberQuery.union(ownerQuery).union(managerQuery) return result.length > 0 } @@ -270,12 +304,15 @@ async function isMemberOrStaff (organizationId, osmId) { * @param {int} organizationId - organization id * @param {int} osmId - id of member we are testing */ -async function isOrgTeamModerator (organizationId, osmId) { +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() + const subquery = db('organization_team') + .select('team_id') + .where('organization_id', organizationId) + const isModeratorOfAny = await db('moderator') + .whereIn('team_id', subquery) + .debug() return isModeratorOfAny.length > 0 } @@ -285,11 +322,13 @@ async function isOrgTeamModerator (organizationId, osmId) { * @param {int} osmId - osm id * @returns boolean */ -async function isOwner (organizationId, osmId) { +async function isOwner(organizationId, osmId) { if (!organizationId) throw new PropertyRequiredError('organization id') if (!osmId) throw new PropertyRequiredError('osm id') - const conn = await db() - const result = await conn('organization_owner').where({ organization_id: organizationId, osm_id: osmId }) + const result = await db('organization_owner').where({ + organization_id: organizationId, + osm_id: osmId, + }) return result.length > 0 } @@ -299,11 +338,13 @@ async function isOwner (organizationId, osmId) { * @param {int} osmId - osm id * @returns boolean */ -async function isManager (organizationId, osmId) { +async function isManager(organizationId, osmId) { if (!organizationId) throw new PropertyRequiredError('organization id') if (!osmId) throw new PropertyRequiredError('osm id') - const conn = await db() - const result = await conn('organization_manager').where({ organization_id: organizationId, osm_id: osmId }) + const result = await db('organization_manager').where({ + organization_id: organizationId, + osm_id: osmId, + }) return result.length > 0 } @@ -313,15 +354,26 @@ async function isManager (organizationId, osmId) { * @param {Object} options.organizationId - filter by organization * @param {Object} options.osmId - filter by osm id */ -async function getOrgStaff (options) { - const conn = await db() - let ownerQuery = conn('organization_owner') - .select(conn.raw("organization_id, osm_id, 'owner' as type, organization.name")) - .join('organization', 'organization.id', 'organization_owner.organization_id') - - let managerQuery = conn('organization_manager') - .select(conn.raw("organization_id, osm_id, 'manager' as type, organization.name")) - .join('organization', 'organization.id', 'organization_manager.organization_id') +async function getOrgStaff(options) { + let ownerQuery = db('organization_owner') + .select( + db.raw("organization_id, osm_id, 'owner' as type, organization.name") + ) + .join( + 'organization', + 'organization.id', + 'organization_owner.organization_id' + ) + + let managerQuery = db('organization_manager') + .select( + db.raw("organization_id, osm_id, 'manager' as type, organization.name") + ) + .join( + 'organization', + 'organization.id', + 'organization_manager.organization_id' + ) if (options.organizationId) { ownerQuery = ownerQuery.where('organization.id', options.organizationId) @@ -329,7 +381,10 @@ async function getOrgStaff (options) { } if (options.osmId) { ownerQuery = ownerQuery.where('organization_owner.osm_id', options.osmId) - managerQuery = managerQuery.where('organization_manager.osm_id', options.osmId) + managerQuery = managerQuery.where( + 'organization_manager.osm_id', + options.osmId + ) } return ownerQuery.unionAll(managerQuery) } @@ -341,11 +396,10 @@ async function getOrgStaff (options) { * @param orgId - ord id * @returns {Boolean} is the org public? */ -async function isPublic (orgId) { +async function isPublic(orgId) { if (!orgId) throw new PropertyRequiredError('organization id') - const conn = await db() - const { privacy } = await unpack(conn('organization').where({ id: orgId })) - return (privacy === 'public') + const { privacy } = await unpack(db('organization').where({ id: orgId })) + return privacy === 'public' } module.exports = { @@ -368,5 +422,5 @@ module.exports = { createOrgTeam, listMyOrganizations, getOrgStaff, - isPublic + isPublic, } diff --git a/app/lib/profile.js b/src/models/profile.js similarity index 65% rename from app/lib/profile.js rename to src/models/profile.js index 1b7d38d9..32ff0186 100644 --- a/app/lib/profile.js +++ b/src/models/profile.js @@ -1,11 +1,26 @@ -const db = require('../db') -const { unpack, ValidationError, checkRequiredProperties, PropertyRequiredError } = require('../lib/utils') -const { map, has, propEq, anyPass, forEach, prop, assoc, pick, keys } = require('ramda') +const db = require('../lib/db') +const { + unpack, + ValidationError, + checkRequiredProperties, + PropertyRequiredError, +} = require('../../app/lib/utils') +const { + map, + has, + propEq, + anyPass, + forEach, + prop, + assoc, + pick, + keys, +} = require('ramda') /** * Given the profile type return the db table */ -function getTableForProfileType (profileType) { +function getTableForProfileType(profileType) { let table switch (profileType) { case 'org': { @@ -28,20 +43,20 @@ function getTableForProfileType (profileType) { const ownerTypeValid = anyPass([ propEq('ownerType', 'user'), propEq('ownerType', 'team'), - propEq('ownerType', 'org') + propEq('ownerType', 'org'), ]) const profileTypeValid = anyPass([ propEq('profileType', 'user'), propEq('profileType', 'team'), - propEq('profileType', 'org') + propEq('profileType', 'org'), ]) const visibilityValid = anyPass([ propEq('visibility', 'public'), propEq('visibility', 'team'), propEq('visibility', 'org'), - propEq('visibility', 'org_staff') + propEq('visibility', 'org_staff'), ]) /** @@ -54,15 +69,19 @@ const visibilityValid = anyPass([ * @param {boolean} attribute.required whether this attribute is required to be filled * @param {enum} attribute.visibility enum: 'org_staff', 'org', 'user', 'team' */ -function checkProfileKey (attribute) { +function checkProfileKey(attribute) { checkRequiredProperties(['name', 'profileType'], attribute) if (!profileTypeValid(attribute)) { - throw new ValidationError('profileType should be one of "user", "team" or "org"') + throw new ValidationError( + 'profileType should be one of "user", "team" or "org"' + ) } if (has('visibility', attribute) && !visibilityValid(attribute)) { - throw new ValidationError('visibility should be one of "public", "team", "org" or "org_staff"') + throw new ValidationError( + 'visibility should be one of "public", "team", "org" or "org_staff"' + ) } } @@ -78,7 +97,7 @@ function checkProfileKey (attribute) { * @param {string} ownerType enum: 'owner', 'user', 'team' * @param {integer} ownerId iD in owner, user or team tables */ -async function addProfileKeys (attributes, ownerType, ownerId) { +async function addProfileKeys(attributes, ownerType, ownerId) { attributes = [].concat(attributes) if (!ownerId) { @@ -86,24 +105,23 @@ async function addProfileKeys (attributes, ownerType, ownerId) { } if (!ownerTypeValid({ ownerType })) { - throw new ValidationError('ownerType should be one of "user", "team" or "org"') + throw new ValidationError( + 'ownerType should be one of "user", "team" or "org"' + ) } // Run checks forEach(checkProfileKey, attributes) // Modify the columns for the database - const toInsert = attributes.map(({ - profileType, - ...rest - }) => ({ + const toInsert = attributes.map(({ profileType, ...rest }) => ({ [`owner_${ownerType}`]: ownerId, - 'profile_type': profileType, - ...rest + profile_type: profileType, + ...rest, })) - const conn = await db() - return conn('profile_keys').insert(toInsert) + return db('profile_keys') + .insert(toInsert) .onConflict(['name', `owner_${ownerType}`]) .merge() .returning('*') @@ -117,13 +135,14 @@ async function addProfileKeys (attributes, ownerType, ownerId) { * @param {boolean} attribute.required whether this attribute is required to be filled * @param {enum} attribute.visibility enum: 'org_staff', 'org', 'user', 'team' */ -async function modifyProfileKey (id, attribute) { +async function modifyProfileKey(id, attribute) { if (has('visibility', attribute) && !visibilityValid(attribute)) { - throw new ValidationError('visibility should be one of "public", "team", "org" or "org_staff"') + throw new ValidationError( + 'visibility should be one of "public", "team", "org" or "org_staff"' + ) } - const conn = await db() - return conn('profile_keys').where('id', id).update(attribute) + return db('profile_keys').where('id', id).update(attribute) } /** @@ -131,9 +150,8 @@ async function modifyProfileKey (id, attribute) { * * @param {integer} id id of key to delete */ -async function deleteProfileKey (id) { - const conn = await db() - return conn('profile_keys').where('id', id).delete() +async function deleteProfileKey(id) { + return db('profile_keys').where('id', id).delete() } /** @@ -142,9 +160,8 @@ async function deleteProfileKey (id) { * @param {integer} id id of key to get * @returns ProfileAttribute */ -async function getProfileKey (id) { - const conn = await db() - return unpack(conn('profile_keys').where('id', id)) +async function getProfileKey(id) { + return unpack(db('profile_keys').where('id', id)) } /** @@ -154,23 +171,26 @@ async function getProfileKey (id) { * @param {*} ownerId id of owner * @param {string} profileType 'user' | 'org' | 'team' */ -async function getProfileKeysForOwner (ownerType, ownerId, profileType) { +async function getProfileKeysForOwner(ownerType, ownerId, profileType) { if (!ownerTypeValid({ ownerType })) { - throw new ValidationError('ownerType must be one of "user", "org" or "team"') + throw new ValidationError( + 'ownerType must be one of "user", "org" or "team"' + ) } if (profileType && !profileTypeValid({ profileType })) { - throw new ValidationError('profileType must be one of "user", "org" or "team"') + throw new ValidationError( + 'profileType must be one of "user", "org" or "team"' + ) } - const conn = await db() - let query = conn('profile_keys').where({ - [`owner_${ownerType}`]: ownerId + let query = db('profile_keys').where({ + [`owner_${ownerType}`]: ownerId, }) if (profileType) { query = query.andWhere({ - 'profile_type': profileType + profile_type: profileType, }) } return query @@ -185,7 +205,7 @@ async function getProfileKeysForOwner (ownerType, ownerId, profileType) { * @param {string} profileType Type of profile we are inserting to * @param {integer} id ID of profile for which we are setting the attributes */ -async function setProfile (attributeValues, profileType, id) { +async function setProfile(attributeValues, profileType, id) { attributeValues = [].concat(attributeValues) if (!profileType) { @@ -197,20 +217,23 @@ async function setProfile (attributeValues, profileType, id) { } if (!profileTypeValid({ profileType })) { - throw new ValidationError('profileType should be one of "user", "team" or "org"') + throw new ValidationError( + 'profileType should be one of "user", "team" or "org"' + ) } - const conn = await db() const currentProfile = await getProfile(profileType, id) let tags = prop('tags', currentProfile) // Pick tags that are still in the database - const tagsInDB = await conn('profile_keys').select('id').whereIn('id', keys(tags)) + const tagsInDB = await db('profile_keys') + .select('id') + .whereIn('id', keys(tags)) tags = pick(map(prop('id'), tagsInDB), tags) // Add the attribute values - attributeValues.forEach(tagPair => { + attributeValues.forEach((tagPair) => { if (tagPair.key_id) { tags[tagPair.key_id] = tagPair.value } @@ -218,8 +241,11 @@ async function setProfile (attributeValues, profileType, id) { const table = getTableForProfileType(profileType) - return conn(table) - .update({ profile: assoc('tags', tags, currentProfile), updated_at: conn.fn.now() }) + return db(table) + .update({ + profile: assoc('tags', tags, currentProfile), + updated_at: db.fn.now(), + }) .where('id', id) } @@ -230,7 +256,7 @@ async function setProfile (attributeValues, profileType, id) { * @param {integer} id ID of profile for which we are getting the attributes * @returns */ -async function getProfile (profileType, id) { +async function getProfile(profileType, id) { if (!profileType) { throw new PropertyRequiredError('profileType') } @@ -239,31 +265,31 @@ async function getProfile (profileType, id) { } if (!profileTypeValid({ profileType })) { - throw new ValidationError('profileType should be one of "user", "team" or "org"') + throw new ValidationError( + 'profileType should be one of "user", "team" or "org"' + ) } const table = getTableForProfileType(profileType) - const conn = await db() - return unpack(conn(table).select('profile').where('id', id)) - .then(prop('profile')) + return unpack(db(table).select('profile').where('id', id)).then( + prop('profile') + ) } -async function getUserManageToken (id) { - const conn = await db() - return unpack(conn('users').select('manageToken').where('id', id).debug()) +async function getUserManageToken(id) { + return unpack(db('users').select('manageToken').where('id', id).debug()) } -async function getUserBadges (id) { - const conn = await db() - return conn('user_badges') +async function getUserBadges(id) { + return db('user_badges') .select([ 'id', 'assigned_at', 'valid_until', 'organization_id', 'name', - 'color' + 'color', ]) .leftJoin( 'organization_badge', @@ -283,5 +309,5 @@ module.exports = { getProfile, getTableForProfileType, getUserManageToken, - getUserBadges + getUserBadges, } diff --git a/src/models/team-invitation.js b/src/models/team-invitation.js new file mode 100644 index 00000000..75304d1d --- /dev/null +++ b/src/models/team-invitation.js @@ -0,0 +1,26 @@ +const db = require('../lib/db') +const crypto = require('crypto') + +async function create({ uuid, teamId, createdAt, expiresAt }) { + const [invitation] = await db('invitations') + .insert({ + id: uuid || crypto.randomUUID(), + team_id: teamId, + created_at: createdAt, + expires_at: expiresAt, + }) + .returning('*') + return invitation +} + +async function get({ id, teamId }) { + const [invitation] = await db('invitations') + .select() + .where({ id, team_id: teamId }) + return invitation +} + +module.exports = { + create, + get, +} diff --git a/app/lib/team.js b/src/models/team.js similarity index 51% rename from app/lib/team.js rename to src/models/team.js index fceb10b9..67df2e1f 100644 --- a/app/lib/team.js +++ b/src/models/team.js @@ -1,8 +1,8 @@ -const db = require('../db') +const db = require('../lib/db') const knexPostgis = require('knex-postgis') const join = require('url-join') const xml2js = require('xml2js') -const { unpack } = require('./utils') +const { unpack } = require('../../app/lib/utils') const { prop, isEmpty } = require('ramda') const request = require('request-promise-native') @@ -19,7 +19,7 @@ const teamAttributes = [ 'privacy', 'require_join_request', 'updated_at', - 'created_at' + 'created_at', ] /** @@ -33,16 +33,31 @@ const teamAttributes = [ * - id: osm id * */ -async function resolveMemberNames (ids) { +async function resolveMemberNames(ids) { + // The following avoids hitting OSM API when testing. We use TESTING variable + // instead of NODE_EN because the server run in development mode while + // executing E2E tests. + if (process.env.TESTING === 'true') { + return ids.map((id) => ({ + id, + name: `User ${id}`, + image: `https://via.placeholder.com/150`, + })) + } + try { - const resp = await request(join(serverRuntimeConfig.OSM_API, `/api/0.6/users?users=${ids.join(',')}`)) + const resp = await request( + join(serverRuntimeConfig.OSM_API, `/api/0.6/users?users=${ids.join(',')}`) + ) var parser = new xml2js.Parser() return new Promise((resolve, reject) => { parser.parseString(resp, (err, xml) => { - if (err) { reject(err) } + if (err) { + reject(err) + } - let users = xml.osm.user.map(user => { + let users = xml.osm.user.map((user) => { let img = prop('img', user) if (img) { img = img[0]['$'].href @@ -50,7 +65,7 @@ async function resolveMemberNames (ids) { return { id: user['$'].id, name: user['$'].display_name, - img + img, } }) @@ -64,26 +79,28 @@ async function resolveMemberNames (ids) { } /** -* Get a team -* -* @param {int} id - team id -* @return {promise} -**/ -async function get (id) { - const conn = await db() - const st = knexPostgis(conn) - return unpack(conn('team').select(...teamAttributes, st.asGeoJSON('location')).where('id', id)) + * Get a team + * + * @param {int} id - team id + * @return {promise} + **/ +async function get(id) { + const st = knexPostgis(db) + return unpack( + db('team') + .select(...teamAttributes, st.asGeoJSON('location')) + .where('id', id) + ) } /** -* Get a team's members -* -* @param {int} id - team id -* @return {promise} -**/ -async function getMembers (id) { - const conn = await db() - return conn('member').where('team_id', id) + * Get a team's members + * + * @param {int} id - team id + * @return {promise} + **/ +async function getMembers(id) { + return db('member').where('team_id', id) } /** @@ -93,28 +110,26 @@ async function getMembers (id) { * @param {int} id - team Id * @returns {Promise[Array]} list of moderators */ -async function getModerators (id) { - const conn = await db() - return conn('moderator').where('team_id', id) +async function getModerators(id) { + return db('moderator').where('team_id', id) } /** -* Get all teams -* -* @param options -* @param {int} options.osmId - filter by whether osmId is a member -* @param {int} options.organizationId - filter by whether team belongs to organization -* @param {Array[float]} options.bbox - filter for teams whose location is in bbox (xmin, ymin, xmax, ymax) -* @return {Promise[Array]} -**/ -async function list (options) { + * Get all teams + * + * @param options + * @param {int} options.osmId - filter by whether osmId is a member + * @param {int} options.organizationId - filter by whether team belongs to organization + * @param {Array[float]} options.bbox - filter for teams whose location is in bbox (xmin, ymin, xmax, ymax) + * @return {Promise[Array]} + **/ +async function list(options) { options = options || {} const { osmId, bbox, organizationId } = options - const conn = await db() - const st = knexPostgis(conn) + const st = knexPostgis(db) - let query = conn('team').select(...teamAttributes, st.asGeoJSON('location')) + let query = db('team').select(...teamAttributes, st.asGeoJSON('location')) if (osmId) { query = query.whereIn('id', function () { @@ -124,12 +139,16 @@ async function list (options) { if (organizationId) { query = query.whereIn('id', function () { - this.select('team_id').from('organization_team').where('organization_id', organizationId) + this.select('team_id') + .from('organization_team') + .where('organization_id', organizationId) }) } if (bbox) { - query = query.where(st.boundingBoxContained('location', st.makeEnvelope(...bbox))) + query = query.where( + st.boundingBoxContained('location', st.makeEnvelope(...bbox)) + ) } return query @@ -142,11 +161,8 @@ async function list (options) { * @returns {Promise<*>} * @async */ -async function listMembers (teamIds) { - const conn = await db() - return conn('member') - .select('team_id', 'osm_id') - .whereIn('team_id', teamIds) +async function listMembers(teamIds) { + return db('member').select('team_id', 'osm_id').whereIn('team_id', teamIds) } /** @@ -156,11 +172,8 @@ async function listMembers (teamIds) { * @returns {Promise<*>} * @async */ -async function listModerators (teamIds) { - const conn = await db() - return conn('moderator') - .select('team_id', 'osm_id') - .whereIn('team_id', teamIds) +async function listModerators(teamIds) { + return db('moderator').select('team_id', 'osm_id').whereIn('team_id', teamIds) } /** @@ -169,43 +182,46 @@ async function listModerators (teamIds) { * @param {*} osmId osm user id to filter by * @returns {Promise[Array]} */ -async function listModeratedBy (osmId) { - const conn = await db() - const st = knexPostgis(conn) - const query = conn('team') +async function listModeratedBy(osmId) { + const st = knexPostgis(db) + const query = db('team') .select(teamAttributes, st.asGeoJSON('location')) - .whereIn('id', subQuery => subQuery.select('team_id').from('moderator').where('osm_id', osmId)) + .whereIn('id', (subQuery) => + subQuery.select('team_id').from('moderator').where('osm_id', osmId) + ) return query } /** -* Create a team -* Teams have to have moderators, so we give an osm id -* as the second param -* -* @param {object} data - params for a team -* @param {string} data.name - name of the team -* @param {geojson?} data.location - lat/lon of team -* @param {int} osmId - id of first moderator -* @param {object=} trx - optional parameter for database connection to re-use connection in case of nested -* transactions. This is used when a team is created as part of an organization -* @return {promise} -**/ -async function create (data, osmId, trx) { + * Create a team + * Teams have to have moderators, so we give an osm id + * as the second param + * + * @param {object} data - params for a team + * @param {string} data.name - name of the team + * @param {geojson?} data.location - lat/lon of team + * @param {int} osmId - id of first moderator + * @param {object=} trx - optional parameter for database connection to re-use connection in case of nested + * transactions. This is used when a team is created as part of an organization + * @return {promise} + **/ +async function create(data, osmId, trx) { if (!osmId) throw new Error('moderator osm id is required as second argument') if (!data.name) throw new Error('data.name property is required') - const conn = trx || await db() + const conn = trx || db const st = knexPostgis(conn) // convert location to postgis geom if (data.location) { data = Object.assign(data, { - location: st.setSRID(st.geomFromGeoJSON(data.location), 4326) + location: st.setSRID(st.geomFromGeoJSON(data.location), 4326), }) } - return conn.transaction(async trx => { - const [row] = await trx('team').insert(data).returning(['*', st.asGeoJSON('location')]) + return conn.transaction(async (trx) => { + const [row] = await trx('team') + .insert(data) + .returning(['*', st.asGeoJSON('location')]) await trx('member').insert({ team_id: row.id, osm_id: osmId }) await trx('moderator').insert({ team_id: row.id, osm_id: osmId }) return row @@ -213,57 +229,58 @@ async function create (data, osmId, trx) { } /** -* Update a team -* @param {int} id - team id -* @param {object} data - params for a team -* @param {string} data.name - name of the team -* @return {promise} -**/ -async function update (id, data) { - const conn = await db() - const st = knexPostgis(conn) + * Update a team + * @param {int} id - team id + * @param {object} data - params for a team + * @param {string} data.name - name of the team + * @return {promise} + **/ +async function update(id, data) { + const st = knexPostgis(db) // convert location to postgis geom if (data.location) { data = Object.assign(data, { - location: st.setSRID(st.geomFromGeoJSON(data.location), 4326) + location: st.setSRID(st.geomFromGeoJSON(data.location), 4326), }) } - return unpack(conn('team').where('id', id).update(data).returning([...teamAttributes, st.asGeoJSON('location')])) + return unpack( + db('team') + .where('id', id) + .update(data) + .returning([...teamAttributes, st.asGeoJSON('location')]) + ) } /** -* Destroy a team and its members -* @param {int} id - team id -* @return {promise} -**/ -async function destroy (id) { - const conn = await db() - return conn.transaction(async trx => { + * Destroy a team and its members + * @param {int} id - team id + * @return {promise} + **/ +async function destroy(id) { + return db.transaction(async (trx) => { await trx('team').where('id', id).del() await trx('member').where('team_id', id).del() }) } /** -* Add multiple osm users as team members -* @param {int} teamId - team id -* @param {array} osmIdsToAdd - array of integer osm ids to add -* @param {array} osmIdsToRemove - array of integer osm ids to remove -* @return {promise} -**/ -async function updateMembers (teamId, osmIdsToAdd, osmIdstoRemove) { - const conn = await db() - + * Add multiple osm users as team members + * @param {int} teamId - team id + * @param {array} osmIdsToAdd - array of integer osm ids to add + * @param {array} osmIdsToRemove - array of integer osm ids to remove + * @return {promise} + **/ +async function updateMembers(teamId, osmIdsToAdd, osmIdstoRemove) { // Make sure we have integers - osmIdsToAdd = (osmIdsToAdd || []).map(x => parseInt(x)) - osmIdstoRemove = (osmIdstoRemove || []).map(x => parseInt(x)) + osmIdsToAdd = (osmIdsToAdd || []).map((x) => parseInt(x)) + osmIdstoRemove = (osmIdstoRemove || []).map((x) => parseInt(x)) - return conn.transaction(async trx => { + return db.transaction(async (trx) => { if (osmIdsToAdd) { const members = await trx('member').where('team_id', teamId) - let dedupedOsmIdsToAdd = osmIdsToAdd.filter(osmId => { + let dedupedOsmIdsToAdd = osmIdsToAdd.filter((osmId) => { for (let i = 0; i < members.length; i++) { if (members[i].osm_id === osmId) { return false @@ -271,32 +288,38 @@ async function updateMembers (teamId, osmIdsToAdd, osmIdstoRemove) { } return true }) - const toAdd = dedupedOsmIdsToAdd.map(osmId => ({ team_id: teamId, osm_id: osmId })) + const toAdd = dedupedOsmIdsToAdd.map((osmId) => ({ + team_id: teamId, + osm_id: osmId, + })) await trx.batchInsert('member', toAdd).returning('*') } if (osmIdstoRemove) { - await trx('member').where('team_id', teamId).andWhere('osm_id', 'in', osmIdstoRemove).del() + await trx('member') + .where('team_id', teamId) + .andWhere('osm_id', 'in', osmIdstoRemove) + .del() } }) } /** -* Add an osm user as a team member -* @param {int} teamId - team id -* @param {int} osmId - osm id -* @return {promise} -**/ -async function addMember (teamId, osmId) { + * Add an osm user as a team member + * @param {int} teamId - team id + * @param {int} osmId - osm id + * @return {promise} + **/ +async function addMember(teamId, osmId) { return updateMembers(teamId, [osmId], []) } /** -* Remove an osm user as a team member. Removes moderator as side-effect when necessary. -* @param {int} teamId - team id -* @param {int} osmId - osm id -* @return {promise} -**/ -async function removeMember (teamId, osmId) { + * Remove an osm user as a team member. Removes moderator as side-effect when necessary. + * @param {int} teamId - team id + * @param {int} osmId - osm id + * @return {promise} + **/ +async function removeMember(teamId, osmId) { const isMod = await isModerator(teamId, osmId) if (isMod) { await removeModerator(teamId, osmId) @@ -309,12 +332,13 @@ async function removeMember (teamId, osmId) { * @param {int} teamId - team id * @param {int} osmId - osm id */ -async function assignModerator (teamId, osmId) { - const conn = await db() - if (!await isMember(teamId, osmId)) { - throw new Error('cannot assign osmId to be moderator because they are not a team member yet') +async function assignModerator(teamId, osmId) { + if (!(await isMember(teamId, osmId))) { + throw new Error( + 'cannot assign osmId to be moderator because they are not a team member yet' + ) } - return unpack(conn('moderator').insert({ team_id: teamId, osm_id: osmId })) + return unpack(db('moderator').insert({ team_id: teamId, osm_id: osmId })) } /** @@ -323,10 +347,9 @@ async function assignModerator (teamId, osmId) { * @param {int} osmId - osm id * @throws {Error} if the osmId is the only remaining moderator for this team, or if osmId was not a moderator. */ -async function removeModerator (teamId, osmId) { +async function removeModerator(teamId, osmId) { const table = 'moderator' - const conn = await db() - const moderatorRecord = conn(table).where({ team_id: teamId, osm_id: osmId }) + const moderatorRecord = db(table).where({ team_id: teamId, osm_id: osmId }) const isModerator = (await moderatorRecord).length > 0 /* the isModerator() function could have been used here ^, but since we are * going to del() the record at the end of this function, calling isModerator() @@ -334,9 +357,11 @@ async function removeModerator (teamId, osmId) { if (!isModerator) { throw new Error('cannot remove osmId because osmId is not a moderator') } - const modCount = (await conn(table).where({ team_id: teamId })).length + const modCount = (await db(table).where({ team_id: teamId })).length if (modCount === 1) { - throw new Error('cannot remove osmId because there must be at least one moderator') + throw new Error( + 'cannot remove osmId because there must be at least one moderator' + ) } await moderatorRecord.del() } @@ -347,11 +372,13 @@ async function removeModerator (teamId, osmId) { * @param {int} osmId - osm id * @returns boolean */ -async function isModerator (teamId, osmId) { +async function isModerator(teamId, osmId) { if (!teamId) throw new Error('team id is required as first argument') if (!osmId) throw new Error('osm id is required as second argument') - const conn = await db() - const result = await conn('moderator').where({ team_id: teamId, osm_id: osmId }) + const result = await db('moderator').where({ + team_id: teamId, + osm_id: osmId, + }) return result.length > 0 } @@ -361,11 +388,10 @@ async function isModerator (teamId, osmId) { * @param {int} osmId - osm id * @returns boolean */ -async function isMember (teamId, osmId) { +async function isMember(teamId, osmId) { if (!teamId) throw new Error('team id is required as first argument') if (!osmId) throw new Error('osm id is required as second argument') - const conn = await db() - const result = await conn('member').where({ team_id: teamId, osm_id: osmId }) + const result = await db('member').where({ team_id: teamId, osm_id: osmId }) return result.length > 0 } @@ -376,12 +402,13 @@ async function isMember (teamId, osmId) { * @param teamId - team id * @returns {Boolean} is the team public? */ -async function isPublic (teamId) { +async function isPublic(teamId) { if (!teamId) throw new Error('team id is required as first argument') - const conn = await db() const { privacy } = await unpack( - conn('team') - .select('id', conn.raw(` + db('team') + .select( + 'id', + db.raw(` case when ( select teams_can_be_public from organization join organization_team on organization.id = organization_id where team_id = team.id @@ -389,10 +416,11 @@ async function isPublic (teamId) { when privacy = 'private' then 'private' when privacy = 'public' then 'public' end privacy - `)) + `) + ) .where({ id: teamId }) ) - return (privacy === 'public') + return privacy === 'public' } /** @@ -402,24 +430,26 @@ async function isPublic (teamId) { * @param teamId - team id * @returns {Object} organization id and name */ -async function associatedOrg (teamId) { +async function associatedOrg(teamId) { if (!teamId) throw new Error('team id is required as first argument') - const conn = await db() return unpack( - conn('organization_team') + db('organization_team') .where('team_id', teamId) .join('organization', 'organization_id', 'organization.id') .select('organization_id', 'name') ) } -async function isInvitationValid (teamId, invitationId) { +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') + 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 }) + const invitations = await db('invitations').where({ + team_id: teamId, + id: invitationId, + }) return !isEmpty(invitations) } @@ -445,5 +475,5 @@ module.exports = { isPublic, isInvitationValid, resolveMemberNames, - associatedOrg + associatedOrg, } diff --git a/src/pages/_app.js b/src/pages/_app.js new file mode 100644 index 00000000..9654378f --- /dev/null +++ b/src/pages/_app.js @@ -0,0 +1,40 @@ +import React from 'react' +import Head from 'next/head' +import Sidebar from '../components/sidebar' +import Layout from '../components/layout.js' +import Button from '../components/button' +import { ToastContainer } from 'react-toastify' +import { SessionProvider } from 'next-auth/react' +import join from 'url-join' + +const APP_URL = process.env.APP_URL + +export default function App({ + Component, + pageProps: { session, ...pageProps }, +}) { + return ( + + + OSM Teams + + + + + + + + + + + ) +} diff --git a/src/pages/_document.js b/src/pages/_document.js new file mode 100644 index 00000000..ec208458 --- /dev/null +++ b/src/pages/_document.js @@ -0,0 +1,40 @@ +import { Html, Head, Main, NextScript } from 'next/document' +import join from 'url-join' +const APP_URL = process.env.APP_URL + +export default function Document() { + return ( + + + + + + + + + + +
    + + + + ) +} diff --git a/src/pages/api/[[...slug]].js b/src/pages/api/[[...slug]].js new file mode 100644 index 00000000..04ee6895 --- /dev/null +++ b/src/pages/api/[[...slug]].js @@ -0,0 +1,46 @@ +import packageJson from '../../../package.json' +import nc from 'next-connect' +import manageRouter from '../../../app/manage' +import logger from '../../lib/logger' +import { unstable_getServerSession } from 'next-auth/next' +import { authOptions } from './auth/[...nextauth]' + +let handler = nc({ + attachParams: true, + onError: (err, req, res) => { + logger.error(err) + // Handle Boom errors + if (err.isBoom) { + const { statusCode, payload } = err.output + return res.status(statusCode).json(payload) + } + + // Generic error + return res.status(500).json({ + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', + }) + }, + onNoMatch: (req, res) => { + res.status(404).json({ + statusCode: 404, + error: 'Not Found', + message: 'missing', + }) + }, +}) + +// Add session to request +handler.use(async (req, res, next) => { + req.session = await unstable_getServerSession(req, res, authOptions) + next() +}) + +handler.get('api/', (_, res) => { + res.status(200).json({ version: packageJson.version }) +}) + +manageRouter(handler) + +export default handler diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js new file mode 100644 index 00000000..3e8f7c86 --- /dev/null +++ b/src/pages/api/auth/[...nextauth].js @@ -0,0 +1,65 @@ +import NextAuth from 'next-auth' +import { mergeDeepRight } from 'ramda' +const db = require('../../../lib/db') + +export const authOptions = { + // Configure one or more authentication providers + providers: [ + { + id: 'osm-teams', + name: 'OSM Teams', + type: 'oauth', + wellKnown: + 'https://auth.mapping.team/hyauth/.well-known/openid-configuration', + authorization: { params: { scope: 'openid offline' } }, + idToken: true, + async profile(profile) { + return { + id: profile.sub, + name: profile.preferred_username, + image: profile.picture, + } + }, + clientId: process.env.OSM_TEAMS_CLIENT_ID, + clientSecret: process.env.OSM_TEAMS_CLIENT_SECRET, + }, + ], + callbacks: { + async jwt({ token, account, profile }) { + // Persist the OAuth access_token and or the user id to the token right after signin + if (account) { + token.accessToken = account.access_token + token.userId = profile.sub + } + return token + }, + async session({ session, token }) { + // Send properties to the client, like an access_token and user id from a provider. + session.accessToken = token.accessToken + session.user_id = token.userId + return session + }, + }, + + events: { + async signIn({ profile }) { + // On successful sign in we should persist the user to the database + let [user] = await db('users').where('id', profile.id) + if (user) { + const newProfile = mergeDeepRight(user.profile, profile) + await db('users') + .where('id', profile.id) + .update({ + profile: JSON.stringify(newProfile), + }) + } else { + await db('users').insert({ + id: profile.id, + profile: JSON.stringify(profile), + }) + } + }, + }, +} + +export default NextAuth(authOptions) diff --git a/src/pages/api/swagger.js b/src/pages/api/swagger.js new file mode 100644 index 00000000..a6e1bbd2 --- /dev/null +++ b/src/pages/api/swagger.js @@ -0,0 +1,14 @@ +import { withSwagger } from 'next-swagger-doc' +import nextSwaggerDocSpec from '../../../next-swagger-doc.json' + +/** + * @swagger + * /api/swagger: + * get: + * description: Get Swagger file as JSON + * responses: + * 200: + * description: API description as Swagger JSON file + */ +const swaggerHandler = withSwagger(nextSwaggerDocSpec) +export default swaggerHandler() diff --git a/pages/clients.js b/src/pages/clients.js similarity index 55% rename from pages/clients.js rename to src/pages/clients.js index 712a396b..151a1cbb 100644 --- a/pages/clients.js +++ b/src/pages/clients.js @@ -2,17 +2,15 @@ import React, { Component } from 'react' import Clients from '../components/clients' export default class Profile extends Component { - static async getInitialProps ({ query }) { + static async getInitialProps({ query }) { if (query) { return { - token: query.access_token + token: query.access_token, } } } - render () { - return ( - - ) + render() { + return } } diff --git a/src/pages/consent.js b/src/pages/consent.js new file mode 100644 index 00000000..6c61254c --- /dev/null +++ b/src/pages/consent.js @@ -0,0 +1,139 @@ +import React, { Component } from 'react' +import Button from '../components/button' +import Link from 'next/link' + +class Consent extends Component { + static async getInitialProps({ query }) { + if (query) { + return { + user: query.user, + client: query.client, + challenge: query.challenge, + requested_scope: query.requested_scope, + } + } + } + + render() { + const { user, client, requested_scope, challenge } = this.props + + if (!client) { + return ( +
    + Invalid parameters, go back home? +
    + ) + } + + const clientDisplayName = client.client_name || client.client_id + return ( +
    +
    + +

    + Hi, {user}, {clientDisplayName} wants to access + resources on your behalf and needs the following permissions: +

    + {requested_scope.map((scope) => { + let scopeLabel = '' + switch (scope) { + case 'clients': { + scopeLabel = 'Read and update your OAuth clients' + break + } + case 'offline': { + scopeLabel = 'Offline access to your profile' + break + } + case 'openid': { + scopeLabel = 'Your user profile information' + break + } + default: { + scopeLabel = 'Unknown scope' + break + } + } + return ( +
    + + +
    +
    + ) + })} +

    + Do you want to be asked next time when this application wants to + access your data? The application will not be able to ask for more + permissions without your consent. +

    +
      + {client.client_policy ? ( +
    • + Policy +
    • + ) : ( +
      + )} + {client.tos_uri ? ( +
    • + Terms of Service +
    • + ) : ( +
      + )} +
    +

    + + +

    +

    + + +

    +
    + +
    + ) + } +} + +export default Consent diff --git a/src/pages/developers.js b/src/pages/developers.js new file mode 100644 index 00000000..0b73870d --- /dev/null +++ b/src/pages/developers.js @@ -0,0 +1,42 @@ +import React, { Component } from 'react' + +const URL = process.env.APP_URL + +class Developers extends Component { + render() { + return ( +
    +

    OSM Teams API Guide

    +

    + OSM Teams API builds a second authentication layer on top of the OSM + id, providing OAuth2 access to a user’s teams. A user signs in through + your app and clicks a “Connect Teams” button that will start the OAuth + flow, sending them to our API site to grant access to their teams, + returning with an access token your app can use to authenticate with + the API +

    +

    Resources

    + +
    + ) + } +} + +export default Developers diff --git a/src/pages/docs/api.js b/src/pages/docs/api.js new file mode 100644 index 00000000..f6c4fb78 --- /dev/null +++ b/src/pages/docs/api.js @@ -0,0 +1,22 @@ +import { createSwaggerSpec } from 'next-swagger-doc' +import dynamic from 'next/dynamic' +import 'swagger-ui-react/swagger-ui.css' +import nextSwaggerDocSpec from '../../../next-swagger-doc.json' + +const SwaggerUI = dynamic(import('swagger-ui-react'), { ssr: false }) + +function ApiDoc({ spec }) { + return +} + +export const getStaticProps = async () => { + const spec = createSwaggerSpec(nextSwaggerDocSpec) + + return { + props: { + spec, + }, + } +} + +export default ApiDoc diff --git a/src/pages/index.js b/src/pages/index.js new file mode 100644 index 00000000..666028d8 --- /dev/null +++ b/src/pages/index.js @@ -0,0 +1,218 @@ +import React from 'react' +import css from 'styled-jsx/css' +import Button from '../components/button' +import join from 'url-join' +import theme from '../styles/theme' +import { useSession, signOut, signIn } from 'next-auth/react' + +const OSM_NAME = process.env.OSM_NAME +const APP_URL = process.env.APP_URL + +const title = String.raw` + ____ _____ __ ___ _______________ __ ________ + / __ \/ ___// |/ / /_ __/ ____/ | / |/ / ___/ + / / / /\__ \/ /|_/ / / / / __/ / /| | / /|_/ /\__ \ +/ /_/ /___/ / / / / / / / /___/ ___ |/ / / /___/ / +\____//____/_/ /_/ /_/ /_____/_/ |_/_/ /_//____/ +` + +const homeStyles = css` + main { + background: ${theme.colors.primaryDark}; + background-image: radial-gradient(white 5%, transparent 0); + background-repeat: repeat; + background-size: 30px 30px; + font-family: ${theme.typography.headingFontFamily}; + position: relative; + z-index: 1; + grid-area: main; + overflow: inherit; + display: grid; + place-content: center; + } + main:after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + height: 100%; + width: 100%; + opacity: 0.8; + z-index: -1; + background: url(${join(APP_URL, '/static/grid-map.svg')}); + background-size: contain; + background-repeat: no-repeat; + background-position: center center; + overflow: hidden; + } + + .card, + .card h1, + .card h2, + .card a { + color: white; + } + + .welcome .card { + display: flex; + flex-flow: column wrap; + text-align: left; + max-width: 48rem; + padding: 2rem; + background: rgba(25, 51, 130, 0.9); + border: 4px solid white; + position: relative; + box-shadow: 12px 12px 0 ${theme.colors.primaryDark}, 12px 12px 0 3px white; + } + + .welcome__intro { + font-size: 0.8rem; + font-size: 2.75vw; + width: 100%; + margin-bottom: 1rem; + } + + pre { + max-width: 100%; + line-height: 1; + margin: 0; + font-family: ${theme.typography.headingFontFamily}; + } + + pre, + .welcome__intro + p, + .welcome__user h2, + .welcome__user--actions { + animation: VHS 2s cubic-bezier(0, 1.21, 0.84, 1.04) 5 alternate; + transition: text-shadow 0.5s ease; + overflow: hidden; + } + + pre:hover, + .welcome__intro + p:hover, + .welcome__user h2:hover, + .welcome__user--actions:hover { + text-shadow: -1px 0 blue, 1px 0 red; + } + + @keyframes VHS { + 0% { + text-shadow: -4px -1px 1px blue, 4px 1px 1px red; + } + 10% { + text-shadow: -2px 0 blue, 2px 0 red; + } + 100% { + text-shadow: -1px 0 red, 1px 0 blue; + } + } + + .welcome__intro + p { + padding-bottom: 2rem; + } + + .welcome__user { + align-self: flex-start; + width: 100%; + border: 2px dashed white; + padding: 2rem; + } + + .welcome__user--actions { + list-style: none; + display: flex; + flex-direction: column; + margin-block-start: 0; + margin-block-end: 0; + padding-inline-start: 0; + padding: ${theme.layout.globalSpacing} 0; + text-transform: uppercase; + } + + .welcome__user--actions li { + padding-bottom: calc(${theme.layout.globalSpacing} / 2); + } + + .welcome__user--actions li:before { + content: '--'; + line-height: 1; + margin-right: 10px; + color: ${theme.colors.secondaryColor}; + } + + .welcome__user--actions a:hover { + text-decoration: underline; + } + + @media screen and (min-width: ${theme.mediaRanges.small}) { + .welcome__intro { + font-size: 1rem; + } + } + + @media screen and (min-width: ${theme.mediaRanges.large}) { + main:after { + background-position: center center; + } + .inner.page.welcome { + margin-left: 0; + } + .welcome__intro { + font-size: 1.25rem; + } + } +` +export default function Home() { + const { data: session, status } = useSession() + + return ( +
    +
    +
    +

    +
    {title}
    +

    +

    + Create teams of {OSM_NAME} users and import them into your apps. + Making maps better, together. Enable teams in OpenStreetMap + applications, or build your team here. It’s dangerous to map alone! +

    + {status === 'authenticated' ? ( +
    +

    Welcome, {session.user.username}!

    + + +
    + ) : ( + + )} +
    +
    +
    + +
    + ) +} diff --git a/src/pages/login.js b/src/pages/login.js new file mode 100644 index 00000000..1cfb3f7a --- /dev/null +++ b/src/pages/login.js @@ -0,0 +1,32 @@ +import Button from '../components/button' +import React, { Component } from 'react' + +const OSM_NAME = process.env.OSM_NAME +class Login extends Component { + static async getInitialProps({ query }) { + if (query) { + return { + challenge: query.challenge, + } + } + } + + render() { + return ( +
    +

    Login

    +

    + Teams uses {OSM_NAME} as your login, connect your {OSM_NAME} account! +

    +
    + +
    + ) + } +} + +export default Login diff --git a/pages/badges-assignment/edit.js b/src/pages/organizations/[id]/badges/[badgeId]/assign/[userId].js similarity index 90% rename from pages/badges-assignment/edit.js rename to src/pages/organizations/[id]/badges/[badgeId]/assign/[userId].js index f78c4a39..03591949 100644 --- a/pages/badges-assignment/edit.js +++ b/src/pages/organizations/[id]/badges/[badgeId]/assign/[userId].js @@ -1,20 +1,18 @@ import React, { Component } from 'react' import * as Yup from 'yup' import { Formik, Field, Form } from 'formik' -import APIClient from '../../lib/api-client' -import Button from '../../components/button' +import APIClient from '../../../../../../lib/api-client' +import Button from '../../../../../../components/button' import { format } from 'date-fns' import { toast } from 'react-toastify' import join from 'url-join' import Router from 'next/router' -import getConfig from 'next/config' -const { publicRuntimeConfig } = getConfig() -const URL = publicRuntimeConfig.APP_URL +const URL = process.env.APP_URL const apiClient = new APIClient() -function ButtonWrapper ({ children }) { +function ButtonWrapper({ children }) { return (
    {children} @@ -27,7 +25,7 @@ function ButtonWrapper ({ children }) { ) } -function Section ({ children }) { +function Section({ children }) { return (
    {children} @@ -41,30 +39,30 @@ function Section ({ children }) { } export default class EditBadgeAssignment extends Component { - static async getInitialProps ({ query }) { + static async getInitialProps({ query }) { if (query) { return { orgId: query.id, badgeId: parseInt(query.badgeId), - userId: parseInt(query.userId) + userId: parseInt(query.userId), } } } - constructor (props) { + constructor(props) { super(props) this.state = { - isDeleting: false + isDeleting: false, } this.loadData = this.loadData.bind(this) } - async componentDidMount () { + async componentDidMount() { this.loadData() } - async loadData () { + async loadData() { const { orgId, badgeId, userId } = this.props try { @@ -84,18 +82,18 @@ export default class EditBadgeAssignment extends Component { this.setState({ org, badge, - assignment + assignment, }) } catch (error) { console.error(error) this.setState({ error, - loading: false + loading: false, }) } } - renderPageInner () { + renderPageInner() { if (this.state.error) { return
    An unexpected error occurred, please try again later.
    } @@ -124,7 +122,7 @@ export default class EditBadgeAssignment extends Component { (assignment && assignment.validUntil && assignment.validUntil.substring(0, 10)) || - '' + '', }} validationSchema={Yup.object().shape({ assignedAt: Yup.date().required( @@ -138,13 +136,13 @@ export default class EditBadgeAssignment extends Component { assignedAt, 'End date must be after the start date.' ) - ) + ), })} onSubmit={async ({ assignedAt, validUntil }) => { try { const payload = { assigned_at: assignedAt, - valid_until: validUntil !== '' ? validUntil : null + valid_until: validUntil !== '' ? validUntil : null, } await apiClient.patch( @@ -215,7 +213,7 @@ export default class EditBadgeAssignment extends Component { + ) + + return ( +
    +
    +

    Current Attributes

    +

    + Members of your organization will be able to add these attributes to + their profile. +

    + {memberAttributes && isEmpty(memberAttributes) ? ( + "You haven't added any attributes yet!" + ) : ( + + )} + +
    + {this.state.isModifying ? ( + <> +

    Modify attribute

    + { + await modifyAttribute(attribute.id, attribute) + this.setState({ isModifying: false }) + return this.getAttributes() + }} + /> + {CancelButton} + + ) : ( + '' + )} + {this.state.isAdding ? ( + <> +

    Add an attribute

    +

    Add an attribute to your org member's profile

    + { + await addOrgMemberAttributes(orgId, attributes) + this.setState({ isAdding: false }) + return this.getAttributes() + }} + /> + {CancelButton} + + ) : ( + !(this.state.isModifying || this.state.isDeleting) && ( + + ) + )} + {this.state.isDeleting ? ( + <> + + {CancelButton} + + ) : ( + '' + )} +
    + + ) + } +} diff --git a/src/pages/organizations/[id]/edit-team-profiles.js b/src/pages/organizations/[id]/edit-team-profiles.js new file mode 100644 index 00000000..79ea62f7 --- /dev/null +++ b/src/pages/organizations/[id]/edit-team-profiles.js @@ -0,0 +1,239 @@ +import React, { Component } from 'react' +import { assoc, isEmpty } from 'ramda' +import Popup from 'reactjs-popup' + +import ProfileAttributeForm from '../../../components/profile-attribute-form' +import Button from '../../../components/button' +import Table from '../../../components/table' +import { + addOrgTeamAttributes, + getOrgTeamAttributes, + modifyAttribute, + deleteAttribute, +} from '../../../lib/profiles-api' +import theme from '../../../styles/theme' + +export default class OrgEditTeamProfile extends Component { + static async getInitialProps({ query }) { + if (query) { + return { + id: query.id, + } + } + } + + constructor(props) { + super(props) + this.state = { + isAdding: false, + isModifying: false, + isDeleting: false, + rowToModify: {}, + rowToDelete: {}, + loading: true, + error: undefined, + } + + this.renderActions = this.renderActions.bind(this) + } + + async componentDidMount() { + this.getAttributes() + } + + renderActions(row) { + return ( + ⚙️} + position='left top' + on='click' + closeOnDocumentClick + contentStyle={{ padding: '10px', border: 'none' }} + > +
      +
    • { + this.setState({ + isModifying: true, + isAdding: false, + isDeleting: false, + rowToModify: assoc( + 'required', + row.required === 'true' ? ['required'] : [], + row + ), + }) + }} + > + Modify +
    • +
    • { + this.setState({ + isModifying: false, + isAdding: false, + isDeleting: true, + rowToDelete: row, + }) + }} + > + Delete +
    • +
    + +
    + ) + } + + async getAttributes() { + const { id } = this.props + try { + let teamAttributes = await getOrgTeamAttributes(id) + this.setState({ + orgId: id, + teamAttributes, + loading: false, + }) + } catch (e) { + console.error(e) + this.setState({ + error: e, + orgId: null, + teamAttributes: [], + loading: false, + }) + } + } + + render() { + const { teamAttributes, orgId } = this.state + const columns = [ + { key: 'name' }, + { key: 'description' }, + { key: 'visibility' }, + { key: 'key_type', label: 'type' }, + { key: 'required' }, + { key: 'actions' }, + ] + + let rows = [] + if (teamAttributes) { + rows = teamAttributes.map((attribute) => { + let newAttribute = assoc('actions', this.renderActions, attribute) + newAttribute.required = attribute.required.toString() + return newAttribute + }) + } + + const CancelButton = ( + + ) + + return ( +
    +
    +

    Current Attributes

    +

    + Teams of your organization will be able to add these attributes to + their profile. +

    + {teamAttributes && isEmpty(teamAttributes) ? ( + "You haven't added any attributes yet!" + ) : ( +
    + )} + +
    + {this.state.isModifying ? ( + <> +

    Modify attribute

    + { + await modifyAttribute(attribute.id, attribute) + this.setState({ isModifying: false }) + return this.getAttributes() + }} + /> + {CancelButton} + + ) : ( + '' + )} + {this.state.isAdding ? ( + <> +

    Add an attribute

    +

    Add an attribute to your org member's profile

    + { + await addOrgTeamAttributes(orgId, attributes) + this.setState({ isAdding: false }) + return this.getAttributes() + }} + /> + {CancelButton} + + ) : ( + !(this.state.isModifying || this.state.isDeleting) && ( + + ) + )} + {this.state.isDeleting ? ( + <> + + {CancelButton} + + ) : ( + '' + )} +
    + + ) + } +} diff --git a/pages/org-edit.js b/src/pages/organizations/[id]/edit.js similarity index 66% rename from pages/org-edit.js rename to src/pages/organizations/[id]/edit.js index 308c6565..c458cdfd 100644 --- a/pages/org-edit.js +++ b/src/pages/organizations/[id]/edit.js @@ -2,73 +2,73 @@ import React, { Component, Fragment } from 'react' import join from 'url-join' import Router from 'next/router' import { pick } from 'ramda' -import { getOrg, updateOrg, destroyOrg } from '../lib/org-api' -import getConfig from 'next/config' -import EditOrgForm from '../components/edit-org-form' -import Button from '../components/button' -import theme from '../styles/theme' -const { publicRuntimeConfig } = getConfig() +import { getOrg, updateOrg, destroyOrg } from '../../../lib/org-api' +import EditOrgForm from '../../../components/edit-org-form' +import Button from '../../../components/button' +import theme from '../../../styles/theme' + +const APP_URL = process.env.APP_URL export default class OrgEdit extends Component { - static async getInitialProps ({ query }) { + static async getInitialProps({ query }) { if (query) { return { - id: query.id + id: query.id, } } } - constructor (props) { + constructor(props) { super(props) this.state = { loading: true, error: undefined, - deleteClickedOnce: false + deleteClickedOnce: false, } } - async componentDidMount () { + async componentDidMount() { const { id } = this.props try { let org = await getOrg(id) this.setState({ org, - loading: false + loading: false, }) } catch (e) { console.error(e) this.setState({ error: e, team: null, - loading: false + loading: false, }) } } - async deleteOrg () { + async deleteOrg() { const { id } = this.props try { const res = await destroyOrg(id) if (res.ok) { - Router.push(join(publicRuntimeConfig.APP_URL, `/profile`)) + Router.push(join(APP_URL, `/profile`)) } else { throw new Error('Could not delete team') } } catch (e) { console.error(e) this.setState({ - error: e + error: e, }) } } - renderDeleter () { + renderDeleter() { let section = ( + + - - + + - +

    Danger Zone 🎸

    -

    Delete this org, org information and all memberships associated to this team

    - { this.renderDeleter() } - +

    + Delete this org, org information and all memberships associated to + this team +

    + {this.renderDeleter()}
    + + ) + } + + async getAttributes() { + const { id } = this.props + try { + let memberAttributes = await getTeamMemberAttributes(id) + this.setState({ + teamId: id, + memberAttributes, + loading: false, + }) + } catch (e) { + console.error(e) + this.setState({ + error: e, + teamId: null, + memberAttributes: [], + loading: false, + }) + } + } + + render() { + const { memberAttributes, teamId } = this.state + const columns = [ + { key: 'name' }, + { key: 'description' }, + { key: 'visibility' }, + { key: 'key_type', header: 'type' }, + { key: 'required' }, + { key: 'actions' }, + ] + + let rows = [] + if (memberAttributes) { + rows = memberAttributes.map((attribute) => { + let newAttribute = assoc('actions', this.renderActions, attribute) + newAttribute.required = attribute.required.toString() + return newAttribute + }) + } + + const CancelButton = ( + + ) + + return ( +
    +
    +

    Current Attributes

    +

    + Members of your team will be able to add these attributes to their + profile. +

    + {memberAttributes && isEmpty(memberAttributes) ? ( + "You haven't added any attributes yet!" + ) : ( +
    + )} + +
    + {this.state.isModifying ? ( + <> +

    Modify attribute

    + { + await modifyAttribute(attribute.id, attribute) + this.setState({ isModifying: false }) + return this.getAttributes() + }} + /> + {CancelButton} + + ) : ( + '' + )} + {this.state.isAdding ? ( + <> +

    Add an attribute

    +

    Add an attribute to your team member's profile

    + { + await addTeamMemberAttributes(teamId, attributes) + this.setState({ isAdding: false }) + return this.getAttributes() + }} + /> + {CancelButton} + + ) : ( + !(this.state.isModifying || this.state.isDeleting) && ( + + ) + )} + {this.state.isDeleting ? ( + <> + + {CancelButton} + + ) : ( + '' + )} +
    + + ) + } +} diff --git a/pages/team-edit.js b/src/pages/teams/[id]/edit.js similarity index 71% rename from pages/team-edit.js rename to src/pages/teams/[id]/edit.js index dd6c08ab..c32c16f1 100644 --- a/pages/team-edit.js +++ b/src/pages/teams/[id]/edit.js @@ -2,37 +2,40 @@ import React, { Component, Fragment } from 'react' import join from 'url-join' import Router from 'next/router' import { pick, split } from 'ramda' -import { getTeam, updateTeam, destroyTeam } from '../lib/teams-api' -import getConfig from 'next/config' -import EditTeamForm from '../components/edit-team-form' -import Button from '../components/button' -import theme from '../styles/theme' -import { getOrgTeamAttributes, getTeamAttributes, getTeamProfile } from '../lib/profiles-api' -const { publicRuntimeConfig } = getConfig() +import { getTeam, updateTeam, destroyTeam } from '../../../lib/teams-api' +import EditTeamForm from '../../../components/edit-team-form' +import Button from '../../../components/button' +import theme from '../../../styles/theme' +import { + getOrgTeamAttributes, + getTeamAttributes, + getTeamProfile, +} from '../../../lib/profiles-api' +const APP_URL = process.env.APP_URL export default class TeamEdit extends Component { - static async getInitialProps ({ query }) { + static async getInitialProps({ query }) { if (query) { return { - id: query.id + id: query.id, } } } - constructor (props) { + constructor(props) { super(props) this.state = { loading: true, error: undefined, - deleteClickedOnce: false + deleteClickedOnce: false, } } - async componentDidMount () { + async componentDidMount() { const { id } = this.props try { let team = await getTeam(id) - let teamAttributes = await getTeamAttributes(id) || [] + let teamAttributes = (await getTeamAttributes(id)) || [] let orgTeamAttributes = [] let profileValues = [] profileValues = await getTeamProfile(id) @@ -44,42 +47,42 @@ export default class TeamEdit extends Component { profileValues, teamAttributes, orgTeamAttributes, - loading: false + loading: false, }) } catch (e) { console.error(e) this.setState({ error: e, team: null, - loading: false + loading: false, }) } } - async deleteTeam () { + async deleteTeam() { const { id } = this.props try { const res = await destroyTeam(id) if (res.ok) { - Router.push(join(publicRuntimeConfig.APP_URL, `/teams`)) + Router.push(join(APP_URL, `/teams`)) } else { throw new Error('Could not delete team') } } catch (e) { console.error(e) this.setState({ - error: e + error: e, }) } } - renderDeleter () { + renderDeleter() { let section = ( + { try { - let tags = Object.keys(values.tags).map(key => { + let tags = Object.keys(values.tags).map((key) => { return { key_id: split('-', key)[1], - value: values.tags[key] + value: values.tags[key], } }) @@ -159,7 +175,7 @@ export default class TeamEdit extends Component { await updateTeam(team.id, values) actions.setSubmitting(false) - Router.push(join(publicRuntimeConfig.APP_URL, `/teams/${team.id}`)) + Router.push(join(APP_URL, `/teams/${team.id}`)) } catch (e) { console.error(e) actions.setSubmitting(false) @@ -171,9 +187,11 @@ export default class TeamEdit extends Component {

    Danger Zone 🎸

    -

    Delete this team, team information and all memberships associated to this team

    - { this.renderDeleter() } - +

    + Delete this team, team information and all memberships associated to + this team +

    + {this.renderDeleter()}