diff --git a/app/db/migrations/20220302104250_add_user_badges.js b/app/db/migrations/20220302104250_add_user_badges.js index df91e74a..bff7b27e 100644 --- a/app/db/migrations/20220302104250_add_user_badges.js +++ b/app/db/migrations/20220302104250_add_user_badges.js @@ -1,20 +1,17 @@ exports.up = async function (knex) { - await knex.schema.createTable('user_badge', (table) => { + await knex.schema.createTable('user_badges', (table) => { table .integer('badge_id') .references('id') .inTable('organization_badge') .onDelete('CASCADE') - table - .integer('user_id') - .references('id') - .inTable('organization_badge') - .onDelete('CASCADE') + table.integer('user_id') table.datetime('assigned_at').defaultTo(knex.fn.now()) table.datetime('valid_until') + table.unique(['badge_id', 'user_id']) }) } exports.down = async function (knex) { - await knex.schema.dropTable('user_badge') + await knex.schema.dropTable('user_badges') } diff --git a/app/lib/profile.js b/app/lib/profile.js index e0a16d7b..1b7d38d9 100644 --- a/app/lib/profile.js +++ b/app/lib/profile.js @@ -256,7 +256,7 @@ async function getUserManageToken (id) { async function getUserBadges (id) { const conn = await db() - return conn('user_badge') + return conn('user_badges') .select([ 'id', 'assigned_at', @@ -267,10 +267,10 @@ async function getUserBadges (id) { ]) .leftJoin( 'organization_badge', - 'user_badge.badge_id', + 'user_badges.badge_id', 'organization_badge.id' ) - .where('user_badge.user_id', id) + .where('user_badges.user_id', id) } module.exports = { diff --git a/app/lib/utils.js b/app/lib/utils.js index 04318c3f..bc4b2c96 100644 --- a/app/lib/utils.js +++ b/app/lib/utils.js @@ -30,9 +30,21 @@ function checkRequiredProperties (requiredProperties, object) { }) } +/** + * Converts a date to the browser locale string + * + * @param {Number or String} timestamp + * @returns + */ +function toDateString (timestamp) { + const dateFormat = new Intl.DateTimeFormat(navigator.language).format + return dateFormat(new Date(timestamp)) +} + module.exports = { unpack, ValidationError, PropertyRequiredError, - checkRequiredProperties + checkRequiredProperties, + toDateString } diff --git a/app/manage/badges.js b/app/manage/badges.js index 47eee591..191b4a6b 100644 --- a/app/manage/badges.js +++ b/app/manage/badges.js @@ -3,6 +3,7 @@ const yup = require('yup') const organization = require('../lib/organization') const profile = require('../lib/profile') const { routeWrapper } = require('./utils') +const team = require('../lib/team') /** * Get the list of badges of an organization @@ -21,6 +22,7 @@ const listBadges = routeWrapper({ const badges = await conn('organization_badge') .select('*') .where('organization_id', req.params.id) + .orderBy('id') reply.send(badges) } catch (err) { console.log(err) @@ -63,6 +65,68 @@ const createBadge = routeWrapper({ } }) +/** + * Get organization badge + */ +const getBadge = routeWrapper({ + validate: { + params: yup + .object({ + id: yup.number().required().positive().integer(), + badgeId: yup.number().required().positive().integer() + }) + .required() + }, + handler: async function (req, reply) { + try { + const conn = await db() + const [badge] = await conn('organization_badge') + .select('*') + .where('id', req.params.badgeId) + .returning('*') + + let users = await conn('user_badges') + .select({ + id: 'user_badges.user_id', + assignedAt: 'user_badges.assigned_at', + validUntil: 'user_badges.valid_until' + }) + .leftJoin( + 'organization_badge', + 'user_badges.badge_id', + 'organization_badge.id' + ) + .where('badge_id', req.params.badgeId) + .returning('*') + + if (users.length > 0) { + // Get user profiles + const userProfiles = ( + await team.resolveMemberNames(users.map((u) => u.id)) + ).reduce((acc, u) => { + acc[u.id] = u + return acc + }, {}) + + users = users.map((u) => ({ + id: u.id, + assignedAt: u.assignedAt, + validUntil: u.validUntil, + displayName: userProfiles[u.id] ? userProfiles[u.id].name : '' + })) + } + + reply.send({ + ...badge, + users + }) + } catch (err) { + console.log(err) + return reply.boom.badRequest(err.message) + } + } +}) + /** * Edit organization badge */ @@ -110,10 +174,11 @@ const deleteBadge = routeWrapper({ handler: async function (req, reply) { try { const conn = await db() - await conn('organization_badge') - .delete() - .where('id', req.params.badgeId) - return reply.sendStatus(200) + await conn('organization_badge').delete().where('id', req.params.badgeId) + return reply.send({ + status: 200, + message: `Badge ${req.params.badgeId} deleted successfully.` + }) } catch (err) { console.log(err) return reply.boom.badRequest(err.message) @@ -133,21 +198,27 @@ const assignUserBadge = routeWrapper({ userId: yup.number().required().positive().integer() }) .required(), - body: yup - .object({ - assigned_at: yup.date().optional(), - valid_until: yup.date().optional() - }) + body: yup.object({ + assigned_at: yup.date().optional(), + valid_until: yup.date().optional() + }) }, handler: async function (req, reply) { try { const conn = await db() - // user is member - await organization.isMember(req.params.id, req.params.userId) + // user is related to org? + const isMemberOrStaff = await organization.isMemberOrStaff( + req.params.id, + req.params.userId + ) + + if (!isMemberOrStaff) { + return reply.boom.badRequest('User is not part of the organization.') + } // assign badge - const [badge] = await conn('user_badge') + const [badge] = await conn('user_badges') .insert({ user_id: req.params.userId, badge_id: req.params.badgeId, @@ -159,7 +230,13 @@ const assignUserBadge = routeWrapper({ reply.send(badge) } catch (err) { console.log(err) - return reply.boom.badRequest(err.message) + if (err.code === '23505') { + return reply.boom.badRequest('User is already assigned to badge.') + } else { + return reply.boom.badRequest( + 'Unexpected error, please try again later.' + ) + } } } }) @@ -197,18 +274,17 @@ const updateUserBadge = routeWrapper({ userId: yup.number().required().positive().integer() }) .required(), - body: yup - .object({ - assigned_at: yup.date().optional(), - valid_until: yup.date().optional() - }) + body: yup.object({ + assigned_at: yup.date().optional(), + valid_until: yup.date().optional() + }) }, handler: async function (req, reply) { try { const conn = await db() // assign badge - const [badge] = await conn('user_badge') + const [badge] = await conn('user_badges') .update({ assigned_at: req.body.assigned_at, valid_until: req.body.valid_until @@ -244,7 +320,7 @@ const removeUserBadge = routeWrapper({ const conn = await db() // delete user badge - await conn('user_badge').delete().where({ + await conn('user_badges').delete().where({ user_id: req.params.userId, badge_id: req.params.badgeId }) @@ -260,6 +336,7 @@ const removeUserBadge = routeWrapper({ module.exports = { listBadges, createBadge, + getBadge, patchBadge, deleteBadge, assignUserBadge, diff --git a/app/manage/index.js b/app/manage/index.js index e8500d5a..553d9d90 100644 --- a/app/manage/index.js +++ b/app/manage/index.js @@ -41,6 +41,7 @@ const { const { createBadge, + getBadge, patchBadge, deleteBadge, listBadges, @@ -152,6 +153,11 @@ function manageRouter (nextApp) { can('organization:edit'), createBadge ) + router.get( + '/api/organizations/:id/badges/:badgeId', + can('organization:edit'), + getBadge + ) router.patch( '/api/organizations/:id/badges/:badgeId', can('organization:edit'), @@ -275,6 +281,27 @@ function manageRouter (nextApp) { return nextApp.render(req, res, '/org-edit-team-profile', { id: req.params.id }) }) + /** + * Badge pages + * */ + router.get( + '/organizations/:id/badges/add', + can('organization:edit'), + (req, res) => { + return nextApp.render(req, res, '/badges/add', { id: req.params.id }) + } + ) + router.get( + '/organizations/:id/badges/:badgeId', + can('organization:edit'), + (req, res) => { + return nextApp.render(req, res, '/badges/edit', { + id: req.params.id, + badgeId: req.params.badgeId + }) + } + ) + return router } diff --git a/app/tests/api/badges-api.test.js b/app/tests/api/badges-api.test.js index 0329224c..d6e112ec 100644 --- a/app/tests/api/badges-api.test.js +++ b/app/tests/api/badges-api.test.js @@ -76,7 +76,7 @@ test.before(async () => { // Add team member await orgOwner.agent - .put(`/api/team/${orgTeam1.id}/${orgTeamMember.id}`) + .put(`/api/teams/add/${orgTeam1.id}/${orgTeamMember.id}`) .expect(200) // Add manager diff --git a/components/layout.js b/components/layout.js index ecba4b85..fea506c7 100644 --- a/components/layout.js +++ b/components/layout.js @@ -203,6 +203,12 @@ function Layout (props) { 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 { diff --git a/components/table.js b/components/table.js index 093f3e6b..d4398fe2 100644 --- a/components/table.js +++ b/components/table.js @@ -13,7 +13,7 @@ function TableHead ({ columns }) { column.onClick && column.onClick() }} > - {column.key} + {column.label || column.key} ) })} @@ -102,12 +102,12 @@ export default function Table ({ columns, rows, onRowClick }) { tbody tr { background: #fff; - cursor: pointer; + ${onRowClick && 'cursor: pointer'} } - tbody tr:hover { + ${onRowClick && `tbody tr:hover { background: ${theme.colors.primaryLite}; - } + }`} `} diff --git a/lib/api-client.js b/lib/api-client.js new file mode 100644 index 00000000..991a0dea --- /dev/null +++ b/lib/api-client.js @@ -0,0 +1,105 @@ +import getConfig from 'next/config' + +const { publicRuntimeConfig } = getConfig() + +class ApiClient { + constructor (props = {}) { + this.defaultOptions = { + headers: { + 'Content-Type': 'application/json' + } + } + } + + baseUrl (subpath) { + return `${publicRuntimeConfig.APP_URL}/api${subpath}` + } + + async fetch (method, path, data, config = {}) { + const url = this.baseUrl(path) + + const defaultConfig = { + format: 'json' + } + const requestConfig = { + ...defaultConfig, + ...config + } + + const { format, headers } = requestConfig + var requestHeaders = this.defaultOptions.headers + for (const key in headers) { + requestHeaders[key] = headers[key] + } + + const options = { + ...this.defaultOptions, + method, + format, + headers: requestHeaders + } + + if (data) { + options.body = JSON.stringify(data) + } + + // Fetch data and let errors to be handle by the caller + // Fetch data and let errors to be handle by the caller + const res = await fetchJSON(url, options) + return res.body + } + + get (path, config) { + return this.fetch('GET', path, null, config) + } + + post (path, data, config) { + return this.fetch('POST', path, data, config) + } + + patch (path, data, config) { + return this.fetch('PATCH', path, data, config) + } + + delete (path, config) { + return this.fetch('DELETE', path, config) + } +} + +export default ApiClient + +/** + * Performs a request to the given url returning the response in json format + * or throwing an error. + * + * @param {string} url Url to query + * @param {object} options Options for fetch + */ +export async function fetchJSON (url, options) { + let response + options = options || {} + const format = options.format || 'json' + let data + try { + response = await fetch(url, options) + if (format === 'json') { + data = await response.json() + } else if (format === 'binary') { + data = await response.arrayBuffer() + } else { + data = await response.text() + } + + if (response.status >= 400) { + const err = new Error(data.message) + err.statusCode = response.status + err.data = data + throw err + } + + return { body: data, headers: response.headers } + } catch (error) { + error.statusCode = response ? response.status || null : null + throw error + } +} diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 00000000..6a3656ca --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,12 @@ +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/package.json b/package.json index 167ba0ca..ed5ecb03 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "react-dom": "^16.14.0", "react-leaflet": "^2.8.0", "react-modal": "^3.14.4", + "react-toastify": "^8.2.0", "reactjs-popup": "^1.5.0", "request": "^2.88.2", "request-promise-native": "^1.0.9", diff --git a/pages/_app.js b/pages/_app.js index 1e58752a..352dd580 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -5,6 +5,7 @@ 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' class OSMHydra extends App { static async getInitialProps ({ Component, ctx }) { @@ -55,6 +56,7 @@ class OSMHydra extends App { integrity='sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ==' crossOrigin='' /> + @@ -66,6 +68,7 @@ class OSMHydra extends App { + ) } diff --git a/pages/badges/add.js b/pages/badges/add.js new file mode 100644 index 00000000..c9421c82 --- /dev/null +++ b/pages/badges/add.js @@ -0,0 +1,169 @@ +import React, { Component } from 'react' +import join from 'url-join' +import { Formik, Field, Form } from 'formik' +import APIClient from '../../lib/api-client' +import { getOrg } from '../../lib/org-api' +import Button from '../../components/button' +import Router from 'next/router' +import { getRandomColor } from '../../lib/utils' +import getConfig from 'next/config' +import { toast } from 'react-toastify' + +const { publicRuntimeConfig } = getConfig() +const URL = publicRuntimeConfig.APP_URL + +const apiClient = new APIClient() + +function validateName (value) { + if (!value) return 'Name field is required' +} + +function renderError (text) { + return
{text}
+} + +function ButtonWrapper ({ children }) { + return ( +
+ {children} + +
+ ) +} + +export default class AddBadge extends Component { + static async getInitialProps ({ query }) { + if (query) { + return { + orgId: query.id + } + } + } + + constructor (props) { + super(props) + this.state = {} + + this.getOrg = this.getOrg.bind(this) + } + + async componentDidMount () { + this.getOrg() + } + + async getOrg () { + try { + let org = await getOrg(this.props.orgId) + this.setState({ + org + }) + } catch (e) { + console.error(e) + this.setState({ + error: e, + org: null, + loading: false + }) + } + } + + render () { + const { orgId } = this.props + + if (!this.state.org) { + return ( +
+
Loading...
+
+ ) + } + + return ( +
+
+
+

{this.state.org.name}

+
+
+

New badge

+
+ + { + actions.setSubmitting(true) + try { + await apiClient.post(`/organizations/${orgId}/badges`, { + name, + color + }) + Router.push(join(URL, `/organizations/${orgId}`)) + } catch (error) { + console.log(error) + toast.error(`There was an error creating badge '${name}'. Please try again later.`) + } finally { + actions.setSubmitting(false) + } + }} + render={({ + isSubmitting, + values, + errors + }) => { + return ( +
+
+ + + {errors.name && renderError(errors.name)} +
+
+ + + {errors.color && renderError(errors.color)} +
+ +
+
+ ) + } +} diff --git a/pages/badges/edit.js b/pages/badges/edit.js new file mode 100644 index 00000000..0c55b89c --- /dev/null +++ b/pages/badges/edit.js @@ -0,0 +1,295 @@ +import React, { Component } from 'react' +import join from 'url-join' +import { Formik, Field, Form } from 'formik' +import APIClient from '../../lib/api-client' +import { getOrg } from '../../lib/org-api' +import Button from '../../components/button' +import Router from 'next/router' +import getConfig from 'next/config' +import { toast } from 'react-toastify' +import theme from '../../styles/theme' +import Table from '../../components/table' +import AddMemberForm from '../../components/add-member-form' +import { toDateString } from '../../app/lib/utils' + +const { publicRuntimeConfig } = getConfig() +const URL = publicRuntimeConfig.APP_URL + +const apiClient = new APIClient() + +function validateName (value) { + if (!value) return 'Name field is required' +} + +function renderError (text) { + return
{text}
+} + +function ButtonWrapper ({ children }) { + return ( +
+ {children} + +
+ ) +} + +export default class EditBadge extends Component { + static async getInitialProps ({ query }) { + if (query) { + return { + orgId: query.id, + badgeId: query.badgeId + } + } + } + + constructor (props) { + super(props) + this.state = {} + + this.loadData = this.loadData.bind(this) + } + + async componentDidMount () { + this.loadData() + } + + async loadData () { + const { orgId, badgeId } = this.props + try { + const [org, badge] = await Promise.all([ + getOrg(orgId), + apiClient.get(`/organizations/${orgId}/badges/${badgeId}`) + ]) + this.setState({ + org, + badge + }) + } catch (error) { + console.error(error) + this.setState({ + error, + loading: false + }) + } + } + + renderAssignedMembers ({ orgId, badgeId }) { + const columns = [ + { key: 'id', label: 'OSM ID' }, + { key: 'displayName', label: 'Display Name' }, + { key: 'assignedAt', label: 'Assigned At' } + ] + + const { badge } = this.state + const users = (badge && badge.users) || [] + + return ( +
+
+

Assigned Members

+
+ + { + try { + await apiClient.post( + `/organizations/${orgId}/badges/${badgeId}/assign/${osmId}` + ) + this.loadData() + } catch (error) { + toast.error(error.message) + } + }} + /> + + {users.length > 0 && ( + ({ + ...u, + assignedAt: u.assignedAt && toDateString(u.assignedAt), + validUntil: u.validUntil && toDateString(u.validUntil) + }))} + columns={columns} + /> + )} + + ) + } + + render () { + const self = this + + if (this.state.error) { + return ( +
+
An unexpected error occurred, please try again later.
+
+ ) + } else if (!this.state.org || !this.state.badge) { + return ( +
+
Loading...
+
+ ) + } + + const { orgId, badgeId } = this.props + + const { badge } = this.state + + return ( +
+
+

{this.state.org.name}

+
+
+
+

Edit Badge

+
+ { + try { + await apiClient.patch( + `/organizations/${orgId}/badges/${badgeId}`, + { + name, + color + } + ) + Router.push(join(URL, `/organizations/${orgId}`)) + } catch (error) { + toast.error( + `There was an error editing badge '${name}'. Please try again later.` + ) + console.log(error) + } + }} + render={({ isSubmitting, values, errors }) => { + return ( +
+
+ + + {errors.name && renderError(errors.name)} +
+
+ + + {errors.color && renderError(errors.color)} +
+ +
+ + {this.renderAssignedMembers({ orgId, badgeId })} + +
+

Danger zone

+

Delete this badge and remove it from all assigned members.

+ {this.state.isDeleting ? ( + <> + + + + ) : ( +
+ +
+ ) + } +} diff --git a/pages/org-create.js b/pages/org-create.js index 52fd2a6a..f602e06a 100644 --- a/pages/org-create.js +++ b/pages/org-create.js @@ -15,7 +15,7 @@ export default class OrgCreate extends Component { try { const org = await createOrg(values) actions.setSubmitting(false) - Router.push(join(publicRuntimeConfig.APP_URL, `organization/${org.id}`)) + Router.push(join(publicRuntimeConfig.APP_URL, `organizations/${org.id}`)) } catch (e) { console.error(e) actions.setSubmitting(false) diff --git a/pages/organization.js b/pages/organization.js index 40b1f931..0283195a 100644 --- a/pages/organization.js +++ b/pages/organization.js @@ -1,4 +1,5 @@ import React, { Component } from 'react' +import Router from 'next/router' import { getOrg, getOrgStaff, getMembers, addManager, removeManager, addOwner, removeOwner } from '../lib/org-api' import { getUserOrgProfile } from '../lib/profiles-api' import Card from '../components/card' @@ -11,7 +12,29 @@ import Button from '../components/button' import Modal from 'react-modal' import ProfileModal from '../components/profile-modal' import { assoc, propEq, find, contains, prop, map } from 'ramda' +import APIClient from '../lib/api-client' +import getConfig from 'next/config' +import join from 'url-join' +const { publicRuntimeConfig } = getConfig() +const URL = publicRuntimeConfig.APP_URL + +const apiClient = new APIClient() + +export function SectionWrapper (props) { + return ( +
+ {props.children} + +
+ ) +} export default class Organization extends Component { static async getInitialProps ({ query }) { if (query) { @@ -36,11 +59,14 @@ export default class Organization extends Component { } this.closeProfileModal = this.closeProfileModal.bind(this) + this.renderBadges = this.renderBadges.bind(this) + this.getBadges = this.getBadges.bind(this) } async componentDidMount () { await this.getOrg() await this.getOrgStaff() + await this.getBadges() return this.getMembers(0) } @@ -159,6 +185,68 @@ export default class Organization extends Component { /> } + async getBadges () { + try { + const { id: orgId } = this.props + const badges = await apiClient.get(`/organizations/${orgId}/badges`) + this.setState({ + badges + }) + } catch (e) { + if (e.statusCode === 401) { + console.log("User doesn't have access to organization badges.") + } else { + console.error(e) + } + } + } + + renderBadges () { + const { id: orgId } = this.props + const columns = [{ key: 'name' }, { key: 'color' }] + + // Do not render section if badges list cannot be fetched. This might happen + // on network error but also when the user doesn't have privileges. + return this.state.badges ? ( + +
+
+ Badges +
+ +
+
+
+ {this.state.badges && ( +
{ + return { + ...row, + color: () => ( + + + + ) + } + })} columns={columns} onRowClick={ + ({ id: badgeId }) => Router.push( + join(URL, `/organizations/${orgId}/badges/${badgeId}`) + ) + + } /> + )} + + ) : null + } + renderMembers (memberRows) { const columns = [ { key: 'id' }, @@ -296,6 +384,7 @@ export default class Organization extends Component { {!this.state.loading ? this.renderMembers(members) : 'Loading...'} ) :
} + {this.renderBadges()}