diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 1d5a2f0..bd63e85 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -229,6 +229,21 @@ mutation Logout { } } +mutation CreateUser { + _create_user( + params: { + email: "test@domain.com", + password: "", + nickname: "test", + } + ) { + id + email + roles + } +} + + mutation UpdateUser { _update_user( params: { diff --git a/dashboard/src/components/EditUserModal.tsx b/dashboard/src/components/EditUserModal.tsx index 8184786..5c4aa79 100644 --- a/dashboard/src/components/EditUserModal.tsx +++ b/dashboard/src/components/EditUserModal.tsx @@ -113,6 +113,13 @@ const EditUserModal = ({ status: 'success', position: 'top-right', }); + } else if (res.data?._create_user?.id) { + toast({ + title: 'User created successfully', + isClosable: true, + status: 'success', + position: 'top-right', + }); } onClose(); updateUserList(); diff --git a/dashboard/src/graphql/mutation/index.ts b/dashboard/src/graphql/mutation/index.ts index 47e9acf..2933534 100644 --- a/dashboard/src/graphql/mutation/index.ts +++ b/dashboard/src/graphql/mutation/index.ts @@ -38,6 +38,14 @@ export const UpdateUser = ` } `; +export const CreateUser = ` + mutation createUser($params: CreateUserInput!) { + _create_user(params: $params) { + id + } + } +`; + export const DeleteUser = ` mutation deleteUser($params: DeleteUserInput!) { _delete_user(params: $params) { diff --git a/dashboard/src/pages/Users.tsx b/dashboard/src/pages/Users.tsx index 3a0cc0a..b9e042d 100644 --- a/dashboard/src/pages/Users.tsx +++ b/dashboard/src/pages/Users.tsx @@ -189,6 +189,13 @@ export default function Users() { status: 'success', position: 'top-right', }); + } else if (res.data?._create_user?.id) { + toast({ + title: 'User verification successful', + isClosable: true, + status: 'success', + position: 'top-right', + }); } updateUserList(); }; @@ -272,6 +279,17 @@ export default function Users() { }); updateUserList(); return; + } else if (res.data?._create_user?.id) { + toast({ + title: `Multi factor authentication ${ + user.is_multi_factor_auth_enabled ? 'disabled' : 'enabled' + } for user`, + isClosable: true, + status: 'success', + position: 'top-right', + }); + updateUserList(); + return; } toast({ title: 'Multi factor authentication update failed for user', diff --git a/server/graph/generated/generated.go b/server/graph/generated/generated.go index b59b34d..bb33630 100644 --- a/server/graph/generated/generated.go +++ b/server/graph/generated/generated.go @@ -183,6 +183,7 @@ type ComplexityRoot struct { AdminLogin func(childComplexity int, params model.AdminLoginInput) int AdminLogout func(childComplexity int) int AdminSignup func(childComplexity int, params model.AdminSignupInput) int + CreateUser func(childComplexity int, params model.CreateUserInput) int DeactivateAccount func(childComplexity int) int DeleteEmailTemplate func(childComplexity int, params model.DeleteEmailTemplateRequest) int DeleteUser func(childComplexity int, params model.DeleteUserInput) int @@ -357,6 +358,7 @@ type MutationResolver interface { VerifyOtp(ctx context.Context, params model.VerifyOTPRequest) (*model.AuthResponse, error) ResendOtp(ctx context.Context, params model.ResendOTPRequest) (*model.Response, error) DeactivateAccount(ctx context.Context) (*model.Response, error) + CreateUser(ctx context.Context, params model.CreateUserInput) (*model.User, error) DeleteUser(ctx context.Context, params model.DeleteUserInput) (*model.Response, error) UpdateUser(ctx context.Context, params model.UpdateUserInput) (*model.User, error) AdminSignup(ctx context.Context, params model.AdminSignupInput) (*model.Response, error) @@ -1215,6 +1217,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.AdminSignup(childComplexity, args["params"].(model.AdminSignupInput)), true + case "Mutation._create_user": + if e.complexity.Mutation.CreateUser == nil { + break + } + + args, err := ec.field_Mutation__create_user_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.CreateUser(childComplexity, args["params"].(model.CreateUserInput)), true + case "Mutation.deactivate_account": if e.complexity.Mutation.DeactivateAccount == nil { break @@ -2172,6 +2186,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputAddWebhookRequest, ec.unmarshalInputAdminLoginInput, ec.unmarshalInputAdminSignupInput, + ec.unmarshalInputCreateUserInput, ec.unmarshalInputDeleteEmailTemplateRequest, ec.unmarshalInputDeleteUserInput, ec.unmarshalInputForgotPasswordInput, @@ -2726,6 +2741,22 @@ input UpdateProfileInput { app_data: Map } +input CreateUserInput { + email: String + email_verified: Boolean + email_verified_at: Int64 + password: String + given_name: String + family_name: String + middle_name: String + nickname: String + phone_number: String + picture: String + created_at: Int64 + updated_at: Int64 +} + + input UpdateUserInput { id: ID! email: String @@ -2910,6 +2941,7 @@ type Mutation { resend_otp(params: ResendOTPRequest!): Response! deactivate_account: Response! # admin only apis + _create_user(params: CreateUserInput!): User! _delete_user(params: DeleteUserInput!): Response! _update_user(params: UpdateUserInput!): User! _admin_signup(params: AdminSignupInput!): Response! @@ -3014,6 +3046,21 @@ func (ec *executionContext) field_Mutation__admin_signup_args(ctx context.Contex return args, nil } +func (ec *executionContext) field_Mutation__create_user_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 model.CreateUserInput + if tmp, ok := rawArgs["params"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + arg0, err = ec.unmarshalNCreateUserInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐCreateUserInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["params"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation__delete_email_template_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -9213,6 +9260,103 @@ func (ec *executionContext) fieldContext_Mutation_deactivate_account(ctx context return fc, nil } +func (ec *executionContext) _Mutation__create_user(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation__create_user(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) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().CreateUser(rctx, fc.Args["params"].(model.CreateUserInput)) + }) + 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.User) + fc.Result = res + return ec.marshalNUser2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐUser(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation__create_user(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 "id": + return ec.fieldContext_User_id(ctx, field) + case "email": + return ec.fieldContext_User_email(ctx, field) + case "email_verified": + return ec.fieldContext_User_email_verified(ctx, field) + case "signup_methods": + return ec.fieldContext_User_signup_methods(ctx, field) + case "given_name": + return ec.fieldContext_User_given_name(ctx, field) + case "family_name": + return ec.fieldContext_User_family_name(ctx, field) + case "middle_name": + return ec.fieldContext_User_middle_name(ctx, field) + case "nickname": + return ec.fieldContext_User_nickname(ctx, field) + case "preferred_username": + return ec.fieldContext_User_preferred_username(ctx, field) + case "gender": + return ec.fieldContext_User_gender(ctx, field) + case "birthdate": + return ec.fieldContext_User_birthdate(ctx, field) + case "phone_number": + return ec.fieldContext_User_phone_number(ctx, field) + case "phone_number_verified": + return ec.fieldContext_User_phone_number_verified(ctx, field) + case "picture": + return ec.fieldContext_User_picture(ctx, field) + case "roles": + return ec.fieldContext_User_roles(ctx, field) + case "created_at": + return ec.fieldContext_User_created_at(ctx, field) + case "updated_at": + return ec.fieldContext_User_updated_at(ctx, field) + case "revoked_timestamp": + return ec.fieldContext_User_revoked_timestamp(ctx, field) + case "is_multi_factor_auth_enabled": + return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) + case "app_data": + return ec.fieldContext_User_app_data(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type User", 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__create_user_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation__delete_user(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation__delete_user(ctx, field) if err != nil { @@ -16485,6 +16629,134 @@ func (ec *executionContext) unmarshalInputAdminSignupInput(ctx context.Context, return it, nil } +func (ec *executionContext) unmarshalInputCreateUserInput(ctx context.Context, obj interface{}) (model.CreateUserInput, error) { + var it model.CreateUserInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"email", "email_verified", "email_verified_at", "password", "given_name", "family_name", "middle_name", "nickname", "phone_number", "picture", "created_at", "updated_at"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "email": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Email = data + case "email_verified": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email_verified")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.EmailVerified = data + case "email_verified_at": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email_verified_at")) + data, err := ec.unmarshalOInt642ᚖint64(ctx, v) + if err != nil { + return it, err + } + it.EmailVerifiedAt = data + case "password": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("password")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Password = data + case "given_name": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("given_name")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.GivenName = data + case "family_name": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("family_name")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.FamilyName = data + case "middle_name": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("middle_name")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.MiddleName = data + case "nickname": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("nickname")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Nickname = data + case "phone_number": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("phone_number")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.PhoneNumber = data + case "picture": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("picture")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Picture = data + case "created_at": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("created_at")) + data, err := ec.unmarshalOInt642ᚖint64(ctx, v) + if err != nil { + return it, err + } + it.CreatedAt = data + case "updated_at": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("updated_at")) + data, err := ec.unmarshalOInt642ᚖint64(ctx, v) + if err != nil { + return it, err + } + it.UpdatedAt = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputDeleteEmailTemplateRequest(ctx context.Context, obj interface{}) (model.DeleteEmailTemplateRequest, error) { var it model.DeleteEmailTemplateRequest asMap := map[string]interface{}{} @@ -19553,6 +19825,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "_create_user": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation__create_user(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "_delete_user": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation__delete_user(ctx, field) @@ -21126,6 +21405,11 @@ func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.Se return res } +func (ec *executionContext) unmarshalNCreateUserInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐCreateUserInput(ctx context.Context, v interface{}) (model.CreateUserInput, error) { + res, err := ec.unmarshalInputCreateUserInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNDeleteEmailTemplateRequest2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐDeleteEmailTemplateRequest(ctx context.Context, v interface{}) (model.DeleteEmailTemplateRequest, error) { res, err := ec.unmarshalInputDeleteEmailTemplateRequest(ctx, v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/server/graph/mod.go b/server/graph/mod.go new file mode 100644 index 0000000..8566596 --- /dev/null +++ b/server/graph/mod.go @@ -0,0 +1 @@ +package graph \ No newline at end of file diff --git a/server/graph/model/mod.go b/server/graph/model/mod.go new file mode 100644 index 0000000..0c3b4f2 --- /dev/null +++ b/server/graph/model/mod.go @@ -0,0 +1 @@ +package model \ No newline at end of file diff --git a/server/graph/model/models_gen.go b/server/graph/model/models_gen.go index 17549a7..86c6bc2 100644 --- a/server/graph/model/models_gen.go +++ b/server/graph/model/models_gen.go @@ -40,6 +40,21 @@ type AuthResponse struct { AuthenticatorRecoveryCodes []*string `json:"authenticator_recovery_codes,omitempty"` } +type CreateUserInput struct { + Email *string `json:"email,omitempty"` + EmailVerified *bool `json:"email_verified,omitempty"` + EmailVerifiedAt *int64 `json:"email_verified_at,omitempty"` + Password *string `json:"password,omitempty"` + GivenName *string `json:"given_name,omitempty"` + FamilyName *string `json:"family_name,omitempty"` + MiddleName *string `json:"middle_name,omitempty"` + Nickname *string `json:"nickname,omitempty"` + PhoneNumber *string `json:"phone_number,omitempty"` + Picture *string `json:"picture,omitempty"` + CreatedAt *int64 `json:"created_at,omitempty"` + UpdatedAt *int64 `json:"updated_at,omitempty"` +} + type DeleteEmailTemplateRequest struct { ID string `json:"id"` } diff --git a/server/graph/schema.graphqls b/server/graph/schema.graphqls index 3d4efb5..6627283 100644 --- a/server/graph/schema.graphqls +++ b/server/graph/schema.graphqls @@ -424,6 +424,22 @@ input UpdateProfileInput { app_data: Map } +input CreateUserInput { + email: String + email_verified: Boolean + email_verified_at: Int64 + password: String + given_name: String + family_name: String + middle_name: String + nickname: String + phone_number: String + picture: String + created_at: Int64 + updated_at: Int64 +} + + input UpdateUserInput { id: ID! email: String @@ -608,6 +624,7 @@ type Mutation { resend_otp(params: ResendOTPRequest!): Response! deactivate_account: Response! # admin only apis + _create_user(params: CreateUserInput!): User! _delete_user(params: DeleteUserInput!): Response! _update_user(params: UpdateUserInput!): User! _admin_signup(params: AdminSignupInput!): Response! diff --git a/server/graph/schema.resolvers.go b/server/graph/schema.resolvers.go index 3390682..c0f6f8d 100644 --- a/server/graph/schema.resolvers.go +++ b/server/graph/schema.resolvers.go @@ -87,6 +87,11 @@ func (r *mutationResolver) DeactivateAccount(ctx context.Context) (*model.Respon return resolvers.DeactivateAccountResolver(ctx) } +// CreateUser is the resolver for the _create_user field. +func (r *mutationResolver) CreateUser(ctx context.Context, params model.CreateUserInput) (*model.User, error) { + return resolvers.CreateUserResolver(ctx, params) +} + // DeleteUser is the resolver for the _delete_user field. func (r *mutationResolver) DeleteUser(ctx context.Context, params model.DeleteUserInput) (*model.Response, error) { return resolvers.DeleteUserResolver(ctx, params) diff --git a/server/resolvers/create_user.go b/server/resolvers/create_user.go new file mode 100644 index 0000000..07da02d --- /dev/null +++ b/server/resolvers/create_user.go @@ -0,0 +1,147 @@ +package resolvers + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/db" + "github.com/authorizerdev/authorizer/server/db/models" + "github.com/authorizerdev/authorizer/server/email" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/parsers" + "github.com/authorizerdev/authorizer/server/refs" + "github.com/authorizerdev/authorizer/server/token" + "github.com/authorizerdev/authorizer/server/utils" + "github.com/authorizerdev/authorizer/server/validators" +) + +// CreateUserResolver is a resolver for create user mutation +// This is admin only mutation +func CreateUserResolver(ctx context.Context, params model.CreateUserInput) (*model.User, error) { + var res *model.User + + gc, err := utils.GinContextFromContext(ctx) + if err != nil { + log.Debug("Failed to get GinContext: ", err) + return res, err + } + + if !token.IsSuperAdmin(gc) { + log.Debug("Not logged in as super admin") + return res, fmt.Errorf("unauthorized") + } + + log := log.New() + + if params.PhoneNumber != nil { + // verify if phone number is unique + if _, err := db.Provider.GetUserByPhoneNumber(ctx, strings.TrimSpace(refs.StringValue(params.PhoneNumber))); err == nil { + log.Debug("user with given phone number already exists") + return nil, errors.New("user with given phone number already exists") + } + } + + if params.EmailVerified != nil { + if *params.EmailVerified { + now := time.Now().Unix() + params.EmailVerifiedAt = &now + } else { + params.EmailVerifiedAt = nil + } + } + + if params.Email != nil { + // check if valid email + if !validators.IsValidEmail(*params.Email) { + log.Debug("Invalid email: ", *params.Email) + return res, fmt.Errorf("invalid email address") + } + newEmail := strings.ToLower(*params.Email) + // check if user with new email exists + _, err = db.Provider.GetUserByEmail(ctx, newEmail) + // err = nil means user exists + if err == nil { + log.Debug("User with email already exists: ", newEmail) + return res, fmt.Errorf("user with this email address already exists") + } + + hostname := parsers.GetHost(gc) + params.Email = &newEmail + params.EmailVerifiedAt = nil + // insert verification request + _, nonceHash, err := utils.GenerateNonce() + if err != nil { + log.Debug("Failed to generate nonce: ", err) + return res, err + } + verificationType := constants.VerificationTypeUpdateEmail + redirectURL := parsers.GetAppURL(gc) + verificationToken, err := token.CreateVerificationToken(newEmail, verificationType, hostname, nonceHash, redirectURL) + if err != nil { + log.Debug("Failed to create verification token: ", err) + } + _, err = db.Provider.AddVerificationRequest(ctx, &models.VerificationRequest{ + Token: verificationToken, + Identifier: verificationType, + ExpiresAt: time.Now().Add(time.Minute * 30).Unix(), + Email: newEmail, + Nonce: nonceHash, + RedirectURI: redirectURL, + }) + if err != nil { + log.Debug("Failed to add verification request: ", err) + return res, err + } + + // exec it as go routine so that we can reduce the api latency + go email.SendEmail([]string{*params.Email}, constants.VerificationTypeBasicAuthSignup, map[string]interface{}{ + "user": params, + "organization": utils.GetOrganization(), + "verification_url": utils.GetEmailVerificationURL(verificationToken, hostname, redirectURL), + }) + + } + + // json-typed model to store in database + userdata := models.User{ + Email: *params.Email, + EmailVerifiedAt: params.EmailVerifiedAt, + CreatedAt: *params.CreatedAt, + UpdatedAt: *params.UpdatedAt, + Password: params.Password, + GivenName: params.GivenName, + FamilyName: params.FamilyName, + MiddleName: params.MiddleName, + Nickname: params.Nickname, // slug + PhoneNumber: params.PhoneNumber, + Picture: params.Picture, + } + + var user *models.User + user, err = db.Provider.AddUser(ctx, &userdata) + + if err != nil { + log.Debug("Failed to update user: ", err) + return res, err + } + + createdAt := user.CreatedAt + updatedAt := user.UpdatedAt + res = &model.User{ + ID: user.ID, + Email: user.Email, + Picture: user.Picture, + GivenName: user.GivenName, + FamilyName: user.FamilyName, + Roles: strings.Split(user.Roles, ","), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + return res, nil +}