mirror of
https://github.com/fleetdm/fleet
synced 2026-05-22 00:18:27 +00:00
feat: Apple App Store (VPP) apps (#20643)
> Related issue: #18867 # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] If database migrations are included, checked table schema to confirm autoupdate - [x] Manual QA for all new/changed functionality
This commit is contained in:
commit
6a31d4eb44
173 changed files with 8687 additions and 1219 deletions
2
changes/19864-vpp-token-crud
Normal file
2
changes/19864-vpp-token-crud
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- Adds the functionality for the `POST /mdm/apple/vpp_token`, `DELETE /mdm/apple/vpp_token` and
|
||||
`GET /vpp` endpoints.
|
||||
1
changes/19865-db-schema
Normal file
1
changes/19865-db-schema
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Adds DB updates to support the VPP software feature.
|
||||
1
changes/19867-get-avail-apps
Normal file
1
changes/19867-get-avail-apps
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Adds functionality for the `GET /software/app_store_apps` and `POST /software/app_store_apps` endpoints.
|
||||
1
changes/19868-vpp-install-command
Normal file
1
changes/19868-vpp-install-command
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Adds functionality for installing App Store apps to the VPP feature.
|
||||
1
changes/19870-vpp-activities-backend
Normal file
1
changes/19870-vpp-activities-backend
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Adds global activity support for VPP related activities.
|
||||
1
changes/19871-gitops-vpp-config
Normal file
1
changes/19871-gitops-vpp-config
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Add support for VPP to gitops config
|
||||
|
|
@ -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.
|
||||
1
changes/20278-vpp-batch-api
Normal file
1
changes/20278-vpp-batch-api
Normal file
|
|
@ -0,0 +1 @@
|
|||
- GitOps supports VPP app associations
|
||||
2
changes/20515-delete-vpp-app
Normal file
2
changes/20515-delete-vpp-app
Normal file
|
|
@ -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`.
|
||||
1
changes/issue-19866-add-remove-disable-vpp-in-ui
Normal file
1
changes/issue-19866-add-remove-disable-vpp-in-ui
Normal file
|
|
@ -0,0 +1 @@
|
|||
- add ability to add/remove/disable vpp in the fleet UI.
|
||||
1
changes/issue-19869-vpp-ui-on-software-pages
Normal file
1
changes/issue-19869-vpp-ui-on-software-pages
Normal file
|
|
@ -0,0 +1 @@
|
|||
- add UI to support the apple vpp feature on the software pages.
|
||||
|
|
@ -0,0 +1 @@
|
|||
- add UI updates for VPP feature on host software and my device pages.
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ controls:
|
|||
policies:
|
||||
queries:
|
||||
software:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/notfound.deb
|
||||
packages:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/notfound.deb
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ controls:
|
|||
policies:
|
||||
queries:
|
||||
software:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb
|
||||
packages:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ controls:
|
|||
policies:
|
||||
queries:
|
||||
software:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
|
||||
packages:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
17
cmd/fleetctl/testdata/gitops/team_vpp_invalid_app.yml
vendored
Normal file
17
cmd/fleetctl/testdata/gitops/team_vpp_invalid_app.yml
vendored
Normal file
|
|
@ -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"
|
||||
17
cmd/fleetctl/testdata/gitops/team_vpp_valid_app.yml
vendored
Normal file
17
cmd/fleetctl/testdata/gitops/team_vpp_valid_app.yml
vendored
Normal file
|
|
@ -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"
|
||||
16
cmd/fleetctl/testdata/gitops/team_vpp_valid_empty.yml
vendored
Normal file
16
cmd/fleetctl/testdata/gitops/team_vpp_valid_empty.yml
vendored
Normal file
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ spec:
|
|||
custom_settings: null
|
||||
scripts: null
|
||||
secrets: null
|
||||
software: null
|
||||
webhook_settings:
|
||||
host_status_webhook: null
|
||||
name: tm1
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ spec:
|
|||
grace_period_days: null
|
||||
scripts: null
|
||||
secrets: null
|
||||
software: null
|
||||
webhook_settings:
|
||||
host_status_webhook: null
|
||||
name: tm1
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
<meta name="title" value="Audit logs">
|
||||
<meta name="pageOrderInSection" value="1400">
|
||||
|
|
|
|||
|
|
@ -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. <app_store_id> 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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
299
ee/server/service/vpp.go
Normal file
299
ee/server/service/vpp.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
): 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>): IVppApp => {
|
||||
return { ...DEFAULT_MDM_APPLE_VPP_APP_MOCK, ...overrides };
|
||||
};
|
||||
|
||||
export default createMockMdmApple;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
): 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>): IConfig => {
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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<IHost>) => {
|
|||
);
|
||||
};
|
||||
|
||||
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>
|
||||
): 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>
|
||||
): 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",
|
||||
|
|
|
|||
|
|
@ -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<ISoftwareTitleWithPackageDetail>
|
||||
| Partial<ISoftwareTitleWithPackageName>;
|
||||
|
||||
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<ISoftwareTitleWithPackageDetail>
|
||||
| Partial<ISoftwareTitleWithPackageName>
|
||||
>(
|
||||
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>
|
||||
): 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<ISoftwareTitleWithPackageDetail>),
|
||||
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<IAppStoreApp>) => {
|
||||
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<ISoftwareTitleDetails>
|
||||
) => {
|
||||
return { ...DEFAULT_SOFTWARE_TITLE_DETAILS_MOCK, ...overrides };
|
||||
};
|
||||
|
||||
const DEFAULT_SOFTWARE_TITLE_RESPONSE: ISoftwareTitleResponse = {
|
||||
software_title: createMockSoftwareTitleDetails(),
|
||||
};
|
||||
|
||||
export const createMockSoftwareTitleResponse = (
|
||||
overrides: Partial<ISoftwareTitleWithPackageDetail> = {}
|
||||
overrides?: Partial<ISoftwareTitleResponse>
|
||||
): 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<ISoftwarePackage>
|
||||
) => {
|
||||
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>
|
||||
): 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>
|
||||
): ISoftwareTitlesResponse => {
|
||||
return { ...DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK, ...overrides };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 <Spinner />;
|
||||
} else if (isError) {
|
||||
return <DataError description="Close this modal and try again." />;
|
||||
} else if (!result) {
|
||||
// FIXME: Find a better solution for this.
|
||||
return <DataError description="No data returned." />;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<div className={`${baseClass}__software-install-details`}>
|
||||
<div className={`${baseClass}__status-message`}>
|
||||
{!!iconName && <Icon name={iconName} />}
|
||||
<span>
|
||||
Fleet {predicate} <b>{software_title}</b> on{" "}
|
||||
<b>{host_display_name}</b>
|
||||
{subordinate}.
|
||||
</span>
|
||||
</div>
|
||||
<div className={`${baseClass}__script-output`}>
|
||||
Request payload:
|
||||
<Textarea className={`${baseClass}__output-textarea`}>
|
||||
{result.payload}
|
||||
</Textarea>
|
||||
</div>
|
||||
{showCommandResponse && (
|
||||
<div className={`${baseClass}__script-output`}>
|
||||
The response from <b>{host_display_name}</b>:
|
||||
<Textarea className={`${baseClass}__output-textarea`}>
|
||||
{result.result}
|
||||
</Textarea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppInstallDetailsModal = ({
|
||||
details,
|
||||
onCancel,
|
||||
}: {
|
||||
details: IAppInstallDetails;
|
||||
onCancel: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title="Install details"
|
||||
onExit={onCancel}
|
||||
onEnter={onCancel}
|
||||
className={baseClass}
|
||||
>
|
||||
<>
|
||||
<div className={`${baseClass}__modal-content`}>
|
||||
<AppInstallDetails {...details} />
|
||||
</div>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button onClick={onCancel} variant="brand">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { AppInstallDetails, AppInstallDetailsModal } from "./AppInstallDetails";
|
||||
|
|
@ -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<SoftwareInstallStatus, IconNames> = {
|
||||
pending: "pending-outline",
|
||||
installed: "success-outline",
|
||||
failed: "error-outline",
|
||||
} as const;
|
||||
|
||||
const STATUS_PREDICATES: Record<SoftwareInstallStatus, string> = {
|
||||
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 (
|
||||
<div className={`${baseClass}__status-message`}>
|
||||
<Icon name={STATUS_ICONS[status]} />
|
||||
<Icon name={INSTALL_DETAILS_STATUS_ICONS[status]} />
|
||||
<span>
|
||||
Fleet {STATUS_PREDICATES[status]} <b>{software_title}</b> (
|
||||
{software_package}) on <b>{host_display_name}</b>
|
||||
Fleet {getInstallDetailsStatusPredicate(status)} <b>{software_title}</b>{" "}
|
||||
({software_package}) on <b>{host_display_name}</b>
|
||||
{status === "pending" ? " when it comes online" : ""}.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={`${baseClass}__script-output`}>
|
||||
{OUTPUT_DISPLAY_LABELS[displayKey]}:
|
||||
{SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS[displayKey]}:
|
||||
<Textarea className={`${baseClass}__output-textarea`}>
|
||||
{result[displayKey]}
|
||||
</Textarea>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -20,6 +20,7 @@ type ISupportedGraphicNames = Extract<
|
|||
| "file-pkg"
|
||||
| "file-p7m"
|
||||
| "file-pem"
|
||||
| "file-vpp"
|
||||
>;
|
||||
|
||||
export const FileDetails = ({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
<>
|
||||
<SoftwareIcon name={name} source={source} />
|
||||
<SoftwareIcon name={name} source={source} url={iconUrl} />
|
||||
<span className="software-name">{name}</span>
|
||||
{hasPackage && (
|
||||
<InstallIconWithTooltip isSelfService={isSelfService} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
59
frontend/components/graphics/FileVpp.tsx
Normal file
59
frontend/components/graphics/FileVpp.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import React from "react";
|
||||
|
||||
const FileVpp = () => {
|
||||
return (
|
||||
<svg width="34" height="40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#a)">
|
||||
<path
|
||||
d="M29.333 39.75H4.667a2.417 2.417 0 0 1-2.417-2.416V2.667A2.417 2.417 0 0 1 4.667.25h19.562c.64 0 1.255.255 1.709.708l5.104 5.105c.453.453.708 1.068.708 1.709v29.562a2.417 2.417 0 0 1-2.417 2.416Z"
|
||||
fill="#fff"
|
||||
stroke="#192147"
|
||||
strokeWidth=".5"
|
||||
/>
|
||||
<path
|
||||
d="M23.5.5h.834l.5 6.5 6.666.5v1h-6a2 2 0 0 1-2-2v-6Z"
|
||||
fill="#C5C7D1"
|
||||
/>
|
||||
<path
|
||||
d="M24.5.334v5.667c0 .736.597 1.333 1.333 1.333h6"
|
||||
stroke="#192147"
|
||||
strokeWidth=".5"
|
||||
/>
|
||||
<path
|
||||
d="M2.5 20h25a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2h-25V20Z"
|
||||
fill="#C5C7D1"
|
||||
/>
|
||||
<rect
|
||||
x=".25"
|
||||
y="18.25"
|
||||
width="27.7"
|
||||
height="16.35"
|
||||
rx="1.75"
|
||||
fill="#515774"
|
||||
/>
|
||||
<rect
|
||||
x=".25"
|
||||
y="18.25"
|
||||
width="27.7"
|
||||
height="16.35"
|
||||
rx="1.75"
|
||||
stroke="#192147"
|
||||
strokeWidth=".5"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.752 31.045v-7.5h1.946v.952h.043c.07-.185.175-.354.312-.508.137-.156.308-.28.511-.373.204-.094.44-.142.71-.142.36 0 .703.096 1.027.288.327.192.592.494.795.905.206.412.31.947.31 1.606 0 .63-.099 1.151-.296 1.566-.194.414-.454.723-.78.927-.325.203-.681.305-1.07.305-.255 0-.484-.042-.685-.124-.199-.086-.37-.2-.511-.345a1.593 1.593 0 0 1-.323-.497h-.029v2.94h-1.96Zm1.917-4.772c0 .265.035.494.104.689.07.191.17.34.298.447.13.104.285.156.465.156.18 0 .333-.05.458-.152a.96.96 0 0 0 .291-.444c.069-.194.103-.427.103-.696 0-.27-.034-.501-.103-.693a.936.936 0 0 0-.291-.444.693.693 0 0 0-.458-.156.722.722 0 0 0-.465.156.981.981 0 0 0-.298.444 2.061 2.061 0 0 0-.104.693Zm4.758 4.772v-7.5h1.945v.952h.043c.071-.185.175-.354.313-.508.137-.156.307-.28.511-.373.204-.094.44-.142.71-.142.36 0 .702.096 1.026.288.327.192.592.494.796.905.206.412.309.947.309 1.606 0 .63-.098 1.151-.295 1.566-.194.414-.454.723-.781.927-.325.203-.68.305-1.069.305-.256 0-.484-.042-.685-.124-.2-.086-.37-.2-.512-.345a1.593 1.593 0 0 1-.323-.497h-.028v2.94h-1.96Zm1.917-4.772c0 .265.034.494.103.689.071.191.17.34.298.447.13.104.286.156.466.156.18 0 .332-.05.458-.152a.96.96 0 0 0 .29-.444c.07-.194.104-.427.104-.696 0-.27-.034-.501-.103-.693a.935.935 0 0 0-.291-.444.693.693 0 0 0-.458-.156.722.722 0 0 0-.466.156.98.98 0 0 0-.298.444 2.061 2.061 0 0 0-.103.693ZM8.162 29l1.832-5.455h-2.06l-.88 3.608h-.057l-.88-3.608h-2.06L5.889 29h2.273Z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M0 0h34v40H0z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileVpp;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ISoftwareTitle, "software_package" | "self-service"> {
|
||||
software_package: string | null;
|
||||
self_service: boolean;
|
||||
}
|
||||
|
||||
export interface ISoftwareTitleWithPackageDetail
|
||||
extends Omit<ISoftwareTitle, "software_package" | "self-service"> {
|
||||
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<SoftwareInstallStatus, string> = {
|
||||
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<SoftwareInstallStatus, IconNames> = {
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<IActivityDetails | null>(null);
|
||||
const queryShown = useRef("");
|
||||
const queryImpact = useRef<string | undefined>(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 && (
|
||||
<AppInstallDetailsModal
|
||||
details={appInstallDetails}
|
||||
onCancel={() => setAppInstallDetails(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 <b>{activity.details?.software_title}</b> (
|
||||
{activity.details?.software_package}) software to{" "}
|
||||
{activity.details?.software_package}) to{" "}
|
||||
{activity.details?.team_name ? (
|
||||
<>
|
||||
{" "}
|
||||
|
|
@ -812,7 +812,7 @@ const TAGGED_TEMPLATES = {
|
|||
<>
|
||||
{" "}
|
||||
deleted <b>{activity.details?.software_title}</b> (
|
||||
{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)} <b>{title}</b> software on{" "}
|
||||
{getInstallStatusPredicate(status)} <b>{title}</b>
|
||||
{showSoftwarePackage && ` (${details.software_package})`} on{" "}
|
||||
<b>{hostName}</b>.{" "}
|
||||
<Button
|
||||
className={`${baseClass}__show-query-link`}
|
||||
variant="text-link"
|
||||
onClick={() =>
|
||||
onDetailsClick?.(ActivityType.InstalledSoftware, { install_uuid })
|
||||
}
|
||||
onClick={() => onDetailsClick?.(activity.type, details)}
|
||||
>
|
||||
Show details{" "}
|
||||
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
|
||||
|
|
@ -858,6 +860,54 @@ const TAGGED_TEMPLATES = {
|
|||
</>
|
||||
);
|
||||
},
|
||||
enabledVpp: () => {
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
enabled <b>Volume Purchasing Program (VPP)</b>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
disabledVpp: () => {
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
disabled <b>Volume Purchasing Program (VPP)</b>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
addedAppStoreApp: (activity: IActivity) => {
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
added <b>{activity.details?.software_title}</b> to{" "}
|
||||
{activity.details?.team_name ? (
|
||||
<>
|
||||
{" "}
|
||||
the <b>{activity.details?.team_name}</b> team.
|
||||
</>
|
||||
) : (
|
||||
"no team."
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
deletedAppStoreApp: (activity: IActivity) => {
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
deleted <b>{activity.details?.software_title}</b> from{" "}
|
||||
{activity.details?.team_name ? (
|
||||
<>
|
||||
{" "}
|
||||
the <b>{activity.details?.team_name}</b> team.
|
||||
</>
|
||||
) : (
|
||||
"no team."
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const getDetail = (
|
||||
|
|
@ -1041,6 +1091,21 @@ const getDetail = (
|
|||
case ActivityType.InstalledSoftware: {
|
||||
return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick);
|
||||
}
|
||||
case ActivityType.AddedAppStoreApp: {
|
||||
return TAGGED_TEMPLATES.addedAppStoreApp(activity);
|
||||
}
|
||||
case ActivityType.DeletedAppStoreApp: {
|
||||
return TAGGED_TEMPLATES.deletedAppStoreApp(activity);
|
||||
}
|
||||
case ActivityType.InstalledAppStoreApp: {
|
||||
return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick);
|
||||
}
|
||||
case ActivityType.EnabledVpp: {
|
||||
return TAGGED_TEMPLATES.enabledVpp();
|
||||
}
|
||||
case ActivityType.DisabledVpp: {
|
||||
return TAGGED_TEMPLATES.disabledVpp();
|
||||
}
|
||||
|
||||
default: {
|
||||
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
|
||||
|
|
|
|||
|
|
@ -4,16 +4,12 @@ import React, {
|
|||
useLayoutEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import FileSaver from "file-saver";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import { SoftwareInstallStatus, ISoftwarePackage } from "interfaces/software";
|
||||
|
||||
import softwareAPI from "services/entities/software";
|
||||
|
||||
import { buildQueryStringFromParams } from "utilities/url";
|
||||
|
|
@ -22,15 +18,20 @@ import { uploadedFromNow } from "utilities/date_format";
|
|||
|
||||
// @ts-ignore
|
||||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
|
||||
import Card from "components/Card";
|
||||
import Graphic from "components/Graphic";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import DataSet from "components/DataSet";
|
||||
import Icon from "components/Icon";
|
||||
|
||||
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
|
||||
|
||||
import DeleteSoftwareModal from "../DeleteSoftwareModal";
|
||||
import AdvancedOptionsModal from "../AdvancedOptionsModal";
|
||||
import {
|
||||
APP_STORE_APP_DROPDOWN_OPTIONS,
|
||||
SOFTWARE_PACAKGE_DROPDOWN_OPTIONS,
|
||||
} from "./helpers";
|
||||
|
||||
const baseClass = "software-package-card";
|
||||
|
||||
|
|
@ -142,30 +143,19 @@ const PackageStatusCount = ({
|
|||
);
|
||||
};
|
||||
|
||||
const DROPDOWN_OPTIONS = [
|
||||
{
|
||||
label: "Download",
|
||||
value: "download",
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
value: "delete",
|
||||
},
|
||||
{
|
||||
label: "Advanced options",
|
||||
value: "advanced",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const ActionsDropdown = ({
|
||||
onDownloadClick,
|
||||
onDeleteClick,
|
||||
onAdvancedOptionsClick,
|
||||
}: {
|
||||
interface IActionsDropdownProps {
|
||||
isSoftwarePackage: boolean;
|
||||
onDownloadClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onAdvancedOptionsClick: () => void;
|
||||
}) => {
|
||||
}
|
||||
|
||||
const ActionsDropdown = ({
|
||||
isSoftwarePackage,
|
||||
onDownloadClick,
|
||||
onDeleteClick,
|
||||
onAdvancedOptionsClick,
|
||||
}: IActionsDropdownProps) => {
|
||||
const onSelect = (value: string) => {
|
||||
switch (value) {
|
||||
case "download":
|
||||
|
|
@ -189,20 +179,42 @@ const ActionsDropdown = ({
|
|||
onChange={onSelect}
|
||||
placeholder="Actions"
|
||||
searchable={false}
|
||||
options={DROPDOWN_OPTIONS}
|
||||
options={
|
||||
isSoftwarePackage
|
||||
? SOFTWARE_PACAKGE_DROPDOWN_OPTIONS
|
||||
: APP_STORE_APP_DROPDOWN_OPTIONS
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISoftwarePackageCardProps {
|
||||
softwarePackage: ISoftwarePackage;
|
||||
name: string;
|
||||
version: string;
|
||||
uploadedAt: string; // TODO: optional?
|
||||
status: {
|
||||
installed: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
};
|
||||
isSelfService: boolean;
|
||||
softwareId: number;
|
||||
teamId: number;
|
||||
// NOTE: we will only have this if we are working with a software package.
|
||||
softwarePackage?: ISoftwarePackage;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
// NOTE: This component is depeent on having either a software package
|
||||
// (ISoftwarePackage) or an app store app (IAppStoreApp). If we add more types
|
||||
// of packages we should consider refactoring this to be more dynamic.
|
||||
const SoftwarePackageCard = ({
|
||||
name,
|
||||
version,
|
||||
uploadedAt,
|
||||
status,
|
||||
isSelfService,
|
||||
softwarePackage,
|
||||
softwareId,
|
||||
teamId,
|
||||
|
|
@ -246,7 +258,7 @@ const SoftwarePackageCard = ({
|
|||
`Byte size (${resp.data.size}) does not match content-length header (${contentLength})`
|
||||
);
|
||||
}
|
||||
const filename = softwarePackage.name;
|
||||
const filename = name;
|
||||
const file = new File([resp.data], filename, {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
|
|
@ -260,10 +272,33 @@ const SoftwarePackageCard = ({
|
|||
}
|
||||
FileSaver.saveAs(file);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
renderFlash("error", "Couldn’t download. Please try again.");
|
||||
renderFlash("error", "Couldn't download. Please try again.");
|
||||
}
|
||||
}, [renderFlash, softwareId, softwarePackage.name, teamId]);
|
||||
}, [renderFlash, softwareId, name, teamId]);
|
||||
|
||||
const renderIcon = () => {
|
||||
return softwarePackage ? (
|
||||
<Graphic name="file-pkg" />
|
||||
) : (
|
||||
<SoftwareIcon name="appStore" size="medium" />
|
||||
);
|
||||
};
|
||||
|
||||
const renderDetails = () => {
|
||||
return !uploadedAt ? (
|
||||
<span>Version {version}</span>
|
||||
) : (
|
||||
<>
|
||||
<span>Version {version} • </span>
|
||||
<TooltipWrapper
|
||||
tipContent={internationalTimeFormat(new Date(uploadedAt))}
|
||||
underline={false}
|
||||
>
|
||||
{uploadedFromNow(uploadedAt)}
|
||||
</TooltipWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const showActions =
|
||||
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
|
||||
|
|
@ -274,45 +309,35 @@ const SoftwarePackageCard = ({
|
|||
{/* TODO: main-info could be a seperate component as its reused on a couple
|
||||
pages already. Come back and pull this into a component */}
|
||||
<div className={`${baseClass}__main-info`}>
|
||||
<Graphic name="file-pkg" />
|
||||
{renderIcon()}
|
||||
<div className={`${baseClass}__info`}>
|
||||
<SoftwareName name={softwarePackage.name} />
|
||||
<span className={`${baseClass}__details`}>
|
||||
<span>Version {softwarePackage.version} • </span>
|
||||
<TooltipWrapper
|
||||
tipContent={internationalTimeFormat(
|
||||
new Date(softwarePackage.uploaded_at)
|
||||
)}
|
||||
underline={false}
|
||||
>
|
||||
{uploadedFromNow(softwarePackage.uploaded_at)}
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
<SoftwareName name={name} />
|
||||
<span className={`${baseClass}__details`}>{renderDetails()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${baseClass}__package-statuses`}>
|
||||
<PackageStatusCount
|
||||
softwareId={softwareId}
|
||||
status="installed"
|
||||
count={softwarePackage.status.installed}
|
||||
count={status.installed}
|
||||
teamId={teamId}
|
||||
/>
|
||||
<PackageStatusCount
|
||||
softwareId={softwareId}
|
||||
status="pending"
|
||||
count={softwarePackage.status.pending}
|
||||
count={status.pending}
|
||||
teamId={teamId}
|
||||
/>
|
||||
<PackageStatusCount
|
||||
softwareId={softwareId}
|
||||
status="failed"
|
||||
count={softwarePackage.status.failed}
|
||||
count={status.failed}
|
||||
teamId={teamId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${baseClass}__actions-wrapper`}>
|
||||
{softwarePackage.self_service && (
|
||||
{isSelfService && (
|
||||
<div className={`${baseClass}__self-service-badge`}>
|
||||
<Icon
|
||||
name="install-self-service"
|
||||
|
|
@ -324,6 +349,7 @@ const SoftwarePackageCard = ({
|
|||
)}
|
||||
{showActions && (
|
||||
<ActionsDropdown
|
||||
isSoftwarePackage={!!softwarePackage}
|
||||
onDownloadClick={onDownloadClick}
|
||||
onDeleteClick={onDeleteClick}
|
||||
onAdvancedOptionsClick={onAdvancedOptionsClick}
|
||||
|
|
@ -332,9 +358,9 @@ const SoftwarePackageCard = ({
|
|||
</div>
|
||||
{showAdvancedOptionsModal && (
|
||||
<AdvancedOptionsModal
|
||||
installScript={softwarePackage.install_script}
|
||||
preInstallQuery={softwarePackage.pre_install_query}
|
||||
postInstallScript={softwarePackage.post_install_script}
|
||||
installScript={softwarePackage?.install_script ?? ""}
|
||||
preInstallQuery={softwarePackage?.pre_install_query}
|
||||
postInstallScript={softwarePackage?.post_install_script}
|
||||
onExit={() => setShowAdvancedOptionsModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
export const SOFTWARE_PACAKGE_DROPDOWN_OPTIONS = [
|
||||
{
|
||||
label: "Download",
|
||||
value: "download",
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
value: "delete",
|
||||
},
|
||||
{
|
||||
label: "Advanced options",
|
||||
value: "advanced",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const APP_STORE_APP_DROPDOWN_OPTIONS = [
|
||||
{
|
||||
label: "Delete",
|
||||
value: "delete",
|
||||
},
|
||||
] as const;
|
||||
|
|
@ -12,16 +12,16 @@ import useTeamIdParam from "hooks/useTeamIdParam";
|
|||
|
||||
import { AppContext } from "context/app";
|
||||
|
||||
import {
|
||||
ISoftwareTitleWithPackageDetail,
|
||||
formatSoftwareType,
|
||||
} from "interfaces/software";
|
||||
import { ISoftwareTitleDetails, formatSoftwareType } from "interfaces/software";
|
||||
import { ignoreAxiosError } from "interfaces/errors";
|
||||
import softwareAPI, {
|
||||
ISoftwareTitleResponse,
|
||||
IGetSoftwareTitleQueryKey,
|
||||
} from "services/entities/software";
|
||||
import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
|
||||
import {
|
||||
APP_CONTEXT_ALL_TEAMS_ID,
|
||||
APP_CONTEXT_NO_TEAM_ID,
|
||||
} from "interfaces/team";
|
||||
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
||||
|
||||
import Spinner from "components/Spinner";
|
||||
|
|
@ -33,6 +33,7 @@ import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary";
|
|||
import SoftwareTitleDetailsTable from "./SoftwareTitleDetailsTable";
|
||||
import DetailsNoHosts from "../components/DetailsNoHosts";
|
||||
import SoftwarePackageCard from "./SoftwarePackageCard";
|
||||
import { getPackageCardInfo } from "./helpers";
|
||||
|
||||
const baseClass = "software-title-details-page";
|
||||
|
||||
|
|
@ -83,7 +84,7 @@ const SoftwareTitleDetailsPage = ({
|
|||
} = useQuery<
|
||||
ISoftwareTitleResponse,
|
||||
AxiosError,
|
||||
ISoftwareTitleWithPackageDetail,
|
||||
ISoftwareTitleDetails,
|
||||
IGetSoftwareTitleQueryKey[]
|
||||
>(
|
||||
[{ scope: "softwareById", softwareId, teamId: teamIdForApi }],
|
||||
|
|
@ -100,6 +101,9 @@ const SoftwareTitleDetailsPage = ({
|
|||
}
|
||||
);
|
||||
|
||||
const hasSoftwarePackage = !!softwareTitle?.software_package;
|
||||
const hasAppStoreApp = !!softwareTitle?.app_store_app;
|
||||
|
||||
const onDeleteInstaller = useCallback(() => {
|
||||
if (softwareTitle?.versions?.length) {
|
||||
refetchSoftwareTitle();
|
||||
|
|
@ -120,85 +124,103 @@ const SoftwareTitleDetailsPage = ({
|
|||
[handleTeamChange]
|
||||
);
|
||||
|
||||
const hasPermission = Boolean(
|
||||
isOnGlobalTeam || isTeamAdmin || isTeamMaintainer || isTeamObserver
|
||||
);
|
||||
const hasSoftwarePackage = softwareTitle && softwareTitle.software_package;
|
||||
const showPackageCard =
|
||||
currentTeamId !== APP_CONTEXT_ALL_TEAMS_ID &&
|
||||
hasPermission &&
|
||||
hasSoftwarePackage;
|
||||
const renderSoftwarePackageCard = (title: ISoftwareTitleDetails) => {
|
||||
const hasPermission = Boolean(
|
||||
isOnGlobalTeam || isTeamAdmin || isTeamMaintainer || isTeamObserver
|
||||
);
|
||||
|
||||
const showPackageCard =
|
||||
currentTeamId !== APP_CONTEXT_ALL_TEAMS_ID &&
|
||||
hasPermission &&
|
||||
(hasSoftwarePackage || hasAppStoreApp);
|
||||
|
||||
if (showPackageCard) {
|
||||
const packageCardData = getPackageCardInfo(title);
|
||||
return (
|
||||
<SoftwarePackageCard
|
||||
softwarePackage={packageCardData.softwarePackage}
|
||||
name={packageCardData.name}
|
||||
version={packageCardData.version}
|
||||
uploadedAt={packageCardData.uploadedAt}
|
||||
status={packageCardData.status}
|
||||
isSelfService={packageCardData.isSelfService}
|
||||
softwareId={softwareId}
|
||||
teamId={currentTeamId ?? APP_CONTEXT_NO_TEAM_ID}
|
||||
onDelete={onDeleteInstaller}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isSoftwareTitleLoading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (!softwareTitle && !isSoftwareTitleError) {
|
||||
return null;
|
||||
if (isSoftwareTitleError) {
|
||||
return (
|
||||
<DetailsNoHosts
|
||||
header="Software not detected"
|
||||
details={`No hosts ${
|
||||
teamIdForApi ? "on this team " : ""
|
||||
}have this software installed.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{isPremiumTier && (
|
||||
<TeamsHeader
|
||||
isOnGlobalTeam={isOnGlobalTeam}
|
||||
currentTeamId={currentTeamId}
|
||||
userTeams={userTeams}
|
||||
onTeamChange={onTeamChange}
|
||||
|
||||
if (softwareTitle) {
|
||||
return (
|
||||
<>
|
||||
<SoftwareDetailsSummary
|
||||
title={softwareTitle.name}
|
||||
type={formatSoftwareType(softwareTitle)}
|
||||
versions={softwareTitle.versions?.length ?? 0}
|
||||
hosts={softwareTitle.hosts_count}
|
||||
queryParams={{
|
||||
software_title_id: softwareId,
|
||||
team_id: teamIdForApi,
|
||||
}}
|
||||
name={softwareTitle.name}
|
||||
source={softwareTitle.source}
|
||||
iconUrl={
|
||||
softwareTitle.app_store_app
|
||||
? softwareTitle.app_store_app.icon_url
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isSoftwareTitleError ? (
|
||||
<DetailsNoHosts
|
||||
header="Software not detected"
|
||||
details={`No hosts ${
|
||||
teamIdForApi ? "on this team " : ""
|
||||
}have this software installed.`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SoftwareDetailsSummary
|
||||
title={softwareTitle.name}
|
||||
type={formatSoftwareType(softwareTitle)}
|
||||
versions={softwareTitle.versions?.length ?? 0}
|
||||
hosts={softwareTitle.hosts_count}
|
||||
queryParams={{
|
||||
software_title_id: softwareId,
|
||||
team_id: teamIdForApi,
|
||||
}}
|
||||
name={softwareTitle.name}
|
||||
source={softwareTitle.source}
|
||||
{renderSoftwarePackageCard(softwareTitle)}
|
||||
<Card
|
||||
borderRadiusSize="xxlarge"
|
||||
includeShadow
|
||||
className={`${baseClass}__versions-section`}
|
||||
>
|
||||
<h2>Versions</h2>
|
||||
<SoftwareTitleDetailsTable
|
||||
router={router}
|
||||
data={softwareTitle.versions ?? []}
|
||||
isLoading={isSoftwareTitleLoading}
|
||||
teamIdForApi={teamIdForApi}
|
||||
/>
|
||||
{showPackageCard &&
|
||||
softwareTitle.software_package &&
|
||||
currentTeamId && (
|
||||
<SoftwarePackageCard
|
||||
softwarePackage={softwareTitle.software_package}
|
||||
softwareId={softwareId}
|
||||
teamId={currentTeamId}
|
||||
onDelete={onDeleteInstaller}
|
||||
/>
|
||||
)}
|
||||
<Card
|
||||
borderRadiusSize="xxlarge"
|
||||
includeShadow
|
||||
className={`${baseClass}__versions-section`}
|
||||
>
|
||||
<h2>Versions</h2>
|
||||
<SoftwareTitleDetailsTable
|
||||
router={router}
|
||||
data={softwareTitle.versions ?? []}
|
||||
isLoading={isSoftwareTitleLoading}
|
||||
teamIdForApi={teamIdForApi}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<MainContent className={baseClass}>
|
||||
{isPremiumTier && (
|
||||
<TeamsHeader
|
||||
isOnGlobalTeam={isOnGlobalTeam}
|
||||
currentTeamId={currentTeamId}
|
||||
userTeams={userTeams}
|
||||
onTeamChange={onTeamChange}
|
||||
/>
|
||||
)}
|
||||
<>{renderContent()}</>
|
||||
</MainContent>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import {
|
||||
IAppStoreApp,
|
||||
ISoftwareTitleDetails,
|
||||
isSoftwarePackage,
|
||||
} from "interfaces/software";
|
||||
|
||||
/**
|
||||
* Generates the data needed to render the package card.
|
||||
*/
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const getPackageCardInfo = (softwareTitle: ISoftwareTitleDetails) => {
|
||||
// we know at this point that softwareTitle.software_package or
|
||||
// softwareTitle.app_store_app is not null so we will do a type assertion.
|
||||
const packageData = softwareTitle.software_package
|
||||
? softwareTitle.software_package
|
||||
: (softwareTitle.app_store_app as IAppStoreApp);
|
||||
|
||||
return {
|
||||
softwarePackage: isSoftwarePackage(packageData) ? packageData : undefined,
|
||||
name: softwareTitle.name,
|
||||
version: isSoftwarePackage(packageData)
|
||||
? packageData.version
|
||||
: packageData.latest_version,
|
||||
uploadedAt: isSoftwarePackage(packageData) ? packageData.uploaded_at : "",
|
||||
status: packageData.status,
|
||||
isSelfService: isSoftwarePackage(packageData)
|
||||
? packageData.self_service
|
||||
: false,
|
||||
};
|
||||
};
|
||||
|
|
@ -19,10 +19,7 @@ import {
|
|||
ISoftwareTitlesResponse,
|
||||
ISoftwareVersionsResponse,
|
||||
} from "services/entities/software";
|
||||
import {
|
||||
ISoftwareTitleWithPackageName,
|
||||
ISoftwareVersion,
|
||||
} from "interfaces/software";
|
||||
import { ISoftwareTitle, ISoftwareVersion } from "interfaces/software";
|
||||
|
||||
// @ts-ignore
|
||||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
|
|
@ -163,10 +160,7 @@ const SoftwareTable = ({
|
|||
[determineQueryParamChange, generateNewQueryParams, router, currentPath]
|
||||
);
|
||||
|
||||
let tableData:
|
||||
| ISoftwareTitleWithPackageName[]
|
||||
| ISoftwareVersion[]
|
||||
| undefined;
|
||||
let tableData: ISoftwareTitle[] | ISoftwareVersion[] | undefined;
|
||||
let generateTableConfig: ITableConfigGenerator;
|
||||
|
||||
if (data === undefined) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import { CellProps, Column } from "react-table";
|
|||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import {
|
||||
ISoftwareTitleWithPackageName,
|
||||
IAppStoreApp,
|
||||
ISoftware,
|
||||
ISoftwarePackage,
|
||||
ISoftwareTitle,
|
||||
formatSoftwareType,
|
||||
} from "interfaces/software";
|
||||
import PATHS from "router/paths";
|
||||
|
|
@ -22,20 +25,17 @@ import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
|
|||
// NOTE: cellProps come from react-table
|
||||
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
|
||||
|
||||
type ISoftwareTitlesTableConfig = Column<ISoftwareTitleWithPackageName>;
|
||||
type ITableStringCellProps = IStringCellProps<ISoftwareTitleWithPackageName>;
|
||||
type IVersionsCellProps = CellProps<
|
||||
ISoftwareTitleWithPackageName,
|
||||
ISoftwareTitleWithPackageName["versions"]
|
||||
>;
|
||||
type ISoftwareTitlesTableConfig = Column<ISoftwareTitle>;
|
||||
type ITableStringCellProps = IStringCellProps<ISoftwareTitle>;
|
||||
type IVersionsCellProps = CellProps<ISoftwareTitle, ISoftwareTitle["versions"]>;
|
||||
type IVulnerabilitiesCellProps = IVersionsCellProps;
|
||||
type IHostCountCellProps = CellProps<
|
||||
ISoftwareTitleWithPackageName,
|
||||
ISoftwareTitleWithPackageName["hosts_count"]
|
||||
ISoftwareTitle,
|
||||
ISoftwareTitle["hosts_count"]
|
||||
>;
|
||||
type IViewAllHostsLinkProps = CellProps<ISoftwareTitleWithPackageName>;
|
||||
type IViewAllHostsLinkProps = CellProps<ISoftwareTitle>;
|
||||
|
||||
type ITableHeaderProps = IHeaderProps<ISoftwareTitleWithPackageName>;
|
||||
type ITableHeaderProps = IHeaderProps<ISoftwareTitle>;
|
||||
|
||||
export const getVulnerabilities = <
|
||||
T extends { vulnerabilities: string[] | null }
|
||||
|
|
@ -57,6 +57,41 @@ export const getVulnerabilities = <
|
|||
return vulnerabilities;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the data needed to render the software name cell.
|
||||
*/
|
||||
const getSoftwareNameCellData = (
|
||||
softwareTitle: ISoftwareTitle,
|
||||
teamId?: number
|
||||
) => {
|
||||
const teamQueryParam = buildQueryStringFromParams({ team_id: teamId });
|
||||
const softwareTitleDetailsPath = `${PATHS.SOFTWARE_TITLE_DETAILS(
|
||||
softwareTitle.id.toString()
|
||||
)}?${teamQueryParam}`;
|
||||
|
||||
const { software_package, app_store_app } = softwareTitle;
|
||||
let hasPackage = false;
|
||||
let isSelfService = false;
|
||||
let iconUrl: string | null = null;
|
||||
if (software_package) {
|
||||
hasPackage = true;
|
||||
isSelfService = software_package.self_service;
|
||||
} else if (app_store_app) {
|
||||
hasPackage = true;
|
||||
isSelfService = false;
|
||||
iconUrl = app_store_app.icon_url;
|
||||
}
|
||||
|
||||
return {
|
||||
name: softwareTitle.name,
|
||||
source: softwareTitle.source,
|
||||
path: softwareTitleDetailsPath,
|
||||
hasPackage: hasPackage && !!teamId,
|
||||
isSelfService,
|
||||
iconUrl,
|
||||
};
|
||||
};
|
||||
|
||||
const generateTableHeaders = (
|
||||
router: InjectedRouter,
|
||||
teamId?: number
|
||||
|
|
@ -69,29 +104,20 @@ const generateTableHeaders = (
|
|||
disableSortBy: false,
|
||||
accessor: "name",
|
||||
Cell: (cellProps: ITableStringCellProps) => {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
source,
|
||||
software_package,
|
||||
self_service,
|
||||
} = cellProps.row.original;
|
||||
|
||||
const teamQueryParam = buildQueryStringFromParams({ team_id: teamId });
|
||||
const softwareTitleDetailsPath = `${PATHS.SOFTWARE_TITLE_DETAILS(
|
||||
id.toString()
|
||||
)}?${teamQueryParam}`;
|
||||
|
||||
const hasPackage = Boolean(software_package) && !!teamId; // teamId is required for package installation
|
||||
const nameCellData = getSoftwareNameCellData(
|
||||
cellProps.row.original,
|
||||
teamId
|
||||
);
|
||||
|
||||
return (
|
||||
<SoftwareNameCell
|
||||
name={name}
|
||||
source={source}
|
||||
path={softwareTitleDetailsPath}
|
||||
name={nameCellData.name}
|
||||
source={nameCellData.source}
|
||||
path={nameCellData.path}
|
||||
router={router}
|
||||
hasPackage={hasPackage}
|
||||
isSelfService={self_service === true}
|
||||
hasPackage={nameCellData.hasPackage}
|
||||
isSelfService={nameCellData.isSelfService}
|
||||
iconUrl={nameCellData.iconUrl ?? undefined}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
112
frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx
Normal file
112
frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import softwareAPI from "services/entities/software";
|
||||
import { QueryParams, buildQueryStringFromParams } from "utilities/url";
|
||||
|
||||
import AddPackageForm from "../AddPackageForm";
|
||||
import { IAddSoftwareFormData } from "../AddPackageForm/AddSoftwareForm";
|
||||
import { getErrorMessage } from "../AddSoftwareModal/helpers";
|
||||
|
||||
const baseClass = "add-package";
|
||||
|
||||
// 8 minutes + 15 seconds to account for extra roundtrip time.
|
||||
const UPLOAD_TIMEOUT = (8 * 60 + 15) * 1000;
|
||||
const MAX_FILE_SIZE_MB = 500;
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
|
||||
interface IAddPackageProps {
|
||||
teamId: number;
|
||||
router: InjectedRouter;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const AddPackage = ({ teamId, router, onExit }: IAddPackageProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
// Next line with e.returnValue is included for legacy support
|
||||
// e.g.Chrome / Edge < 119
|
||||
e.returnValue = true;
|
||||
};
|
||||
|
||||
// set up event listener to prevent user from leaving page while uploading
|
||||
if (isUploading) {
|
||||
addEventListener("beforeunload", beforeUnloadHandler);
|
||||
timeout = setTimeout(() => {
|
||||
removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
}, UPLOAD_TIMEOUT);
|
||||
} else {
|
||||
removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
}
|
||||
|
||||
// clean up event listener and timeout on component unmount
|
||||
return () => {
|
||||
removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [isUploading]);
|
||||
|
||||
const onAddPackage = async (formData: IAddSoftwareFormData) => {
|
||||
setIsUploading(true);
|
||||
|
||||
if (formData.software && formData.software.size > MAX_FILE_SIZE_BYTES) {
|
||||
renderFlash(
|
||||
"error",
|
||||
`Couldn't add. The maximum file size is ${MAX_FILE_SIZE_MB} MB.`
|
||||
);
|
||||
onExit();
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: confirm we are deleting the second sentence (not modifying it) for non-self-service installers
|
||||
try {
|
||||
await softwareAPI.addSoftwarePackage(formData, teamId, UPLOAD_TIMEOUT);
|
||||
renderFlash(
|
||||
"success",
|
||||
<>
|
||||
<b>{formData.software?.name}</b> successfully added.
|
||||
{formData.selfService
|
||||
? " The end user can install from Fleet Desktop."
|
||||
: ""}
|
||||
</>
|
||||
);
|
||||
|
||||
const newQueryParams: QueryParams = { team_id: teamId };
|
||||
if (formData.selfService) {
|
||||
newQueryParams.self_service = true;
|
||||
} else {
|
||||
newQueryParams.available_for_install = true;
|
||||
}
|
||||
|
||||
router.push(
|
||||
`${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(newQueryParams)}`
|
||||
);
|
||||
} catch (e) {
|
||||
renderFlash("error", getErrorMessage(e));
|
||||
}
|
||||
|
||||
onExit();
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<AddPackageForm
|
||||
isUploading={isUploading}
|
||||
onCancel={onExit}
|
||||
onSubmit={onAddPackage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPackage;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.add-package {
|
||||
margin-top: $pad-large;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./AddPackage";
|
||||
|
|
@ -1,25 +1,15 @@
|
|||
import React, { useContext, useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { InjectedRouter } from "react-router";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { Tab, TabList, TabPanel, Tabs } from "react-tabs";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
|
||||
import softwareAPI from "services/entities/software";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { QueryParams, buildQueryStringFromParams } from "utilities/url";
|
||||
import { IApiError } from "interfaces/errors";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import TabsWrapper from "components/TabsWrapper";
|
||||
|
||||
import AddSoftwareForm from "../AddSoftwareForm";
|
||||
import { IAddSoftwareFormData } from "../AddSoftwareForm/AddSoftwareForm";
|
||||
import { getErrorMessage } from "./helpers";
|
||||
|
||||
// 8 minutes + 15 seconds to account for extra roundtrip time.
|
||||
const UPLOAD_TIMEOUT = (8 * 60 + 15) * 1000;
|
||||
const MAX_FILE_SIZE_MB = 500;
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
import AppStoreVpp from "../AppStoreVpp";
|
||||
import AddPackage from "../AddPackage";
|
||||
|
||||
const baseClass = "add-software-modal";
|
||||
|
||||
|
|
@ -54,81 +44,6 @@ const AddSoftwareModal = ({
|
|||
router,
|
||||
onExit,
|
||||
}: IAddSoftwareModalProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
// Next line with e.returnValue is included for legacy support
|
||||
// e.g.Chrome / Edge < 119
|
||||
e.returnValue = true;
|
||||
};
|
||||
|
||||
// set up event listener to prevent user from leaving page while uploading
|
||||
if (isUploading) {
|
||||
addEventListener("beforeunload", beforeUnloadHandler);
|
||||
timeout = setTimeout(() => {
|
||||
removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
}, UPLOAD_TIMEOUT);
|
||||
} else {
|
||||
removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
}
|
||||
|
||||
// clean up event listener and timeout on component unmount
|
||||
return () => {
|
||||
removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [isUploading]);
|
||||
|
||||
const onAddSoftware = async (formData: IAddSoftwareFormData) => {
|
||||
setIsUploading(true);
|
||||
|
||||
if (formData.software && formData.software.size > MAX_FILE_SIZE_BYTES) {
|
||||
renderFlash(
|
||||
"error",
|
||||
`Couldn’t add. The maximum file size is ${MAX_FILE_SIZE_MB} MB.`
|
||||
);
|
||||
onExit();
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: confirm we are deleting the second sentence (not modifying it) for non-self-service installers
|
||||
try {
|
||||
await softwareAPI.addSoftwarePackage(formData, teamId, UPLOAD_TIMEOUT);
|
||||
renderFlash(
|
||||
"success",
|
||||
<>
|
||||
<b>{formData.software?.name}</b> successfully added.
|
||||
{formData.selfService
|
||||
? " The end user can install from Fleet Desktop."
|
||||
: ""}
|
||||
</>
|
||||
);
|
||||
onExit();
|
||||
|
||||
const newQueryParams: QueryParams = { team_id: teamId };
|
||||
if (formData.selfService) {
|
||||
newQueryParams.self_service = true;
|
||||
} else {
|
||||
newQueryParams.available_for_install = true;
|
||||
}
|
||||
|
||||
router.push(
|
||||
`${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(newQueryParams)}`
|
||||
);
|
||||
} catch (e) {
|
||||
renderFlash("error", getErrorMessage(e));
|
||||
onExit();
|
||||
}
|
||||
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add software"
|
||||
|
|
@ -140,11 +55,20 @@ const AddSoftwareModal = ({
|
|||
{teamId === APP_CONTEXT_ALL_TEAMS_ID ? (
|
||||
<AllTeamsMessage onExit={onExit} />
|
||||
) : (
|
||||
<AddSoftwareForm
|
||||
isUploading={isUploading}
|
||||
onCancel={onExit}
|
||||
onSubmit={onAddSoftware}
|
||||
/>
|
||||
<TabsWrapper className={`${baseClass}__tabs`}>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>Package</Tab>
|
||||
<Tab>App Store (VPP)</Tab>
|
||||
</TabList>
|
||||
<TabPanel>
|
||||
<AddPackage teamId={teamId} router={router} onExit={onExit} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<AppStoreVpp teamId={teamId} router={router} onExit={onExit} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</TabsWrapper>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
.add-software-modal {
|
||||
|
||||
// have to use this selector to override the default styles
|
||||
.component__tabs-wrapper {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
import React, { useContext, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { InjectedRouter } from "react-router";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import mdmAppleAPI, {
|
||||
IGetVppInfoResponse,
|
||||
IVppApp,
|
||||
} from "services/entities/mdm_apple";
|
||||
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
||||
|
||||
import Card from "components/Card";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import Spinner from "components/Spinner";
|
||||
import Button from "components/buttons/Button";
|
||||
import DataError from "components/DataError";
|
||||
import Radio from "components/forms/fields/Radio";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
import { buildQueryStringFromParams } from "utilities/url";
|
||||
import SoftwareIcon from "../icons/SoftwareIcon";
|
||||
import { getErrorMessage } from "./helpers";
|
||||
|
||||
const baseClass = "app-store-vpp";
|
||||
|
||||
const EnableVppCard = () => {
|
||||
return (
|
||||
<Card borderRadiusSize="medium">
|
||||
<div className={`${baseClass}__enable-vpp`}>
|
||||
<p className={`${baseClass}__enable-vpp-title`}>
|
||||
<b>Volume Purchasing Program (VPP) isn’t enabled.</b>
|
||||
</p>
|
||||
<p className={`${baseClass}__enable-vpp-description`}>
|
||||
To add App Store apps, first enable VPP.
|
||||
</p>
|
||||
<CustomLink
|
||||
url={PATHS.ADMIN_INTEGRATIONS_VPP}
|
||||
text="Enable VPP"
|
||||
className={`${baseClass}__enable-vpp-link`}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface IVppAppListItemProps {
|
||||
app: IVppApp;
|
||||
selected: boolean;
|
||||
onSelect: (software: IVppApp) => void;
|
||||
}
|
||||
|
||||
const VppAppListItem = ({ app, selected, onSelect }: IVppAppListItemProps) => {
|
||||
return (
|
||||
<li className={`${baseClass}__list-item`}>
|
||||
<Radio
|
||||
label={
|
||||
<div className={`${baseClass}__app-info`}>
|
||||
<SoftwareIcon url={app.icon_url} />
|
||||
<span>{app.name}</span>
|
||||
</div>
|
||||
}
|
||||
id={`vppApp-${app.app_store_id}`}
|
||||
checked={selected}
|
||||
value={app.app_store_id.toString()}
|
||||
name="vppApp"
|
||||
onChange={() => onSelect(app)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
interface IVppAppListProps {
|
||||
apps: IVppApp[];
|
||||
selectedApp: IVppApp | null;
|
||||
onSelect: (app: IVppApp) => void;
|
||||
}
|
||||
|
||||
const VppAppList = ({ apps, selectedApp, onSelect }: IVppAppListProps) => {
|
||||
const renderContent = () => {
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<div className={`${baseClass}__no-software`}>
|
||||
<p className={`${baseClass}__no-software-title`}>
|
||||
You don't have any App Store apps
|
||||
</p>
|
||||
<p className={`${baseClass}__no-software-description`}>
|
||||
You must purchase apps in ABM. App Store apps that are already added
|
||||
to this team are not listed.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={`${baseClass}__list`}>
|
||||
{apps.map((app) => (
|
||||
<VppAppListItem
|
||||
key={app.app_store_id}
|
||||
app={app}
|
||||
selected={selectedApp?.app_store_id === app.app_store_id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__list-container`}>{renderContent()}</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IAppStoreVppProps {
|
||||
teamId: number;
|
||||
router: InjectedRouter;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const AppStoreVpp = ({ teamId, router, onExit }: IAppStoreVppProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [isSubmitDisabled, setIsSubmitDisabled] = useState(true);
|
||||
const [selectedApp, setSelectedApp] = useState<IVppApp | null>(null);
|
||||
|
||||
const {
|
||||
data: vppInfo,
|
||||
isLoading: isLoadingVppInfo,
|
||||
error: errorVppInfo,
|
||||
} = useQuery<IGetVppInfoResponse, AxiosError>(
|
||||
["vppInfo"],
|
||||
() => mdmAppleAPI.getVppInfo(),
|
||||
{
|
||||
...DEFAULT_USE_QUERY_OPTIONS,
|
||||
staleTime: 30000,
|
||||
retry: (tries, error) => error.status !== 404 && tries <= 3,
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: vppApps,
|
||||
isLoading: isLoadingVppApps,
|
||||
error: errorVppApps,
|
||||
} = useQuery(["vppSoftware", teamId], () => mdmAppleAPI.getVppApps(teamId), {
|
||||
...DEFAULT_USE_QUERY_OPTIONS,
|
||||
enabled: !!vppInfo,
|
||||
staleTime: 30000,
|
||||
select: (res) => res.app_store_apps,
|
||||
});
|
||||
|
||||
const onSelectApp = (app: IVppApp) => {
|
||||
setIsSubmitDisabled(false);
|
||||
setSelectedApp(app);
|
||||
};
|
||||
|
||||
const onAddSoftware = async () => {
|
||||
if (!selectedApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await mdmAppleAPI.addVppApp(teamId, selectedApp.app_store_id);
|
||||
renderFlash(
|
||||
"success",
|
||||
<>
|
||||
<b>{selectedApp.name}</b> successfully added. Go to Host details page
|
||||
to install software.
|
||||
</>
|
||||
);
|
||||
const queryParams = buildQueryStringFromParams({
|
||||
team_id: teamId,
|
||||
available_for_install: true,
|
||||
});
|
||||
router.push(`${PATHS.SOFTWARE}?${queryParams}`);
|
||||
} catch (e) {
|
||||
renderFlash("error", getErrorMessage(e));
|
||||
}
|
||||
onExit();
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoadingVppInfo || isLoadingVppApps) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (
|
||||
errorVppInfo &&
|
||||
getErrorReason(errorVppInfo).includes("MDMConfigAsset was not found")
|
||||
) {
|
||||
return <EnableVppCard />;
|
||||
}
|
||||
|
||||
if (errorVppInfo || errorVppApps) {
|
||||
return <DataError className={`${baseClass}__error`} />;
|
||||
}
|
||||
|
||||
return vppApps ? (
|
||||
<VppAppList
|
||||
apps={vppApps}
|
||||
selectedApp={selectedApp}
|
||||
onSelect={onSelectApp}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<p className={`${baseClass}__description`}>
|
||||
Apple App Store apps purchased via Apple Business Manager.
|
||||
</p>
|
||||
{renderContent()}
|
||||
<div className="modal-cta-wrap">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="brand"
|
||||
disabled={isSubmitDisabled}
|
||||
onClick={onAddSoftware}
|
||||
>
|
||||
Add software
|
||||
</Button>
|
||||
<Button onClick={onExit} variant="inverse">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppStoreVpp;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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} <b>{match[1]}</b> already has software
|
||||
available for install on the <b>{match[2]}</b> 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;
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./AppStoreVpp";
|
||||
|
|
@ -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 (
|
||||
<div className={baseClass}>
|
||||
<SoftwareIcon name={name} source={source} size="large" />
|
||||
<SoftwareIcon name={name} source={source} url={iconUrl} size="xlarge" />
|
||||
<dl className={`${baseClass}__info`}>
|
||||
<h1>{title}</h1>
|
||||
<dl className={`${baseClass}__description-list`}>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import TooltipWrapper from "components/TooltipWrapper";
|
|||
|
||||
const generateText = <T extends { version: string }>(versions: T[] | null) => {
|
||||
if (!versions) {
|
||||
return <TextCell value="---" grey italic />;
|
||||
return <TextCell value="---" grey />;
|
||||
}
|
||||
const text =
|
||||
versions.length !== 1 ? `${versions.length} versions` : versions[0].version;
|
||||
|
|
|
|||
|
|
@ -12,15 +12,13 @@ const baseClass = "vulnerabilities-cell";
|
|||
const generateCell = (
|
||||
vulnerabilities: ISoftwareVulnerability[] | string[] | null
|
||||
) => {
|
||||
if (vulnerabilities === null) {
|
||||
return <TextCell value="---" grey italic />;
|
||||
if (vulnerabilities === null || vulnerabilities.length === 0) {
|
||||
return <TextCell value="---" grey />;
|
||||
}
|
||||
|
||||
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"
|
||||
|
|
|
|||
14
frontend/pages/SoftwarePage/components/icons/AppStore.tsx
Normal file
14
frontend/pages/SoftwarePage/components/icons/AppStore.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from "react";
|
||||
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const AppStore = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
|
||||
<path fill="#515774" d="M0 0h32v32H0z" />
|
||||
<path
|
||||
d="m15.96 7.564.648-1.12A1.458 1.458 0 1 1 19.136 7.9l-6.244 10.808h4.516c1.464 0 2.284 1.72 1.648 2.912H5.816a1.451 1.451 0 0 1-1.456-1.456c0-.808.648-1.456 1.456-1.456h3.712l4.752-8.236-1.484-2.576a1.46 1.46 0 0 1 2.528-1.456l.636 1.124ZM10.344 23.12l-1.4 2.428a1.458 1.458 0 1 1-2.528-1.456l1.04-1.8c1.176-.364 2.132-.084 2.888.828ZM22.4 18.716h3.788c.808 0 1.456.648 1.456 1.456 0 .808-.648 1.456-1.456 1.456h-2.104l1.42 2.464a1.46 1.46 0 0 1-2.528 1.456c-2.392-4.148-4.188-7.252-5.38-9.32-1.22-2.104-.348-4.216.512-4.932.956 1.64 2.384 4.116 4.292 7.42Z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default AppStore;
|
||||
|
|
@ -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<string, string> = {
|
||||
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<SoftwareIconSizes, string> = {
|
||||
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 (
|
||||
<div className={classNames}>
|
||||
<img className={imgClasses} src={url} alt="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MatchedIcon = getMatchedSoftwareIcon({ name, source });
|
||||
return (
|
||||
<MatchedIcon
|
||||
width={SOFTWARE_ICON_SIZES[size]}
|
||||
height={SOFTWARE_ICON_SIZES[size]}
|
||||
viewBox="0 0 32 32"
|
||||
className={baseClass}
|
||||
className={classNames}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<any>[] = [
|
||||
// TODO: types
|
||||
|
|
@ -18,7 +19,7 @@ const integrationSettingsNavItems: ISideNavItem<any>[] = [
|
|||
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<any>[] = [
|
|||
path: PATHS.ADMIN_INTEGRATIONS_CALENDARS,
|
||||
Card: Calendars,
|
||||
},
|
||||
{
|
||||
title: "Volume Purchasing Program (VPP)",
|
||||
urlSection: "vpp",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_VPP,
|
||||
Card: Vpp,
|
||||
},
|
||||
];
|
||||
|
||||
export default integrationSettingsNavItems;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./MdmSettings";
|
||||
|
|
@ -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(<Vpp router={createMockRouter()} />);
|
||||
|
||||
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(<Vpp router={createMockRouter()} />);
|
||||
|
||||
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(<Vpp router={createMockRouter()} />);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Edit" })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
135
frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tsx
Normal file
135
frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tsx
Normal file
|
|
@ -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 = (
|
||||
<div className={`${baseClass}__mdm-disabled-content`}>
|
||||
<div>
|
||||
<h3>Volume Purchasing Program (VPP)</h3>
|
||||
<p>
|
||||
To enable Volume Purchasing Program (VPP) for macOS devices, first
|
||||
turn on macOS MDM.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={nagivateToMdm} variant="text-link">
|
||||
Turn on macOS MDM
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
const isOnContent = (
|
||||
<div className={`${baseClass}__vpp-on-content`}>
|
||||
<p>
|
||||
<span>
|
||||
<Icon name="success" />
|
||||
Volume Purchasing Program (VPP) enabled.
|
||||
</span>
|
||||
</p>
|
||||
<Button onClick={navigateToVppSetup} variant="text-icon">
|
||||
<Icon name="pencil" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const isOffContent = (
|
||||
<div className={`${baseClass}__vpp-off-content`}>
|
||||
<div>
|
||||
<h3>Volume Purchasing Program (VPP)</h3>
|
||||
<p>
|
||||
Install apps from Apple's App Store purchased through Apple
|
||||
Business Manager.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={navigateToVppSetup} variant="brand">
|
||||
Enable
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCardContent = () => {
|
||||
if (!isAppleMdmOn) {
|
||||
return appleMdmDiabledContent;
|
||||
}
|
||||
|
||||
return isVppOn ? isOnContent : isOffContent;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`${baseClass}__card`} color="gray">
|
||||
{renderCardContent()}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
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 <Spinner />;
|
||||
}
|
||||
|
||||
if (isError && vppError?.status !== 404) {
|
||||
return <DataError />;
|
||||
}
|
||||
|
||||
return (
|
||||
<VppCard
|
||||
isAppleMdmOn={!!config?.mdm.enabled_and_configured}
|
||||
isVppOn={!!vppData && vppError?.status !== 404}
|
||||
router={router}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<SectionHeader title="Volume Purchasing Program (VPP)" />
|
||||
<>{renderContent()}</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Vpp;
|
||||
|
|
@ -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(<VppSetupPage router={createMockRouter()} />);
|
||||
|
||||
// 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(<VppSetupPage router={createMockRouter()} />);
|
||||
|
||||
expect(await screen.findByText("Organization name")).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Disable" })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Renew token" })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<div className={`${baseClass}__setup-content`}>
|
||||
<p className={`${baseClass}__description`}>
|
||||
Connect Fleet to your Apple Business Manager account to enable access to
|
||||
purchased apps.
|
||||
</p>
|
||||
<VppSetupSteps extendendSteps />
|
||||
<FileUploader
|
||||
className={`${baseClass}__file-uploader ${
|
||||
isUploading ? `${baseClass}__file-uploader--loading` : ""
|
||||
}`}
|
||||
accept=".vpptoken"
|
||||
message="Content token (.vpptoken)"
|
||||
graphicName="file-vpp"
|
||||
buttonType="link"
|
||||
buttonMessage={isUploading ? "Uploading..." : "Upload"}
|
||||
onFileUpload={uploadToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IVppDisableOrRenewContentProps {
|
||||
vppInfo: IGetVppInfoResponse;
|
||||
onDisable: () => void;
|
||||
onRenew: () => void;
|
||||
}
|
||||
|
||||
const VPPDisableOrRenewContent = ({
|
||||
vppInfo,
|
||||
onDisable,
|
||||
onRenew,
|
||||
}: IVppDisableOrRenewContentProps) => {
|
||||
return (
|
||||
<div className={`${baseClass}__disable-renew-content`}>
|
||||
<div className={`${baseClass}__info`}>
|
||||
<DataSet title="Organization name" value={vppInfo.org_name} />
|
||||
<DataSet title="Location" value={vppInfo.location} />
|
||||
<DataSet title="Renew date" value={readableDate(vppInfo.renew_date)} />
|
||||
</div>
|
||||
<div className={`${baseClass}__button-wrap`}>
|
||||
<Button variant="inverse" onClick={onDisable}>
|
||||
Disable
|
||||
</Button>
|
||||
<Button variant="brand" onClick={onRenew}>
|
||||
Renew token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<IGetVppInfoResponse, AxiosError>(
|
||||
"vppInfo",
|
||||
() => mdmAppleAPI.getVppInfo(),
|
||||
{
|
||||
...DEFAULT_USE_QUERY_OPTIONS,
|
||||
retry: false,
|
||||
}
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (isError && vppError?.status !== 404) {
|
||||
return <DataError />;
|
||||
}
|
||||
|
||||
// 404 means there is no token, se we want to show the setup steps content
|
||||
if (vppError?.status === 404) {
|
||||
return <VPPSetupContent router={router} />;
|
||||
}
|
||||
|
||||
return vppData ? (
|
||||
<VPPDisableOrRenewContent
|
||||
vppInfo={vppData}
|
||||
onDisable={() => setShowDisableModal(true)}
|
||||
onRenew={() => setShowRenewModal(true)}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<MainContent className={baseClass}>
|
||||
<>
|
||||
<BackLink
|
||||
text="Back to automatic enrollment"
|
||||
path={PATHS.ADMIN_INTEGRATIONS_VPP}
|
||||
className={`${baseClass}__back-to-vpp`}
|
||||
/>
|
||||
<h1>Volume Purchasing Program (VPP)</h1>
|
||||
<>{renderContent()}</>
|
||||
</>
|
||||
{showDisableModal && (
|
||||
<DisableVppModal
|
||||
router={router}
|
||||
onExit={() => setShowDisableModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showRenewModal && (
|
||||
<RenewVppTokenModal
|
||||
onExit={() => setShowRenewModal(false)}
|
||||
onTokenRenewed={refetchVppInfo}
|
||||
/>
|
||||
)}
|
||||
</MainContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default VppSetupPage;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Modal
|
||||
title="Disable Volume Purchasing Program (VPP)"
|
||||
onExit={onExit}
|
||||
className={baseClass}
|
||||
>
|
||||
<>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button
|
||||
variant="alert"
|
||||
onClick={onDisableVpp}
|
||||
isLoading={isDisabling}
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
<Button onClick={onExit} variant="inverse-alert">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisableVppModal;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./DisableVppModal";
|
||||
|
|
@ -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<File | null>(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 (
|
||||
<Modal title="Renew token" className={baseClass} onExit={onExit}>
|
||||
<>
|
||||
<VppSetupSteps />
|
||||
<FileUploader
|
||||
className={`${baseClass}__file-uploader`}
|
||||
accept=".vpptoken"
|
||||
message="Content token (.vpptoken)"
|
||||
graphicName="file-vpp"
|
||||
buttonType="link"
|
||||
buttonMessage="Upload"
|
||||
filePreview={
|
||||
tokenFile && (
|
||||
<FileDetails
|
||||
details={{ name: tokenFile.name }}
|
||||
graphicName="file-vpp"
|
||||
/>
|
||||
)
|
||||
}
|
||||
onFileUpload={onSelectFile}
|
||||
/>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button
|
||||
variant="brand"
|
||||
onClick={onRenewToken}
|
||||
isLoading={isRenewing}
|
||||
disabled={!tokenFile}
|
||||
>
|
||||
Renew token
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenewVppTokenModal;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
.renew-vpp-token-modal {
|
||||
&__file-uploader {
|
||||
margin-top: $pad-medium;
|
||||
|
||||
button {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&--loading {
|
||||
label {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue