diff --git a/changes/19864-vpp-token-crud b/changes/19864-vpp-token-crud new file mode 100644 index 0000000000..ee4a92e80f --- /dev/null +++ b/changes/19864-vpp-token-crud @@ -0,0 +1,2 @@ +- Adds the functionality for the `POST /mdm/apple/vpp_token`, `DELETE /mdm/apple/vpp_token` and +`GET /vpp` endpoints. \ No newline at end of file diff --git a/changes/19865-db-schema b/changes/19865-db-schema new file mode 100644 index 0000000000..ede5f90ed0 --- /dev/null +++ b/changes/19865-db-schema @@ -0,0 +1 @@ +- Adds DB updates to support the VPP software feature. \ No newline at end of file diff --git a/changes/19867-get-avail-apps b/changes/19867-get-avail-apps new file mode 100644 index 0000000000..4ace068f95 --- /dev/null +++ b/changes/19867-get-avail-apps @@ -0,0 +1 @@ +- Adds functionality for the `GET /software/app_store_apps` and `POST /software/app_store_apps` endpoints. \ No newline at end of file diff --git a/changes/19868-vpp-install-command b/changes/19868-vpp-install-command new file mode 100644 index 0000000000..337b5d5010 --- /dev/null +++ b/changes/19868-vpp-install-command @@ -0,0 +1 @@ +- Adds functionality for installing App Store apps to the VPP feature. \ No newline at end of file diff --git a/changes/19870-vpp-activities-backend b/changes/19870-vpp-activities-backend new file mode 100644 index 0000000000..115f92e1fd --- /dev/null +++ b/changes/19870-vpp-activities-backend @@ -0,0 +1 @@ +- Adds global activity support for VPP related activities. \ No newline at end of file diff --git a/changes/19871-gitops-vpp-config b/changes/19871-gitops-vpp-config new file mode 100644 index 0000000000..e9a02e0fa7 --- /dev/null +++ b/changes/19871-gitops-vpp-config @@ -0,0 +1 @@ +* Add support for VPP to gitops config diff --git a/changes/19880-include-vpp-apps-in-software-titles-endpoints b/changes/19880-include-vpp-apps-in-software-titles-endpoints new file mode 100644 index 0000000000..9503cdef99 --- /dev/null +++ b/changes/19880-include-vpp-apps-in-software-titles-endpoints @@ -0,0 +1,2 @@ +* Added the associated VPP apps to the `GET /software/titles` and `GET /software/titles/:id` endpoints. +* Added the associated VPP apps to the `GET /hosts/:id/software` and `GET /device/:token/software` endpoints. diff --git a/changes/20278-vpp-batch-api b/changes/20278-vpp-batch-api new file mode 100644 index 0000000000..e5cbbf7eca --- /dev/null +++ b/changes/20278-vpp-batch-api @@ -0,0 +1 @@ +- GitOps supports VPP app associations diff --git a/changes/20515-delete-vpp-app b/changes/20515-delete-vpp-app new file mode 100644 index 0000000000..49599edf94 --- /dev/null +++ b/changes/20515-delete-vpp-app @@ -0,0 +1,2 @@ +* Added support to delete a VPP app from a team in `DELETE /software/titles/:software_title_id/available_for_install`. +* Fixed path that was incorrect for the download software installer package endpoint `GET /software/titles/:software_title_id/package`. diff --git a/changes/issue-19866-add-remove-disable-vpp-in-ui b/changes/issue-19866-add-remove-disable-vpp-in-ui new file mode 100644 index 0000000000..09000dbff2 --- /dev/null +++ b/changes/issue-19866-add-remove-disable-vpp-in-ui @@ -0,0 +1 @@ +- add ability to add/remove/disable vpp in the fleet UI. diff --git a/changes/issue-19869-vpp-ui-on-software-pages b/changes/issue-19869-vpp-ui-on-software-pages new file mode 100644 index 0000000000..74f71d41c9 --- /dev/null +++ b/changes/issue-19869-vpp-ui-on-software-pages @@ -0,0 +1 @@ +- add UI to support the apple vpp feature on the software pages. diff --git a/changes/issue-20612-ui-updates-host-software-device-user-pages-for-vpp b/changes/issue-20612-ui-updates-host-software-device-user-pages-for-vpp new file mode 100644 index 0000000000..01e6073b2d --- /dev/null +++ b/changes/issue-20612-ui-updates-host-software-device-user-pages-for-vpp @@ -0,0 +1 @@ +- add UI updates for VPP feature on host software and my device pages. diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 06b2be5669..db5a00c9b2 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -692,10 +692,10 @@ func TestGetSoftwareTitles(t *testing.T) { apiVersion: "1" kind: software_title spec: -- hosts_count: 2 +- app_store_app: null + hosts_count: 2 id: 0 name: foo - self_service: false software_package: null source: chrome_extensions versions: @@ -713,10 +713,10 @@ spec: vulnerabilities: - cve-123-456-003 versions_count: 3 -- hosts_count: 0 +- app_store_app: null + hosts_count: 0 id: 0 name: bar - self_service: false software_package: null source: deb_packages versions: @@ -761,8 +761,8 @@ spec: ] } ], - "self_service": false, - "software_package": null + "software_package": null, + "app_store_app": null }, { "id": 0, @@ -774,11 +774,11 @@ spec: { "id": 0, "version": "0.0.3", - "vulnerabilities": null + "vulnerabilities": null } ], - "self_service": false, - "software_package": null + "software_package": null, + "app_store_app": null } ] } diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index d0a3f13946..b09ee8f8e0 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -2,6 +2,9 @@ package main import ( "context" + "crypto/rand" + "encoding/base64" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -16,6 +19,7 @@ import ( "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki" "github.com/fleetdm/fleet/v4/server/mock" mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm" @@ -261,6 +265,12 @@ func TestBasicTeamGitOps(t *testing.T) { const secret = "TestSecret" + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, @@ -747,7 +757,12 @@ func TestFullTeamGitOps(t *testing.T) { ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } - + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { enrolledSecrets = secrets return nil @@ -867,6 +882,13 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) { return nil } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } + const ( fleetServerURL = "https://fleet.example.com" orgName = "GitOps Test" @@ -1149,6 +1171,13 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) { }, nil } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } + globalFile := "./testdata/gitops/global_config_no_paths.yml" teamFile := "./testdata/gitops/team_config_no_paths.yml" @@ -1191,11 +1220,18 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) { {"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"}, - {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field TeamSpecSoftware.self_service of type bool"}, + {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field TeamSpecSoftware.packages of type bool"}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { - setupFullGitOpsPremiumServer(t) + ds, _, _ := setupFullGitOpsPremiumServer(t) + + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } _, err := runAppNoChecks([]string{"gitops", "-f", c.file}) if c.wantErr == "" { @@ -1207,6 +1243,99 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) { } } +func TestTeamVPPAppsGitOps(t *testing.T) { + config := &appleVPPConfigSrvConf{ + Assets: []vpp.Asset{ + { + AdamID: "1", + PricingParam: "STDQ", + AvailableCount: 12, + }, + { + AdamID: "2", + PricingParam: "STDQ", + AvailableCount: 3, + }, + }, + SerialNumbers: []string{"123", "456"}, + } + + startVPPApplyServer(t, config) + + cases := []struct { + file string + wantErr string + tokenExpiration time.Time + }{ + {"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour)}, + {"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour)}, + {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(24 * time.Hour)}, + {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(-24 * time.Hour)}, + {"testdata/gitops/team_vpp_valid_app.yml", "VPP token expired", time.Now().Add(-24 * time.Hour)}, + {"testdata/gitops/team_vpp_invalid_app.yml", "app not available on vpp account", time.Now().Add(24 * time.Hour)}, + } + + for _, c := range cases { + t.Run(filepath.Base(c.file), func(t *testing.T) { + ds, _, _ := setupFullGitOpsPremiumServer(t) + token, err := createVPPDataToken(c.tokenExpiration, "fleet", "ca") + require.NoError(t, err) + + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } + + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + asset := map[fleet.MDMAssetName]fleet.MDMConfigAsset{ + fleet.MDMAssetVPPToken: { + Name: fleet.MDMAssetVPPToken, + Value: token, + }, + } + return asset, nil + } + + _, err = runAppNoChecks([]string{"gitops", "-f", c.file}) + if c.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, c.wantErr) + } + }) + } +} + +func createVPPDataToken(expiration time.Time, orgName, location string) ([]byte, error) { + var randBytes [32]byte + _, err := rand.Read(randBytes[:]) + if err != nil { + return nil, fmt.Errorf("generating random bytes: %w", err) + } + token := base64.StdEncoding.EncodeToString(randBytes[:]) + raw := fleet.VPPTokenRaw{ + OrgName: orgName, + Token: token, + ExpDate: expiration.Format("2006-01-02T15:04:05Z0700"), + } + rawJson, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("marshalling vpp raw token: %w", err) + } + + base64Token := base64.StdEncoding.EncodeToString(rawJson) + + dataToken := fleet.VPPTokenData{Token: base64Token, Location: location} + dataTokenJson, err := json.Marshal(dataToken) + if err != nil { + return nil, fmt.Errorf("marshalling vpp data token: %w", err) + } + + return dataTokenJson, nil +} + func TestCustomSettingsGitOps(t *testing.T) { cases := []struct { file string @@ -1243,6 +1372,12 @@ func TestCustomSettingsGitOps(t *testing.T) { } return ret, nil } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } _, err := runAppNoChecks([]string{"gitops", "-f", c.file}) if c.wantErr == "" { @@ -1288,6 +1423,140 @@ func startSoftwareInstallerServer(t *testing.T) { t.Setenv("SOFTWARE_INSTALLER_URL", srv.URL) } +type appleVPPConfigSrvConf struct { + Assets []vpp.Asset + SerialNumbers []string +} + +func startVPPApplyServer(t *testing.T, config *appleVPPConfigSrvConf) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "associate") { + var associations vpp.AssociateAssetsRequest + + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&associations); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + if len(associations.Assets) == 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + res := vpp.ErrorResponse{ + ErrorNumber: 9718, + ErrorMessage: "This request doesn't contain an asset, which is a required argument. Change the request to provide an asset.", + } + if err := json.NewEncoder(w).Encode(res); err != nil { + panic(err) + } + return + } + + if len(associations.SerialNumbers) == 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + res := vpp.ErrorResponse{ + ErrorNumber: 9719, + ErrorMessage: "Either clientUserIds or serialNumbers are required arguments. Change the request to provide assignable users and devices.", + } + if err := json.NewEncoder(w).Encode(res); err != nil { + panic(err) + } + return + } + + var badAssets []vpp.Asset + for _, reqAsset := range associations.Assets { + var found bool + for _, goodAsset := range config.Assets { + if reqAsset == goodAsset { + found = true + } + } + if !found { + badAssets = append(badAssets, reqAsset) + } + } + + var badSerials []string + for _, reqSerial := range associations.SerialNumbers { + var found bool + for _, goodSerial := range config.SerialNumbers { + if reqSerial == goodSerial { + found = true + } + } + if !found { + badSerials = append(badSerials, reqSerial) + } + } + + if len(badAssets) != 0 || len(badSerials) != 0 { + errMsg := "error associating assets." + if len(badAssets) > 0 { + var badAdamIds []string + for _, asset := range badAssets { + badAdamIds = append(badAdamIds, asset.AdamID) + } + errMsg += fmt.Sprintf(" assets don't exist on account: %s.", strings.Join(badAdamIds, ", ")) + } + if len(badSerials) > 0 { + errMsg += fmt.Sprintf(" bad serials: %s.", strings.Join(badSerials, ", ")) + } + res := vpp.ErrorResponse{ + ErrorInfo: vpp.ResponseErrorInfo{ + Assets: badAssets, + ClientUserIds: []string{"something"}, + SerialNumbers: badSerials, + }, + // Not sure what error should be returned on each + // error type + ErrorNumber: 1, + ErrorMessage: errMsg, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(res); err != nil { + panic(err) + } + } + return + } + + if strings.Contains(r.URL.Path, "assets") { + // Then we're responding to GetAssets + w.Header().Set("Content-Type", "application/json") + encoder := json.NewEncoder(w) + err := encoder.Encode(map[string][]vpp.Asset{"assets": config.Assets}) + if err != nil { + panic(err) + } + return + } + + resp := []byte(`{"locationName": "Fleet Location One"}`) + if strings.Contains(r.URL.RawQuery, "invalidToken") { + // This replicates the response sent back from Apple's VPP endpoints when an invalid + // token is passed. For more details see: + // https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes + // https://developer.apple.com/documentation/devicemanagement/client_config + // https://developer.apple.com/documentation/devicemanagement/errorresponse + // Note that the Apple server returns 200 in this case. + resp = []byte(`{"errorNumber": 9622,"errorMessage": "Invalid authentication token"}`) + } + + if strings.Contains(r.URL.RawQuery, "serverError") { + resp = []byte(`{"errorNumber": 9603,"errorMessage": "Internal server error"}`) + w.WriteHeader(http.StatusInternalServerError) + } + + _, _ = w.Write(resp) + })) + + t.Setenv("FLEET_DEV_VPP_URL", srv.URL) + t.Cleanup(srv.Close) +} + func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, **fleet.Team) { testCert, testKey, err := apple_mdm.NewSCEPCACertKey() require.NoError(t, err) @@ -1322,6 +1591,12 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, savedAppConfig = &appConfigCopy return nil } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } var savedTeam *fleet.Team diff --git a/cmd/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/testdata/expectedGetTeamsJson.json index 05e71c9db0..de6669ac12 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/testdata/expectedGetTeamsJson.json @@ -61,7 +61,6 @@ } }, "scripts": null, - "software": null, "user_count": 99, "host_count": 42 } @@ -145,7 +144,6 @@ } }, "scripts": null, - "software": null, "user_count": 87, "host_count": 43 } diff --git a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml index fd1b7a5119..422456f5b5 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -36,7 +36,6 @@ spec: macos_setup_assistant: scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: team1 @@ -86,7 +85,6 @@ spec: enable_release_device_manually: false macos_setup_assistant: scripts: null - software: null webhook_settings: host_status_webhook: null name: team2 diff --git a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml index ee81d27fc4..785ba5d215 100644 --- a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml @@ -116,12 +116,13 @@ policies: resolution: There is no resolution for this policy. query: SELECT 1 FROM osquery_info WHERE start_time < 0; software: - - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb - install_script: - path: lib/install_ruby.sh - pre_install_query: - path: lib/query_ruby.yml - post_install_script: - path: lib/post_install_ruby.sh - - url: ${SOFTWARE_INSTALLER_URL}/other.deb - self_service: true + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_install_not_found.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_install_not_found.yml index c3837b3a0e..8c25d098a1 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_install_not_found.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_install_not_found.yml @@ -13,6 +13,7 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb - install_script: - path: lib/notfound.sh + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/notfound.sh diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml index b27a982703..fe50a971d0 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml @@ -13,5 +13,6 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt - self_service: "not a boolean" + packages: + - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt + self_service: "not a boolean" diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_no_url.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_no_url.yml index 43bfa4babf..dfdce81879 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_no_url.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_no_url.yml @@ -13,9 +13,10 @@ controls: policies: queries: software: - - install_script: - path: lib/install_ruby.sh - pre_install_query: - path: lib/query_ruby.yml - post_install_script: - path: lib/post_install_ruby.sh + packages: + - install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_not_found.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_not_found.yml index ca657a5736..ff30037bd5 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_not_found.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_not_found.yml @@ -13,4 +13,5 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/notfound.deb + packages: + - url: ${SOFTWARE_INSTALLER_URL}/notfound.deb diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_post_install_not_found.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_post_install_not_found.yml index 4cc9fbcef7..cdd20768bf 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_post_install_not_found.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_post_install_not_found.yml @@ -13,8 +13,9 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb - install_script: - path: lib/install_ruby.sh - post_install_script: - path: lib/notfound.sh + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + post_install_script: + path: lib/notfound.sh diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml index 4b26e63e4e..a9e96544a2 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml @@ -13,10 +13,11 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb - install_script: - path: lib/install_ruby.sh - pre_install_query: - path: lib/query_multiple.yml - post_install_script: - path: lib/post_install_ruby.sh + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_multiple.yml + post_install_script: + path: lib/post_install_ruby.sh diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_not_found.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_not_found.yml index 681590d04d..162a9aecb9 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_not_found.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_not_found.yml @@ -13,8 +13,9 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb - install_script: - path: lib/install_ruby.sh - pre_install_query: - path: lib/notfound.yml + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/notfound.yml diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_too_large.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_too_large.yml index 15d16e9e9a..6592a55212 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_too_large.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_too_large.yml @@ -13,4 +13,5 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb + packages: + - url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_unsupported.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_unsupported.yml index 3f58009a03..b722970cf8 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_unsupported.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_unsupported.yml @@ -13,4 +13,5 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt + packages: + - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml index ceeb1a7415..e894112249 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml @@ -13,12 +13,13 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb - install_script: - path: lib/install_ruby.sh - pre_install_query: - path: lib/query_ruby.yml - post_install_script: - path: lib/post_install_ruby.sh - - url: ${SOFTWARE_INSTALLER_URL}/other.deb - self_service: true + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app.yml b/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app.yml new file mode 100644 index 0000000000..f0822f443b --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app.yml @@ -0,0 +1,17 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + app_store_apps: + - app_store_id: "999999999" diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_valid_app.yml b/cmd/fleetctl/testdata/gitops/team_vpp_valid_app.yml new file mode 100644 index 0000000000..8d588fb127 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_valid_app.yml @@ -0,0 +1,17 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + app_store_apps: + - app_store_id: "1" diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_valid_empty.yml b/cmd/fleetctl/testdata/gitops/team_vpp_valid_empty.yml new file mode 100644 index 0000000000..1c98065d59 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_valid_empty.yml @@ -0,0 +1,16 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + app_store_apps: diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index 3e8cfb0f1a..f0aa275b56 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -36,7 +36,6 @@ spec: grace_period_days: null scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: tm1 @@ -77,7 +76,6 @@ spec: grace_period_days: null scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: tm2 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index 598261b4b8..1d2b076740 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -36,7 +36,6 @@ spec: grace_period_days: null scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: tm1 @@ -77,7 +76,6 @@ spec: grace_period_days: null scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: tm2 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index 7f450ef682..885d254827 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -36,7 +36,6 @@ spec: custom_settings: null scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: tm1 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml index 5dfe7dae3b..76c45b10b2 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml @@ -35,7 +35,6 @@ spec: grace_period_days: null scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: tm1 diff --git a/docs/Using Fleet/Audit-logs.md b/docs/Using Fleet/Audit-logs.md index d72ce60198..5f7b2e8cfe 100644 --- a/docs/Using Fleet/Audit-logs.md +++ b/docs/Using Fleet/Audit-logs.md @@ -1200,7 +1200,7 @@ Generated when a software installer is deleted from Fleet. This activity contains the following fields: - "software_title": Name of the software. - "software_package": Filename of the installer. -- "team_name": Name of the team to which this software was added. `null if it was added to no team. +- "team_name": Name of the team to which this software was added. `null` if it was added to no team. - "team_id": The ID of the team to which this software was added. `null` if it was added to no team. - "self_service": Whether the software was available for installation by the end user. @@ -1216,6 +1216,83 @@ This activity contains the following fields: } ``` +## enabled_vpp + +Generated when the VPP feature is enabled in Fleet. + + + +## disabled_vpp + +Generated when the VPP feature is disabled in Fleet. + + + +## added_app_store_app + +Generated when an App Store app is added to Fleet. + +This activity contains the following fields: +- "software_title": Name of the App Store app. +- "app_store_id": ID of the app on the Apple App Store. +- "team_name": Name of the team to which this App Store app was added, or `null` if it was added to no team. +- "team_id": ID of the team to which this App Store app was added, or `null`if it was added to no team. + +#### Example + +```json +{ + "software_title": "Logic Pro", + "app_store_id": "1234567", + "team_name": "Workstations", + "team_id": 1 +} +``` + +## deleted_app_store_app + +Generated when an App Store app is deleted from Fleet. + +This activity contains the following fields: +- "software_title": Name of the App Store app. +- "app_store_id": ID of the app on the Apple App Store. +- "team_name": Name of the team from which this App Store app was deleted, or `null` if it was deleted from no team. +- "team_id": ID of the team from which this App Store app was deleted, or `null`if it was deleted from no team. + +#### Example + +```json +{ + "software_title": "Logic Pro", + "app_store_id": "1234567", + "team_name": "Workstations", + "team_id": 1 +} +``` + +## installed_app_store_app + +Generated when an App Store app is installed on a device. + +This activity contains the following fields: +- host_id: ID of the host on which the app was installed. +- host_display_name: Display name of the host. +- software_title: Name of the App Store app. +- app_store_id: ID of the app on the Apple App Store. +- command_uuid: UUID of the MDM command used to install the app. + +#### Example + +```json +{ + "host_id": 42, + "host_display_name": "Anna's MacBook Pro", + "software_title": "Logic Pro", + "app_store_id": "1234567", + "command_uuid": "98765432-1234-1234-1234-1234567890ab" +} +``` + diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 988e2d4df5..7412f7df7b 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -14,11 +14,14 @@ import ( "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/go-kit/log/level" + "github.com/google/uuid" "golang.org/x/sync/errgroup" ) @@ -85,15 +88,60 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t return fleet.NewInvalidArgumentError("team_id", "is required and can't be zero") } + // we authorize with SoftwareInstaller here, but it uses the same AuthzType + // as VPPApp, so this is correct for both software installers and VPP apps. if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil { return err } + // first, look for a software installer meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false) if err != nil { + if fleet.IsNotFound(err) { + // no software installer, look for a VPP app + meta, err := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting software app metadata") + } + return svc.deleteVPPApp(ctx, teamID, meta) + } return ctxerr.Wrap(ctx, err, "getting software installer metadata") } + return svc.deleteSoftwareInstaller(ctx, meta) +} +func (svc *Service) deleteVPPApp(ctx context.Context, teamID *uint, meta *fleet.VPPAppStoreApp) error { + vc, ok := viewer.FromContext(ctx) + if !ok { + return fleet.ErrNoContext + } + + if err := svc.ds.DeleteVPPAppFromTeam(ctx, teamID, meta.AppStoreID); err != nil { + return ctxerr.Wrap(ctx, err, "deleting VPP app") + } + + var teamName *string + if teamID != nil { + t, err := svc.ds.Team(ctx, *teamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting team name for deleted VPP app") + } + teamName = &t.Name + } + + if err := svc.NewActivity(ctx, vc.User, fleet.ActivityDeletedAppStoreApp{ + AppStoreID: meta.AppStoreID, + SoftwareTitle: meta.Name, + TeamName: teamName, + TeamID: teamID, + }); err != nil { + return ctxerr.Wrap(ctx, err, "creating activity for deleted VPP app") + } + + return nil +} + +func (svc *Service) deleteSoftwareInstaller(ctx context.Context, meta *fleet.SoftwareInstaller) error { vc, ok := viewer.FromContext(ctx) if !ok { return fleet.ErrNoContext @@ -217,7 +265,6 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw // fleetd is required to install software so if the host is // enrolled via plain osquery we return an error svc.authz.SkipAuthorization(ctx) - // TODO(roberto): for cleanup task, confirm with product error message. return fleet.NewUserMessageError(errors.New("Host doesn't have fleetd installed"), http.StatusUnprocessableEntity) } @@ -228,19 +275,159 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false) if err != nil { + if !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "finding software installer for title") + } + installer = nil + } + + // if we found an installer, use that + if installer != nil { + return svc.installSoftwareTitleUsingInstaller(ctx, host, installer) + } + + vppApp, err := svc.ds.GetVPPAppByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false) + if err != nil { + // if we couldn't find an installer or a VPP app, return a bad + // request error if fleet.IsNotFound(err) { return &fleet.BadRequestError{ - Message: "Software title has no package added. Please add software package to install.", + Message: "Couldn't install software. Software title is not available for install. Please add software package or App Store app to install.", InternalErr: ctxerr.WrapWithData( - ctx, err, "couldn't find an installer for software title", + ctx, err, "couldn't find an installer or VPP app for software title", map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID}, ), } } - return ctxerr.Wrap(ctx, err, "finding software installer for title") + return ctxerr.Wrap(ctx, err, "finding VPP app for title") } + return svc.installSoftwareFromVPP(ctx, host, vppApp) +} + +func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp) error { + if host.FleetPlatform() != "darwin" { + return &fleet.BadRequestError{ + Message: "VPP apps can only be installed only on macOS hosts.", + InternalErr: ctxerr.NewWithData( + ctx, "invalid host platform for requested installer", + map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": vppApp.TitleID}, + ), + } + } + + config, err := svc.ds.AppConfig(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "fetching config to check MDM status") + } + + if !config.MDM.EnabledAndConfigured { + return fleet.NewUserMessageError(errors.New("Couldn't install. MDM is turned off. Please make sure that MDM is turned on to install App Store apps."), http.StatusUnprocessableEntity) + } + + mdmConnected, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host) + if err != nil { + return ctxerr.Wrapf(ctx, err, "checking MDM status for host %d", host.ID) + } + + if !mdmConnected { + return &fleet.BadRequestError{ + Message: "VPP apps can only be installed only on hosts enrolled in MDM.", + InternalErr: ctxerr.NewWithData( + ctx, "VPP install attempted on non-MDM host", + map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": vppApp.TitleID}, + ), + } + } + + token, err := svc.getVPPToken(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting VPP token") + } + + // at this moment, neither the UI or the back-end are prepared to + // handle [asyncronous errors][1] on assignment, so before assigning a + // device to a license, we need to: + // + // 1. Check if the app is already assigned to the serial number. + // 2. If it's not assigned yet, check if we have enough licenses. + // + // A race still might happen, so async error checking needs to be + // implemented anyways at some point. + // + // [1]: https://developer.apple.com/documentation/devicemanagement/app_and_book_management/handling_error_responses#3729433 + assignments, err := vpp.GetAssignments(token, &vpp.AssignmentFilter{AdamID: vppApp.AdamID, SerialNumber: host.HardwareSerial}) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting assignments from VPP API") + } + + var eventID string + + // this app is not assigned to this device, check if we have licenses + // left and assign it. + if len(assignments) == 0 { + assets, err := vpp.GetAssets(token, &vpp.AssetFilter{AdamID: vppApp.AdamID}) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting assets from VPP API") + } + + if len(assets) == 0 { + level.Debug(svc.logger).Log( + "msg", "trying to assign VPP asset to host", + "adam_id", vppApp.AdamID, + "host_serial", host.HardwareSerial, + ) + return &fleet.BadRequestError{ + Message: "Couldn't add software. isn't available in Apple Business Manager. Please purchase license in Apple Business Manager and try again.", + InternalErr: ctxerr.Errorf(ctx, "VPP API didn't return any assets for adamID %s", vppApp.AdamID), + } + } + + if len(assets) > 1 { + return ctxerr.Errorf(ctx, "VPP API returned more than one asset for adamID %s", vppApp.AdamID) + } + + if assets[0].AvailableCount <= 0 { + return &fleet.BadRequestError{ + Message: "Couldn't install. No available licenses. Please purchase license in Apple Business Manager and try again.", + InternalErr: ctxerr.NewWithData( + ctx, "license available count <= 0", + map[string]any{ + "host_id": host.ID, + "team_id": host.TeamID, + "adam_id": vppApp.AdamID, + "count": assets[0].AvailableCount, + }, + ), + } + } + + eventID, err = vpp.AssociateAssets(token, &vpp.AssociateAssetsRequest{Assets: assets, SerialNumbers: []string{host.HardwareSerial}}) + if err != nil { + return ctxerr.Wrapf(ctx, err, "associating asset with adamID %s to host %s", vppApp.AdamID, host.HardwareSerial) + } + + } + + user := authz.UserFromContext(ctx) + + // add command to install + cmdUUID := uuid.NewString() + err = svc.mdmAppleCommander.InstallApplication(ctx, []string{host.UUID}, cmdUUID, vppApp.AdamID) + if err != nil { + return ctxerr.Wrapf(ctx, err, "sending command to install VPP %s application to host with serial %s", vppApp.AdamID, host.HardwareSerial) + } + + err = svc.ds.InsertHostVPPSoftwareInstall(ctx, host.ID, user.ID, vppApp.AdamID, cmdUUID, eventID) + if err != nil { + return ctxerr.Wrapf(ctx, err, "inserting host vpp software install for host with serial %s and app with adamID %s", host.HardwareSerial, vppApp.AdamID) + } + + return nil +} + +func (svc *Service) installSoftwareTitleUsingInstaller(ctx context.Context, host *fleet.Host, installer *fleet.SoftwareInstaller) error { ext := filepath.Ext(installer.Name) requiredPlatform := packageExtensionToPlatform(ext) if requiredPlatform == "" { @@ -251,14 +438,14 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw if host.FleetPlatform() != requiredPlatform { return &fleet.BadRequestError{ Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform), - InternalErr: ctxerr.WrapWithData( - ctx, err, "invalid host platform for requested installer", - map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID}, + InternalErr: ctxerr.NewWithData( + ctx, "invalid host platform for requested installer", + map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": installer.TitleID}, ), } } - _, err = svc.ds.InsertSoftwareInstallRequest(ctx, hostID, installer.InstallerID, false) + _, err := svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, installer.InstallerID, false) return ctxerr.Wrap(ctx, err, "inserting software install request") } diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 302af0a013..4b8df03123 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -1083,6 +1083,7 @@ func (svc *Service) createTeamFromSpec( Integrations: fleet.TeamIntegrations{ GoogleCalendar: spec.Integrations.GoogleCalendar, }, + Software: spec.Software, }, Secrets: secrets, }) @@ -1243,8 +1244,18 @@ func (svc *Service) editTeamFromSpec( team.Config.Scripts = spec.Scripts } - if spec.Software.Set { - team.Config.Software = spec.Software + if spec.Software != nil { + if team.Config.Software == nil { + team.Config.Software = &fleet.TeamSpecSoftware{} + } + + if spec.Software.Packages.Set { + team.Config.Software.Packages = spec.Software.Packages + } + + if spec.Software.AppStoreApps.Set { + team.Config.Software.AppStoreApps = spec.Software.AppStoreApps + } } if secrets != nil { diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go new file mode 100644 index 0000000000..cd9871d7f0 --- /dev/null +++ b/ee/server/service/vpp.go @@ -0,0 +1,299 @@ +package service + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/fleetdm/fleet/v4/server/authz" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/itunes" + "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" +) + +// getVPPToken returns the base64 encoded VPP token, ready for use in requests to Apple's VPP API. +// It returns an error if the token is expired. +func (svc *Service) getVPPToken(ctx context.Context) (string, error) { + configMap, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetVPPToken}) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "fetching vpp token") + } + + var vppTokenData fleet.VPPTokenData + if err := json.Unmarshal(configMap[fleet.MDMAssetVPPToken].Value, &vppTokenData); err != nil { + return "", ctxerr.Wrap(ctx, err, "unmarshaling VPP token data") + } + + vppTokenRawBytes, err := base64.StdEncoding.DecodeString(vppTokenData.Token) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "decoding raw vpp token data") + } + + var vppTokenRaw fleet.VPPTokenRaw + + if err := json.Unmarshal(vppTokenRawBytes, &vppTokenRaw); err != nil { + return "", ctxerr.Wrap(ctx, err, "unmarshaling raw vpp token data") + } + + exp, err := time.Parse("2006-01-02T15:04:05Z0700", vppTokenRaw.ExpDate) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "parsing vpp token expiration date") + } + + if time.Now().After(exp) { + return "", fleet.NewUserMessageError(errors.New("Couldn't install. VPP token expired."), http.StatusUnprocessableEntity) + } + + return vppTokenData.Token, nil +} + +func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, payloads []fleet.VPPBatchPayload, dryRun bool) error { + if teamName == "" { + svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden" + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "must not be empty")) + } + + if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil { + return err + } + + team, err := svc.ds.TeamByName(ctx, teamName) + if err != nil { + // If this is a dry run, the team may not have been created yet + if dryRun && fleet.IsNotFound(err) { + return nil + } + return err + } + + if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: &team.ID}, fleet.ActionWrite); err != nil { + return ctxerr.Wrap(ctx, err, "validating authorization") + } + + var adamIDs []string + + // Don't check for token if we're only disassociating assets + if len(payloads) > 0 { + token, err := svc.getVPPToken(ctx) + if err != nil { + return fleet.NewUserMessageError(ctxerr.Wrap(ctx, err, "could not retrieve vpp token"), http.StatusUnprocessableEntity) + } + + for _, payload := range payloads { + adamIDs = append(adamIDs, payload.AppStoreID) + } + + var missingAssets []string + + assets, err := vpp.GetAssets(token, nil) + if err != nil { + return ctxerr.Wrap(ctx, err, "unable to retrieve assets") + } + + assetMap := map[string]struct{}{} + for _, asset := range assets { + assetMap[asset.AdamID] = struct{}{} + } + + for _, adamID := range adamIDs { + if _, ok := assetMap[adamID]; !ok { + missingAssets = append(missingAssets, adamID) + } + } + + if len(missingAssets) != 0 { + reqErr := ctxerr.Errorf(ctx, "requested app not available on vpp account: %s", strings.Join(missingAssets, ",")) + return fleet.NewUserMessageError(reqErr, http.StatusUnprocessableEntity) + } + } + + if !dryRun { + apps, err := getVPPAppsMetadata(ctx, adamIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "refreshing VPP app metadata") + } + + if err := svc.ds.BatchInsertVPPApps(ctx, apps); err != nil { + return ctxerr.Wrap(ctx, err, "inserting vpp app metadata") + } + + if err := svc.ds.SetTeamVPPApps(ctx, &team.ID, adamIDs); err != nil { + return fleet.NewUserMessageError(ctxerr.Wrap(ctx, err, "set team vpp assets"), http.StatusInternalServerError) + } + } + + return nil +} + +func (svc *Service) GetAppStoreApps(ctx context.Context, teamID *uint) ([]*fleet.VPPApp, error) { + if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionRead); err != nil { + return nil, err + } + + vppToken, err := svc.getVPPToken(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "retrieving VPP token") + } + + assets, err := vpp.GetAssets(vppToken, nil) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "fetching Apple VPP assets") + } + + if len(assets) == 0 { + return []*fleet.VPPApp{}, nil + } + + var adamIDs []string + for _, a := range assets { + adamIDs = append(adamIDs, a.AdamID) + } + + assetMetadata, err := itunes.GetAssetMetadata(adamIDs, &itunes.AssetMetadataFilter{Entity: "desktopSoftware"}) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "fetching VPP asset metadata") + } + + assignedApps, err := svc.ds.GetAssignedVPPApps(ctx, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "retrieving assigned VPP apps") + } + + var apps []*fleet.VPPApp + var appsToUpdate []*fleet.VPPApp + for _, a := range assets { + m, ok := assetMetadata[a.AdamID] + if !ok { + // Then this adam_id belongs to a non-desktop app. + continue + } + + app := &fleet.VPPApp{ + AdamID: a.AdamID, + BundleIdentifier: m.BundleID, + IconURL: m.ArtworkURL, + Name: m.TrackName, + LatestVersion: m.Version, + } + + if _, ok := assignedApps[a.AdamID]; ok { + // Then this is already assigned, so filter it out. + appsToUpdate = append(appsToUpdate, app) + continue + } + + apps = append(apps, app) + } + + if len(appsToUpdate) > 0 { + if err := svc.ds.BatchInsertVPPApps(ctx, appsToUpdate); err != nil { + return nil, ctxerr.Wrap(ctx, err, "updating existing VPP apps") + } + } + + return apps, nil +} + +func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, adamID string) error { + if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionWrite); err != nil { + return err + } + + var teamName string + if teamID != nil { + tm, err := svc.ds.Team(ctx, *teamID) + if fleet.IsNotFound(err) { + return fleet.NewInvalidArgumentError("team_id", fmt.Sprintf("team %d does not exist", *teamID)). + WithStatus(http.StatusNotFound) + } else if err != nil { + return ctxerr.Wrap(ctx, err, "checking if team exists") + } + + teamName = tm.Name + } + + vppToken, err := svc.getVPPToken(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "retrieving VPP token") + } + + assets, err := vpp.GetAssets(vppToken, &vpp.AssetFilter{AdamID: adamID}) + if err != nil { + return ctxerr.Wrap(ctx, err, "retrieving VPP asset") + } + + if len(assets) == 0 { + return ctxerr.New(ctx, fmt.Sprintf("Error: Couldn't add software. %s isn't available in Apple Business Manager. Please purchase license in Apple Business Manager and try again.", adamID)) + } + + asset := assets[0] + + assetMetadata, err := itunes.GetAssetMetadata([]string{asset.AdamID}, &itunes.AssetMetadataFilter{Entity: "desktopSoftware"}) + if err != nil { + return ctxerr.Wrap(ctx, err, "fetching VPP asset metadata") + } + + assetMD := assetMetadata[asset.AdamID] + + // Check if we've already added an installer for this app + exists, err := svc.ds.UploadedSoftwareExists(ctx, assetMD.BundleID, teamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking existence of VPP app installer") + } + + if exists { + return ctxerr.New(ctx, fmt.Sprintf("Error: Couldn't add software. %s already has software available for install on the %s team.", assetMD.TrackName, teamName)) + } + + app := &fleet.VPPApp{ + AdamID: asset.AdamID, + BundleIdentifier: assetMD.BundleID, + IconURL: assetMD.ArtworkURL, + Name: assetMD.TrackName, + LatestVersion: assetMD.Version, + } + if _, err := svc.ds.InsertVPPAppWithTeam(ctx, app, teamID); err != nil { + return ctxerr.Wrap(ctx, err, "writing VPP app to db") + } + + act := fleet.ActivityAddedAppStoreApp{ + AppStoreID: app.AdamID, + TeamName: &teamName, + SoftwareTitle: app.Name, + TeamID: teamID, + } + if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for add app store app") + } + + return nil +} + +func getVPPAppsMetadata(ctx context.Context, adamIDs []string) ([]*fleet.VPPApp, error) { + var apps []*fleet.VPPApp + + assetMetatada, err := itunes.GetAssetMetadata(adamIDs, &itunes.AssetMetadataFilter{Entity: "desktopSoftware"}) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "fetching VPP asset metadata") + } + + for adamID, metadata := range assetMetatada { + app := &fleet.VPPApp{ + AdamID: adamID, + BundleIdentifier: metadata.BundleID, + IconURL: metadata.ArtworkURL, + Name: metadata.TrackName, + LatestVersion: metadata.Version, + } + + apps = append(apps, app) + } + + return apps, nil +} diff --git a/frontend/__mocks__/appleMdm.ts b/frontend/__mocks__/appleMdm.ts index 1ed7964056..f1365546dd 100644 --- a/frontend/__mocks__/appleMdm.ts +++ b/frontend/__mocks__/appleMdm.ts @@ -1,4 +1,5 @@ import { IMdmApple } from "interfaces/mdm"; +import { IGetVppInfoResponse, IVppApp } from "services/entities/mdm_apple"; const DEFAULT_MDM_APPLE_MOCK: IMdmApple = { common_name: "APSP:12345", @@ -13,4 +14,28 @@ export const createMockMdmApple = ( return { ...DEFAULT_MDM_APPLE_MOCK, ...overrides }; }; +const DEFAULT_MDM_APPLE_VPP_INFO_MOCK: IGetVppInfoResponse = { + org_name: "test org", + renew_date: "2024-09-19T00:00:00Z", + location: "test location", +}; + +export const createMockVppInfo = ( + overrides?: Partial +): IGetVppInfoResponse => { + return { ...DEFAULT_MDM_APPLE_VPP_INFO_MOCK, ...overrides }; +}; + +const DEFAULT_MDM_APPLE_VPP_APP_MOCK: IVppApp = { + name: "Test App", + icon_url: "https://via.placeholder.com/512", + latest_version: "1.0", + app_store_id: 1, + added: false, +}; + +export const createMockVppApp = (overrides?: Partial): IVppApp => { + return { ...DEFAULT_MDM_APPLE_VPP_APP_MOCK, ...overrides }; +}; + export default createMockMdmApple; diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index f15c09ce2b..28519b9613 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -1,4 +1,49 @@ -import { IConfig } from "interfaces/config"; +import { IConfig, IMdmConfig } from "interfaces/config"; + +const DEFAULT_CONFIG_MDM_MOCK: IMdmConfig = { + enable_disk_encryption: false, + windows_enabled_and_configured: true, + apple_bm_default_team: "Apples", + apple_bm_enabled_and_configured: true, + apple_bm_terms_expired: false, + enabled_and_configured: true, + macos_updates: { + minimum_version: "", + deadline: "", + }, + macos_settings: { + custom_settings: null, + enable_disk_encryption: false, + }, + macos_setup: { + bootstrap_package: "", + enable_end_user_authentication: false, + macos_setup_assistant: null, + enable_release_device_manually: false, + }, + macos_migration: { + enable: false, + mode: "", + webhook_url: "", + }, + windows_updates: { + deadline_days: null, + grace_period_days: null, + }, + end_user_authentication: { + entity_id: "", + issuer_uri: "", + metadata: "", + metadata_url: "", + idp_name: "", + }, +}; + +export const createMockMdmConfig = ( + overrides?: Partial +): IMdmConfig => { + return { ...DEFAULT_CONFIG_MDM_MOCK, ...overrides }; +}; const DEFAULT_CONFIG_MOCK: IConfig = { org_info: { @@ -136,44 +181,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = { enable_software_inventory: true, }, fleet_desktop: { transparency_url: "https://fleetdm.com/transparency" }, - mdm: { - enable_disk_encryption: false, - windows_enabled_and_configured: true, - apple_bm_default_team: "Apples", - apple_bm_enabled_and_configured: true, - apple_bm_terms_expired: false, - enabled_and_configured: true, - macos_updates: { - minimum_version: "", - deadline: "", - }, - macos_settings: { - custom_settings: null, - enable_disk_encryption: false, - }, - macos_setup: { - bootstrap_package: "", - enable_end_user_authentication: false, - macos_setup_assistant: null, - enable_release_device_manually: false, - }, - macos_migration: { - enable: false, - mode: "", - webhook_url: "", - }, - windows_updates: { - deadline_days: null, - grace_period_days: null, - }, - end_user_authentication: { - entity_id: "", - issuer_uri: "", - metadata: "", - metadata_url: "", - idp_name: "", - }, - }, + mdm: createMockMdmConfig(), }; const createMockConfig = (overrides?: Partial): IConfig => { diff --git a/frontend/__mocks__/deviceUserMock.ts b/frontend/__mocks__/deviceUserMock.ts index 38017864fd..fe5c2344cb 100644 --- a/frontend/__mocks__/deviceUserMock.ts +++ b/frontend/__mocks__/deviceUserMock.ts @@ -1,6 +1,7 @@ import { IDeviceUser } from "interfaces/host"; import { IDeviceSoftware } from "interfaces/software"; import { IGetDeviceSoftwareResponse } from "services/entities/device_user"; +import { createMockHostSoftwarePackage } from "./hostMock"; const DEFAULT_DEVICE_USER_MOCK: IDeviceUser = { email: "test@test.com", @@ -16,16 +17,12 @@ const createMockDeviceUser = ( const DEFAULT_DEVICE_SOFTWARE_MOCK: IDeviceSoftware = { id: 1, name: "mock software 1.app", - self_service: false, source: "apps", bundle_identifier: "com.app.mock", status: null, - last_install: null, installed_versions: null, - package: { - name: "mock software 1", - version: "1.0.0", - }, + software_package: createMockHostSoftwarePackage(), + app_store_app: null, }; export const createMockDeviceSoftware = ( diff --git a/frontend/__mocks__/hostMock.ts b/frontend/__mocks__/hostMock.ts index 4c67aebe8f..f6d8b6342e 100644 --- a/frontend/__mocks__/hostMock.ts +++ b/frontend/__mocks__/hostMock.ts @@ -5,7 +5,11 @@ import { pick } from "lodash"; import { normalizeEmptyValues } from "utilities/helpers"; import { HOST_SUMMARY_DATA } from "utilities/constants"; import { IGetHostSoftwareResponse } from "services/entities/hosts"; -import { IHostSoftware } from "interfaces/software"; +import { + IHostAppStoreApp, + IHostSoftware, + IHostSoftwarePackage, +} from "interfaces/software"; const DEFAULT_HOST_PROFILE_MOCK: IHostMdmProfile = { profile_uuid: "123-abc", @@ -136,18 +140,45 @@ export const createMockHostSummary = (overrides?: Partial) => { ); }; -const DEFAULT_HOST_SOFTWARE_MOCK: IHostSoftware = { - id: 1, +const DEFAULT_HOST_SOFTWARE_PACKAGE_MOCK: IHostSoftwarePackage = { name: "mock software.app", - package_available_for_install: "mockSoftware.app", + version: "1.0.0", self_service: false, - source: "apps", - bundle_identifier: "com.test.mock", - status: "installed", + icon_url: "https://example.com/icon.png", last_install: { install_uuid: "123-abc", installed_at: "2022-01-01T12:00:00Z", }, +}; + +export const createMockHostSoftwarePackage = ( + overrides?: Partial +): IHostSoftwarePackage => { + return { ...DEFAULT_HOST_SOFTWARE_PACKAGE_MOCK, ...overrides }; +}; + +const DEFAULT_HOST_APP_STORE_APP_MOCK: IHostAppStoreApp = { + app_store_id: "123456789", + version: "1.0.0", + self_service: false, + icon_url: "https://via.placeholder.com/512", + last_install: null, +}; + +export const createMockHostAppStoreApp = ( + overrides?: Partial +): IHostAppStoreApp => { + return { ...DEFAULT_HOST_APP_STORE_APP_MOCK, ...overrides }; +}; + +const DEFAULT_HOST_SOFTWARE_MOCK: IHostSoftware = { + id: 1, + name: "mock software.app", + software_package: createMockHostSoftwarePackage(), + app_store_app: null, + source: "apps", + bundle_identifier: "com.test.mock", + status: "installed", installed_versions: [ { version: "1.0.0", diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index eb1f67f638..2846b18dde 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -1,11 +1,12 @@ import { ISoftware, ISoftwareVersion, - ISoftwareTitleWithPackageDetail, - ISoftwareTitleWithPackageName, ISoftwareVulnerability, ISoftwareTitleVersion, ISoftwarePackage, + ISoftwareTitle, + ISoftwareTitleDetails, + IAppStoreApp, } from "interfaces/software"; import { ISoftwareTitlesResponse, @@ -44,53 +45,6 @@ export const createMockSoftwareTitleVersion = ( return { ...DEFAULT_SOFTWARE_TITLE_VERSION_MOCK, ...overrides }; }; -type MockSoftwareTitle = - | Partial - | Partial; - -const DEFAULT_SOFTWARE_TITLE_MOCK = { - id: 1, - name: "mock software 1.app", - software_package: null, - versions_count: 1, - source: "apps", - hosts_count: 1, - browser: "chrome", - versions: [createMockSoftwareTitleVersion()], -}; - -export const createMockSoftwareTitle = < - T extends - | Partial - | Partial ->( - overrides: T -) => { - const mock = { - ...DEFAULT_SOFTWARE_TITLE_MOCK, - ...overrides, - }; - return mock; -}; - -const DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK: ISoftwareTitlesResponse = { - counts_updated_at: "2020-01-01T00:00:00.000Z", - count: 1, - software_titles: [ - createMockSoftwareTitle({ software_package: null, self_service: false }), - ], - meta: { - has_next_results: false, - has_previous_results: false, - }, -}; - -export const createMockSoftwareTitlesReponse = ( - overrides?: Partial -): ISoftwareTitlesResponse => { - return { ...DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK, ...overrides }; -}; - const DEFAULT_SOFTWARE_VULNERABILITY_MOCK = { cve: "CVE-2020-0001", details_link: "https://test.com", @@ -145,17 +99,48 @@ export const createMockSoftwareVersionsReponse = ( return { ...DEFAULT_SOFTWARE_VERSIONS_RESPONSE_MOCK, ...overrides }; }; -const DEFAULT_SOFTWARE_TITLE_RESPONSE = { - software_title: createMockSoftwareTitle({ - software_package: null, - } as Partial), +const DEFAULT_APP_STORE_APP_MOCK: IAppStoreApp = { + name: "test app", + app_store_id: 1, + icon_url: "https://via.placeholder.com/512", + latest_version: "1.2.3", + status: { + installed: 1, + pending: 2, + failed: 3, + }, +}; + +export const createMockAppStoreApp = (overrides?: Partial) => { + return { ...DEFAULT_APP_STORE_APP_MOCK, ...overrides }; +}; + +const DEFAULT_SOFTWARE_TITLE_DETAILS_MOCK: ISoftwareTitleDetails = { + id: 1, + name: "test.app", + software_package: null, + app_store_app: null, + source: "test_package", + hosts_count: 1, + versions: [createMockSoftwareTitleVersion()], + bundle_identifier: "com.test.Desktop", + versions_count: 1, +}; + +export const createMockSoftwareTitleDetails = ( + overrides?: Partial +) => { + return { ...DEFAULT_SOFTWARE_TITLE_DETAILS_MOCK, ...overrides }; +}; + +const DEFAULT_SOFTWARE_TITLE_RESPONSE: ISoftwareTitleResponse = { + software_title: createMockSoftwareTitleDetails(), }; export const createMockSoftwareTitleResponse = ( - overrides: Partial = {} + overrides?: Partial ): ISoftwareTitleResponse => { - const mock = DEFAULT_SOFTWARE_TITLE_RESPONSE.software_title; - return { software_title: { ...mock, ...overrides } }; + return { ...DEFAULT_SOFTWARE_TITLE_RESPONSE, ...overrides }; }; const DEFAULT_SOFTWARE_VERSION_RESPONSE = { @@ -168,7 +153,7 @@ export const createMockSoftwareVersionResponse = ( return { ...DEFAULT_SOFTWARE_VERSION_RESPONSE, ...overrides }; }; -const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = { +const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = { name: "TestPackage-1.2.3.pkg", version: "1.2.3", uploaded_at: "2020-01-01T00:00:00.000Z", @@ -177,6 +162,7 @@ const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = { post_install_script: "sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123", self_service: false, + icon_url: null, status: { installed: 1, pending: 2, @@ -187,5 +173,42 @@ const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = { export const createMockSoftwarePackage = ( overrides?: Partial ) => { - return { ...DEFAULT_SOFTWAREPACKAGE_MOCK, ...overrides }; + return { ...DEFAULT_SOFTWARE_PACKAGE_MOCK, ...overrides }; +}; + +const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = { + id: 1, + name: "mock software 1.app", + versions_count: 1, + source: "apps", + hosts_count: 1, + browser: "chrome", + versions: [createMockSoftwareTitleVersion()], + software_package: createMockSoftwarePackage(), + app_store_app: null, +}; + +export const createMockSoftwareTitle = ( + overrides?: Partial +): ISoftwareTitle => { + return { + ...DEFAULT_SOFTWARE_TITLE_MOCK, + ...overrides, + }; +}; + +const DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK: ISoftwareTitlesResponse = { + counts_updated_at: "2020-01-01T00:00:00.000Z", + count: 1, + software_titles: [createMockSoftwareTitle()], + meta: { + has_next_results: false, + has_previous_results: false, + }, +}; + +export const createMockSoftwareTitlesReponse = ( + overrides?: Partial +): ISoftwareTitlesResponse => { + return { ...DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK, ...overrides }; }; diff --git a/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx new file mode 100644 index 0000000000..c4991f0fd7 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx @@ -0,0 +1,151 @@ +import React from "react"; +import { useQuery } from "react-query"; + +import { SoftwareInstallStatus } from "interfaces/software"; +import mdmApi from "services/entities/mdm"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; +import Textarea from "components/Textarea"; +import DataError from "components/DataError/DataError"; +import Spinner from "components/Spinner/Spinner"; +import { IMdmCommandResult } from "interfaces/mdm"; +import { IActivityDetails } from "interfaces/activity"; + +import { IconNames } from "components/icons"; +import { + getInstallDetailsStatusPredicate, + INSTALL_DETAILS_STATUS_ICONS, +} from "../constants"; + +const baseClass = "app-install-details"; + +export type IAppInstallDetails = Pick< + IActivityDetails, + | "host_id" + | "command_uuid" + | "host_display_name" + | "software_title" + | "app_store_id" + | "status" +>; + +export const AppInstallDetails = ({ + status, + command_uuid = "", + host_display_name = "", + software_title = "", +}: IAppInstallDetails) => { + const { data: result, isLoading, isError } = useQuery< + IMdmCommandResult, + Error + >( + ["mdm_command_results", command_uuid], + async () => { + return mdmApi.getCommandResults(command_uuid).then((response) => { + const results = response.results?.[0]; + if (!results) { + return Promise.reject(new Error("No data returned")); + } + return { + ...results, + payload: atob(results.payload), + result: atob(results.result), + }; + }); + }, + { + refetchOnWindowFocus: false, + staleTime: 3000, + } + ); + + if (isLoading) { + return ; + } else if (isError) { + return ; + } else if (!result) { + // FIXME: Find a better solution for this. + return ; + } + + // Note: We need to reconcile status values from two different sources. From props, we + // get the status from the activity item details (which can be "failed", "pending", or + // "installed"). From the command results API response, we also receive the raw status + // from the MDM protocol, e.g., "NotNow" or "Acknowledged". We need to display some special + // messaging for the "NotNow" status, which otherwise would be treated as "pending". + const isStatusNotNow = result.status === "NotNow"; + let iconName: IconNames; + let predicate: string; + let subordinate: string; + if (isStatusNotNow) { + iconName = INSTALL_DETAILS_STATUS_ICONS.pending; + predicate = "tried to install"; + subordinate = + " but couldn’t because the host was locked or was running on battery power while in Power Nap. Fleet will try again"; + } else { + iconName = INSTALL_DETAILS_STATUS_ICONS[status as SoftwareInstallStatus]; + predicate = getInstallDetailsStatusPredicate(status); + subordinate = status === "pending" ? " when it comes online" : ""; + } + + const showCommandResponse = isStatusNotNow || status !== "pending"; + + return ( + <> +
+
+ {!!iconName && } + + Fleet {predicate} {software_title} on{" "} + {host_display_name} + {subordinate}. + +
+
+ Request payload: + +
+ {showCommandResponse && ( +
+ The response from {host_display_name}: + +
+ )} +
+ + ); +}; + +export const AppInstallDetailsModal = ({ + details, + onCancel, +}: { + details: IAppInstallDetails; + onCancel: () => void; +}) => { + return ( + + <> +
+ +
+
+ +
+ +
+ ); +}; diff --git a/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/_styles.scss b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/_styles.scss new file mode 100644 index 0000000000..2de4b0386e --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/_styles.scss @@ -0,0 +1,27 @@ +.app-install-details { + .modal__content { + margin-top: $pad-xlarge; + } + &__status-message { + display: flex; + align-items: center; + gap: $pad-small; + margin: 0; + .icon { + padding-top: 3px; + align-self: flex-start; + } + } + &__script-output { + padding-top: $pad-xlarge; + .textarea { + margin-top: $pad-medium; + overflow-wrap: break-word; + } + } + + &__output-textarea { + max-height: 300px; + overflow-y: auto; + } +} diff --git a/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/index.ts b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/index.ts new file mode 100644 index 0000000000..509d370bb9 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/index.ts @@ -0,0 +1 @@ +export { AppInstallDetails, AppInstallDetailsModal } from "./AppInstallDetails"; diff --git a/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/SoftwareInstallDetails.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx similarity index 78% rename from frontend/pages/SoftwarePage/components/SoftwareInstallDetails/SoftwareInstallDetails.tsx rename to frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx index ca735dcf5a..27480cd8e5 100644 --- a/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/SoftwareInstallDetails.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx @@ -4,7 +4,6 @@ import { useQuery } from "react-query"; import { ISoftwareInstallResult, ISoftwareInstallResults, - SoftwareInstallStatus, } from "interfaces/software"; import softwareAPI from "services/entities/software"; @@ -14,22 +13,14 @@ import Icon from "components/Icon"; import Textarea from "components/Textarea"; import DataError from "components/DataError/DataError"; import Spinner from "components/Spinner/Spinner"; -import { IconNames } from "components/icons"; +import { + INSTALL_DETAILS_STATUS_ICONS, + SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS, + getInstallDetailsStatusPredicate, +} from "../constants"; const baseClass = "software-install-details"; -const STATUS_ICONS: Record = { - pending: "pending-outline", - installed: "success-outline", - failed: "error-outline", -} as const; - -const STATUS_PREDICATES: Record = { - pending: "will install", - installed: "installed", - failed: "failed to install", -} as const; - const StatusMessage = ({ result: { host_display_name, software_package, software_title, status }, }: { @@ -37,32 +28,26 @@ const StatusMessage = ({ }) => { return (
- + - Fleet {STATUS_PREDICATES[status]} {software_title} ( - {software_package}) on {host_display_name} + Fleet {getInstallDetailsStatusPredicate(status)} {software_title}{" "} + ({software_package}) on {host_display_name} {status === "pending" ? " when it comes online" : ""}.
); }; -const OUTPUT_DISPLAY_LABELS = { - pre_install_query_output: "Pre-install condition", - output: "Software install output", - post_install_script_output: "Post-install script output", -} as const; - const Output = ({ displayKey, result, }: { - displayKey: keyof typeof OUTPUT_DISPLAY_LABELS; + displayKey: keyof typeof SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS; result: ISoftwareInstallResult; }) => { return (
- {OUTPUT_DISPLAY_LABELS[displayKey]}: + {SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS[displayKey]}: diff --git a/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/_styles.scss b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/_styles.scss similarity index 82% rename from frontend/pages/SoftwarePage/components/SoftwareInstallDetails/_styles.scss rename to frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/_styles.scss index 5e8df96d67..8cdc2ef600 100644 --- a/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/_styles.scss +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/_styles.scss @@ -7,6 +7,10 @@ align-items: center; gap: $pad-small; margin: 0; + .icon { + padding-top: 3px; + align-self: flex-start; + } } &__script-output { padding-top: $pad-xlarge; diff --git a/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/index.ts b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/index.ts similarity index 100% rename from frontend/pages/SoftwarePage/components/SoftwareInstallDetails/index.ts rename to frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/index.ts diff --git a/frontend/components/ActivityDetails/InstallDetails/constants.ts b/frontend/components/ActivityDetails/InstallDetails/constants.ts new file mode 100644 index 0000000000..b3cd75cf79 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/constants.ts @@ -0,0 +1,39 @@ +import { IconNames } from "components/icons"; +import { SoftwareInstallStatus } from "interfaces/software"; + +export const INSTALL_DETAILS_STATUS_ICONS: Record< + SoftwareInstallStatus, + IconNames +> = { + pending: "pending-outline", + installed: "success-outline", + failed: "error-outline", +} as const; + +const INSTALL_DETAILS_STATUS_PREDICATES: Record< + SoftwareInstallStatus, + string +> = { + pending: "will install", + installed: "installed", + failed: "failed to install", +} as const; + +export const getInstallDetailsStatusPredicate = ( + status: string | undefined +) => { + if (!status) { + return INSTALL_DETAILS_STATUS_PREDICATES.pending; + } + return ( + INSTALL_DETAILS_STATUS_PREDICATES[ + status.toLowerCase() as SoftwareInstallStatus + ] || INSTALL_DETAILS_STATUS_PREDICATES.pending + ); +}; + +export const SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS = { + pre_install_query_output: "Pre-install condition", + output: "Software install output", + post_install_script_output: "Post-install script output", +} as const; diff --git a/frontend/components/FileUploader/FileUploader.tsx b/frontend/components/FileUploader/FileUploader.tsx index 9c22930f44..a79955df28 100644 --- a/frontend/components/FileUploader/FileUploader.tsx +++ b/frontend/components/FileUploader/FileUploader.tsx @@ -20,6 +20,7 @@ type ISupportedGraphicNames = Extract< | "file-pkg" | "file-p7m" | "file-pem" + | "file-vpp" >; export const FileDetails = ({ diff --git a/frontend/components/FileUploader/_styles.scss b/frontend/components/FileUploader/_styles.scss index 2862466e17..b94ca3ef7c 100644 --- a/frontend/components/FileUploader/_styles.scss +++ b/frontend/components/FileUploader/_styles.scss @@ -2,7 +2,7 @@ display: flex; flex-direction: column; align-items: center; - border-radius: $border-radius; + border-radius: $border-radius-medium; background-color: $ui-fleet-blue-10; border: 1px solid $ui-fleet-black-10; padding: $pad-xlarge $pad-large; @@ -43,6 +43,7 @@ } &__message { margin: 0; + color: $ui-fleet-black-75; } &__additional-info { diff --git a/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx b/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx index 38fc05db82..d8301b9b8b 100644 --- a/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx +++ b/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx @@ -61,6 +61,7 @@ interface ISoftwareNameCellProps { router?: InjectedRouter; hasPackage?: boolean; isSelfService?: boolean; + iconUrl?: string; } const SoftwareNameCell = ({ @@ -70,6 +71,7 @@ const SoftwareNameCell = ({ router, hasPackage = false, isSelfService = false, + iconUrl, }: ISoftwareNameCellProps) => { // NO path or router means it's not clickable. return // a non-clickable cell early @@ -95,7 +97,7 @@ const SoftwareNameCell = ({ customOnClick={onClickSoftware} value={ <> - + {name} {hasPackage && ( diff --git a/frontend/components/forms/fields/Radio/Radio.tsx b/frontend/components/forms/fields/Radio/Radio.tsx index cf38349bac..de8a153df5 100644 --- a/frontend/components/forms/fields/Radio/Radio.tsx +++ b/frontend/components/forms/fields/Radio/Radio.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { ReactNode } from "react"; import classnames from "classnames"; import TooltipWrapper from "components/TooltipWrapper"; @@ -6,7 +6,7 @@ import TooltipWrapper from "components/TooltipWrapper"; const baseClass = "radio"; export interface IRadioProps { - label: string; + label: ReactNode; value: string; id: string; onChange: (value: string) => void; diff --git a/frontend/components/graphics/FileVpp.tsx b/frontend/components/graphics/FileVpp.tsx new file mode 100644 index 0000000000..35b0abaa5e --- /dev/null +++ b/frontend/components/graphics/FileVpp.tsx @@ -0,0 +1,59 @@ +import React from "react"; + +const FileVpp = () => { + return ( + + + + + + + + + + + + + + + + + ); +}; + +export default FileVpp; diff --git a/frontend/components/graphics/index.ts b/frontend/components/graphics/index.ts index 84e10a3711..cfcafe4562 100644 --- a/frontend/components/graphics/index.ts +++ b/frontend/components/graphics/index.ts @@ -12,6 +12,7 @@ import FilePdf from "./FilePdf"; import FilePkg from "./FilePkg"; import FileP7m from "./FileP7m"; import FilePem from "./FilePem"; +import FileVpp from "./FileVpp"; import EmptyHosts from "./EmptyHosts"; import EmptyTeams from "./EmptyTeams"; import EmptyPacks from "./EmptyPacks"; @@ -39,6 +40,7 @@ export const GRAPHIC_MAP = { "file-pkg": FilePkg, "file-p7m": FileP7m, "file-pem": FilePem, + "file-vpp": FileVpp, // Other graphics "collecting-results": CollectingResults, }; diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 2ca026aa47..127945310b 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -77,6 +77,11 @@ export enum ActivityType { AddedSoftware = "added_software", DeletedSoftware = "deleted_software", InstalledSoftware = "installed_software", + EnabledVpp = "enabled_vpp", + DisabledVpp = "disabled_vpp", + AddedAppStoreApp = "added_app_store_app", + DeletedAppStoreApp = "deleted_app_store_app", + InstalledAppStoreApp = "installed_app_store_app", } // This is a subset of ActivityType that are shown only for the host past activities @@ -84,12 +89,14 @@ export type IHostPastActivityType = | ActivityType.RanScript | ActivityType.LockedHost | ActivityType.UnlockedHost - | ActivityType.InstalledSoftware; + | ActivityType.InstalledSoftware + | ActivityType.InstalledAppStoreApp; // This is a subset of ActivityType that are shown only for the host upcoming activities export type IHostUpcomingActivityType = | ActivityType.RanScript - | ActivityType.InstalledSoftware; + | ActivityType.InstalledSoftware + | ActivityType.InstalledAppStoreApp; export interface IActivity { created_at: string; @@ -156,4 +163,6 @@ export interface IActivityDetails { status?: string; install_uuid?: string; self_service?: boolean; + command_uuid?: string; + app_store_id?: number; } diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index f52cfd3925..e4118b83d1 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -37,6 +37,9 @@ interface ICustomSetting { export interface IMdmConfig { enable_disk_encryption: boolean; + /** `enabled_and_configured` only tells us if Apples MDM has been enabled and + configured correctly. The naming is slightly confusing but at one point we + only supported apple mdm, so thats why it's name the way it is. */ enabled_and_configured: boolean; apple_bm_default_team?: string; apple_bm_terms_expired: boolean; diff --git a/frontend/interfaces/mdm.ts b/frontend/interfaces/mdm.ts index 3a75fc8258..496cc1c8b9 100644 --- a/frontend/interfaces/mdm.ts +++ b/frontend/interfaces/mdm.ts @@ -160,3 +160,22 @@ export enum BootstrapPackageStatus { PENDING = "pending", FAILED = "failed", } + +/** + * IMdmCommandResult is the shape of an mdm command result object + * returned by the Fleet API. + */ +export interface IMdmCommandResult { + host_uuid: string; + command_uuid: string; + /** Status is the status of the command. It can be one of Acknowledged, Error, or NotNow for + // Apple, or 200, 400, etc for Windows. */ + status: string; + updated_at: string; + request_type: string; + hostname: string; + /** Payload is a base64-encoded string containing the MDM command request */ + payload: string; + /** Result is a base64-enconded string containing the MDM command response */ + result: string; +} diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 91e45130bc..cedd6a67db 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -1,5 +1,8 @@ import { startCase } from "lodash"; import PropTypes from "prop-types"; + +import { IconNames } from "components/icons"; + import vulnerabilityInterface from "./vulnerability"; export default PropTypes.shape({ @@ -58,6 +61,7 @@ export interface ISoftwarePackage { pre_install_query?: string; post_install_script?: string; self_service: boolean; + icon_url: string | null; status: { installed: number; pending: number; @@ -65,28 +69,46 @@ export interface ISoftwarePackage { }; } -interface ISoftwareTitle { +export const isSoftwarePackage = ( + data: ISoftwarePackage | IAppStoreApp +): data is ISoftwarePackage => + (data as ISoftwarePackage).install_script !== undefined; + +export interface IAppStoreApp { + name: string; + app_store_id: number; + latest_version: string; + icon_url: string; + status: { + installed: number; + pending: number; + failed: number; + }; +} + +export interface ISoftwareTitle { id: number; name: string; - software_package: ISoftwarePackage | string | null; versions_count: number; source: string; hosts_count: number; versions: ISoftwareTitleVersion[] | null; - browser: string; - self_service?: boolean; -} - -export interface ISoftwareTitleWithPackageName - extends Omit { - software_package: string | null; - self_service: boolean; -} - -export interface ISoftwareTitleWithPackageDetail - extends Omit { software_package: ISoftwarePackage | null; - self_service?: never; + app_store_app: IAppStoreApp | null; + browser?: string; +} + +export interface ISoftwareTitleDetails { + id: number; + name: string; + software_package: ISoftwarePackage | null; + app_store_app: IAppStoreApp | null; + source: string; + hosts_count: number; + versions: ISoftwareTitleVersion[] | null; + bundle_identifier?: string; + browser?: string; + versions_count?: number; } export interface ISoftwareVulnerability { @@ -213,6 +235,11 @@ export interface ISoftwareLastInstall { installed_at: string; } +export interface IAppLastInstall { + command_uuid: string; + installed_at: string; +} + export interface ISoftwareInstallVersion { version: string; last_opened_at: string | null; @@ -220,24 +247,77 @@ export interface ISoftwareInstallVersion { installed_paths: string[]; } +export interface IHostSoftwarePackage { + name: string; + self_service: boolean; + icon_url: string; + version: string; + last_install: ISoftwareLastInstall | null; +} + +export interface IHostAppStoreApp { + app_store_id: string; + self_service: boolean; + icon_url: string; + version: string; + last_install: IAppLastInstall | null; +} + export interface IHostSoftware { id: number; name: string; - package_available_for_install?: string | null; - self_service: boolean; + software_package: IHostSoftwarePackage | null; + app_store_app: IHostAppStoreApp | null; source: string; bundle_identifier?: string; status: SoftwareInstallStatus | null; - last_install: ISoftwareLastInstall | null; installed_versions: ISoftwareInstallVersion[] | null; } -export type IDeviceSoftware = Omit< - IHostSoftware, - "package_available_for_install" -> & { - package: { - name: string; - version: string; - }; +export type IDeviceSoftware = IHostSoftware; + +const INSTALL_STATUS_PREDICATES: Record = { + failed: "failed to install", + installed: "installed", + pending: "told Fleet to install", +} as const; + +export const getInstallStatusPredicate = (status: string | undefined) => { + if (!status) { + return INSTALL_STATUS_PREDICATES.pending; + } + return ( + INSTALL_STATUS_PREDICATES[status.toLowerCase() as SoftwareInstallStatus] || + INSTALL_STATUS_PREDICATES.pending + ); +}; + +export const INSTALL_STATUS_ICONS: Record = { + pending: "pending-outline", + installed: "success-outline", + failed: "error-outline", +} as const; + +type IHostSoftwarePackageWithLastInstall = IHostSoftwarePackage & { + last_install: ISoftwareLastInstall; +}; + +export const hasHostSoftwarePackageLastInstall = ( + software: IHostSoftware +): software is IHostSoftware & { + software_package: IHostSoftwarePackageWithLastInstall; +} => { + return !!software.software_package?.last_install; +}; + +type IHostAppWithLastInstall = IHostAppStoreApp & { + last_install: IAppLastInstall; +}; + +export const hasHostSoftwareAppLastInstall = ( + software: IHostSoftware +): software is IHostSoftware & { + app_store_app: IHostAppWithLastInstall; +} => { + return !!software.app_store_app?.last_install; }; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx index b51946d0f3..3a5d520d27 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx @@ -16,7 +16,8 @@ import Spinner from "components/Spinner"; // @ts-ignore import FleetIcon from "components/icons/FleetIcon"; -import { SoftwareInstallDetailsModal } from "pages/SoftwarePage/components/SoftwareInstallDetails"; +import { AppInstallDetailsModal } from "components/ActivityDetails/InstallDetails/AppInstallDetails"; +import { SoftwareInstallDetailsModal } from "components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails"; import ActivityItem from "./ActivityItem"; import ScriptDetailsModal from "./components/ScriptDetailsModal/ScriptDetailsModal"; @@ -37,6 +38,10 @@ const ActivityFeed = ({ const [showShowQueryModal, setShowShowQueryModal] = useState(false); const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false); const [installedSoftwareUuid, setInstalledSoftwareUuid] = useState(""); + const [ + appInstallDetails, + setAppInstallDetails, + ] = useState(null); const queryShown = useRef(""); const queryImpact = useRef(undefined); const scriptExecutionId = useRef(""); @@ -97,10 +102,11 @@ const ActivityFeed = ({ setShowScriptDetailsModal(true); break; case ActivityType.InstalledSoftware: - // installUuid.current = details.install_uuid ?? ""; - // console.log("installUuid.current", installUuid.current); setInstalledSoftwareUuid(details.install_uuid ?? ""); break; + case ActivityType.InstalledAppStoreApp: + setAppInstallDetails(details); + break; default: break; } @@ -197,6 +203,12 @@ const ActivityFeed = ({ onCancel={() => setInstalledSoftwareUuid("")} /> )} + {appInstallDetails && ( + setAppInstallDetails(null)} + /> + )}
); }; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index 387fc30dde..18530b33f5 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -3,6 +3,7 @@ import { find, lowerCase, noop } from "lodash"; import { formatDistanceToNowStrict } from "date-fns"; import { ActivityType, IActivity, IActivityDetails } from "interfaces/activity"; +import { getInstallStatusPredicate } from "interfaces/software"; import { addGravatarUrlToResource, formatScriptNameForActivityItem, @@ -16,7 +17,6 @@ import Icon from "components/Icon"; import ReactTooltip from "react-tooltip"; import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip"; import { COLORS } from "styles/var/colors"; -import { getSoftwareInstallStatusPredicate } from "pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem"; const baseClass = "activity-item"; @@ -795,7 +795,7 @@ const TAGGED_TEMPLATES = { <> {" "} added {activity.details?.software_title} ( - {activity.details?.software_package}) software to{" "} + {activity.details?.software_package}) to{" "} {activity.details?.team_name ? ( <> {" "} @@ -812,7 +812,7 @@ const TAGGED_TEMPLATES = { <> {" "} deleted {activity.details?.software_title} ( - {activity.details?.software_package}) software from{" "} + {activity.details?.software_package}) from{" "} {activity.details?.team_name ? ( <> {" "} @@ -837,20 +837,22 @@ const TAGGED_TEMPLATES = { host_display_name: hostName, software_title: title, status, - install_uuid, } = details; + const showSoftwarePackage = + !!details.software_package && + activity.type === ActivityType.InstalledSoftware; + return ( <> {" "} - {getSoftwareInstallStatusPredicate(status)} {title} software on{" "} + {getInstallStatusPredicate(status)} {title} + {showSoftwarePackage && ` (${details.software_package})`} on{" "} {hostName}.{" "} + + + + ); +}; + +export default AppStoreVpp; diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss b/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss new file mode 100644 index 0000000000..7c0ac47565 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss @@ -0,0 +1,66 @@ +.app-store-vpp { + margin-top: $pad-large; + + &__description { + margin: $pad-medium 0; + } + + &__list-container { + border: 1px solid $ui-fleet-black-10; + border-radius: $border-radius-medium; + } + + &__list { + list-style: none; + margin: 0; + padding: 0; + } + + &__list-item { + padding: $pad-small $pad-medium; + border-bottom: 1px solid $ui-fleet-black-10; + + &:last-child { + border-bottom: none; + } + } + + &__app-info { + display: flex; + align-items: center; + gap: $pad-small; + } + + &__no-software { + padding: $pad-xxlarge 48px; + text-align: center; + font-size: $x-small; + } + + &__no-software-title { + font-weight: $bold; + margin: 0; + } + + &__no-software-description { + margin-top: $pad-small; + color: $ui-fleet-black-75; + } + + &__error { + margin: $pad-xxlarge 0; + } + + &__enable-vpp { + display: flex; + flex-direction: column; + min-height: 149px; + align-items: center; + justify-content: center; + gap: $pad-small; + + p { + margin: 0; + } + } +} diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/helpers.tsx b/frontend/pages/SoftwarePage/components/AppStoreVpp/helpers.tsx new file mode 100644 index 0000000000..b32495cbb8 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/helpers.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { getErrorReason } from "interfaces/errors"; + +const ADD_SOFTWARE_ERROR_PREFIX = "Couldn’t add software."; +const DEFAULT_ERROR_MESSAGE = `${ADD_SOFTWARE_ERROR_PREFIX} Please try again.`; + +const generateAlreadyAvailableMessage = (msg: string) => { + const regex = new RegExp( + `${ADD_SOFTWARE_ERROR_PREFIX} (.+) already.+on the (.+) team.` + ); + + const match = msg.match(regex); + if (!match) return DEFAULT_ERROR_MESSAGE; + + return ( + <> + {ADD_SOFTWARE_ERROR_PREFIX} {match[1]} already has software + available for install on the {match[2]} team.{" "} + + ); +}; + +// eslint-disable-next-line import/prefer-default-export +export const getErrorMessage = (e: unknown) => { + const reason = getErrorReason(e); + + // software is already available for install + if (reason.toLowerCase().includes("already")) { + return generateAlreadyAvailableMessage(reason); + } + return DEFAULT_ERROR_MESSAGE; +}; diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/index.ts b/frontend/pages/SoftwarePage/components/AppStoreVpp/index.ts new file mode 100644 index 0000000000..b0dbd383b2 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/index.ts @@ -0,0 +1 @@ +export { default } from "./AppStoreVpp"; diff --git a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx index bf18d8c47d..2dd8c25dc1 100644 --- a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx +++ b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx @@ -24,6 +24,7 @@ interface ISoftwareDetailsSummaryProps { name?: string; source?: string; versions?: number; + iconUrl?: string; } const SoftwareDetailsSummary = ({ @@ -34,10 +35,11 @@ const SoftwareDetailsSummary = ({ name, source, versions, + iconUrl, }: ISoftwareDetailsSummaryProps) => { return (
- +

{title}

diff --git a/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx b/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx index 8452a0f92b..3143eb32ba 100644 --- a/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx +++ b/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx @@ -5,7 +5,7 @@ import TooltipWrapper from "components/TooltipWrapper"; const generateText = (versions: T[] | null) => { if (!versions) { - return ; + return ; } const text = versions.length !== 1 ? `${versions.length} versions` : versions[0].version; diff --git a/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx index 2fd45edfbd..be607717e7 100644 --- a/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx +++ b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx @@ -12,15 +12,13 @@ const baseClass = "vulnerabilities-cell"; const generateCell = ( vulnerabilities: ISoftwareVulnerability[] | string[] | null ) => { - if (vulnerabilities === null) { - return ; + if (vulnerabilities === null || vulnerabilities.length === 0) { + return ; } let text = ""; let italicize = true; - if (vulnerabilities.length === 0) { - text = "---"; - } else if (vulnerabilities.length === 1) { + if (vulnerabilities.length === 1) { italicize = false; text = typeof vulnerabilities[0] === "string" diff --git a/frontend/pages/SoftwarePage/components/icons/AppStore.tsx b/frontend/pages/SoftwarePage/components/icons/AppStore.tsx new file mode 100644 index 0000000000..42632e584a --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/AppStore.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const AppStore = (props: SVGProps) => ( + + + + +); +export default AppStore; diff --git a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx index 2e93fc9272..e7c29fec98 100644 --- a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx +++ b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx @@ -1,34 +1,57 @@ import React from "react"; +import classnames from "classnames"; + import getMatchedSoftwareIcon from "../"; const baseClass = "software-icon"; +type SoftwareIconSizes = "small" | "medium" | "large" | "xlarge"; + interface ISoftwareIconProps { name?: string; source?: string; size?: SoftwareIconSizes; + /** Accepts an image url to display for a the software icon image. */ + url?: string; } -const SOFTWARE_ICON_SIZES: Record = { - medium: "24", - meduim_large: "64", // TODO: rename this to large and update large to xlarge - large: "96", -} as const; - -type SoftwareIconSizes = keyof typeof SOFTWARE_ICON_SIZES; +const SOFTWARE_ICON_SIZES: Record = { + small: "24", + medium: "40", + large: "64", + xlarge: "96", +}; const SoftwareIcon = ({ name = "", source = "", - size = "medium", + size = "small", + url, }: ISoftwareIconProps) => { + const classNames = classnames(baseClass, `${baseClass}__${size}`); + + // If we are given a url to render as the icon, we need to render it + // differently than the svg icons. We will use an img tag instead with the + // src set to the url. + if (url) { + const imgClasses = classnames( + `${baseClass}__software-img`, + `${baseClass}__software-img-${size}` + ); + return ( +
+ +
+ ); + } + const MatchedIcon = getMatchedSoftwareIcon({ name, source }); return ( ); }; diff --git a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss index 328a564a1e..a94db1daa5 100644 --- a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss +++ b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss @@ -1,5 +1,44 @@ .software-icon { flex-shrink: 0; border: 1px solid $ui-fleet-black-10; - border-radius: 8px; + + &__small { + border-radius: $border-radius; + } + + &__medium { + border-radius: $border-radius-xlarge; + } + + &__large { + border-radius: $border-radius-xxlarge; + } + + &__xlarge { + border-radius: $border-radius-xxlarge; + } + + &__software-img { + display: block; + } + + // we use this selector to give higher specifity than the selector + // ".core-wrapper a img" which is in /styles/global/_styles.scss + // TODO: we should change ".core-wrapper a img" selector in that file + // as it too generic and can affect other parts of the app. + > img.software-icon__software-img-small { + width: 22px; + height: 22px; + border-radius: $border-radius-small; + padding: 1px; + margin-left: 0; + } + + >img.software-icon__software-img-xlarge { + width: 88px; + height: 88px; + border-radius: $border-radius-xxlarge; + padding: $pad-xsmall; + margin-left: 0; + } } diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts index b63ad02086..e77e9e44cb 100644 --- a/frontend/pages/SoftwarePage/components/icons/index.ts +++ b/frontend/pages/SoftwarePage/components/icons/index.ts @@ -20,6 +20,7 @@ import Zoom from "./Zoom"; import ChromeOS from "./ChromeOS"; import LinuxOS from "./LinuxOS"; import Falcon from "./Falcon"; +import AppStore from "./AppStore"; import iOS from "./iOS"; import iPadOS from "./iPadOS"; @@ -33,6 +34,7 @@ const LINUX_OS_NAME_TO_ICON_MAP = HOST_LINUX_PLATFORMS.reduce( // icon for them, keys refer to application names, and are intended to be fuzzy // matched in the application logic. const SOFTWARE_NAME_TO_ICON_MAP = { + appStore: AppStore, "adobe acrobat reader": AcrobatReader, "microsoft excel": Excel, falcon: Falcon, diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx index a3f8734da8..908974e119 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx @@ -2,9 +2,10 @@ import PATHS from "router/paths"; import { ISideNavItem } from "../components/SideNav/SideNav"; import Integrations from "./cards/Integrations"; -import Mdm from "./cards/MdmSettings/MdmSettings"; -import AutomaticEnrollment from "./cards/AutomaticEnrollment/AutomaticEnrollment"; -import Calendars from "./cards/Calendars/Calendars"; +import MdmSettings from "./cards/MdmSettings"; +import AutomaticEnrollment from "./cards/AutomaticEnrollment"; +import Calendars from "./cards/Calendars"; +import Vpp from "./cards/Vpp"; const integrationSettingsNavItems: ISideNavItem[] = [ // TODO: types @@ -18,7 +19,7 @@ const integrationSettingsNavItems: ISideNavItem[] = [ title: "Mobile device management (MDM)", urlSection: "mdm", path: PATHS.ADMIN_INTEGRATIONS_MDM, - Card: Mdm, + Card: MdmSettings, }, { title: "Automatic enrollment", @@ -32,6 +33,12 @@ const integrationSettingsNavItems: ISideNavItem[] = [ path: PATHS.ADMIN_INTEGRATIONS_CALENDARS, Card: Calendars, }, + { + title: "Volume Purchasing Program (VPP)", + urlSection: "vpp", + path: PATHS.ADMIN_INTEGRATIONS_VPP, + Card: Vpp, + }, ]; export default integrationSettingsNavItems; diff --git a/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/AutomaticEnrollment.tsx b/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/AutomaticEnrollment.tsx index 59bfa24030..2040e0fdaa 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/AutomaticEnrollment.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/AutomaticEnrollment.tsx @@ -1,11 +1,10 @@ -import React, { useCallback, useContext, useState } from "react"; +import React, { useContext } from "react"; import { useQuery } from "react-query"; import { AxiosError } from "axios"; import { InjectedRouter } from "react-router"; import PATHS from "router/paths"; import { AppContext } from "context/app"; -import { IConfig } from "interfaces/config"; import { IMdmApple } from "interfaces/mdm"; import mdmAppleAPI from "services/entities/mdm_apple"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/index.ts b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/index.ts new file mode 100644 index 0000000000..06899675d0 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/index.ts @@ -0,0 +1 @@ +export { default } from "./MdmSettings"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tests.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tests.tsx new file mode 100644 index 0000000000..49cb45e2d6 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tests.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { screen } from "@testing-library/react"; + +import { createCustomRenderer, createMockRouter } from "test/test-utils"; +import mockServer from "test/mock-server"; +import { + defaultVppInfoHandler, + errorNoVppInfoHandler, +} from "test/handlers/apple_mdm"; +import createMockConfig, { createMockMdmConfig } from "__mocks__/configMock"; + +import Vpp from "./Vpp"; + +describe("Vpp Section", () => { + it("renders turn on apple mdm message when apple mdm is not turned on ", async () => { + mockServer.use(defaultVppInfoHandler); + + const render = createCustomRenderer({ + context: { + app: { + config: createMockConfig({ + mdm: createMockMdmConfig({ enabled_and_configured: false }), + }), + }, + }, + withBackendMock: true, + }); + + render(); + + expect( + await screen.findByRole("button", { name: "Turn on macOS MDM" }) + ).toBeInTheDocument(); + }); + + it("renders enable vpp when vpp is disabled", async () => { + mockServer.use(errorNoVppInfoHandler); + + const render = createCustomRenderer({ + context: { + app: { + config: createMockConfig({ + mdm: createMockMdmConfig({ enabled_and_configured: true }), + }), + }, + }, + withBackendMock: true, + }); + + render(); + + expect( + await screen.findByRole("button", { name: "Enable" }) + ).toBeInTheDocument(); + }); + + it("renders edit vpp when vpp is enabled", async () => { + mockServer.use(defaultVppInfoHandler); + + const render = createCustomRenderer({ + context: { + app: { + config: createMockConfig({ + mdm: createMockMdmConfig({ enabled_and_configured: true }), + }), + }, + }, + withBackendMock: true, + }); + render(); + expect( + await screen.findByRole("button", { name: "Edit" }) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tsx new file mode 100644 index 0000000000..8813714ea4 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tsx @@ -0,0 +1,135 @@ +import React, { useContext } from "react"; +import { InjectedRouter } from "react-router"; +import { useQuery } from "react-query"; +import { AxiosError } from "axios"; + +import PATHS from "router/paths"; +import { AppContext } from "context/app"; +import mdmAppleAPI, { IGetVppInfoResponse } from "services/entities/mdm_apple"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; + +import Card from "components/Card"; +import SectionHeader from "components/SectionHeader"; +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; +import Spinner from "components/Spinner"; +import DataError from "components/DataError"; + +const baseClass = "vpp"; + +interface IVppCardProps { + isAppleMdmOn: boolean; + isVppOn: boolean; + router: InjectedRouter; +} + +const VppCard = ({ isAppleMdmOn, isVppOn, router }: IVppCardProps) => { + const nagivateToMdm = () => { + router.push(PATHS.ADMIN_INTEGRATIONS_MDM); + }; + + const navigateToVppSetup = () => { + router.push(PATHS.ADMIN_INTEGRATIONS_VPP_SETUP); + }; + + const appleMdmDiabledContent = ( +
+
+

Volume Purchasing Program (VPP)

+

+ To enable Volume Purchasing Program (VPP) for macOS devices, first + turn on macOS MDM. +

+
+ +
+ ); + const isOnContent = ( +
+

+ + + Volume Purchasing Program (VPP) enabled. + +

+ +
+ ); + + const isOffContent = ( +
+
+

Volume Purchasing Program (VPP)

+

+ Install apps from Apple's App Store purchased through Apple + Business Manager. +

+
+ +
+ ); + + const renderCardContent = () => { + if (!isAppleMdmOn) { + return appleMdmDiabledContent; + } + + return isVppOn ? isOnContent : isOffContent; + }; + + return ( + + {renderCardContent()} + + ); +}; + +interface IVppProps { + router: InjectedRouter; +} + +const Vpp = ({ router }: IVppProps) => { + const { config } = useContext(AppContext); + + const { data: vppData, error: vppError, isLoading, isError } = useQuery< + IGetVppInfoResponse, + AxiosError + >("vppInfo", () => mdmAppleAPI.getVppInfo(), { + ...DEFAULT_USE_QUERY_OPTIONS, + retry: false, + }); + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (isError && vppError?.status !== 404) { + return ; + } + + return ( + + ); + }; + + return ( +
+ + <>{renderContent()} +
+ ); +}; + +export default Vpp; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tests.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tests.tsx new file mode 100644 index 0000000000..e2159d9a79 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tests.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { screen } from "@testing-library/react"; + +import { createCustomRenderer, createMockRouter } from "test/test-utils"; +import mockServer from "test/mock-server"; +import { + defaultVppInfoHandler, + errorNoVppInfoHandler, +} from "test/handlers/apple_mdm"; + +import VppSetupPage from "./VppSetupPage"; + +describe("VppSetupPage", () => { + it("renders the VPP setup steps content when VPP is not set up", async () => { + mockServer.use(errorNoVppInfoHandler); + + const render = createCustomRenderer({ + withBackendMock: true, + }); + + render(); + + // This is part of the setup steps content we expect to see. + expect(await screen.findByText(/Sign in to/g)).toBeInTheDocument(); + // This is the upload token UI we expect to see. + expect( + await screen.findByRole("button", { name: "Upload" }) + ).toBeInTheDocument(); + }); + + it("renders the VPP disable and renew content when VPP is set up", async () => { + mockServer.use(defaultVppInfoHandler); + + const render = createCustomRenderer({ + withBackendMock: true, + }); + + render(); + + expect(await screen.findByText("Organization name")).toBeInTheDocument(); + + expect( + await screen.findByRole("button", { name: "Disable" }) + ).toBeInTheDocument(); + expect( + await screen.findByRole("button", { name: "Renew token" }) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx new file mode 100644 index 0000000000..a4289664e6 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx @@ -0,0 +1,190 @@ +import React, { useContext, useState } from "react"; +import { InjectedRouter } from "react-router"; +import { useQuery } from "react-query"; +import { AxiosError } from "axios"; + +import PATHS from "router/paths"; +import { NotificationContext } from "context/notification"; +import { getErrorReason } from "interfaces/errors"; +import mdmAppleAPI, { IGetVppInfoResponse } from "services/entities/mdm_apple"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; +import { readableDate } from "utilities/helpers"; + +import MainContent from "components/MainContent"; +import BackLink from "components/BackLink"; +import FileUploader from "components/FileUploader"; +import DataSet from "components/DataSet"; +import Button from "components/buttons/Button"; +import Spinner from "components/Spinner"; +import DataError from "components/DataError"; + +import DisableVppModal from "./components/DisableVppModal"; +import VppSetupSteps from "./components/VppSetupSteps"; +import RenewVppTokenModal from "./components/RenewVppTokenModal"; + +const baseClass = "vpp-setup-page"; + +interface IVppSetupContentProps { + router: InjectedRouter; +} + +const VPPSetupContent = ({ router }: IVppSetupContentProps) => { + const { renderFlash } = useContext(NotificationContext); + const [isUploading, setIsUploading] = useState(false); + + const uploadToken = async (files: FileList | null) => { + setIsUploading(true); + + const token = files?.[0]; + if (!token) { + setIsUploading(false); + renderFlash("error", "No token selected."); + return; + } + + try { + await mdmAppleAPI.uploadVppToken(token); + renderFlash( + "success", + "Volume Purchasing Program (VPP) integration enabled successfully." + ); + router.push(PATHS.ADMIN_INTEGRATIONS_VPP); + } catch (e) { + // TODO: error messages + const msg = getErrorReason(e, { reasonIncludes: "valid token" }); + if (msg) { + renderFlash("error", msg); + } else { + renderFlash("error", "Couldn't Upload. Please try again."); + } + } finally { + setIsUploading(false); + } + }; + + return ( +
+

+ Connect Fleet to your Apple Business Manager account to enable access to + purchased apps. +

+ + +
+ ); +}; + +interface IVppDisableOrRenewContentProps { + vppInfo: IGetVppInfoResponse; + onDisable: () => void; + onRenew: () => void; +} + +const VPPDisableOrRenewContent = ({ + vppInfo, + onDisable, + onRenew, +}: IVppDisableOrRenewContentProps) => { + return ( +
+
+ + + +
+
+ + +
+
+ ); +}; + +interface IVppSetupPageProps { + router: InjectedRouter; +} + +const VppSetupPage = ({ router }: IVppSetupPageProps) => { + const [showDisableModal, setShowDisableModal] = useState(false); + const [showRenewModal, setShowRenewModal] = useState(false); + + const { + data: vppData, + error: vppError, + isLoading, + isError, + refetch: refetchVppInfo, + } = useQuery( + "vppInfo", + () => mdmAppleAPI.getVppInfo(), + { + ...DEFAULT_USE_QUERY_OPTIONS, + retry: false, + } + ); + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (isError && vppError?.status !== 404) { + return ; + } + + // 404 means there is no token, se we want to show the setup steps content + if (vppError?.status === 404) { + return ; + } + + return vppData ? ( + setShowDisableModal(true)} + onRenew={() => setShowRenewModal(true)} + /> + ) : null; + }; + + return ( + + <> + +

Volume Purchasing Program (VPP)

+ <>{renderContent()} + + {showDisableModal && ( + setShowDisableModal(false)} + /> + )} + {showRenewModal && ( + setShowRenewModal(false)} + onTokenRenewed={refetchVppInfo} + /> + )} +
+ ); +}; + +export default VppSetupPage; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/_styles.scss new file mode 100644 index 0000000000..4c1efec145 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/_styles.scss @@ -0,0 +1,48 @@ +.vpp-setup-page { + &__back-to-vpp { + margin-bottom: $pad-xlarge; + } + + h1 { + margin-bottom: $pad-xxlarge; + font-size: $large; + } + + &__description { + font-size: $x-small; + margin: 0 0 $pad-large; + } + + &__file-uploader { + max-width: 784px; + margin-top: $pad-medium; + margin-left: $pad-medium; + + button { + margin-top: 0; + } + + &--loading { + label { + opacity: 0.5; + } + } + } + + &__button-wrap { + display: flex; + gap: $pad-medium; + } + + &__disable-renew-content { + display: flex; + flex-direction: column; + gap: $pad-large; + } + + &__info { + display: flex; + flex-direction: column; + gap: $pad-medium; + } +} diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/DisableVppModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/DisableVppModal.tsx new file mode 100644 index 0000000000..b601dc4847 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/DisableVppModal.tsx @@ -0,0 +1,70 @@ +import React, { useContext, useState } from "react"; +import { InjectedRouter } from "react-router"; +import paths from "router/paths"; + +import mdmAppleAPI from "services/entities/mdm_apple"; +import { NotificationContext } from "context/notification"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; + +const baseClass = "diable-vpp-modal"; + +interface IDisableVppModalProps { + router: InjectedRouter; + onExit: () => void; +} + +const DisableVppModal = ({ router, onExit }: IDisableVppModalProps) => { + const { renderFlash } = useContext(NotificationContext); + const [isDisabling, setIsDisabling] = useState(false); + + const onDisableVpp = async () => { + setIsDisabling(true); + try { + await mdmAppleAPI.disableVpp(); + renderFlash( + "success", + "Volume Purchasing Program (VPP) disabled successfully." + ); + router.push(paths.ADMIN_INTEGRATIONS_VPP); + } catch { + renderFlash( + "error", + "Couldn't disable Volume Purchasing Program (VPP). Please try again." + ); + } + + onExit(); + }; + + return ( + + <> +

+ Apps purchased in Apple Business Manager won't appear in Fleet. + Apps won't be uninstalled from hosts. If you want to enable VPP + integration again, you'll have to upload a new content token. +

+
+ + +
+ +
+ ); +}; + +export default DisableVppModal; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/index.ts b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/index.ts new file mode 100644 index 0000000000..d223bbdb91 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/index.ts @@ -0,0 +1 @@ +export { default } from "./DisableVppModal"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/RenewVppTokenModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/RenewVppTokenModal.tsx new file mode 100644 index 0000000000..dafd2b4b93 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/RenewVppTokenModal.tsx @@ -0,0 +1,100 @@ +import React, { useContext, useState } from "react"; + +import mdmAppleAPI from "services/entities/mdm_apple"; +import { NotificationContext } from "context/notification"; +import { getErrorReason } from "interfaces/errors"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import FileUploader from "components/FileUploader"; +import { FileDetails } from "components/FileUploader/FileUploader"; + +import VppSetupSteps from "../VppSetupSteps"; + +const baseClass = "renew-vpp-token-modal"; + +interface IRenewVppTokenModalProps { + onExit: () => void; + onTokenRenewed: () => void; +} + +const RenewVppTokenModal = ({ + onExit, + onTokenRenewed, +}: IRenewVppTokenModalProps) => { + const { renderFlash } = useContext(NotificationContext); + const [isRenewing, setIsRenewing] = useState(false); + const [tokenFile, setTokenFile] = useState(null); + + const onSelectFile = (files: FileList | null) => { + const file = files?.[0]; + if (file) { + setTokenFile(file); + } + }; + + const onRenewToken = async () => { + setIsRenewing(true); + + if (!tokenFile) { + setIsRenewing(false); + renderFlash("error", "No token selected."); + return; + } + + try { + await mdmAppleAPI.uploadVppToken(tokenFile); + renderFlash( + "success", + "Volume Purchasing Program (VPP) integration enabled successfully." + ); + onTokenRenewed(); + } catch (e) { + const msg = getErrorReason(e, { reasonIncludes: "valid token" }); + if (msg) { + renderFlash("error", msg); + } else { + renderFlash("error", "Couldn't Upload. Please try again."); + } + } + onExit(); + setIsRenewing(false); + }; + + return ( + + <> + + + ) + } + onFileUpload={onSelectFile} + /> +
+ +
+ +
+ ); +}; + +export default RenewVppTokenModal; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/_styles.scss new file mode 100644 index 0000000000..a848fb9c45 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/_styles.scss @@ -0,0 +1,15 @@ +.renew-vpp-token-modal { + &__file-uploader { + margin-top: $pad-medium; + + button { + margin-top: 0; + } + + &--loading { + label { + opacity: 0.5; + } + } + } +} diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/index.ts b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/index.ts new file mode 100644 index 0000000000..8cb881457f --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/index.ts @@ -0,0 +1 @@ +export { default } from "./RenewVppTokenModal"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/VppSetupSteps.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/VppSetupSteps.tsx new file mode 100644 index 0000000000..303003f20c --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/VppSetupSteps.tsx @@ -0,0 +1,68 @@ +import CustomLink from "components/CustomLink"; +import React from "react"; + +const baseClass = "vpp-setup-steps"; + +interface IVppSetupStepsProps { + /** This prop is used to display additional setup steps content. We have this + * because some places that use this component show additional content. + */ + extendendSteps?: boolean; +} + +const VppSetupSteps = ({ extendendSteps = false }: IVppSetupStepsProps) => { + return ( +
    +
  1. + 1. +

    + Sign in to{" "} + + {extendendSteps && ( + <> +
    + If your organization doesn't have an account, select{" "} + Sign up now. + + )} +

    +
  2. +
  3. + 2. +

    + Select your account name at the bottom left of the screen, then + select Preferences. +

    +
  4. +
  5. + 3. +

    + Select Payments and Billings in the menu. +

    +
  6. +
  7. + 4. +

    + Under the Content Tokens, download the token for the location + you want to use. + {extendendSteps && ( + <> +
    Each token is based on a location in Apple Business + Manager. + + )} +

    +
  8. +
  9. + 5. +

    Upload content token (.vpptoken file) below.

    +
  10. +
+ ); +}; + +export default VppSetupSteps; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/_styles.scss new file mode 100644 index 0000000000..23dd2748b9 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/_styles.scss @@ -0,0 +1,20 @@ +.vpp-setup-steps { + font-size: $x-small; + display: flex; + flex-direction: column; + gap: $pad-large; + padding: 0; + margin: 0; + max-width: 660px; + list-style: none; + + li { + display: flex; + flex-direction: row; + gap: $pad-small; + + p { + margin: 0; + } + } +} diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/index.ts b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/index.ts new file mode 100644 index 0000000000..94ab3ee836 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/index.ts @@ -0,0 +1 @@ +export { default } from "./VppSetupSteps"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/index.ts b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/index.ts new file mode 100644 index 0000000000..a591bb640a --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/index.ts @@ -0,0 +1 @@ +export { default } from "./VppSetupPage"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/Vpp/_styles.scss new file mode 100644 index 0000000000..0da13eb9c3 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/_styles.scss @@ -0,0 +1,47 @@ +.vpp { + + // TODO: this is very similar to the Apple Mdm card, consider pulling out + // into a shared component if we do this pattern one more time. + &__card { + + h3 { + font-size: $x-small; + font-weight: $bold; + margin: 0 0 $pad-xsmall; + } + + p { + margin: 0; + max-width: 520px; + + span { + display: flex; + align-items: center; + gap: $pad-small; + } + } + + &__turn-on-mdm { + flex-direction: column; + align-items: flex-start; + gap: $pad-medium; + + .button { + height: auto; + } + } + } + + &__mdm-disabled-content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: $pad-medium; + } + + &__vpp-on-content, &__vpp-off-content { + display: flex; + justify-content: space-between; + align-items: center; + } +} diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/index.ts b/frontend/pages/admin/IntegrationsPage/cards/Vpp/index.ts new file mode 100644 index 0000000000..35854011a7 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/index.ts @@ -0,0 +1 @@ +export { default } from "./Vpp"; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 8fb425ce09..f1b2589b11 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -58,7 +58,11 @@ import TabsWrapper from "components/TabsWrapper"; import MainContent from "components/MainContent"; import BackLink from "components/BackLink"; import ScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal"; -import { SoftwareInstallDetailsModal } from "pages/SoftwarePage/components/SoftwareInstallDetails"; +import { + AppInstallDetailsModal, + IAppInstallDetails, +} from "components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails"; +import { SoftwareInstallDetailsModal } from "components/ActivityDetails/InstallDetails/SoftwareInstallDetails"; import HostSummaryCard from "../cards/HostSummary"; import AboutCard from "../cards/About"; @@ -169,6 +173,11 @@ const HostDetailsPage = ({ null ); const [softwareInstallUuid, setSoftwareInstallUuid] = useState(""); + const [ + appInstallDetails, + setAppInstallDetails, + ] = useState(null); + const [isUpdatingHost, setIsUpdatingHost] = useState(false); const [refetchStartTime, setRefetchStartTime] = useState(null); const [showRefetchSpinner, setShowRefetchSpinner] = useState(false); @@ -553,6 +562,9 @@ const HostDetailsPage = ({ case "installed_software": setSoftwareInstallUuid(details?.install_uuid || ""); break; + case "installed_app_store_app": + setAppInstallDetails({ ...details }); + break; default: // do nothing } }, @@ -589,6 +601,10 @@ const HostDetailsPage = ({ setSoftwareInstallUuid(""); }, []); + const onCancelAppInstallDetailsModal = useCallback(() => { + setAppInstallDetails(null); + }, []); + const onTransferHostSubmit = async (team: ITeam) => { setIsUpdatingHost(true); @@ -994,6 +1010,12 @@ const HostDetailsPage = ({ onCancel={onCancelSoftwareInstallDetailsModal} /> )} + {!!appInstallDetails && ( + + )} {showLockHostModal && ( setSelectedSoftwareDetails(null)} /> diff --git a/frontend/pages/hosts/details/cards/Activity/Activity.tsx b/frontend/pages/hosts/details/cards/Activity/Activity.tsx index acb20b81cd..91d35ce35f 100644 --- a/frontend/pages/hosts/details/cards/Activity/Activity.tsx +++ b/frontend/pages/hosts/details/cards/Activity/Activity.tsx @@ -68,7 +68,6 @@ const Activity = ({ onPreviousPage, onShowDetails, }: IActivityProps) => { - // TODO: add count to upcoming activities tab when available via API return ( = { [ActivityType.RanScript]: RanScriptActivityItem, [ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem, + [ActivityType.InstalledAppStoreApp]: InstalledSoftwareActivityItem, }; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx index b5edd5f4b5..5d474d4d8b 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { SoftwareInstallStatus } from "interfaces/software"; +import { getInstallStatusPredicate } from "interfaces/software"; import { IHostActivityItemComponentPropsWithShowDetails } from "../../ActivityConfig"; import HostActivityItem from "../../HostActivityItem"; @@ -8,24 +8,6 @@ import ShowDetailsButton from "../../ShowDetailsButton"; const baseClass = "installed-software-activity-item"; -const STATUS_PREDICATES: Record = { - failed: "failed to install", - installed: "installed", - pending: "told Fleet to install", -} as const; - -export const getSoftwareInstallStatusPredicate = ( - status: string | undefined -) => { - if (!status) { - return STATUS_PREDICATES.pending; - } - return ( - STATUS_PREDICATES[status as SoftwareInstallStatus] || - STATUS_PREDICATES.pending - ); -}; - const InstalledSoftwareActivityItem = ({ activity, onShowDetails, @@ -41,8 +23,8 @@ const InstalledSoftwareActivityItem = ({ return ( - <>{actorDisplayName} {getSoftwareInstallStatusPredicate(status)}{" "} - {title} software on this host.{" "} + <>{actorDisplayName} {getInstallStatusPredicate(status)} {title}{" "} + on this host.{" "} ); diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx index 6cf80111ac..3e9b067eef 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useContext, useMemo, useState } from "react"; import { InjectedRouter } from "react-router"; import { useQuery } from "react-query"; import { AxiosError } from "axios"; -import { trimEnd, upperFirst } from "lodash"; import hostAPI, { IGetHostSoftwareResponse, @@ -12,7 +11,6 @@ import deviceAPI, { IDeviceSoftwareQueryKey, IGetDeviceSoftwareResponse, } from "services/entities/device_user"; -import { getErrorReason } from "interfaces/errors"; import { IHostSoftware, ISoftware } from "interfaces/software"; import { DEFAULT_USE_QUERY_OPTIONS, SUPPORT_LINK } from "utilities/constants"; import { NotificationContext } from "context/notification"; @@ -27,6 +25,7 @@ import CustomLink from "components/CustomLink"; import { generateSoftwareTableHeaders as generateHostSoftwareTableConfig } from "./HostSoftwareTableConfig"; import { generateSoftwareTableHeaders as generateDeviceSoftwareTableConfig } from "./DeviceSoftwareTableConfig"; import HostSoftwareTable from "./HostSoftwareTable"; +import { getErrorMessage } from "./helpers"; const baseClass = "software-card"; @@ -187,17 +186,7 @@ const HostSoftware = ({ "Software is installing or will install when the host comes online." ); } catch (e) { - const reason = upperFirst(trimEnd(getErrorReason(e), ".")); - if (reason.includes("fleetd installed")) { - renderFlash("error", `Couldn't install. ${reason}.`); - } else if (reason.includes("can be installed only on")) { - renderFlash( - "error", - `Couldn't install. ${reason.replace("darwin", "macOS")}.` - ); - } else { - renderFlash("error", "Couldn't install. Please try again."); - } + renderFlash("error", getErrorMessage(e)); } setInstallingSoftwareId(null); refetchSoftware(); diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx index 1e8cd5fbb8..b01806032f 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx @@ -4,7 +4,9 @@ import { CellProps, Column } from "react-table"; import { cloneDeep } from "lodash"; import { + IHostAppStoreApp, IHostSoftware, + IHostSoftwarePackage, SoftwareInstallStatus, formatSoftwareType, } from "interfaces/software"; @@ -53,14 +55,16 @@ const generateActions = ({ isFleetdHost, softwareId, status, - packageToInstall, + software_package, + app_store_app, }: { canInstall: boolean; installingSoftwareId: number | null; isFleetdHost: boolean; softwareId: number; status: SoftwareInstallStatus | null; - packageToInstall?: string | null; + software_package: IHostSoftwarePackage | null; + app_store_app: IHostAppStoreApp | null; }) => { // this gives us a clean slate of the default actions so we can modify // the options. @@ -73,8 +77,9 @@ const generateActions = ({ throw new Error("Install action not found in default actions"); } + const hasSoftwareToInstall = !!software_package || !!app_store_app; // remove install if there is no package to install - if (!packageToInstall || !canInstall) { + if (!hasSoftwareToInstall || !canInstall) { actions.splice(indexInstallAction, 1); return actions; } @@ -123,7 +128,7 @@ export const generateSoftwareTableHeaders = ({ accessor: "name", disableSortBy: false, Cell: (cellProps: ITableStringCellProps) => { - const { id, name, source } = cellProps.row.original; + const { id, name, source, app_store_app } = cellProps.row.original; const softwareTitleDetailsPath = PATHS.SOFTWARE_TITLE_DETAILS( id.toString().concat(`?team_id=${teamId}`) @@ -133,6 +138,7 @@ export const generateSoftwareTableHeaders = ({ @@ -188,8 +194,10 @@ export const generateSoftwareTableHeaders = ({ const { id: softwareId, status, - package_available_for_install: packageToInstall, + software_package, + app_store_app, } = original; + return ( onSelectAction(original, action)} /> diff --git a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx index 8dfceee721..38a3a42df3 100644 --- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx +++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx @@ -12,6 +12,11 @@ import TextCell from "components/TableContainer/DataTable/TextCell"; const baseClass = "install-status-cell"; type IStatusValue = SoftwareInstallStatus | "avaiableForInstall"; +interface TootipArgs { + softwareName?: string | null; + lastInstalledAt?: string; + isAppStoreApp?: boolean; +} export type IStatusDisplayConfig = { iconName: @@ -21,10 +26,7 @@ export type IStatusDisplayConfig = { | "install" | "install-self-service"; displayText: string; - tooltip: (args: { - softwareName?: string | null; - lastInstalledAt?: string; - }) => ReactNode; + tooltip: (args: TootipArgs) => ReactNode; }; export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< @@ -59,12 +61,18 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< avaiableForInstall: { iconName: "install", displayText: "Available for install", - tooltip: ({ softwareName }) => ( - <> - {softwareName ? {softwareName} : "Software"} can be installed on - the host. Select Actions {">"} Install to install. - - ), + tooltip: ({ softwareName, isAppStoreApp }) => + isAppStoreApp ? ( + <> + App Store app can be installed on the host. Select{" "} + Actions {">"} Install to install. + + ) : ( + <> + {softwareName ? {softwareName} : "Software"} can be installed + on the host. Select Actions {">"} Install to install. + + ), }, selfService: { iconName: "install-self-service", @@ -79,21 +87,29 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< }, }; +type IInstallStatusCellProps = IHostSoftware; + const InstallStatusCell = ({ status, - last_install, - package_available_for_install: softwareName, - self_service, -}: IHostSoftware) => { - const lastInstalledAt = last_install?.installed_at; + software_package, + app_store_app, +}: IInstallStatusCellProps) => { + // FIXME: Improve the way we handle polymophism of software_package and app_store_app + const lastInstalledAt = + software_package?.last_install?.installed_at || + app_store_app?.last_install?.installed_at || + ""; + const hasPackage = !!software_package; + const hasAppStoreApp = !!app_store_app; let displayStatus: keyof typeof INSTALL_STATUS_DISPLAY_OPTIONS; if (status !== null) { displayStatus = status; - } else if (softwareName && self_service) { + } else if (software_package?.self_service) { + // currently only software packages can be self-service displayStatus = "selfService"; - } else if (softwareName) { + } else if (hasPackage || hasAppStoreApp) { displayStatus = "avaiableForInstall"; } else { return ; @@ -101,6 +117,7 @@ const InstallStatusCell = ({ const displayConfig = INSTALL_STATUS_DISPLAY_OPTIONS[displayStatus]; const tooltipId = uniqueId(); + const softwareName = software_package?.name; return (
@@ -123,6 +140,7 @@ const InstallStatusCell = ({ {displayConfig.tooltip({ softwareName, lastInstalledAt, + isAppStoreApp: hasAppStoreApp, })} diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx index 60c5ea7d6b..09c7a19c81 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx @@ -71,10 +71,6 @@ describe("SelfService", () => { createMockDeviceSoftware({ name: "test-software", status: "installed", - last_install: { - install_uuid: "test-uuid", - installed_at: "2021-08-18T15:11:35Z", - }, }), ], }) diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx index f72b02f468..190fd11092 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx @@ -122,8 +122,11 @@ const SoftwareSelfService = ({
{data.software.map((s) => { - // concatenating install_uuid so item updates with fresh data on refetch - const key = `${s.id}${s.last_install?.install_uuid}`; + // TODO: update this if/when we support self-service app store apps + const uuid = + s.software_package?.last_install?.install_uuid || ""; + // concatenating uuid so item updates with fresh data on refetch + const key = `${s.id}${uuid}`; return ( { - const { name, source, package: installerPackage } = software; + const { name, source, software_package: installerPackage } = software; return (
- +
@@ -70,10 +71,10 @@ const InstallerInfo = ({ software }: IInstallerInfoProps) => { ); }; -type IInstallerStatusProps = Pick< - IHostSoftware, - "id" | "status" | "last_install" ->; +// TODO: update if/when we support self-service app store apps +type IInstallerStatusProps = Pick & { + last_install: ISoftwareLastInstall | null; +}; const InstallerStatus = ({ id, @@ -137,11 +138,14 @@ const getInstallButtonText = (status: SoftwareInstallStatus | null) => { const InstallerStatusAction = ({ deviceToken, - software: { id, status, last_install }, + software: { id, status, software_package }, onInstall, }: IInstallerStatusActionProps) => { const { renderFlash } = useContext(NotificationContext); + // TODO: update this if/when we support self-service app store apps + const last_install = software_package?.last_install || null; + // localStatus is used to track the status of the any user-initiated install action const [localStatus, setLocalStatus] = React.useState< SoftwareInstallStatus | undefined @@ -152,7 +156,7 @@ const InstallerStatusAction = ({ const displayStatus = localStatus || status; const installButtonText = getInstallButtonText(displayStatus); - // if the localStatus is "failed", we don't our tooltip to include the old installed_at date so we + // if the localStatus is "failed", we don't want our tooltip to include the old installed_at date so we // set this to null, which tells the tooltip to omit the parenthetical date const lastInstall = localStatus === "failed" ? null : last_install; diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss index 56d357f798..027b9c4fd6 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss @@ -21,8 +21,6 @@ &__item-icon { display: flex; - height: 64px; - min-width: 64px; } &__item-name-version { diff --git a/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx b/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx index 97d4cbfe59..f8373d8c14 100644 --- a/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx +++ b/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx @@ -5,6 +5,8 @@ import { IHostSoftware, ISoftwareInstallVersion, formatSoftwareType, + hasHostSoftwareAppLastInstall, + hasHostSoftwarePackageLastInstall, } from "interfaces/software"; import Modal from "components/Modal"; @@ -13,7 +15,8 @@ import Button from "components/buttons/Button"; import DataSet from "components/DataSet"; import { dateAgo } from "utilities/date_format"; -import { SoftwareInstallDetails } from "pages/SoftwarePage/components/SoftwareInstallDetails"; +import { AppInstallDetails } from "components/ActivityDetails/InstallDetails/AppInstallDetails"; +import { SoftwareInstallDetails } from "components/ActivityDetails/InstallDetails/SoftwareInstallDetails"; import TooltipTruncatedText from "components/TooltipTruncatedText"; const baseClass = "software-details-modal"; @@ -88,69 +91,117 @@ const SoftwareDetailsInfo = ({ }; interface ISoftwareDetailsModalProps { + hostDisplayName: string; software: IHostSoftware; onExit: () => void; } +const SoftwareDetailsContent = ({ + software, +}: Pick) => { + const { installed_versions } = software; + + // special case when we dont have installed versions. We can only show the + // software type atm. + if (!installed_versions || installed_versions.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {installed_versions?.map((installedVersion) => { + return ( + + ); + })} +
+ ); +}; + +const InstallDetailsContent = ({ + hostDisplayName, + software, +}: { + hostDisplayName: string; + software: IHostSoftware; +}) => { + if (hasHostSoftwareAppLastInstall(software)) { + return ( + + ); + } else if (hasHostSoftwarePackageLastInstall(software)) { + return ( + + ); + } + + // caller should ensure this nevers happen + return null; +}; + +const TabsContent = ({ + hostDisplayName, + software, +}: { + hostDisplayName: string; + software: IHostSoftware; +}) => { + return ( + + + + Software details + Install details + + + + + + + + + + ); +}; + const SoftwareDetailsModal = ({ + hostDisplayName, software, onExit, }: ISoftwareDetailsModalProps) => { - const installUuid = software.last_install?.install_uuid || ""; - - const renderSoftwareDetails = () => { - const { installed_versions } = software; - - // special case when we dont have installed versions. We can only show the - // software type atm. - if (!installed_versions || installed_versions.length === 0) { - return ( -
- -
- ); - } - - return ( -
- {installed_versions?.map((installedVersion) => { - return ( - - ); - })} -
- ); - }; - - const renderTabs = () => { - return ( - - - - Software details - Install Details - - {renderSoftwareDetails()} - - - - - - ); - }; - + const hasLastInstall = + hasHostSoftwarePackageLastInstall(software) || + hasHostSoftwareAppLastInstall(software); return ( <> - {software.last_install ? renderTabs() : renderSoftwareDetails()} + {!hasLastInstall ? ( + + ) : ( + + )}