From d7099551d23b6e4b617fc21c39c10a1eeb7c05cd Mon Sep 17 00:00:00 2001 From: fcaps Date: Tue, 5 Dec 2023 17:56:36 +0100 Subject: [PATCH] refactor clan management --- docker-compose.yml | 9 -- public/styles/site/clans.sass | 30 ++++ src/backend/routes/views/clanRouter.js | 23 +-- .../routes/views/clans/get/accept_invite.js | 92 ----------- src/backend/routes/views/clans/get/manage.js | 7 +- src/backend/routes/views/clans/invite.js | 46 ++++++ .../routes/views/clans/inviteAccept.js | 36 +++++ src/backend/routes/views/clans/join.js | 33 ++++ src/backend/routes/views/clans/kick.js | 33 ++++ src/backend/routes/views/clans/leave.js | 32 ++++ src/backend/routes/views/clans/post/invite.js | 143 ------------------ src/backend/routes/views/clans/post/join.js | 93 ------------ src/backend/routes/views/clans/post/kick.js | 76 ---------- src/backend/routes/views/clans/post/leave.js | 93 ------------ src/backend/routes/views/clans/view.js | 48 ++++++ .../services/ClanManagementRepository.js | 52 +++++++ src/backend/services/ClanManagementService.js | 26 ++++ src/backend/services/DataRepository.js | 28 +++- src/backend/services/UserRepository.js | 6 +- src/backend/services/UserService.js | 2 +- src/backend/templates/layouts/default.pug | 4 +- .../templates/views/clans/accept_invite.pug | 15 +- src/backend/templates/views/clans/clan.pug | 13 ++ src/backend/templates/views/clans/invite.pug | 29 ++++ src/backend/templates/views/clans/leave.pug | 18 +++ src/backend/templates/views/clans/manage.pug | 2 +- src/frontend/js/entrypoint/clan-invite.js | 38 +++++ webpack.config.js | 3 +- 28 files changed, 487 insertions(+), 543 deletions(-) delete mode 100644 src/backend/routes/views/clans/get/accept_invite.js create mode 100644 src/backend/routes/views/clans/invite.js create mode 100644 src/backend/routes/views/clans/inviteAccept.js create mode 100644 src/backend/routes/views/clans/join.js create mode 100755 src/backend/routes/views/clans/kick.js create mode 100755 src/backend/routes/views/clans/leave.js delete mode 100644 src/backend/routes/views/clans/post/invite.js delete mode 100644 src/backend/routes/views/clans/post/join.js delete mode 100755 src/backend/routes/views/clans/post/kick.js delete mode 100755 src/backend/routes/views/clans/post/leave.js create mode 100644 src/backend/routes/views/clans/view.js create mode 100644 src/backend/templates/views/clans/invite.pug create mode 100644 src/backend/templates/views/clans/leave.pug create mode 100644 src/frontend/js/entrypoint/clan-invite.js diff --git a/docker-compose.yml b/docker-compose.yml index 44a2dbcc..2e94819f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,12 +10,3 @@ services: build: dockerfile: Dockerfile-dev context: . - networks: - - faf-stack - logging: - driver: "json-file" - -networks: - faf-stack: - name: faf-stack_faf - external: true diff --git a/public/styles/site/clans.sass b/public/styles/site/clans.sass index b6eaaeea..864455c2 100644 --- a/public/styles/site/clans.sass +++ b/public/styles/site/clans.sass @@ -45,3 +45,33 @@ z-index: 2 position: relative + +.faf-button + appearance: button + text-decoration: none + margin: 0.2em + padding: 0.8em + color: #000000 + border: 0px solid #262626 + background-color: #ec9d36 + border-radius: 20px + justify-self: center + font-weight: bold + letter-spacing: 0.1em + cursor: pointer + &:link + color: #000000 + &:visited + color: #000000 + &:hover + color: #ffffff + background-color: #731d00 + +.action-link + color: #ec9d36 + &:link + color: #ec9d36 + &:visited + color: #ec9d36 + &:hover + color: #731d00 diff --git a/src/backend/routes/views/clanRouter.js b/src/backend/routes/views/clanRouter.js index f7a2529f..30f16756 100644 --- a/src/backend/routes/views/clanRouter.js +++ b/src/backend/routes/views/clanRouter.js @@ -1,25 +1,26 @@ const create = require('../views/clans/create') +const invite = require('./clans/invite') +const leave = require('./clans/leave') const express = require('../../ExpressApp') const router = express.Router() const middlewares = require('../middleware') // This will be replaced soon, therefor I did not spend time on it router.get('/', (req, res) => res.render('clans/clans')) -router.get('/view/:id', async (req, res) => { - const clanId = parseInt(req.params.id || null) - if (!clanId) { - return res.redirect('/clans') - } +router.get('/view/:id', require('./clans/view')) - return res.render('clans/clan', { clan: await req.appContainer.get('ClanService').getClan(clanId) }) -}) - -router.get('/create', create) -router.post('/create', create) +router.get('/create', middlewares.isAuthenticated(), create) +router.post('/create', middlewares.isAuthenticated(), create) router.get('/manage', middlewares.isAuthenticated(), require('./clans/get/manage')) router.post('/update', middlewares.isAuthenticated(), require('./clans/post/update')) router.post('/destroy', middlewares.isAuthenticated(), require('./clans/post/destroy')) - +router.get('/invite', middlewares.isAuthenticated(), invite) +router.post('/invite', middlewares.isAuthenticated(), invite) +router.get('/kick/:memberId', middlewares.isAuthenticated(), require('./clans/kick')) +router.get('/leave', middlewares.isAuthenticated(), leave) +router.post('/leave', middlewares.isAuthenticated(), leave) +router.get('/join', middlewares.isAuthenticated(), require('./clans/join')) +router.get('/invite-accept', middlewares.isAuthenticated(), require('./clans/inviteAccept')) router.get('*', (req, res) => res.status(503).render('errors/503-known-issue')) module.exports = router diff --git a/src/backend/routes/views/clans/get/accept_invite.js b/src/backend/routes/views/clans/get/accept_invite.js deleted file mode 100644 index 9b11368b..00000000 --- a/src/backend/routes/views/clans/get/accept_invite.js +++ /dev/null @@ -1,92 +0,0 @@ -const request = require('request') - -exports = module.exports = function (req, res) { - const locals = res.locals - - // locals.section is used to set the currently selected - // item in the header navigation. - locals.section = 'clan' - const flash = {} - - if (!req.query.i) { - flash.type = 'Error!' - flash.class = 'alert-danger' - flash.messages = [{ msg: 'The invitation link is wrong or truncated. Key informations are missing.' }] - - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - - return res.redirect('/clans?flash=' + data) - } - - const invitationId = req.query.i - - if (!req.app.locals.clanInvitations[invitationId]) { - flash.type = 'Error!' - flash.class = 'alert-danger' - flash.messages = [{ msg: 'The invitation link is wrong or truncated. Invite code missing from website clan map.' }] - - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - - return res.redirect('/clans?flash=' + data) - } - - const invite = req.app.locals.clanInvitations[invitationId] - const clanId = invite.clan - - if (req.user.data.attributes.clan != null) { - // User is already in a clan! - return res.redirect(`/clans/${req.user.data.attributes.clan.tag}?member=true`) - } - - const queryUrl = process.env.API_URL + - '/data/clan/' + clanId + - '?include=memberships.player' + - '&fields[clan]=createTime,description,name,tag,updateTime,websiteUrl,founder,leader' + - '&fields[player]=login,updateTime' - - request.get( - { - url: queryUrl - }, - function (err, childRes, body) { - const clan = JSON.parse(body) - - if (err || !clan.data) { - flash.type = 'Error!' - flash.class = 'alert-danger' - flash.messages = [{ msg: 'The clan you want to join is invalid or does no longer exist' }] - - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - - return res.redirect('./?flash=' + data) - } - - locals.clanName = clan.data.attributes.name - locals.clanLeaderName = '' - - for (const k in clan.included) { - let player = null - switch (clan.included[k].type) { - case 'player': - player = clan.included[k] - - // Getting the leader name - if (player.id === clan.data.relationships.leader.data.id) { - locals.clanLeaderName = player.attributes.login - } - - break - } - } - - const token = invite.token - locals.acceptURL = `/clans/join?clan_id=${clanId}&token=${token}` - - // Render the view - res.render('clans/accept_invite') - } - ) -} diff --git a/src/backend/routes/views/clans/get/manage.js b/src/backend/routes/views/clans/get/manage.js index a0344982..213ce56d 100755 --- a/src/backend/routes/views/clans/get/manage.js +++ b/src/backend/routes/views/clans/get/manage.js @@ -1,7 +1,12 @@ const { JavaApiError } = require('../../../../services/ApiErrors') exports = module.exports = async (req, res) => { + // if something changed in another session we should refresh the user first + await req.requestContainer.get('UserService').refreshUser() + const clanMembershipId = req.requestContainer.get('UserService').getUser()?.clan?.membershipId || null if (!clanMembershipId) { + await req.asyncFlash('error', 'You don\'t belong to a clan') + return res.redirect('/clans') } @@ -17,6 +22,6 @@ exports = module.exports = async (req, res) => { await req.asyncFlash('error', message) - return res.redirect('/') + return res.redirect('/clans') } } diff --git a/src/backend/routes/views/clans/invite.js b/src/backend/routes/views/clans/invite.js new file mode 100644 index 00000000..08fd5a6a --- /dev/null +++ b/src/backend/routes/views/clans/invite.js @@ -0,0 +1,46 @@ +const { JavaApiError } = require('../../../services/ApiErrors') +const { body } = require('express-validator') +const url = require('url') + +exports = module.exports = [ + body('invited_player', 'Please select a player').notEmpty().isLength({ max: 20 }), + async (req, res) => { + if (req.method === 'POST') { + const user = await req.appContainer.get('DataRepository').fetchUserByName(req.body.invited_player) + + if (!user) { + await req.asyncFlash('error', 'User not found') + return res.render('clans/invite', { + invited_player: req.body.invited_player + }) + } + + try { + const invitation = await req.requestContainer.get('ClanManagementService').createInvite(user.id) + + return res.render('clans/invite', { + invited_player: req.body.invited_player, + link: url.format({ + pathname: '/clans/invite-accept', + query: { + token: encodeURIComponent(invitation) + } + }) + }) + } catch (e) { + let message = e.toString() + if (e instanceof JavaApiError && e.error?.errors) { + message = e.error.errors[0].detail + } + + await req.asyncFlash('error', message) + + return res.redirect('/clans/manage') + } + } + + return res.render('clans/invite', { + invited_player: '' + }) + } +] diff --git a/src/backend/routes/views/clans/inviteAccept.js b/src/backend/routes/views/clans/inviteAccept.js new file mode 100644 index 00000000..ab4c56ff --- /dev/null +++ b/src/backend/routes/views/clans/inviteAccept.js @@ -0,0 +1,36 @@ +const decodingJWT = (token) => { + if (token !== null) { + const base64String = token.split('.')[1] + return JSON.parse(Buffer.from(base64String, + 'base64').toString('ascii')) + } + return null +} + +exports = module.exports = async function (req, res) { + if (!req.query.token) { + await req.asyncFlash('error', 'The invitation link is invalid!') + + return res.redirect('/clans') + } + const token = req.query.token + + const decodedToken = decodingJWT(req.query.token) + + if (decodedToken === null || decodedToken.clan?.name === undefined) { + await req.asyncFlash('error', 'The invitation link is invalid!') + + return res.redirect('/clans') + } + + if (req.requestContainer.get('UserService').getUser()?.clan) { + await req.asyncFlash('error', 'You are already in a clan') + + return res.redirect('/clans/view/' + req.requestContainer.get('UserService').getUser().clan.id) + } + + res.render('clans/accept_invite', { + acceptURL: `/clans/join?token=${token}`, + clanName: decodedToken.clan.name + }) +} diff --git a/src/backend/routes/views/clans/join.js b/src/backend/routes/views/clans/join.js new file mode 100644 index 00000000..02107f93 --- /dev/null +++ b/src/backend/routes/views/clans/join.js @@ -0,0 +1,33 @@ +const { JavaApiError } = require('../../../services/ApiErrors') + +exports = module.exports = async function (req, res) { + if (!req.query.token) { + await req.asyncFlash('error', 'The invitation link is invalid!') + + return res.redirect('/clans') + } + const token = req.query.token + + try { + await req.requestContainer.get('ClanManagementService').acceptInvitation(token) + await req.asyncFlash('info', 'Clan joined!') + + return res.redirect('/clans/view/' + req.requestContainer.get('UserService').getUser().clan.id) + } catch (e) { + console.log(e.stack) + let message = e.toString() + + if (e instanceof JavaApiError && e.error?.errors) { + message = e.error.errors[0].detail + + if (e.error.errors[0].code === '152') { + await req.asyncFlash('error', message) + return res.redirect('/clans') + } + } + + await req.asyncFlash('error', message) + + return res.redirect('/clans') + } +} diff --git a/src/backend/routes/views/clans/kick.js b/src/backend/routes/views/clans/kick.js new file mode 100755 index 00000000..327454b1 --- /dev/null +++ b/src/backend/routes/views/clans/kick.js @@ -0,0 +1,33 @@ +const { JavaApiError } = require('../../../services/ApiErrors') + +exports = module.exports = async function (req, res) { + const memberId = parseInt(req.params.memberId || null) + if (!memberId) { + await req.asyncFlash('error', 'memberId is required') + + return res.redirect('/clans') + } + + const user = req.requestContainer.get('UserService').getUser() + + if (!user.clan) { + await req.asyncFlash('info', 'You are not in a clan') + + return res.redirect('/clans') + } + + try { + await req.requestContainer.get('ClanManagementService').kickMember(memberId) + + return res.redirect('/clans/view/' + req.requestContainer.get('UserService').getUser().clan.id) + } catch (e) { + let message = e.toString() + if (e instanceof JavaApiError && e.error?.errors) { + message = e.error.errors[0].detail + } + + await req.asyncFlash('error', message) + + return res.redirect('/clans/view/' + req.requestContainer.get('UserService').getUser().clan.id) + } +} diff --git a/src/backend/routes/views/clans/leave.js b/src/backend/routes/views/clans/leave.js new file mode 100755 index 00000000..e3ca6244 --- /dev/null +++ b/src/backend/routes/views/clans/leave.js @@ -0,0 +1,32 @@ +const { JavaApiError } = require('../../../services/ApiErrors') + +exports = module.exports = async function (req, res) { + const user = req.requestContainer.get('UserService').getUser() + + if (!user.clan.membershipId) { + await req.asyncFlash('info', 'You are not in a clan') + + return res.redirect('/clans') + } + + if (req.method === 'POST') { + try { + await req.requestContainer.get('ClanManagementService').leaveClan() + await req.asyncFlash('info', 'Clan left') + + return res.redirect('/clans') + } catch (e) { + console.error(e.stack) + let message = e.toString() + if (e instanceof JavaApiError && e.error?.errors) { + message = e.error.errors[0].detail + } + + await req.asyncFlash('error', message) + + return res.redirect('/clans') + } + } + + return res.render('clans/leave') +} diff --git a/src/backend/routes/views/clans/post/invite.js b/src/backend/routes/views/clans/post/invite.js deleted file mode 100644 index bc6bbe5b..00000000 --- a/src/backend/routes/views/clans/post/invite.js +++ /dev/null @@ -1,143 +0,0 @@ -const flash = {} -const request = require('request') -const { check, validationResult } = require('express-validator') - -function promiseRequest (url) { - return new Promise(function (resolve, reject) { - request(url, function (error, res, body) { - if (!error && res.statusCode < 300) { - resolve(body) - } else { - console.error('Call to ' + url + ' failed: ' + error) - reject(error) - } - }) - }) -} - -function setLongTimeout (func, delayMs) { - const maxDelay = 214748364 - 1 // JS Limit for 32 bit integers - - if (delayMs > maxDelay) { - const remainingDelay = delayMs - maxDelay - - // we cut it in smaller, edible chunks - setTimeout(() => { - setLongTimeout(func, remainingDelay) - }, maxDelay) - } else { - setTimeout(func, delayMs) - } -} - -exports = module.exports = async function (req, res) { - const locals = res.locals - - locals.formData = req.body || {} - - const overallRes = res - - // validate the input - check('invited_player', 'Please indicate the player name').notEmpty() - - // check the validation object for errors - const errors = validationResult(req) - - // Must have client side errors to fix - if (!errors.isEmpty()) { - flash.class = 'alert-danger' - flash.messages = errors - flash.type = 'Error!' - - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - - return overallRes.redirect('manage?flash=' + data) - } else { - const clanId = req.body.clan_id - const userName = req.body.invited_player - - // Let's check first that the player exists - const fetchRoute = process.env.API_URL + '/data/player?filter=login=="' + userName + '"&fields[player]=' - - let playerData = null - let playerId = null - try { - const httpData = await promiseRequest(fetchRoute) - playerData = JSON.parse(httpData).data - playerId = playerData[0].id - } catch (e) { - flash.class = 'alert-danger' - flash.messages = [{ msg: 'The player ' + userName + " doesn't seem to exist" + e }] - flash.type = 'Error!' - - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - - return overallRes.redirect('manage?flash=' + data) - } - - const queryUrl = - process.env.API_URL + - '/clans/generateInvitationLink' + - '?clanId=' + encodeURIComponent(clanId) + - '&playerId=' + encodeURIComponent(playerId) - - // Run post to endpoint - request.get({ - url: queryUrl, - body: '', - headers: { - Authorization: 'Bearer ' + req.user.data.attributes.token - } - }, function (err, res, body) { - if (err || res.statusCode !== 200) { - const errorMessages = [] - let msg = 'Error while generating the invite link' - try { - msg += ': ' + JSON.stringify(JSON.parse(res.body).errors[0].detail) - } catch { - } - - errorMessages.push({ msg }) - flash.class = 'alert-danger' - flash.messages = errorMessages - flash.type = 'Error!' - - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - return overallRes.redirect('manage?flash=' + data) - } else { - try { - const token = JSON.parse(res.body).jwtToken - - const id = Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 5).toUpperCase() - - req.app.locals.clanInvitations[id] = { - token, - clan: clanId - } - - // We use timeout here because if we delete the invite link whenver the page is GET, - // then discord and other messaging applications will destroy the link accidentally - // when pre-fetching the page. So we will delete it later. Regardless if the website is restarted all the links will be - // killed instantly, which is fine. They are short lived by design. - const lifespan = process.env.CLAN_INVITES_LIFESPAN_DAYS * 24 * 3600 * 1000 - setLongTimeout(() => { - delete req.app.locals.clanInvitations[id] - console.log(`Killed invitation with id ${id} after having waited ${lifespan} seconds (${process.env.CLAN_INVITES_LIFESPAN_DAYS} days)`) - }, lifespan) - - return overallRes.redirect('manage?invitation_id=' + id) - } catch (e) { - flash.class = 'alert-danger' - flash.messages = [{ msg: 'Unkown error while generating the invite link: ' + e }] - flash.type = 'Error!' - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - return overallRes.redirect('manage?flash=' + data) - } - } - }) - } -} diff --git a/src/backend/routes/views/clans/post/join.js b/src/backend/routes/views/clans/post/join.js deleted file mode 100644 index 936d0490..00000000 --- a/src/backend/routes/views/clans/post/join.js +++ /dev/null @@ -1,93 +0,0 @@ -const request = require('request') - -exports = module.exports = function (req, res) { - const locals = res.locals - - // locals.section is used to set the currently selected - // item in the header navigation. - locals.section = 'clan' - - const flash = {} - const overallRes = res - - if (!req.query.token || !req.query.clan_id) { - flash.type = 'Error!' - flash.class = 'alert-danger' - flash.messages = [{ msg: 'The invitation link is invalid!' }] - - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - - return res.redirect('/clans?flash=' + data + '') - } - - const token = req.query.token - - request.post( - { - url: process.env.API_URL + '/clans/joinClan?token=' + token, - headers: { - Authorization: 'Bearer ' + req.user.data.attributes.token - } - }, - function (err, childRes, body) { - if (err) { - console.error('There was an error at join') - - return - } - - let flashData - if (childRes.statusCode === 200 || childRes.statusCode === 201) { - flash.class = 'alert-success' - flash.messages = [ - { msg: 'Welcome to your new clan!' } - ] - flash.type = 'Success!' - const buff = Buffer.from(JSON.stringify(flash)) - flashData = buff.toString('base64') - - // Refreshing user - return request.get({ - url: process.env.API_URL + '/me', - headers: { - Authorization: 'Bearer ' + req.user.data.attributes.token - } - }, - - function (err, res, body) { - if (err) { - console.error('There was an error updating a session after an user left a clan') - - return - } - try { - const user = JSON.parse(body) - user.data.id = user.data.attributes.userId - user.data.attributes.token = req.user.data.attributes.token - req.logIn(user, function (err) { - if (err) console.error(err) - return overallRes.redirect(`${user.data.attributes.clan.tag}?member=true&flash=${flashData}`) - }) - } catch { - console.error('There was an error updating a session after an user left a clan') - } - }) - } else { - flash.type = 'Error!' - flash.class = 'alert-danger' - let msg = 'The invitation is invalid or has expired, or you are already part of a clan' - try { - msg += ': ' + JSON.stringify(JSON.parse(childRes.body).errors[0].detail) - } catch {} - - flash.messages = [{ msg }] - - const buff = Buffer.from(JSON.stringify(flash)) - flashData = buff.toString('base64') - - return overallRes.redirect('/clans?flash=' + flashData + '') - } - } - ) -} diff --git a/src/backend/routes/views/clans/post/kick.js b/src/backend/routes/views/clans/post/kick.js deleted file mode 100755 index 2969228a..00000000 --- a/src/backend/routes/views/clans/post/kick.js +++ /dev/null @@ -1,76 +0,0 @@ -let flash = {} -const request = require('request') -const { check, validationResult } = require('express-validator') - -exports = module.exports = async function (req, res) { - const locals = res.locals - - locals.formData = req.body || {} - - const overallRes = res - - // validate the input - check('clan_id', 'Internal error while processing your query: invalid clan ID').notEmpty() - check('membership_id', 'Internal error while processing your query: invalid member ID').notEmpty() - - // check the validation object for errors - let errors = validationResult(req) - - // Should not happen normally, but you never know - if (req.body.membership_id === req.user.clan.membershipId) errors = [{ msg: 'You cannot kick yourself' }] - - // Must have client side errors to fix - if (!errors.isEmpty()) { - flash.class = 'alert-danger' - flash.messages = errors - flash.type = 'Error!' - - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - - return overallRes.redirect('manage?flash=' + data) - } else { - // Building update query - const membershipId = req.body.membership_id - const queryUrl = - process.env.API_URL + - '/data/clanMembership/' + membershipId - - // Run post to endpoint - request.delete({ - url: queryUrl, - body: '', - headers: { - Authorization: 'Bearer ' + req.user.data.attributes.token - } - }, function (err, res, body) { - const errorMessages = [] - - if (err || res.statusCode !== 204) { - let msg = 'Error while removing the member' - try { - msg += ': ' + JSON.stringify(JSON.parse(res.body).errors[0].detail) - } catch {} - errorMessages.push({ msg }) - flash.class = 'alert-danger' - flash.messages = errorMessages - flash.type = 'Error!' - - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - - return overallRes.redirect('manage?flash=' + data) - } - - flash = {} - flash.class = 'alert-success' - flash.messages = [{ msg: 'The member was kicked' }] - flash.type = 'Success!' - - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - - return overallRes.redirect('manage?flash=' + data) - }) - } -} diff --git a/src/backend/routes/views/clans/post/leave.js b/src/backend/routes/views/clans/post/leave.js deleted file mode 100755 index ec8fff2a..00000000 --- a/src/backend/routes/views/clans/post/leave.js +++ /dev/null @@ -1,93 +0,0 @@ -let flash = {} -const request = require('request') -const { check, validationResult } = require('express-validator') - -exports = module.exports = async function (req, res) { - const locals = res.locals - - locals.formData = req.body || {} - - const overallRes = res - - // validate the input - check('clan_id', 'Internal error while processing your query: invalid clan ID').notEmpty() - check('membership_id', 'Internal error while processing your query: invalid member ID').notEmpty() - - // check the validation object for errors - const errors = validationResult(req) - - // Must have client side errors to fix - if (!errors.isEmpty()) { - flash.class = 'alert-danger' - flash.messages = errors - flash.type = 'Error!' - - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - - return overallRes.redirect('/clans?flash=' + data) - } else { - // Building update query - // Run post to endpoint - request.delete({ - url: `${process.env.API_URL}/data/clanMembership/${req.user.clan.membershipId}`, - headers: { - Authorization: 'Bearer ' + req.user.data.attributes.token - } - }, function (err, res, body) { - const errorMessages = [] - - if (err || res.statusCode !== 204) { - let msg = 'Error while leaving the clan' - try { - msg += ': ' + JSON.stringify(JSON.parse(res.body).errors[0].detail) - } catch { - errorMessages.push({ msg }) - flash.class = 'alert-danger' - flash.messages = errorMessages - flash.type = 'Error!' - } - - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - - return overallRes.redirect('/clans?flash=' + data) - } - - flash = {} - flash.class = 'alert-success' - flash.messages = [{ msg: 'You left the clan' }] - flash.type = 'Success!' - - const buff = Buffer.from(JSON.stringify(flash)) - const data = buff.toString('base64') - - // Refreshing user - request.get({ - url: process.env.API_URL + '/me', - headers: { - Authorization: 'Bearer ' + req.user.data.attributes.token - } - }, - - function (err, res, body) { - if (err) { - console.error('There was an error updating a session after an user left a clan') - - return - } - try { - const user = JSON.parse(body) - user.data.id = user.data.attributes.userId - user.data.attributes.token = req.user.data.attributes.token - req.logIn(user, function (err) { - if (err) console.error(err) - return overallRes.redirect('/clans?flash=' + data) - }) - } catch { - console.error('There was an error updating a session after an user left a clan') - } - }) - }) - } -} diff --git a/src/backend/routes/views/clans/view.js b/src/backend/routes/views/clans/view.js new file mode 100644 index 00000000..7e0af591 --- /dev/null +++ b/src/backend/routes/views/clans/view.js @@ -0,0 +1,48 @@ +const { JavaApiError } = require('../../../services/ApiErrors') + +module.exports = async (req, res) => { + const clanId = parseInt(req.params.id || null) + if (!clanId) { + return res.redirect('/clans') + } + + try { + let isMember = false + let isLeader = false + let canLeave = false + + const clan = await req.appContainer.get('ClanService').getClan(clanId) + let user = req.requestContainer.get('UserService').getUser() + + if (user) { + user = await req.requestContainer.get('UserService').refreshUser() + if (parseInt(user.clan?.id) === parseInt(clan.id)) { + isMember = true + canLeave = true + } + + if (user.id === clan.leader?.id) { + isLeader = true + } + + if (isLeader) { + canLeave = false + } + } + + return res.render('clans/clan', { clan, isMember, isLeader, canLeave, userMembershipId: user?.clan?.membershipId }) + } catch (e) { + let message = e.toString() + if (e instanceof JavaApiError && e.error?.errors) { + if (e.status === 404) { + return res.status(404).render('errors/404') + } + message = e.error.errors[0].detail + } + + console.error(e.stack) + await req.asyncFlash('error', message) + + return res.redirect('/clans') + } +} diff --git a/src/backend/services/ClanManagementRepository.js b/src/backend/services/ClanManagementRepository.js index 948c6eba..23bbd8b5 100644 --- a/src/backend/services/ClanManagementRepository.js +++ b/src/backend/services/ClanManagementRepository.js @@ -64,6 +64,58 @@ class ClanManagementRepository { throw new GenericJavaApiError(e.toString()) } } + + async createInvite (clanId, playerId) { + try { + const response = await this.javaApiClient.get(`/clans/generateInvitationLink?clanId=${clanId}&playerId=${playerId}`) + + if (response.status !== 200) { + throw new JavaApiError(response.status, response.config.url, JSON.parse(response.data) || []) + } + + const rawToken = JSON.parse(response.data) + + return rawToken.jwtToken || null + } catch (e) { + if (e instanceof JavaApiError) { + throw e + } + + throw new GenericJavaApiError(e.toString()) + } + } + + async acceptInvitation (token) { + try { + const response = await this.javaApiClient.post(`/clans/joinClan?token=${token}`) + + if (response.status !== 200) { + throw new JavaApiError(response.status, response.config.url, JSON.parse(response.data) || []) + } + } catch (e) { + if (e instanceof JavaApiError) { + throw e + } + + throw new GenericJavaApiError(e.toString()) + } + } + + async removeClanMembership (membershipId) { + try { + const response = await this.javaApiClient.delete(`/data/clanMembership/${membershipId}`) + + if (response.status !== 204) { + throw new JavaApiError(response.status, response.config.url, JSON.parse(response.data) || []) + } + } catch (e) { + if (e instanceof JavaApiError) { + throw e + } + + throw new GenericJavaApiError(e.toString()) + } + } } module.exports.ClanManagementRepository = ClanManagementRepository diff --git a/src/backend/services/ClanManagementService.js b/src/backend/services/ClanManagementService.js index f20f2dab..0dd3daf1 100644 --- a/src/backend/services/ClanManagementService.js +++ b/src/backend/services/ClanManagementService.js @@ -41,6 +41,32 @@ class ClanManagementService { console.error(e.stack) } } + + async createInvite (playerId) { + return await this.clanManagementRepository.createInvite(this.userService.getUser().clan.id, playerId) + } + + async acceptInvitation (token) { + await this.clanManagementRepository.acceptInvitation(token) + try { + await this.userService.refreshUser() + } catch (e) { + console.error(e.stack) + } + } + + async kickMember (membershipId) { + await this.clanManagementRepository.removeClanMembership(membershipId) + } + + async leaveClan () { + await this.clanManagementRepository.removeClanMembership(this.userService.getUser().clan.membershipId) + try { + await this.userService.refreshUser() + } catch (e) { + console.error(e.stack) + } + } } module.exports.ClanManagementService = ClanManagementService diff --git a/src/backend/services/DataRepository.js b/src/backend/services/DataRepository.js index e0ee3075..3cd48732 100644 --- a/src/backend/services/DataRepository.js +++ b/src/backend/services/DataRepository.js @@ -60,7 +60,7 @@ class DataRepository { const response = await this.javaApiM2MClient.get(`/data/clan/${id}?include=memberships.player`) if (response.status !== 200) { - throw new Error('DataRepository::fetchClan failed with response status "' + response.status + '"') + throw new JavaApiError(response.status, response.config.url, JSON.parse(response.data) || []) } const data = JSON.parse(response.data) @@ -84,7 +84,7 @@ class DataRepository { const clanRaw = data.data.attributes const clan = { - id: data.data.id, + id: parseInt(data.data.id), name: clanRaw.name, tag: clanRaw.tag, description: clanRaw.description, @@ -104,7 +104,7 @@ class DataRepository { case 'player': { const player = data.included[k] if (!members[player.id]) members[player.id] = {} - members[player.id].id = player.id + members[player.id].id = parseInt(player.id) members[player.id].name = player.attributes.login if (player.id === data.data.relationships.leader.data.id) { @@ -121,8 +121,8 @@ class DataRepository { const membership = data.included[k] const member = membership.relationships.player.data if (!members[member.id]) members[member.id] = {} - members[member.id].id = member.id - members[member.id].membershipId = membership.id + members[member.id].id = parseInt(member.id) + members[member.id].membershipId = parseInt(membership.id) members[member.id].joinedAt = membership.attributes.createTime break } @@ -199,6 +199,24 @@ class DataRepository { return clanMembership } + + async fetchUserByName (userName) { + const response = await this.javaApiM2MClient.get(`/data/player?filter=login==${userName}&fields[player]=`) + + if (response.status !== 200) { + throw new JavaApiError(response.status, response.config.url, JSON.parse(response.data) || []) + } + + const rawUser = JSON.parse(response.data) + + if (!rawUser.data[0]) { + return null + } + + return { + id: rawUser.data[0].id + } + } } module.exports.DataRepository = DataRepository diff --git a/src/backend/services/UserRepository.js b/src/backend/services/UserRepository.js index 356ff551..24b02ca0 100644 --- a/src/backend/services/UserRepository.js +++ b/src/backend/services/UserRepository.js @@ -11,15 +11,15 @@ class UserRepository { if (rawUser.attributes.clan) { clan = { - id: rawUser.attributes.clan.id, - membershipId: rawUser.attributes.clan.membershipId, + id: parseInt(rawUser.attributes.clan.id), + membershipId: parseInt(rawUser.attributes.clan.membershipId), tag: rawUser.attributes.clan.tag, name: rawUser.attributes.clan.name } } return { - id: rawUser.id, + id: parseInt(rawUser.attributes.userId), name: rawUser.attributes.userName, email: rawUser.attributes.email, clan, diff --git a/src/backend/services/UserService.js b/src/backend/services/UserService.js index f5dd7555..1c59ed5b 100644 --- a/src/backend/services/UserService.js +++ b/src/backend/services/UserService.js @@ -19,7 +19,7 @@ class UserService { } getUser () { - return this.session.passport.user + return this.session?.passport?.user } updatePassport (oAuthPassport) { diff --git a/src/backend/templates/layouts/default.pug b/src/backend/templates/layouts/default.pug index 3e2d58ba..e70ed0e9 100755 --- a/src/backend/templates/layouts/default.pug +++ b/src/backend/templates/layouts/default.pug @@ -128,7 +128,7 @@ html(lang='en') li.loginDropdown ul(role='menu') if appGlobals.loggedInUser.clan - a(href="/clans/manage"): li My Clan + a(href="/clans/view/" + appGlobals.loggedInUser.clan.id): li My Clan else a(href="/clans/create"): li Create Clan a(href="/account/changeEmail"): li Change Email @@ -190,7 +190,7 @@ html(lang='en') .mobileNavElement.mobileNavMenu My Account .mobileNavMenuContent if appGlobals.loggedInUser.clan - a(href="/clans/manage") My Clan + a(href="/clans/view/" + appGlobals.loggedInUser.clan.id) My Clan else a(href="/clans/create") Create Clan a(href="/account/changeEmail") Change Email diff --git a/src/backend/templates/views/clans/accept_invite.pug b/src/backend/templates/views/clans/accept_invite.pug index f5a7b6db..bf5ab1a6 100644 --- a/src/backend/templates/views/clans/accept_invite.pug +++ b/src/backend/templates/views/clans/accept_invite.pug @@ -1,19 +1,10 @@ extends ../../layouts/default -include ../../mixins/flash-messages block bannerMixin block content - .containerCenter.text-center .row .col-md-12 h1.account-title Accept invitation - h4.account-subtitle.text-center Click the button below to accept the invitation from #{clanLeaderName} to join #{clanName} - - .row - .col-md-offset-3.col-md-6 - +flash-messages(flash) + h4.account-subtitle.text-center Click the button below to accept the invitation to join #{clanName} + a(href=acceptURL).faf-button Join #{clanName} + br - .row - .col-md-offset-3.col-md-6 - form(method='post', action=acceptURL, data-toggle="validator") - button(type='submit').btn.btn-default.btn-lg.btn-outro.btn-danger Join #{clanName} - diff --git a/src/backend/templates/views/clans/clan.pug b/src/backend/templates/views/clans/clan.pug index 250d333c..571da6d2 100644 --- a/src/backend/templates/views/clans/clan.pug +++ b/src/backend/templates/views/clans/clan.pug @@ -22,6 +22,9 @@ block content td #{clan.founder.name} else td - + tr + td FOUNDED + td #{clan.createTime} tr td JOIN if clan.requiresInvitation @@ -30,16 +33,26 @@ block content td Free For All .column8 div(style='white-space:pre-wrap') #{clan.description} + .column12 + if canLeave + a(href='/clans/leave').faf-button Leave Clan + if isLeader + a(href='/clans/invite').faf-button Create Invite + a(href='/clans/manage').faf-button Update Clan .column12 table#clan-members thead tr th NAME th JOINED AT + th ACTIONS tbody each member in clan.memberships tr td #{member.name} td #{member.joinedAt} + td + if isLeader && member.membershipId !== userMembershipId + a(href='/clans/kick/' + member.membershipId onclick="return confirm('Kick?')").action-link Kick block js script( src=webpackAssetJS('clan')) diff --git a/src/backend/templates/views/clans/invite.pug b/src/backend/templates/views/clans/invite.pug new file mode 100644 index 00000000..f7707335 --- /dev/null +++ b/src/backend/templates/views/clans/invite.pug @@ -0,0 +1,29 @@ +extends ../../layouts/default +include ../../mixins/flash-error +include ../../mixins/form/account +block bannerMixin +block css + link(href="/styles/awesomplete.css?version=" + Date.now(), rel="stylesheet") +block content + + .clanManagement + .column12 + +flash-error(errors) + + .clanManagement + .column12 + h2 Invite players + if link + .row + p Invite created for #{invited_player} + button(data-href=link id='invitationLink') click to copy link + form(method='post' action="/clans/invite") + .row.inline-panel + input(type='text' id='invited_player' name='invited_player' value=invited_player placeholder='Player name' style="margin-left:5px;margin-right:5px").form-control + button(type='submit') Invite + br + br + + +block js + script(src=webpackAssetJS('clan-invite')) diff --git a/src/backend/templates/views/clans/leave.pug b/src/backend/templates/views/clans/leave.pug new file mode 100644 index 00000000..2782fe69 --- /dev/null +++ b/src/backend/templates/views/clans/leave.pug @@ -0,0 +1,18 @@ +extends ../../layouts/default +include ../../mixins/flash-error +include ../../mixins/form/account +block bannerMixin +block content + + .clanManagement + .column12 + +flash-error(errors) + + .clanManagement + .column12 + h2 Leave Clan + form(method='post' action="/clans/leave") + .row.inline-panel + button(type='submit') Confirm + br + diff --git a/src/backend/templates/views/clans/manage.pug b/src/backend/templates/views/clans/manage.pug index 9378656f..013aec16 100644 --- a/src/backend/templates/views/clans/manage.pug +++ b/src/backend/templates/views/clans/manage.pug @@ -22,7 +22,7 @@ block content br br hr - .clanManagement + .clanManagement.row.important-form .column12 h3 DANGER ZONE div The settings below CANNOT be undone. Do not touch these settings unless you are sure about what you are doing. diff --git a/src/frontend/js/entrypoint/clan-invite.js b/src/frontend/js/entrypoint/clan-invite.js new file mode 100644 index 00000000..29c36e48 --- /dev/null +++ b/src/frontend/js/entrypoint/clan-invite.js @@ -0,0 +1,38 @@ +import Awesomplete from 'awesomplete' +import axios from 'axios' + +async function getPlayers () { + const response = await axios.get('/data/recent-players.json') + if (response.status !== 200) { + throw new Error('issues getting data') + } + + return response.data +} + +getPlayers().then((memberList) => { + addAwesompleteListener(document.getElementById('invited_player'), memberList) +}) + +function addAwesompleteListener (element, memberList) { + const list = memberList.map((player) => { + return player.name + }) + + /* eslint-disable no-new */ + new Awesomplete(element, { + list + }) +} + +const invitationLinkButton = document.getElementById('invitationLink') +if (invitationLinkButton) { + invitationLinkButton.addEventListener('click', async function (event) { + try { + await navigator.clipboard.writeText(location.protocol + '//' + location.host + invitationLinkButton.dataset.href) + invitationLinkButton.innerText = 'copied!' + } catch (err) { + console.error('Failed to copy: ', err) + } + }) +} diff --git a/webpack.config.js b/webpack.config.js index ab3011ca..9b90ca54 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -21,7 +21,8 @@ module.exports = { navigation: ['./src/frontend/js/entrypoint/navigation.js'], newshub: ['./src/frontend/js/entrypoint/newshub.js'], play: ['./src/frontend/js/entrypoint/play.js'], - report: ['./src/frontend/js/entrypoint/report.js'] + report: ['./src/frontend/js/entrypoint/report.js'], + 'clan-invite': ['./src/frontend/js/entrypoint/clan-invite.js'] }, output: { filename: '[name].[contenthash].js',