fix: various server invites related improvements & fixes (#837)

* moving to invite token field, fixing comments & activity bugs I found, adding role prop to stream invite creation
* more fixes
* more tests
This commit is contained in:
Kristaps Fabians Geikins
2022-07-25 11:02:22 +03:00
committed by GitHub
parent 8ecc7f5a68
commit 3ff772e342
35 changed files with 477 additions and 181 deletions
@@ -27,6 +27,7 @@ export const usersOwnInviteFieldsFragment = gql`
inviteId
streamId
streamName
token
invitedBy {
...LimitedUserFields
}
@@ -654,8 +654,8 @@ export type MutationStreamInviteCreateArgs = {
export type MutationStreamInviteUseArgs = {
accept: Scalars['Boolean'];
inviteId: Scalars['String'];
streamId: Scalars['String'];
token: Scalars['String'];
};
@@ -788,6 +788,8 @@ export type PendingStreamCollaborator = {
streamName: Scalars['String'];
/** E-mail address or name of the invited user */
title: Scalars['String'];
/** Only available if the active user is the pending stream collaborator */
token?: Maybe<Scalars['String']>;
/** Set only if user is registered */
user?: Maybe<LimitedUser>;
};
@@ -821,7 +823,7 @@ export type Query = {
*/
stream?: Maybe<Stream>;
/**
* Look for an invitation to a stream, for the current user (authed or not). If inviteId
* Look for an invitation to a stream, for the current user (authed or not). If token
* isn't specified, the server will look for any valid invite.
*/
streamInvite?: Maybe<PendingStreamCollaborator>;
@@ -885,8 +887,8 @@ export type QueryStreamArgs = {
export type QueryStreamInviteArgs = {
inviteId?: InputMaybe<Scalars['String']>;
streamId: Scalars['String'];
token?: InputMaybe<Scalars['String']>;
};
@@ -1186,6 +1188,8 @@ export type StreamCreateInput = {
export type StreamInviteCreateInput = {
email?: InputMaybe<Scalars['String']>;
message?: InputMaybe<Scalars['String']>;
/** Defaults to the contributor role, if not specified */
role?: InputMaybe<Scalars['String']>;
streamId: Scalars['String'];
userId?: InputMaybe<Scalars['String']>;
};
@@ -1529,25 +1533,25 @@ export type LimitedUserFieldsFragment = { __typename?: 'LimitedUser', id: string
export type StreamCollaboratorFieldsFragment = { __typename?: 'StreamCollaborator', id: string, name: string, role: string, company?: string | null, avatar?: string | null };
export type UsersOwnInviteFieldsFragment = { __typename?: 'PendingStreamCollaborator', id: string, inviteId: string, streamId: string, streamName: string, invitedBy: { __typename?: 'LimitedUser', id: string, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null } };
export type UsersOwnInviteFieldsFragment = { __typename?: 'PendingStreamCollaborator', id: string, inviteId: string, streamId: string, streamName: string, token?: string | null, invitedBy: { __typename?: 'LimitedUser', id: string, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null } };
export type StreamInviteQueryVariables = Exact<{
streamId: Scalars['String'];
inviteId?: InputMaybe<Scalars['String']>;
token?: InputMaybe<Scalars['String']>;
}>;
export type StreamInviteQuery = { __typename?: 'Query', streamInvite?: { __typename?: 'PendingStreamCollaborator', id: string, inviteId: string, streamId: string, streamName: string, invitedBy: { __typename?: 'LimitedUser', id: string, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null } } | null };
export type StreamInviteQuery = { __typename?: 'Query', streamInvite?: { __typename?: 'PendingStreamCollaborator', id: string, inviteId: string, streamId: string, streamName: string, token?: string | null, invitedBy: { __typename?: 'LimitedUser', id: string, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null } } | null };
export type UserStreamInvitesQueryVariables = Exact<{ [key: string]: never; }>;
export type UserStreamInvitesQuery = { __typename?: 'Query', streamInvites: Array<{ __typename?: 'PendingStreamCollaborator', id: string, inviteId: string, streamId: string, streamName: string, invitedBy: { __typename?: 'LimitedUser', id: string, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null } }> };
export type UserStreamInvitesQuery = { __typename?: 'Query', streamInvites: Array<{ __typename?: 'PendingStreamCollaborator', id: string, inviteId: string, streamId: string, streamName: string, token?: string | null, invitedBy: { __typename?: 'LimitedUser', id: string, name?: string | null, bio?: string | null, company?: string | null, avatar?: string | null, verified?: boolean | null } }> };
export type UseStreamInviteMutationVariables = Exact<{
accept: Scalars['Boolean'];
streamId: Scalars['String'];
inviteId: Scalars['String'];
token: Scalars['String'];
}>;
@@ -1796,6 +1800,7 @@ export const UsersOwnInviteFields = gql`
inviteId
streamId
streamName
token
invitedBy {
...LimitedUserFields
}
@@ -1936,8 +1941,8 @@ export const StreamCommitQuery = gql`
}
`;
export const StreamInvite = gql`
query StreamInvite($streamId: String!, $inviteId: String) {
streamInvite(streamId: $streamId, inviteId: $inviteId) {
query StreamInvite($streamId: String!, $token: String) {
streamInvite(streamId: $streamId, token: $token) {
...UsersOwnInviteFields
}
}
@@ -1950,8 +1955,8 @@ export const UserStreamInvites = gql`
}
${UsersOwnInviteFields}`;
export const UseStreamInvite = gql`
mutation UseStreamInvite($accept: Boolean!, $streamId: String!, $inviteId: String!) {
streamInviteUse(accept: $accept, streamId: $streamId, inviteId: $inviteId)
mutation UseStreamInvite($accept: Boolean!, $streamId: String!, $token: String!) {
streamInviteUse(accept: $accept, streamId: $streamId, token: $token)
}
`;
export const CancelStreamInvite = gql`
@@ -2340,6 +2345,7 @@ export const UsersOwnInviteFieldsFragmentDoc = gql`
inviteId
streamId
streamName
token
invitedBy {
...LimitedUserFields
}
@@ -2569,8 +2575,8 @@ export const useStreamCommitQueryQuery = createSmartQueryOptionsFunction<
>(StreamCommitQueryDocument, handleApolloError);
export const StreamInviteDocument = gql`
query StreamInvite($streamId: String!, $inviteId: String) {
streamInvite(streamId: $streamId, inviteId: $inviteId) {
query StreamInvite($streamId: String!, $token: String) {
streamInvite(streamId: $streamId, token: $token) {
...UsersOwnInviteFields
}
}
@@ -2590,7 +2596,7 @@ export const StreamInviteDocument = gql`
* streamInvite: useStreamInviteQuery({
* variables: {
* streamId: // value for 'streamId'
* inviteId: // value for 'inviteId'
* token: // value for 'token'
* },
* loadingKey: 'loading',
* fetchPolicy: 'no-cache',
@@ -2638,8 +2644,8 @@ export const useUserStreamInvitesQuery = createSmartQueryOptionsFunction<
>(UserStreamInvitesDocument, handleApolloError);
export const UseStreamInviteDocument = gql`
mutation UseStreamInvite($accept: Boolean!, $streamId: String!, $inviteId: String!) {
streamInviteUse(accept: $accept, streamId: $streamId, inviteId: $inviteId)
mutation UseStreamInvite($accept: Boolean!, $streamId: String!, $token: String!) {
streamInviteUse(accept: $accept, streamId: $streamId, token: $token)
}
`;
@@ -2658,7 +2664,7 @@ export const UseStreamInviteDocument = gql`
* variables: {
* accept: // value for 'accept'
* streamId: // value for 'streamId'
* inviteId: // value for 'inviteId'
* token: // value for 'token'
* },
* });
*/
+4 -4
View File
@@ -2,8 +2,8 @@ import gql from 'graphql-tag'
import { usersOwnInviteFieldsFragment } from '@/graphql/fragments/user'
export const streamInviteQuery = gql`
query StreamInvite($streamId: String!, $inviteId: String) {
streamInvite(streamId: $streamId, inviteId: $inviteId) {
query StreamInvite($streamId: String!, $token: String) {
streamInvite(streamId: $streamId, token: $token) {
...UsersOwnInviteFields
}
}
@@ -22,8 +22,8 @@ export const userStreamInvitesQuery = gql`
`
export const useStreamInviteMutation = gql`
mutation UseStreamInvite($accept: Boolean!, $streamId: String!, $inviteId: String!) {
streamInviteUse(accept: $accept, streamId: $streamId, inviteId: $inviteId)
mutation UseStreamInvite($accept: Boolean!, $streamId: String!, $token: String!) {
streamInviteUse(accept: $accept, streamId: $streamId, token: $token)
}
`
@@ -152,7 +152,7 @@
<v-icon small class="mr-2 float-left">mdi-source-commit</v-icon>
{{ stream.commits.totalCount }}
</v-btn>
<v-chip small outlined class="ml-3 no-hover">
<v-chip v-if="stream.role" small outlined class="ml-3 no-hover">
<v-icon small left>mdi-account-key-outline</v-icon>
{{ stream.role.split(':')[1] }}
</v-chip>
@@ -19,7 +19,7 @@
:color="s.color"
:href="`${s.url}?appId=${appId}&challenge=${challenge}${
suuid ? '&suuid=' + suuid : ''
}${inviteId ? '&inviteId=' + inviteId : ''}`"
}${token ? '&token=' + token : ''}`"
>
<v-icon small class="mr-5">{{ s.icon }}</v-icon>
{{ s.name }}
@@ -30,7 +30,7 @@
</div>
</template>
<script>
import { getInviteIdFromURL } from '@/main/lib/auth/services/authService'
import { getInviteTokenFromRoute } from '@/main/lib/auth/services/authService'
export default {
name: 'AuthStrategies',
props: {
@@ -51,18 +51,15 @@ export default {
default: () => null
}
},
data() {
return {
inviteId: null
computed: {
token() {
return getInviteTokenFromRoute(this.$route)
}
},
mounted() {
this.inviteId = getInviteIdFromURL()
},
methods: {
trackSignIn(strategyName) {
this.$mixpanel.track('Log In', {
isInvite: this.inviteId !== null,
isInvite: this.token !== null,
type: 'action',
provider: strategyName
})
@@ -91,7 +91,14 @@ export default {
},
methods: {
groupSimilarActivities(data) {
if (!data) return
const skippableActionTypes = ['stream_invite_sent', 'stream_invite_declined']
const groupedActivity = data.stream.activity.items.reduce(function (prev, curr) {
if (skippableActionTypes.includes(curr.actionType)) {
return prev
}
//first item
if (!prev.length) {
prev.push([curr])
@@ -8,8 +8,10 @@
:avatar="streamInviter.avatar"
:size="75"
/>
<v-icon class="mx-4">mdi-plus</v-icon>
<user-avatar :id="$userId()" :size="75" />
<template v-if="$userId()">
<v-icon class="mx-4">mdi-plus</v-icon>
<user-avatar :id="$userId()" :size="75" />
</template>
</div>
</template>
<template #default>
@@ -2,6 +2,7 @@ import { LocalStorageKeys } from '@/helpers/mainConstants'
import { Nullable } from '@/helpers/typeHelpers'
import { getCurrentQueryParams } from '@/main/lib/common/web-apis/helpers/urlHelper'
import { AppLocalStorage } from '@/utils/localStorage'
import { Route } from 'vue-router'
/**
* Process a successful authentication (from login page, registration page or elsewhere)
@@ -34,6 +35,16 @@ export function processSuccessfulAuth(res: Response): void {
/**
* Get invite id from URL query string
*/
export function getInviteIdFromURL(): Nullable<string> {
return getCurrentQueryParams().get('inviteId')
export function getInviteTokenFromURL(): Nullable<string> {
const query = getCurrentQueryParams()
return query.get('token') || query.get('inviteId')
}
/**
* Get invite id from VueRouter route, can be used instead of getInviteTokenFromURL()
* when you want the result to be reactive and dependant on the route object
*/
export function getInviteTokenFromRoute(route: Route): Nullable<string> {
const query = route.query
return (query.token as string) || (query.inviteId as string) || null
}
@@ -23,6 +23,10 @@ export const UsersStreamInviteMixin = vueWithMixins(IsLoggedInMixin).extend({
streamInvite: {
type: Object as PropType<StreamInviteType>,
required: true
},
inviteToken: {
type: String as PropType<Nullable<string>>,
default: null
}
},
data: () => ({
@@ -35,6 +39,9 @@ export const UsersStreamInviteMixin = vueWithMixins(IsLoggedInMixin).extend({
inviteId(): string {
return this.streamInvite.inviteId
},
token(): Nullable<string> {
return this.streamInvite.token || this.inviteToken || null
},
streamInviter(): Nullable<Get<StreamInviteQuery, 'streamInvite.invitedBy'>> {
return this.streamInvite.invitedBy
},
@@ -59,13 +66,13 @@ export const UsersStreamInviteMixin = vueWithMixins(IsLoggedInMixin).extend({
this.$loginAndSetRedirect()
},
async processInvite(accept: boolean) {
if (!this.inviteId) return
if (!this.token) return
const { data, errors } = await useStreamInviteMutation(this, {
variables: {
accept,
streamId: this.streamId,
inviteId: this.inviteId
token: this.token
},
update: (cache, { data }) => {
if (!data?.streamInviteUse) return
@@ -80,7 +87,7 @@ export const UsersStreamInviteMixin = vueWithMixins(IsLoggedInMixin).extend({
// 1. Single stream invite query
const singleStreamInviteCacheFilter = {
query: StreamInviteDocument,
variables: { streamId: this.streamId, inviteId: this.inviteId }
variables: { streamId: this.streamId, token: this.token }
}
let singleStreamInviteQueryData: MaybeFalsy<StreamInviteQuery> = undefined
try {
@@ -106,7 +106,10 @@ import gql from 'graphql-tag'
import AuthStrategies from '@/main/components/auth/AuthStrategies.vue'
import { randomString } from '@/helpers/randomHelpers'
import { isEmailValid } from '@/plugins/authHelpers'
import { processSuccessfulAuth } from '@/main/lib/auth/services/authService'
import {
getInviteTokenFromRoute,
processSuccessfulAuth
} from '@/main/lib/auth/services/authService'
export default {
name: 'TheLogin',
@@ -152,8 +155,7 @@ export default {
serverApp: null,
appId: null,
suuid: null,
challenge: null,
inviteId: null
challenge: null
}),
computed: {
strategies() {
@@ -162,6 +164,9 @@ export default {
hasLocalStrategy() {
return this.serverInfo.authStrategies.findIndex((s) => s.id === 'local') !== -1
},
token() {
return getInviteTokenFromRoute(this.$route)
},
registerRoute() {
return {
name: 'Register',
@@ -169,7 +174,7 @@ export default {
appId: this.$route.query.appId,
challenge: this.$route.query.challenge,
suuid: this.$route.query.suuid,
inviteId: this.$route.query.inviteId
token: this.token
}
}
}
@@ -180,8 +185,6 @@ export default {
const challenge = urlParams.get('challenge')
const suuid = urlParams.get('suuid')
this.suuid = suuid
const inviteId = urlParams.get('inviteId')
this.inviteId = inviteId
this.$mixpanel.track('Visit Log In')
@@ -12,7 +12,7 @@
<v-icon small>mdi-shield-alert-outline</v-icon>
This Speckle server is invite only.
</div>
<v-alert v-if="serverInfo.inviteOnly && !inviteId" type="info">
<v-alert v-if="serverInfo.inviteOnly && !token" type="info">
This server is invite only. If you have received an invitation email, please
follow the instructions in it.
</v-alert>
@@ -171,9 +171,7 @@
</v-card-text>
</div>
<v-card-title
:class="`justify-center caption ${
serverInfo.inviteOnly && !inviteId ? 'pt-0' : ''
}`"
:class="`justify-center caption ${serverInfo.inviteOnly && !token ? 'pt-0' : ''}`"
>
<div class="mx-4 align-self-center">Already have an account?</div>
<div class="mx-4 align-self-center">
@@ -189,7 +187,10 @@ import { randomString } from '@/helpers/randomHelpers'
import AuthStrategies from '@/main/components/auth/AuthStrategies.vue'
import { isEmailValid } from '@/plugins/authHelpers'
import { processSuccessfulAuth } from '@/main/lib/auth/services/authService'
import {
getInviteTokenFromRoute,
processSuccessfulAuth
} from '@/main/lib/auth/services/authService'
export default {
name: 'TheRegistration',
@@ -249,11 +250,13 @@ export default {
pwdSuggestions: null,
appId: null,
challenge: null,
suuid: null,
inviteId: null
suuid: null
}
},
computed: {
token() {
return getInviteTokenFromRoute(this.$route)
},
loginRoute() {
return {
name: 'Login',
@@ -261,7 +264,7 @@ export default {
appId: this.$route.query.appId,
challenge: this.$route.query.challenge,
suuid: this.$route.query.suuid,
inviteId: this.$route.query.inviteId
token: this.token
}
}
},
@@ -278,8 +281,6 @@ export default {
const challenge = urlParams.get('challenge')
const suuid = urlParams.get('suuid')
this.suuid = suuid
const inviteId = urlParams.get('inviteId')
this.inviteId = inviteId
this.$mixpanel.track('Visit Sign Up')
@@ -320,7 +321,7 @@ export default {
const res = await fetch(
`/auth/local/register?challenge=${this.challenge}${
this.inviteId ? '&inviteId=' + this.inviteId : ''
this.token ? '&token=' + this.token : ''
}`,
{
method: 'POST',
@@ -334,7 +335,7 @@ export default {
if (res.redirected) {
this.$mixpanel.track('Sign Up', {
isInvite: this.inviteId !== null,
isInvite: this.token !== null,
type: 'action'
})
processSuccessfulAuth(res)
@@ -11,6 +11,7 @@
<stream-invite-banner
v-if="hasInvite && !showInvitePlaceholder"
:stream-invite="streamInvite"
:invite-token="inviteToken"
@invite-used="onInviteClosed"
/>
@@ -27,6 +28,7 @@
<stream-invite-placeholder
v-else
:stream-invite="streamInvite"
:invite-token="inviteToken"
@invite-used="onInviteClosed"
/>
</div>
@@ -53,11 +55,12 @@ import type { ApolloQueryResult } from 'apollo-client'
import type { Get } from 'type-fest'
import StreamInvitePlaceholder from '@/main/components/stream/StreamInvitePlaceholder.vue'
import { StreamInviteType } from '@/main/lib/stream/mixins/streamInviteMixin'
import { getInviteTokenFromRoute } from '@/main/lib/auth/services/authService'
// Cause of a limitation of vue-apollo-smart-ops, this needs to be duplicated
type VueThis = Vue & {
streamId: string
inviteId: Nullable<string>
inviteToken: Nullable<string>
error: Nullable<Error>
}
@@ -81,8 +84,8 @@ export default Vue.extend({
}
},
computed: {
inviteId(): Nullable<string> {
return this.$route.query['inviteId'] as Nullable<string>
inviteToken(): Nullable<string> {
return getInviteTokenFromRoute(this.$route)
},
streamId(): string {
return this.$route.params.streamId
@@ -119,7 +122,7 @@ export default Vue.extend({
variables() {
return {
streamId: this.streamId,
inviteId: this.inviteId
token: this.inviteToken
}
}
}),
+3 -5
View File
@@ -3,7 +3,7 @@ import { getMixpanelUserId, getMixpanelServerId } from '@/mixpanelManager'
import { NotificationEventPayload } from '@/main/lib/core/helpers/eventHubHelper'
import { AppLocalStorage } from '@/utils/localStorage'
import { LocalStorageKeys } from '@/helpers/mainConstants'
import { getInviteIdFromURL } from '@/main/lib/auth/services/authService'
import { getInviteTokenFromURL } from '@/main/lib/auth/services/authService'
Vue.prototype.$userId = function () {
return AppLocalStorage.get(LocalStorageKeys.Uuid)
@@ -38,10 +38,8 @@ Vue.prototype.$loginAndSetRedirect = function () {
AppLocalStorage.set(LocalStorageKeys.ShouldRedirectTo, relativePath)
// Carry inviteId over
const inviteId = getInviteIdFromURL()
this.$router.push(
inviteId ? { path: '/authn/login', query: { inviteId } } : '/authn/login'
)
const token = getInviteTokenFromURL()
this.$router.push(token ? { path: '/authn/login', query: { token } } : '/authn/login')
}
/**
+3 -2
View File
@@ -40,8 +40,9 @@ module.exports = async (app) => {
req.session.suuid = req.query.suuid
}
if (req.query.inviteId) {
req.session.inviteId = req.query.inviteId
const token = req.query.token || req.query.inviteId
if (token) {
req.session.token = token
}
next()
@@ -98,14 +98,14 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => {
}
// if the server is invite only and we have no invite id, throw.
if (serverInfo.inviteOnly && !req.session.inviteId) {
if (serverInfo.inviteOnly && !req.session.token) {
throw new Error(
'This server is invite only. Please authenticate yourself through a valid invite link.'
)
}
// validate the invite
const validInvite = await validateServerInvite(user.email, req.session.inviteId)
const validInvite = await validateServerInvite(user.email, req.session.token)
// create the user
const myUser = await findOrCreateUser({ user, rawProfile: req.user._json })
@@ -72,14 +72,14 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => {
}
// if the server is invite only and we have no invite id, throw.
if (serverInfo.inviteOnly && !req.session.inviteId) {
if (serverInfo.inviteOnly && !req.session.token) {
throw new Error(
'This server is invite only. Please authenticate yourself through a valid invite link.'
)
}
// validate the invite
const validInvite = await validateServerInvite(user.email, req.session.inviteId)
const validInvite = await validateServerInvite(user.email, req.session.token)
// create the user
const myUser = await findOrCreateUser({ user, rawProfile: profile._raw })
@@ -69,14 +69,14 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => {
}
// if the server is invite only and we have no invite id, throw.
if (serverInfo.inviteOnly && !req.session.inviteId) {
if (serverInfo.inviteOnly && !req.session.token) {
throw new Error(
'This server is invite only. Please authenticate yourself through a valid invite link.'
)
}
// validate the invite
const validInvite = await validateServerInvite(user.email, req.session.inviteId)
const validInvite = await validateServerInvite(user.email, req.session.token)
// create the user
const myUser = await findOrCreateUser({ user, rawProfile: profile._raw })
@@ -86,14 +86,14 @@ module.exports = async (app, session, sessionAppId, finalizeAuth) => {
}
// 1. if the server is invite only you must have an invite
if (serverInfo.inviteOnly && !req.session.inviteId)
if (serverInfo.inviteOnly && !req.session.token)
throw new Error('This server is invite only. Please provide an invite id.')
// 2. if you have an invite it must be valid, both for invite only and public servers
/** @type {import('@/modules/serverinvites/repositories').ServerInviteRecord} */
let invite
if (req.session.inviteId) {
invite = await validateServerInvite(user.email, req.session.inviteId)
if (req.session.token) {
invite = await validateServerInvite(user.email, req.session.token)
}
// 3. at this point we know, that we have one of these cases:
@@ -71,18 +71,23 @@ describe('Auth @auth', () => {
})
const inviteTypeDataSet = [
{ display: 'stream invite', streamInvite: true },
{ display: 'server invite', streamInvite: false }
{ display: 'stream invite', streamInvite: true, withOldStyleParam: false },
{ display: 'server invite', streamInvite: false, withOldStyleParam: false },
{
display: 'server invite (old style param)',
streamInvite: false,
withOldStyleParam: true
}
]
inviteTypeDataSet.forEach(({ display, streamInvite }) => {
inviteTypeDataSet.forEach(({ display, streamInvite, withOldStyleParam }) => {
it(`Allows registering with a ${display} in an invite-only server`, async () => {
await updateServerInfo({ inviteOnly: true })
const targetEmail = `invited.bunny.${
streamInvite ? 'stream' : 'server'
const targetEmail = `invited.bunny.${streamInvite ? 'stream' : 'server'}.${
withOldStyleParam ? 'oldparam' : 'newparam'
}@speckle.systems`
const inviterUser = await getUserByEmail({ email: registeredUserEmail })
const inviteId = await createInviteDirectly(
const { token, inviteId } = await createInviteDirectly(
streamInvite
? {
email: targetEmail,
@@ -129,7 +134,11 @@ describe('Auth @auth', () => {
// finally correct
await request(app)
.post('/auth/local/register?challenge=test&suuid=test&inviteId=' + inviteId)
.post(
'/auth/local/register?challenge=test&suuid=test&' +
(withOldStyleParam ? 'inviteId=' : 'token=') +
token
)
.send({
email: targetEmail,
name: 'dimitrie stefanescu',
@@ -0,0 +1,60 @@
const { Users } = require('@/modules/core/dbSchema')
const COMMENTS_TABLE = 'comments'
const COMMENT_VIEWS_TABLE = 'comment_views'
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function (knex) {
// Delete all orphaned comments, which can be there even though there was a FK there before for some reason
await knex
.table(COMMENTS_TABLE)
.whereNotNull(`${COMMENTS_TABLE}.parentComment`)
.whereNotIn(
`${COMMENTS_TABLE}.parentComment`,
knex.table(`${COMMENTS_TABLE} as c2`).select('c2.id')
)
.delete()
// Fix comments FKs
await knex.schema.alterTable(COMMENTS_TABLE, (table) => {
table.dropForeign('authorId')
table.foreign('authorId').references(Users.col.id).onDelete('CASCADE')
table.dropForeign('parentComment')
table
.foreign('parentComment')
.references(`${COMMENTS_TABLE}.id`)
.onDelete('CASCADE')
})
// Fix comment_views FKs
await knex.schema.alterTable(COMMENT_VIEWS_TABLE, (table) => {
table.dropForeign('userId')
table.foreign('userId').references(Users.col.id).onDelete('CASCADE')
})
}
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function (knex) {
await knex.schema.alterTable(COMMENTS_TABLE, (table) => {
table.dropForeign('authorId')
table.foreign('authorId').references(Users.col.id).onDelete('NO ACTION')
table.dropForeign('parentComment')
table
.foreign('parentComment')
.references(`${COMMENTS_TABLE}.id`)
.onDelete('NO ACTION')
})
await knex.schema.alterTable(COMMENT_VIEWS_TABLE, (table) => {
table.dropForeign('userId')
table.foreign('userId').references(Users.col.id).onDelete('NO ACTION')
})
}
+2 -1
View File
@@ -99,7 +99,8 @@ module.exports = {
message: 'server_invites.message',
resourceTarget: 'server_invites.resourceTarget',
resourceId: 'server_invites.resourceId',
role: 'server_invites.role'
role: 'server_invites.role',
token: 'server_invites.token'
}
},
knex
@@ -413,6 +413,24 @@ module.exports = {
const { streamId } = parent
const stream = await ctx.loaders.streams.getStream.load(streamId)
return stream.name
},
/**
* @param {import('@/modules/serverinvites/services/inviteRetrievalService').PendingStreamCollaboratorGraphQLType} parent
* @param {Object} _args
* @param {import('@/modules/shared/index').GraphQLContext} ctx
*/
async token(parent, _args, ctx) {
const authedUserId = ctx.userId
const targetUserId = parent.user?.id
const inviteId = parent.inviteId
// Only returning it for the user that is the pending stream collaborator
if (!authedUserId || !targetUserId || authedUserId !== targetUserId) {
return null
}
const invite = await ctx.loaders.invites.getInvite.load(inviteId)
return invite?.token || null
}
}
}
@@ -88,6 +88,10 @@ type PendingStreamCollaborator {
Set only if user is registered
"""
user: LimitedUser
"""
Only available if the active user is the pending stream collaborator
"""
token: String
}
type StreamCollection {
+13
View File
@@ -7,6 +7,7 @@ const {
} = require('@/modules/core/repositories/streams')
const { getUsers } = require('@/modules/core/repositories/users')
const { keyBy } = require('lodash')
const { getInvites } = require('@/modules/serverinvites/repositories')
/**
* All DataLoaders available on the GQL ctx object
@@ -20,6 +21,9 @@ const { keyBy } = require('lodash')
* @property {{
* getUser: DataLoader<string, import('@/modules/core/helpers/userHelper').UserRecord>
* }} users
* @property {{
* getInvite: DataLoader<string, import('@/modules/serverinvites/repositories').ServerInviteRecord>
* }} invites
*/
module.exports = {
@@ -80,6 +84,15 @@ module.exports = {
const results = keyBy(await getUsers(userIds), 'id')
return userIds.map((i) => results[i])
})
},
invites: {
/**
* Get invite from DB
*/
getInvite: new DataLoader(async (inviteIds) => {
const results = keyBy(await getInvites(inviteIds), 'id')
return inviteIds.map((i) => results[i])
})
}
}
}
@@ -144,7 +144,7 @@ describe('[Admin users list]', () => {
// Create a few more stream invites to registered users, which should not appear in
// the users list
const inviteIds = await Promise.all(
const createdInvitesData = await Promise.all(
times(3, () => {
const { id: streamId, ownerId } = randomEl(streamData)
const userId = randomEl(userIds.filter((i) => i !== ownerId))
@@ -158,7 +158,8 @@ describe('[Admin users list]', () => {
)
})
)
if (!inviteIds.every((id) => !!id))
if (!createdInvitesData.every(({ inviteId, token }) => inviteId && token))
throw new Error('Stream invite generation failed')
// Resolve ordered ids
@@ -37,7 +37,7 @@ module.exports = {
async streamInviteCreate(_parent, args, context) {
await authorizeResolver(context.userId, args.input.streamId, Roles.Stream.Owner)
const { email, userId, message, streamId } = args.input
const { email, userId, message, streamId, role } = args.input
if (!email && !userId) {
throw new InviteCreateValidationError(
@@ -52,7 +52,7 @@ module.exports = {
message,
resourceTarget: ResourceTargets.Streams,
resourceId: streamId,
role: Roles.Stream.Contributor
role: role || Roles.Stream.Contributor
})
return true
@@ -96,7 +96,7 @@ module.exports = {
for (const paramsBatchArray of batches) {
await Promise.all(
paramsBatchArray.map((params) => {
const { email, userId, message, streamId } = params
const { email, userId, message, streamId, role } = params
const target = userId ? buildUserTarget(userId) : email
return createAndSendInvite({
target,
@@ -104,7 +104,7 @@ module.exports = {
message,
resourceTarget: ResourceTargets.Streams,
resourceId: streamId,
role: Roles.Stream.Contributor
role: role || Roles.Stream.Contributor
})
})
)
@@ -114,10 +114,10 @@ module.exports = {
},
async streamInviteUse(_parent, args, ctx) {
const { accept, streamId, inviteId } = args
const { accept, streamId, token } = args
const { userId } = ctx
await finalizeStreamInvite(accept, streamId, inviteId, userId)
await finalizeStreamInvite(accept, streamId, token, userId)
return true
},
@@ -150,9 +150,9 @@ module.exports = {
},
Query: {
async streamInvite(_parent, args, context) {
const { streamId, inviteId } = args
const { streamId, token } = args
return await getUserPendingStreamInvite(streamId, context.userId, inviteId)
return await getUserPendingStreamInvite(streamId, context.userId, token)
},
async streamInvites(_parrent, _args, context) {
const { userId } = context
@@ -24,7 +24,7 @@ extend type Mutation {
"""
Accept or decline a stream invite
"""
streamInviteUse(accept: Boolean!, streamId: String!, inviteId: String!): Boolean!
streamInviteUse(accept: Boolean!, streamId: String!, token: String!): Boolean!
@hasRole(role: "server:user")
"""
@@ -51,10 +51,10 @@ extend type Mutation {
extend type Query {
"""
Look for an invitation to a stream, for the current user (authed or not). If inviteId
Look for an invitation to a stream, for the current user (authed or not). If token
isn't specified, the server will look for any valid invite.
"""
streamInvite(streamId: String!, inviteId: String): PendingStreamCollaborator
streamInvite(streamId: String!, token: String): PendingStreamCollaborator
"""
Get all invitations to streams that the active user has
@@ -80,4 +80,8 @@ input StreamInviteCreateInput {
userId: String
streamId: String!
message: String
"""
Defaults to the contributor role, if not specified
"""
role: String
}
@@ -0,0 +1,31 @@
const { ServerInvites } = require('@/modules/core/dbSchema')
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function (knex) {
await knex.schema.alterTable(ServerInvites.name, (table) => {
// Add token field
table.string('token', 256).defaultTo('').notNullable()
table.index('token')
})
// Update all pre-existing rows and move inviteId to token
await knex.raw(`
UPDATE ${ServerInvites.name}
SET token = COALESCE(id, '')
WHERE coalesce(TRIM(token), '') = ''
`)
}
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function (knex) {
await knex.schema.alterTable(ServerInvites.name, (table) => {
// Drop token field
table.dropColumn('token')
})
}
@@ -20,7 +20,8 @@ const { getStream } = require('@/modules/core/repositories/streams')
* message?: string,
* resourceTarget?: string,
* resourceId?: string,
* role?: string
* role?: string,
* token: string
* }} ServerInviteRecord
*/
@@ -79,19 +80,19 @@ async function insertInviteAndDeleteOld(invite, alternateTargets = []) {
/**
* Retrieve a valid server invite for the specified target
* @param {string} email Email address
* @param {string|undefined} inviteId Specify an invite ID, if you're looking for
* a specific invite
* @param {string|undefined} token Specify an invite token, if you're looking for
* a specific invite. For backwards compatibility purposes, the token can also just be the invite ID.
* @returns {ServerInviteRecord | null}
*/
async function getServerInvite(email, inviteId = undefined) {
async function getServerInvite(email, token = undefined) {
if (!email) return null
const q = ServerInvites.knex().where({
[ServerInvites.col.target]: email.toLowerCase()
})
if (inviteId) {
q.andWhere(ServerInvites.col.id, inviteId)
if (token) {
q.andWhere(ServerInvites.col.token, token)
}
return await q.first()
@@ -164,15 +165,19 @@ async function getAllUserStreamInvites(userId) {
}
/**
* Retrieve a stream invite for the specified target, inviteId or both.
* Note: Either the target or inviteId must be set
* Retrieve a stream invite for the specified target, token or both.
* Note: Either the target, inviteId or token must be set
* @param {string} streamId
* @param {string|null} target
* @param {string|null} token
* @param {string|null} inviteId
* @returns {Promise<ServerInviteRecord | null>}
*/
async function getStreamInvite(streamId, target = null, inviteId = null) {
if (!target && !inviteId) return null
async function getStreamInvite(
streamId,
{ target = null, token = null, inviteId = null } = {}
) {
if (!target && !token && !inviteId) return null
const q = ServerInvites.knex().where({
[ServerInvites.col.resourceTarget]: ResourceTargets.Streams,
@@ -187,6 +192,10 @@ async function getStreamInvite(streamId, target = null, inviteId = null) {
q.andWhere({
[ServerInvites.col.id]: inviteId
})
} else if (token) {
q.andWhere({
[ServerInvites.col.token]: token
})
}
return await q.first()
@@ -256,6 +265,16 @@ async function getInvite(inviteId) {
return await ServerInvites.knex().where(ServerInvites.col.id, inviteId).first()
}
/**
* Retrieve a specific invite (irregardless of the type) by the token
* @param {string} inviteId
* @returns {Promise<ServerInviteRecord | null>}
*/
async function getInviteByToken(inviteToken) {
if (!inviteToken) return null
return await ServerInvites.knex().where(ServerInvites.col.token, inviteToken).first()
}
/**
* Delete a specific invite (irregardless of the type)
* @param {string} inviteId
@@ -307,6 +326,15 @@ async function deleteAllUserInvites(userId) {
return true
}
/**
* Get all invites by IDs
* @returns {Promise<ServerInviteRecord[]>}
*/
async function getInvites(inviteIds) {
if (!inviteIds?.length) return []
return await ServerInvites.knex().whereIn(ServerInvites.col.id, inviteIds)
}
module.exports = {
insertInviteAndDeleteOld,
getServerInvite,
@@ -323,5 +351,7 @@ module.exports = {
deleteInvitesByTarget,
deleteAllUserInvites,
getResource,
getAllUserStreamInvites
getAllUserStreamInvites,
getInvites,
getInviteByToken
}
@@ -106,7 +106,7 @@ function validateTargetUser(params, targetUser) {
* @param {import('@/modules/core/helpers/userHelper').UserRecord | undefined} targetUser Target user, if one exists in our DB
*/
async function validateResource(params, resource, targetUser) {
const { resourceId, resourceTarget } = params
const { resourceId, resourceTarget, role } = params
if (resourceId && !resource) {
throw new InviteCreateValidationError("Couldn't resolve invite resource")
@@ -125,6 +125,10 @@ async function validateResource(params, resource, targetUser) {
)
}
}
if (!Object.values(Roles.Stream).includes(role)) {
throw new InviteCreateValidationError('Unexpected stream invite role')
}
}
}
@@ -264,18 +268,18 @@ function buildEmailSubject(invite, inviter, resourceName) {
* @returns {string}
*/
function buildInviteLink(invite) {
const { id, resourceTarget, resourceId } = invite
const { resourceTarget, resourceId, token } = invite
if (isServerInvite(invite)) {
return new URL(
`${getRegistrationRoute()}?inviteId=${id}`,
`${getRegistrationRoute()}?token=${token}`,
process.env.CANONICAL_URL
).toString()
}
if (resourceTarget === 'streams') {
return new URL(
`${getStreamRoute(resourceId)}?inviteId=${id}`,
`${getStreamRoute(resourceId)}?token=${token}`,
process.env.CANONICAL_URL
).toString()
} else {
@@ -355,7 +359,8 @@ async function createAndSendInvite(params) {
message,
resourceTarget,
resourceId,
role
role,
token: crs({ length: 50 })
}
await insertInviteAndDeleteOld(
invite,
@@ -380,7 +385,10 @@ async function createAndSendInvite(params) {
: [])
])
return invite.id
return {
inviteId: invite.id,
token: invite.token
}
}
/**
@@ -49,20 +49,20 @@ function resolveAuthRedirectPath(invite) {
/**
* Validate that the new user has a valid invite for registering to the server
* @param {Object} email User's email address
* @param {string} inviteId Invite ID
* @param {string} token Invite token
* @returns {import('@/modules/serverinvites/repositories').ServerInviteRecord}
*/
async function validateServerInvite(email, inviteId) {
const invite = await getServerInvite(email, inviteId)
async function validateServerInvite(email, token) {
const invite = await getServerInvite(email, token)
if (!invite) {
throw new NoInviteFoundError(
inviteId
? "Wrong e-mail address or invite ID. Make sure you're using the same e-mail address that received the invite."
token
? "Wrong e-mail address or invite token. Make sure you're using the same e-mail address that received the invite."
: "Wrong e-mail address. Make sure you're using the same e-mail address that received the invite.",
{
info: {
email,
inviteId
token
}
}
)
@@ -90,16 +90,19 @@ async function finalizeInvitedServerRegistration(email, userId) {
* Accept or decline a stream invite
* @param {boolean} accept
* @param {string} streamId
* @param {string} inviteId
* @param {string} token
* @param {string} userId User who's accepting the invite
*/
async function finalizeStreamInvite(accept, streamId, inviteId, userId) {
const invite = await getStreamInvite(streamId, buildUserTarget(userId), inviteId)
async function finalizeStreamInvite(accept, streamId, token, userId) {
const invite = await getStreamInvite(streamId, {
token,
target: buildUserTarget(userId)
})
if (!invite) {
throw new NoInviteFoundError('Attempted to finalize nonexistant stream invite', {
info: {
streamId,
inviteId,
token,
userId
}
})
@@ -141,7 +144,7 @@ async function finalizeStreamInvite(accept, streamId, inviteId, userId) {
* @param {string} inviteId
*/
async function cancelStreamInvite(streamId, inviteId) {
const invite = await getStreamInvite(streamId, null, inviteId)
const invite = await getStreamInvite(streamId, { inviteId })
if (!invite) {
throw new NoInviteFoundError('Attempted to process nonexistant stream invite', {
info: {
@@ -14,6 +14,9 @@ const {
const { keyBy, uniq } = require('lodash')
/**
* The token field is intentionally ommited from this and only managed through the .token resolver
* for extra security - so that no one accidentally returns it out from this service
*
* @typedef {{
* id: string,
* inviteId: string,
@@ -21,7 +24,7 @@ const { keyBy, uniq } = require('lodash')
* title: string,
* role: string,
* invitedById: string,
* user: import('@/modules/core/helpers/userHelper').LimitedUserRecord | null
* user: import('@/modules/core/helpers/userHelper').LimitedUserRecord | null,
* }} PendingStreamCollaboratorGraphQLType
*/
@@ -100,17 +103,16 @@ async function getPendingStreamCollaborators(streamId) {
* Either the user ID or invite ID must be set
* @param {string} streamId
* @param {string|null} userId
* @param {string|null} inviteId
* @param {string|null} token
* @returns {Promise<PendingStreamCollaboratorGraphQLType>}
*/
async function getUserPendingStreamInvite(streamId, userId, inviteId) {
if (!userId && !inviteId) return null
async function getUserPendingStreamInvite(streamId, userId, token) {
if (!userId && !token) return null
const invite = await getStreamInvite(
streamId,
userId ? buildUserTarget(userId) : null,
inviteId
)
const invite = await getStreamInvite(streamId, {
target: buildUserTarget(userId),
token
})
if (!invite) return null
const targetUser = userId ? await getUser(userId) : null
@@ -34,7 +34,10 @@ const {
createStream,
grantPermissionsStream
} = require('@/modules/core/services/streams')
const { getInvite: getInviteFromDB } = require('@/modules/serverinvites/repositories')
const {
getInviteByToken,
getInvite: getInviteFromDB
} = require('@/modules/serverinvites/repositories')
const { getUserStreamRole } = require('@/test/speckle-helpers/streamHelper')
const { createInviteDirectly } = require('@/test/speckle-helpers/inviteHelper')
const { buildAuthenticatedApolloServer } = require('@/test/serverHelper')
@@ -43,18 +46,20 @@ async function cleanup() {
await truncateTables([ServerInvites.name, Streams.name, Users.name])
}
function getInviteIdFromEmailParams(emailParams) {
function getInviteTokenFromEmailParams(emailParams) {
const { text } = emailParams
const [, inviteId] = text.match(/\?inviteId=(.*)\s/i)
const [, inviteId] = text.match(/\?token=(.*)\s/i)
return inviteId
}
async function validateInviteExistanceFromEmail(emailParams) {
// Validate that invite exists
const inviteId = getInviteIdFromEmailParams(emailParams)
expect(inviteId).to.be.ok
const invite = await getInviteFromDB(inviteId)
const token = getInviteTokenFromEmailParams(emailParams)
expect(token).to.be.ok
const invite = await getInviteByToken(token)
expect(invite).to.be.ok
return invite
}
describe('[Stream & Server Invites]', () => {
@@ -252,23 +257,51 @@ describe('[Stream & Server Invites]', () => {
})
})
it("can't invite with an invalid role", async () => {
const result = await createInvite({
email: 'badroleguy@speckle.com',
streamId: myPrivateStream.id,
role: 'aaa'
})
expect(result.data?.streamInviteCreate).to.be.not.ok
expect(result.errors).to.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'Unexpected stream invite role'
)
})
const userTypesDataSet = [
{
display: 'registered',
display: 'registered user',
user: otherGuy,
stream: myPrivateStream,
email: null
},
{
display: 'unregistered',
display: 'registered user (with custom role)',
user: otherGuy,
stream: myPrivateStream,
email: null,
role: Roles.Stream.Owner
},
{
display: 'unregistered user',
user: null,
stream: myPrivateStream,
email: 'randomer22@lool.com'
},
{
display: 'unregistered user (with custom role)',
user: null,
stream: myPrivateStream,
email: 'randomer22@lool.com',
Role: Roles.Stream.Reviewer
}
]
userTypesDataSet.forEach(({ display, user, stream, email }) => {
it(`can invite a ${display} user`, async () => {
userTypesDataSet.forEach(({ display, user, stream, email, role }) => {
it(`can invite a ${display}`, async () => {
const messagePart1 = '1234hiiiiduuuuude'
const messagePart2 = 'yepppppp'
const unsanitaryMessage = `<a href="https://google.com">${messagePart1}</a> <script>${messagePart2}</script>`
@@ -284,7 +317,8 @@ describe('[Stream & Server Invites]', () => {
email,
message: unsanitaryMessage,
userId: user?.id || null,
streamId: stream?.id || null
streamId: stream?.id || null,
role: role || null
})
// Check that operation was successful
@@ -303,7 +337,8 @@ describe('[Stream & Server Invites]', () => {
expect(emailParams.html).to.not.contain(messagePart2)
// Validate that invite exists
await validateInviteExistanceFromEmail(emailParams)
const invite = await validateInviteExistanceFromEmail(emailParams)
expect(invite.role).to.eq(role || Roles.Stream.Contributor)
})
})
@@ -348,21 +383,24 @@ describe('[Stream & Server Invites]', () => {
const serverInvite1 = {
message: 'some server invite1',
email: 'serverinvite1recipient@google.com',
inviteId: undefined
inviteId: undefined,
token: undefined
}
const streamInvite1 = {
message: 'some stream invite1',
email: 'somestreaminvite1recipient@google.com',
stream: myPrivateStream,
inviteId: undefined
inviteId: undefined,
token: undefined
}
const streamInvite2 = {
message: 'some stream invite2',
user: otherGuy,
stream: myPrivateStream,
inviteId: undefined
inviteId: undefined,
token: undefined
}
const invites = [serverInvite1, streamInvite1, streamInvite2]
@@ -382,7 +420,10 @@ describe('[Stream & Server Invites]', () => {
// Creating some invites
await Promise.all(
invites.map((i) =>
createInviteDirectly(i, me.id).then((id) => (i.inviteId = id))
createInviteDirectly(i, me.id).then((o) => {
i.inviteId = o.inviteId
i.token = o.token
})
)
)
})
@@ -414,18 +455,23 @@ describe('[Stream & Server Invites]', () => {
{
message: 'some server invite1',
email: 'serverinvite1recipient@google.com',
inviteId: undefined
inviteId: undefined,
token: undefined
},
{
message: 'some stream invite1',
email: 'somestreaminvite1recipient@google.com',
stream: myPrivateStream,
inviteId: undefined
inviteId: undefined,
token: undefined
}
]
await Promise.all(
deletableInvites.map((i) =>
createInviteDirectly(i, me.id).then((id) => (i.inviteId = id))
createInviteDirectly(i, me.id).then((o) => {
i.inviteId = o.inviteId
i.token = o.token
})
)
)
@@ -489,6 +535,12 @@ describe('[Stream & Server Invites]', () => {
userId: otherGuy.id,
message: 'waddup',
streamId: myPrivateStream.id
},
{
email: 'someroleguy@asdasdad.com',
message: 'yoo bruh',
streamId: myPrivateStream.id,
role: Roles.Stream.Reviewer
}
]
@@ -511,7 +563,9 @@ describe('[Stream & Server Invites]', () => {
expect(emailParams).to.be.ok
expect(emailParams.html).to.contain(inputData.message)
expect(emailParams.text).to.contain(inputData.message)
await validateInviteExistanceFromEmail(emailParams)
const invite = await validateInviteExistanceFromEmail(emailParams)
expect(invite.role).to.eq(inputData.role || Roles.Stream.Contributor)
}
})
})
@@ -521,26 +575,28 @@ describe('[Stream & Server Invites]', () => {
message: 'some stream invite3',
user: me,
stream: otherGuysStream,
inviteId: undefined
inviteId: undefined,
token: undefined
}
beforeEach(async () => {
// Create an invite before each test so that we can mutate them
// in each test as needed
await createInviteDirectly(inviteFromOtherGuy, otherGuy.id).then(
(id) => (inviteFromOtherGuy.inviteId = id)
)
await createInviteDirectly(inviteFromOtherGuy, otherGuy.id).then((o) => {
inviteFromOtherGuy.inviteId = o.inviteId
inviteFromOtherGuy.token = o.token
})
})
const inviteRetrievalDataset = [
{ display: 'by id', withId: true },
{ display: 'without an invite ID', withId: false }
{ display: 'by token', withId: true },
{ display: 'without a token', withId: false }
]
inviteRetrievalDataset.forEach(({ display, withId }) => {
it(`the invite can be retrieved ${display}`, async () => {
const result = await getStreamInvite(apollo, {
streamId: inviteFromOtherGuy.stream.id,
inviteId: withId ? inviteFromOtherGuy.inviteId : null
token: withId ? inviteFromOtherGuy.token : null
})
expect(result.data?.streamInvite).to.be.ok
@@ -548,6 +604,7 @@ describe('[Stream & Server Invites]', () => {
const data = result.data.streamInvite
expect(data.inviteId).to.eq(inviteFromOtherGuy.inviteId)
expect(data.token).to.eq(inviteFromOtherGuy.token)
expect(data.streamId).to.eq(inviteFromOtherGuy.stream.id)
expect(data.title).to.eq(me.name)
@@ -565,12 +622,13 @@ describe('[Stream & Server Invites]', () => {
]
useUpDataSet.forEach(({ display, accept }) => {
it(`the invite can be ${display}`, async () => {
const token = inviteFromOtherGuy.token
const inviteId = inviteFromOtherGuy.inviteId
const streamId = inviteFromOtherGuy.stream.id
const { data, errors } = await useUpStreamInvite(apollo, {
accept,
inviteId,
token,
streamId
})
@@ -633,19 +691,24 @@ describe('[Stream & Server Invites]', () => {
// Create a couple of static invites that shouldn't be mutated in tests
await Promise.all([
createInviteDirectly(myInvite, me.id).then((id) => (myInvite.inviteId = id)),
createInviteDirectly(otherGuysInvite, otherGuy.id).then(
(id) => (otherGuysInvite.inviteId = id)
)
createInviteDirectly(myInvite, me.id).then((o) => {
myInvite.inviteId = o.inviteId
myInvite.token = o.token
}),
createInviteDirectly(otherGuysInvite, otherGuy.id).then((o) => {
otherGuysInvite.inviteId = o.inviteId
otherGuysInvite.token = o.token
})
])
})
beforeEach(async () => {
// Create an invite before each test so that we can mutate them
// in each test as needed
await createInviteDirectly(dynamicInvite, me.id).then(
(id) => (dynamicInvite.inviteId = id)
)
await createInviteDirectly(dynamicInvite, me.id).then((o) => {
dynamicInvite.inviteId = o.inviteId
dynamicInvite.token = o.token
})
})
it('a pending invite can be deleted', async () => {
@@ -670,8 +733,15 @@ describe('[Stream & Server Invites]', () => {
expect(errors).to.be.not.ok
expect(data.stream).to.be.ok
expect(data.stream.id).to.eq(streamId)
expect(data.stream.pendingCollaborators || []).to.have.length(1)
expect(data.stream.pendingCollaborators[0].user?.id).to.eq(otherGuy.id)
const pendingCollaborators = data.stream.pendingCollaborators || []
expect(pendingCollaborators).to.have.length(1)
const pendingCollaborator = pendingCollaborators[0]
expect(pendingCollaborator.user?.id).to.eq(otherGuy.id)
// tokens shouldn't be resolved, as they're for other people
expect(pendingCollaborator.token).to.be.null
})
it("a foreign stream's pending collaborators can't be retrieved", async () => {
+12 -9
View File
@@ -12,7 +12,8 @@ const { gql } = require('apollo-server-express')
* email: string | null,
* userId: string | null,
* streamId: string,
* message: string
* message: string,
* role: string | null
* }} StreamInviteCreateInput
*/
@@ -59,6 +60,7 @@ const streamInviteFragment = gql`
streamId
title
role
token
invitedBy {
id
name
@@ -79,8 +81,8 @@ const streamInviteFragment = gql`
`
const streamInviteQuery = gql`
query ($streamId: String!, $inviteId: String) {
streamInvite(streamId: $streamId, inviteId: $inviteId) {
query ($streamId: String!, $token: String) {
streamInvite(streamId: $streamId, token: $token) {
...StreamInviteData
}
}
@@ -99,8 +101,8 @@ const streamInvitesQuery = gql`
`
const useStreamInviteMutation = gql`
mutation ($accept: Boolean!, $streamId: String!, $inviteId: String!) {
streamInviteUse(accept: $accept, streamId: $streamId, inviteId: $inviteId)
mutation ($accept: Boolean!, $streamId: String!, $token: String!) {
streamInviteUse(accept: $accept, streamId: $streamId, token: $token)
}
`
@@ -117,6 +119,7 @@ const streamPendingCollaboratorsQuery = gql`
pendingCollaborators {
inviteId
title
token
user {
id
name
@@ -209,10 +212,10 @@ module.exports = {
* streamInvite query
* @param {import('apollo-server-express').ApolloServer} apollo
*/
getStreamInvite(apollo, { streamId, inviteId }) {
getStreamInvite(apollo, { streamId, token }) {
return apollo.executeOperation({
query: streamInviteQuery,
variables: { streamId, inviteId }
variables: { streamId, token }
})
},
/**
@@ -228,10 +231,10 @@ module.exports = {
* streamInviteUse mutation
* @param {import('apollo-server-express').ApolloServer} apollo
*/
useUpStreamInvite(apollo, { accept, streamId, inviteId }) {
useUpStreamInvite(apollo, { accept, streamId, token }) {
return apollo.executeOperation({
query: useStreamInviteMutation,
variables: { accept, streamId, inviteId }
variables: { accept, streamId, token }
})
},
/**
@@ -19,6 +19,8 @@ const {
* streamId?: string
* }} invite
* @param {string} creatorId
*
* @returns {Promise<{inviteId: string, token: string}>}
*/
function createInviteDirectly(invite, creatorId) {
const userId = invite.userId || invite.user?.id || null