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:
committed by
GitHub
parent
8ecc7f5a68
commit
3ff772e342
@@ -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'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
|
||||
@@ -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,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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
+60
@@ -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')
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+31
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user