~netlandish/links

a07e05d43254dc68f73304df8e95e421a75724ad — Peter Sanchez 4 months ago 1a5c05c + a8de3b0
Merge userorgs branch
M api/graph/generated.go => api/graph/generated.go +232 -4
@@ 227,6 227,7 @@ type ComplexityRoot struct {
		DeleteLinkShort        func(childComplexity int, id int) int
		DeleteListing          func(childComplexity int, id int) int
		DeleteListingLink      func(childComplexity int, id int) int
		DeleteMember           func(childComplexity int, orgSlug string, email string) int
		DeleteQRCode           func(childComplexity int, id int) int
		Follow                 func(childComplexity int, orgSlug string) int
		Register               func(childComplexity int, input *model.RegisterInput) int


@@ 374,7 375,7 @@ type ComplexityRoot struct {
		GetOrgLinks           func(childComplexity int, input *model.GetLinkInput) int
		GetOrgMembers         func(childComplexity int, orgSlug string) int
		GetOrganization       func(childComplexity int, id int) int
		GetOrganizations      func(childComplexity int) int
		GetOrganizations      func(childComplexity int, input *model.GetOrganizationsInput) int
		GetPaymentHistory     func(childComplexity int, input *model.GetPaymentInput) int
		GetPopularLinks       func(childComplexity int, input *model.PopularLinksInput) int
		GetQRDetail           func(childComplexity int, hashID string, orgSlug *string) int


@@ 438,6 439,7 @@ type MutationResolver interface {
	DeleteLink(ctx context.Context, id int) (*model.DeletePayload, error)
	AddNote(ctx context.Context, input *model.NoteInput) (*models.OrgLink, error)
	AddMember(ctx context.Context, input *model.MemberInput) (*model.AddMemberPayload, error)
	DeleteMember(ctx context.Context, orgSlug string, email string) (*model.AddMemberPayload, error)
	ConfirmMember(ctx context.Context, key string) (*model.AddMemberPayload, error)
	Register(ctx context.Context, input *model.RegisterInput) (*models.User, error)
	CompleteRegister(ctx context.Context, input *model.CompleteRegisterInput) (*models.User, error)


@@ 475,7 477,7 @@ type QueryResolver interface {
	Me(ctx context.Context) (*models.User, error)
	GetUsers(ctx context.Context, input *model.GetUserInput) (*model.UserCursor, error)
	GetUser(ctx context.Context, id int) (*models.User, error)
	GetOrganizations(ctx context.Context) ([]*models.Organization, error)
	GetOrganizations(ctx context.Context, input *model.GetOrganizationsInput) ([]*models.Organization, error)
	GetOrganization(ctx context.Context, id int) (*models.Organization, error)
	GetPaymentHistory(ctx context.Context, input *model.GetPaymentInput) (*model.PaymentCursor, error)
	GetPopularLinks(ctx context.Context, input *model.PopularLinksInput) ([]*models.BaseURL, error)


@@ 1394,6 1396,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in

		return e.complexity.Mutation.DeleteListingLink(childComplexity, args["id"].(int)), true

	case "Mutation.deleteMember":
		if e.complexity.Mutation.DeleteMember == nil {
			break
		}

		args, err := ec.field_Mutation_deleteMember_args(context.TODO(), rawArgs)
		if err != nil {
			return 0, false
		}

		return e.complexity.Mutation.DeleteMember(childComplexity, args["orgSlug"].(string), args["email"].(string)), true

	case "Mutation.deleteQRCode":
		if e.complexity.Mutation.DeleteQRCode == nil {
			break


@@ 2289,7 2303,12 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
			break
		}

		return e.complexity.Query.GetOrganizations(childComplexity), true
		args, err := ec.field_Query_getOrganizations_args(context.TODO(), rawArgs)
		if err != nil {
			return 0, false
		}

		return e.complexity.Query.GetOrganizations(childComplexity, args["input"].(*model.GetOrganizationsInput)), true

	case "Query.getPaymentHistory":
		if e.complexity.Query.GetPaymentHistory == nil {


@@ 2561,6 2580,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
		ec.unmarshalInputGetLinkShortInput,
		ec.unmarshalInputGetListingDetailInput,
		ec.unmarshalInputGetListingInput,
		ec.unmarshalInputGetOrganizationsInput,
		ec.unmarshalInputGetPaymentInput,
		ec.unmarshalInputGetUserInput,
		ec.unmarshalInputLinkInput,


@@ 2951,6 2971,30 @@ func (ec *executionContext) field_Mutation_deleteListing_args(ctx context.Contex
	return args, nil
}

func (ec *executionContext) field_Mutation_deleteMember_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
	var err error
	args := map[string]interface{}{}
	var arg0 string
	if tmp, ok := rawArgs["orgSlug"]; ok {
		ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("orgSlug"))
		arg0, err = ec.unmarshalNString2string(ctx, tmp)
		if err != nil {
			return nil, err
		}
	}
	args["orgSlug"] = arg0
	var arg1 string
	if tmp, ok := rawArgs["email"]; ok {
		ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email"))
		arg1, err = ec.unmarshalNString2string(ctx, tmp)
		if err != nil {
			return nil, err
		}
	}
	args["email"] = arg1
	return args, nil
}

func (ec *executionContext) field_Mutation_deleteQRCode_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
	var err error
	args := map[string]interface{}{}


@@ 3476,6 3520,21 @@ func (ec *executionContext) field_Query_getOrganization_args(ctx context.Context
	return args, nil
}

func (ec *executionContext) field_Query_getOrganizations_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
	var err error
	args := map[string]interface{}{}
	var arg0 *model.GetOrganizationsInput
	if tmp, ok := rawArgs["input"]; ok {
		ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("input"))
		arg0, err = ec.unmarshalOGetOrganizationsInput2ᚖlinksᚋapiᚋgraphᚋmodelᚐGetOrganizationsInput(ctx, tmp)
		if err != nil {
			return nil, err
		}
	}
	args["input"] = arg0
	return args, nil
}

func (ec *executionContext) field_Query_getPaymentHistory_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
	var err error
	args := map[string]interface{}{}


@@ 9420,6 9479,95 @@ func (ec *executionContext) fieldContext_Mutation_addMember(ctx context.Context,
	return fc, nil
}

func (ec *executionContext) _Mutation_deleteMember(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_Mutation_deleteMember(ctx, field)
	if err != nil {
		return graphql.Null
	}
	ctx = graphql.WithFieldContext(ctx, fc)
	defer func() {
		if r := recover(); r != nil {
			ec.Error(ctx, ec.Recover(ctx, r))
			ret = graphql.Null
		}
	}()
	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
		directive0 := func(rctx context.Context) (interface{}, error) {
			ctx = rctx // use context from middleware stack in children
			return ec.resolvers.Mutation().DeleteMember(rctx, fc.Args["orgSlug"].(string), fc.Args["email"].(string))
		}
		directive1 := func(ctx context.Context) (interface{}, error) {
			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "ORGS")
			if err != nil {
				return nil, err
			}
			kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RW")
			if err != nil {
				return nil, err
			}
			if ec.directives.Access == nil {
				return nil, errors.New("directive access is not implemented")
			}
			return ec.directives.Access(ctx, nil, directive0, scope, kind)
		}

		tmp, err := directive1(rctx)
		if err != nil {
			return nil, graphql.ErrorOnPath(ctx, err)
		}
		if tmp == nil {
			return nil, nil
		}
		if data, ok := tmp.(*model.AddMemberPayload); ok {
			return data, nil
		}
		return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/api/graph/model.AddMemberPayload`, tmp)
	})
	if err != nil {
		ec.Error(ctx, err)
		return graphql.Null
	}
	if resTmp == nil {
		if !graphql.HasFieldError(ctx, fc) {
			ec.Errorf(ctx, "must not be null")
		}
		return graphql.Null
	}
	res := resTmp.(*model.AddMemberPayload)
	fc.Result = res
	return ec.marshalNAddMemberPayload2ᚖlinksᚋapiᚋgraphᚋmodelᚐAddMemberPayload(ctx, field.Selections, res)
}

func (ec *executionContext) fieldContext_Mutation_deleteMember(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
	fc = &graphql.FieldContext{
		Object:     "Mutation",
		Field:      field,
		IsMethod:   true,
		IsResolver: true,
		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
			switch field.Name {
			case "success":
				return ec.fieldContext_AddMemberPayload_success(ctx, field)
			case "message":
				return ec.fieldContext_AddMemberPayload_message(ctx, field)
			}
			return nil, fmt.Errorf("no field named %q was found under type AddMemberPayload", field.Name)
		},
	}
	defer func() {
		if r := recover(); r != nil {
			err = ec.Recover(ctx, r)
			ec.Error(ctx, err)
		}
	}()
	ctx = graphql.WithFieldContext(ctx, fc)
	if fc.Args, err = ec.field_Mutation_deleteMember_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
		ec.Error(ctx, err)
		return
	}
	return fc, nil
}

func (ec *executionContext) _Mutation_confirmMember(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_Mutation_confirmMember(ctx, field)
	if err != nil {


@@ 15988,7 16136,7 @@ func (ec *executionContext) _Query_getOrganizations(ctx context.Context, field g
	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
		directive0 := func(rctx context.Context) (interface{}, error) {
			ctx = rctx // use context from middleware stack in children
			return ec.resolvers.Query().GetOrganizations(rctx)
			return ec.resolvers.Query().GetOrganizations(rctx, fc.Args["input"].(*model.GetOrganizationsInput))
		}
		directive1 := func(ctx context.Context) (interface{}, error) {
			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "ORGS")


@@ 16068,6 16216,17 @@ func (ec *executionContext) fieldContext_Query_getOrganizations(ctx context.Cont
			return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name)
		},
	}
	defer func() {
		if r := recover(); r != nil {
			err = ec.Recover(ctx, r)
			ec.Error(ctx, err)
		}
	}()
	ctx = graphql.WithFieldContext(ctx, fc)
	if fc.Args, err = ec.field_Query_getOrganizations_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
		ec.Error(ctx, err)
		return
	}
	return fc, nil
}



@@ 22243,6 22402,58 @@ func (ec *executionContext) unmarshalInputGetListingInput(ctx context.Context, o
	return it, nil
}

func (ec *executionContext) unmarshalInputGetOrganizationsInput(ctx context.Context, obj interface{}) (model.GetOrganizationsInput, error) {
	var it model.GetOrganizationsInput
	asMap := map[string]interface{}{}
	for k, v := range obj.(map[string]interface{}) {
		asMap[k] = v
	}

	fieldsInOrder := [...]string{"limit", "after", "before", "search"}
	for _, k := range fieldsInOrder {
		v, ok := asMap[k]
		if !ok {
			continue
		}
		switch k {
		case "limit":
			var err error

			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("limit"))
			it.Limit, err = ec.unmarshalOInt2ᚖint(ctx, v)
			if err != nil {
				return it, err
			}
		case "after":
			var err error

			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
			it.After, err = ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
			if err != nil {
				return it, err
			}
		case "before":
			var err error

			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
			it.Before, err = ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
			if err != nil {
				return it, err
			}
		case "search":
			var err error

			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("search"))
			it.Search, err = ec.unmarshalOString2ᚖstring(ctx, v)
			if err != nil {
				return it, err
			}
		}
	}

	return it, nil
}

func (ec *executionContext) unmarshalInputGetPaymentInput(ctx context.Context, obj interface{}) (model.GetPaymentInput, error) {
	var it model.GetPaymentInput
	asMap := map[string]interface{}{}


@@ 24576,6 24787,15 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
			if out.Values[i] == graphql.Null {
				invalids++
			}
		case "deleteMember":

			out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
				return ec._Mutation_deleteMember(ctx, field)
			})

			if out.Values[i] == graphql.Null {
				invalids++
			}
		case "confirmMember":

			out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {


@@ 28281,6 28501,14 @@ func (ec *executionContext) unmarshalOGetListingInput2ᚖlinksᚋapiᚋgraphᚋm
	return &res, graphql.ErrorOnPath(ctx, err)
}

func (ec *executionContext) unmarshalOGetOrganizationsInput2ᚖlinksᚋapiᚋgraphᚋmodelᚐGetOrganizationsInput(ctx context.Context, v interface{}) (*model.GetOrganizationsInput, error) {
	if v == nil {
		return nil, nil
	}
	res, err := ec.unmarshalInputGetOrganizationsInput(ctx, v)
	return &res, graphql.ErrorOnPath(ctx, err)
}

func (ec *executionContext) unmarshalOGetPaymentInput2ᚖlinksᚋapiᚋgraphᚋmodelᚐGetPaymentInput(ctx context.Context, v interface{}) (*model.GetPaymentInput, error) {
	if v == nil {
		return nil, nil

M api/graph/model/models_gen.go => api/graph/model/models_gen.go +7 -0
@@ 197,6 197,13 @@ type GetListingInput struct {
	ExcludeTag *string `json:"excludeTag,omitempty"`
}

type GetOrganizationsInput struct {
	Limit  *int    `json:"limit,omitempty"`
	After  *Cursor `json:"after,omitempty"`
	Before *Cursor `json:"before,omitempty"`
	Search *string `json:"search,omitempty"`
}

type GetPaymentInput struct {
	OrgSlug   *string `json:"orgSlug,omitempty"`
	Limit     *int    `json:"limit,omitempty"`

M api/graph/schema.graphqls => api/graph/schema.graphqls +9 -1
@@ 424,6 424,13 @@ input GetUserInput {
    search: String
}

input GetOrganizationsInput {
    limit: Int
    after: Cursor
    before: Cursor
    search: String
}

input AdminBillingInput {
    orgSlug: String
    interval: Int


@@ 679,7 686,7 @@ type Query {
    getUser(id: Int!): User! @access(scope: PROFILE, kind: RW)

    "Returns an array of organizations"
    getOrganizations: [Organization!]! @access(scope: ORGS, kind: RO)
    getOrganizations(input: GetOrganizationsInput): [Organization!]! @access(scope: ORGS, kind: RO)

    "Returns a specific organization"
    getOrganization(id: Int!): Organization @access(scope: ORGS, kind: RO)


@@ 756,6 763,7 @@ type Mutation {
    
    "Add organization members"
    addMember(input: MemberInput): AddMemberPayload! @access(scope: ORGS, kind: RW)
    deleteMember(orgSlug: String!, email: String!): AddMemberPayload! @access(scope: ORGS, kind: RW)
    confirmMember(key: String!): AddMemberPayload! @access(scope: PROFILE, kind: RW)

    "Register an account"

M api/graph/schema.resolvers.go => api/graph/schema.resolvers.go +122 -6
@@ 11,6 11,7 @@ import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"html/template"
	"image"
	"image/jpeg"
	"image/png"


@@ 41,6 42,7 @@ import (
	"netlandish.com/x/gobwebs"
	oauth2 "netlandish.com/x/gobwebs-oauth2"
	gaccounts "netlandish.com/x/gobwebs/accounts"
	"netlandish.com/x/gobwebs/crypto"
	"netlandish.com/x/gobwebs/database"
	"netlandish.com/x/gobwebs/email"
	"netlandish.com/x/gobwebs/server"


@@ 739,7 741,8 @@ func (r *mutationResolver) AddMember(ctx context.Context, input *model.MemberInp
		return nil, valid.ErrAuthorization
	}
	currentUser := tokenUser.User.(*models.User)
	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), currentUser)
	c := server.EchoForContext(ctx)
	lang := links.GetLangFromRequest(c.Request(), currentUser)
	lt := localizer.GetLocalizer(lang)

	emailRegex, err := regexp.Compile(links.EmailPattern)


@@ 813,7 816,7 @@ func (r *mutationResolver) AddMember(ctx context.Context, input *model.MemberInp

	// New member to be added
	opts = &database.FilterOptions{
		Filter: sq.Eq{"u.email": input.Email},
		Filter: sq.Eq{"u.email": strings.ToLower(input.Email)},
		Limit:  1,
	}



@@ 829,7 832,7 @@ func (r *mutationResolver) AddMember(ctx context.Context, input *model.MemberInp
	if len(users) == 0 {
		// We create a new user
		user = models.NewUser()
		insecurePass := fmt.Sprintf("%s-%s", input.Email, time.Now())
		insecurePass := crypto.GenerateKey(20, true)
		user.Email = input.Email
		user.SetPassword(base64.StdEncoding.EncodeToString([]byte(insecurePass)))
		if err := user.Store(ctx); err != nil {


@@ 864,18 867,22 @@ func (r *mutationResolver) AddMember(ctx context.Context, input *model.MemberInp
	if err := conf.Store(ctx); err != nil {
		return nil, err
	}

	confURL := links.GetLinksDomainURL(c)
	q := url.Values{}
	q.Set("key", conf.Key)
	if isNewUser {
		q.Set("redirect", "true")
	}
	confURL := fmt.Sprintf("/member/confirm?%s", q.Encode())
	confURL.Path = "/member/confirm"
	confURL.RawQuery = q.Encode()

	data := gobwebs.Map{
		"title":       lt.Translate("Link Taco: Invitation to join organization"),
		"currentUser": currentUser.Name,
		"user":        user.Name,
		"org":         org.Name,
		"confURL":     confURL,
		"confURL":     template.URL(confURL.String()),
	}
	helper := email.NewHelper(srv.Email, tmap, tmpl)
	err = helper.Send(


@@ 891,6 898,94 @@ func (r *mutationResolver) AddMember(ctx context.Context, input *model.MemberInp
	return addMemberPayload, nil
}

// DeleteMember is the resolver for the deleteMember field.
func (r *mutationResolver) DeleteMember(ctx context.Context, orgSlug string, email string) (*model.AddMemberPayload, error) {
	tokenUser := oauth2.ForContext(ctx)
	if tokenUser == nil {
		return nil, valid.ErrAuthorization
	}
	user := tokenUser.User.(*models.User)
	c := server.EchoForContext(ctx)
	lang := links.GetLangFromRequest(c.Request(), user)
	lt := localizer.GetLocalizer(lang)

	emailRegex, err := regexp.Compile(links.EmailPattern)
	if err != nil {
		return nil, fmt.Errorf(lt.Translate("Error compiling url regex: %s", err))
	}

	validator := valid.New(ctx)
	validator.Expect(orgSlug != "", lt.Translate("Org slug is required")).
		WithField("org").
		WithCode(valid.ErrValidationCode)
	validator.Expect(email != "", lt.Translate("User email is required")).
		WithField("email").
		WithCode(valid.ErrValidationCode)
	validator.Expect(len(email) < 255, lt.Translate("Email may not exceed 255 characters")).
		WithField("email").
		WithCode(valid.ErrValidationCode)
	validator.Expect(emailRegex.MatchString(email), lt.Translate("Invalid email format")).
		WithField("email").
		WithCode(valid.ErrValidationCode)

	if !validator.Ok() {
		return nil, nil
	}

	org, err := user.GetOrgsSlug(ctx, models.OrgUserPermissionAdminWrite, orgSlug)
	if err != nil {
		return nil, err
	}
	if org == nil {
		validator.Error(lt.Translate("Organization Not Found")).
			WithCode(valid.ErrNotFoundCode)
		return nil, nil
	}

	opts := &database.FilterOptions{
		Filter: sq.Eq{"u.email": strings.ToLower(email)},
		Limit:  1,
	}

	users, err := models.GetUsers(ctx, opts)
	if err != nil {
		return nil, err
	}
	if len(users) == 0 {
		validator.Error(lt.Translate("User not found for given email")).
			WithCode(valid.ErrNotFoundCode)
		return nil, nil
	}
	duser := users[0]

	opts = &database.FilterOptions{
		Filter: sq.And{
			sq.Eq{"org_id": org.ID},
			sq.Eq{"user_id": duser.ID},
		},
	}
	ousers, err := models.GetOrgUsers(ctx, opts)
	if err != nil {
		return nil, err
	}

	addMemberPayload := &model.AddMemberPayload{}
	if len(ousers) == 0 {
		addMemberPayload.Success = false
		addMemberPayload.Message = lt.Translate("The user for given email is not a member of given organization")
	} else {
		for _, ou := range ousers {
			err = ou.Delete(ctx)
			if err != nil {
				return nil, err
			}
		}
		addMemberPayload.Success = true
		addMemberPayload.Message = lt.Translate("The member was removed successfully")
	}
	return addMemberPayload, nil
}

// ConfirmMember is the resolver for the confirmMember field.
func (r *mutationResolver) ConfirmMember(ctx context.Context, key string) (*model.AddMemberPayload, error) {
	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), nil)


@@ 4251,7 4346,7 @@ func (r *queryResolver) GetUser(ctx context.Context, id int) (*models.User, erro
}

// GetOrganizations is the resolver for the getOrganizations field.
func (r *queryResolver) GetOrganizations(ctx context.Context) ([]*models.Organization, error) {
func (r *queryResolver) GetOrganizations(ctx context.Context, input *model.GetOrganizationsInput) ([]*models.Organization, error) {
	tokenUser := oauth2.ForContext(ctx)
	if tokenUser == nil {
		return nil, valid.ErrAuthorization


@@ 4261,6 4356,27 @@ func (r *queryResolver) GetOrganizations(ctx context.Context) ([]*models.Organiz
		Filter:  sq.Eq{"o.owner_id": user.ID},
		OrderBy: "o.created_on ASC",
	}
	// XXX Uncomment when we decide on a path for org members with admin write permissions
	//opts := &database.FilterOptions{
	//    Filter: sq.Or{
	//        sq.Eq{"o.owner_id": user.ID},
	//        sq.And{
	//            sq.Eq{"ou.user_id": user.ID},
	//            sq.GtOrEq{"ou.permission": models.OrgUserPermissionAdminWrite},
	//            sq.Eq{"ou.is_active": true},
	//        },
	//    },
	//    OrderBy: "o.created_on ASC",
	//}
	if input.Search != nil && *input.Search != "" {
		s := links.ParseSearch(*input.Search)
		opts.Filter = sq.And{
			opts.Filter,
			sq.Expr(`to_tsvector('simple', o.name || ' ' || o.slug )
				@@ to_tsquery('simple', ?)`, s),
		}
	}

	orgs, err := models.GetOrganizations(ctx, opts)
	if err != nil {
		return nil, err

M core/routes.go => core/routes.go +123 -20
@@ 88,7 88,9 @@ func (s *Service) RegisterRoutes() {
	s.eg.POST("/:slug/edit", s.OrgUpdate).Name = s.RouteName("org_edit_post")
	s.eg.GET("/:slug/members", s.OrgMembersList).Name = s.RouteName("org_member_list")
	s.eg.GET("/:slug/members/add", s.OrgMembersAdd).Name = s.RouteName("org_member_add")
	s.eg.POST("/:slug/members/add", s.OrgMembersAdd).Name = s.RouteName("org_member_add")
	s.eg.POST("/:slug/members/add", s.OrgMembersAdd).Name = s.RouteName("org_member_add_post")
	s.eg.GET("/:slug/members/delete/:id", s.OrgMembersDelete).Name = s.RouteName("org_member_delete")
	s.eg.POST("/:slug/members/delete/:id", s.OrgMembersDelete).Name = s.RouteName("org_member_delete_post")
	s.eg.GET("/:slug/export", s.ExportData).Name = s.RouteName("export_data")
	s.eg.POST("/:slug/export", s.ExportData).Name = s.RouteName("export_data_post")
	s.eg.GET("/:slug/import", s.ImportData).Name = s.RouteName("import_data")


@@ 638,13 640,14 @@ func (s *Service) DomainDelete(c echo.Context) error {

func (s *Service) OrgList(c echo.Context) error {
	gctx := c.(*server.Context)
	query := c.QueryParam("q")
	type GraphQLResponse struct {
		Orgs []models.Organization `json:"getOrganizations"`
	}
	var result GraphQLResponse
	op := gqlclient.NewOperation(
		`query GetOrganizations() {
			getOrganizations {
		`query GetOrganizations($search: String) {
			getOrganizations(input: {search: $search}) {
				id
				name
				slug


@@ 656,6 659,9 @@ func (s *Service) OrgList(c echo.Context) error {
				isActive
		}
	}`)
	if query != "" {
		op.Var("search", query)
	}
	err := links.Execute(c.Request().Context(), op, &result)
	if err != nil {
		return err


@@ 717,6 723,9 @@ func (s *Service) OrgList(c echo.Context) error {
		"dOrgSlug":       dOrgSlug,
		"orgs":           result.Orgs,
	}
	if query != "" {
		gmap["search"] = query
	}
	return links.Render(c, http.StatusOK, "org_list.html", gmap)
}



@@ 1098,6 1107,107 @@ func (s *Service) OrgMembersAdd(c echo.Context) error {
	return links.Render(c, http.StatusOK, "member_add.html", gmap)
}

// OrgMembersDelete ...
func (s *Service) OrgMembersDelete(c echo.Context) error {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		return echo.NotFoundHandler(c)
	}
	slug := c.Param("slug")
	if slug == "" {
		return echo.NotFoundHandler(c)
	}

	gctx := c.(*server.Context)
	user := gctx.User.(*models.User)

	req := c.Request()
	org, err := user.GetOrgsSlug(req.Context(), models.OrgUserPermissionAdminWrite, slug)
	if err != nil {
		return err
	}
	if org == nil {
		return echo.NotFoundHandler(c)
	}

	opts := &database.FilterOptions{
		Filter: sq.And{
			sq.Eq{"org_id": org.ID},
			sq.Eq{"user_id": id},
			sq.Eq{"is_active": true},
		},
		Limit: 1,
	}
	ousers, err := models.GetOrgUsers(req.Context(), opts)
	if err != nil {
		return err
	}
	if len(ousers) == 0 {
		return echo.NotFoundHandler(c)
	}
	orgUser := ousers[0]

	ouser, err := models.GetUser(req.Context(), orgUser.UserID, false)
	if err != nil {
		if err == sql.ErrNoRows {
			return echo.NotFoundHandler(c)
		}
		return err
	}

	lt := localizer.GetSessionLocalizer(c)
	pd := localizer.NewPageData(lt.Translate("Delete Org Member"))

	if req.Method == http.MethodPost {
		type GraphQLResponse struct {
			DeleteMember struct {
				Success bool   `json:"success"`
				Message string `json:"message"`
			} `json:"deleteMember"`
		}

		var result GraphQLResponse
		op := gqlclient.NewOperation(
			`mutation DeleteMember($orgSlug: String!, $email: String!) {
				deleteMember(orgSlug: $orgSlug, email: $email) {
					success
					message
				}
			}`)
		op.Var("orgSlug", slug)
		op.Var("email", strings.ToLower(ouser.Email))
		err = links.Execute(c.Request().Context(), op, &result)
		if err != nil {
			return err
		}
		if !result.DeleteMember.Success {
			messages.Error(
				c,
				lt.Translate(
					"Something went wrong. This member could not be deleted: %s",
					result.DeleteMember.Message,
				),
			)
		} else {
			messages.Success(c, lt.Translate("Member successfully deleted"))
		}
		return c.Redirect(http.StatusMovedPermanently,
			c.Echo().Reverse(s.RouteName("org_member_list"), slug))
	}

	pd.Data["message"] = lt.Translate(
		"Delete member %s (%s) from Organization %s?", ouser.Name, ouser.Email, slug)
	pd.Data["yes"] = lt.Translate("Yes")
	pd.Data["cancel"] = lt.Translate("Cancel")

	gmap := gobwebs.Map{
		"pd":   pd,
		"url":  c.Echo().Reverse(s.RouteName("org_member_delete"), slug, id),
		"back": c.Echo().Reverse(s.RouteName("org_member_list"), slug),
	}
	return links.Render(c, http.StatusOK, "element_delete.html", gmap)
}

// OrgMemberConfirmation ...
func (s *Service) OrgMemberConfirmation(c echo.Context) error {
	key := c.QueryParam("key")


@@ 1197,6 1307,7 @@ func (s *Service) OrgMembersList(c echo.Context) error {
	pd.Data["no_member"] = lt.Translate("No members")
	pd.Data["add"] = lt.Translate("Add")
	pd.Data["back"] = lt.Translate("Back")
	pd.Data["delete"] = lt.Translate("Delete")
	pd.Data["restricted"] = lt.Translate("Please upgrade to a Business organization to add members")
	pd.Data["continue_to_upgrade"] = lt.Translate("Continue to Upgrade")
	gmap := gobwebs.Map{


@@ 2211,7 2322,7 @@ func (s *Service) OrgLinkDelete(c echo.Context) error {
			return err
		}
		if !result.DeleteLink.Success {
			messages.Error(c, lt.Translate("Something went wrong. This element could not be deleted."))
			messages.Error(c, lt.Translate("Something went wrong. This bookmark could not be deleted."))
			redirect := c.Request().Header.Get("Referer")
			if redirect == "" {
				redirect = c.Echo().Reverse(s.RouteName("home_link_list"))


@@ 2219,7 2330,7 @@ func (s *Service) OrgLinkDelete(c echo.Context) error {
			return c.Redirect(http.StatusMovedPermanently, redirect)
		}

		messages.Success(c, lt.Translate("Element successfully deleted"))
		messages.Success(c, lt.Translate("Bookmark successfully deleted"))
		return c.Redirect(http.StatusMovedPermanently,
			c.Echo().Reverse(s.RouteName("home_link_list")))
	}


@@ 2241,31 2352,23 @@ func (s *Service) OrgLinkDelete(c echo.Context) error {

	}
	link := orgLinks[0]
	// If the user is not the link creator, check
	// if it's the org owner

	// If the user is not the link creator, check that the user
	//has admin write permissions.
	user := gctx.User.(*models.User)
	if link.UserID != int(user.ID) {
		opts = &database.FilterOptions{
			Filter: sq.And{
				sq.Expr("o.id = ?", link.OrgID),
				sq.Expr("o.owner_id = ?", user.ID),
				sq.Expr("o.is_active = true"),
			},
			Limit: 1,
		}

		orgs, err := models.GetOrganizations(c.Request().Context(), opts)
		org, err := user.GetOrgsID(
			c.Request().Context(), models.OrgUserPermissionWrite, link.OrgID)
		if err != nil {
			return err
		}

		if len(orgs) == 0 {
		if org == nil {
			return echo.NotFoundHandler(c)
		}
	}
	pd.Data["yes"] = lt.Translate("Yes")
	pd.Data["cancel"] = lt.Translate("Cancel")
	pd.Data["message"] = lt.Translate("Do you really whant to delete this element")
	pd.Data["message"] = lt.Translate("Do you really whant to delete this bookmark?")
	gmap := gobwebs.Map{
		"pd":   pd,
		"url":  c.Echo().Reverse(s.RouteName("link_delete"), link.ID),

M templates/email_add_member_invitation_html.html => templates/email_add_member_invitation_html.html +1 -3
@@ 1,11 1,9 @@
{{template "email_base" .}}

<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">
    You have been invited by {{.currentUser}} to join {{.org}} in links.
</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">
    Please click the link below:
    <a href="{{buildURL .confURL}}" class="btn btn-primary">{{buildURL .confURL}}</a>
    <a href="{{.confURL}}" class="btn btn-primary">{{.confURL}}</a>
</p>

{{template "email_base_footer" .}}

M templates/email_add_member_invitation_text.txt => templates/email_add_member_invitation_text.txt +3 -1
@@ 1,3 1,5 @@
You have been invited by {{.currentUser}} to join {{.org}} in links.

Please click the link below: {{buildURL .confURL}}
Please click the link below:

{{.confURL}}

M templates/member_list.html => templates/member_list.html +1 -1
@@ 29,7 29,7 @@
              <tr>
                  <td>{{.Name}}</td>
                  <td>{{.Email}}</td>
                  <td></td>
                  <td><a class="button primary is-small" href="{{ reverse "core:org_member_delete" $.slug .ID }}">{{ $.pd.Data.delete }}</a></td>
              </tr>
              {{end}}
              {{else}}

M templates/org_list.html => templates/org_list.html +9 -0
@@ 6,6 6,15 @@
</section>

<section class="card shadow-card">
    <form class="app-header__search app-header__search--inline" method="GET" action="{{reverse "core:org_list"}}">
      <input type="search" name="q" value="{{.search}}"/>
      <button type="submit" class="button dark icon-only">
          <svg class="search-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width:20px">
              <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
          </svg>
      </button>
    </form>

    <table class="striped">
        <thead>
            <tr>