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 {