Merge branch 'main' of github.com:specklesystems/speckle-server into user_admin_features
This commit is contained in:
Generated
+17211
-12
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,10 @@
|
||||
<v-card class="my-4 elevation-1" :loading="$apollo.loading">
|
||||
<div v-if="!$apollo.loading && file">
|
||||
<v-toolbar dense flat color="transparent">
|
||||
<v-app-bar-nav-icon>
|
||||
<v-app-bar-nav-icon
|
||||
v-tooltip="`Download the original file`"
|
||||
@click="downloadOriginalFile()"
|
||||
>
|
||||
<v-icon>mdi-download</v-icon>
|
||||
</v-app-bar-nav-icon>
|
||||
<v-toolbar-title>
|
||||
@@ -104,6 +107,24 @@ export default {
|
||||
mounted() {
|
||||
this.$apollo.queries.file.startPolling(1000)
|
||||
},
|
||||
methods: {}
|
||||
methods: {
|
||||
async downloadOriginalFile() {
|
||||
let res = await fetch(`/api/file/${this.fileId}`, {
|
||||
headers: {
|
||||
Authorization: localStorage.getItem('AuthToken')
|
||||
}
|
||||
})
|
||||
let blob = await res.blob()
|
||||
let file = window.URL.createObjectURL(blob)
|
||||
|
||||
let a = document.createElement('a')
|
||||
document.body.appendChild(a)
|
||||
a.style = 'display: none'
|
||||
a.href = file
|
||||
a.download = this.file.fileName
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,47 +9,37 @@
|
||||
<span class="caption">{{ file.size }}kb</span>
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn @click="upload()" color="primary">Upload</v-btn>
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ attrs, on }">
|
||||
<v-btn v-tooltip="`Change the branch to upload to`" text v-bind="attrs" v-on="on">
|
||||
<v-icon small>mdi-source-branch</v-icon>
|
||||
<span class="caption">{{ selectedBranch }}</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="item in branches.filter((b) => b.name != 'globals')"
|
||||
:key="item.name"
|
||||
link
|
||||
@click="selectedBranch = item.name"
|
||||
>
|
||||
<v-list-item-title class="caption">{{ item.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn color="primary" @click="upload()">Upload</v-btn>
|
||||
</v-toolbar>
|
||||
<v-alert v-if="error" type="error" dismissible>An error occurred.</v-alert>
|
||||
</v-card>
|
||||
</template>
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export default {
|
||||
props: ['file'],
|
||||
props: ['file', 'branches'],
|
||||
data: () => ({
|
||||
percentCompleted: -1,
|
||||
error: null
|
||||
error: null,
|
||||
selectedBranch: 'main'
|
||||
}),
|
||||
apollo: {
|
||||
streams: {
|
||||
query: gql`
|
||||
query Streams($query: String) {
|
||||
streams(query: $query) {
|
||||
totalCount
|
||||
cursor
|
||||
items {
|
||||
id
|
||||
name
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables() {
|
||||
return {
|
||||
query: this.search
|
||||
}
|
||||
},
|
||||
skip() {
|
||||
return !this.search || this.search.length < 3
|
||||
},
|
||||
debounce: 300
|
||||
}
|
||||
},
|
||||
watch: {},
|
||||
methods: {
|
||||
upload() {
|
||||
let data = new FormData()
|
||||
@@ -57,14 +47,19 @@ export default {
|
||||
data.append('file', this.file)
|
||||
|
||||
let request = new XMLHttpRequest()
|
||||
request.open('POST', `/api/file/ifc/${this.$route.params.streamId}`)
|
||||
request.open(
|
||||
'POST',
|
||||
`/api/file/ifc/${this.$route.params.streamId}/${
|
||||
this.selectedBranch ? this.selectedBranch : 'main'
|
||||
}`
|
||||
)
|
||||
request.setRequestHeader('Authorization', `Bearer ${localStorage.getItem('AuthToken')}`)
|
||||
|
||||
request.upload.addEventListener(
|
||||
'progress',
|
||||
function (e) {
|
||||
this.percentCompleted = (e.loaded / e.total) * 100
|
||||
if (this.percentCompleted >= 100) {
|
||||
if (this.percentCompleted >= 100) {
|
||||
this.$emit('done', this.file.name)
|
||||
}
|
||||
}.bind(this)
|
||||
|
||||
@@ -39,7 +39,11 @@
|
||||
<v-dialog v-model="liff" max-width="400" :fullscreen="$vuetify.breakpoint.xsOnly">
|
||||
<v-card>
|
||||
<v-toolbar>
|
||||
<v-toolbar-title>thanks for all the fish <v-icon>mdi-fish</v-icon><v-icon>mdi-arrow-up</v-icon></v-toolbar-title>
|
||||
<v-toolbar-title>
|
||||
thanks for all the fish
|
||||
<v-icon>mdi-fish</v-icon>
|
||||
<v-icon>mdi-arrow-up</v-icon>
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon @click="liff = false"><v-icon>mdi-close</v-icon></v-btn>
|
||||
</v-toolbar>
|
||||
@@ -51,6 +55,12 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
gotostreamonclick: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
search: '',
|
||||
liff: false,
|
||||
@@ -85,12 +95,16 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
selectedSearchResult(val) {
|
||||
this.search = ''
|
||||
let myStream = this.streams.items.find((s) => s.id === val.id)
|
||||
this.$emit('select', myStream)
|
||||
|
||||
this.streams.items = []
|
||||
if (val) this.$router.push({ name: 'stream', params: { streamId: val.id } })
|
||||
this.search = ''
|
||||
|
||||
if (val && this.gotostreamonclick)
|
||||
this.$router.push({ name: 'stream', params: { stream: val } })
|
||||
},
|
||||
search(val) {
|
||||
console.log(val)
|
||||
if (val === '42') this.liff = true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -69,6 +69,8 @@ export default {
|
||||
showDelete: false,
|
||||
nameRules: [
|
||||
(v) => !!v || 'Branches need a name too!',
|
||||
(v) =>
|
||||
!(v.startsWith('#') || v.startsWith('/')) || 'Branch names cannot start with "#" or "/"',
|
||||
(v) =>
|
||||
(v && this.allBranchNames.findIndex((e) => e === v) === -1) ||
|
||||
'A branch with this name already exists',
|
||||
|
||||
@@ -42,18 +42,17 @@ export default {
|
||||
showError: false,
|
||||
error: null,
|
||||
streamId: null,
|
||||
branchNames: ['main', 'globals'],
|
||||
reservedBranchNames: ['main', 'globals'],
|
||||
valid: false,
|
||||
loading: false,
|
||||
name: null,
|
||||
nameRules: [
|
||||
(v) => !!v || 'Branches need a name too!',
|
||||
(v) =>
|
||||
(v && !v.startsWith('globals')) ||
|
||||
'Globals is a reserved branch name. Please choose a different name.',
|
||||
!(v.startsWith('#') || v.startsWith('/')) || 'Branch names cannot start with "#" or "/"',
|
||||
(v) =>
|
||||
(v && this.branchNames.findIndex((e) => e === v) === -1) ||
|
||||
'A branch with this name already exists',
|
||||
(v && this.reservedBranchNames.findIndex((e) => e === v) === -1) ||
|
||||
'This is a reserved branch name',
|
||||
(v) => (v && v.length <= 100) || 'Name must be less than 100 characters',
|
||||
(v) => (v && v.length >= 3) || 'Name must be at least 3 characters'
|
||||
],
|
||||
@@ -61,6 +60,11 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
watch: {
|
||||
name(val) {
|
||||
this.name = val.toLowerCase()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
show() {
|
||||
this.showDialog = true
|
||||
@@ -80,7 +84,7 @@ export default {
|
||||
variables: {
|
||||
params: {
|
||||
streamId: this.$route.params.streamId,
|
||||
name: this.name,
|
||||
name: this.name.toLowerCase(),
|
||||
description: this.description
|
||||
}
|
||||
}
|
||||
@@ -90,7 +94,9 @@ export default {
|
||||
this.loading = false
|
||||
this.showDialog = false
|
||||
this.$emit('refetch-branches')
|
||||
this.$router.push(`/streams/${this.$route.params.streamId}/branches/${this.name}`)
|
||||
this.$router.push(
|
||||
`/streams/${this.$route.params.streamId}/branches/${this.name.toLowerCase()}`
|
||||
)
|
||||
} catch (err) {
|
||||
this.showError = true
|
||||
if (err.message.includes('branches_streamid_name_unique'))
|
||||
|
||||
@@ -57,7 +57,7 @@ const routes = [
|
||||
meta: {
|
||||
title: 'Home | Speckle'
|
||||
},
|
||||
component: () => import('@/views/Frontend_re.vue'),
|
||||
component: () => import('@/views/Frontend.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -80,7 +80,7 @@ const routes = [
|
||||
meta: {
|
||||
title: 'Stream | Speckle'
|
||||
},
|
||||
component: () => import('@/views/stream/Stream_re_re.vue'),
|
||||
component: () => import('@/views/stream/Stream.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -88,7 +88,7 @@ const routes = [
|
||||
meta: {
|
||||
title: 'Stream | Speckle'
|
||||
},
|
||||
component: () => import('@/views/stream/Details_re.vue')
|
||||
component: () => import('@/views/stream/Details.vue')
|
||||
},
|
||||
|
||||
{
|
||||
@@ -102,7 +102,14 @@ const routes = [
|
||||
meta: {
|
||||
title: 'Branch | Speckle'
|
||||
},
|
||||
component: () => import('@/views/stream/Branch.vue')
|
||||
component: () => import('@/views/stream/Branch.vue'),
|
||||
beforeEnter: (to, from, next) => {
|
||||
if (to.params.branchName.toLowerCase() !== to.params.branchName)
|
||||
return next(
|
||||
`/streams/${to.params.streamId}/branches/${to.params.branchName.toLowerCase()}`
|
||||
)
|
||||
else next()
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'commits/:commitId',
|
||||
@@ -228,6 +235,11 @@ const routes = [
|
||||
name: 'Admin | Settings',
|
||||
path: 'settings',
|
||||
component: () => import('@/views/admin/AdminSettings.vue')
|
||||
},
|
||||
{
|
||||
name: 'Admin | Invites',
|
||||
path: 'invites',
|
||||
component: () => import('@/views/admin/AdminInvites.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -263,7 +275,7 @@ const router = new VueRouter({
|
||||
mode: 'history',
|
||||
// base: process.env.BASE_URL,
|
||||
routes,
|
||||
scrollBehavior (to, from, savedPosition) {
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
+26
-4
@@ -251,18 +251,32 @@
|
||||
<router-view></router-view>
|
||||
</transition>
|
||||
</v-main>
|
||||
<v-snackbar
|
||||
v-model="streamSnackbar"
|
||||
rounded="pill"
|
||||
:timeout="10000"
|
||||
style="z-index: 10000"
|
||||
:color="`${$vuetify.theme.dark ? 'primary' : 'primary'}`"
|
||||
>
|
||||
<span v-if="streamSnackbarInfo.sharedBy">You have been granted access to a new stream!</span>
|
||||
<span v-else>New stream created!</span>
|
||||
<template #action="{ attrs }">
|
||||
<v-btn color="white" text v-bind="attrs" @click="goToStreamAndCloseSnackbar()">View</v-btn>
|
||||
<v-btn color="pink" icon v-bind="attrs" @click="streamSnackbar = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-app>
|
||||
</template>
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import { signOut } from '@/auth-helpers'
|
||||
import userQuery from '../graphql/user.gql'
|
||||
import SearchBar from '../components/SearchBar'
|
||||
import StreamInviteDialog from '../components/dialogs/StreamInviteDialog'
|
||||
import UserAvatarIcon from '@/components/UserAvatarIcon'
|
||||
|
||||
export default {
|
||||
components: { UserAvatarIcon, SearchBar, StreamInviteDialog },
|
||||
components: { UserAvatarIcon },
|
||||
data() {
|
||||
return {
|
||||
streamSnackbar: false,
|
||||
@@ -298,6 +312,7 @@ export default {
|
||||
}
|
||||
`,
|
||||
result(streamInfo) {
|
||||
console.log(streamInfo)
|
||||
if (!streamInfo.data.userStreamAdded) return
|
||||
this.streamSnackbar = true
|
||||
this.streamSnackbarInfo = streamInfo.data.userStreamAdded
|
||||
@@ -321,7 +336,10 @@ export default {
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
$route(to) {
|
||||
this.bottomSheet = false
|
||||
// close the snackbar if it's a stream create event in this window
|
||||
if (to.params.streamId === this.streamSnackbarInfo.id) this.streamSnackbar = false
|
||||
this.bottomSheet = false
|
||||
}
|
||||
},
|
||||
@@ -335,6 +353,10 @@ export default {
|
||||
},
|
||||
showStreamInviteDialog() {
|
||||
this.$refs.streamInviteDialog.show()
|
||||
},
|
||||
goToStreamAndCloseSnackbar() {
|
||||
this.streamSnackbar = false
|
||||
this.$router.push(`/streams/${this.streamSnackbarInfo.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@
|
||||
pr-0
|
||||
>
|
||||
<v-navigation-drawer
|
||||
v-model="streamNav"
|
||||
app
|
||||
fixed
|
||||
:permanent="streamNav && !$vuetify.breakpoint.smAndDown"
|
||||
v-model="streamNav"
|
||||
:style="`${!$vuetify.breakpoint.xsOnly ? 'left: 56px' : ''}`"
|
||||
>
|
||||
<main-nav-actions :open-new-stream="newStreamDialog" />
|
||||
@@ -51,12 +51,12 @@
|
||||
<v-subheader class="ml-2">Your latest commits:</v-subheader>
|
||||
<v-list-item
|
||||
v-for="(commit, i) in userCommits.commits.items"
|
||||
v-if="commit"
|
||||
:key="i"
|
||||
v-tooltip="`In stream '${commit.streamName}'`"
|
||||
:to="`streams/${commit.streamId}/${
|
||||
commit.branchName === 'globals' ? 'globals' : 'commits'
|
||||
}/${commit.id}`"
|
||||
v-if="commit"
|
||||
v-tooltip="`In stream '${commit.streamName}'`"
|
||||
>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
@@ -84,12 +84,8 @@
|
||||
Streams
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-toolbar-items style="margin-right: -18px" v-if="$vuetify.breakpoint.smAndDown">
|
||||
<v-btn
|
||||
color="primary"
|
||||
depressed
|
||||
@click="newStreamDialog++"
|
||||
>
|
||||
<v-toolbar-items v-if="$vuetify.breakpoint.smAndDown" style="margin-right: -18px">
|
||||
<v-btn color="primary" depressed @click="newStreamDialog++">
|
||||
<v-icon>mdi-plus-box</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar-items>
|
||||
@@ -107,7 +103,7 @@
|
||||
<div v-if="$apollo.loading" class="my-5"></div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" v-else-if="streams && streams.items && streams.items.length > 0">
|
||||
<v-col v-else-if="streams && streams.items && streams.items.length > 0" cols="12">
|
||||
<v-row :class="`${$vuetify.breakpoint.xsOnly ? '' : 'pl-2'}`">
|
||||
<v-col
|
||||
v-for="(stream, i) in streams.items"
|
||||
@@ -137,7 +133,7 @@
|
||||
here.
|
||||
</p>
|
||||
|
||||
<template v-slot:actions>
|
||||
<template #actions>
|
||||
<v-list rounded class="transparent">
|
||||
<v-list-item link class="primary mb-4" dark @click="newStreamDialog++">
|
||||
<v-list-item-icon>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<template>
|
||||
<v-container :style="`${ !$vuetify.breakpoint.xsOnly ? 'padding-left: 56px' : ''}`" fluid pt-4 pr-0>
|
||||
<v-container
|
||||
:style="`${!$vuetify.breakpoint.xsOnly ? 'padding-left: 56px' : ''}`"
|
||||
fluid
|
||||
pt-4
|
||||
pr-0
|
||||
>
|
||||
<v-navigation-drawer
|
||||
v-model="activityNav"
|
||||
app
|
||||
fixed
|
||||
:permanent="activityNav && !$vuetify.breakpoint.smAndDown"
|
||||
v-model="activityNav"
|
||||
:style="`${ !$vuetify.breakpoint.xsOnly ? 'left: 56px' : ''}`"
|
||||
:style="`${!$vuetify.breakpoint.xsOnly ? 'left: 56px' : ''}`"
|
||||
>
|
||||
<main-nav-actions :open-new-stream="newStreamDialog" />
|
||||
|
||||
@@ -13,9 +18,9 @@
|
||||
<v-subheader class="mt-3 ml-2">Recently updated streams</v-subheader>
|
||||
<v-list-item
|
||||
v-for="(s, i) in streams.items"
|
||||
v-if="streams.items"
|
||||
:key="i"
|
||||
:to="'streams/' + s.id"
|
||||
v-if="streams.items"
|
||||
>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
@@ -32,16 +37,14 @@
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-app-bar app :style="`${ !$vuetify.breakpoint.xsOnly ? 'padding-left: 56px' : ''}`" flat>
|
||||
<v-app-bar-nav-icon
|
||||
@click="activityNav = !activityNav"
|
||||
></v-app-bar-nav-icon>
|
||||
<v-app-bar app :style="`${!$vuetify.breakpoint.xsOnly ? 'padding-left: 56px' : ''}`" flat>
|
||||
<v-app-bar-nav-icon @click="activityNav = !activityNav"></v-app-bar-nav-icon>
|
||||
<v-toolbar-title class="space-grotesk pl-0">
|
||||
<v-icon class="hidden-xs-only">mdi-clock-fast</v-icon>
|
||||
Feed
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-toolbar-items style="margin-right: -18px;" v-if="$vuetify.breakpoint.smAndDown ">
|
||||
<v-toolbar-items v-if="$vuetify.breakpoint.smAndDown" style="margin-right: -18px">
|
||||
<v-btn color="primary" depressed @click="newStreamDialog++">
|
||||
<v-icon>mdi-plus-box</v-icon>
|
||||
</v-btn>
|
||||
@@ -59,7 +62,13 @@
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" lg="8" v-else-if="timeline && timeline.items.length > 0" class="pr-2" :style="`${$vuetify.breakpoint.xsOnly ? 'margin-left: -20px;' :''}`">
|
||||
<v-col
|
||||
v-else-if="timeline && timeline.items.length > 0"
|
||||
cols="12"
|
||||
lg="8"
|
||||
class="pr-2"
|
||||
:style="`${$vuetify.breakpoint.xsOnly ? 'margin-left: -20px;' : ''}`"
|
||||
>
|
||||
<div>
|
||||
<div v-if="timeline" key="activity-list">
|
||||
<v-timeline align-top dense>
|
||||
@@ -83,20 +92,15 @@
|
||||
</v-col>
|
||||
<v-col v-else cols="12">
|
||||
<no-data-placeholder v-if="quickUser">
|
||||
<h2>Welcome {{quickUser.name.split(' ')[0]}}!</h2>
|
||||
<h2>Welcome {{ quickUser.name.split(' ')[0] }}!</h2>
|
||||
<p class="caption">
|
||||
Once you will create a stream and start sending some data, your activity will show up
|
||||
here.
|
||||
</p>
|
||||
|
||||
<template v-slot:actions>
|
||||
<template #actions>
|
||||
<v-list rounded class="transparent">
|
||||
<v-list-item
|
||||
link
|
||||
class="primary mb-4"
|
||||
dark
|
||||
@click="newStreamDialog++"
|
||||
>
|
||||
<v-list-item link class="primary mb-4" dark @click="newStreamDialog++">
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-plus-box</v-icon>
|
||||
</v-list-item-icon>
|
||||
@@ -113,11 +117,11 @@
|
||||
</v-col>
|
||||
|
||||
<v-col
|
||||
v-show="$vuetify.breakpoint.lgAndUp"
|
||||
v-if="timeline && timeline.items.length > 0"
|
||||
cols="12"
|
||||
lg="4"
|
||||
v-show="$vuetify.breakpoint.lgAndUp"
|
||||
class="mt-7"
|
||||
v-if="timeline && timeline.items.length > 0"
|
||||
>
|
||||
<latest-blogposts></latest-blogposts>
|
||||
<v-card rounded="lg" class="mt-2">
|
||||
@@ -170,18 +174,16 @@ export default {
|
||||
activityNav: true
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
mounted() {
|
||||
setTimeout(
|
||||
function () {
|
||||
this.activityNav = !this.$vuetify.breakpoint.smAndDown
|
||||
}.bind(this),
|
||||
10
|
||||
)
|
||||
},
|
||||
apollo: {
|
||||
quickUser: {
|
||||
query: gql`query { quickUser: user { id name } } `
|
||||
query: gql`
|
||||
query {
|
||||
quickUser: user {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`
|
||||
},
|
||||
timeline: {
|
||||
query: gql`
|
||||
@@ -217,7 +219,7 @@ export default {
|
||||
prefetch: true,
|
||||
query: gql`
|
||||
query {
|
||||
streams (limit: 10) {
|
||||
streams(limit: 10) {
|
||||
items {
|
||||
id
|
||||
name
|
||||
@@ -228,9 +230,25 @@ export default {
|
||||
`
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
watch: {
|
||||
timeline(val) {
|
||||
if (val.totalCount === 0 && !localStorage.getItem('onboarding')) {
|
||||
this.$router.push('/onboarding')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
setTimeout(
|
||||
function () {
|
||||
this.activityNav = !this.$vuetify.breakpoint.smAndDown
|
||||
}.bind(this),
|
||||
10
|
||||
)
|
||||
},
|
||||
methods: {
|
||||
groupSimilarActivities(data) {
|
||||
if(!data) return
|
||||
if (!data) return
|
||||
let groupedTimeline = data.user.timeline.items.reduce(function (prev, curr) {
|
||||
//first item
|
||||
if (!prev.length) {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<template lang="html">
|
||||
|
||||
<v-container v-if="$apollo.loading" fluid>
|
||||
<v-skeleton-loader type="article"></v-skeleton-loader>
|
||||
</v-container>
|
||||
|
||||
<v-container v-else-if="isAdmin" :class="`${$vuetify.breakpoint.xsOnly ? 'pl-0' : ''}`">
|
||||
<v-navigation-drawer
|
||||
v-model="adminNav"
|
||||
app
|
||||
fixed
|
||||
:permanent="adminNav && !$vuetify.breakpoint.smAndDown"
|
||||
v-model="adminNav"
|
||||
:style="`${ !$vuetify.breakpoint.xsOnly ? 'left: 56px' : ''}`"
|
||||
:style="`${!$vuetify.breakpoint.xsOnly ? 'left: 56px' : ''}`"
|
||||
>
|
||||
<v-app-bar style="position: absolute; top: 0; width: 100%; z-index: 90" elevation="0">
|
||||
<v-toolbar-title>Server Admin</v-toolbar-title>
|
||||
@@ -50,29 +49,44 @@
|
||||
<v-list-item-subtitle class="caption">Edit server user details.</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item link to="/admin/invites">
|
||||
<v-list-item-icon>
|
||||
<v-icon small class="mt-1">mdi-account-multiple-plus-outline</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Server invites</v-list-item-title>
|
||||
<v-list-item-subtitle class="caption">Manage server invitations.</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-app-bar app :style="`${ !$vuetify.breakpoint.xsOnly ? 'padding-left: 56px' : ''}`" flat v-if="!adminNav">
|
||||
<v-app-bar-nav-icon @click="adminNav = !adminNav" v-if="!adminNav"/>
|
||||
<v-toolbar-title v-if="!adminNav">
|
||||
Server Admin
|
||||
</v-toolbar-title>
|
||||
<v-app-bar
|
||||
v-if="!adminNav"
|
||||
app
|
||||
:style="`${!$vuetify.breakpoint.xsOnly ? 'padding-left: 56px' : ''}`"
|
||||
flat
|
||||
>
|
||||
<v-app-bar-nav-icon v-if="!adminNav" @click="adminNav = !adminNav" />
|
||||
<v-toolbar-title v-if="!adminNav">Server Admin</v-toolbar-title>
|
||||
</v-app-bar>
|
||||
|
||||
<v-container :style="`${ !$vuetify.breakpoint.xsOnly ? 'padding-left: 56px;' : ''} max-width: 1024px;`" fluid pt-4 pr-0>
|
||||
<v-container
|
||||
:style="`${!$vuetify.breakpoint.xsOnly ? 'padding-left: 56px;' : ''} max-width: 1024px;`"
|
||||
fluid
|
||||
pt-4
|
||||
pr-0
|
||||
>
|
||||
<transition name="fade">
|
||||
<router-view></router-view>
|
||||
</transition>
|
||||
</v-container>
|
||||
</v-container>
|
||||
|
||||
<v-container v-else-if="!isAdmin">
|
||||
<error-placeholder error-type="access">
|
||||
<h2>Only server admins have access to this section.</h2>
|
||||
</error-placeholder>
|
||||
</v-container>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -108,5 +122,3 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-toolbar flat>
|
||||
<v-toolbar-title>Send invites to multiple adresses</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
<v-alert v-model="success" prominent timeout="3000" dismissible type="success">
|
||||
Great! All invites were sent.
|
||||
</v-alert>
|
||||
<v-alert v-show="errors.length !== 0" prominent dismissible type="error">
|
||||
<p>Invite send failed for adresses:</p>
|
||||
<ul>
|
||||
<li v-for="error in errors" :key="error.email">{{ error.email }}: {{ error.reason }}</li>
|
||||
</ul>
|
||||
</v-alert>
|
||||
<v-alert
|
||||
v-show="errors.length !== 0 && sentToEmails.length !== 0"
|
||||
prominent
|
||||
timeout="3000"
|
||||
dismissible
|
||||
type="success"
|
||||
>
|
||||
<p>Invite sent to: {{ sentToEmails.join(', ') }}</p>
|
||||
</v-alert>
|
||||
<v-form v-model="valid" @submit.prevent="submit">
|
||||
<v-textarea
|
||||
v-model="invitation"
|
||||
label="Invitation message"
|
||||
rounded
|
||||
filled
|
||||
auto-grow
|
||||
:rules="validation.messageRules"
|
||||
></v-textarea>
|
||||
<v-combobox
|
||||
v-model="chips"
|
||||
:search-input.sync="emails"
|
||||
placeholder="Type emails separated by commas or paste the content of a .csv"
|
||||
deletable-chips
|
||||
append-icon=""
|
||||
filled
|
||||
rounded
|
||||
flat
|
||||
type="email"
|
||||
class="lighten-2"
|
||||
:error-messages="inputErrors"
|
||||
multiple
|
||||
append-outer-icon="mdi-close"
|
||||
@keydown="keyDownHandler"
|
||||
@blur="validateAndCreateChips"
|
||||
@paste="validateAndCreateChips"
|
||||
@click:append-outer="chips = []"
|
||||
>
|
||||
<template #selection="data">
|
||||
<v-chip
|
||||
v-if="data.item"
|
||||
:input-value="data.selected"
|
||||
close
|
||||
@click:close="remove(data.item)"
|
||||
>
|
||||
{{ data.item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-combobox>
|
||||
<p v-if="!selectedStream">Optionaly invite users to stream.</p>
|
||||
<stream-search-bar
|
||||
v-if="!selectedStream"
|
||||
:gotostreamonclick="false"
|
||||
class="py-3"
|
||||
@select="setStream"
|
||||
/>
|
||||
<v-alert v-else text dense type="info" dismissible @input="dismiss">
|
||||
They will be invited to be collaborators on {{ selectedStream.name }} stream.
|
||||
</v-alert>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn :disabled="!submitable" color="primary" type="submit">Invite</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-overlay absolute :value="submitting">
|
||||
<v-progress-circular :width="1.5" indeterminate></v-progress-circular>
|
||||
</v-overlay>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import DOMPurify from 'dompurify'
|
||||
import StreamSearchBar from '@/components/SearchBar'
|
||||
export default {
|
||||
components: { StreamSearchBar },
|
||||
data() {
|
||||
return {
|
||||
valid: false,
|
||||
success: false,
|
||||
showError: false,
|
||||
errors: [],
|
||||
sentToEmails: [],
|
||||
submitting: false,
|
||||
invitation: '',
|
||||
emails: '',
|
||||
chips: [],
|
||||
inputErrors: [],
|
||||
selectedStream: null,
|
||||
validation: {
|
||||
messageRules: [
|
||||
(v) => {
|
||||
if (v.length >= 1024) return 'Message too long!'
|
||||
return true
|
||||
},
|
||||
(v) => {
|
||||
let pure = DOMPurify.sanitize(v)
|
||||
if (pure !== v) return 'No crazy hacks please.'
|
||||
else return true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
submitable() {
|
||||
return this.chips && this.chips.length !== 0
|
||||
}
|
||||
},
|
||||
apollo: {
|
||||
user: {
|
||||
query: gql`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
prefetch: true
|
||||
},
|
||||
serverInfo: {
|
||||
query: gql`
|
||||
query {
|
||||
serverInfo {
|
||||
name
|
||||
canonicalUrl
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setStream(stream) {
|
||||
this.selectedStream = stream
|
||||
},
|
||||
dismiss() {
|
||||
this.selectedStream = null
|
||||
},
|
||||
remove(item) {
|
||||
this.chips.splice(this.chips.indexOf(item), 1)
|
||||
},
|
||||
validateEmail(email) {
|
||||
const re = /^\S+@\S+\.\S+$/
|
||||
return re.test(email)
|
||||
},
|
||||
keyDownHandler(val) {
|
||||
if (!(val.key === ' ' || val.key === ',' || val.key === 'Enter')) return
|
||||
this.validateAndCreateChips()
|
||||
},
|
||||
validateAndCreateChips() {
|
||||
this.inputErrors = []
|
||||
if (!this.emails || this.emails === '') return
|
||||
let splitEmails = this.emails.split(/[ ,]+/)
|
||||
for (let email of splitEmails) {
|
||||
let valid = this.validateEmail(email) && this.chips.indexOf(email) === -1
|
||||
if (valid) {
|
||||
this.chips.push(email)
|
||||
} else {
|
||||
this.inputErrors.push('Invalid email')
|
||||
}
|
||||
}
|
||||
this.emails = ''
|
||||
},
|
||||
createInviteMessage() {
|
||||
let message =
|
||||
`You have been invited to a Speckle server: ${this.serverInfo.name} ` +
|
||||
`by ${this.user.name}. Visit ${this.serverInfo.canonicalUrl} to register.`
|
||||
return this.invitation || message
|
||||
},
|
||||
async submit() {
|
||||
this.submitting = true
|
||||
this.errors = []
|
||||
this.sentToEmails = []
|
||||
for (let chip of this.chips) {
|
||||
if (!chip || chip.length === 0) continue
|
||||
try {
|
||||
await this.sendInvite(chip, this.createInviteMessage(), this.selectedStream?.id)
|
||||
this.sentToEmails.push(chip)
|
||||
} catch (err) {
|
||||
this.errors.push({ email: chip, reason: err.graphQLErrors[0].message })
|
||||
}
|
||||
}
|
||||
|
||||
this.submitting = false
|
||||
if (this.errors.length === 0) {
|
||||
this.success = true
|
||||
this.chips = []
|
||||
this.dismiss()
|
||||
}
|
||||
},
|
||||
async sendInvite(email, message, streamId) {
|
||||
let input = {
|
||||
email: email,
|
||||
message: message
|
||||
}
|
||||
|
||||
let query = gql`
|
||||
mutation($input: ${streamId ? 'StreamInviteCreateInput!' : 'ServerInviteCreateInput!'}) {
|
||||
${streamId ? 'streamInviteCreate' : 'serverInviteCreate'}(input: $input)
|
||||
}
|
||||
`
|
||||
if (streamId) {
|
||||
input.streamId = streamId
|
||||
}
|
||||
|
||||
await this.$apollo.mutate({
|
||||
mutation: query,
|
||||
variables: {
|
||||
input: input
|
||||
}
|
||||
})
|
||||
|
||||
this.$matomo && this.$matomo.trackEvent('invite', 'server')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+60
-6
@@ -447,6 +447,27 @@
|
||||
:stream-id="$route.params.streamId"
|
||||
:stream-name="stream.name"
|
||||
/>
|
||||
<v-snackbar
|
||||
v-model="snackbar"
|
||||
rounded="pill"
|
||||
:timeout="10000"
|
||||
style="z-index: 10000"
|
||||
:color="`${$vuetify.theme.dark ? 'primary' : 'primary'}`"
|
||||
>
|
||||
<template v-if="snackbarInfo.type === 'commit'">
|
||||
<span>New commit created!</span>
|
||||
</template>
|
||||
<template v-if="snackbarInfo.type === 'branch'">
|
||||
<span>Branch "{{ snackbarInfo.name }}" created!</span>
|
||||
</template>
|
||||
|
||||
<template #action="{ attrs }">
|
||||
<v-btn color="white" text v-bind="attrs" @click="goToItemAndCloseSnackbar()">View</v-btn>
|
||||
<v-btn color="pink" icon v-bind="attrs" @click="snackbar = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -465,8 +486,8 @@ export default {
|
||||
return {
|
||||
streamNav: true,
|
||||
error: '',
|
||||
commitSnackbar: false,
|
||||
commitSnackbarInfo: {},
|
||||
snackbar: false,
|
||||
snackbarInfo: {},
|
||||
editStreamDialog: false,
|
||||
shareStream: false,
|
||||
branchMenuOpen: false,
|
||||
@@ -533,6 +554,26 @@ export default {
|
||||
}
|
||||
},
|
||||
$subscribe: {
|
||||
branchCreated: {
|
||||
query: gql`
|
||||
subscription($streamId: String!) {
|
||||
branchCreated(streamId: $streamId)
|
||||
}
|
||||
`,
|
||||
variables() {
|
||||
return {
|
||||
streamId: this.$route.params.streamId
|
||||
}
|
||||
},
|
||||
result(args) {
|
||||
if (!args.data.branchCreated) return
|
||||
this.snackbar = true
|
||||
this.snackbarInfo = { ...args.data.branchCreated, type: 'branch' }
|
||||
},
|
||||
skip() {
|
||||
return !this.loggedIn
|
||||
}
|
||||
},
|
||||
commitCreated: {
|
||||
query: gql`
|
||||
subscription($streamId: String!) {
|
||||
@@ -546,8 +587,9 @@ export default {
|
||||
},
|
||||
result(commitInfo) {
|
||||
if (!commitInfo.data.commitCreated) return
|
||||
this.commitSnackbar = true
|
||||
this.commitSnackbarInfo = commitInfo.data.commitCreated
|
||||
console.log(commitInfo)
|
||||
this.snackbar = true
|
||||
this.snackbarInfo = { ...commitInfo.data.commitCreated, type: 'commit' }
|
||||
},
|
||||
skip() {
|
||||
return !this.loggedIn
|
||||
@@ -589,12 +631,13 @@ export default {
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
$route() {
|
||||
// Ensures branch menu is open when navigating to a branch url
|
||||
if (this.$route.name.toLowerCase().includes('branch') && !this.branchMenuOpen)
|
||||
this.branchMenuOpen = true
|
||||
|
||||
// closes any share dialog
|
||||
this.shareStream = false
|
||||
this.snackbar = false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -615,6 +658,17 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goToItemAndCloseSnackbar() {
|
||||
if (this.snackbarInfo.type === 'commit') {
|
||||
this.$router.push(`/streams/${this.$route.params.streamId}/commits/${this.snackbarInfo.id}`)
|
||||
} else if (this.snackbarInfo.type === 'branch') {
|
||||
this.$router.push(
|
||||
`/streams/${this.$route.params.streamId}/branches/${this.snackbarInfo.name}`
|
||||
)
|
||||
this.refetchBranches()
|
||||
}
|
||||
this.snackbar = false
|
||||
},
|
||||
copyToClipboard(e) {
|
||||
e.target.select()
|
||||
document.execCommand('copy')
|
||||
@@ -1,47 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<no-data-placeholder v-if="false" :show-image="false">
|
||||
<h2>Import IFC Files</h2>
|
||||
<p class="caption">
|
||||
Speckle can now process IFC files and store them as a commit (snapshot). You can then access
|
||||
it from the Speckle API, and receive it in other applications.
|
||||
</p>
|
||||
<template #actions>
|
||||
<v-list rounded class="transparent">
|
||||
<v-list-item link class="primary mb-4" dark @click="showUploadDialog = true">
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-plus-box</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Upload IFC File</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
link
|
||||
:class="`grey ${$vuetify.theme.dark ? 'darken-4' : 'lighten-4'} mb-4`"
|
||||
href="https://speckle.guide/dev/server-webhooks.html"
|
||||
target="_blank"
|
||||
>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-book-open-variant</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Release Announcement</v-list-item-title>
|
||||
<v-list-item-subtitle class="caption"></v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
</no-data-placeholder>
|
||||
|
||||
<error-placeholder v-if="error" error-type="access">
|
||||
<h2>Only stream owners can access webhooks.</h2>
|
||||
<p class="caption">
|
||||
If you need to use webhooks, ask the stream's owner to grant you ownership.
|
||||
</p>
|
||||
</error-placeholder>
|
||||
|
||||
<v-container style="max-width: 768px">
|
||||
<portal to="streamTitleBar">
|
||||
<div>
|
||||
@@ -71,63 +29,70 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card
|
||||
elevation="0"
|
||||
color="transparent"
|
||||
class=""
|
||||
style="height: 220px; transition: all 0.2s ease"
|
||||
:class="`mt-4 mb-4 d-flex justify-center
|
||||
<v-alert v-if="stream && (stream.role === 'stream:reviewer' || !stream.role)" type="warning">
|
||||
Your permission level ({{ stream.role ? stream.role : 'none' }}) is not high enough to
|
||||
access this feature.
|
||||
</v-alert>
|
||||
|
||||
<div v-if="stream && !(stream.role === 'stream:reviewer' || !stream.role)">
|
||||
<v-card
|
||||
elevation="0"
|
||||
color="transparent"
|
||||
class=""
|
||||
style="height: 220px; transition: all 0.2s ease"
|
||||
:class="`mt-4 mb-4 d-flex justify-center
|
||||
${dragover && !$vuetify.theme.dark ? 'grey lighten-4' : ''}
|
||||
${dragover && $vuetify.theme.dark ? 'grey darken-4' : ''}
|
||||
`"
|
||||
@drop.prevent="onFileDrop($event)"
|
||||
@dragover.prevent="dragover = true"
|
||||
@dragenter.prevent="dragover = true"
|
||||
@dragleave.prevent="dragover = false"
|
||||
>
|
||||
<div v-if="!dragError" class="align-self-center text-center">
|
||||
<input
|
||||
id="myid"
|
||||
type="file"
|
||||
accept=".ifc,.IFC"
|
||||
style="display: none"
|
||||
multiple
|
||||
@change="onFileSelect($event)"
|
||||
/>
|
||||
<v-icon
|
||||
x-large
|
||||
color="primary"
|
||||
:class="`hover-tada ${dragover ? 'tada' : ''}`"
|
||||
style="cursor: pointer"
|
||||
onclick="document.getElementById('myid').click()"
|
||||
>
|
||||
mdi-cloud-upload
|
||||
</v-icon>
|
||||
<br />
|
||||
<span class="primary--text">Drag and drop your IFC file here!</span>
|
||||
<br />
|
||||
<span class="caption">Maximum 5 files at a time. Size is restricted to 50mb each.</span>
|
||||
</div>
|
||||
<v-alert
|
||||
v-if="dragError"
|
||||
dismissible
|
||||
class="align-self-center text-center"
|
||||
type="error"
|
||||
@click="dragError = null"
|
||||
@drop.prevent="onFileDrop($event)"
|
||||
@dragover.prevent="dragover = true"
|
||||
@dragenter.prevent="dragover = true"
|
||||
@dragleave.prevent="dragover = false"
|
||||
>
|
||||
{{ dragError }}
|
||||
</v-alert>
|
||||
</v-card>
|
||||
|
||||
<!-- {{ uploads }} -->
|
||||
<template v-for="file in files">
|
||||
<file-upload-item
|
||||
:key="file.fileName"
|
||||
:file="file"
|
||||
@done="uploadCompleted"
|
||||
></file-upload-item>
|
||||
</template>
|
||||
<div v-if="!dragError" class="align-self-center text-center">
|
||||
<input
|
||||
id="myid"
|
||||
type="file"
|
||||
accept=".ifc,.IFC"
|
||||
style="display: none"
|
||||
multiple
|
||||
@change="onFileSelect($event)"
|
||||
/>
|
||||
<v-icon
|
||||
x-large
|
||||
color="primary"
|
||||
:class="`hover-tada ${dragover ? 'tada' : ''}`"
|
||||
style="cursor: pointer"
|
||||
onclick="document.getElementById('myid').click()"
|
||||
>
|
||||
mdi-cloud-upload
|
||||
</v-icon>
|
||||
<br />
|
||||
<span class="primary--text">Drag and drop your IFC file here!</span>
|
||||
<br />
|
||||
<span class="caption">Maximum 5 files at a time. Size is restricted to 50mb each.</span>
|
||||
</div>
|
||||
<v-alert
|
||||
v-if="dragError"
|
||||
dismissible
|
||||
class="align-self-center text-center"
|
||||
type="error"
|
||||
@click="dragError = null"
|
||||
>
|
||||
{{ dragError }}
|
||||
</v-alert>
|
||||
</v-card>
|
||||
|
||||
<!-- {{ uploads }} -->
|
||||
<template v-for="file in files">
|
||||
<file-upload-item
|
||||
:key="file.fileName"
|
||||
:file="file"
|
||||
:branches="stream.branches.items"
|
||||
@done="uploadCompleted"
|
||||
></file-upload-item>
|
||||
</template>
|
||||
</div>
|
||||
<v-card elevation="1" rounded="lg" :class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''}`">
|
||||
<v-toolbar flat :class="`${!$vuetify.theme.dark ? 'white' : ''} mb-2`">
|
||||
<v-toolbar-title>
|
||||
@@ -161,17 +126,35 @@ import gql from 'graphql-tag'
|
||||
export default {
|
||||
name: 'Webhooks',
|
||||
components: {
|
||||
NoDataPlaceholder: () => import('@/components/NoDataPlaceholder'),
|
||||
ErrorPlaceholder: () => import('@/components/ErrorPlaceholder'),
|
||||
FileUploadItem: () => import('@/components/FileUploadItem'),
|
||||
FileProcessingItem: () => import('@/components/FileProcessingItem')
|
||||
},
|
||||
apollo: {
|
||||
stream: {
|
||||
query: gql`
|
||||
query stream($id: String!) {
|
||||
stream(id: $id) {
|
||||
id
|
||||
role
|
||||
branches {
|
||||
totalCount
|
||||
items {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables() {
|
||||
return { id: this.$route.params.streamId }
|
||||
}
|
||||
},
|
||||
streamUploads: {
|
||||
query: gql`
|
||||
query streamUploads($streamId: String!) {
|
||||
stream(id: $streamId) {
|
||||
id
|
||||
role
|
||||
fileUploads {
|
||||
id
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
module.exports = {
|
||||
configureWebpack: {
|
||||
devtool: 'source-map'
|
||||
},
|
||||
productionSourceMap: false,
|
||||
pages: {
|
||||
app: {
|
||||
@@ -16,6 +19,7 @@ module.exports = {
|
||||
},
|
||||
devServer: {
|
||||
host: 'localhost',
|
||||
proxy: 'http://localhost:3000',
|
||||
historyApiFallback: {
|
||||
rewrites: [
|
||||
{ from: /^\/$/, to: '/app.html' },
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use strict'
|
||||
const passport = require( 'passport' )
|
||||
const URL = require( 'url' ).URL
|
||||
const appRoot = require( 'app-root-path' )
|
||||
const debug = require( 'debug' )
|
||||
const { createUser, updateUser, findOrCreateUser, validatePasssword, getUserByEmail } = require( `${appRoot}/modules/core/services/users` )
|
||||
const { createUser, updateUser, validatePasssword, getUserByEmail } = require( `${appRoot}/modules/core/services/users` )
|
||||
const { getServerInfo } = require( `${appRoot}/modules/core/services/generic` )
|
||||
const { validateInvite, useInvite } = require( `${appRoot}/modules/serverinvites/services` )
|
||||
|
||||
@@ -40,7 +39,6 @@ module.exports = async ( app, session, sessionAppId, finalizeAuth ) => {
|
||||
app.post( '/auth/local/register', session, sessionAppId, async ( req, res, next ) => {
|
||||
const serverInfo = await getServerInfo()
|
||||
try {
|
||||
|
||||
if ( !req.body.password )
|
||||
throw new Error( 'Password missing' )
|
||||
|
||||
@@ -62,7 +60,6 @@ module.exports = async ( app, session, sessionAppId, finalizeAuth ) => {
|
||||
req.user = { id: userId, email: user.email }
|
||||
|
||||
return next( )
|
||||
|
||||
} catch ( err ) {
|
||||
debug( 'speckle:errors' )( err )
|
||||
return res.status( 400 ).send( { err: err.message } )
|
||||
|
||||
@@ -18,6 +18,8 @@ module.exports = {
|
||||
branch.name = name.toLowerCase( )
|
||||
branch.description = description
|
||||
|
||||
if(name) module.exports.validateBranchName( { name } )
|
||||
|
||||
let [ id ] = await Branches( ).returning( 'id' ).insert( branch )
|
||||
|
||||
// update stream updated at
|
||||
@@ -27,9 +29,14 @@ module.exports = {
|
||||
},
|
||||
|
||||
async updateBranch( { id, name, description } ) {
|
||||
if ( name ) module.exports.validateBranchName( { name } )
|
||||
return await Branches( ).where( { id: id } ).update( { name: name ? name.toLowerCase( ) : name, description: description } )
|
||||
},
|
||||
|
||||
validateBranchName( { name } ) {
|
||||
if ( name.startsWith( '/' ) || name.startsWith( '#' ) ) throw new Error( 'Branch names cannot start with # or /.' )
|
||||
},
|
||||
|
||||
async getBranchById( { id } ) {
|
||||
return await Branches( ).where( { id: id } ).first( ).select( '*' )
|
||||
},
|
||||
@@ -54,7 +61,7 @@ module.exports = {
|
||||
},
|
||||
|
||||
async getBranchByNameAndStreamId( { streamId, name } ) {
|
||||
let query = Branches( ).select( '*' ).where( { streamId: streamId } ).andWhere( knex.raw( 'LOWER(name) = ?', [name]) ).first( )
|
||||
let query = Branches( ).select( '*' ).where( { streamId: streamId } ).andWhere( knex.raw( 'LOWER(name) = ?', [ name.toLowerCase() ] ) ).first( )
|
||||
return await query
|
||||
},
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ module.exports = {
|
||||
|
||||
async createUser( user ) {
|
||||
user.id = crs( { length: 10 } )
|
||||
user.email = user.email.toLowerCase()
|
||||
|
||||
if ( user.password ) {
|
||||
if ( user.password.length < 8 ) throw new Error( 'Password to short; needs to be 8 characters or longer.' )
|
||||
@@ -93,7 +94,7 @@ module.exports = {
|
||||
},
|
||||
|
||||
async getUserByEmail( { email } ) {
|
||||
let user = await Users( ).where( { email: email } ).select( '*' ).first( )
|
||||
let user = await Users( ).where( { email: email.toLowerCase() } ).select( '*' ).first( )
|
||||
if ( !user ) return null
|
||||
delete user.passwordDigest
|
||||
return user
|
||||
@@ -138,7 +139,7 @@ module.exports = {
|
||||
},
|
||||
|
||||
async validatePasssword( { email, password } ) {
|
||||
let { passwordDigest } = await Users( ).where( { email: email } ).select( 'passwordDigest' ).first( )
|
||||
let { passwordDigest } = await Users( ).where( { email: email.toLowerCase() } ).select( 'passwordDigest' ).first( )
|
||||
return bcrypt.compare( password, passwordDigest )
|
||||
},
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ const {
|
||||
} = require( '../services/branches' )
|
||||
|
||||
describe( 'Branches @core-branches', ( ) => {
|
||||
|
||||
let user = {
|
||||
name: 'Dimitrie Stefanescu',
|
||||
email: 'didimitrie4342@gmail.com',
|
||||
@@ -73,6 +72,52 @@ describe( 'Branches @core-branches', ( ) => {
|
||||
}
|
||||
} )
|
||||
|
||||
it( 'Should not allow branch names starting with # or /', async ( ) => {
|
||||
try {
|
||||
await createBranch( { name: '/pasta', streamId: stream.id, authorId: user.id } )
|
||||
assert.fail( 'Illegal branch name passed through.' )
|
||||
} catch ( err ) {
|
||||
expect( err.message ).to.contain( 'names cannot start with # or /' )
|
||||
}
|
||||
|
||||
try {
|
||||
await createBranch( { name: '#rice', streamId: stream.id, authorId: user.id } )
|
||||
assert.fail( 'Illegal branch name passed through.' )
|
||||
} catch ( err ) {
|
||||
expect( err.message ).to.contain( 'names cannot start with # or /' )
|
||||
}
|
||||
|
||||
try {
|
||||
await updateBranch( { id: branch.id, name: '/super/part/two' } )
|
||||
assert.fail( 'Illegal branch name passed through in update operation.' )
|
||||
} catch ( err ) {
|
||||
expect( err.message ).to.contain( 'names cannot start with # or /' )
|
||||
}
|
||||
|
||||
try {
|
||||
await updateBranch( { id: branch.id, name: '#super#part#three' } )
|
||||
assert.fail( 'Illegal branch name passed through in update operation.' )
|
||||
} catch ( err ) {
|
||||
expect( err.message ).to.contain( 'names cannot start with # or /' )
|
||||
}
|
||||
} )
|
||||
|
||||
it( 'Branch names should be case insensitive (always lowercase)', async ( ) => {
|
||||
let id = await createBranch( { name: 'CaseSensitive', streamId: stream.id, authorId: user.id } )
|
||||
|
||||
let b = await getBranchByNameAndStreamId( { streamId: stream.id, name:'casesensitive' } )
|
||||
expect( b.name ).to.equal( 'casesensitive' )
|
||||
|
||||
let bb = await getBranchByNameAndStreamId( { streamId: stream.id, name:'CaseSensitive' } )
|
||||
expect( bb.name ).to.equal( 'casesensitive' )
|
||||
|
||||
let bbb = await getBranchByNameAndStreamId( { streamId: stream.id, name:'CASESENSITIVE' } )
|
||||
expect( bbb.name ).to.equal( 'casesensitive' )
|
||||
|
||||
// cleanup
|
||||
await deleteBranchById( { id, streamId: stream.id } )
|
||||
} )
|
||||
|
||||
it( 'Should get a branch', async ( ) => {
|
||||
let myBranch = await getBranchById( { id: branch.id } )
|
||||
expect( myBranch.authorId ).to.equal( user.id )
|
||||
@@ -87,7 +132,6 @@ describe( 'Branches @core-branches', ( ) => {
|
||||
} )
|
||||
|
||||
it( 'Should get all stream branches', async ( ) => {
|
||||
|
||||
await createBranch( { name: 'main-faster', streamId: stream.id, authorId: user.id } )
|
||||
await createBranch( { name: 'main-blaster', streamId: stream.id, authorId: user.id } )
|
||||
await createBranch( { name: 'blaster-farter', streamId: stream.id, authorId: user.id } )
|
||||
@@ -112,7 +156,5 @@ describe( 'Branches @core-branches', ( ) => {
|
||||
} catch ( e ){
|
||||
// pass
|
||||
}
|
||||
|
||||
} )
|
||||
|
||||
} )
|
||||
|
||||
@@ -12,7 +12,7 @@ chai.use( chaiHttp )
|
||||
|
||||
const knex = require( `${appRoot}/db/knex` )
|
||||
|
||||
const { createUser, findOrCreateUser, getUser, getUsers, searchUsers, countUsers, updateUser, deleteUser, validatePasssword, updateUserPassword, getUserRole, unmakeUserAdmin, makeUserAdmin } = require( '../services/users' )
|
||||
const { createUser, findOrCreateUser, getUser, getUserByEmail, getUsers, searchUsers, countUsers, updateUser, deleteUser, validatePasssword, updateUserPassword, getUserRole, unmakeUserAdmin, makeUserAdmin } = require( '../services/users' )
|
||||
const { createPersonalAccessToken, createAppToken, revokeToken, revokeTokenById, validateToken, getUserTokens } = require( '../services/tokens' )
|
||||
const { grantPermissionsStream, createStream, getStream } = require( '../services/streams' )
|
||||
|
||||
@@ -68,6 +68,24 @@ describe( 'Actors & Tokens @user-services', ( ) => {
|
||||
expect( actorId ).to.be.a( 'string' )
|
||||
} )
|
||||
|
||||
it( 'Should store user email lowercase', async ( ) => {
|
||||
let user = { name: 'Marty McFly', email: 'Marty@Mc.Fly', password: 'something_future_proof' }
|
||||
|
||||
let userId = await createUser( user )
|
||||
|
||||
let storedUser = await getUser( userId )
|
||||
expect( storedUser.email ).to.equal( user.email.toLowerCase() )
|
||||
} )
|
||||
|
||||
it ( 'Get user by should ignore email casing', async ( ) => {
|
||||
let user = await getUserByEmail( { email: 'BiLL@GaTES.cOm' } )
|
||||
expect( user.email ).to.equal( 'bill@gates.com' )
|
||||
} )
|
||||
|
||||
it ( 'Validate password should ignore email casing', async ( ) => {
|
||||
expect( await validatePasssword( { email: 'BiLL@GaTES.cOm', password: 'testthebest' } ) )
|
||||
} )
|
||||
|
||||
it( 'Should not create a user with a too small password', async () => {
|
||||
try {
|
||||
await createUser( { name: 'Dim Sum', email: 'dim@gmail.com', password: '1234567' } )
|
||||
|
||||
@@ -53,7 +53,9 @@ exports.init = async ( app, options ) => {
|
||||
if ( process.env.DISABLE_FILE_UPLOADS ) {
|
||||
return res.status( 503 ).send( 'File uploads are disabled on this server' )
|
||||
}
|
||||
|
||||
let fileInfo = await getFileInfo( { fileId: req.params.fileId } )
|
||||
|
||||
if ( !fileInfo )
|
||||
return res.status( 404 ).send( 'File not found' )
|
||||
|
||||
@@ -85,7 +87,7 @@ exports.init = async ( app, options ) => {
|
||||
|
||||
let fileStream = await getFileStream( { fileId: req.params.fileId } )
|
||||
|
||||
res.writeHead( 200, { 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment' } )
|
||||
res.writeHead( 200, { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${fileInfo.fileName}"`, } )
|
||||
|
||||
fileStream.pipe( res )
|
||||
} ),
|
||||
|
||||
@@ -12,7 +12,7 @@ exports.up = async knex => {
|
||||
table.integer( 'fileSize' )
|
||||
table.boolean( 'uploadComplete' ).notNullable( ).defaultTo( false )
|
||||
table.timestamp( 'uploadDate' ).notNullable( ).defaultTo( knex.fn.now( ) )
|
||||
|
||||
// 0 = queued, 1 = in progress, 2 = success, 3 = error
|
||||
table.integer( 'convertedStatus' ).notNullable( ).defaultTo( 0 )
|
||||
table.timestamp( 'convertedLastUpdate' ).notNullable( ).defaultTo( knex.fn.now( ) )
|
||||
table.string( 'convertedMessage' )
|
||||
|
||||
@@ -16,6 +16,7 @@ const Invites = () => knex( 'server_invites' )
|
||||
module.exports = {
|
||||
async createAndSendInvite( { email, inviterId, message, resourceTarget, resourceId, role } ) {
|
||||
// check if email is already registered as a user
|
||||
email = email.toLowerCase()
|
||||
let existingUser = await getUserByEmail( { email } )
|
||||
|
||||
if ( existingUser ) throw new Error( 'This email is already associated with an account on this server!' )
|
||||
@@ -104,12 +105,12 @@ This email was sent from ${serverInfo.name} at ${process.env.CANONICAL_URL}, dep
|
||||
},
|
||||
|
||||
async getInviteByEmail( { email } ) {
|
||||
return await Invites().where( { email: email } ).select( '*' ).first()
|
||||
return await Invites().where( { email: email.toLowerCase() } ).select( '*' ).first()
|
||||
},
|
||||
|
||||
async validateInvite( { email, id } ) {
|
||||
const invite = await module.exports.getInviteById( { id } )
|
||||
return invite && invite.email === email && !invite.used
|
||||
return invite && invite.email === email.toLowerCase() && !invite.used
|
||||
},
|
||||
|
||||
async useInvite( { id, email } ) {
|
||||
@@ -119,7 +120,7 @@ This email was sent from ${serverInfo.name} at ${process.env.CANONICAL_URL}, dep
|
||||
let invite = await module.exports.getInviteById( { id } )
|
||||
if ( !invite ) throw new Error( 'Invite not found' )
|
||||
if ( invite.used ) throw new Error( 'Invite has been used' )
|
||||
if ( invite.email !== email ) throw new Error( 'Invite email mismatch. Please use the original email the invite was sent to register.' )
|
||||
if ( invite.email !== email.toLowerCase() ) throw new Error( 'Invite email mismatch. Please use the original email the invite was sent to register.' )
|
||||
|
||||
if ( invite.resourceId && invite.resourceTarget && invite.role ) {
|
||||
let user = await getUserByEmail( { email: invite.email } )
|
||||
|
||||
@@ -19,7 +19,6 @@ const { createPersonalAccessToken } = require( `${appRoot}/modules/core/services
|
||||
const serverAddress = 'http://localhost:3300'
|
||||
|
||||
describe( 'Server Invites @server-invites', ( ) => {
|
||||
|
||||
let myApp
|
||||
|
||||
describe( 'Services @server-invites-services', () => {
|
||||
@@ -43,15 +42,16 @@ describe( 'Server Invites @server-invites', ( ) => {
|
||||
} )
|
||||
|
||||
it( 'should create an invite', async() => {
|
||||
|
||||
let inviteId = await createAndSendInvite( { email:'didimitrie@gmail.com', inviterId: actor.id, message: 'Hey, join!' } )
|
||||
expect( inviteId ).to.be.a( 'string' )
|
||||
|
||||
} )
|
||||
|
||||
it( 'should store invited email as lowercase', async() => {
|
||||
let inviteId = await createAndSendInvite( { email:'GerGO@gmaIl.com', inviterId: actor.id, message: 'Hey, join!' } )
|
||||
expect( inviteId ).to.be.a( 'string' )
|
||||
} )
|
||||
|
||||
it( 'should not allow multiple invites for the same email', async() => {
|
||||
|
||||
let inviteId = await createAndSendInvite( { email:'cat@speckle.systems', inviterId: actor.id, message: 'Hey, join!' } )
|
||||
|
||||
try {
|
||||
@@ -61,9 +61,11 @@ describe( 'Server Invites @server-invites', ( ) => {
|
||||
}
|
||||
assert.fail( 'should not allow multiple invites for the same email' )
|
||||
} )
|
||||
it( 'low multiple invites for the same email regardles of casing', () => {
|
||||
return createAndSendInvite( { email:'dIdImItrIe@gmaIl.com', inviterId: actor.id, message: 'Hey, join!' } ).then( ( result ) => {} ).catch( ( result ) => { expect( result.message ).to.equal( 'Already invited!' ) } )
|
||||
} )
|
||||
|
||||
it( 'should not allow self invites', async() => {
|
||||
|
||||
try {
|
||||
await createAndSendInvite( { email: 'didimitrie-100@gmail.com', inviterId: actor.id } )
|
||||
} catch ( e ) {
|
||||
@@ -73,7 +75,6 @@ describe( 'Server Invites @server-invites', ( ) => {
|
||||
} )
|
||||
|
||||
it( 'should not allow invites from no user', async() => {
|
||||
|
||||
try {
|
||||
await createAndSendInvite( { email: 'didimitrie233-100@gmail.com', inviterId: 'fake' } )
|
||||
} catch ( e ) {
|
||||
@@ -83,7 +84,6 @@ describe( 'Server Invites @server-invites', ( ) => {
|
||||
} )
|
||||
|
||||
it( 'should not allow invites with a too long message', async() => {
|
||||
|
||||
try {
|
||||
let inviteId = await createAndSendInvite( {
|
||||
email: '123456@gmail.com',
|
||||
@@ -111,7 +111,6 @@ describe( 'Server Invites @server-invites', ( ) => {
|
||||
expect( invite.email ).to.equal( 'badger@speckle.systems' )
|
||||
expect( invite.used ).to.equal( false )
|
||||
expect( invite.inviterId ).to.equal( actor.id )
|
||||
|
||||
} )
|
||||
|
||||
it( 'should get an invite by email', async() => {
|
||||
@@ -127,7 +126,7 @@ describe( 'Server Invites @server-invites', ( ) => {
|
||||
it( 'should validate an invite', async() => {
|
||||
let inviteId = await createAndSendInvite( { email:'raven@speckle.systems', inviterId: actor.id, message: 'Hey, join!' } )
|
||||
|
||||
const valid = await validateInvite( { email: 'raven@speckle.systems', id: inviteId } )
|
||||
const valid = await validateInvite( { email: 'rAvEn@specklE.sYstems', id: inviteId } )
|
||||
const invalid = await validateInvite( { email: 'bunny@speckle.systems', id: inviteId } )
|
||||
|
||||
expect( valid ).to.equal( true )
|
||||
@@ -144,14 +143,14 @@ describe( 'Server Invites @server-invites', ( ) => {
|
||||
// pass
|
||||
}
|
||||
|
||||
let result = await useInvite( { id: inviteId, email:'crow@speckle.systems' } )
|
||||
let result = await useInvite( { id: inviteId, email:'crOw@specKle.systeMs' } )
|
||||
|
||||
let invite = await getInviteByEmail( { email: 'crow@speckle.systems' } )
|
||||
let invite = await getInviteByEmail( { email: 'crow@speCkle.syStems' } )
|
||||
expect( result ).equals( true )
|
||||
expect( invite.used ).equals( true )
|
||||
|
||||
try {
|
||||
await useInvite( { id: inviteId, email:'crow@speckle.systems' } )
|
||||
await useInvite( { id: inviteId, email:'CrOw@speckle.systems' } )
|
||||
assert.fail( 'Should not be able to use an already used invite.' )
|
||||
} catch ( e ) {
|
||||
//pass
|
||||
@@ -209,7 +208,6 @@ describe( 'Server Invites @server-invites', ( ) => {
|
||||
} )
|
||||
|
||||
it( 'should create a server invite', async() => {
|
||||
|
||||
const res = await sendRequest( testToken, {
|
||||
query: 'mutation inviteToServer($input: ServerInviteCreateInput!) { serverInviteCreate( input: $input ) }',
|
||||
variables: { input: { email: 'cabbages@speckle.systems', message: 'wow!' } }
|
||||
@@ -220,7 +218,6 @@ describe( 'Server Invites @server-invites', ( ) => {
|
||||
} )
|
||||
|
||||
it( 'should create a stream invite', async() => {
|
||||
|
||||
let stream = { name: 'test', description:'wow' }
|
||||
stream.id = await createStream( { ...stream, ownerId: actor.id } )
|
||||
|
||||
@@ -232,14 +229,11 @@ describe( 'Server Invites @server-invites', ( ) => {
|
||||
expect( res.body.errors ).to.not.exist
|
||||
expect( res.body.data.streamInviteCreate ).to.equal( true )
|
||||
} )
|
||||
|
||||
} )
|
||||
} )
|
||||
|
||||
function sendRequest( auth, obj, address = serverAddress ) {
|
||||
|
||||
return chai.request( address ).post( '/graphql' ).set( 'Authorization', auth ).send( obj )
|
||||
|
||||
}
|
||||
|
||||
const longInviteMessage =
|
||||
|
||||
@@ -21,10 +21,9 @@ async function contextApiTokenHelper( { req, res, connection } ) {
|
||||
|
||||
if ( connection && connection.context.token ) { // Websockets (subscriptions)
|
||||
token = connection.context.token
|
||||
} else if ( req && req.headers.authorization ) { // Standard http
|
||||
} else if ( req && req.headers.authorization ) { // Standard http post
|
||||
token = req.headers.authorization
|
||||
}
|
||||
|
||||
}
|
||||
if ( token && token.includes( 'Bearer ' ) ) {
|
||||
token = token.split( ' ' )[ 1 ]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user