diff --git a/Makefile b/Makefile index 84cf6ea902..e7ee346ee1 100644 --- a/Makefile +++ b/Makefile @@ -270,7 +270,7 @@ test-components: $(NODE_DEPS) "$(WAITFOR) tcp://localhost:6008 && $(BIN_DIR)/tools/bun run test-storybook --ci --url http://127.0.0.1:6008 --maxWorkers 2" storybook: $(NODE_DEPS) # Start the Storybook UI - $(BIN_DIR)/tools/bun run storybook + $(BIN_DIR)/tools/bun -b run storybook playwright-run: $(NODE_DEPS) web/src/build/static/app.js bin/goalert.cover web/src/schema.d.ts $(BIN_DIR)/tools/prometheus $(BIN_DIR)/tools/mailpit reset-integration ## Start playwright tests in headless mode rm -rf test/coverage/integration/playwright diff --git a/devtools/resetdb/datagen.go b/devtools/resetdb/datagen.go index 36b5f60d41..640253346e 100644 --- a/devtools/resetdb/datagen.go +++ b/devtools/resetdb/datagen.go @@ -158,6 +158,9 @@ func (d *datagen) NewCM(userID string) { if d.Bool() { cm.Dest.Type = twilio.DestTypeTwilioVoice } + if d.Intn(4) == 0 { + cm.Private = true + } cm.Dest.SetArg(twilio.FieldPhoneNumber, d.ids.Gen(d.genPhone, cm.Dest.Type)) d.ContactMethods = append(d.ContactMethods, cm) diff --git a/devtools/resetdb/main.go b/devtools/resetdb/main.go index b81ee428a0..b7b1914a8b 100644 --- a/devtools/resetdb/main.go +++ b/devtools/resetdb/main.go @@ -148,9 +148,9 @@ func fillDB(ctx context.Context, dataCfg *datagenConfig, url string) error { u := data.Users[n] return []interface{}{asUUID(u.ID), u.Name, u.Role, u.Email} }) - copyFrom("user_contact_methods", []string{"id", "user_id", "name", "dest", "disabled", "pending"}, len(data.ContactMethods), func(n int) []interface{} { + copyFrom("user_contact_methods", []string{"id", "user_id", "name", "dest", "disabled", "pending", "private"}, len(data.ContactMethods), func(n int) []interface{} { cm := data.ContactMethods[n] - return []interface{}{cm.ID, asUUID(cm.UserID), cm.Name, cm.Dest, cm.Disabled, cm.Pending} + return []interface{}{cm.ID, asUUID(cm.UserID), cm.Name, cm.Dest, cm.Disabled, cm.Pending, cm.Private} }, "users") copyFrom("user_notification_rules", []string{"id", "user_id", "contact_method_id", "delay_minutes"}, len(data.NotificationRules), func(n int) []interface{} { nr := data.NotificationRules[n] diff --git a/gadb/models.go b/gadb/models.go index 0f9591d4a5..c57a19fc49 100644 --- a/gadb/models.go +++ b/gadb/models.go @@ -1337,6 +1337,7 @@ type UserContactMethod struct { Metadata pqtype.NullRawMessage Name string Pending bool + Private bool Type EnumUserContactMethodType UserID uuid.UUID Value string diff --git a/gadb/queries.sql.go b/gadb/queries.sql.go index b862bc2c3b..4fe6230c0c 100644 --- a/gadb/queries.sql.go +++ b/gadb/queries.sql.go @@ -1691,8 +1691,8 @@ func (q *Queries) ConnectionInfo(ctx context.Context) ([]ConnectionInfoRow, erro } const contactMethodAdd = `-- name: ContactMethodAdd :exec -INSERT INTO user_contact_methods(id, name, dest, disabled, user_id, enable_status_updates) - VALUES ($1, $2, $3, $4, $5, $6) +INSERT INTO user_contact_methods(id, name, dest, disabled, user_id, enable_status_updates, private) + VALUES ($1, $2, $3, $4, $5, $6, $7) ` type ContactMethodAddParams struct { @@ -1702,6 +1702,7 @@ type ContactMethodAddParams struct { Disabled bool UserID uuid.UUID EnableStatusUpdates bool + Private bool } func (q *Queries) ContactMethodAdd(ctx context.Context, arg ContactMethodAddParams) error { @@ -1712,6 +1713,7 @@ func (q *Queries) ContactMethodAdd(ctx context.Context, arg ContactMethodAddPara arg.Disabled, arg.UserID, arg.EnableStatusUpdates, + arg.Private, ) return err } @@ -1741,7 +1743,7 @@ func (q *Queries) ContactMethodEnableDisable(ctx context.Context, arg ContactMet const contactMethodFindAll = `-- name: ContactMethodFindAll :many SELECT - dest, disabled, enable_status_updates, id, last_test_verify_at, metadata, name, pending, type, user_id, value + dest, disabled, enable_status_updates, id, last_test_verify_at, metadata, name, pending, private, type, user_id, value FROM user_contact_methods WHERE @@ -1766,6 +1768,7 @@ func (q *Queries) ContactMethodFindAll(ctx context.Context, userID uuid.UUID) ([ &i.Metadata, &i.Name, &i.Pending, + &i.Private, &i.Type, &i.UserID, &i.Value, @@ -1785,7 +1788,7 @@ func (q *Queries) ContactMethodFindAll(ctx context.Context, userID uuid.UUID) ([ const contactMethodFindMany = `-- name: ContactMethodFindMany :many SELECT - dest, disabled, enable_status_updates, id, last_test_verify_at, metadata, name, pending, type, user_id, value + dest, disabled, enable_status_updates, id, last_test_verify_at, metadata, name, pending, private, type, user_id, value FROM user_contact_methods WHERE @@ -1810,6 +1813,7 @@ func (q *Queries) ContactMethodFindMany(ctx context.Context, dollar_1 []uuid.UUI &i.Metadata, &i.Name, &i.Pending, + &i.Private, &i.Type, &i.UserID, &i.Value, @@ -1829,7 +1833,7 @@ func (q *Queries) ContactMethodFindMany(ctx context.Context, dollar_1 []uuid.UUI const contactMethodFindOneUpdate = `-- name: ContactMethodFindOneUpdate :one SELECT - dest, disabled, enable_status_updates, id, last_test_verify_at, metadata, name, pending, type, user_id, value + dest, disabled, enable_status_updates, id, last_test_verify_at, metadata, name, pending, private, type, user_id, value FROM user_contact_methods WHERE @@ -1849,6 +1853,7 @@ func (q *Queries) ContactMethodFindOneUpdate(ctx context.Context, id uuid.UUID) &i.Metadata, &i.Name, &i.Pending, + &i.Private, &i.Type, &i.UserID, &i.Value, @@ -1858,7 +1863,7 @@ func (q *Queries) ContactMethodFindOneUpdate(ctx context.Context, id uuid.UUID) const contactMethodFineOne = `-- name: ContactMethodFineOne :one SELECT - dest, disabled, enable_status_updates, id, last_test_verify_at, metadata, name, pending, type, user_id, value + dest, disabled, enable_status_updates, id, last_test_verify_at, metadata, name, pending, private, type, user_id, value FROM user_contact_methods WHERE @@ -1877,6 +1882,7 @@ func (q *Queries) ContactMethodFineOne(ctx context.Context, id uuid.UUID) (UserC &i.Metadata, &i.Name, &i.Pending, + &i.Private, &i.Type, &i.UserID, &i.Value, @@ -1944,7 +1950,8 @@ UPDATE SET name = $2, disabled = $3, - enable_status_updates = $4 + enable_status_updates = $4, + private = $5 WHERE id = $1 ` @@ -1954,6 +1961,7 @@ type ContactMethodUpdateParams struct { Name string Disabled bool EnableStatusUpdates bool + Private bool } func (q *Queries) ContactMethodUpdate(ctx context.Context, arg ContactMethodUpdateParams) error { @@ -1962,6 +1970,7 @@ func (q *Queries) ContactMethodUpdate(ctx context.Context, arg ContactMethodUpda arg.Name, arg.Disabled, arg.EnableStatusUpdates, + arg.Private, ) return err } diff --git a/graphql2/generated.go b/graphql2/generated.go index 2ede94a005..ed92bed0b4 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -855,6 +855,7 @@ type ComplexityRoot struct { LastVerifyMessageState func(childComplexity int) int Name func(childComplexity int) int Pending func(childComplexity int) int + Private func(childComplexity int) int StatusUpdates func(childComplexity int) int Type func(childComplexity int) int Value func(childComplexity int) int @@ -4743,6 +4744,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.UserContactMethod.Pending(childComplexity), true + case "UserContactMethod.private": + if e.complexity.UserContactMethod.Private == nil { + break + } + + return e.complexity.UserContactMethod.Private(childComplexity), true case "UserContactMethod.statusUpdates": if e.complexity.UserContactMethod.StatusUpdates == nil { break @@ -15099,6 +15106,8 @@ func (ec *executionContext) fieldContext_Mutation_createUserContactMethod(ctx co return ec.fieldContext_UserContactMethod_lastVerifyMessageState(ctx, field) case "statusUpdates": return ec.fieldContext_UserContactMethod_statusUpdates(ctx, field) + case "private": + return ec.fieldContext_UserContactMethod_private(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type UserContactMethod", field.Name) }, @@ -18709,6 +18718,8 @@ func (ec *executionContext) fieldContext_Query_userContactMethod(ctx context.Con return ec.fieldContext_UserContactMethod_lastVerifyMessageState(ctx, field) case "statusUpdates": return ec.fieldContext_UserContactMethod_statusUpdates(ctx, field) + case "private": + return ec.fieldContext_UserContactMethod_private(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type UserContactMethod", field.Name) }, @@ -23224,6 +23235,8 @@ func (ec *executionContext) fieldContext_User_contactMethods(_ context.Context, return ec.fieldContext_UserContactMethod_lastVerifyMessageState(ctx, field) case "statusUpdates": return ec.fieldContext_UserContactMethod_statusUpdates(ctx, field) + case "private": + return ec.fieldContext_UserContactMethod_private(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type UserContactMethod", field.Name) }, @@ -24339,6 +24352,35 @@ func (ec *executionContext) fieldContext_UserContactMethod_statusUpdates(_ conte return fc, nil } +func (ec *executionContext) _UserContactMethod_private(ctx context.Context, field graphql.CollectedField, obj *contactmethod.ContactMethod) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_UserContactMethod_private, + func(ctx context.Context) (any, error) { + return obj.Private, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_UserContactMethod_private(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UserContactMethod", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _UserNotificationRule_id(ctx context.Context, field graphql.CollectedField, obj *notificationrule.NotificationRule) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -24474,6 +24516,8 @@ func (ec *executionContext) fieldContext_UserNotificationRule_contactMethod(_ co return ec.fieldContext_UserContactMethod_lastVerifyMessageState(ctx, field) case "statusUpdates": return ec.fieldContext_UserContactMethod_statusUpdates(ctx, field) + case "private": + return ec.fieldContext_UserContactMethod_private(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type UserContactMethod", field.Name) }, @@ -27730,7 +27774,7 @@ func (ec *executionContext) unmarshalInputCreateUserContactMethodInput(ctx conte asMap[k] = v } - fieldsInOrder := [...]string{"userID", "type", "dest", "name", "value", "newUserNotificationRule", "enableStatusUpdates"} + fieldsInOrder := [...]string{"userID", "type", "dest", "name", "value", "newUserNotificationRule", "enableStatusUpdates", "private"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -27786,6 +27830,13 @@ func (ec *executionContext) unmarshalInputCreateUserContactMethodInput(ctx conte return it, err } it.EnableStatusUpdates = data + case "private": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("private")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.Private = data } } @@ -30414,7 +30465,7 @@ func (ec *executionContext) unmarshalInputUpdateUserContactMethodInput(ctx conte asMap[k] = v } - fieldsInOrder := [...]string{"id", "name", "value", "enableStatusUpdates"} + fieldsInOrder := [...]string{"id", "name", "value", "enableStatusUpdates", "private"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -30449,6 +30500,13 @@ func (ec *executionContext) unmarshalInputUpdateUserContactMethodInput(ctx conte return it, err } it.EnableStatusUpdates = data + case "private": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("private")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.Private = data } } @@ -39750,6 +39808,11 @@ func (ec *executionContext) _UserContactMethod(ctx context.Context, sel ast.Sele } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "private": + out.Values[i] = ec._UserContactMethod_private(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/graphql2/graphqlapp/contactmethod.go b/graphql2/graphqlapp/contactmethod.go index bae487368e..a5f272926a 100644 --- a/graphql2/graphqlapp/contactmethod.go +++ b/graphql2/graphqlapp/contactmethod.go @@ -121,6 +121,7 @@ func (m *Mutation) CreateUserContactMethod(ctx context.Context, input graphql2.C UserID: input.UserID, Disabled: true, StatusUpdates: input.EnableStatusUpdates != nil && *input.EnableStatusUpdates, + Private: input.Private != nil && *input.Private, } if input.Dest != nil { @@ -189,6 +190,9 @@ func (m *Mutation) UpdateUserContactMethod(ctx context.Context, input graphql2.U } cm.Name = *input.Name } + if input.Private != nil { + cm.Private = *input.Private + } if input.EnableStatusUpdates != nil { cm.StatusUpdates = *input.EnableStatusUpdates diff --git a/graphql2/graphqlapp/dataloaders.go b/graphql2/graphqlapp/dataloaders.go index e112c2b0a5..6370a12bff 100644 --- a/graphql2/graphqlapp/dataloaders.go +++ b/graphql2/graphqlapp/dataloaders.go @@ -13,6 +13,7 @@ import ( "github.com/target/goalert/heartbeat" "github.com/target/goalert/notification" "github.com/target/goalert/notificationchannel" + "github.com/target/goalert/permission" "github.com/target/goalert/schedule" "github.com/target/goalert/schedule/rotation" "github.com/target/goalert/service" @@ -273,13 +274,24 @@ func (app *App) FindOneAlertMetric(ctx context.Context, id int) (*alertmetrics.M } // FindOneCM will return a single contact method for the given id, using the contexts dataloader if enabled. -func (app *App) FindOneCM(ctx context.Context, id uuid.UUID) (*contactmethod.ContactMethod, error) { +func (app *App) FindOneCM(ctx context.Context, id uuid.UUID) (cm *contactmethod.ContactMethod, err error) { loader := loadersFrom(ctx).CM if loader == nil { - return app.CMStore.FindOne(ctx, app.DB, id) + cm, err = app.CMStore.FindOne(ctx, app.DB, id) + } else { + cm, err = loader.FetchOne(ctx, id.String()) + } + if err != nil { + return nil, err + } + if cm == nil { + return nil, nil + } + if cm.Private && cm.UserID != permission.UserID(ctx) { + return nil, nil } - return loader.FetchOne(ctx, id.String()) + return cm, nil } // FindOneNC will return a single notification channel for the given id, using the contexts dataloader if enabled. diff --git a/graphql2/graphqlapp/user.go b/graphql2/graphqlapp/user.go index 23382f13b1..9675b83b50 100644 --- a/graphql2/graphqlapp/user.go +++ b/graphql2/graphqlapp/user.go @@ -119,7 +119,8 @@ func (a *User) Role(ctx context.Context, usr *user.User) (graphql2.UserRole, err } func (a *User) ContactMethods(ctx context.Context, obj *user.User) ([]contactmethod.ContactMethod, error) { - return a.CMStore.FindAll(ctx, a.DB, obj.ID) + cm, _, err := a.CMStore.FindAll(ctx, a.DB, obj.ID) + return cm, err } func (a *User) NotificationRules(ctx context.Context, obj *user.User) ([]notificationrule.NotificationRule, error) { diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go index f14f1d02d0..4afbf47eb2 100644 --- a/graphql2/models_gen.go +++ b/graphql2/models_gen.go @@ -299,6 +299,8 @@ type CreateUserContactMethodInput struct { // // Note: Some contact method types, like Slack, will always receive status updates and this value is ignored. EnableStatusUpdates *bool `json:"enableStatusUpdates,omitempty"` + // If true, this contact method will be private and only visible to the user. + Private *bool `json:"private,omitempty"` } type CreateUserInput struct { @@ -926,6 +928,8 @@ type UpdateUserContactMethodInput struct { // // Note: Some contact method types, like Slack, will always receive status updates and this value is ignored. EnableStatusUpdates *bool `json:"enableStatusUpdates,omitempty"` + // If true, this contact method will be private and only visible to the user. + Private *bool `json:"private,omitempty"` } type UpdateUserInput struct { diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index 7413b075b5..8ed5126543 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -1410,6 +1410,7 @@ type UserContactMethod { lastVerifyMessageState: NotificationState statusUpdates: StatusUpdateState! + private: Boolean! } enum StatusUpdateState { @@ -1441,6 +1442,11 @@ input CreateUserContactMethodInput { Note: Some contact method types, like Slack, will always receive status updates and this value is ignored. """ enableStatusUpdates: Boolean + + """ + If true, this contact method will be private and only visible to the user. + """ + private: Boolean } input CreateUserNotificationRuleInput { @@ -1463,6 +1469,11 @@ input UpdateUserContactMethodInput { Note: Some contact method types, like Slack, will always receive status updates and this value is ignored. """ enableStatusUpdates: Boolean + + """ + If true, this contact method will be private and only visible to the user. + """ + private: Boolean } input SendContactMethodVerificationInput { diff --git a/migrate/migrations/20250514091650-cm-private.sql b/migrate/migrations/20250514091650-cm-private.sql new file mode 100644 index 0000000000..68b3de34f8 --- /dev/null +++ b/migrate/migrations/20250514091650-cm-private.sql @@ -0,0 +1,8 @@ +-- +migrate Up +ALTER TABLE user_contact_methods + ADD COLUMN private boolean NOT NULL DEFAULT FALSE; + +-- +migrate Down +ALTER TABLE user_contact_methods + DROP COLUMN private; + diff --git a/migrate/schema.sql b/migrate/schema.sql index 48416c04e4..9d817ff7b8 100644 --- a/migrate/schema.sql +++ b/migrate/schema.sql @@ -1,7 +1,7 @@ -- This file is auto-generated by "make db-schema"; DO NOT EDIT --- DATA=50f009ab810d42724027d31ec49f2584a1c8ecd647194c2455de853975d40bd0 - --- DISK=2f8c154346b509ddac6e6d36a7a4f1bc86c047dcd1f98eb3ee5846bdf4bcf687 - --- PSQL=2f8c154346b509ddac6e6d36a7a4f1bc86c047dcd1f98eb3ee5846bdf4bcf687 - +-- DATA=c274594d00459967cd85c939f0b0712baa81e041eee02a383371eadc06d549cd - +-- DISK=22479577daa4266fcaccbcf25675b21862e38fb630b49da2a5e5875344a27c3a - +-- PSQL=22479577daa4266fcaccbcf25675b21862e38fb630b49da2a5e5875344a27c3a - -- -- pgdump-lite database dump -- @@ -2554,6 +2554,7 @@ CREATE TABLE user_contact_methods ( metadata jsonb, name text NOT NULL, pending boolean DEFAULT true NOT NULL, + private boolean DEFAULT false NOT NULL, type enum_user_contact_method_type NOT NULL, user_id uuid NOT NULL, value text NOT NULL, diff --git a/test/smoke/privatecm_test.go b/test/smoke/privatecm_test.go new file mode 100644 index 0000000000..7f70176288 --- /dev/null +++ b/test/smoke/privatecm_test.go @@ -0,0 +1,198 @@ +package smoke + +import ( + "encoding/json" + "fmt" + "sort" + "testing" + + "github.com/stretchr/testify/require" + "github.com/target/goalert/test/smoke/harness" +) + +const privCMQuery = ` +mutation NewCM($input: CreateUserContactMethodInput!) { + createUserContactMethod(input: $input) { + id + } +} + +mutation UpdateCM($input: UpdateUserContactMethodInput!) { + updateUserContactMethod(input: $input) +} + +query GetCM($id: ID!) { + userContactMethod(id: $id) { + id + name + private + } +} + + +query ListUserCM($userID: ID!) { + user(id: $userID) { + id + contactMethods { + id + name + private + } + notificationRules { + id + contactMethodID + contactMethod { + id + } + } + } +} +` + +// TestPrivateCM checks that private contact methods are not visible in GraphQL calls from any user that is not the owner. +func TestPrivateCM(t *testing.T) { + t.Parallel() + + const sql = ` + insert into users (id, name, email) + values + ({{uuid "user1"}}, 'bob', 'joe'), + ({{uuid "user2"}}, 'bob2', 'joe2'); + ` + + h := harness.NewHarness(t, sql, "") + defer h.Close() + + var newCM struct { + CreateUserContactMethod struct{ ID string } + } + + resp := h.GraphQLQueryUserVarsT(t, h.UUID("user1"), privCMQuery, "NewCM", + json.RawMessage(fmt.Sprintf( + `{"input":{ + "userID": "%s", + "name": "cm1", + "dest":{ + "type":"builtin-smtp-email", + "args":{"email_address":"foobar@example.com"} + }, + "newUserNotificationRule": {"delayMinutes": 0} + }}`, h.UUID("user1")))) + require.Empty(t, resp.Errors, "expected no errors") + require.NoError(t, json.Unmarshal(resp.Data, &newCM)) + cmID1 := newCM.CreateUserContactMethod.ID + + resp = h.GraphQLQueryUserVarsT(t, h.UUID("user1"), privCMQuery, "NewCM", json.RawMessage(fmt.Sprintf( + `{"input":{ + "userID": "%s", + "name": "cm2", + "private": true, + "dest":{ + "type":"builtin-smtp-email", + "args":{"email_address":"foobar-priv@example.com"} + }, + "newUserNotificationRule": {"delayMinutes": 0} + }}`, h.UUID("user1")))) + require.Empty(t, resp.Errors, "expected no errors") + require.NoError(t, json.Unmarshal(resp.Data, &newCM)) + cmID2 := newCM.CreateUserContactMethod.ID + + userCanSeeCM := func(userID, cmID string, isPrivate, expectAccess bool) { + t.Helper() + + // check direct access to the contact method + resp := h.GraphQLQueryUserVarsT(t, userID, privCMQuery, "GetCM", json.RawMessage(fmt.Sprintf(`{"id":"%s"}`, cmID))) + require.Empty(t, resp.Errors, "expected no errors") + t.Log("resp", string(resp.Data)) + var cm struct { + UserContactMethod *struct { + ID string + Name string + Private bool + } + } + require.NoError(t, json.Unmarshal(resp.Data, &cm)) + if !expectAccess { + require.Nil(t, cm.UserContactMethod, "expected to not see private contact method") + return + } + require.NotNil(t, cm.UserContactMethod, "expected to see contact method") + require.Equal(t, cmID, cm.UserContactMethod.ID, "expected to see contact method with ID %s", cmID) + require.Equal(t, isPrivate, cm.UserContactMethod.Private, "expected to see contact method with private=%t", isPrivate) + } + + userCanSeeCM(h.UUID("user1"), cmID1, false, true) + userCanSeeCM(h.UUID("user1"), cmID2, true, true) + userCanSeeCM(h.UUID("user2"), cmID1, false, true) + userCanSeeCM(h.UUID("user2"), cmID2, true, false) + + // validate listing + type listCMResp struct { + User struct { + ID string + ContactMethods []struct { + ID string + Name string + Private bool + } + NotificationRules []struct { + ID string + ContactMethodID string + ContactMethod *struct { + ID string + } + } + } + } + + // as user 1 + var listCM listCMResp + resp = h.GraphQLQueryUserVarsT(t, h.UUID("user1"), privCMQuery, "ListUserCM", json.RawMessage(fmt.Sprintf(`{"userID":"%s"}`, h.UUID("user1")))) + require.Empty(t, resp.Errors, "expected no errors") + + require.NoError(t, json.Unmarshal(resp.Data, &listCM)) + + require.Len(t, listCM.User.ContactMethods, 2, "expected to see both contact methods") + sort.Slice(listCM.User.ContactMethods, func(i, j int) bool { + return listCM.User.ContactMethods[i].Name < listCM.User.ContactMethods[j].Name + }) + require.Equal(t, cmID1, listCM.User.ContactMethods[0].ID, "expected to see contact method 1") + require.False(t, listCM.User.ContactMethods[0].Private, "expected to see contact method 1 as not private") + require.True(t, listCM.User.ContactMethods[1].Private, "expected to see contact method 2 as private") + require.Len(t, listCM.User.NotificationRules, 2, "expected to see two notification rules") + for _, rule := range listCM.User.NotificationRules { + switch rule.ContactMethodID { + case cmID1: + require.NotNil(t, rule.ContactMethod, "expected to see contact method 1") + case cmID2: + require.NotNil(t, rule.ContactMethod, "expected to see contact method 2") + default: + t.Fatalf("unexpected contact method ID %s", rule.ContactMethodID) + } + } + + // as user 2 + listCM = listCMResp{} + resp = h.GraphQLQueryUserVarsT(t, h.UUID("user2"), privCMQuery, "ListUserCM", json.RawMessage(fmt.Sprintf(`{"userID":"%s"}`, h.UUID("user1")))) + require.Empty(t, resp.Errors, "expected no errors") + + require.NoError(t, json.Unmarshal(resp.Data, &listCM)) + + require.Len(t, listCM.User.ContactMethods, 1, "expected to see only contact method 1") + sort.Slice(listCM.User.ContactMethods, func(i, j int) bool { + return listCM.User.ContactMethods[i].Name < listCM.User.ContactMethods[j].Name + }) + require.Equal(t, cmID1, listCM.User.ContactMethods[0].ID, "expected to see contact method 1") + require.False(t, listCM.User.ContactMethods[0].Private, "expected to see contact method 1 as not private") + require.Len(t, listCM.User.NotificationRules, 2, "expected to see two notification rules") + for _, rule := range listCM.User.NotificationRules { + switch rule.ContactMethodID { + case cmID1: + require.NotNil(t, rule.ContactMethod, "expected to see contact method 1") + case cmID2: + require.Nil(t, rule.ContactMethod, "expected to not see contact method 2") + default: + t.Fatalf("unexpected contact method ID %s", rule.ContactMethodID) + } + } +} diff --git a/user/contactmethod/contactmethod.go b/user/contactmethod/contactmethod.go index e3ca37dc4e..70334a8067 100644 --- a/user/contactmethod/contactmethod.go +++ b/user/contactmethod/contactmethod.go @@ -20,6 +20,7 @@ type ContactMethod struct { Disabled bool UserID string Pending bool + Private bool StatusUpdates bool diff --git a/user/contactmethod/queries.sql b/user/contactmethod/queries.sql index d62660195b..0ed6e70e11 100644 --- a/user/contactmethod/queries.sql +++ b/user/contactmethod/queries.sql @@ -1,6 +1,6 @@ -- name: ContactMethodAdd :exec -INSERT INTO user_contact_methods(id, name, dest, disabled, user_id, enable_status_updates) - VALUES ($1, $2, $3, $4, $5, $6); +INSERT INTO user_contact_methods(id, name, dest, disabled, user_id, enable_status_updates, private) + VALUES ($1, $2, $3, $4, $5, $6, $7); -- name: ContactMethodUpdate :exec UPDATE @@ -8,7 +8,8 @@ UPDATE SET name = $2, disabled = $3, - enable_status_updates = $4 + enable_status_updates = $4, + private = $5 WHERE id = $1; diff --git a/user/contactmethod/store.go b/user/contactmethod/store.go index f050f70bda..04ca5a7302 100644 --- a/user/contactmethod/store.go +++ b/user/contactmethod/store.go @@ -141,6 +141,7 @@ func (s *Store) Create(ctx context.Context, dbtx gadb.DBTX, c *ContactMethod) (* Disabled: n.Disabled, UserID: uuid.MustParse(n.UserID), EnableStatusUpdates: n.StatusUpdates, + Private: n.Private, }) if err != nil { return nil, err @@ -209,6 +210,7 @@ func (s *Store) FindOne(ctx context.Context, dbtx gadb.DBTX, id uuid.UUID) (*Con UserID: row.UserID.String(), Pending: row.Pending, StatusUpdates: row.EnableStatusUpdates, + Private: row.Private, lastTestVerifyAt: row.LastTestVerifyAt, } @@ -239,7 +241,7 @@ func (s *Store) Update(ctx context.Context, dbtx gadb.DBTX, c *ContactMethod) er } if permission.Admin(ctx) { - err = gadb.New(dbtx).ContactMethodUpdate(ctx, gadb.ContactMethodUpdateParams{ID: n.ID, Name: n.Name, Disabled: n.Disabled, EnableStatusUpdates: n.StatusUpdates}) + err = gadb.New(dbtx).ContactMethodUpdate(ctx, gadb.ContactMethodUpdateParams{ID: n.ID, Name: n.Name, Disabled: n.Disabled, EnableStatusUpdates: n.StatusUpdates, Private: n.Private}) return err } @@ -248,7 +250,7 @@ func (s *Store) Update(ctx context.Context, dbtx gadb.DBTX, c *ContactMethod) er return err } - err = gadb.New(dbtx).ContactMethodUpdate(ctx, gadb.ContactMethodUpdateParams{ID: n.ID, Name: n.Name, Disabled: n.Disabled, EnableStatusUpdates: n.StatusUpdates}) + err = gadb.New(dbtx).ContactMethodUpdate(ctx, gadb.ContactMethodUpdateParams{ID: n.ID, Name: n.Name, Disabled: n.Disabled, EnableStatusUpdates: n.StatusUpdates, Private: n.Private}) return err } @@ -280,6 +282,7 @@ func (s *Store) FindMany(ctx context.Context, dbtx gadb.DBTX, ids []string) ([]C UserID: row.UserID.String(), Pending: row.Pending, StatusUpdates: row.EnableStatusUpdates, + Private: row.Private, lastTestVerifyAt: row.LastTestVerifyAt, } } @@ -287,26 +290,33 @@ func (s *Store) FindMany(ctx context.Context, dbtx gadb.DBTX, ids []string) ([]C return cms, nil } -// FindAll finds all contact methods from the database associated with the given user ID. -func (s *Store) FindAll(ctx context.Context, dbtx gadb.DBTX, userID string) ([]ContactMethod, error) { +// FindAll finds all contact methods from the database associated with the given user ID along with the number of omitted (private) entries. +func (s *Store) FindAll(ctx context.Context, dbtx gadb.DBTX, userID string) ([]ContactMethod, int, error) { uid, err := validate.ParseUUID("ContactMethodID", userID) if err != nil { - return nil, err + return nil, 0, err } err = permission.LimitCheckAny(ctx, permission.All) if err != nil { - return nil, err + return nil, 0, err } rows, err := gadb.New(dbtx).ContactMethodFindAll(ctx, uid) if err != nil { - return nil, err + return nil, 0, err } + authID := permission.UserNullUUID(ctx).UUID - cms := make([]ContactMethod, len(rows)) - for i, row := range rows { - cms[i] = ContactMethod{ + result := make([]ContactMethod, 0, len(rows)) + var omitted int + for _, row := range rows { + if row.Private && row.UserID != authID { + omitted++ + continue + } + + result = append(result, ContactMethod{ ID: row.ID, Name: row.Name, Dest: row.Dest.DestV1, @@ -315,8 +325,9 @@ func (s *Store) FindAll(ctx context.Context, dbtx gadb.DBTX, userID string) ([]C Pending: row.Pending, StatusUpdates: row.EnableStatusUpdates, lastTestVerifyAt: row.LastTestVerifyAt, - } + Private: row.Private, + }) } - return cms, nil + return result, omitted, nil } diff --git a/user/notificationrule/store.go b/user/notificationrule/store.go index 918c5dc73f..51583af895 100644 --- a/user/notificationrule/store.go +++ b/user/notificationrule/store.go @@ -124,7 +124,7 @@ func (s *Store) FindAll(ctx context.Context, userID string) ([]NotificationRule, return nil, err } - err = permission.LimitCheckAny(ctx, permission.System, permission.User, permission.Admin) + err = permission.LimitCheckAny(ctx, permission.All) if err != nil { return nil, err } diff --git a/web/src/app/users/UserContactMethodCreateDialog.tsx b/web/src/app/users/UserContactMethodCreateDialog.tsx index fed8d6865b..c334710bb4 100644 --- a/web/src/app/users/UserContactMethodCreateDialog.tsx +++ b/web/src/app/users/UserContactMethodCreateDialog.tsx @@ -13,6 +13,7 @@ type Value = { name: string dest: DestinationInput statusUpdates: boolean + private: boolean } const createMutation = gql` @@ -41,6 +42,7 @@ export default function UserContactMethodCreateDialog(props: { args: {}, }, statusUpdates: false, + private: false, }) const [createErr, setCreateErr] = useState(null) const setCMValue = (newValue: Value): void => { @@ -112,6 +114,7 @@ export default function UserContactMethodCreateDialog(props: { dest: CMValue.dest, enableStatusUpdates: CMValue.statusUpdates, userID: props.userID, + private: CMValue.private, newUserNotificationRule: { delayMinutes: 0, }, diff --git a/web/src/app/users/UserContactMethodEditDialog.stories.tsx b/web/src/app/users/UserContactMethodEditDialog.stories.tsx index a123c32308..664d079eb5 100644 --- a/web/src/app/users/UserContactMethodEditDialog.stories.tsx +++ b/web/src/app/users/UserContactMethodEditDialog.stories.tsx @@ -171,7 +171,9 @@ export const SingleField: Story = { const single = await canvas.findByLabelText('Destination Type') expect(single).toHaveTextContent('Single With Status') - await canvas.findByTestId('CheckBoxOutlineBlankIcon') + + await canvas.findByLabelText('Send alert status updates') + await canvas.findByLabelText('This is a private contact method') }, } diff --git a/web/src/app/users/UserContactMethodEditDialog.tsx b/web/src/app/users/UserContactMethodEditDialog.tsx index 2c1f54a332..0bbfc94722 100644 --- a/web/src/app/users/UserContactMethodEditDialog.tsx +++ b/web/src/app/users/UserContactMethodEditDialog.tsx @@ -10,6 +10,7 @@ type Value = { name: string dest: DestinationInput statusUpdates: boolean + private: boolean } const query = gql` @@ -22,6 +23,7 @@ const query = gql` args } statusUpdates + private } } ` @@ -89,6 +91,7 @@ export default function UserContactMethodEditDialog(props: { id: props.contactMethodID, name: CMValue.name, enableStatusUpdates: Boolean(CMValue.statusUpdates), + private: CMValue.private, }, }, { additionalTypenames: ['UserContactMethod'] }, diff --git a/web/src/app/users/UserContactMethodForm.stories.tsx b/web/src/app/users/UserContactMethodForm.stories.tsx index c95730bce2..26e561bc3c 100644 --- a/web/src/app/users/UserContactMethodForm.stories.tsx +++ b/web/src/app/users/UserContactMethodForm.stories.tsx @@ -41,6 +41,7 @@ export const SupportStatusUpdates: Story = { args: { value: { name: 'supports status', + private: false, dest: { type: 'supports-status', args: { phone_number: '+15555555555' }, @@ -63,6 +64,7 @@ export const RequiredStatusUpdates: Story = { args: { value: { name: 'required status', + private: false, dest: { type: 'required-status', args: { phone_number: '+15555555555' }, @@ -87,6 +89,7 @@ export const ErrorSingleField: Story = { args: { value: { name: '-notvalid', + private: false, dest: { type: 'single-field', args: { phone_number: '+15555555555' }, @@ -112,6 +115,7 @@ export const ErrorMultiField: Story = { args: { value: { name: '-notvalid', + private: false, dest: { type: 'triple-field', args: { @@ -144,6 +148,7 @@ export const Disabled: Story = { args: { value: { name: 'disabled dest', + private: false, dest: { type: 'triple-field', args: {}, diff --git a/web/src/app/users/UserContactMethodForm.tsx b/web/src/app/users/UserContactMethodForm.tsx index 0d237bbaff..9e1d41cefb 100644 --- a/web/src/app/users/UserContactMethodForm.tsx +++ b/web/src/app/users/UserContactMethodForm.tsx @@ -12,6 +12,7 @@ export type Value = { name: string dest: DestinationInput statusUpdates: boolean + private: boolean } export type UserContactMethodFormProps = { @@ -168,6 +169,26 @@ export default function UserContactMethodForm( } /> + + + props.onChange && + props.onChange({ + ...value, + private: v.target.checked, + }) + } + /> + } + /> + ) diff --git a/web/src/app/users/UserContactMethodList.tsx b/web/src/app/users/UserContactMethodList.tsx index 757986b046..a8d77c76f6 100644 --- a/web/src/app/users/UserContactMethodList.tsx +++ b/web/src/app/users/UserContactMethodList.tsx @@ -44,6 +44,7 @@ const query = gql` } disabled pending + private } } } @@ -220,7 +221,7 @@ export default function UserContactMethodList( return ( {sortNotificationRules(user?.notificationRules ?? []).map((nr) => { - const formattedValue = - nr.contactMethod.dest.displayInfo.text || 'Unknown Label' - const name = nr.contactMethod.name || 'Unknown User' - const type = - nr.contactMethod.dest.displayInfo.iconAltText || 'Unknown Type' + let info = null + if (nr.contactMethod) { + const formattedValue = + nr.contactMethod.dest.displayInfo.text || 'Unknown Label' + const name = nr.contactMethod.name || 'Unknown User' + const type = + nr.contactMethod.dest.displayInfo.iconAltText || 'Unknown Type' + info = { + type, + name, + formattedValue, + } + } return (