Team stuff

This commit is contained in:
Troplo 2020-10-25 23:29:31 +11:00
parent 50fb35ea17
commit 4fa794f133
27 changed files with 1771 additions and 275 deletions

View File

@ -1,4 +1,5 @@
module.exports = {
port: process.env.PORT || 23981,
sessionSecret: process.env.SESSION_SECRET || 'iouydhtrfguyrthgftryhgidrhytgidhytiglriltnhgrhtiuygrthiugritghiyutrcginhrtijghurfcuhjgnioergjfuiehtiehtiehyritheithreifbhgehfbdxhbkvfdbhjkvgdkhnjUIYIRUiuiuYIYI3i42yiuyIUYIU4yiu$YUI#YUI$3mvsazr57;'
sessionSecret: process.env.SESSION_SECRET || 'iouydhtrfguyrthgftryhgidrhytgidhytiglriltnhgrhtiuygrthiugritghiyutrcginhrtijghurfcuhjgnioergjfuiehtiehtiehyritheithreifbhgehfbdxhbkvfdbhjkvgdkhnjUIYIRUiuiuYIYI3i42yiuyIUYIU4yiu$YUI#YUI$3mvsazr57;',
imageUploadTeams: process.env.TEAMUPLOADS || "C:\\Users\\matth\\Documents\\GitHub\\Kaverti-Team-Images"
}

View File

@ -44,6 +44,9 @@
border-left: 8px solid #bab7b7;
padding: 1rem;
}
body {
background: #fdf9f9
}
</style>
<template>
<div id='app'>
@ -519,7 +522,7 @@
}
},
input: '',
experimentsTemp: [],
experimentsTemp: null,
loadingLogout: false,
showMenu: false,
connModal: true,
@ -621,8 +624,7 @@
setExperimentsStore() {
if (localStorage.getItem('experimentsStore')) {
this.experimentsTemp = JSON.parse(localStorage.getItem('experimentsStore'))
this.$store.commit('setUserWallExperiment', this.experimentsTemp.wall)
this.$store.commit('setRelationshipsExperiment', this.experimentsTemp.relationships)
this.$store.commit('setExperimentsStore', this.experimentsTemp)
} else {
console.log("No experimentsStore, not storing experiments into state (Kaverti Experiments)")
}

View File

@ -24,6 +24,14 @@
Toggle
</b-switch>
</div>
<div class="box">
<h2>Teams (Local)</h2>
<b-switch
v-model="experiments.teams"
v-if="$store.state.developerMode">
Toggle
</b-switch>
</div>
<b-button
class='button is-info'
:loading='experiments.loading'
@ -61,6 +69,7 @@
error: '',
relationships: false,
wall: true,
teams: false,
local: []
},
}
@ -75,7 +84,7 @@
this.localExperiments()
},
localExperiments() {
localStorage.setItem('experimentsStore', JSON.stringify({wall: this.experiments.wall, theme: this.experiments.theme, relationships: this.experiments.relationships}));
localStorage.setItem('experimentsStore', JSON.stringify({wall: this.experiments.wall, theme: this.experiments.theme, relationships: this.experiments.relationships, teams: this.experiments.teams}));
this.setExperimentsStore()
},
savePreferences() {
@ -107,8 +116,9 @@
created () {
if (localStorage.getItem('experimentsStore')) {
this.experiments = JSON.parse(localStorage.getItem('experimentsStore'))
this.experiments(this.experiments.wall)
this.experiments(this.experiments.relationships)
this.experiments.wall(this.experiments.wall)
this.experiments.relationships(this.experiments.relationships)
this.experiments.teams(this.experiments.teams)
} else {
console.log("No experimentsStore, not storing experiments into state (Kaverti Experiments)")
}

View File

@ -6,9 +6,6 @@
.team-img {
border-radius: 50%;
}
body {
background: #F5F7FA
}
.vertical {
margin: 0;
position: absolute;
@ -27,23 +24,12 @@ body {
<div class="section profile-heading card">
<div class="columns is-mobile is-multiline">
<div class="column is-2">
<figure class="image is-256 team-img" v-if="user.approved && user.picture !== 'default'">
<img class="team-img" alt="Kaverti Team profile picture (not a character avatar)" src="https://placehold.it/256x256">
</figure>
<figure class="image is-256 team-img">
<div
v-if="user.approved && user.picture === 'default'"
class='user_header__icon team-img'
style="background: #249acb;"
>
<h1 class="vertical default-img">{{username[0].toUpperCase()}}</h1>
</div>
</figure>
<figure v-if="!user.approved && $store.state.theme === 'light'" class="image is-256 is-centered">
<img class="team-img" alt="Kaverti Team profile picture (not a character avatar)" src="https://cdn.kaverti.com/teams/pending-light.png">
</figure>
<figure v-if="!user.approved && $store.state.theme === 'dark'" class="image is-256 is-centered">
<img class="team-img" alt="Kaverti Team profile picture (not a character avatar)" src="https://cdn.kaverti.com/teams/pending-dark.png">
<figure class="image is-256 is-centered">
<img class="team-img" v-if="user.picture !== 'default' && user.approved" width="256px" height="256px" :src="user.picture">
<img class="team-img" v-if="user.picture === 'default' && $store.state.theme === 'light' && user.approved" width="256px" height="256px" src="https://cdn.kaverti.com/teams/unknown-light.png">
<img class="team-img" v-if="user.picture === 'default' && $store.state.theme === 'dark' && user.approved" width="256px" height="256px" src="https://cdn.kaverti.com/teams/unknown-dark.png">
<img class="team-img" v-if="$store.state.theme === 'light' && !user.approved" width="256px" height="256px" src="https://cdn.kaverti.com/teams/pending-light.png">
<img class="team-img" v-if="$store.state.theme === 'dark' && !user.approved" width="256px" height="256px" src="https://cdn.kaverti.com/teams/pending-dark.png">
</figure>
</div>
<div class="column is-4-tablet is-10-mobile name">
@ -68,20 +54,20 @@ body {
</div>
</div>
<br>
<div class="column is-8-fullhd">
<b-button v-if="!user.userWallOptOut && $store.state.username" class="menu_button" :key='"user-menu-item-wall"' @click='$router.push(`/user/${username}/wall`)'>
User Wall
<div class="column">
<b-button v-if="!user.userWallOptOut && $store.state.username" class="menu_button" :key='"user-menu-item-wall"' @click='$router.push(`/t/${username}/wall`)'>
Team Wall
</b-button>
<b-tooltip v-if="!user.userWallOptOut && !$store.state.username" label="You can only view a user's wall when logged in">
<b-tooltip v-if="!user.userWallOptOut && !$store.state.username" label="You can only view a team's wall when logged in">
<b-button v-if="!user.userWallOptOut && !$store.state.username" disabled="disabled" class="menu_button disabled" :key='"user-menu-item-wall"'>
User Wall
Team Wall
</b-button>
</b-tooltip>
<b-button class="menu_button" :key='"user-menu-item-posts"' @click='$router.push(`/user/${username}/posts`)'>
Posts
<b-button class="menu_button" :key='"user-menu-item-posts"' @click='$router.push(`/t/${username}/items`)'>
Items
</b-button>
<b-button class="menu_button" :key='"user-menu-item-threads"' @click='$router.push(`/user/${username}/threads`)'>
Threads
<b-button class="menu_button" :key='"user-menu-item-posts"' @click='$router.push(`/t/${username}/members`)'>
Members and Roles
</b-button>
<br/> <br/>
<div class="column box">
@ -209,9 +195,6 @@ export default {
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `teams/view/${this.$route.params.username}`)
.then(res => this.user = res.data)
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `relationships/${this.$route.params.username}`)
.then(res => this.relationship = res.data)
}
}
</script>

View File

@ -0,0 +1,323 @@
<style>
mini-br {
border-width: 1px;
}
</style>
<template>
<div class='route_container'>
<modal-window
v-model='picture.showProfilePictureModal'
:loading='picture.loading'
width='25rem'
style="z-index: 99;"
@input='hideProflePictureModal'
>
<div slot="header">
Add a team profile picture
</div>
<div
slot='main'
class='card-content'
:class='{ "profile_picture_modal--picture.dragging": picture.dragging }'
@dragover='handleDragOver'
@drkagend='picture.dragging = false'
@drkgleave='picture.dragging = false'
@drop='handleFileDrop'
>
<div class='h3'>Add a profile picture</div>
<p class='p--condensed'>
Drag and drop an image or<br>
<label class='button profile_picture_modal__upload_button'>
<input type='file' accept='image/*' @change='processImage($event.target.files[0])'>
Upload image
</label>
</p>
<div class='profile_picture_modal__drag_area'>
<span
v-if='!picture.dataURL'
class='profile_picture_modal__drag_area__icon'
:class='{ "profile_picture_modal__drag_area__icon--picture.dragging": picture.dragging }'
>
<font-awesome-icon :icon='["fa", "cloud-upload-alt"]' />
</span>
<div
class='profile_picture_modal__drag_area__image picture_circle'
:style='{ "background-image": "url(" + picture.dataURL + ")" }'
v-else
></div>
</div>
</div>
<div class='profile_picture_modal__buttons' slot='footer'>
<button
class='button button--modal button--green'
:class='{ "button--disabled": !picture.dataURL }'
@click='uploadProfilePicture'
>
Upload picture
</button>
<button class='button button--modal' @click='hideProflePictureModal'>Cancel</button>
</div>
</modal-window>
<h1>{{team.name}} General</h1>
<div>
<h3>Username (200 Koins):</h3>
<b-input
:placeholder='team.username'
maxlength="30"
:value="team.username"
v-model='teamGeneral.username'
:error='teamGeneral.usernameError'
></b-input>
<h3>Name:</h3>
<b-input
:placeholder='team.name'
maxlength="30"
:value="teamGeneral.name"
v-model='teamGeneral.name'
:error='teamGeneral.nameError'
></b-input>
<h3>Description:</h3>
<b-input
:placeholder='team.description'
maxlength="256"
:value="team.description"
v-model='teamGeneral.description'
:error='teamGeneral.descriptionError'
></b-input>
<b-button
class='button is-info'
:loading='teamGeneral.loading'
@click='saveTeam'
>
Save team
</b-button>
</div>
<div>
<h2>Add/change Team profile picture:</h2>
<p
class='p--condensed profile_picture_preview picture_circle'
:style='{ "background-image": "url(" + picture.current + ")" }'
v-if='picture.current'
></p>
<button class='button' @click='picture.showProfilePictureModal = true'>
{{picture.current ? "Change" : "Add" }} profile picture
</button>
<button
v-if='picture.current'
class='button'
style='margin-left: 0.5rem;'
@click='picture.showRemoveProfilePictureModal = true'
>
Remove
</button>
</div>
</div>
</template>
<script>
import FancyTextarea from '../FancyTextarea'
import ModalWindow from '../ModalWindow'
import AjaxErrorHandler from '../../assets/js/errorHandler'
import logger from '../../assets/js/logger'
export default {
name: 'settingsGeneral',
components: {
// eslint-disable-next-line vue/no-unused-components
FancyTextarea,
ModalWindow
},
data() {
return {
teamGeneral: {
name: '',
loading: false,
nameError: '',
description: '',
descriptionError: '',
username: '',
usernameError: ''
},
picture: {
current: null,
showProfilePictureModal: false,
showRemoveProfilePictureModal: false,
dragging: false,
dataURL: null,
file: null,
loading: false
},
team: null
}
},
computed: {},
methods: {
uploadProfilePicture() {
this.picture.loading = true
let formData = new FormData()
formData.append('picture', this.picture.file)
this.axios
.post('/api/v1/teams/admin/' + this.team.username + '/picture', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then(res => {
this.hideProflePictureModal()
this.picture.current = res.data.picture
})
.catch(e => {
this.picture.loading = false
AjaxErrorHandler(this.$store)(e)
})
},
removeProfilePicture() {
this.axios
.delete('/api/v1/user/' + this.$store.state.username + '/picture')
.then(() => {
this.picture.current = null
})
.catch(AjaxErrorHandler(this.$store))
},
hideProflePictureModal() {
this.picture.showProfilePictureModal = false
//Wait for transition to complete
setTimeout(() => {
this.picture.dataURL = null
this.picture.loading = false
}, 200)
},
handleDragOver(e) {
e.preventDefault()
this.picture.dragging = true
},
handleFileDrop(e) {
e.preventDefault()
this.picture.dragging = false
if (e.dataTransfer && e.dataTransfer.items) {
let file = e.dataTransfer.items[0]
if (file.type.match('^image/')) {
this.processImage(file.getAsFile())
}
}
},
processImage(file) {
let reader = new FileReader()
reader.readAsDataURL(file)
this.picture.file = file
reader.addEventListener('load', () => {
this.picture.dataURL = reader.result
})
},
capitalizeFirstLetter(value) {
return value.toUpperCase()
},
saveTeam() {
this.teamGeneral.error = ''
this.teamGeneral.loading = true
this.axios
.put(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + '/' + 'teams/admin/modify/' + this.team.username, {
description: this.teamGeneral.description,
name: this.teamGeneral.name
})
.then(() => {
this.teamGeneral.loading = false
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `teams/view/${this.$route.params.username}`)
.then(res => this.team = res.data)
this.$nextTick(() => {
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `teams/view/${this.$route.params.username}`)
.then(res => {
this.teamGeneral.description = res.data.description || ''
this.teamGeneral.username = res.data.username
this.teamGeneral.name = res.data.name || ''
})
.catch(e => {
AjaxErrorHandler(this.$store)(e)
})
})
})
.catch(e => {
this.teamGeneral.loading = false
AjaxErrorHandler(this.$store)(e)
})
},
},
created () {
this.axios.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + '/' + 'userinfo')
.then(res => {
this.$store.commit('setUsername', res.data.username)
this.$store.commit('setEmail', res.data.email)
this.$store.commit('setEmailVerified', res.data.emailVerified)
this.$store.commit('setAdmin', res.data.admin)
this.$store.commit('setDevMode', res.data.developerMode)
})
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `teams/view/${this.$route.params.username}`)
.then(res => this.team = res.data)
this.$nextTick(() => {
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `teams/view/${this.$route.params.username}`)
.then(res => {
this.teamGeneral.description = res.data.description || ''
this.teamGeneral.username = res.data.username
this.teamGeneral.name = res.data.name || ''
})
.catch(e => {
AjaxErrorHandler(this.$store)(e)
})
})
this.$store.dispatch('setTitle', 'General Team Settings')
logger('settingsGeneral')
}
}
</script>
<style lang='scss' scoped>
@import '../../assets/scss/variables.scss';
.profile_picture_preview {
height: 5rem;
width: 5rem;
}
.profile_picture_modal {
padding-top: 1rem;
transition: all 0.2s;
@at-root #{&}--picture .dragging {
background-color: $color__lightgray--primary;
}
@at-root #{&}__overlay {
@include loading-overlay(rgba(0, 0, 0, 0.5), 0.125rem);
}
@at-root #{&}__upload_button input[type="file"] {
display: none;
}
@at-root #{&}__drag_area {
padding: 1rem;
text-align: center;
@at-root #{&}__image {
width: 5rem;
height: 5rem;
display: inline-block;
margin-top: -1rem;
}
@at-root #{&}__icon {
font-size: 6rem;
color: $color__gray--darker;
transition: all 0.2s;
@at-root #{&}--picture.dragging {
transform: translateY(-0.5rem) scale(1.1);
color: $color__gray--darkest;
}
}
}
}
@media (max-width: $breakpoint--tablet) {
.h1 {
display: none;
}
}
</style>

View File

@ -0,0 +1,5 @@
<template>
<main>
</main>
</template>

View File

@ -0,0 +1,5 @@
<template>
<main>
</main>
</template>

View File

@ -0,0 +1,5 @@
<template>
<main>
</main>
</template>

View File

@ -0,0 +1,219 @@
<template>
<div class='route_container section route_container--settings'>
<div class='settings_menu columns'>
<b-menu class="column box is-11">
<b-menu-list :label="team.name + ' Team'">
<b-menu-item
:label=item.name
:icon=item.icon
:key='index'
v-for='(item, index) in menuItems'
:class="{'': index === selected}"
@click='$router.push("/team/" + team.username + "/" + item.route)'
></b-menu-item>
</b-menu-list>
</b-menu>
</div>
<div class='column box'>
<router-view></router-view>
</div>
</div>
</template>
<script>
export default {
name: 'TeamSettings',
data () {
return {
menuItems: [
{ name: 'General', route: 'general', icon: 'cog' },
{ name: 'Roles', route: 'account', icon: 'user-lock'},
{ name: 'Users', route: 'privacy', icon: 'users'},
{ name: 'Join Requests', route: 'experiments', icon: 'envelope'},
{ name: 'Team Privacy', route: 'about', icon: 'lock'},
],
selected: 0,
team: null
}
},
watch: {
$route (to) {
this.selected = this.getIndexFromRoute(to.path)
},
'$store.state.username' (username) {
if(!username) {
this.$router.push('/')
}
}
},
mounted () {
this.selected = this.getIndexFromRoute(this.$route.path)
this.axios.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + '/' + 'userinfo')
.then(res => {
this.$store.commit('setUsername', res.data.username)
this.$store.commit('setEmail', res.data.email)
this.$store.commit('setEmailVerified', res.data.emailVerified)
this.$store.commit('setAdmin', res.data.admin)
this.$store.commit('setKoins', res.data.koins)
})
.catch(e => {
this.showConn(e)
})
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `teams/view/${this.$route.params.username}`)
.then(res => this.team = res.data)
},
methods: {
getIndexFromRoute (path) {
let selectedIndex
let route = path.split('/')[2]
this.menuItems.forEach((item, index) => {
if(item.route === route) {
selectedIndex = index
}
})
return selectedIndex
}
},
beforeRouteEnter (to, from, next) {
next(vm => {
if(!vm.$store.state.username) {
vm.$store.commit('setAccountModalState', true);
next('/')
}
})
}
}
</script>
<style lang='scss' scoped>
@import '../../assets/scss/variables.scss';
.route_container--settings {
display: flex;
align-items: flex-start
}
.settings_menu {
width: 15rem;
padding: 1rem;
border-radius: 0.25rem;
@at-root #{&}__title {
cursor: default;
font-weight: 500;
font-variant: small-caps;
font-size: 1.125rem;
padding-left: 0.25rem;
margin-bottom: 0.5rem;
}
@at-root #{&}__item {
padding: 0.5rem 1rem;
margin-bottom: 0.25rem;
padding-right: 0;
transition: background-color 0.2s;
cursor: pointer;
position: relative;
border-radius: 0.25rem;
&:first-child { margin-top: 0.5rem; }
&:last-child { margin-bottom: 0.5rem; }
&:hover { background-color: $color__lightgray--primary; }
&::before {
content: '';
display: inline-block;
width: 0.25rem;
z-index: 1;
height: 100%;
position: absolute;
left: 0;
border-radius: 0.25rem 0 0 0.25em;
top: 0;
background-color: $color__gray--darkest;
opacity: 0;
transition: opacity 0.2s;
}
span {
color: $color__text--secondary;
margin-right: 0.5rem;
}
@at-root #{&}--selected {
background-color: $color__lightgray--darker;
color: $color__text--primary;
span {
color: $color__text--primary;
}
&:hover { background-color: $color__lightgray--darker; }
&::before {
opacity: 1;
}
}
}
}
.settings_page {
width: calc(100% - 15rem);
background-color: #fff;
border-radius: 0.25rem;
margin-left: 2rem;
border: thin solid $color__gray--darker;
}
@media (max-width: $breakpoint--tablet) and (min-width: $breakpoint--phone) {
div.settings_menu, div.settings_page {
width: calc(100% - 4rem);
margin: 0.5rem 2rem;
padding: 1rem;
}
}
@media (max-width: $breakpoint--tablet) {
.route_container--settings {
flex-direction: column;
}
.settings_menu {
width: 100%;
@at-root #{&}__items {
display: flex;
align-items: baseline;
}
@at-root #{&}__item {
width: 7rem;
margin-right: 0.5rem;
color: $color__text--primary;
&:first-child, &:last-child {
margin-bottom: 0;
margin-top: 0;
}
&::before {
height: 0.2rem;
width: 100%;
left: 0;
border-radius: 0 0 1rem 1rem;
top: auto;
bottom: 0;
}
}
}
.settings_page {
width: 100%;
margin: 0;
margin-top: 1rem;
}
}
</style>

View File

@ -0,0 +1,5 @@
<template>
<main>
</main>
</template>

View File

@ -0,0 +1,433 @@
<template>
<main>
<div class='' :class='{ "user_posts--no_border_bottom": posts && !posts.length }'>
<div class='user_posts__title'>{{team.name}}'s team wall</div>
<div
class='editor'
:class='{
"editor--focus": focusInput,
"editor--error": errors.content
}'
>
<div class='editor__input'>
<input-editor-core
v-model='editor'
@mentions='setMentions'
@focus='setFocusInput(true)'
@blur='setFocusInput(false)'
></input-editor-core>
</div>
</div>
<error-tooltip :error='errors.content' class='editor_error'></error-tooltip>
<b-button class='submit' :loading='loading' @click='postThread'>Post on wall</b-button>
<template v-if='!posts'>
<thread-post-placeholder v-if='!posts'>
</thread-post-placeholder>
</template>
<scroll-load
:loading='loadingPosts'
@loadNext='loadNewPosts'
v-else-if='posts.length'
>
<wall-post
v-for='(post, index) in posts'
:key='"thread-post-" + post.id'
:post='post'
:show-thread='true'
:click-for-post='true'
:class='{"post--last": index === posts.length-1}'
></wall-post>
<template v-if='loadingPosts'>
<thread-post-placeholder
v-for='n in nextPostsCount'
:key='"thread-post-placeholder-" + n'
></thread-post-placeholder>
</template>
</scroll-load>
<template v-else><br><br>There are no wall posts yet</template>
</div>
</main>
</template>
<script>
import ScrollLoad from '../ScrollLoad'
import WallPost from '../WallPost'
import ThreadPostPlaceholder from '../ThreadPostPlaceholder'
import InputEditorCore from '../InputEditorCore'
import ErrorTooltip from '../ErrorTooltip'
import AjaxErrorHandler from '../../assets/js/errorHandler'
import logger from '../../assets/js/logger'
export default {
name: 'ThreadNew',
props: ['username'],
components: {
WallPost,
ScrollLoad,
ThreadPostPlaceholder,
InputEditorCore,
ErrorTooltip
},
data () {
return {
selectedCategory: this.$store.state.category.selectedCategory,
editor: '',
mentions: [],
name: '',
loading: false,
focusInput: false,
threads: null,
loadingThreads: false,
nextURL: '',
nextThreadsCount: 0,
posts: null,
errors: {
content: '',
name: '',
pollQuestion: '',
pollAnswer: ''
},
showPoll: false,
pollQuestion: '',
newPollAnswer: '',
pollAnswers: [],
team: null
}
},
computed: {
categories () {
return this.$store.getters.categoriesWithoutAll
}
},
methods: {
loadNewPosts () {
if(this.nextURL === null) return
this.loadingThreads = true
},
togglePoll (val) {
if(val !== undefined) {
this.showPoll = val
} else {
this.showPoll = !this.showPoll
}
},
addPollAnswer () {
if(!this.newPollAnswer.trim().length) return
this.pollAnswers.push({ answer: this.newPollAnswer })
this.newPollAnswer = ''
},
removePollAnswer ($index) {
this.pollAnswers.splice($index, 1)
},
removePoll () {
this.pollQuestion = ''
this.pollAnswers = []
this.newPollAnswer = ''
this.togglePoll()
},
setErrors (errors) {
errors.forEach(error => {
this.errors[error.name] = error.error
})
},
getUserInfo () {
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `teams/view/${this.$route.params.username}`)
.catch((e) => {
AjaxErrorHandler(this.$store)(e)
})
},
clearErrors () {
this.errors.content = ''
this.errors.name = ''
this.errors.pollQuestion = ''
this.errors.pollAnswer = ''
},
hasDuplicates (array, cb) {
if(cb) array = array.map(cb)
return array.length !== (new Set(array)).size
},
postThread () {
let errors = []
this.clearErrors()
if(!this.editor.trim().length) {
errors.push({name: 'content', error: 'Post content cannot be blank'})
} if(errors.length) {
this.setErrors(errors)
return
}
this.loading = true
this.axios.post(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `teams/wall/post`, {
username: this.$route.params.username,
content: this.editor,
mentions: this.mentions
}).then(() => {
let ajax = []
return Promise.all(ajax)
}).then(() => {
this.loading = false
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `teams/view/${this.$route.params.username}?wall=true`)
.then(res => {
this.posts = res.data.teamWalls
this.nextPostsCount = res.data.meta.postNumber
})
}).catch(e => {
this.loading = false
AjaxErrorHandler(this.$store)(e, (error, errors) => {
let path = error.path
if(this.errors[path] !== undefined) {
this.errors[path] = error.message
} else {
errors.push(error.message)
}
})
})
},
setFocusInput (val) {
this.focusInput = val
},
setMentions (mentions) {
this.mentions = mentions
}
},
watch: {
'$store.state.username' (username) {
if(!username) {
this.$router.push('/')
}
}
},
mounted () {
this.getUserInfo();
this.$store.dispatch('setTitle', this.$route.params.name + '\'s team wall')
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `teams/view/${this.$route.params.username}?wall=true`)
.then(res => {
this.loadingPosts = false
this.posts = res.data.teamWalls
this.team = res.data
this.nextPostsCount = res.data.meta.nextPostsCount
})
.catch((e) => {
AjaxErrorHandler(this.$store)(e)
})
logger('userWall', this.$route.params.username)
},
beforeRouteEnter (to, from, next) {
next(vm => {
if(!vm.$store.state.username) {
vm.$store.commit('setAccountModalState', true);
next('/')
}
})
}
}
</script>
<style lang='scss'>
@import '../../assets/scss/variables.scss';
.thread_new {
margin-top: 1rem;
}
.thread_meta_info {
background-color: #fff;
border: thin solid $color__gray--darker;
border-radius: 0.25rem;
padding: 1rem;
margin: 1rem 0;
@at-root #{&}__title {
margin: 0 0.5rem;
margin-top: 0.5rem;
display: inline-block;
}
@at-root #{&}__form {
display: flex;
align-items: baseline;
}
@at-root #{&}__add_poll {
margin-top: 0.5rem;
}
@at-root #{&}__text {
margin-bottom: 0.5rem;
}
@at-root #{&}__poll {
border-top: thin solid $color__gray--primary;
margin-top: 1rem;
padding-top: 0.75rem;
position: relative;
@at-root #{&}__top_bar {
display: flex;
justify-content: space-between;
align-items: baseline;
}
@at-root #{&}__answer {
display: flex;
align-items: baseline;
& > span {
opacity: 0;
pointer-events: none;
transition: all 0.1s;
font-size: 1.5rem;
margin-left: 0.5rem;
cursor: pointer;
@include user-select(none);
}
&:hover > span {
opacity: 1;
pointer-events: all;
}
}
}
}
.submit {
margin-top: 1rem;
}
.editor {
display: flex;
background-color: #fff;
border-radius: 0.25rem;
border: thin solid $color__gray--darker;
transition: all 0.2s;
@at-root #{&}--focus {
border: thin solid $color__gray--darkest;
}
@at-root #{&}--error {
border: thin solid $color__red--primary;
}
@at-root #{&}__format_bar {
height: 2.5rem;
background-color: $color__gray--primary;
display: flex;
padding-right: 1rem;
padding-bottom: 0.25rem;
justify-content: flex-end;
align-items: center;
font-variant: small-caps;
@at-root #{&}--preview {
border-radius: 0 0.25rem 0 0;
}
@at-root #{&}--editor {
border-radius: 0.25rem 0 0 0;
}
}
@at-root #{&}__input {
width: 100%;
position: relative;
.input_editor_core__format_bar {
left: 0rem;
}
.input_editor_core textarea {
height: 5rem;
}
}
@at-root #{&}__preview {
border-left: 1px solid $color__gray--darker;
div.input_editor_preview__markdownHTML {
height: 14.1rem;
}
}
}
.editor_error {
width: 100%;
background: #fff;
margin-top: 0.5rem;
border-radius: 0.2rem;
border: thin solid $color__red--primary;
&.error_tooltip--show {
max-height: 4rem;
padding: 0.5rem;
}
}
@media (max-width: 600px) {
.thread_meta_info {
@at-root #{&}__form {
flex-direction: column;
}
@at-root #{&}__title.fancy_input {
margin: 0;
margin-top: 0.5rem;
}
@at-root #{&}__poll__question {
width: 100%;
> div, input {
width: 100% ;
}
}
}
.editor {
flex-direction: column;
overflow-x: hidden;
@at-root #{&}__input, #{&}__preview {
border: 0;
width: 100%;
}
}
}
.user_posts {
background: #fff;
border-radius: 0.25rem;
padding: 1rem;
border: thin solid $color__gray--darker;
@at-root #{&}__title {
font-size: 1.5rem;
margin-bottom: 1rem;
}
}
@media (max-width: $breakpoint--tablet) {
.user_posts {
margin-top: 1rem;
overflow: hidden;
}
}
</style>

View File

@ -1,14 +1,176 @@
<style>
.team-img {
border-radius: 50%;
}
.vertical-alt {
margin: 0;
position: absolute;
top: 50%;
left: 50%;
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
</style>
<template>
<section class="hero is-info is-large">
<div class="hero-body">
<div class="container">
<h2>
Kaverti Teams
</h2>
<h1 class="title">
Kaverti Teams are coming soon
</h1>
<main>
<section v-if="!$store.state.experimentsStore.teams" class="hero is-info is-large">
<div class="hero-body">
<div class="container">
<h2>
Kaverti Teams
</h2>
<h1 class="title">
Kaverti Teams are coming soon
</h1>
</div>
</div>
</div>
</section>
</section>
<div class="section" v-if="$store.state.experimentsStore.teams">
<div class="">
<div class="column">
<b-button class="is-primary">Create Team</b-button>
</div>
<scroll-load
key='user-row'
class='columns is-multiline'
v-if='users.length'
:loading='loading'
@loadNext='fetchData'
>
<div class="column is-4" v-for='user in users' :key='"user-row" + user.username' v-show="user && !user.hidden"><div class="card">
<div class="card-content">
<router-link :to="'/t/' + user.username"><b-button style="float:right;">View</b-button></router-link>
<div class="media">
<div class="media-left">
<figure class="image is-64x64">
<img class="team-img" v-if="user.picture !== 'default' && user.approved" width="128px" height="128px" :src="user.picture">
<img class="team-img" v-if="user.picture === 'default' && $store.state.theme === 'light' && user.approved" width="128px" height="128px" src="https://cdn.kaverti.com/teams/unknown-light.png">
<img class="team-img" v-if="user.picture === 'default' && $store.state.theme === 'dark' && user.approved" width="128px" height="128px" src="https://cdn.kaverti.com/teams/unknown-dark.png">
<img class="team-img" v-if="$store.state.theme === 'light' && !user.approved" width="128px" height="128px" src="https://cdn.kaverti.com/teams/pending-light.png">
<img class="team-img" v-if="$store.state.theme === 'dark' && !user.approved" width="128px" height="128px" src="https://cdn.kaverti.com/teams/pending-dark.png">
</figure>
</div>
<div class="media-content">
<p class="title is-4">{{user.name}}</p>
<p class="subtitle is-6">@{{user.username}}</p>
</div>
</div>
<div class="content">
{{user.description}}
</div>
</div>
</div>
</div>
</scroll-load>
</div>
</div>
<p name='fade' mode='out-in'>
<center><loading-message key='loading' v-if='loading'></loading-message></center>
<center><div class='overlay_message' v-if='!loading && !users.length'>
Something went wrong while loading the users, check your internet connection, or check the <a href="https://status.troplo.com">Service Status</a>
</div></center></p>
</main>
</template>
<script>
import LoadingMessage from '../LoadingMessage';
import throttle from 'lodash.throttle';
import AjaxErrorHandler from '../../assets/js/errorHandler';
export default {
name: 'UserList',
components: {
LoadingMessage
},
data () {
return {
search: '',
users: [],
loading: true,
offset: 0,
limit: 15,
roleOptions: [
{ name: 'Admins', value: 'admin' },
{ name: 'Users', value: 'user' }
],
roleSelected: ['admin', 'user'],
tableSort: {
column: 'username',
sort: 'desc'
}
}
},
methods: {
fetchData () {
if(this.offset === null) return;
let url = process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `teams?
sort=${this.tableSort.column}
&order=${this.tableSort.sort}
&offset=${this.offset}
`;
if(this.roleSelected.length === 1) {
url += '&role=' + this.roleSelected[0];
}
if(this.search.length) {
url += '&search=' + encodeURIComponent(this.search.trim());
}
this.loading = true;
this.axios
.get(url)
.then(res => {
this.users.push(...res.data);
this.loading = /*loading =*/ false;
//If returned data is less than the limit
//then there must be no more pages to paginate
if(res.data.length < this.limit) {
this.offset = null;
} else {
this.offset+= this.limit;
}
})
.catch(e => {
AjaxErrorHandler(this.$store)(e);
this.loading = /*loading =*/ false;
});
},
resetFetchData () {
this.offset = 0;
this.users = [];
this.fetchData();
}
},
getNewerUsers () {
this.loadingNewer = true
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + '/' + 'teams' + '?limit=' + this.newUsers)
.then(res => {
this.loadingNewer = false
this.newUsers = 0
this.threads.unshift(...res.data.Threads)
})
.catch((e) => {
this.loadingNewer = false
AjaxErrorHandler(this.$store)(e)
})
},
mounted () {
this.fetchData();
},
watch: {
tableSort: 'resetFetchData',
roleSelected: 'resetFetchData',
search: throttle(function () {
this.resetFetchData();
}, 200)
}
}
</script>

View File

@ -58,10 +58,10 @@
Manage user
</button>
</menu-button><br>
<b-button v-if="$store.state.relationships && relationship && user.username !== $store.state.username && $store.state.username" class='is-danger button' icon-left="minus">
<b-button v-if="$store.state.experimentsStore.relationships && relationship && user.username !== $store.state.username && $store.state.username" class='is-danger button' icon-left="minus">
Remove Friend
</b-button>
<b-button :value="1" @click="doRelationship" v-if="$store.state.relationships && user && !relationship && user.username !== $store.state.username && $store.state.username" class='is-info button' icon-left="plus">
<b-button :value="1" @click="doRelationship" v-if="$store.state.experimentsStore.relationships && user && !relationship && user.username !== $store.state.username && $store.state.username" class='is-info button' icon-left="plus">
Send friend request
</b-button>
</div>

View File

@ -71,6 +71,13 @@ const SearchUsersThreads = () => import('./components/routes/SearchUsersThreads'
const Teams = () => import('./components/routes/Teams')
const Team = () => import('./components/routes/Team')
const TeamSettings = () => import('./components/routes/TeamSettings')
const TeamRoles = () => import('./components/routes/TeamRoles')
const TeamGeneral = () => import('./components/routes/TeamGeneral')
const TeamPrivacy = () => import('./components/routes/TeamPrivacy')
const TeamRequests = () => import('./components/routes/TeamRequests')
const TeamUsers = () => import('./components/routes/TeamUsers')
const TeamWall = () => import('./components/routes/TeamWall')
const User = () => import('./components/routes/User')
const UserPosts = () => import('./components/routes/UserPosts')
@ -177,9 +184,16 @@ const router = new VueRouter({
{ path: '/marketplace', component: Marketplace },
{ path: '/licenses', component: Licenses },
{ path: '/teams', component: Teams },
{ path: '/team/:username', redirect: '/team/:username/general', component: TeamSettings, children: [
{ path: 'general', component: TeamGeneral },
{ path: 'users', component: TeamUsers },
{ path: 'roles', component: TeamRoles },
{ path: 'privacy', component: TeamPrivacy },
{ path: 'requests', component: TeamRequests },
] },
{ path: '/t/:username', component: Team, children: [
{ path: 'items', component: UserMarketplace },
{ path: 'wall', component: UserWall }
{ path: 'wall', component: TeamWall }
] },
{ path: '/verify', component: EmailVerify },
{ path: '/verify/check', component: EmailVerifyCheck },

View File

@ -130,11 +130,8 @@ export default new Vuex.Store({
showConnModal(state, value) {
state.connModal = value;
},
setUserWallExperiment(state, value) {
state.userWall = value;
},
setRelationshipsExperiment(state, value) {
state.relationships = value;
setExperimentsStore(state, value) {
state.experimentsStore = value;
},
setAjaxErrorsModalState(state, value) {
state.ajaxErrorsModal = value;

View File

@ -0,0 +1,27 @@
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('teamPictures', {
id: {
type: Sequelize.BIGINT,
primaryKey: true,
autoIncrement: true
},
mimetype :{
type: Sequelize.STRING
},
createdAt: Sequelize.DATE,
updatedAt: Sequelize.DATE,
file: Sequelize.BLOB('long'),
TeamId: Sequelize.BIGINT
}, {
charset: 'utf8mb4'
})
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('profilepictures');
}
};

View File

@ -0,0 +1,35 @@
module.exports = {
up(queryInterface, Sequelize) {
return Promise.all([
queryInterface.createTable(
'teamWalls',
{
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true
},
createdAt: Sequelize.DATE,
updatedAt: Sequelize.DATE,
content: {
type: Sequelize.TEXT,
allowNull: false
},
plainText: {
type: Sequelize.TEXT,
allowNull: false
},
postNumber: Sequelize.BIGINT,
teamId: Sequelize.BIGINT,
fromUserId: Sequelize.BIGINT,
replyingToUsername: Sequelize.STRING,
removed: {
type: Sequelize.BOOLEAN,
defaultValue: false
},
},
)
]);
},
};

View File

@ -16,7 +16,14 @@ let options = {
}
}
if(env === 'production') {
var sequelize = new Sequelize(process.env.DATABASE_URL, options)
var sequelize = new Sequelize(
config[env].database, config[env].username, config[env].password, {
host: config[env].host,
dialect: config[env].dialect,
logging: false,
...options
}
);
} else {
var sequelize = new Sequelize(
config[env].database, config[env].username, config[env].password, {

View File

@ -1,21 +1,21 @@
let sharp = require('sharp')
module.exports = (sequelize, DataTypes) => {
let ProfilePicture = sequelize.define('ProfilePicture', {
let teamPicture = sequelize.define('teamPicture', {
file: DataTypes.BLOB('long'),
mimetype: DataTypes.STRING
}, {
classMethods: {
associate (models) {
ProfilePicture.belongsTo(models.User)
teamPicture.belongsTo(models.Team)
}
},
hooks: {
beforeUpdate (profilePicture, options, cb) {
sharp(profilePicture.file)
.resize(300, 300, { fit: 'cover' })
beforeUpdate (teamPicture, options, cb) {
sharp(teamPicture.file)
.resize(256, 256, { fit: 'cover' })
.toBuffer((err, buff) => {
profilePicture.file = buff
teamPicture.file = buff
cb(err || null, options)
})
@ -23,5 +23,5 @@ module.exports = (sequelize, DataTypes) => {
}
})
return ProfilePicture
return teamPicture
}

View File

@ -154,7 +154,7 @@ module.exports = (sequelize, DataTypes) => {
return meta
},
async getWallMeta (limit) {
let Post = sequelize.models.userWall
let Post = sequelize.models.teamWall
let meta = {}
let nextId = await pagination.getNextIdDesc(Post, { userId: this.id }, this.Posts)
@ -177,6 +177,9 @@ module.exports = (sequelize, DataTypes) => {
}
},
classMethods: {
associate (models) {
Team.hasMany(models.teamWall)
},
includeOptions (from, limit) {
let models = sequelize.models
let options = models.Post.includeOptions()
@ -193,7 +196,7 @@ module.exports = (sequelize, DataTypes) => {
let models = sequelize.models
return [{
model: models.userWall,
model: models.teamWall,
limit,
where: { postNumber: { $gte: from } },
order: [['id', 'ASC']]

122
models/teamWall.js Normal file
View File

@ -0,0 +1,122 @@
var md = require('../kaverti_modules/markdown-it')({
html: false, // Enable HTML tags in source
xhtmlOut: false, // Use '/' to close single tags (<br />).
// This is only for full CommonMark compatibility.
breaks: true, // Convert '\n' in paragraphs into <br>
langPrefix: 'language-', // CSS language prefix for fenced blocks. Can be
// useful for external highlighters.
linkify: true, // Autoconvert URL-like text to links
image: false,
// Enable some language-neutral replacement + quotes beautification
typographer: true,
// Double + single quotes replacement pairs, when typographer enabled,
// and smartquotes on. Could be either a String or an Array.
//
// For example, you can use '«»„“' for Russian, '„“‚‘' for German,
// and ['«\xA0', '\xA0»', '\xA0', '\xA0'] for French (including nbsp).
quotes: '“”‘’',
// Highlighter function. Should return escaped HTML,
// or '' if the source string is not changed and should be escaped externally.
// If result starts with <pre... internal wrapper is skipped.
highlight: function (/*str, lang*/) { return ''; }
});
md.disable('image')
var emoji = require('markdown-it-emoji');
var twemoji = require('twemoji')
md.use(emoji);
md.renderer.rules.emoji = function(token, idx) {
return twemoji.parse(token[idx].content);
};
let createDOMPurify = require('dompurify');
let { JSDOM } = require('jsdom');
let window = new JSDOM('').window;
let DOMPurify = createDOMPurify(window);
var escaped_str = require('querystring')
const Errors = require('../lib/errors')
let pagination = require('../lib/pagination.js')
module.exports = (sequelize, DataTypes) => {
let teamWall = sequelize.define('teamWall', {
content: {
type: DataTypes.TEXT,
set (val) {
if(!val) {
throw Errors.sequelizeValidation(sequelize, {
error: 'content must be a string',
path: 'content'
})
}
let rawHTML = md.render(val);
let cleanHTML = DOMPurify.sanitize(rawHTML);
let plainText = (new JSDOM(cleanHTML)).window.document.body.textContent;
if (!plainText.trim().length) {
throw Errors.sequelizeValidation(sequelize, {
error: 'Post content must not be empty',
path: 'content'
})
}
this.setDataValue('content', cleanHTML)
this.setDataValue('plainText', plainText)
},
allowNull: false
},
plainText: {
type: DataTypes.TEXT,
},
postNumber: DataTypes.INTEGER,
replyingToUsername: DataTypes.STRING,
teamId: DataTypes.BIGINT,
removed: {
type: DataTypes.BOOLEAN,
defaultValue: false
}
}, {
instanceMethods: {
async getMeta(limit) {
let Post = sequelize.models.userWall
let meta = {}
let nextId = await pagination.getNextIdDesc(userWall, {fromUserId: this.id}, this.Posts)
if (nextId === null) {
meta.nextURL = null
meta.nextPostsCount = 0
} else {
meta.nextURL =
process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `wall/show/${this.username}?posts=true&limit=${limit}&from=${nextId - 1}`
meta.nextPostsCount = await pagination.getNextCount(
Post, this.Posts, limit,
{fromUserId: this.id},
true
)
}
return meta
}
},
classMethods: {
associate (models) {
teamWall.belongsTo(models.User, { as: 'fromUser' })
},
includeOptions () {
let models = sequelize.models
return [
{ model: models.User, as: 'fromUser', attributes: ['username', 'createdAt', 'id', 'color', 'picture'] },
]
}
},
})
return teamWall
}

View File

@ -66,7 +66,7 @@
"sendgrid": "^5.2.3",
"sequelize": "3.35.1",
"sequelize-cli": "^4.1.1",
"sharp": "^0.25.4",
"sharp": "^0.26.2",
"simple-rate-limit": "^1.0.0",
"socket.io": "^2.1.1",
"speakeasy": "^2.0.0",

View File

@ -41,7 +41,7 @@ var reCAPTCHASecret = "6LdlbrwZAAAAAKvtcVQhVl_QaNOqmQ4PgyW3SKHy";
const Errors = require('../lib/errors.js')
var format = require('date-format');
let {
User, Post, ProfilePicture, userWall, StaffApplications, AdminToken, PassKey, Thread, Category, Sequelize, Ip, Ban, sequelize, Team, TeamMembers, TeamRoles
User, Post, teamWall, teamPicture, userWall, StaffApplications, AdminToken, PassKey, Thread, Category, Sequelize, Ip, Ban, sequelize, Team, TeamMembers, TeamRoles
} = require('../models')
let pagination = require('../lib/pagination.js')
const sgMail = require('@sendgrid/mail');
@ -106,102 +106,70 @@ router.post('/create', emailLimiter, auth, async(req, res, next) => {
res.json(team.toJSON())
} catch (e) { next(e) }
})
router.post('/job-application', auth, async(req, res, next) => {
try {
let userParams = {
username: req.body.username,
dob: req.body.dob,
email: req.body.email,
whyWork: req.body.whyWork,
otherForm: req.body.otherForm,
experience: req.body.experience,
selectedOption: req.body.selectedOption,
}
await StaffApplications.submitApplication(userParams)
} catch (e) { next(e) }
})
router.get('/view/:username', async(req, res, next) => {
try {
let queryObj = {
attributes: {exclude: ['banReason']},
where: {username: req.params.username}
}
let team = await Team.findOne(queryObj)
if(!team) throw Errors.accountDoesNotExist
res.json(team.toJSON())
} catch (err) { next(err) }
})
router.post('/login', async(req, res, next) => {
try {
await Ban.isIpBanned(req.ip, req.body.email)
let user = await User.findOne({ where: {
username: req.body.username
}})
if(user) {
if(await user.comparePassword(req.body.password)) {
await Ip.createIfNotExists(req.ip, user)
setUserSession(req, res, user.username, user.id, user.admin)
res.json({
username: user.username,
admin: user.admin,
success: true
})
} else {
res.status(401)
res.json({
errors: [Errors.invalidLoginCredentials]
})
if(req.query.wall) {
let {from, limit} = pagination.getPaginationProps(req.query, true)
let postInclude = {
model: teamWall,
include: teamWall.includeOptions(),
limit,
order: [['id', 'DESC']],
}
if (from !== null) {
postInclude.where = {id: {$lte: from}}
}
queryObj.include = [postInclude]
let user = await Team.findOne(queryObj)
if (!user) throw Errors.accountDoesNotExist
if (user.userWallOptOut) {
throw Errors.userWallOptOut
}
let meta = await user.getMeta(limit)
res.json(Object.assign(user.toJSON(limit), {meta}))
} else {
res.status(401)
res.json({
errors: [Errors.invalidLoginCredentials]
})
let team = await Team.findOne(queryObj)
if (!team) throw Errors.accountDoesNotExist
res.json(team.toJSON())
}
} catch (err) { next(err) }
})
router.post('/:username/logout', auth, async(req, res) => {
req.userData.destroy(() => {
res.clearCookie('username')
res.clearCookie('admin')
res.json({
success: true
})
})
})
router.get('/:username/picture', async(req, res, next) => {
router.get('/view/:username/picture', async (req, res, next) => {
try {
let user = await User.findOne({
let user = await Team.findOne({
where: {
username: req.params.username
}
})
if(!user) throw Errors.accountDoesNotExist
let picture = await ProfilePicture.findOne({
let picture = await teamPicture.findOne({
where: {
UserId: user.id
TeamId: user.id
}
})
if(!picture) {
res.status(404)
res.end('')
res.json({picture: "https://cdn.kaverti.com/teams/unknown-light.png"})
} else {
res.writeHead(200, {
'Content-Type': picture.mimetype,
'Content-disposition': 'attachment;filename=profile',
'Content-disposition': 'attachment;filename=kaverti-team-profile-picture',
'Content-Length': picture.file.length
})
res.end(new Buffer(picture.file, 'binary'))
res.end(new Buffer.from(picture.file, 'binary'))
}
} catch (e) { next(e) }
})
@ -211,18 +179,10 @@ router.get('/', async(req, res, next) => {
let sortFields = {
createdAt: 'X.id',
username: 'X.username',
threadCount: 'threadCount',
postCount: 'postCount'
};
let offset = Number.isInteger(+req.query.offset) ? +req.query.offset : 0;
let havingClause = '';
if(req.query.role === 'admin') {
havingClause = 'HAVING Users.admin = true';
} else if(req.query.role === 'user') {
havingClause = 'HAVING Users.admin = false';
} else {
havingClause = '';
}
if(req.query.search) {
//I.e. if there is not already a HAVING clause
if(!havingClause.length) {
@ -230,89 +190,27 @@ router.get('/', async(req, res, next) => {
} else {
havingClause += ' AND ';
}
havingClause += 'Users.username LIKE $search';
havingClause += 'Team.username LIKE $search';
}
let sql = `
SELECT X.username, X.admin, X.level, X.levelProgress, X.bot, X.booster, X.description, X.bodyColor, X.headColor, X.leftLegColor, X.rightLegColor, X.leftArmColor, X.rightArmColor, X.hidden, X.system, X.createdAt, X.contributor, X.postCount, COUNT(Threads.id) as threadCount
SELECT X.username, X.approved, X.name, X.picture, X.id, X.forumEnabled, X.description, X.banned, X.createdAt, X.updatedAt
FROM (
SELECT Users.*, COUNT(Posts.id) as postCount
FROM Users
LEFT OUTER JOIN Posts
ON Users.id = Posts.UserId
GROUP BY Users.id
SELECT Teams.*
FROM Teams
GROUP BY Teams.id
${havingClause}
) as X
LEFT OUTER JOIN threads
ON X.id = Threads.UserId
GROUP BY X.id
ORDER BY ${sortFields[req.query.sort] || 'X.id'} ${req.query.order === 'asc' ? 'DESC' : 'ASC'}
LIMIT 2222
OFFSET ${offset}
`;
let users = await sequelize.query(sql, {
model: User,
model: Team,
bind: { search: req.query.search + '%' }
});
res.json(users)
} catch (e) { next(e) }
})
router.all('*', auth, (req, res, next) => {
if(req.userData.username) {
next()
} else {
res.status(401)
res.json({
errors: [Errors.requestNotAuthorized]
})
}
})
router.put('/:username', auth, async(req, res, next) => {
try {
if(req.userData.username !== req.params.username) {
throw Errors.requestNotAuthorized
}
await Ban.ReadOnlyMode(req.userData.username)
if(req.autosan.body.description !== undefined) {
let user = await User.update({ description: req.autosan.body.description }, { where: {
username: req.userData.username
}})
res.json({ success: true })
} else if(
req.body.currentPassword !== undefined &&
req.body.newPassword !== undefined
) {
let user = await User.findOne({
where: {
username: req.userData.username
}
})
await user.updatePassword(req.body.currentPassword, req.body.newPassword)
res.json({success: true})
} else if(
req.body.emailCurrentPassword !== undefined &&
req.body.newEmail !== undefined
) {
let user = await User.findOne({where: {
username: req.userData.username
}})
await user.updateEmail(req.body.emailCurrentPassword, req.body.newEmail)
res.json({ success: true })
} else if(
req.body.username !== undefined &&
req.body.changeUsername === true
) {
console.log("yes")
res.json({ success: true })
} else {
res.json({ success: false })
}
} catch (e) { next(e) }
})
module.exports = router;

132
routes/team_admin.js Normal file
View File

@ -0,0 +1,132 @@
/*
@swagger
components:
schemas:
Book:
type: object
required:
- title
- author
- finished
properties:
id:
type: integer
description: The auto-generated id of the book.
title:
type: string
description: The title of your book.
author:
type: string
description: Who wrote the book?
finished:
type: boolean
description: Have you finished reading it?
createdAt:
type: string
format: date
description: The date of the record creation.
example:
title: The Pragmatic Programmer
author: Andy Hunt / Dave Thomas
finished: true
*/
let bcrypt = require('bcryptjs')
let multer = require('multer')
let express = require('express')
let router = express.Router()
const auth = require('../lib/auth')
var Recaptcha = require('express-recaptcha').RecaptchaV3;
var recaptcha = new Recaptcha('6LdlbrwZAAAAAKvtcVQhVl_QaNOqmQ4PgyW3SKHy', '6LdlbrwZAAAAAMAWPVDrL8eNPxrws6AMDtLf1bgd');
var reCAPTCHASecret = "6LdlbrwZAAAAAKvtcVQhVl_QaNOqmQ4PgyW3SKHy";
const Errors = require('../lib/errors.js')
var format = require('date-format');
let {
User, Post, teamPicture, userWall, StaffApplications, AdminToken, PassKey, Thread, Category, Sequelize, Ip, Ban, sequelize, Team, TeamMembers, TeamRoles
} = require('../models')
let pagination = require('../lib/pagination.js')
const sgMail = require('@sendgrid/mail');
const MailGen = require('mailgen')
const crypto = require("crypto")
const cryptoRandomString = require("crypto-random-string")
const rateLimit = require("express-rate-limit");
let upload = multer({
storage: multer.memoryStorage(),
limits:{
fileSize: 1024 * 1024
}
})
const emailLimiter = rateLimit({
windowMs: 60000,
max: 1, // limit each IP to 100 requests per windowMs
message: "{\"errors\":[{\"name\":\"rateLimit\",\"message\":\"You may only make 1 request to this endpoint per minute.\",\"status\":429}]}"
});
router.post('/:username/picture', auth, upload.single('picture'), async (req, res, next) => {
try {
let user = await Team.findOne({
where: {
username: req.params.username
}
})
let picture = await teamPicture.findOne({
where: { TeamId: user.id }
})
let pictureObj = {
file: req.file.buffer,
mimetype: req.file.mimetype,
TeamId: user.id
}
//No picture set yet
if(!picture) {
await teamPicture.create(pictureObj)
} else {
await picture.update(pictureObj)
}
//Add random query to end to force browser to reload background images
await user.update({
picture: '/api/v1/teams/view/' + req.params.username + '/picture?rand=' + Date.now()
})
res.json(user.toJSON())
} catch (e) { next(e) }
})
router.put('/modify/:username', auth, async(req, res, next) => {
try {
if(!req.userData.username) {
throw Errors.requestNotAuthorized
}
await Ban.ReadOnlyMode(req.userData.username)
let user1 = await Team.findOne({ where: {
username: req.params.username
}})
let user2 = await User.findOne({ where: {
username: req.userData.username
}})
console.log(user1.OwnerId, user2.id)
if(user1 && user2.id === user1.OwnerId) {
if(req.autosan.body.description !== undefined, req.autosan.body.name !== undefined) {
let user = await Team.update({description: req.autosan.body.description, name: req.autosan.body.name}, {
where: {
username: req.params.username
}
})
res.status(200)
res.json({success: true})
} else {
res.status(400)
res.json({success: "Not passed body test"})
}
} else {
res.status(400)
res.json({ success: "Not passed u1 and u2 test" })
}
} catch (e) { next(e) }
})
module.exports = router;

140
routes/team_wall.js Normal file
View File

@ -0,0 +1,140 @@
let express = require('express')
let router = express.Router()
const auth = require('../lib/auth')
const Errors = require('../lib/errors')
let { User, Team, teamWall, Notification, Ban, Sequelize, sequelize } = require('../models')
let pagination = require('../lib/pagination.js')
const rateLimit = require("express-rate-limit");
const postLimiter = rateLimit({
windowMs: 60000,
max: 10,
message: "{\"errors\":[{\"name\":\"rateLimit\",\"message\":\"You may only make 10 requests to this endpoint per minute.\",\"status\":429}]}"
});
router.get('/show/:username', async(req, res, next) => {
try {
let { limit } = pagination.getPaginationProps(req.query, true)
let postInclude = {
model: userWall,
limit,
order: [['id', 'DESC']]
}
let user = await teamWall.findOne(postInclude)
if (!user) throw Errors.accountDoesNotExist
let meta = await user.getMeta(limit)
let Posts = await teamWall.find(postInclude)
res.json(Object.assign( user.toJSON(limit), { meta, Posts } )) } catch (e) { next(e) }
})
router.all('*', auth, (req, res, next) => {
if(req.userData.loggedIn) {
next()
} else {
res.status(401)
res.json({
errors: [Errors.requestNotAuthorized]
})
}
})
router.post('/post', postLimiter, auth, async(req, res, next) => {
let queryObj = {
attributes: {include: ['emailVerified']},
where: {username: req.userData.username}
}
let getSessionId = {
attributes: {include: ['id']},
where: {username: req.userData.username}
}
let teamToId = {
attributes: {include: ['id']},
where: {username: req.body.username}
}
let user = await User.findOne(queryObj)
let sessionId = await User.findOne(getSessionId)
let getWallUser = await Team.findOne(teamToId)
try {
//Will throw an error if banned
await Ban.ReadOnlyMode(req.userData.username)
if (req.body.mentions) {
uniqueMentions = Notification.filterMentions(req.body.mentions)
}
if (!user.emailVerified) {
throw Errors.verifyEmail
}
if(getWallUser.userWallOptOut) {
throw Errors.userWallOptOut
}
if(teamToId.id == "null") throw Errors.sequelizeValidation(Sequelize, {
error: 'User doesn\'t exist',
path: 'id'
})
user = await teamWall.findOne({ where: {
fromUserId: sessionId.id
}})
post = await teamWall.create({content: req.body.content, postNumber: "0", teamId: getWallUser.id, fromUserId: req.userData.UserId})
if (uniqueMentions.length) {
let ioUsers = req.app.get('io-users')
let io = req.app.get('io')
for (const mention of uniqueMentions) {
let mentionNotification = await Notification.createPostNotification({
usernameTo: mention,
userFrom: user,
type: 'mention',
post
})
if (mentionNotification) {
await mentionNotification.emitNotificationMessage(ioUsers, io)
}
}
}
res.json({success: true})
} catch (e) {
next(e)
}
})
router.all('*', auth, (req, res, next) => {
if(!req.userData.admin) {
res.status(401)
res.json({
errors: [Errors.requestNotAuthorized]
})
} else {
next()
}
})
router.delete('/:post_id', auth, async(req, res, next) => {
try {
if(!req.userData.admin){
res.status(401)
res.json({errors: [Errors.requestNotAuthorized]})
}
let post = await userWall.findById(req.params.post_id)
if(!post) throw Errors.sequelizeValidation(Sequelize, {
error: 'post does not exist',
path: 'id'
})
await post.update({ content: '[This post has been removed by an administrator]', removed: true })
res.json({ success: true })
} catch (e) { next(e) }
})
module.exports = router

View File

@ -112,6 +112,8 @@ app.use('/api/v1/chat/message', require('./routes/message'));
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs, { explorer: true }));
app.use(require('./lib/errorHandler'))
app.use('/api/v1/teams/', require('./routes/team'))
app.use('/api/v1/teams/admin/', require('./routes/team_admin'))
app.use('/api/v1/teams/wall/', require('./routes/team_wall'))
app.use(profanity.init);
app.set('trust proxy', true)
function main () {

View File

@ -696,11 +696,6 @@ chownr@^1.1.1:
resolved "https://npm.open-registry.dev/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
chownr@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
cli-color@^1.2.0:
version "1.4.0"
resolved "https://npm.open-registry.dev/cli-color/-/cli-color-1.4.0.tgz#7d10738f48526824f8fe7da51857cb0f572fe01f"
@ -1896,13 +1891,6 @@ fs-extra@^8.0.1:
jsonfile "^4.0.0"
universalify "^0.1.0"
fs-minipass@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb"
integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==
dependencies:
minipass "^3.0.0"
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://npm.open-registry.dev/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@ -3069,22 +3057,7 @@ minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5:
resolved "https://npm.open-registry.dev/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
minipass@^3.0.0:
version "3.1.3"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd"
integrity sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==
dependencies:
yallist "^4.0.0"
minizlib@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.0.tgz#fd52c645301ef09a63a2c209697c294c6ce02cf3"
integrity sha512-EzTZN/fjSvifSX0SlqUERCN39o6T40AMarPbv0MrarSFtIITCBh7bi+dU8nxGFHuqs9jdIAeoYoKuQAAASsPPA==
dependencies:
minipass "^3.0.0"
yallist "^4.0.0"
mkdirp-classic@^0.5.2:
mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
version "0.5.3"
resolved "https://npm.open-registry.dev/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
@ -3103,7 +3076,7 @@ mkdirp@^0.5.1:
dependencies:
minimist "^1.2.5"
mkdirp@^1.0.3, mkdirp@~1.0.3:
mkdirp@~1.0.3:
version "1.0.4"
resolved "https://npm.open-registry.dev/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
@ -3325,10 +3298,10 @@ node-abi@^2.7.0:
dependencies:
semver "^5.4.1"
node-addon-api@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.0.0.tgz#812446a1001a54f71663bed188314bba07e09247"
integrity sha512-sSHCgWfJ+Lui/u+0msF3oyCgvdkhxDbkCS6Q8uiJquzOimkJBvX6hl5aSSA7DR1XbMpdM8r7phjcF63sF4rkKg==
node-addon-api@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.0.2.tgz#04bc7b83fd845ba785bb6eae25bc857e1ef75681"
integrity sha512-+D4s2HCnxPd5PjjI0STKwncjXTUKKqm74MDMz9OPXavjsGmjkvwgLtA5yoxJUdmpj52+2u+RrXgPipahKczMKg==
node-email-verification@^0.0.0:
version "0.0.0"
@ -3796,16 +3769,16 @@ postgresql@^0.0.1:
dependencies:
empty-dir "^0.1.0"
prebuild-install@^5.3.4:
version "5.3.5"
resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-5.3.5.tgz#e7e71e425298785ea9d22d4f958dbaccf8bb0e1b"
integrity sha512-YmMO7dph9CYKi5IR/BzjOJlRzpxGGVo1EsLSUZ0mt/Mq0HWZIHOKHHcHdT69yG54C9m6i45GpItwRHpk0Py7Uw==
prebuild-install@^5.3.5:
version "5.3.6"
resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-5.3.6.tgz#7c225568d864c71d89d07f8796042733a3f54291"
integrity sha512-s8Aai8++QQGi4sSbs/M1Qku62PFK49Jm1CbgXklGz4nmHveDq0wzJkg7Na5QbnO1uNH8K7iqx2EQ/mV0MZEmOg==
dependencies:
detect-libc "^1.0.3"
expand-template "^2.0.3"
github-from-package "0.0.0"
minimist "^1.2.3"
mkdirp "^0.5.1"
mkdirp-classic "^0.5.3"
napi-build-utils "^1.0.1"
node-abi "^2.7.0"
noop-logger "^0.1.1"
@ -4351,19 +4324,19 @@ setprototypeof@1.2.0:
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
sharp@^0.25.4:
version "0.25.4"
resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.25.4.tgz#1a8e542144a07ab7e9316ab89de80182b827c363"
integrity sha512-umSzJJ1oBwIOfwFFt/fJ7JgCva9FvrEU2cbbm7u/3hSDZhXvkME8WE5qpaJqLIe2Har5msF5UG4CzYlEg5o3BQ==
sharp@^0.26.2:
version "0.26.2"
resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.26.2.tgz#3d5777d246ae32890afe82a783c1cbb98456a88c"
integrity sha512-bGBPCxRAvdK9bX5HokqEYma4j/Q5+w8Nrmb2/sfgQCLEUx/HblcpmOfp59obL3+knIKnOhyKmDb4tEOhvFlp6Q==
dependencies:
color "^3.1.2"
detect-libc "^1.0.3"
node-addon-api "^3.0.0"
node-addon-api "^3.0.2"
npmlog "^4.1.2"
prebuild-install "^5.3.4"
prebuild-install "^5.3.5"
semver "^7.3.2"
simple-get "^4.0.0"
tar "^6.0.2"
tar-fs "^2.1.0"
tunnel-agent "^0.6.0"
shebang-command@^1.2.0:
@ -4851,6 +4824,16 @@ tar-fs@^2.0.0:
pump "^3.0.0"
tar-stream "^2.0.0"
tar-fs@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.0.tgz#d1cdd121ab465ee0eb9ccde2d35049d3f3daf0d5"
integrity sha512-9uW5iDvrIMCVpvasdFHW0wJPez0K4JnMZtsuIeDI7HyMGJNxmDZDOCQROr7lXyS+iL/QMpj07qcjGYTSdRFXUg==
dependencies:
chownr "^1.1.1"
mkdirp-classic "^0.5.2"
pump "^3.0.0"
tar-stream "^2.0.0"
tar-stream@^2.0.0:
version "2.1.2"
resolved "https://npm.open-registry.dev/tar-stream/-/tar-stream-2.1.2.tgz#6d5ef1a7e5783a95ff70b69b97455a5968dc1325"
@ -4862,18 +4845,6 @@ tar-stream@^2.0.0:
inherits "^2.0.3"
readable-stream "^3.1.1"
tar@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.2.tgz#5df17813468a6264ff14f766886c622b84ae2f39"
integrity sha512-Glo3jkRtPcvpDlAs/0+hozav78yoXKFr+c4wgw62NNMO3oo4AaJdCo21Uu7lcwr55h39W2XD1LMERc64wtbItg==
dependencies:
chownr "^2.0.0"
fs-minipass "^2.0.0"
minipass "^3.0.0"
minizlib "^2.1.0"
mkdirp "^1.0.3"
yallist "^4.0.0"
terraformer-wkt-parser@^1.1.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/terraformer-wkt-parser/-/terraformer-wkt-parser-1.2.1.tgz#8041e2aeb0c9f2b4cbbec8ec2c5c00c45ddfee02"
@ -5343,11 +5314,6 @@ yallist@^3.0.2:
resolved "https://npm.open-registry.dev/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
yallist@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
yargs-parser@^13.1.2:
version "13.1.2"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38"