diff --git a/engine/verifymanager/db.go b/engine/verifymanager/db.go index b71ef50558..ecd579a266 100644 --- a/engine/verifymanager/db.go +++ b/engine/verifymanager/db.go @@ -22,7 +22,7 @@ func (db *DB) Name() string { return "Engine.VerificationManager" } func NewDB(ctx context.Context, db *sql.DB) (*DB, error) { lock, err := processinglock.NewLock(ctx, db, processinglock.Config{ Type: processinglock.TypeVerify, - Version: 1, + Version: 2, }) if err != nil { return nil, err @@ -34,15 +34,16 @@ func NewDB(ctx context.Context, db *sql.DB) (*DB, error) { insertMessages: p.P(` with rows as ( insert into outgoing_messages (message_type, contact_method_id, user_id, user_verification_code_id) - select 'verification_message', send_to, user_id, code.id + select 'verification_message', contact_method_id, cm.user_id, code.id from user_verification_codes code - where send_to notnull and now() < expires_at + join user_contact_methods cm on cm.id = contact_method_id + where not sent and now() < expires_at limit 100 for update skip locked returning user_verification_code_id id ) update user_verification_codes code - set send_to = null + set sent = true from rows where code.id = rows.id `), diff --git a/graphql/contactmethod.go b/graphql/contactmethod.go index 0fe585f82a..d3a392fb26 100644 --- a/graphql/contactmethod.go +++ b/graphql/contactmethod.go @@ -170,77 +170,3 @@ func (h *Handler) sendContactMethodTest() *g.Field { }, } } - -func (h *Handler) sendContactMethodVerification() *g.Field { - return &g.Field{ - Type: g.NewObject(g.ObjectConfig{ - Name: "SendContactMethodVerification", - Fields: g.Fields{"id": &g.Field{Type: g.String}}, - }), - Args: g.FieldConfigArgument{ - "input": &g.ArgumentConfig{ - Type: g.NewInputObject(g.InputObjectConfig{ - Name: "SendContactMethodVerificationInput", - Fields: g.InputObjectConfigFieldMap{ - "contact_method_id": &g.InputObjectFieldConfig{Type: g.NewNonNull(g.String)}, - "resend": &g.InputObjectFieldConfig{Type: g.Boolean}, - }, - }), - }, - }, - Resolve: func(p g.ResolveParams) (interface{}, error) { - m, ok := p.Args["input"].(map[string]interface{}) - if !ok { - return nil, errors.New("invalid input type") - } - - resend, _ := m["resend"].(bool) - var result struct { - CMID string `json:"id"` - } - result.CMID, _ = m["contact_method_id"].(string) - - err := h.c.NotificationStore.SendContactMethodVerification(p.Context, result.CMID, resend) - return newScrubber(p.Context).scrub(result, err) - }, - } -} - -func (h *Handler) verifyContactMethod() *g.Field { - return &g.Field{ - Type: g.NewObject(g.ObjectConfig{ - Name: "VerifyContactMethodOutput", - Fields: g.Fields{ - "contact_method_ids": &g.Field{Type: g.NewList(g.String), Description: "IDs of contact methods that have been enabled by this operation."}}, - }), - Args: g.FieldConfigArgument{ - "input": &g.ArgumentConfig{ - Type: g.NewInputObject(g.InputObjectConfig{ - Name: "VerifyContactMethodInput", - Fields: g.InputObjectConfigFieldMap{ - "verification_code": &g.InputObjectFieldConfig{Type: g.NewNonNull(g.Int)}, - "contact_method_id": &g.InputObjectFieldConfig{Type: g.NewNonNull(g.String)}, - }, - }), - }, - }, - Resolve: func(p g.ResolveParams) (interface{}, error) { - m, ok := p.Args["input"].(map[string]interface{}) - if !ok { - return nil, errors.New("invalid input type") - } - - id, _ := m["contact_method_id"].(string) - var code int - code, _ = m["verification_code"].(int) - - changed, err := h.c.NotificationStore.VerifyContactMethod(p.Context, id, code) - var result struct { - IDs []string `json:"contact_method_ids"` - } - result.IDs = changed - return newScrubber(p.Context).scrub(result, err) - - }, - } -} diff --git a/graphql/schema.go b/graphql/schema.go index 132487b716..3f4fc2b7d4 100644 --- a/graphql/schema.go +++ b/graphql/schema.go @@ -91,8 +91,6 @@ func (h *Handler) buildSchema() error { "createAll": h.createAllField(), "updateConfigLimit": h.updateConfigLimitField(), "sendContactMethodTest": h.sendContactMethodTest(), - "sendContactMethodVerification": h.sendContactMethodVerification(), - "verifyContactMethod": h.verifyContactMethod(), "deleteHeartbeatMonitor": h.deleteHeartbeatMonitorField(), "updateUserOverride": h.updateUserOverrideField(), "deleteAll": h.deleteAllField(), diff --git a/graphql2/generated.go b/graphql2/generated.go index c5cac0aca3..b1896efff5 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -167,34 +167,36 @@ type ComplexityRoot struct { } Mutation struct { - AddAuthSubject func(childComplexity int, input user.AuthSubject) int - CreateAlert func(childComplexity int, input CreateAlertInput) int - CreateEscalationPolicy func(childComplexity int, input CreateEscalationPolicyInput) int - CreateEscalationPolicyStep func(childComplexity int, input CreateEscalationPolicyStepInput) int - CreateIntegrationKey func(childComplexity int, input CreateIntegrationKeyInput) int - CreateRotation func(childComplexity int, input CreateRotationInput) int - CreateSchedule func(childComplexity int, input CreateScheduleInput) int - CreateService func(childComplexity int, input CreateServiceInput) int - CreateUserContactMethod func(childComplexity int, input CreateUserContactMethodInput) int - CreateUserNotificationRule func(childComplexity int, input CreateUserNotificationRuleInput) int - CreateUserOverride func(childComplexity int, input CreateUserOverrideInput) int - DeleteAll func(childComplexity int, input []assignment.RawTarget) int - DeleteAuthSubject func(childComplexity int, input user.AuthSubject) int - EscalateAlerts func(childComplexity int, input []int) int - SetConfig func(childComplexity int, input []ConfigValueInput) int - SetFavorite func(childComplexity int, input SetFavoriteInput) int - SetLabel func(childComplexity int, input SetLabelInput) int - TestContactMethod func(childComplexity int, id string) int - UpdateAlerts func(childComplexity int, input UpdateAlertsInput) int - UpdateEscalationPolicy func(childComplexity int, input UpdateEscalationPolicyInput) int - UpdateEscalationPolicyStep func(childComplexity int, input UpdateEscalationPolicyStepInput) int - UpdateRotation func(childComplexity int, input UpdateRotationInput) int - UpdateSchedule func(childComplexity int, input UpdateScheduleInput) int - UpdateScheduleTarget func(childComplexity int, input ScheduleTargetInput) int - UpdateService func(childComplexity int, input UpdateServiceInput) int - UpdateUser func(childComplexity int, input UpdateUserInput) int - UpdateUserContactMethod func(childComplexity int, input UpdateUserContactMethodInput) int - UpdateUserOverride func(childComplexity int, input UpdateUserOverrideInput) int + AddAuthSubject func(childComplexity int, input user.AuthSubject) int + CreateAlert func(childComplexity int, input CreateAlertInput) int + CreateEscalationPolicy func(childComplexity int, input CreateEscalationPolicyInput) int + CreateEscalationPolicyStep func(childComplexity int, input CreateEscalationPolicyStepInput) int + CreateIntegrationKey func(childComplexity int, input CreateIntegrationKeyInput) int + CreateRotation func(childComplexity int, input CreateRotationInput) int + CreateSchedule func(childComplexity int, input CreateScheduleInput) int + CreateService func(childComplexity int, input CreateServiceInput) int + CreateUserContactMethod func(childComplexity int, input CreateUserContactMethodInput) int + CreateUserNotificationRule func(childComplexity int, input CreateUserNotificationRuleInput) int + CreateUserOverride func(childComplexity int, input CreateUserOverrideInput) int + DeleteAll func(childComplexity int, input []assignment.RawTarget) int + DeleteAuthSubject func(childComplexity int, input user.AuthSubject) int + EscalateAlerts func(childComplexity int, input []int) int + SendContactMethodVerification func(childComplexity int, input SendContactMethodVerificationInput) int + SetConfig func(childComplexity int, input []ConfigValueInput) int + SetFavorite func(childComplexity int, input SetFavoriteInput) int + SetLabel func(childComplexity int, input SetLabelInput) int + TestContactMethod func(childComplexity int, id string) int + UpdateAlerts func(childComplexity int, input UpdateAlertsInput) int + UpdateEscalationPolicy func(childComplexity int, input UpdateEscalationPolicyInput) int + UpdateEscalationPolicyStep func(childComplexity int, input UpdateEscalationPolicyStepInput) int + UpdateRotation func(childComplexity int, input UpdateRotationInput) int + UpdateSchedule func(childComplexity int, input UpdateScheduleInput) int + UpdateScheduleTarget func(childComplexity int, input ScheduleTargetInput) int + UpdateService func(childComplexity int, input UpdateServiceInput) int + UpdateUser func(childComplexity int, input UpdateUserInput) int + UpdateUserContactMethod func(childComplexity int, input UpdateUserContactMethodInput) int + UpdateUserOverride func(childComplexity int, input UpdateUserOverrideInput) int + VerifyContactMethod func(childComplexity int, input VerifyContactMethodInput) int } OnCallShift struct { @@ -353,10 +355,11 @@ type ComplexityRoot struct { } UserContactMethod struct { - ID func(childComplexity int) int - Name func(childComplexity int) int - Type func(childComplexity int) int - Value func(childComplexity int) int + Disabled func(childComplexity int) int + ID func(childComplexity int) int + Name func(childComplexity int) int + Type func(childComplexity int) int + Value func(childComplexity int) int } UserNotificationRule struct { @@ -435,6 +438,8 @@ type MutationResolver interface { CreateUserContactMethod(ctx context.Context, input CreateUserContactMethodInput) (*contactmethod.ContactMethod, error) CreateUserNotificationRule(ctx context.Context, input CreateUserNotificationRuleInput) (*notificationrule.NotificationRule, error) UpdateUserContactMethod(ctx context.Context, input UpdateUserContactMethodInput) (bool, error) + SendContactMethodVerification(ctx context.Context, input SendContactMethodVerificationInput) (bool, error) + VerifyContactMethod(ctx context.Context, input VerifyContactMethodInput) (bool, error) UpdateSchedule(ctx context.Context, input UpdateScheduleInput) (bool, error) UpdateUserOverride(ctx context.Context, input UpdateUserOverrideInput) (bool, error) SetConfig(ctx context.Context, input []ConfigValueInput) (bool, error) @@ -1068,6 +1073,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.EscalateAlerts(childComplexity, args["input"].([]int)), true + case "Mutation.SendContactMethodVerification": + if e.complexity.Mutation.SendContactMethodVerification == nil { + break + } + + args, err := ec.field_Mutation_sendContactMethodVerification_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.SendContactMethodVerification(childComplexity, args["input"].(SendContactMethodVerificationInput)), true + case "Mutation.SetConfig": if e.complexity.Mutation.SetConfig == nil { break @@ -1236,6 +1253,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.UpdateUserOverride(childComplexity, args["input"].(UpdateUserOverrideInput)), true + case "Mutation.VerifyContactMethod": + if e.complexity.Mutation.VerifyContactMethod == nil { + break + } + + args, err := ec.field_Mutation_verifyContactMethod_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.VerifyContactMethod(childComplexity, args["input"].(VerifyContactMethodInput)), true + case "OnCallShift.End": if e.complexity.OnCallShift.End == nil { break @@ -2047,6 +2076,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.UserConnection.PageInfo(childComplexity), true + case "UserContactMethod.Disabled": + if e.complexity.UserContactMethod.Disabled == nil { + break + } + + return e.complexity.UserContactMethod.Disabled(childComplexity), true + case "UserContactMethod.ID": if e.complexity.UserContactMethod.ID == nil { break @@ -2455,6 +2491,8 @@ type Mutation { input: CreateUserNotificationRuleInput! ): UserNotificationRule updateUserContactMethod(input: UpdateUserContactMethodInput!): Boolean! + sendContactMethodVerification(input: SendContactMethodVerificationInput!): Boolean! + verifyContactMethod(input: VerifyContactMethodInput!): Boolean! updateSchedule(input: UpdateScheduleInput!): Boolean! updateUserOverride(input: UpdateUserOverrideInput!): Boolean! @@ -3014,13 +3052,12 @@ enum ContactMethodType { # A method of contacting a user. type UserContactMethod { id: ID! - type: ContactMethodType # User-defined label for this contact method. name: String! - value: String! + disabled: Boolean! } input CreateUserContactMethodInput { @@ -3045,6 +3082,15 @@ input UpdateUserContactMethodInput { value: String } +input SendContactMethodVerificationInput { + contactMethodID: ID! +} + +input VerifyContactMethodInput { + contactMethodID: ID! + code: Int! +} + type AuthSubject { providerID: ID! subjectID: ID! @@ -3267,6 +3313,20 @@ func (ec *executionContext) field_Mutation_escalateAlerts_args(ctx context.Conte return args, nil } +func (ec *executionContext) field_Mutation_sendContactMethodVerification_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 SendContactMethodVerificationInput + if tmp, ok := rawArgs["input"]; ok { + arg0, err = ec.unmarshalNSendContactMethodVerificationInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐSendContactMethodVerificationInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_setConfig_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -3463,6 +3523,20 @@ func (ec *executionContext) field_Mutation_updateUser_args(ctx context.Context, return args, nil } +func (ec *executionContext) field_Mutation_verifyContactMethod_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 VerifyContactMethodInput + if tmp, ok := rawArgs["input"]; ok { + arg0, err = ec.unmarshalNVerifyContactMethodInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐVerifyContactMethodInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -6123,6 +6197,74 @@ func (ec *executionContext) _Mutation_updateUserContactMethod(ctx context.Contex return ec.marshalNBoolean2bool(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_sendContactMethodVerification(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + ctx = ec.Tracer.StartFieldExecution(ctx, field) + defer func() { ec.Tracer.EndFieldExecution(ctx) }() + rctx := &graphql.ResolverContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + } + ctx = graphql.WithResolverContext(ctx, rctx) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_sendContactMethodVerification_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + rctx.Args = args + ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx) + resTmp := ec.FieldMiddleware(ctx, nil, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().SendContactMethodVerification(rctx, args["input"].(SendContactMethodVerificationInput)) + }) + if resTmp == nil { + if !ec.HasError(rctx) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + rctx.Result = res + ctx = ec.Tracer.StartFieldChildExecution(ctx) + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_verifyContactMethod(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + ctx = ec.Tracer.StartFieldExecution(ctx, field) + defer func() { ec.Tracer.EndFieldExecution(ctx) }() + rctx := &graphql.ResolverContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + } + ctx = graphql.WithResolverContext(ctx, rctx) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_verifyContactMethod_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + rctx.Args = args + ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx) + resTmp := ec.FieldMiddleware(ctx, nil, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().VerifyContactMethod(rctx, args["input"].(VerifyContactMethodInput)) + }) + if resTmp == nil { + if !ec.HasError(rctx) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + rctx.Result = res + ctx = ec.Tracer.StartFieldChildExecution(ctx) + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + func (ec *executionContext) _Mutation_updateSchedule(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { ctx = ec.Tracer.StartFieldExecution(ctx, field) defer func() { ec.Tracer.EndFieldExecution(ctx) }() @@ -9161,6 +9303,33 @@ func (ec *executionContext) _UserContactMethod_value(ctx context.Context, field return ec.marshalNString2string(ctx, field.Selections, res) } +func (ec *executionContext) _UserContactMethod_disabled(ctx context.Context, field graphql.CollectedField, obj *contactmethod.ContactMethod) graphql.Marshaler { + ctx = ec.Tracer.StartFieldExecution(ctx, field) + defer func() { ec.Tracer.EndFieldExecution(ctx) }() + rctx := &graphql.ResolverContext{ + Object: "UserContactMethod", + Field: field, + Args: nil, + IsMethod: false, + } + ctx = graphql.WithResolverContext(ctx, rctx) + ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx) + resTmp := ec.FieldMiddleware(ctx, obj, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Disabled, nil + }) + if resTmp == nil { + if !ec.HasError(rctx) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + rctx.Result = res + ctx = ec.Tracer.StartFieldChildExecution(ctx) + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + func (ec *executionContext) _UserNotificationRule_id(ctx context.Context, field graphql.CollectedField, obj *notificationrule.NotificationRule) graphql.Marshaler { ctx = ec.Tracer.StartFieldExecution(ctx, field) defer func() { ec.Tracer.EndFieldExecution(ctx) }() @@ -11175,6 +11344,24 @@ func (ec *executionContext) unmarshalInputScheduleTargetInput(ctx context.Contex return it, nil } +func (ec *executionContext) unmarshalInputSendContactMethodVerificationInput(ctx context.Context, v interface{}) (SendContactMethodVerificationInput, error) { + var it SendContactMethodVerificationInput + var asMap = v.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "contactMethodID": + var err error + it.ContactMethodID, err = ec.unmarshalNID2string(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputServiceSearchOptions(ctx context.Context, v interface{}) (ServiceSearchOptions, error) { var it ServiceSearchOptions var asMap = v.(map[string]interface{}) @@ -11843,6 +12030,30 @@ func (ec *executionContext) unmarshalInputUserSearchOptions(ctx context.Context, return it, nil } +func (ec *executionContext) unmarshalInputVerifyContactMethodInput(ctx context.Context, v interface{}) (VerifyContactMethodInput, error) { + var it VerifyContactMethodInput + var asMap = v.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "contactMethodID": + var err error + it.ContactMethodID, err = ec.unmarshalNID2string(ctx, v) + if err != nil { + return it, err + } + case "code": + var err error + it.Code, err = ec.unmarshalNInt2int(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + // endregion **************************** input.gotpl ***************************** // region ************************** interface.gotpl *************************** @@ -12640,6 +12851,16 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalid = true } + case "sendContactMethodVerification": + out.Values[i] = ec._Mutation_sendContactMethodVerification(ctx, field) + if out.Values[i] == graphql.Null { + invalid = true + } + case "verifyContactMethod": + out.Values[i] = ec._Mutation_verifyContactMethod(ctx, field) + if out.Values[i] == graphql.Null { + invalid = true + } case "updateSchedule": out.Values[i] = ec._Mutation_updateSchedule(ctx, field) if out.Values[i] == graphql.Null { @@ -13997,6 +14218,11 @@ func (ec *executionContext) _UserContactMethod(ctx context.Context, sel ast.Sele if out.Values[i] == graphql.Null { invalid = true } + case "disabled": + out.Values[i] = ec._UserContactMethod_disabled(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalid = true + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -15359,6 +15585,10 @@ func (ec *executionContext) unmarshalNScheduleTargetInput2githubᚗcomᚋtarget return ec.unmarshalInputScheduleTargetInput(ctx, v) } +func (ec *executionContext) unmarshalNSendContactMethodVerificationInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐSendContactMethodVerificationInput(ctx context.Context, v interface{}) (SendContactMethodVerificationInput, error) { + return ec.unmarshalInputSendContactMethodVerificationInput(ctx, v) +} + func (ec *executionContext) marshalNService2githubᚗcomᚋtargetᚋgoalertᚋserviceᚐService(ctx context.Context, sel ast.SelectionSet, v service.Service) graphql.Marshaler { return ec._Service(ctx, sel, &v) } @@ -15882,6 +16112,10 @@ func (ec *executionContext) marshalNUserRole2githubᚗcomᚋtargetᚋgoalertᚋg return v } +func (ec *executionContext) unmarshalNVerifyContactMethodInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐVerifyContactMethodInput(ctx context.Context, v interface{}) (VerifyContactMethodInput, error) { + return ec.unmarshalInputVerifyContactMethodInput(ctx, v) +} + func (ec *executionContext) marshalN__Directive2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirective(ctx context.Context, sel ast.SelectionSet, v introspection.Directive) graphql.Marshaler { return ec.___Directive(ctx, sel, &v) } diff --git a/graphql2/graphqlapp/contactmethod.go b/graphql2/graphqlapp/contactmethod.go index 2d5ca28412..d4c35c8783 100644 --- a/graphql2/graphqlapp/contactmethod.go +++ b/graphql2/graphqlapp/contactmethod.go @@ -3,6 +3,7 @@ package graphqlapp import ( context "context" "database/sql" + "github.com/target/goalert/validation/validate" "github.com/target/goalert/graphql2" "github.com/target/goalert/user/contactmethod" @@ -45,6 +46,7 @@ func (m *Mutation) CreateUserContactMethod(ctx context.Context, input graphql2.C return cm, nil } + func (m *Mutation) UpdateUserContactMethod(ctx context.Context, input graphql2.UpdateUserContactMethodInput) (bool, error) { err := withContextTx(ctx, m.DB, func(ctx context.Context, tx *sql.Tx) error { cm, err := m.CMStore.FindOneTx(ctx, tx, input.ID) @@ -61,3 +63,19 @@ func (m *Mutation) UpdateUserContactMethod(ctx context.Context, input graphql2.U }) return err == nil, err } + +func (m *Mutation) SendContactMethodVerification(ctx context.Context, input graphql2.SendContactMethodVerificationInput) (bool, error) { + err := m.NotificationStore.SendContactMethodVerification(ctx, input.ContactMethodID) + return err == nil, err +} + +func (m *Mutation) VerifyContactMethod(ctx context.Context, input graphql2.VerifyContactMethodInput) (bool, error) { + err := validate.Range("Code", input.Code, 100000, 999999) + if err != nil { + // return "must be 6 digits" error as we care about # of digits, not the code's actual value + return false, validation.NewFieldError("Code", "must be 6 digits") + } + + err = m.NotificationStore.VerifyContactMethod(ctx, input.ContactMethodID, input.Code) + return err == nil, err +} diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go index 7daaf2e753..a7fb43d9be 100644 --- a/graphql2/models_gen.go +++ b/graphql2/models_gen.go @@ -222,6 +222,10 @@ type ScheduleTargetInput struct { Rules []ScheduleRuleInput `json:"rules"` } +type SendContactMethodVerificationInput struct { + ContactMethodID string `json:"contactMethodID"` +} + type ServiceConnection struct { Nodes []service.Service `json:"nodes"` PageInfo PageInfo `json:"pageInfo"` @@ -371,6 +375,11 @@ type UserSearchOptions struct { Omit []string `json:"omit"` } +type VerifyContactMethodInput struct { + ContactMethodID string `json:"contactMethodID"` + Code int `json:"code"` +} + type AlertStatus string const ( diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index fb1d8ac02d..e67adac334 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -203,6 +203,8 @@ type Mutation { input: CreateUserNotificationRuleInput! ): UserNotificationRule updateUserContactMethod(input: UpdateUserContactMethodInput!): Boolean! + sendContactMethodVerification(input: SendContactMethodVerificationInput!): Boolean! + verifyContactMethod(input: VerifyContactMethodInput!): Boolean! updateSchedule(input: UpdateScheduleInput!): Boolean! updateUserOverride(input: UpdateUserOverrideInput!): Boolean! @@ -762,13 +764,12 @@ enum ContactMethodType { # A method of contacting a user. type UserContactMethod { id: ID! - type: ContactMethodType # User-defined label for this contact method. name: String! - value: String! + disabled: Boolean! } input CreateUserContactMethodInput { @@ -793,6 +794,15 @@ input UpdateUserContactMethodInput { value: String } +input SendContactMethodVerificationInput { + contactMethodID: ID! +} + +input VerifyContactMethodInput { + contactMethodID: ID! + code: Int! +} + type AuthSubject { providerID: ID! subjectID: ID! diff --git a/migrate/inline_data_gen.go b/migrate/inline_data_gen.go index 81d69925ff..5e6fafaf65 100644 --- a/migrate/inline_data_gen.go +++ b/migrate/inline_data_gen.go @@ -492,81 +492,87 @@ VAv6YYmLJrQVOMf5skpLXLmspFjXIW4yZJfpl2jw6tWbV_948-rV4M0b6eft1PsxdhLENhwRsrpL6_im uMUldxaNKiMXepqoJOZpPtfEPPpoXWEW45lcAYnLncHZoMVn9NNsdgokzV-XJRHBWGMYPpNFH_88Of7n sPg8cpKWu98uPo_NLyitT12u8ZhPtjFfuD2yI9G7DcxVQHlRl1FeMd2Tlc3WLtmhux1O36HJb9OLywuU JuO-5giaJuin6XtWp1K8UD421pC0YhaNsBelzJyUukYhiy1BIMZr_jzoxb0FMMBxW4DjcSbeAkDwyM0g -WZVoHw6JOWAzAJ5w24cD4onObQRIUx9IsXkL4EDDdgIGl2WxFcQ4Y7YG4_9n79p63Lax8HPmV7BFFrZR -p-0A2wXagQtMxk7g7sQOZpxt8yR4ZGXGG1syfJlLi_73BW_i7ZAiJblRtZOXYCzyI_XxIvLw8Dt0M1F3 -RWTU4qqoYTpqqIUK6FkBejJBltWr7LauWqiocFX0D0rIJwDZ6nlmn7Q988D3ZTzzWm6b-OXWbkT5ZbLc -UvLLDA3l8Ky0w4flk4eKZ04tdE9AJrU3OnIWLEocmy-yZ1IjUAloKm2Jyz5TF4PjN2TtZiQTa_F8nJkr -w2wDRYrcEzc1Ebxbcy7PHiwRJvePES1uPGGXauI1OfqK-RkmzruY7-fol-vp5LViloT2nNxXxKFOi16- -PBmOLi7PmW5rfNg617k_Ddh69Ey6-MuE9cgCVwAoi1qSjio6GMtQxHVxx2-k7F8N1KW9GgRA3kgiIIhA -d_Y2mr5HA9ShG_5OT80vr8nlRs82fan9-qy1-rRt-qxBhFA_X6mT0vpo9jYivSqanL8b9ek12enl8Nvl -Am9gcJP3MNRyETFyur0-ufocr-ePUowC-eoz0sP-V6p6P-9DQS9BJYwcL4ETxOsluTYV_XeXpTc4T898 -J7jF-EMivOIyDFxM370bz0pqdbAZQrm2L6aI33-PfvxRzFF82BByuaQQ88LXjBCOnJolQuhByJfMRU41 -BoxpltBH9F9FCEiGk4hyJDSSAH2jqlFhhHqESbGj-NHjCprZIKJIAECIIhYZ0E6OntOfFpKzqYTkBgaA -ExEc004LkN-fmdxa0lBylBigEENakFA7TTYkf64UhAYSFmfpp-VttFqul0ZvUp5ZaLLl92JIydxAcmAj -mkqSRXoSJKsIz4s02GLYQPJMBWudOjOFjTgnlh9tJsTfgLRck6WAujydJ4EQbikac6C_BZnU4ltEJdtB -ehKpY5ak0ZR-ai6Jyaa4PzKham8SVczSJOqK6s0gMY8HHK2zdLnPjK-ImcBCnRPJizUToYGEESeVLW3b -z8mTTpf-2EKWA8WLKj1_A4liwdw1fvIQ7yAtZh4vNli2BpLg0CVWeXEpLIFUeSF7sefQLmweoaZcksqj -8dxCnwvHizVTv6h5ZG2TW3rfVmdJPLDQA-b04kXkbCIhkGqfzg2UxkZTAZ4fYxBIk8mD1qrqwyK6Sq1M -NXeL5hJk61FFvah0z2lib7G4kKjMwIksNBUienFm8ZlpMIHk0qmNuO3BvkSwIoQRRXI3mCAbN0W0lGak -kWSwwAE6F7Yov9ZcfkxAQX6bQQToK6aSAiWxEFSA5kUW6BTXaOKYP5mVNfq8mDIDJ5Qv5i7XWLIU9zuQ -LjmFmzALVghlio9h80iDLgxrrEFJLLQVoHnxBmE0lTjhZglQlj90kQUh-NMk_EYbSpDmAQqwpKZwUWXF -8udLc3NtKGmmvAlEnJnKRZ4T059AE6apJGb3yXa7XMDc5Q9dlEEI_kzluZtK0H2yle5cZhamzFQuypyY -_tyZMA0lEaLMRVApOo7-8oDbfXX_xPIOffV6wlV3F6vLv6p-V6S6fHWO5dZyDI-PYztEHNNH4JhH5_Wf -KNd75FrlXPL4x3l1n29VPwI61plJfccJVe3uxzNV12fDrWrsrGYfPI5RrW6L0zGMMsexWdS3oT_GzveY -G8P69kvH3FiUXW9roi4ip7kyLryZp-tCGNcIcy6M6NICUGM0j5Yc3yXxZ0hFwV6KVUHQBk-VH1C3m8ea --5lK7_QK4mgfRFxo8qtW4E6JrE47NaxXOBy9GV1dYUyfV62lyMl0Zi9We0_tzBoBccK176AlUiApug81 -UEB-ix67mhLUZlfDHGriQpMpOqejwb81_i-IuRpdz67GFzN3H7F53IJddXcf411SYN8vXURBX9d28ott -tkHLdJE8ouUnKmS4I0HuDqv5igyp5eLsJN4mODdNF2cpu1a5etKToiwFb1N36WM8xbhLJJo4AcVK6a1l -S2mKKyBC_HqUnoup2YqWI6EU2FT8WiKAPd_3LNlB2NybR7YvIkxPTyJpsnA44tcqRJk1KvlqPBayd2cw -MuCX84lXWuV1gVoWSBMUMKdEfvJrTiVL3qIRDewlv6RX2Xc3YQWT9Hqpu8MNfsAMFUVjXkJbpoHvTTNA -5Wv2jYBK5GJ03rXgwmpANbhYXuUxpfWMgLYMIT6In7JdPVtL265iorXkJGSvGdlVTuJs6Wwd3ePdjF_J -PKmlVHij5NfgAQT5vYy-XKpF75abSjpnLOS8aoBB4wka_XZx-eF6_J8Rejcl6uusYM0X7no0o3qCfNcz -oLIRC7zO3G8Padzt0JDwnX6eqPdN55QFipfUA0kYeQxhyBD2JPlTXgpYgEjRO3FV2agu__tVYMUYtaQy -ePkLvSwFuk226B_oBzRAp9ASlQmpnLJI-fbWtbclOa-ir_yFmjWYvbMy-s1AYGWpP1v0JcnafzW_SVY7 -L32f_a2ypvrwYTwUYphyMCVmNXSp--LdAyD6cz9fHUCdIB4lQq1DH-P0ToxAPnjGoC8mV3g6yd9WhQHV -JMl-0wGnGHDo0yINyYrHvKwKqt2Jlwwe9Bo5vE56aa4vcNRb4fV15eXSssTzxf08jZMo2xJj1jbbR2wb -Hy2SlVWiWFaNSpMHkoGMkLM8VM99Iv8qaRqzec5iV2Cyx7OpDCIEpABLCZ0K5LiIAyKrJP0ipKHkmn1F -0y0XoLqU0F_S1aV2ySqJ9-gUfdpma3Gugh7ukm2CoPJ7uKxTtRjWAW1v5f1mRhUZvzKVvIkAHpVrNnmh -eRWchaPzyTBPuVxIhMoPNtluiTPgb0_3eyp5xX_75pTJTV8NR1fo9UeReDi6viCPLsfvxjPyxUSsAfIO -N74mc_HxaJWVt9jUYcHEXyhLj0YDlf_yLSv3zHIC2oCK8PPU8RdNHa0el8_DssqwLLXBY3-gAfqnM5xJ -oSQxyk8LiCRn6YgzGk5uu6KilmY4DP2AQFkxyyKhIpQItPKV3ikv0nIEb1gH41UyTw8blK0WiLo7yh0V -ZItSTeKvYCKYCmZX6nLyMM8HthST2hzNiG5t94cdUb6MV9kuWXTQ9EpJQY0aJPYHO1hEr1DnFC3mT9Jm -i2TRXm9x2KyWMdHlDHlJFk4Azw_yDLaeP5KWW88f-axrR8JP315NP7wns4gSIQbXhcqYsg4PAuCunrfr -gGTy7tTsMEftjhiQd2HRn9iOC-xWND6DtU-ZbwXusEJHtTsMkhcB8iBiw9ocXXkkQn0A64GsJNK03aLO -1fMG5UtuUPTVgDKG5RlKrTHtMr3nXcrzLuULL4eedynPBo7nqeN56qg6deCFhxFdivgOELNqmqxo_HAR -nxRXonN9eX7xbyB0lBorjwJIRwnkgMCMHyUW7aU2VKSCtoqrhwa2oAPWU4Z1sp8jotduxOUyIkyBL2-E -mTLrCIQJdPticZ1KpC5GOSYnWtpNWtqleHMpRWGaR3s8uUXxPI2ydPUU3SRRliagK501NXcAzUdBl0S3 -JdFruS9Bmu3Tw2qF9vjHU5Ssdgn6HiXpAn2TZxO58jO54Jzy0AvLKTFtzdgjh3A4X89KUZpF-b5TNAs_ -2YKv09BgBXkF4F7p33_kbY5ALdeovAHH13Ssnk-GSvPIv8vk49_5-J5eIR8YnhyCAmDk5L41-nB5WeCU -bEq6vTjGcHyhbNL5a5GGkyIOK4kMT2Qzudq22ZrdXFjjZSCrEovTd_LiRVcfm_N0ARSiPBWvv9zhX3sn -L15kWwwGDB8Yj2YkD3kFciwt0qjhwgo0jj6ngS_dz9MZYwJuCNlYUtwOmmnFmPnDT_p_cBpB6gkuLeaQ -I8aXVgsJh3TaeJ3n8Kzf0G-0Xh5d_wyHNA6MFhCpw-yHnTMljKW91qjLqtcTgWc5Bux1IC1mHbDGq4uK -Bbz7QXa_4mFsCwigA0Q3uEkxW7Rlyu5wE0kzgB6N2GtuJKOIjCDD1q1dAGelKBfAu0AVomyzK_6WSj6O -iuFQwXN_NDRi9DlJqn6WJrwNzjRPCCmsr-6eKJzAC6LyeGEo7iEn9lg8eOhNEImDlNwn6R73QKUld0m6 -75DvLE6DCSPdakCC0W47PWeMYcC7ncQadn8BwC5YQDRfyuAeLnuxmqsI0NEWTKb4A8spyKWnNrRrudZz -OaFREQCxc7R5oOHF3HqOJ109xP98P0evP85G59qD8rtNZbsLhQ4J9KqiucK9qiyeaQVaC5DIguKxxn-q -YUnyL-eShOGBE33uU0k23gO69Y52yb6Lf-ijzh8Pyc1dln3-cHX5Z0eKpkZ28L0-2m8P3BOWbuoHBOrV -zz-TL9V42JHqhitGTRmuVY27onopon6dvvwm5L9XSH5uq0nda0CbwUfc6Nyt5vFnGjpRNdbkI0EaeUbA -dKchYR6T7kvCuKsWlmKLilY3e9-EFw2YJcZ-548_O2yNRH6h2wg1ECfczkg_JSX5MTSfHDB235FOWu47 -v1ch5WvbOUciVssazxtJPZ_4vLJNPm2T3R18TIDr9fIl-U-Ew8T_JtPZ-M1H9PV3txn5KHxH0V4xtK-d -cRHPGG6g4RP4TrJ3OGwW-e1GZeLOJ2liNJe_i6Qq0EfPwo6jq9vqY8zTHo1Q5HBcTjtpc7hZLeNvq0ko -ySAVlZQ4VE2CSjJcVV0lGasueSUZs4TKEgimLgBKyDQpOPWpNTHY44g2cfCjaTdZCqhXwslaSH1KTo4i -ahB0Yuh16zox2FrknRiWn8oTeA-BIfDrCPXIRDHQo6lFMfzaRKMYXq3aURyzbgkpHbeKkpSGVYugFMOs -X1dKB64iL6VhlVSZ4ii1iU0xwCNoTpnINUlPqcD1KlAx7LqFqGTY-vSoZNQjyFLJ8PWqU8nI9YpUyci1 -aFVJgDvIrhpwb3E4xTszutvzjwOPdIMZN5MJ0xmwDUFFtjOawTShjX67GL3HW6kT4oczEe7fUUbW7CeI -uBQhaiUYTYbo5cuSer1-hFQioy1EOHZ9IZRoMC0ix9i9htJCAFpECLQLD-WEY7SIFqs1IZQbBagdBJlW -kQBS2LlUm4gwrTvBfDCMdtBSaKUKoAfEaglNbmtbCEkmUjspAq2GFYjK8dpKl2GmqUQWQWsvVZoVtyJV -yaYlvcptjQ5gyQRqB0Euq3oAPTpMO8gBTgcCOGG520GFecoRwAST2WoFEX6nNQHkOLRaW0GY89QpgCdT -hbIV9MCnZwG8CICWEFJ0ChjCDai73i6aqiyTVZB2EVO1z7SknxSfLgdwA4O1jCjjnLAMQVRNtlXEVOWk -LXQAXgshbLDs7SCjyPsigBhQO6ptJJnOHuUYojitosfmDRNOkIzUDoqKvHoCOIKgWkQS6J0USk8O0iJi -7F5WoexogYbaQ5HbWyyUJhOtRVSBXm-hDIl4he0hxu29F8qQidYeqqoQ0xwa9Ht16lUtsBqoK25-k6t_ -9iif9KKYejdakOYTpCPbQMJW-_nNKolseljb7CHSNU1ZvkehV8wuVK-JsEnMVYNwXnKT9Jfr6eS1ccnT -_6qh3nTABUOk6RDGhy0zZBEti93Dch_f4UmG_frTAHWWCxICRdxHZGJ5RLhDALB4SJLGG5Hg0CHpXUSu -Cyiyf4VLSqPNNrvdJrtdB9QXlC81qvqCs7fR9D0aoA4dBh1Nc1TWGpG7Q7bpSy3bZ-3Yp63WZ03Vy2G4 -AgkprY9mbyPSw6LJ-btRn8ayYWJ-8x3pDD0MtVxEjJxuj0r5xev5Y8-4sinUE2UNvUpV7-e9K-glJqNf -3S-BE8TrZSpdKJ-Mfu2Z7wS3GH9ILvmWUuVzCIao61FdTWV_u49k5SpdakrYMl2XtJW7w1pUH-iqMRDy -Vyy7mWRGpFUt-pw85dpmLE1frz94T7aADV1Cxb86gI66mqiYnQI9p7CGlKXJ9IbMbWjOdvRuGKkoqWHU -dpHSHLldtNrA7SIlOvtfAAAA__80AwSB6R0EAA== +WZVoHw6JOWAzAJ5w24cD4onObQRIUx9IsXkL4EDDdgIGl2WxFcQ4Y7YG4_9n72p727a18OfmV3BDL2xj +brcAdxfYAg9IY7fwbmoXSXq3fRIcWU19a0uGZafJhv33C76Jb4cUKcmrppt9GRqTD8mHLyIPD59DDxNN +V0RGLa-KGqajgVqogJ4VoDcTZFu9zu6aqoWKCldF_6CEfAKQrZ5n9kXbMw_8XsYzr-W1iV9u7UWUXybL +KyW_zNBUDs9KB3xYPnmqeObUQvcEZFJHoyNnyabEcfgiZyY1ApWAptKWuOwzdTM4fU32bkYysRcv5pm5 +M8y2UKTIPXFTE8G7Nefy7LMlwuT-IaLFTWfsUU28IVdfMb_DxHmXi_0C_Xw9n71SzJLQmZP7ijjUadHz +5yfjycXlOdNtjQ875z73xxHbj55JD3-ZsB7Z4AoAZVNL0lFFB2Mbirgu7vS1lP2rkbq1V4MAyAdJBAQR +6N-8iebv0Aj16IG_N1Dzy3tyudOz7VDqvyHrrSHtmyHrECHUz3fqpLQhunkTkVEVzc7fTob0mez8cvxy +tcQHGNzlAwy1WkaMnP5gSJ4-x5vFgxSjQH76jPSw_7WqPizGUFAjqISRoxE4QbxZkWdT0X_zLL3FeQZm +m-Ae4z8S4RWXYeBi_vbt9KaiVgdbIZRn-2KJ-P336IcfxBrFpw0hl0sKMS98zQjhyKlZIoQehPzIXORU +Y8CYZgl9Rv9VhIBkOImoRkIrCdAPqhoVRqhHmBQ7ih89rqCZLSKKBACEKGKRAe3k6Dn9aSE520pIYWAA +OBHBMe20APn9mSmsJS0lR4kBCjGkBQm102RD8udKQWghYXGWfljdRevVZmWMJuU3C022_F4MKZlbSA5s +RFNJskhPgmSV4XmRBlsMW0ieqWCtU2emsBHnxPKjzYT4G5BWaLKUUFek8yQQwq1EYwH0tyCTWnzLqGQn +SE8idcyKNJrST-0lMdmWj0cmVO1NoopZmURdUb0dJBbxgKNNlq72mfEVMRNYqHMiebFmIrSQMOKksqN9 +-yl51OnSf7aQ5UDxokrP30KiWDB3jZ8ixDtIi5nHiw2WrYUkOHSJVV5cCksgVV7IXuw5tAvbR6gpl6Ty +aPxuoc-F48WaqV_UPrJ2yR19b6uzJH6w0APm9OJF5GwjIZBqn84NlMZGUwmeH2MQSJvJg_aq6o9ldFXa +mWruFu0lyDaiykZR5ZHTxtFicSFRmYETWWgqRfTizOIz02ICyaNTG3G7g32LYEUII4rkbjFBNm7KaKnM +SCvJYIEDdC5sUX6tufyYgIL8toMI0FdMJQVKYiGoBM2LLNAprtXEMX8yK2v093LKDJxQvpi7XGvJUtzv +QLrkFG7CLFghlCk-hu0jDXowrLEGJbHQVoLmxRuE0VbihJslQFnxo4ssCMGfJuE32lKCNA9QgCU1hYsq +K5Y_X5qba0tJM-VNIOLMVC7ynJj-BJowbSUxu092u9US5q740UUZhODPVJG7rQTdJzvpzWVmYcpM5aLM +ienPnQnTUhIhylwEVaLj6I0H3O7r-ydWd-hr1hOuvrtYU_5VzbsiNeWrcyy3lmN4fBzbIeKYPgLHvDpv +_ka52SvXOveSx7_Oa_p-q_4V0LHuTJq7Tqhrdz-eqbo5G25dY2c9--BxjGpNW5yOYZQ5js2iuQP9MU6- +xzwYNndeOubBoup-WxN1ETnNnXHpyzxdF8J4RlhwYUSXFoAao0W05PhjEn-CVBTspVgVBG3wVPkB9ftF +rLmfqPTOoCSO9kHEhSZ_1QrMlcjqdFDDeoXjyevJ1RXG9GlqI0XO5jf2YrV2anfWCIgTrn0HLZECSdFD +qIMC8lv02NWUoDa7GuZQExeazdE5nQ3-vfF_QczV5Prmanpx4x4jNo9bcKjm9zE-JQWO_cpFlIx17SS_ +3GVbtEqXyQNafaBChjkJcndYL9ZkSq2WZyfxLsG5abo4S9mzyvWjnhRlKfiauk9_xkuMu0SiiRNQrJTe +WraUprwCIsSvR-mFmJqtaDkSSolNxa8nAtjzbWfFAcLW3iKyfRlhenoSSZOFwxF_rUOUWaOKTeOxkL0H +g5EBN84nXmmd5gK1LJEmKGFOifzk151KlqJHIxrYS26kV9kfb8MKJun1UvPDLf6BGSrK5ryEtkoD200z +QOVr9o2AShRidN614MJqQDW4WF7tOaWNjIC-DCE-iJ-qQz3bSMeucqK15CRkrxnZVU7i7OlsE93j04xf +yTyppVT4oOTX4QEE-TVG3y41onfLTSW9MxZyXjXAoOkMTX69uHx_Pf3PBL2dE_V1VrDmC3c9uaF6gvzU +M6KyEUu8z9zvDmnc79GQ8L1hkWjwTe-UBYqX1ANJGHkMYcgQDiT5U14KWIBIMThxVdmoLv_3i8CKMWpJ +ZfD2F2osBbpLdugf6Hs0QqfQFpUJqZyySPn23rX3Jbmvok3-Qt0azN5ZFf1mILCyNJ4t-pJk779e3Cbr +3EvfZ3-n7Knev5-OhRimHEyJWQ1d6r749ACI_twv1gdQJ4hHiVDrMMQ4gxMjkA9eMWjD5ArPZ0VrVRhQ +TZKcNx1wigGH_lqmIVnzmpdVQbU78ZLBi14jh9dNL831Ba56azRfV16uLEu8WN4v0jiJsh0xZu2yfcSO +8dEyWVslimXVqDT5TDKQGXJWhOq5T-S_SprGbJ2z2BWY7PHNXAYRAlKApYQuBXJcxBGRVZL-IqSh5Jp9 +RdOtlqC6lNBf0tWl8mSdxHt0ij7sso24V0GfPya7BEHlD3BZp2oxbADaWuXdMqOKjF-ZSt5FAI_KM5ui +0KIKzsLR-WxcpFwtJULlH7ZZvsIZ8Len_x2VvOJ_--aUyU1fjSdX6NVvIvF4cn1Bfrqcvp3ekC8mYh1Q +DLjpNVmLj0errLzFlg4LJv5CWUY0Gqn8V-9ZeWRWE9AGVISflo6_aOno9Lx8mpZ1pmWlAx77BxqhfzrD +mZRKEqPitoBIclaOOKPhFLYrKmpphsPQLwiUHbMsEipCiUA7X6lNRZGWK3jDOhivk0V62KJsvUTU3VEe +qCBblGoSfwUTwVQw-9KQk6d5MbGlmNTmbEb0aLs_5ET5Ml5nebLsofmVkoIaNUjsD3axiF6g3ilaLh6l +wxbJojVvediuVzHR5QxpJAsngNcHeQXbLB5Iz20WD3zVtSPhX99czd-_I6uIEiEG14XKmLIBDwLgoV70 +64hk8h7U7DJHHY4YkA9hMZ7YiQscVjQ-g3VMma0CT1ihs9odBsmLAHkSsWltzq4iEqE-gfVAVhJp2mlR +5-rpgPIlDyj6bkCZw_IKpdaYDpnB0ynl6ZTyhbdDT6eUJwPH09LxtHTUXTrwxsOILkV8B4hZNU3WNH64 +iE-KK9G7vjy_-DcQOkqNlUcBpKsEckFgxo8Sm_ZKBypSQVvF1UsDW9AB6y3DJtkvENFrN-JyGRGmwMYb +YabMOgJhAt2-WFynEqmbUY7JiZZOk5Z-KT9cSlGYFtEeL25RvEijLF0_RrdJlKUJ6EpnTc0dQItZ0CfR +bUn0Wu5LkGb79LBeoz3-4ylK1nmCvkNJukTfFNlEruJOLjinPPXCckpMWzMOyCUczjewUpRmUXHuFN3C +b7bg5zQ0WEFRAXhU-o8f-ZgjUKt1Ku_A6TWdq-ezsdI98t9l8vHf-fyeXyEfGJ4cggJg5OS-NXp_eVni +lGxKuj07xnR8phzSebNIx0kRh5VEhieymVzt22zDXi5s8DaQVYnF6Tt59qyvz81FugQKUX4VzV_l-K-D +k2fPsh0GA6YPjEczkh95BQosLdKo4cIKdI6-poGNHhbpjDkBd4RsLCnvB820Yqz84Tf93zuNIM0ElxZr +yBHjS6uFhEM6bbzOe3g2bug3Wi-P7n_GYxoHRguI1GP2w96ZEsbSXmvUZ9UbiMCzHAP2OpA2sw5Yo-mi +YgFtP8juVzyMbQkBdILoBjcpZou2TckPt5G0AujRiL3WRjKLyAwybN3aA3BWivIAvA9UIcq2efm3VPJx +VAyHCp77o6ERo69JUvWzNOF9cKZ5QkhhfXX3ROEEXhKVxwtDcQ85scfiwVNvhkgcpOQ-Sfd4BCo9mSfp +vke-szgNJowMqxEJRrvrDZwxhgHvdhJr2P0FAIdgCdF8K4NHuOzFau4iQEdbMJniDyynII-eutCv1XrP +5YRGRQDEydHmgYY3c5sFXnT1EP-L_QK9-u1mcq79UP20qRx3odAhgV5VNFe4V5XFM61EawESWVA81vif +GtiS_Mu5JWF44EJf-FSSg_eIHr2jPNn38R-GqPfH5-T2Y5Z9en91-WdPiqZGTvCDIdrvDtwTlh7qRwTq +xU8_kS_VdNyT6oYrRk0Zrl2Nu6J6KaJ-vaHcEvK_F0j-3VaTpveANoOPeNGZrxfxJxo6UTXWFDNBmnlG +wHSnIWERk-FLwrirFpZyi4pWN_vYhDcNmCXGfu-PP3tsj0T-Qo8RaiBOuJ-RfktK8mNovjhg7KEjnbTd +d36vQsrXjnOORKyWDd43kno-8nVll3zYJflH-JoA1-v5c_I_EQ4T_zeb30xf_4a-_vYuIx-FbynaC4b2 +tTMu4hnDDTR8At9J1obDdlm8blQW7mKRJkZz-btIqgJ99CzsOIa6rT7GOu3RCWUOx9W0k7aH2_UqfllP +QkkGqamkxKEaElSS4erqKslYTckryZgVVJZAMHUDUEGmScFpTq2JwR5HtImDH027yVJAsxJO1kKaU3Jy +FNGAoBNDb1rXicE2Iu_EsPxUnsB3CAyBP0doRiaKgR5NLYrhNyYaxfAa1Y7imE1LSOm4dZSkNKxGBKUY +ZvO6UjpwHXkpDauiyhRHaUxsigEeQXPKRG5IekoFblaBimE3LUQlwzanRyWjHkGWSoZvVp1KRm5WpEpG +bkSrSgLMIbtqwLvF8RyfzOhpzz8OPNINZtxMJkxnwDEEldnOaAbThDb59WLyDh-lTogfzky4f0cZ2bOf +IOJShKiVYDIbo-fPK-r1-hFSi4yuEOE49YVQosF0iBzj9BpKCwHoECHQKTyUE47RIVqs1oRQbhSgbhBk +WkUCSGH3Ul0iwrTuBPPBMLpBS6mVKoAeEKsjNLmtbSEkmUjdpAi0GtYgqsDrKl2GmaYWWQStu1RpVtya +VCXbjowqtzU6gCUTqBsEuazqAfToMN0gB7gdCOCE5e4GFeYtRwATTGarE0T43dYEkOPQau0EYc5bpwCe +TBXKTtAD354F8CIAOkJI2S1gCDeg7nq3aKqzTVZBukVM3THTkXFSfrscwA0M1jGijHvCKgRRNdlOEVOX +k67QAXgthLDBsneDjDLviwBiQO2orpFkOntUY4jidIoemzdMOEEyUjcoKvPqCeAIguoQSaB3Uig9BUiH +iLF7WYWyowUa6g5Fbm-xUJpMtA5RBXq9hTIk4hV2hxi3914oQyZad6iqQ0x7aNDf1alPtcBqoL54-U2e +_tmjfNKHYurbaEGaT5CObAsJW-0Xt-sksulh7bLPka5pyvI9CL1i9qB6Q4RNYq4ahPOSl6Q_X89nr4xH +nv5PDfWuAx4YIk2HMD7smCGLaFnkn1f7-CNeZNhffxyh3mpJQqCI94hMLI8IdwgAFg9J0ngjEhw6JH2L +yHUBRfavcElptN1ld7skz3ugvqD8qFHVF7x5E83foRHq0WnQ0zRHZa0ReThk26HUs0PWj0Paa0PWVYMC +hiuQkNKG6OZNREZYNDt_OxnSWDZMzG-Rk8EwwFCrZcTI6Q-olF-8WTwMjCebQj1R1tCrVfVhMbqCGjGb +_OJuBE4Qb1ap9KB8NvllYLYJ7jH-I3nkW0mVzyEYou5HdTWV_d0-kpWrdKkpYct0PdJW3g5rUX2gp8ZA +yF-x7WaSGZFWtehT8lhom7E0Q73-4DvZEjZ0CRX_6gA66mqicnZK9JzCOlKWJtM7srChOfvRu2OkoqSO +UftFSnPkftFqA_eLlOisUjiAEv0pstV6ZIIJi-USpclnFGfrwybNzbaaGzNDj9EQ_9L7FDyJw_3LOshQ +IM-TdI9us2ydLNJCkeD1-eX1RG0H1XY4xesn-pDtULKIP_IaIlo04WLAubTtPokkPRe_yKkkTE6Cm2Vc +No_tC4zmj9DF_Pxycn0x6bMcQzMqgJmNfHdNOTeej_ajNRwhE8R_KUUbYHKyVJyR7MQkjS1X5_LABnoN +Wbs1CSwXkjy2-Zwz9RjVgojSiJmKU2kuVaR3VHk6I02pQl1Fkf6SWHRirlWYWFyTCZpOZHk0ZgnEJNon +D3toPpGh7DNV9aLUlQ44B_IlD6oO_HmEUg6ERIyNMD4_OVMjFG9eKuMMZIQkk4YZJ2OEV6EJPS2RQUXO +R0QXBu_tgEE0mY1PxHTSl7h4w2OEbF6yGaoBVJxKBg44XfbAPIDVHMumC6VNnTH_CwAA___Nbz8RhCQE +AA== ` dataRange := func(start, end int) func() []byte { return func() []byte { @@ -582,7 +588,7 @@ dpHSHLldtNrA7SIlOvtfAAAA__80AwSB6R0EAA== defer r.Close() buf := new(bytes.Buffer) - buf.Grow(44591) + buf.Grow(45041) _, err = io.Copy(buf, r) if err != nil { @@ -780,5 +786,6 @@ dpHSHLldtNrA7SIlOvtfAAAA__80AwSB6R0EAA== {Data: dataRange(267892, 268985), Name: "migrations/20190613120345-drop-switchover-resources.sql"}, {Data: dataRange(268985, 269442), Name: "migrations/20190701111645-add-rotation-favorite.sql"}, {Data: dataRange(269442, 269801), Name: "migrations/20190702161722-add-schedule-favorites.sql"}, + {Data: dataRange(269801, 271492), Name: "migrations/20190715130233-verification-codes-update.sql"}, } } diff --git a/migrate/migrations/20190715130233-verification-codes-update.sql b/migrate/migrations/20190715130233-verification-codes-update.sql new file mode 100644 index 0000000000..a866e93b73 --- /dev/null +++ b/migrate/migrations/20190715130233-verification-codes-update.sql @@ -0,0 +1,54 @@ +-- +migrate Up +UPDATE engine_processing_versions +SET "version" = 2 +WHERE type_id = 'verify'; + +-- add new columns +ALTER TABLE user_verification_codes + ADD COLUMN contact_method_id UUID REFERENCES user_contact_methods (id) ON DELETE CASCADE UNIQUE, + ADD COLUMN sent boolean DEFAULT FALSE; + +-- add new data (1 row for each contact method type) +UPDATE user_verification_codes code +SET + sent = send_to IS NULL, + contact_method_id = COALESCE(send_to, ( + SELECT contact_method_id FROM outgoing_messages + WHERE user_verification_code_id = code.id + LIMIT 1 + )); + +DELETE FROM user_verification_codes + WHERE contact_method_id IS NULL; + +ALTER TABLE user_verification_codes + DROP COLUMN user_id, + DROP COLUMN contact_method_value, + DROP COLUMN send_to, + ALTER COLUMN sent SET NOT NULL, + ALTER COLUMN contact_method_id SET NOT NULL; + +-- +migrate Down +UPDATE engine_processing_versions +SET "version" = 1 +WHERE type_id = 'verify'; + +ALTER TABLE user_verification_codes + ADD COLUMN user_id UUID REFERENCES users(id), + ADD COLUMN contact_method_value text, + ADD COLUMN send_to UUID REFERENCES user_contact_methods(id), + ADD CONSTRAINT user_verification_codes_user_id_contact_method_value_key UNIQUE (user_id, contact_method_value); + +UPDATE user_verification_codes +SET + user_id = cm.user_id, + contact_method_value = cm.value, + send_to = CASE WHEN sent THEN NULL ELSE contact_method_id END +FROM user_contact_methods cm +WHERE cm.id = contact_method_id; + +ALTER TABLE user_verification_codes + DROP COLUMN contact_method_id, + DROP COLUMN sent, + ALTER COLUMN user_id SET NOT NULL, + ALTER COLUMN contact_method_value SET NOT NULL; diff --git a/notification/store.go b/notification/store.go index d217c1c56a..8b41681705 100644 --- a/notification/store.go +++ b/notification/store.go @@ -21,25 +21,23 @@ const minTimeBetweenTests = time.Minute type Store interface { SendContactMethodTest(ctx context.Context, cmID string) error - SendContactMethodVerification(ctx context.Context, cmID string, resend bool) error - VerifyContactMethod(ctx context.Context, cmID string, code int) ([]string, error) - CodeExpiration(ctx context.Context, cmID string) (*time.Time, error) + SendContactMethodVerification(ctx context.Context, cmID string) error + VerifyContactMethod(ctx context.Context, cmID string, code int) error Code(ctx context.Context, id string) (int, error) } var _ Store = &DB{} type DB struct { - db *sql.DB - getCMUserID *sql.Stmt - setVerificationCode *sql.Stmt - verifyVerificationCode *sql.Stmt - enableContactMethods *sql.Stmt - insertTestNotification *sql.Stmt - updateLastSendTime *sql.Stmt - codeExpiration *sql.Stmt - getCode *sql.Stmt - sendTestLock *sql.Stmt + db *sql.DB + getCMUserID *sql.Stmt + setVerificationCode *sql.Stmt + verifyAndEnableContactMethod *sql.Stmt + insertTestNotification *sql.Stmt + updateLastSendTime *sql.Stmt + getCode *sql.Stmt + isDisabled *sql.Stmt + sendTestLock *sql.Stmt rand *rand.Rand } @@ -68,46 +66,34 @@ func NewDB(ctx context.Context, db *sql.DB) (*DB, error) { where id = $1 `), - codeExpiration: p.P(` - select expires_at - from user_verification_codes v - join user_contact_methods cm on cm.id = $1 - where v.user_id = cm.user_id and v.contact_method_value = cm.value + isDisabled: p.P(` + select disabled + from user_contact_methods + where id = $1 `), + // should result in sending a verification code to the specified contact method setVerificationCode: p.P(` - insert into user_verification_codes (id, user_id, contact_method_value, code, expires_at, send_to) - select - $1, - cm.user_id, - cm.value, - $3, - now() + cast($4 as interval), - $2 - from user_contact_methods cm - where id = $2 - on conflict (user_id, contact_method_value) do update + insert into user_verification_codes (id, contact_method_id, code, expires_at) + values ($1, $2, $3, NOW() + '15 minutes'::interval) + on conflict (contact_method_id) do update set - send_to = $2, - expires_at = case when $5 then user_verification_codes.expires_at else EXCLUDED.expires_at end, - code = case when $5 then user_verification_codes.code else EXCLUDED.code end - `), - verifyVerificationCode: p.P(` - delete from user_verification_codes v - using user_contact_methods cm - where - cm.id = $1 and - v.contact_method_value = cm.value and - v.user_id = cm.user_id and - v.code = $2 - returning cm.value + sent = false, + expires_at = EXCLUDED.expires_at `), - enableContactMethods: p.P(` - update user_contact_methods + // should reactivate a contact method if specified code matches what was set + verifyAndEnableContactMethod: p.P(` + with v as ( + delete from user_verification_codes + where contact_method_id = $1 and code = $2 + returning contact_method_id id + ) + update user_contact_methods cm set disabled = false - where user_id = $1 and value = $2 - returning id + from v + where cm.id = v.id + returning cm.id `), updateLastSendTime: p.P(` @@ -176,23 +162,6 @@ func (db *DB) Code(ctx context.Context, id string) (int, error) { return code, err } -func (db *DB) CodeExpiration(ctx context.Context, id string) (t *time.Time, err error) { - _, err = db.cmUserID(ctx, id) - if err != nil { - return nil, err - } - - err = db.codeExpiration.QueryRowContext(ctx, id).Scan(&t) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - - return t, nil -} - func (db *DB) SendContactMethodTest(ctx context.Context, id string) error { _, err := db.cmUserID(ctx, id) if err != nil { @@ -206,12 +175,21 @@ func (db *DB) SendContactMethodTest(ctx context.Context, id string) error { // Lock outgoing_messages first, before we modify user_contact methods // to prevent deadlock. - _, err = tx.Stmt(db.sendTestLock).ExecContext(ctx) + _, err = tx.StmtContext(ctx, db.sendTestLock).ExecContext(ctx) if err != nil { return err } - r, err := tx.Stmt(db.updateLastSendTime).ExecContext(ctx, id, fmt.Sprintf("%f seconds", minTimeBetweenTests.Seconds())) + var isDisabled bool + err = tx.StmtContext(ctx, db.isDisabled).QueryRowContext(ctx, id).Scan(&isDisabled) + if err != nil { + return err + } + if isDisabled { + return validation.NewFieldError("ContactMethod", "contact method disabled") + } + + r, err := tx.StmtContext(ctx, db.updateLastSendTime).ExecContext(ctx, id, fmt.Sprintf("%f seconds", minTimeBetweenTests.Seconds())) if err != nil { return err } @@ -224,7 +202,7 @@ func (db *DB) SendContactMethodTest(ctx context.Context, id string) error { } vID := uuid.NewV4().String() - _, err = tx.Stmt(db.insertTestNotification).ExecContext(ctx, vID, id) + _, err = tx.StmtContext(ctx, db.insertTestNotification).ExecContext(ctx, vID, id) if err != nil { return err } @@ -232,8 +210,8 @@ func (db *DB) SendContactMethodTest(ctx context.Context, id string) error { return tx.Commit() } -func (db *DB) SendContactMethodVerification(ctx context.Context, id string, resend bool) error { - _, err := db.cmUserID(ctx, id) +func (db *DB) SendContactMethodVerification(ctx context.Context, cmID string) error { + _, err := db.cmUserID(ctx, cmID) if err != nil { return err } @@ -244,7 +222,7 @@ func (db *DB) SendContactMethodVerification(ctx context.Context, id string, rese } defer tx.Rollback() - r, err := tx.Stmt(db.updateLastSendTime).ExecContext(ctx, id, fmt.Sprintf("%f seconds", minTimeBetweenTests.Seconds())) + r, err := tx.StmtContext(ctx, db.updateLastSendTime).ExecContext(ctx, cmID, fmt.Sprintf("%f seconds", minTimeBetweenTests.Seconds())) if err != nil { return err } @@ -253,12 +231,12 @@ func (db *DB) SendContactMethodVerification(ctx context.Context, id string, rese return err } if rows != 1 { - return validation.NewFieldError("ContactMethod", "test message rate-limit exceeded") + return validation.NewFieldError("ContactMethod", fmt.Sprintf("Too many messages! Please try again in %.0f minute(s)", minTimeBetweenTests.Minutes())) } - vID := uuid.NewV4().String() + vcID := uuid.NewV4().String() code := db.rand.Intn(900000) + 100000 - _, err = tx.Stmt(db.setVerificationCode).ExecContext(ctx, vID, id, code, fmt.Sprintf("%f seconds", (15*time.Minute).Seconds()), resend) + _, err = tx.StmtContext(ctx, db.setVerificationCode).ExecContext(ctx, vcID, cmID, code) if err != nil { return errors.Wrap(err, "set verification code") } @@ -266,41 +244,27 @@ func (db *DB) SendContactMethodVerification(ctx context.Context, id string, rese return tx.Commit() } -func (db *DB) VerifyContactMethod(ctx context.Context, cmID string, code int) ([]string, error) { - userID, err := db.cmUserID(ctx, cmID) +func (db *DB) VerifyContactMethod(ctx context.Context, cmID string, code int) error { + _, err := db.cmUserID(ctx, cmID) if err != nil { - return nil, err - } - - tx, err := db.db.BeginTx(ctx, nil) - if err != nil { - return nil, err + return err } - defer tx.Rollback() - var cmValue string - err = db.verifyVerificationCode.QueryRowContext(ctx, cmID, code).Scan(&cmValue) + res, err := db.verifyAndEnableContactMethod.ExecContext(ctx, cmID, code) if err == sql.ErrNoRows { - return nil, validation.NewFieldError("Code", "unrecognized code") + return validation.NewFieldError("code", "invalid code") } if err != nil { - return nil, err + return err } - rows, err := db.enableContactMethods.QueryContext(ctx, userID, cmValue) + num, err := res.RowsAffected() if err != nil { - return nil, err + return err } - defer rows.Close() - var result []string - - for rows.Next() { - var id string - err = rows.Scan(&id) - if err != nil { - return nil, err - } - result = append(result, id) + if num != 1 { + return validation.NewFieldError("code", "invalid code") } - return result, tx.Commit() + + return nil } diff --git a/smoketest/harness/graphql.go b/smoketest/harness/graphql.go index 0d21a71c0b..43c5c81529 100644 --- a/smoketest/harness/graphql.go +++ b/smoketest/harness/graphql.go @@ -45,7 +45,7 @@ func (h *Harness) GraphQLQuery(query string) *QLResponse { // handling authentication. Queries are performed with Admin role. func (h *Harness) GraphQLQuery2(query string) *QLResponse { h.t.Helper() - return h.GraphQLQueryT(h.t, query, "/v1/graphql2") + return h.GraphQLQueryT(h.t, query, "/api/graphql") } // GraphQLQueryT will perform a GraphQL query against the backend, internally diff --git a/smoketest/twilioenablebysms_test.go b/smoketest/twilioenablebysms_test.go index 21ed34ebc4..29cf913103 100644 --- a/smoketest/twilioenablebysms_test.go +++ b/smoketest/twilioenablebysms_test.go @@ -11,22 +11,32 @@ func TestTwilioEnableBySMS(t *testing.T) { t.Parallel() sqlQuery := ` - insert into users (id, name, email) - values - ({{uuid "user"}}, 'bob', 'joe'); - insert into user_contact_methods (id, user_id, name, type, value, disabled) - values - ({{uuid "cm1"}}, {{uuid "user"}}, 'personal', 'SMS', {{phone "1"}}, true), - ({{uuid "cm2"}}, {{uuid "user"}}, 'personal', 'VOICE', {{phone "1"}}, true); - insert into user_verification_codes (id, user_id, contact_method_value, code, expires_at) - values - ({{uuid "id"}}, {{uuid "user"}}, {{phone "1"}}, 123456, now() + '15 minutes'::interval) -` + insert into users (id, name, email) + values + ({{uuid "user"}}, 'bob', 'joe'); + insert into user_contact_methods (id, user_id, name, type, value, disabled) + values + ({{uuid "cm1"}}, {{uuid "user"}}, 'personal', 'SMS', {{phone "1"}}, true), + ({{uuid "cm2"}}, {{uuid "user"}}, 'personal', 'VOICE', {{phone "1"}}, true); + insert into user_verification_codes (id, user_id, contact_method_value, code, expires_at) + values + ({{uuid "id"}}, {{uuid "user"}}, {{phone "1"}}, 123456, now() + '15 minutes'::interval); + insert into outgoing_messages (message_type, contact_method_id, last_status, sent_at, user_id, user_verification_code_id) + values + ('verification_message', {{uuid "cm1"}}, 'delivered', now(), {{uuid "user"}}, {{uuid "id"}}); + ` + h := harness.NewHarness(t, sqlQuery, "add-verification-code") defer h.Close() - doQL := func(query string) { - g := h.GraphQLQuery(query) + doQL := func(query string, expectErr bool) { + g := h.GraphQLQuery2(query) + if expectErr { + if len(g.Errors) == 0 { + t.Fatal("expected error") + } + return + } for _, err := range g.Errors { t.Error("GraphQL Error:", err.Message) } @@ -35,43 +45,32 @@ func TestTwilioEnableBySMS(t *testing.T) { } } - cm1 := h.UUID("cm1") - cm2 := h.UUID("cm2") + smsID := h.UUID("cm1") + voiceID := h.UUID("cm2") doQL(fmt.Sprintf(` mutation { - verifyContactMethod(input: { - contact_method_id: "%s", - verification_code: %d, - }) { - contact_method_ids - } - } - `, cm1, 123456)) + verifyContactMethod(input:{ + contactMethodID: "%s", + code: %d + }) + } + `, smsID, 123456), false) - // All contact methods that have same value and of the same user should be enabled now. + // Voice should still be disabled - expect error doQL(fmt.Sprintf(` - mutation { - sendContactMethodTest(input:{ - contact_method_id: "%s", - }){ - id - } - } - `, cm1)) + mutation { + testContactMethod(id: "%s") + } + `, voiceID), true) d1 := h.Twilio().Device(h.Phone("1")) - d1.ExpectSMS("test") doQL(fmt.Sprintf(` mutation { - sendContactMethodTest(input:{ - contact_method_id: "%s", - }){ - id - } + testContactMethod(id: "%s") } - `, cm2)) + `, smsID), false) - d1.ExpectVoice("test") + d1.ExpectSMS("test") } diff --git a/smoketest/twilioenablebyvoice_test.go b/smoketest/twilioenablebyvoice_test.go index 1dfdaf2aca..daab19a323 100644 --- a/smoketest/twilioenablebyvoice_test.go +++ b/smoketest/twilioenablebyvoice_test.go @@ -11,22 +11,32 @@ func TestTwilioEnablebyVoice(t *testing.T) { t.Parallel() sqlQuery := ` - insert into users (id, name, email) - values - ({{uuid "user"}}, 'bob', 'joe'); - insert into user_contact_methods (id, user_id, name, type, value, disabled) - values - ({{uuid "cm1"}}, {{uuid "user"}}, 'personal', 'SMS', {{phone "1"}}, true), - ({{uuid "cm2"}}, {{uuid "user"}}, 'personal', 'VOICE', {{phone "1"}}, true); - insert into user_verification_codes (id, user_id, contact_method_value, code, expires_at) - values - ({{uuid "id"}}, {{uuid "user"}}, {{phone "1"}}, 123456, now() + '15 minutes'::interval) -` + insert into users (id, name, email) + values + ({{uuid "user"}}, 'bob', 'joe'); + insert into user_contact_methods (id, user_id, name, type, value, disabled) + values + ({{uuid "cm1"}}, {{uuid "user"}}, 'personal', 'SMS', {{phone "1"}}, true), + ({{uuid "cm2"}}, {{uuid "user"}}, 'personal', 'VOICE', {{phone "1"}}, true); + insert into user_verification_codes (id, user_id, contact_method_value, code, expires_at) + values + ({{uuid "id"}}, {{uuid "user"}}, {{phone "1"}}, 123456, now() + '15 minutes'::interval); + insert into outgoing_messages (message_type, contact_method_id, last_status, sent_at, user_id, user_verification_code_id) + values + ('verification_message', {{uuid "cm2"}}, 'delivered', now(), {{uuid "user"}}, {{uuid "id"}}); + ` + h := harness.NewHarness(t, sqlQuery, "add-verification-code") defer h.Close() - doQL := func(query string) { - g := h.GraphQLQuery(query) + doQL := func(query string, expectErr bool) { + g := h.GraphQLQuery2(query) + if expectErr { + if len(g.Errors) == 0 { + t.Fatal("expected error") + } + return + } for _, err := range g.Errors { t.Error("GraphQL Error:", err.Message) } @@ -35,43 +45,33 @@ func TestTwilioEnablebyVoice(t *testing.T) { } } - cm1 := h.UUID("cm1") - cm2 := h.UUID("cm2") + smsID := h.UUID("cm1") + voiceID := h.UUID("cm2") doQL(fmt.Sprintf(` mutation { - verifyContactMethod(input: { - contact_method_id: "%s", - verification_code: %d, - }) { - contact_method_ids - } - } - `, cm2, 123456)) + verifyContactMethod(input:{ + contactMethodID: "%s", + code: %d + }) + } + `, voiceID, 123456), false) - // All contact methods that have same value and of the same user should be enabled now. + // SMS should still be disabled - expect error doQL(fmt.Sprintf(` - mutation { - sendContactMethodTest(input:{ - contact_method_id: "%s", - }){ - id - } - } - `, cm1)) + mutation { + testContactMethod(id: "%s") + } + `, smsID), true) + d1 := h.Twilio().Device(h.Phone("1")) - d1.ExpectSMS("test") - h.Twilio().WaitAndAssert() + // Voice should now be enabled doQL(fmt.Sprintf(` mutation { - sendContactMethodTest(input:{ - contact_method_id: "%s", - }){ - id - } + testContactMethod(id: "%s") } - `, cm2)) + `, voiceID), false) d1.ExpectVoice("test") } diff --git a/smoketest/twiliosmsverification_test.go b/smoketest/twiliosmsverification_test.go index 4013946ed2..0b897d0794 100644 --- a/smoketest/twiliosmsverification_test.go +++ b/smoketest/twiliosmsverification_test.go @@ -3,6 +3,7 @@ package smoketest import ( "fmt" "github.com/target/goalert/smoketest/harness" + "strconv" "strings" "testing" "time" @@ -13,42 +14,39 @@ func TestTwilioSMSVerification(t *testing.T) { t.Parallel() sqlQuery := ` - insert into users (id, name, email) - values - ({{uuid "user"}}, 'bob', 'joe'); - insert into user_contact_methods (id, user_id, name, type, value, disabled) - values - ({{uuid "cm1"}}, {{uuid "user"}}, 'personal', 'SMS', {{phone "1"}}, true), - ({{uuid "cm2"}}, {{uuid "user"}}, 'personal', 'VOICE', {{phone "1"}}, true); - insert into user_notification_rules (id, user_id, delay_minutes, contact_method_id) - values - ({{uuid "nr1"}}, {{uuid "user"}}, 0, {{uuid "cm1"}}), - ({{uuid "nr2"}}, {{uuid "user"}}, 0, {{uuid "cm2"}}), - ({{uuid "nr3"}}, {{uuid "user"}}, 1, {{uuid "cm1"}}), - ({{uuid "nr4"}}, {{uuid "user"}}, 1, {{uuid "cm2"}}); - insert into escalation_policies (id, name) - values - ({{uuid "eid"}}, 'esc policy'); - insert into escalation_policy_steps (id, escalation_policy_id) - values - ({{uuid "esid"}}, {{uuid "eid"}}); - insert into escalation_policy_actions (escalation_policy_step_id, user_id) - values - ({{uuid "esid"}}, {{uuid "user"}}); + insert into users (id, name, email) + values + ({{uuid "user"}}, 'bob', 'joe'); + insert into user_contact_methods (id, user_id, name, type, value, disabled) + values + ({{uuid "cm1"}}, {{uuid "user"}}, 'personal', 'SMS', {{phone "1"}}, true); + insert into user_notification_rules (id, user_id, delay_minutes, contact_method_id) + values + ({{uuid "nr1"}}, {{uuid "user"}}, 0, {{uuid "cm1"}}); + insert into escalation_policies (id, name) + values + ({{uuid "eid"}}, 'esc policy'); + insert into escalation_policy_steps (id, escalation_policy_id) + values + ({{uuid "esid"}}, {{uuid "eid"}}); + insert into escalation_policy_actions (escalation_policy_step_id, user_id) + values + ({{uuid "esid"}}, {{uuid "user"}}); + + insert into services (id, escalation_policy_id, name) + values + ({{uuid "sid"}}, {{uuid "eid"}}, 'service'); + + insert into alerts (service_id, description) + values + ({{uuid "sid"}}, 'testing'); + ` - insert into services (id, escalation_policy_id, name) - values - ({{uuid "sid"}}, {{uuid "eid"}}, 'service'); - - insert into alerts (service_id, description) - values - ({{uuid "sid"}}, 'testing'); -` h := harness.NewHarness(t, sqlQuery, "add-verification-code") defer h.Close() doQL := func(query string) { - g := h.GraphQLQuery(query) + g := h.GraphQLQuery2(query) for _, err := range g.Errors { t.Error("GraphQL Error:", err.Message) } @@ -57,44 +55,47 @@ func TestTwilioSMSVerification(t *testing.T) { } } - cm1 := h.UUID("cm1") + smsID := h.UUID("cm1") doQL(fmt.Sprintf(` mutation { sendContactMethodVerification(input:{ - contact_method_id: "%s", - }){ - id - } + contactMethodID: "%s" + }) } - `, cm1)) + `, smsID)) tw := h.Twilio() d1 := tw.Device(h.Phone("1")) msg := d1.ExpectSMS("verification") tw.WaitAndAssert() // wait for code, and ensure no notifications went out - code := strings.Map(func(r rune) rune { + codeStr := strings.Map(func(r rune) rune { if r >= '0' && r <= '9' { return r } return -1 }, msg.Body()) + code, _ := strconv.Atoi(codeStr) + doQL(fmt.Sprintf(` mutation { verifyContactMethod(input:{ - contact_method_id: "%s", - verification_code: %s - }){ - contact_method_ids - } + contactMethodID: "%s", + code: %d + }) } - `, cm1, code)) + `, smsID, code)) h.FastForward(time.Minute) - // both CM's for the given number should be enabled - d1.ExpectSMS("testing") - d1.ExpectVoice("testing") + doQL(fmt.Sprintf(` + mutation { + testContactMethod(id: "%s") + } + `, smsID)) + + // sms for the given number should be enabled + d1.ExpectSMS("test") } diff --git a/smoketest/twiliovoiceverification_test.go b/smoketest/twiliovoiceverification_test.go index 80ce5762db..c79e413649 100644 --- a/smoketest/twiliovoiceverification_test.go +++ b/smoketest/twiliovoiceverification_test.go @@ -3,6 +3,7 @@ package smoketest import ( "fmt" "github.com/target/goalert/smoketest/harness" + "strconv" "strings" "testing" "time" @@ -13,42 +14,39 @@ func TestTwilioVoiceVerification(t *testing.T) { t.Parallel() sqlQuery := ` - insert into users (id, name, email) - values - ({{uuid "user"}}, 'bob', 'joe'); - insert into user_contact_methods (id, user_id, name, type, value, disabled) - values - ({{uuid "cm1"}}, {{uuid "user"}}, 'personal', 'SMS', {{phone "1"}}, true), - ({{uuid "cm2"}}, {{uuid "user"}}, 'personal', 'VOICE', {{phone "1"}}, true); - insert into user_notification_rules (id, user_id, delay_minutes, contact_method_id) - values - ({{uuid "nr1"}}, {{uuid "user"}}, 0, {{uuid "cm1"}}), - ({{uuid "nr2"}}, {{uuid "user"}}, 0, {{uuid "cm2"}}), - ({{uuid "nr3"}}, {{uuid "user"}}, 1, {{uuid "cm1"}}), - ({{uuid "nr4"}}, {{uuid "user"}}, 1, {{uuid "cm2"}}); - insert into escalation_policies (id, name) - values - ({{uuid "eid"}}, 'esc policy'); - insert into escalation_policy_steps (id, escalation_policy_id) - values - ({{uuid "esid"}}, {{uuid "eid"}}); - insert into escalation_policy_actions (escalation_policy_step_id, user_id) - values - ({{uuid "esid"}}, {{uuid "user"}}); + insert into users (id, name, email) + values + ({{uuid "user"}}, 'bob', 'joe'); + insert into user_contact_methods (id, user_id, name, type, value, disabled) + values + ({{uuid "cm2"}}, {{uuid "user"}}, 'personal', 'VOICE', {{phone "1"}}, true); + insert into user_notification_rules (id, user_id, delay_minutes, contact_method_id) + values + ({{uuid "nr2"}}, {{uuid "user"}}, 0, {{uuid "cm2"}}); + insert into escalation_policies (id, name) + values + ({{uuid "eid"}}, 'esc policy'); + insert into escalation_policy_steps (id, escalation_policy_id) + values + ({{uuid "esid"}}, {{uuid "eid"}}); + insert into escalation_policy_actions (escalation_policy_step_id, user_id) + values + ({{uuid "esid"}}, {{uuid "user"}}); + + insert into services (id, escalation_policy_id, name) + values + ({{uuid "sid"}}, {{uuid "eid"}}, 'service'); + + insert into alerts (service_id, description) + values + ({{uuid "sid"}}, 'testing'); + ` - insert into services (id, escalation_policy_id, name) - values - ({{uuid "sid"}}, {{uuid "eid"}}, 'service'); - - insert into alerts (service_id, description) - values - ({{uuid "sid"}}, 'testing'); -` h := harness.NewHarness(t, sqlQuery, "add-verification-code") defer h.Close() doQL := func(query string) { - g := h.GraphQLQuery(query) + g := h.GraphQLQuery2(query) for _, err := range g.Errors { t.Error("GraphQL Error:", err.Message) } @@ -57,44 +55,47 @@ func TestTwilioVoiceVerification(t *testing.T) { } } - cm2 := h.UUID("cm2") + voiceID := h.UUID("cm2") doQL(fmt.Sprintf(` mutation { sendContactMethodVerification(input:{ - contact_method_id: "%s", - }){ - id - } + contactMethodID: "%s" + }) } - `, cm2)) + `, voiceID)) tw := h.Twilio() d1 := tw.Device(h.Phone("1")) msg := d1.ExpectVoice("verification") tw.WaitAndAssert() // wait for code, and ensure no notifications went out - code := strings.Map(func(r rune) rune { + codeStr := strings.Map(func(r rune) rune { if r >= '0' && r <= '9' { return r } return -1 }, msg.Body()) + code, _ := strconv.Atoi(codeStr) + doQL(fmt.Sprintf(` mutation { verifyContactMethod(input:{ - contact_method_id: "%s", - verification_code: %s - }){ - contact_method_ids - } + contactMethodID: "%s", + code: %d + }) } - `, cm2, code)) + `, voiceID, code)) h.FastForward(time.Minute) - // both CM's for the given number should be enabled - d1.ExpectSMS("testing") - d1.ExpectVoice("testing") + doQL(fmt.Sprintf(` + mutation { + testContactMethod(id: "%s") + } + `, voiceID)) + + // voice for the given number should be enabled + d1.ExpectVoice("test") } diff --git a/web/src/app/contact-methods/components/ContactMethodForm.js b/web/src/app/contact-methods/components/ContactMethodForm.js index 0ba766b915..9f542b7683 100644 --- a/web/src/app/contact-methods/components/ContactMethodForm.js +++ b/web/src/app/contact-methods/components/ContactMethodForm.js @@ -7,11 +7,11 @@ import InputLabel from '@material-ui/core/InputLabel' import MenuItem from '@material-ui/core/MenuItem' import Select from '@material-ui/core/Select' import TextField from '@material-ui/core/TextField' -import VerificationForm from './VerificationForm' import gql from 'graphql-tag' import { withApollo } from 'react-apollo' import ApolloFormDialog from '../../dialogs/components/ApolloFormDialog' import { createNotificationRuleMutation } from '../../notification-rules/components/CreateNotificationRuleForm' +import UserContactMethodVerificationDialog from '../../users/UserContactMethodVerificationDialog' const createContactMethodMutation = gql` mutation CreateContactMethodMutation($input: CreateContactMethodInput) { @@ -191,23 +191,14 @@ class ContactMethodForm extends Component { this.createNotificationRule(this.state.contactMethod) } - renderVerificationForm() { - if (!this.state.showVerifyForm) return null - const { contactMethod, showVerifyForm } = this.state - return ( - this.setState({ showVerifyForm: false })} - onSuccess={() => this.onVerificationSuccess()} + renderVerificationForm = () => + this.state.showVerifyForm && ( + this.setState({ showVerifyForm: false })} + contactMethodID={this.state.contactMethod.id} /> ) - } getValue() { switch (this.state.type) { diff --git a/web/src/app/contact-methods/components/VerificationForm.js b/web/src/app/contact-methods/components/VerificationForm.js deleted file mode 100644 index 50915076e9..0000000000 --- a/web/src/app/contact-methods/components/VerificationForm.js +++ /dev/null @@ -1,203 +0,0 @@ -import React, { Component } from 'react' -import p from 'prop-types' -import FormControl from '@material-ui/core/FormControl' -import FormHelperText from '@material-ui/core/FormHelperText' -import Grid from '@material-ui/core/Grid' -import TextField from '@material-ui/core/TextField' -import LoadingButton from '../../loading/components/LoadingButton' -import ApolloFormDialog from '../../dialogs/components/ApolloFormDialog' -import gql from 'graphql-tag' -import { Mutation } from 'react-apollo' - -const verifyContactMethodMutation = gql` - mutation VerifyContactMethodMutation($input: VerifyContactMethodInput) { - verifyContactMethod(input: $input) { - contact_method_ids - } - } -` - -const sendContactMethodVerificationMutation = gql` - mutation SendContactMethodVerificationMutation( - $input: SendContactMethodVerificationInput - ) { - sendContactMethodVerification(input: $input) { - id - } - } -` - -const fieldStyle = { - width: '100%', -} - -function formatNumber(n) { - if (n.startsWith('+1')) { - return `+1 (${n.slice(2, 5)}) ${n.slice(5, 8)}-${n.slice(8)}` - } - if (n.startsWith('+91')) { - return `+91-${n.slice(3, 5)}-${n.slice(5, 8)}-${n.slice(8)}` - } - if (n.startsWith('+44')) { - return `+44 ${n.slice(3, 7)} ${n.slice(7)}` - } else { - return {n} - } -} - -export default class VerificationForm extends Component { - static propTypes = { - id: p.string, - open: p.bool, - userId: p.string, - handleRequestClose: p.func.isRequired, - } - - constructor(props) { - super(props) - - this.state = { - code: '', - submitted: false, - readOnly: false, - resend: false, - sendError: '', - loading: false, - } - } - - shouldSubmit = () => { - this.setState({ submitted: true }) - - const shouldSubmit = !this.getCodeError(true) - if (shouldSubmit) { - this.setState({ readOnly: true }) - return true - } - - return false - } - - sendCode = mutation => { - this.setState({ loading: true }) - mutation({ - variables: { - input: { - contact_method_id: this.props.id, - }, - }, - }) - } - - getCodeError(submitted = this.state.submitted) { - const code = this.state.code.trim() - if (submitted && !code) { - return 'Code is required' - } - if ((submitted && code.length !== 6) || (submitted && code.match(/\D/))) { - return 'Enter the 6-digit numeric code' - } - } - - getTitle() { - if (this.state.resend) { - return 'Resend Code' - } else { - return 'Send Code' - } - } - - renderFields() { - const { code, loading, readOnly } = this.state - - return ( - - - - this.setState({ resend: true, sendError: '', loading: false }) - } - onError={() => - this.setState({ - loading: false, - sendError: 'Too many messages! Try again after some time.', - }) - } - > - {mutation => ( - this.sendCode(mutation)} - /> - )} - - - - - - this.setState({ - code: e.target.value.replace(/\D/, '').slice(0, 6), - }) - } - placeholder='Enter the verification code received' - value={code} - /> - {this.getCodeError()} - - - - ) - } - - resetForm = () => { - this.setState({ - sendError: '', - code: '', - submitted: false, - readOnly: false, - loading: false, - }) - } - - render() { - const { open } = this.props - const title = 'Verify Contact Method by ' + this.props.type - const subtitle = `Verifying "${this.props.name}" at ${formatNumber( - this.props.value, - )}` - return ( - this.setState({ readOnly: false })} - errorMessage={this.state.sendError} - fields={this.renderFields()} - getVariables={() => ({ - input: { - contact_method_id: this.props.id, - verification_code: parseInt(this.state.code), - }, - })} - mutation={verifyContactMethodMutation} - onRequestClose={this.props.handleRequestClose} - open={open} - resetForm={this.resetForm} - shouldSubmit={this.shouldSubmit} - subtitle={subtitle} - title={title} - /> - ) - } -} diff --git a/web/src/app/dialogs/FormDialog.js b/web/src/app/dialogs/FormDialog.js index c83e231ce6..2c2456d2ae 100644 --- a/web/src/app/dialogs/FormDialog.js +++ b/web/src/app/dialogs/FormDialog.js @@ -120,6 +120,7 @@ export default class FormDialog extends React.PureComponent { fullScreen={!isWideScreen && !confirm && !alert} onClose={onClose} title={title} + subTitle={subTitle} />
{ - const { classes, disableGutters, form, subTitle } = this.props + const { classes, disableGutters, form } = this.props // don't render empty space - if (!form && !subTitle) { + if (!form) { return null } let Component = DialogContent if (disableGutters) Component = 'div' - return ( - - {this.renderSubtitle()} - {form} - - ) - } - - renderSubtitle = () => { - if (!this.props.subTitle) return null - - return {this.props.subTitle} + return {form} } renderCaption = () => { if (!this.props.caption) return null return ( - + {this.props.caption} ) diff --git a/web/src/app/dialogs/components/DialogTitleWrapper.js b/web/src/app/dialogs/components/DialogTitleWrapper.js index a302891427..6b0d9ccf8a 100644 --- a/web/src/app/dialogs/components/DialogTitleWrapper.js +++ b/web/src/app/dialogs/components/DialogTitleWrapper.js @@ -9,6 +9,7 @@ import withStyles from '@material-ui/core/styles/withStyles' import CloseIcon from '@material-ui/icons/Close' import DropDownMenu from '../../dialogs/components/DropDownMenu' import { styles } from '../../styles/materialStyles' +import { DialogContent } from '@material-ui/core' /** * Renders a fullscreen dialog with an app bar if on a small @@ -21,6 +22,7 @@ export default class DialogTitleWrapper extends Component { closeIcon: p.object, toolbarItems: p.array, // list of JSX items to display on the toolbar title: p.string.isRequired, + subTitle: p.string, onClose: p.func, options: p.array, // list of options to display as list items from option icon } @@ -33,6 +35,7 @@ export default class DialogTitleWrapper extends Component { toolbarItems, onClose, options, + subTitle, title, } = this.props @@ -58,21 +61,32 @@ export default class DialogTitleWrapper extends Component { if (fullScreen) { return ( - - - {closeButton} - - {title} - - {toolbarItems} - {menu} - - + + + + {closeButton} + + {title} + + {toolbarItems} + {menu} + + + + {subTitle} + + ) } else { return ( - {title} + + {title} + {subTitle} + {menu} ) diff --git a/web/src/app/icons/components/Icons.js b/web/src/app/icons/components/Icons.js index 96f7607845..3d8839a716 100644 --- a/web/src/app/icons/components/Icons.js +++ b/web/src/app/icons/components/Icons.js @@ -1,4 +1,5 @@ import React, { Component } from 'react' +import { PropTypes as p } from 'prop-types' import Tooltip from '@material-ui/core/Tooltip' import withStyles from '@material-ui/core/styles/withStyles' import AddIcon from '@material-ui/icons/Add' @@ -25,8 +26,12 @@ export class Trash extends Component { @withStyles(styles) export class Warning extends Component { + static propTypes = { + message: p.string, + } + render() { - const { classes, details } = this.props + const { classes, message } = this.props const warningIcon = ( ) - if (!details) { + if (!message) { return warningIcon } return ( - + {warningIcon} ) diff --git a/web/src/app/index.js b/web/src/app/index.js index 2b61056c3f..7cfe6a2e3f 100644 --- a/web/src/app/index.js +++ b/web/src/app/index.js @@ -7,9 +7,10 @@ import ReactDOM from 'react-dom' import { Provider as ReduxProvider } from 'react-redux' import { ConnectedRouter } from 'connected-react-router' import { ApolloProvider } from 'react-apollo' +import { ApolloProvider as ApolloProviderHooks } from '@apollo/react-hooks' import MuiThemeProvider from '@material-ui/core/styles/MuiThemeProvider' import { theme } from './mui' -import { graphql1Client } from './apollo' +import { graphql1Client, graphql2Client } from './apollo' import './styles' import App from './main/NewApp' import MuiPickersUtilsProvider from './mui-pickers' @@ -39,24 +40,26 @@ const LazyGARouteTracker = React.memo(props => { ReactDOM.render( - - - - - - {config => ( - - )} - - - - - - - - + + + + + + + {config => ( + + )} + + + + + + + + + , document.getElementById('app'), diff --git a/web/src/app/lists/FlatList.js b/web/src/app/lists/FlatList.js index e0e9d615f8..c50abaf18d 100644 --- a/web/src/app/lists/FlatList.js +++ b/web/src/app/lists/FlatList.js @@ -2,6 +2,7 @@ import React from 'react' import p from 'prop-types' import List from '@material-ui/core/List' import ListItem from '@material-ui/core/ListItem' +import ListItemIcon from '@material-ui/core/ListItemIcon' import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' import ListItemText from '@material-ui/core/ListItemText' import Typography from '@material-ui/core/Typography' @@ -12,16 +13,16 @@ import { Link } from 'react-router-dom' import { absURLSelector } from '../selectors' import { connect } from 'react-redux' -const styles = theme => ({ +const styles = { + background: { backgroundColor: 'white' }, highlightedItem: { borderLeft: '6px solid #93ed94', background: '#defadf', }, - background: { backgroundColor: 'white' }, participantDragging: { backgroundColor: '#ebebeb', }, -}) +} const mapStateToProps = state => { return { @@ -45,9 +46,9 @@ export default class FlatList extends React.PureComponent { highlight: p.bool, title: p.node.isRequired, subText: p.node, - action: p.element, + secondaryAction: p.element, url: p.string, - icon: p.element, + icon: p.element, // renders a list item icon (or avatar) id: p.string, // required for drag and drop }), p.shape({ @@ -90,6 +91,7 @@ export default class FlatList extends React.PureComponent { button: true, } } + return ( - {item.icon} + {item.icon && {item.icon}} - {item.action && ( - {item.action} + {item.secondaryAction && ( + + {item.secondaryAction} + )} ) diff --git a/web/src/app/lists/PaginatedList.js b/web/src/app/lists/PaginatedList.js index d9167eba32..082a6d3b2f 100644 --- a/web/src/app/lists/PaginatedList.js +++ b/web/src/app/lists/PaginatedList.js @@ -22,6 +22,7 @@ import { connect } from 'react-redux' import { ITEMS_PER_PAGE } from '../config' import { absURLSelector } from '../selectors/url' +import ListItemIcon from '@material-ui/core/ListItemIcon' // gray boxes on load // disable overflow @@ -149,7 +150,7 @@ export class PaginatedList extends React.PureComponent { title: p.string.isRequired, subText: p.string, isFavorite: p.bool, - icon: p.element, + icon: p.element, // renders a list item icon (or avatar) action: p.element, }), ), @@ -252,6 +253,7 @@ export class PaginatedList extends React.PureComponent { renderItem = (item, idx) => { const { classes, width, absURL } = this.props + let favIcon = if (item.isFavorite) { favIcon = ( @@ -271,7 +273,7 @@ export class PaginatedList extends React.PureComponent { to={absURL(item.url)} button={Boolean(item.url)} > - {item.icon} + {item.icon && {item.icon}} {favIcon} {item.action && ( diff --git a/web/src/app/loading/components/LoadingButton.js b/web/src/app/loading/components/LoadingButton.js index 7fd0ce3e0b..1b45e4ec89 100644 --- a/web/src/app/loading/components/LoadingButton.js +++ b/web/src/app/loading/components/LoadingButton.js @@ -10,6 +10,7 @@ export default class LoadingButton extends Component { color: p.string, disabled: p.bool, loading: p.bool, + noSubmit: p.bool, onClick: p.func, } @@ -20,6 +21,7 @@ export default class LoadingButton extends Component { color, disabled, loading, + noSubmit, onClick, style, } = this.props @@ -32,7 +34,7 @@ export default class LoadingButton extends Component { color={color || 'primary'} onClick={onClick} disabled={loading || disabled} - type='submit' + type={noSubmit ? 'button' : 'submit'} > {!attemptCount ? buttonText || 'Confirm' : 'Retry'} diff --git a/web/src/app/rotations/RotationUserList.js b/web/src/app/rotations/RotationUserList.js index bbc1b71d74..6010ea266e 100644 --- a/web/src/app/rotations/RotationUserList.js +++ b/web/src/app/rotations/RotationUserList.js @@ -153,7 +153,7 @@ export default class RotationUserList extends React.PureComponent { highlight: index === activeUserIndex, icon: , subText: handoff[index], - action: ( + secondaryAction: ( : , - action: ( + secondaryAction: ( ), - action: ( + secondaryAction: ( this.setState({ delete: key.id })}> diff --git a/web/src/app/services/ServiceLabelList.js b/web/src/app/services/ServiceLabelList.js index f5cc0fb5b2..5c697e8f0c 100644 --- a/web/src/app/services/ServiceLabelList.js +++ b/web/src/app/services/ServiceLabelList.js @@ -66,7 +66,7 @@ export default class ServiceLabelList extends React.PureComponent { .map(label => ({ title: label.key, subText: label.value, - action: ( + secondaryAction: ( - + + + {(commit, status) => this.renderDialog(commit, status)} @@ -31,7 +32,7 @@ export default class UserContactMethodDeleteDialog extends React.PureComponent { } renderDialog(commit, { loading, error }) { - const { cmID, ...rest } = this.props + const { contactMethodID, ...rest } = this.props return ( commit({ variables: { id: cmID } })} + onSubmit={() => commit({ variables: { id: contactMethodID } })} {...rest} /> ) diff --git a/web/src/app/users/UserContactMethodEditDialog.js b/web/src/app/users/UserContactMethodEditDialog.js index 372ee096ae..42bc626c5c 100644 --- a/web/src/app/users/UserContactMethodEditDialog.js +++ b/web/src/app/users/UserContactMethodEditDialog.js @@ -28,7 +28,7 @@ const mutation = gql` export default class UserContactMethodEditDialog extends React.PureComponent { static propTypes = { - cmID: p.string.isRequired, + contactMethodID: p.string.isRequired, onClose: p.func, } @@ -42,7 +42,7 @@ export default class UserContactMethodEditDialog extends React.PureComponent { return ( this.renderMutation(data.userContactMethod)} noPoll /> @@ -80,7 +80,10 @@ export default class UserContactMethodEditDialog extends React.PureComponent { return commit({ variables: { // removing field 'type' from value for mutation - input: { ...omit(this.state.value, 'type'), id: this.props.cmID }, + input: { + ...omit(this.state.value, 'type'), + id: this.props.contactMethodID, + }, }, }) }} diff --git a/web/src/app/users/UserContactMethodList.js b/web/src/app/users/UserContactMethodList.js index 4e3917d8fe..cc4e1aa298 100644 --- a/web/src/app/users/UserContactMethodList.js +++ b/web/src/app/users/UserContactMethodList.js @@ -1,19 +1,20 @@ -import React from 'react' +import React, { useState } from 'react' import p from 'prop-types' import Query from '../util/Query' import gql from 'graphql-tag' import FlatList from '../lists/FlatList' -import { Grid, Card, CardHeader, withStyles } from '@material-ui/core' +import { Button, Card, CardHeader, Grid, IconButton } from '@material-ui/core' import { formatCMValue, sortContactMethods } from './util' import OtherActions from '../util/OtherActions' -import { Mutation } from 'react-apollo' -import { graphql2Client } from '../apollo' import UserContactMethodDeleteDialog from './UserContactMethodDeleteDialog' import UserContactMethodEditDialog from './UserContactMethodEditDialog' import ListItem from '@material-ui/core/ListItem' import ListItemText from '@material-ui/core/ListItemText' import { Config } from '../util/RequireConfig' - +import { Warning } from '../icons' +import UserContactMethodVerificationDialog from './UserContactMethodVerificationDialog' +import { makeStyles, createStyles } from '@material-ui/core/styles' +import { useMutation } from '@apollo/react-hooks' import { styles as globalStyles } from '../styles/materialStyles' const query = gql` @@ -25,6 +26,7 @@ const query = gql` name type value + disabled } } } @@ -36,54 +38,96 @@ const testCM = gql` } ` -const styles = theme => { +const useStyles = makeStyles(theme => { const { cardHeader } = globalStyles(theme) - return { + return createStyles({ + actionGrid: { + display: 'flex', + alignItems: 'center', + }, cardHeader, - } -} + }) +}) -@withStyles(styles) -export default class UserContactMethodList extends React.PureComponent { - static propTypes = { - userID: p.string.isRequired, - readOnly: p.bool, - } +export default function UserContactMethodList(props) { + const classes = useStyles() - state = { - edit: null, - delete: null, - } + const [showVerifyDialogByID, setShowVerifyDialogByID] = useState(null) + const [showEditDialogByID, setShowEditDialogByID] = useState(null) + const [showDeleteDialogByID, setShowDeleteDialogByID] = useState(null) + + const [sendTest] = useMutation(testCM) + + const getIcon = cm => { + if (!cm.disabled) return null + if (props.readOnly) { + return + } - render() { return ( - this.renderList(data.user.contactMethods)} - /> + setShowVerifyDialogByID(cm.id)} + variant='contained' + color='primary' + disabled={props.readOnly} + > + + ) } - renderActions(id) { + function getActionMenuItems(cm) { + let actions = [ + { label: 'Edit', onClick: () => setShowEditDialogByID(cm.id) }, + { + label: 'Delete', + onClick: () => setShowDeleteDialogByID(cm.id), + }, + ] + + if (!cm.disabled) { + actions.push({ + label: 'Send Test', + // todo: show dialog with error if test message fails to send + onClick: () => + sendTest({ + variables: { + id: cm.id, + }, + }), + }) + } + + return actions + } + + function getSecondaryAction(cm) { return ( - - {commit => ( - this.setState({ edit: id }) }, - { label: 'Delete', onClick: () => this.setState({ delete: id }) }, - { label: 'Send Test', onClick: () => commit() }, - ]} - /> + + {cm.disabled && !props.readOnly && ( + + + + )} + {!props.readOnly && ( + + + )} - + ) } - renderList(contactMethods) { - const { classes } = this.props + function renderList(contactMethods) { return ( @@ -95,15 +139,36 @@ export default class UserContactMethodList extends React.PureComponent { ({ - title: `${cm.name} (${cm.type})`, + title: `${cm.name} (${cm.type})${ + cm.disabled ? ' - Disabled' : '' + }`, subText: formatCMValue(cm.type, cm.value), - action: this.props.readOnly ? null : this.renderActions(cm.id), + secondaryAction: getSecondaryAction(cm), + icon: getIcon(cm), }))} emptyMessage='No contact methods' /> + {showVerifyDialogByID && ( + setShowVerifyDialogByID(null)} + /> + )} + {showEditDialogByID && ( + setShowEditDialogByID(null)} + /> + )} + {showDeleteDialogByID && ( + setShowDeleteDialogByID(null)} + /> + )} {cfg => - !this.props.readOnly && + !props.readOnly && cfg['General.NotificationDisclaimer'] && ( - {this.state.edit && ( - this.setState({ edit: null })} - /> - )} - {this.state.delete && ( - this.setState({ delete: null })} - /> - )} ) } + + return ( + renderList(data.user.contactMethods)} + /> + ) +} + +UserContactMethodList.propTypes = { + userID: p.string.isRequired, + readOnly: p.bool, } diff --git a/web/src/app/users/UserContactMethodVerificationDialog.js b/web/src/app/users/UserContactMethodVerificationDialog.js new file mode 100644 index 0000000000..d30b030138 --- /dev/null +++ b/web/src/app/users/UserContactMethodVerificationDialog.js @@ -0,0 +1,117 @@ +import React, { useState } from 'react' +import p from 'prop-types' +import FormDialog from '../dialogs/FormDialog' +import gql from 'graphql-tag' +import Query from '../util/Query' +import { fieldErrors, nonFieldErrors } from '../util/errutil' +import UserContactMethodVerificationForm from './UserContactMethodVerificationForm' +import { formatPhoneNumber } from './util' +import { Config } from '../util/RequireConfig' +import { useMutation } from '@apollo/react-hooks' + +/* + * Reactivates a cm if disabled and the verification code matches + */ +const verifyContactMethodMutation = gql` + mutation verifyContactMethod($input: VerifyContactMethodInput!) { + verifyContactMethod(input: $input) + } +` + +/* + * Get cm data so this component isn't dependent on parent props + */ +const contactMethodQuery = gql` + query($id: ID!) { + userContactMethod(id: $id) { + id + name + type + value + } + } +` + +export default function UserContactMethodVerificationDialog(props) { + const [value, setValue] = useState({ + code: '', + }) + const [sendError, setSendError] = useState('') + + const [submitVerify, status] = useMutation(verifyContactMethodMutation, { + variables: { + input: { + contactMethodID: props.contactMethodID, + code: value.code, + }, + }, + awaitRefetchQueries: true, + refetchQueries: ['cmList'], + onCompleted: props.onClose, + }) + + // dialog rendered that handles rendering the verification form + function renderDialog(cm) { + const { loading, error } = status + const fieldErrs = fieldErrors(error) + + return ( + + {config => { + const fromNumber = config['Twilio.FromNumber'] + + let caption = null + if (fromNumber && cm.type === 'SMS') { + caption = `If you do not receive a code, try sending UNSTOP to ${formatPhoneNumber( + fromNumber, + )} before resending.` + } + + return ( + { + setSendError('') + return submitVerify() + }} + form={ + setValue(value)} + /> + } + /> + ) + }} + + ) + } + + // queries for cm data for the dialog subtitle + return ( + renderDialog(data.userContactMethod)} + noPoll + /> + ) +} + +UserContactMethodVerificationDialog.propTypes = { + onClose: p.func.isRequired, + contactMethodID: p.string.isRequired, +} diff --git a/web/src/app/users/UserContactMethodVerificationForm.js b/web/src/app/users/UserContactMethodVerificationForm.js new file mode 100644 index 0000000000..3e9c6ddecf --- /dev/null +++ b/web/src/app/users/UserContactMethodVerificationForm.js @@ -0,0 +1,97 @@ +import React, { useEffect } from 'react' +import p from 'prop-types' +import Grid from '@material-ui/core/Grid' +import TextField from '@material-ui/core/TextField' +import LoadingButton from '../loading/components/LoadingButton' +import gql from 'graphql-tag' +import { makeStyles } from '@material-ui/core/styles' +import { FormContainer, FormField } from '../forms' +import { useMutation } from '@apollo/react-hooks' + +/* + * Triggers sending a verification code to the specified cm + * when the dialog is first opened + */ +const sendVerificationCodeMutation = gql` + mutation sendContactMethodVerification( + $input: SendContactMethodVerificationInput! + ) { + sendContactMethodVerification(input: $input) + } +` + +const useStyles = makeStyles({ + fieldGridItem: { + flexGrow: 1, + }, + sendGridItem: { + display: 'flex', + alignItems: 'center', + }, +}) + +export default function UserContactMethodVerificationForm(props) { + const classes = useStyles() + + const [sendCode, sendCodeStatus] = useMutation(sendVerificationCodeMutation, { + variables: { + input: { + contactMethodID: props.contactMethodID, + }, + }, + }) + + function sendAndCatch() { + sendCode().catch(err => props.setSendError(err.message)) + } + + // componentDidMount + useEffect(() => { + sendAndCatch() + }, []) + + return ( + + + + sendAndCatch()} + /> + + + value.toString()} + /> + + + + ) +} + +UserContactMethodVerificationForm.propTypes = { + contactMethodID: p.string.isRequired, + disabled: p.bool.isRequired, + errors: p.arrayOf( + p.shape({ + field: p.oneOf(['code']).isRequired, + message: p.string.isRequired, + }), + ), + onChange: p.func.isRequired, + setSendError: p.func.isRequired, + value: p.shape({ + code: p.string.isRequired, + }).isRequired, +} diff --git a/web/src/app/users/UserNotificationRuleCreateDialog.js b/web/src/app/users/UserNotificationRuleCreateDialog.js index 53976e979f..c4e9fcc9da 100644 --- a/web/src/app/users/UserNotificationRuleCreateDialog.js +++ b/web/src/app/users/UserNotificationRuleCreateDialog.js @@ -47,7 +47,6 @@ export default class UserNotificationRuleCreateDialog extends React.PureComponen renderDialog(commit, status) { const { loading, error } = status - const fieldErrs = fieldErrors(error) return ( diff --git a/web/src/app/users/UserNotificationRuleList.js b/web/src/app/users/UserNotificationRuleList.js index 5bfbd92c39..dcdd332205 100644 --- a/web/src/app/users/UserNotificationRuleList.js +++ b/web/src/app/users/UserNotificationRuleList.js @@ -77,7 +77,7 @@ export default class UserNotificationRuleList extends React.PureComponent { data-cy='notification-rules' items={sortNotificationRules(notificationRules).map(nr => ({ title: formatNotificationRule(nr.delayMinutes, nr.contactMethod), - action: this.props.readOnly ? null : ( + secondaryAction: this.props.readOnly ? null : ( this.setState({ delete: nr.id })} diff --git a/web/src/app/users/util.js b/web/src/app/users/util.js index 8bb99a29f3..d8f8e22e67 100644 --- a/web/src/app/users/util.js +++ b/web/src/app/users/util.js @@ -18,7 +18,7 @@ export function formatPhoneNumber(n) { return `+1 ${n.slice(2, 5)}-${n.slice(5, 8)}-${n.slice(8)}` } if (n.startsWith('+91')) { - return `+91 ${n.slice(3, 5)} ${n.slice(5, 9)} ${n.slice(9)}` + return `+91 ${n.slice(3, 6)} ${n.slice(6, 9)} ${n.slice(9)}` } if (n.startsWith('+44')) { return `+44 ${n.slice(3, 7)} ${n.slice(7)}` diff --git a/web/src/app/users/util.test.js b/web/src/app/users/util.test.js index 8e535e2759..c5b2b27b29 100644 --- a/web/src/app/users/util.test.js +++ b/web/src/app/users/util.test.js @@ -9,9 +9,9 @@ test('formatPhoneNumber', () => { expect(formatPhoneNumber('+17635550100')).toBe('+1 763-555-0100') expect(formatPhoneNumber('+12085550105')).toBe('+1 208-555-0105') expect(formatPhoneNumber('+15165550184')).toBe('+1 516-555-0184') - expect(formatPhoneNumber('+911400000000')).toBe('+91 14 0000 0000') - expect(formatPhoneNumber('+911401234567')).toBe('+91 14 0123 4567') - expect(formatPhoneNumber('+911409876543')).toBe('+91 14 0987 6543') + expect(formatPhoneNumber('+911400000000')).toBe('+91 140 000 0000') + expect(formatPhoneNumber('+911401234567')).toBe('+91 140 123 4567') + expect(formatPhoneNumber('+911409876543')).toBe('+91 140 987 6543') expect(formatPhoneNumber('+447700000000')).toBe('+44 7700 000000') expect(formatPhoneNumber('+447701234567')).toBe('+44 7701 234567') expect(formatPhoneNumber('+447709876543')).toBe('+44 7709 876543') @@ -45,7 +45,7 @@ test('formatNotificationRule', () => { name: 'myPhone', value: '+911400000000', }), - ).toBe('After 1 minute notify me via VOICE at +91 14 0000 0000 (myPhone)') + ).toBe('After 1 minute notify me via VOICE at +91 140 000 0000 (myPhone)') expect( formatNotificationRule(5, { type: 'VOICE', diff --git a/web/src/app/util/avatar/types.js b/web/src/app/util/avatar/types.js index afb78bd0f8..94b71b371d 100644 --- a/web/src/app/util/avatar/types.js +++ b/web/src/app/util/avatar/types.js @@ -9,6 +9,7 @@ export class UserAvatar extends BaseAvatar { renderFallback() { return } + srcURL(props) { return props.userID && `/api/v2/user-avatar/${props.userID}` } diff --git a/web/src/package.json b/web/src/package.json index 7f73d271ff..94a759b375 100644 --- a/web/src/package.json +++ b/web/src/package.json @@ -65,6 +65,7 @@ ] }, "dependencies": { + "@apollo/react-hooks": "0.1.0-beta.10", "@date-io/luxon": "1.3.7", "@material-ui/core": "4.1.1", "@material-ui/icons": "4.2.0", diff --git a/web/src/yarn.lock b/web/src/yarn.lock index dff6f8c854..e8dfeeb84f 100644 --- a/web/src/yarn.lock +++ b/web/src/yarn.lock @@ -2,6 +2,24 @@ # yarn lockfile v1 +"@apollo/react-common@^0.1.0-beta.8": + version "0.1.0-beta.8" + resolved "https://registry.yarnpkg.com/@apollo/react-common/-/react-common-0.1.0-beta.8.tgz#98978b0da1a12b5866655295dc46e8a8eaa33ea2" + integrity sha512-iNxF9gxxwe9oeovx8XgqU+2J5cOB3RnoSvIfahW2vhJrxtAahWg0AjTTvhQsnLi5OvIiVyivvU+R7xlj/9C0pg== + dependencies: + ts-invariant "^0.4.4" + tslib "^1.10.0" + +"@apollo/react-hooks@0.1.0-beta.10": + version "0.1.0-beta.10" + resolved "https://registry.yarnpkg.com/@apollo/react-hooks/-/react-hooks-0.1.0-beta.10.tgz#4069fad454c0a77eb09d00242dcd96b3bac7791b" + integrity sha512-txwIsFhhK4QvF/L5geh4Mh8d1XRje6It3HL9PEc4N83Q/fgzHMWDlvvbUo1HqZuyPKEbFOH7Hi7LsYJBGev6Sw== + dependencies: + "@apollo/react-common" "^0.1.0-beta.8" + apollo-utilities "^1.3.2" + ts-invariant "^0.4.4" + tslib "^1.10.0" + "@babel/cli@7.4.4": version "7.4.4" resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.4.4.tgz#5454bb7112f29026a4069d8e6f0e1794e651966c" @@ -4254,7 +4272,7 @@ eslint-plugin-import@2.17.3: read-pkg-up "^2.0.0" resolve "^1.11.0" -eslint-plugin-jsx-a11y@^6.2.1: +eslint-plugin-jsx-a11y@6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.2.1.tgz#4ebba9f339b600ff415ae4166e3e2e008831cf0c" integrity sha512-cjN2ObWrRz0TTw7vEcGQrx+YltMvZoOEx4hWU8eEERDnBIU00OTq7Vr+jA7DFKxiwLNv4tTh5Pq2GUNEa8b6+w== @@ -11180,7 +11198,7 @@ ts-invariant@^0.3.2: dependencies: tslib "^1.9.3" -ts-invariant@^0.4.0, ts-invariant@^0.4.2: +ts-invariant@^0.4.0, ts-invariant@^0.4.2, ts-invariant@^0.4.4: version "0.4.4" resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" integrity sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA== @@ -11198,15 +11216,15 @@ ts-loader@6.0.3: micromatch "^4.0.0" semver "^6.0.0" -tslib@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" - -tslib@^1.9.3: +tslib@^1.10.0, tslib@^1.9.3: version "1.10.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== +tslib@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" + tty-browserify@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"