mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +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"
|
apiVersion: "1"
|
||||||
kind: software_title
|
kind: software_title
|
||||||
spec:
|
spec:
|
||||||
- hosts_count: 2
|
- app_store_app: null
|
||||||
|
hosts_count: 2
|
||||||
id: 0
|
id: 0
|
||||||
name: foo
|
name: foo
|
||||||
self_service: false
|
|
||||||
software_package: null
|
software_package: null
|
||||||
source: chrome_extensions
|
source: chrome_extensions
|
||||||
versions:
|
versions:
|
||||||
|
|
@ -713,10 +713,10 @@ spec:
|
||||||
vulnerabilities:
|
vulnerabilities:
|
||||||
- cve-123-456-003
|
- cve-123-456-003
|
||||||
versions_count: 3
|
versions_count: 3
|
||||||
- hosts_count: 0
|
- app_store_app: null
|
||||||
|
hosts_count: 0
|
||||||
id: 0
|
id: 0
|
||||||
name: bar
|
name: bar
|
||||||
self_service: false
|
|
||||||
software_package: null
|
software_package: null
|
||||||
source: deb_packages
|
source: deb_packages
|
||||||
versions:
|
versions:
|
||||||
|
|
@ -761,8 +761,8 @@ spec:
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"self_service": false,
|
"software_package": null,
|
||||||
"software_package": null
|
"app_store_app": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 0,
|
"id": 0,
|
||||||
|
|
@ -774,11 +774,11 @@ spec:
|
||||||
{
|
{
|
||||||
"id": 0,
|
"id": 0,
|
||||||
"version": "0.0.3",
|
"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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
@ -16,6 +19,7 @@ import (
|
||||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
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/mdm/nanodep/tokenpki"
|
||||||
"github.com/fleetdm/fleet/v4/server/mock"
|
"github.com/fleetdm/fleet/v4/server/mock"
|
||||||
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
|
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
|
||||||
|
|
@ -261,6 +265,12 @@ func TestBasicTeamGitOps(t *testing.T) {
|
||||||
|
|
||||||
const secret = "TestSecret"
|
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.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil }
|
||||||
ds.BatchSetMDMProfilesFunc = func(
|
ds.BatchSetMDMProfilesFunc = func(
|
||||||
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration,
|
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 {
|
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
|
||||||
return nil
|
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 {
|
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
|
||||||
enrolledSecrets = secrets
|
enrolledSecrets = secrets
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -867,6 +882,13 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) {
|
||||||
return nil
|
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 (
|
const (
|
||||||
fleetServerURL = "https://fleet.example.com"
|
fleetServerURL = "https://fleet.example.com"
|
||||||
orgName = "GitOps Test"
|
orgName = "GitOps Test"
|
||||||
|
|
@ -1149,6 +1171,13 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) {
|
||||||
}, nil
|
}, 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"
|
globalFile := "./testdata/gitops/global_config_no_paths.yml"
|
||||||
teamFile := "./testdata/gitops/team_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_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_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_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 {
|
for _, c := range cases {
|
||||||
t.Run(filepath.Base(c.file), func(t *testing.T) {
|
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})
|
_, err := runAppNoChecks([]string{"gitops", "-f", c.file})
|
||||||
if c.wantErr == "" {
|
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) {
|
func TestCustomSettingsGitOps(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
file string
|
file string
|
||||||
|
|
@ -1243,6 +1372,12 @@ func TestCustomSettingsGitOps(t *testing.T) {
|
||||||
}
|
}
|
||||||
return ret, nil
|
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})
|
_, err := runAppNoChecks([]string{"gitops", "-f", c.file})
|
||||||
if c.wantErr == "" {
|
if c.wantErr == "" {
|
||||||
|
|
@ -1288,6 +1423,140 @@ func startSoftwareInstallerServer(t *testing.T) {
|
||||||
t.Setenv("SOFTWARE_INSTALLER_URL", srv.URL)
|
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) {
|
func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, **fleet.Team) {
|
||||||
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
|
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -1322,6 +1591,12 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig,
|
||||||
savedAppConfig = &appConfigCopy
|
savedAppConfig = &appConfigCopy
|
||||||
return nil
|
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
|
var savedTeam *fleet.Team
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": null,
|
"scripts": null,
|
||||||
"software": null,
|
|
||||||
"user_count": 99,
|
"user_count": 99,
|
||||||
"host_count": 42
|
"host_count": 42
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +144,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": null,
|
"scripts": null,
|
||||||
"software": null,
|
|
||||||
"user_count": 87,
|
"user_count": 87,
|
||||||
"host_count": 43
|
"host_count": 43
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ spec:
|
||||||
macos_setup_assistant:
|
macos_setup_assistant:
|
||||||
scripts: null
|
scripts: null
|
||||||
secrets: null
|
secrets: null
|
||||||
software: null
|
|
||||||
webhook_settings:
|
webhook_settings:
|
||||||
host_status_webhook: null
|
host_status_webhook: null
|
||||||
name: team1
|
name: team1
|
||||||
|
|
@ -86,7 +85,6 @@ spec:
|
||||||
enable_release_device_manually: false
|
enable_release_device_manually: false
|
||||||
macos_setup_assistant:
|
macos_setup_assistant:
|
||||||
scripts: null
|
scripts: null
|
||||||
software: null
|
|
||||||
webhook_settings:
|
webhook_settings:
|
||||||
host_status_webhook: null
|
host_status_webhook: null
|
||||||
name: team2
|
name: team2
|
||||||
|
|
|
||||||
|
|
@ -116,12 +116,13 @@ policies:
|
||||||
resolution: There is no resolution for this policy.
|
resolution: There is no resolution for this policy.
|
||||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||||
software:
|
software:
|
||||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
packages:
|
||||||
install_script:
|
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||||
path: lib/install_ruby.sh
|
install_script:
|
||||||
pre_install_query:
|
path: lib/install_ruby.sh
|
||||||
path: lib/query_ruby.yml
|
pre_install_query:
|
||||||
post_install_script:
|
path: lib/query_ruby.yml
|
||||||
path: lib/post_install_ruby.sh
|
post_install_script:
|
||||||
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
|
path: lib/post_install_ruby.sh
|
||||||
self_service: true
|
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
|
||||||
|
self_service: true
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ controls:
|
||||||
policies:
|
policies:
|
||||||
queries:
|
queries:
|
||||||
software:
|
software:
|
||||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
packages:
|
||||||
install_script:
|
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||||
path: lib/notfound.sh
|
install_script:
|
||||||
|
path: lib/notfound.sh
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,6 @@ controls:
|
||||||
policies:
|
policies:
|
||||||
queries:
|
queries:
|
||||||
software:
|
software:
|
||||||
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
|
packages:
|
||||||
self_service: "not a boolean"
|
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
|
||||||
|
self_service: "not a boolean"
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,10 @@ controls:
|
||||||
policies:
|
policies:
|
||||||
queries:
|
queries:
|
||||||
software:
|
software:
|
||||||
- install_script:
|
packages:
|
||||||
path: lib/install_ruby.sh
|
- install_script:
|
||||||
pre_install_query:
|
path: lib/install_ruby.sh
|
||||||
path: lib/query_ruby.yml
|
pre_install_query:
|
||||||
post_install_script:
|
path: lib/query_ruby.yml
|
||||||
path: lib/post_install_ruby.sh
|
post_install_script:
|
||||||
|
path: lib/post_install_ruby.sh
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,5 @@ controls:
|
||||||
policies:
|
policies:
|
||||||
queries:
|
queries:
|
||||||
software:
|
software:
|
||||||
- url: ${SOFTWARE_INSTALLER_URL}/notfound.deb
|
packages:
|
||||||
|
- url: ${SOFTWARE_INSTALLER_URL}/notfound.deb
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,9 @@ controls:
|
||||||
policies:
|
policies:
|
||||||
queries:
|
queries:
|
||||||
software:
|
software:
|
||||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
packages:
|
||||||
install_script:
|
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||||
path: lib/install_ruby.sh
|
install_script:
|
||||||
post_install_script:
|
path: lib/install_ruby.sh
|
||||||
path: lib/notfound.sh
|
post_install_script:
|
||||||
|
path: lib/notfound.sh
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,11 @@ controls:
|
||||||
policies:
|
policies:
|
||||||
queries:
|
queries:
|
||||||
software:
|
software:
|
||||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
packages:
|
||||||
install_script:
|
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||||
path: lib/install_ruby.sh
|
install_script:
|
||||||
pre_install_query:
|
path: lib/install_ruby.sh
|
||||||
path: lib/query_multiple.yml
|
pre_install_query:
|
||||||
post_install_script:
|
path: lib/query_multiple.yml
|
||||||
path: lib/post_install_ruby.sh
|
post_install_script:
|
||||||
|
path: lib/post_install_ruby.sh
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,9 @@ controls:
|
||||||
policies:
|
policies:
|
||||||
queries:
|
queries:
|
||||||
software:
|
software:
|
||||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
packages:
|
||||||
install_script:
|
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||||
path: lib/install_ruby.sh
|
install_script:
|
||||||
pre_install_query:
|
path: lib/install_ruby.sh
|
||||||
path: lib/notfound.yml
|
pre_install_query:
|
||||||
|
path: lib/notfound.yml
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,5 @@ controls:
|
||||||
policies:
|
policies:
|
||||||
queries:
|
queries:
|
||||||
software:
|
software:
|
||||||
- url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb
|
packages:
|
||||||
|
- url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,5 @@ controls:
|
||||||
policies:
|
policies:
|
||||||
queries:
|
queries:
|
||||||
software:
|
software:
|
||||||
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
|
packages:
|
||||||
|
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,13 @@ controls:
|
||||||
policies:
|
policies:
|
||||||
queries:
|
queries:
|
||||||
software:
|
software:
|
||||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
packages:
|
||||||
install_script:
|
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||||
path: lib/install_ruby.sh
|
install_script:
|
||||||
pre_install_query:
|
path: lib/install_ruby.sh
|
||||||
path: lib/query_ruby.yml
|
pre_install_query:
|
||||||
post_install_script:
|
path: lib/query_ruby.yml
|
||||||
path: lib/post_install_ruby.sh
|
post_install_script:
|
||||||
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
|
path: lib/post_install_ruby.sh
|
||||||
self_service: true
|
- 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
|
grace_period_days: null
|
||||||
scripts: null
|
scripts: null
|
||||||
secrets: null
|
secrets: null
|
||||||
software: null
|
|
||||||
webhook_settings:
|
webhook_settings:
|
||||||
host_status_webhook: null
|
host_status_webhook: null
|
||||||
name: tm1
|
name: tm1
|
||||||
|
|
@ -77,7 +76,6 @@ spec:
|
||||||
grace_period_days: null
|
grace_period_days: null
|
||||||
scripts: null
|
scripts: null
|
||||||
secrets: null
|
secrets: null
|
||||||
software: null
|
|
||||||
webhook_settings:
|
webhook_settings:
|
||||||
host_status_webhook: null
|
host_status_webhook: null
|
||||||
name: tm2
|
name: tm2
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ spec:
|
||||||
grace_period_days: null
|
grace_period_days: null
|
||||||
scripts: null
|
scripts: null
|
||||||
secrets: null
|
secrets: null
|
||||||
software: null
|
|
||||||
webhook_settings:
|
webhook_settings:
|
||||||
host_status_webhook: null
|
host_status_webhook: null
|
||||||
name: tm1
|
name: tm1
|
||||||
|
|
@ -77,7 +76,6 @@ spec:
|
||||||
grace_period_days: null
|
grace_period_days: null
|
||||||
scripts: null
|
scripts: null
|
||||||
secrets: null
|
secrets: null
|
||||||
software: null
|
|
||||||
webhook_settings:
|
webhook_settings:
|
||||||
host_status_webhook: null
|
host_status_webhook: null
|
||||||
name: tm2
|
name: tm2
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ spec:
|
||||||
custom_settings: null
|
custom_settings: null
|
||||||
scripts: null
|
scripts: null
|
||||||
secrets: null
|
secrets: null
|
||||||
software: null
|
|
||||||
webhook_settings:
|
webhook_settings:
|
||||||
host_status_webhook: null
|
host_status_webhook: null
|
||||||
name: tm1
|
name: tm1
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ spec:
|
||||||
grace_period_days: null
|
grace_period_days: null
|
||||||
scripts: null
|
scripts: null
|
||||||
secrets: null
|
secrets: null
|
||||||
software: null
|
|
||||||
webhook_settings:
|
webhook_settings:
|
||||||
host_status_webhook: null
|
host_status_webhook: null
|
||||||
name: tm1
|
name: tm1
|
||||||
|
|
|
||||||
|
|
@ -1200,7 +1200,7 @@ Generated when a software installer is deleted from Fleet.
|
||||||
This activity contains the following fields:
|
This activity contains the following fields:
|
||||||
- "software_title": Name of the software.
|
- "software_title": Name of the software.
|
||||||
- "software_package": Filename of the installer.
|
- "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.
|
- "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.
|
- "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="title" value="Audit logs">
|
||||||
<meta name="pageOrderInSection" value="1400">
|
<meta name="pageOrderInSection" value="1400">
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,14 @@ import (
|
||||||
|
|
||||||
"github.com/fleetdm/fleet/v4/pkg/file"
|
"github.com/fleetdm/fleet/v4/pkg/file"
|
||||||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||||||
|
"github.com/fleetdm/fleet/v4/server/authz"
|
||||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||||
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
|
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
|
||||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||||
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
|
||||||
"github.com/go-kit/log/level"
|
"github.com/go-kit/log/level"
|
||||||
|
"github.com/google/uuid"
|
||||||
"golang.org/x/sync/errgroup"
|
"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")
|
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 {
|
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// first, look for a software installer
|
||||||
meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false)
|
meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false)
|
||||||
if err != nil {
|
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 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)
|
vc, ok := viewer.FromContext(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fleet.ErrNoContext
|
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
|
// fleetd is required to install software so if the host is
|
||||||
// enrolled via plain osquery we return an error
|
// enrolled via plain osquery we return an error
|
||||||
svc.authz.SkipAuthorization(ctx)
|
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)
|
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)
|
installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false)
|
||||||
if err != nil {
|
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) {
|
if fleet.IsNotFound(err) {
|
||||||
return &fleet.BadRequestError{
|
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(
|
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},
|
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)
|
ext := filepath.Ext(installer.Name)
|
||||||
requiredPlatform := packageExtensionToPlatform(ext)
|
requiredPlatform := packageExtensionToPlatform(ext)
|
||||||
if requiredPlatform == "" {
|
if requiredPlatform == "" {
|
||||||
|
|
@ -251,14 +438,14 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
|
||||||
if host.FleetPlatform() != requiredPlatform {
|
if host.FleetPlatform() != requiredPlatform {
|
||||||
return &fleet.BadRequestError{
|
return &fleet.BadRequestError{
|
||||||
Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform),
|
Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform),
|
||||||
InternalErr: ctxerr.WrapWithData(
|
InternalErr: ctxerr.NewWithData(
|
||||||
ctx, err, "invalid host platform for requested installer",
|
ctx, "invalid host platform for requested installer",
|
||||||
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
|
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")
|
return ctxerr.Wrap(ctx, err, "inserting software install request")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1083,6 +1083,7 @@ func (svc *Service) createTeamFromSpec(
|
||||||
Integrations: fleet.TeamIntegrations{
|
Integrations: fleet.TeamIntegrations{
|
||||||
GoogleCalendar: spec.Integrations.GoogleCalendar,
|
GoogleCalendar: spec.Integrations.GoogleCalendar,
|
||||||
},
|
},
|
||||||
|
Software: spec.Software,
|
||||||
},
|
},
|
||||||
Secrets: secrets,
|
Secrets: secrets,
|
||||||
})
|
})
|
||||||
|
|
@ -1243,8 +1244,18 @@ func (svc *Service) editTeamFromSpec(
|
||||||
team.Config.Scripts = spec.Scripts
|
team.Config.Scripts = spec.Scripts
|
||||||
}
|
}
|
||||||
|
|
||||||
if spec.Software.Set {
|
if spec.Software != nil {
|
||||||
team.Config.Software = spec.Software
|
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 {
|
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 { IMdmApple } from "interfaces/mdm";
|
||||||
|
import { IGetVppInfoResponse, IVppApp } from "services/entities/mdm_apple";
|
||||||
|
|
||||||
const DEFAULT_MDM_APPLE_MOCK: IMdmApple = {
|
const DEFAULT_MDM_APPLE_MOCK: IMdmApple = {
|
||||||
common_name: "APSP:12345",
|
common_name: "APSP:12345",
|
||||||
|
|
@ -13,4 +14,28 @@ export const createMockMdmApple = (
|
||||||
return { ...DEFAULT_MDM_APPLE_MOCK, ...overrides };
|
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;
|
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 = {
|
const DEFAULT_CONFIG_MOCK: IConfig = {
|
||||||
org_info: {
|
org_info: {
|
||||||
|
|
@ -136,44 +181,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = {
|
||||||
enable_software_inventory: true,
|
enable_software_inventory: true,
|
||||||
},
|
},
|
||||||
fleet_desktop: { transparency_url: "https://fleetdm.com/transparency" },
|
fleet_desktop: { transparency_url: "https://fleetdm.com/transparency" },
|
||||||
mdm: {
|
mdm: createMockMdmConfig(),
|
||||||
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: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMockConfig = (overrides?: Partial<IConfig>): IConfig => {
|
const createMockConfig = (overrides?: Partial<IConfig>): IConfig => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { IDeviceUser } from "interfaces/host";
|
import { IDeviceUser } from "interfaces/host";
|
||||||
import { IDeviceSoftware } from "interfaces/software";
|
import { IDeviceSoftware } from "interfaces/software";
|
||||||
import { IGetDeviceSoftwareResponse } from "services/entities/device_user";
|
import { IGetDeviceSoftwareResponse } from "services/entities/device_user";
|
||||||
|
import { createMockHostSoftwarePackage } from "./hostMock";
|
||||||
|
|
||||||
const DEFAULT_DEVICE_USER_MOCK: IDeviceUser = {
|
const DEFAULT_DEVICE_USER_MOCK: IDeviceUser = {
|
||||||
email: "test@test.com",
|
email: "test@test.com",
|
||||||
|
|
@ -16,16 +17,12 @@ const createMockDeviceUser = (
|
||||||
const DEFAULT_DEVICE_SOFTWARE_MOCK: IDeviceSoftware = {
|
const DEFAULT_DEVICE_SOFTWARE_MOCK: IDeviceSoftware = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "mock software 1.app",
|
name: "mock software 1.app",
|
||||||
self_service: false,
|
|
||||||
source: "apps",
|
source: "apps",
|
||||||
bundle_identifier: "com.app.mock",
|
bundle_identifier: "com.app.mock",
|
||||||
status: null,
|
status: null,
|
||||||
last_install: null,
|
|
||||||
installed_versions: null,
|
installed_versions: null,
|
||||||
package: {
|
software_package: createMockHostSoftwarePackage(),
|
||||||
name: "mock software 1",
|
app_store_app: null,
|
||||||
version: "1.0.0",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createMockDeviceSoftware = (
|
export const createMockDeviceSoftware = (
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,11 @@ import { pick } from "lodash";
|
||||||
import { normalizeEmptyValues } from "utilities/helpers";
|
import { normalizeEmptyValues } from "utilities/helpers";
|
||||||
import { HOST_SUMMARY_DATA } from "utilities/constants";
|
import { HOST_SUMMARY_DATA } from "utilities/constants";
|
||||||
import { IGetHostSoftwareResponse } from "services/entities/hosts";
|
import { IGetHostSoftwareResponse } from "services/entities/hosts";
|
||||||
import { IHostSoftware } from "interfaces/software";
|
import {
|
||||||
|
IHostAppStoreApp,
|
||||||
|
IHostSoftware,
|
||||||
|
IHostSoftwarePackage,
|
||||||
|
} from "interfaces/software";
|
||||||
|
|
||||||
const DEFAULT_HOST_PROFILE_MOCK: IHostMdmProfile = {
|
const DEFAULT_HOST_PROFILE_MOCK: IHostMdmProfile = {
|
||||||
profile_uuid: "123-abc",
|
profile_uuid: "123-abc",
|
||||||
|
|
@ -136,18 +140,45 @@ export const createMockHostSummary = (overrides?: Partial<IHost>) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_HOST_SOFTWARE_MOCK: IHostSoftware = {
|
const DEFAULT_HOST_SOFTWARE_PACKAGE_MOCK: IHostSoftwarePackage = {
|
||||||
id: 1,
|
|
||||||
name: "mock software.app",
|
name: "mock software.app",
|
||||||
package_available_for_install: "mockSoftware.app",
|
version: "1.0.0",
|
||||||
self_service: false,
|
self_service: false,
|
||||||
source: "apps",
|
icon_url: "https://example.com/icon.png",
|
||||||
bundle_identifier: "com.test.mock",
|
|
||||||
status: "installed",
|
|
||||||
last_install: {
|
last_install: {
|
||||||
install_uuid: "123-abc",
|
install_uuid: "123-abc",
|
||||||
installed_at: "2022-01-01T12:00:00Z",
|
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: [
|
installed_versions: [
|
||||||
{
|
{
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import {
|
import {
|
||||||
ISoftware,
|
ISoftware,
|
||||||
ISoftwareVersion,
|
ISoftwareVersion,
|
||||||
ISoftwareTitleWithPackageDetail,
|
|
||||||
ISoftwareTitleWithPackageName,
|
|
||||||
ISoftwareVulnerability,
|
ISoftwareVulnerability,
|
||||||
ISoftwareTitleVersion,
|
ISoftwareTitleVersion,
|
||||||
ISoftwarePackage,
|
ISoftwarePackage,
|
||||||
|
ISoftwareTitle,
|
||||||
|
ISoftwareTitleDetails,
|
||||||
|
IAppStoreApp,
|
||||||
} from "interfaces/software";
|
} from "interfaces/software";
|
||||||
import {
|
import {
|
||||||
ISoftwareTitlesResponse,
|
ISoftwareTitlesResponse,
|
||||||
|
|
@ -44,53 +45,6 @@ export const createMockSoftwareTitleVersion = (
|
||||||
return { ...DEFAULT_SOFTWARE_TITLE_VERSION_MOCK, ...overrides };
|
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 = {
|
const DEFAULT_SOFTWARE_VULNERABILITY_MOCK = {
|
||||||
cve: "CVE-2020-0001",
|
cve: "CVE-2020-0001",
|
||||||
details_link: "https://test.com",
|
details_link: "https://test.com",
|
||||||
|
|
@ -145,17 +99,48 @@ export const createMockSoftwareVersionsReponse = (
|
||||||
return { ...DEFAULT_SOFTWARE_VERSIONS_RESPONSE_MOCK, ...overrides };
|
return { ...DEFAULT_SOFTWARE_VERSIONS_RESPONSE_MOCK, ...overrides };
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SOFTWARE_TITLE_RESPONSE = {
|
const DEFAULT_APP_STORE_APP_MOCK: IAppStoreApp = {
|
||||||
software_title: createMockSoftwareTitle({
|
name: "test app",
|
||||||
software_package: null,
|
app_store_id: 1,
|
||||||
} as Partial<ISoftwareTitleWithPackageDetail>),
|
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 = (
|
export const createMockSoftwareTitleResponse = (
|
||||||
overrides: Partial<ISoftwareTitleWithPackageDetail> = {}
|
overrides?: Partial<ISoftwareTitleResponse>
|
||||||
): ISoftwareTitleResponse => {
|
): ISoftwareTitleResponse => {
|
||||||
const mock = DEFAULT_SOFTWARE_TITLE_RESPONSE.software_title;
|
return { ...DEFAULT_SOFTWARE_TITLE_RESPONSE, ...overrides };
|
||||||
return { software_title: { ...mock, ...overrides } };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SOFTWARE_VERSION_RESPONSE = {
|
const DEFAULT_SOFTWARE_VERSION_RESPONSE = {
|
||||||
|
|
@ -168,7 +153,7 @@ export const createMockSoftwareVersionResponse = (
|
||||||
return { ...DEFAULT_SOFTWARE_VERSION_RESPONSE, ...overrides };
|
return { ...DEFAULT_SOFTWARE_VERSION_RESPONSE, ...overrides };
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = {
|
const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
|
||||||
name: "TestPackage-1.2.3.pkg",
|
name: "TestPackage-1.2.3.pkg",
|
||||||
version: "1.2.3",
|
version: "1.2.3",
|
||||||
uploaded_at: "2020-01-01T00:00:00.000Z",
|
uploaded_at: "2020-01-01T00:00:00.000Z",
|
||||||
|
|
@ -177,6 +162,7 @@ const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = {
|
||||||
post_install_script:
|
post_install_script:
|
||||||
"sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123",
|
"sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123",
|
||||||
self_service: false,
|
self_service: false,
|
||||||
|
icon_url: null,
|
||||||
status: {
|
status: {
|
||||||
installed: 1,
|
installed: 1,
|
||||||
pending: 2,
|
pending: 2,
|
||||||
|
|
@ -187,5 +173,42 @@ const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = {
|
||||||
export const createMockSoftwarePackage = (
|
export const createMockSoftwarePackage = (
|
||||||
overrides?: Partial<ISoftwarePackage>
|
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 {
|
import {
|
||||||
ISoftwareInstallResult,
|
ISoftwareInstallResult,
|
||||||
ISoftwareInstallResults,
|
ISoftwareInstallResults,
|
||||||
SoftwareInstallStatus,
|
|
||||||
} from "interfaces/software";
|
} from "interfaces/software";
|
||||||
import softwareAPI from "services/entities/software";
|
import softwareAPI from "services/entities/software";
|
||||||
|
|
||||||
|
|
@ -14,22 +13,14 @@ import Icon from "components/Icon";
|
||||||
import Textarea from "components/Textarea";
|
import Textarea from "components/Textarea";
|
||||||
import DataError from "components/DataError/DataError";
|
import DataError from "components/DataError/DataError";
|
||||||
import Spinner from "components/Spinner/Spinner";
|
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 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 = ({
|
const StatusMessage = ({
|
||||||
result: { host_display_name, software_package, software_title, status },
|
result: { host_display_name, software_package, software_title, status },
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -37,32 +28,26 @@ const StatusMessage = ({
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={`${baseClass}__status-message`}>
|
<div className={`${baseClass}__status-message`}>
|
||||||
<Icon name={STATUS_ICONS[status]} />
|
<Icon name={INSTALL_DETAILS_STATUS_ICONS[status]} />
|
||||||
<span>
|
<span>
|
||||||
Fleet {STATUS_PREDICATES[status]} <b>{software_title}</b> (
|
Fleet {getInstallDetailsStatusPredicate(status)} <b>{software_title}</b>{" "}
|
||||||
{software_package}) on <b>{host_display_name}</b>
|
({software_package}) on <b>{host_display_name}</b>
|
||||||
{status === "pending" ? " when it comes online" : ""}.
|
{status === "pending" ? " when it comes online" : ""}.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 = ({
|
const Output = ({
|
||||||
displayKey,
|
displayKey,
|
||||||
result,
|
result,
|
||||||
}: {
|
}: {
|
||||||
displayKey: keyof typeof OUTPUT_DISPLAY_LABELS;
|
displayKey: keyof typeof SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS;
|
||||||
result: ISoftwareInstallResult;
|
result: ISoftwareInstallResult;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={`${baseClass}__script-output`}>
|
<div className={`${baseClass}__script-output`}>
|
||||||
{OUTPUT_DISPLAY_LABELS[displayKey]}:
|
{SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS[displayKey]}:
|
||||||
<Textarea className={`${baseClass}__output-textarea`}>
|
<Textarea className={`${baseClass}__output-textarea`}>
|
||||||
{result[displayKey]}
|
{result[displayKey]}
|
||||||
</Textarea>
|
</Textarea>
|
||||||
|
|
@ -7,6 +7,10 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $pad-small;
|
gap: $pad-small;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
.icon {
|
||||||
|
padding-top: 3px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&__script-output {
|
&__script-output {
|
||||||
padding-top: $pad-xlarge;
|
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-pkg"
|
||||||
| "file-p7m"
|
| "file-p7m"
|
||||||
| "file-pem"
|
| "file-pem"
|
||||||
|
| "file-vpp"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const FileDetails = ({
|
export const FileDetails = ({
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: $border-radius;
|
border-radius: $border-radius-medium;
|
||||||
background-color: $ui-fleet-blue-10;
|
background-color: $ui-fleet-blue-10;
|
||||||
border: 1px solid $ui-fleet-black-10;
|
border: 1px solid $ui-fleet-black-10;
|
||||||
padding: $pad-xlarge $pad-large;
|
padding: $pad-xlarge $pad-large;
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
}
|
}
|
||||||
&__message {
|
&__message {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
color: $ui-fleet-black-75;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__additional-info {
|
&__additional-info {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ interface ISoftwareNameCellProps {
|
||||||
router?: InjectedRouter;
|
router?: InjectedRouter;
|
||||||
hasPackage?: boolean;
|
hasPackage?: boolean;
|
||||||
isSelfService?: boolean;
|
isSelfService?: boolean;
|
||||||
|
iconUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SoftwareNameCell = ({
|
const SoftwareNameCell = ({
|
||||||
|
|
@ -70,6 +71,7 @@ const SoftwareNameCell = ({
|
||||||
router,
|
router,
|
||||||
hasPackage = false,
|
hasPackage = false,
|
||||||
isSelfService = false,
|
isSelfService = false,
|
||||||
|
iconUrl,
|
||||||
}: ISoftwareNameCellProps) => {
|
}: ISoftwareNameCellProps) => {
|
||||||
// NO path or router means it's not clickable. return
|
// NO path or router means it's not clickable. return
|
||||||
// a non-clickable cell early
|
// a non-clickable cell early
|
||||||
|
|
@ -95,7 +97,7 @@ const SoftwareNameCell = ({
|
||||||
customOnClick={onClickSoftware}
|
customOnClick={onClickSoftware}
|
||||||
value={
|
value={
|
||||||
<>
|
<>
|
||||||
<SoftwareIcon name={name} source={source} />
|
<SoftwareIcon name={name} source={source} url={iconUrl} />
|
||||||
<span className="software-name">{name}</span>
|
<span className="software-name">{name}</span>
|
||||||
{hasPackage && (
|
{hasPackage && (
|
||||||
<InstallIconWithTooltip isSelfService={isSelfService} />
|
<InstallIconWithTooltip isSelfService={isSelfService} />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from "react";
|
import React, { ReactNode } from "react";
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
|
|
||||||
import TooltipWrapper from "components/TooltipWrapper";
|
import TooltipWrapper from "components/TooltipWrapper";
|
||||||
|
|
@ -6,7 +6,7 @@ import TooltipWrapper from "components/TooltipWrapper";
|
||||||
const baseClass = "radio";
|
const baseClass = "radio";
|
||||||
|
|
||||||
export interface IRadioProps {
|
export interface IRadioProps {
|
||||||
label: string;
|
label: ReactNode;
|
||||||
value: string;
|
value: string;
|
||||||
id: string;
|
id: string;
|
||||||
onChange: (value: string) => void;
|
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 FilePkg from "./FilePkg";
|
||||||
import FileP7m from "./FileP7m";
|
import FileP7m from "./FileP7m";
|
||||||
import FilePem from "./FilePem";
|
import FilePem from "./FilePem";
|
||||||
|
import FileVpp from "./FileVpp";
|
||||||
import EmptyHosts from "./EmptyHosts";
|
import EmptyHosts from "./EmptyHosts";
|
||||||
import EmptyTeams from "./EmptyTeams";
|
import EmptyTeams from "./EmptyTeams";
|
||||||
import EmptyPacks from "./EmptyPacks";
|
import EmptyPacks from "./EmptyPacks";
|
||||||
|
|
@ -39,6 +40,7 @@ export const GRAPHIC_MAP = {
|
||||||
"file-pkg": FilePkg,
|
"file-pkg": FilePkg,
|
||||||
"file-p7m": FileP7m,
|
"file-p7m": FileP7m,
|
||||||
"file-pem": FilePem,
|
"file-pem": FilePem,
|
||||||
|
"file-vpp": FileVpp,
|
||||||
// Other graphics
|
// Other graphics
|
||||||
"collecting-results": CollectingResults,
|
"collecting-results": CollectingResults,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,11 @@ export enum ActivityType {
|
||||||
AddedSoftware = "added_software",
|
AddedSoftware = "added_software",
|
||||||
DeletedSoftware = "deleted_software",
|
DeletedSoftware = "deleted_software",
|
||||||
InstalledSoftware = "installed_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
|
// 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.RanScript
|
||||||
| ActivityType.LockedHost
|
| ActivityType.LockedHost
|
||||||
| ActivityType.UnlockedHost
|
| ActivityType.UnlockedHost
|
||||||
| ActivityType.InstalledSoftware;
|
| ActivityType.InstalledSoftware
|
||||||
|
| ActivityType.InstalledAppStoreApp;
|
||||||
|
|
||||||
// This is a subset of ActivityType that are shown only for the host upcoming activities
|
// This is a subset of ActivityType that are shown only for the host upcoming activities
|
||||||
export type IHostUpcomingActivityType =
|
export type IHostUpcomingActivityType =
|
||||||
| ActivityType.RanScript
|
| ActivityType.RanScript
|
||||||
| ActivityType.InstalledSoftware;
|
| ActivityType.InstalledSoftware
|
||||||
|
| ActivityType.InstalledAppStoreApp;
|
||||||
|
|
||||||
export interface IActivity {
|
export interface IActivity {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|
@ -156,4 +163,6 @@ export interface IActivityDetails {
|
||||||
status?: string;
|
status?: string;
|
||||||
install_uuid?: string;
|
install_uuid?: string;
|
||||||
self_service?: boolean;
|
self_service?: boolean;
|
||||||
|
command_uuid?: string;
|
||||||
|
app_store_id?: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ interface ICustomSetting {
|
||||||
|
|
||||||
export interface IMdmConfig {
|
export interface IMdmConfig {
|
||||||
enable_disk_encryption: boolean;
|
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;
|
enabled_and_configured: boolean;
|
||||||
apple_bm_default_team?: string;
|
apple_bm_default_team?: string;
|
||||||
apple_bm_terms_expired: boolean;
|
apple_bm_terms_expired: boolean;
|
||||||
|
|
|
||||||
|
|
@ -160,3 +160,22 @@ export enum BootstrapPackageStatus {
|
||||||
PENDING = "pending",
|
PENDING = "pending",
|
||||||
FAILED = "failed",
|
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 { startCase } from "lodash";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
import { IconNames } from "components/icons";
|
||||||
|
|
||||||
import vulnerabilityInterface from "./vulnerability";
|
import vulnerabilityInterface from "./vulnerability";
|
||||||
|
|
||||||
export default PropTypes.shape({
|
export default PropTypes.shape({
|
||||||
|
|
@ -58,6 +61,7 @@ export interface ISoftwarePackage {
|
||||||
pre_install_query?: string;
|
pre_install_query?: string;
|
||||||
post_install_script?: string;
|
post_install_script?: string;
|
||||||
self_service: boolean;
|
self_service: boolean;
|
||||||
|
icon_url: string | null;
|
||||||
status: {
|
status: {
|
||||||
installed: number;
|
installed: number;
|
||||||
pending: 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;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
software_package: ISoftwarePackage | string | null;
|
|
||||||
versions_count: number;
|
versions_count: number;
|
||||||
source: string;
|
source: string;
|
||||||
hosts_count: number;
|
hosts_count: number;
|
||||||
versions: ISoftwareTitleVersion[] | null;
|
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;
|
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 {
|
export interface ISoftwareVulnerability {
|
||||||
|
|
@ -213,6 +235,11 @@ export interface ISoftwareLastInstall {
|
||||||
installed_at: string;
|
installed_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IAppLastInstall {
|
||||||
|
command_uuid: string;
|
||||||
|
installed_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ISoftwareInstallVersion {
|
export interface ISoftwareInstallVersion {
|
||||||
version: string;
|
version: string;
|
||||||
last_opened_at: string | null;
|
last_opened_at: string | null;
|
||||||
|
|
@ -220,24 +247,77 @@ export interface ISoftwareInstallVersion {
|
||||||
installed_paths: string[];
|
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 {
|
export interface IHostSoftware {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
package_available_for_install?: string | null;
|
software_package: IHostSoftwarePackage | null;
|
||||||
self_service: boolean;
|
app_store_app: IHostAppStoreApp | null;
|
||||||
source: string;
|
source: string;
|
||||||
bundle_identifier?: string;
|
bundle_identifier?: string;
|
||||||
status: SoftwareInstallStatus | null;
|
status: SoftwareInstallStatus | null;
|
||||||
last_install: ISoftwareLastInstall | null;
|
|
||||||
installed_versions: ISoftwareInstallVersion[] | null;
|
installed_versions: ISoftwareInstallVersion[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IDeviceSoftware = Omit<
|
export type IDeviceSoftware = IHostSoftware;
|
||||||
IHostSoftware,
|
|
||||||
"package_available_for_install"
|
const INSTALL_STATUS_PREDICATES: Record<SoftwareInstallStatus, string> = {
|
||||||
> & {
|
failed: "failed to install",
|
||||||
package: {
|
installed: "installed",
|
||||||
name: string;
|
pending: "told Fleet to install",
|
||||||
version: string;
|
} 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
|
// @ts-ignore
|
||||||
import FleetIcon from "components/icons/FleetIcon";
|
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 ActivityItem from "./ActivityItem";
|
||||||
import ScriptDetailsModal from "./components/ScriptDetailsModal/ScriptDetailsModal";
|
import ScriptDetailsModal from "./components/ScriptDetailsModal/ScriptDetailsModal";
|
||||||
|
|
@ -37,6 +38,10 @@ const ActivityFeed = ({
|
||||||
const [showShowQueryModal, setShowShowQueryModal] = useState(false);
|
const [showShowQueryModal, setShowShowQueryModal] = useState(false);
|
||||||
const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false);
|
const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false);
|
||||||
const [installedSoftwareUuid, setInstalledSoftwareUuid] = useState("");
|
const [installedSoftwareUuid, setInstalledSoftwareUuid] = useState("");
|
||||||
|
const [
|
||||||
|
appInstallDetails,
|
||||||
|
setAppInstallDetails,
|
||||||
|
] = useState<IActivityDetails | null>(null);
|
||||||
const queryShown = useRef("");
|
const queryShown = useRef("");
|
||||||
const queryImpact = useRef<string | undefined>(undefined);
|
const queryImpact = useRef<string | undefined>(undefined);
|
||||||
const scriptExecutionId = useRef("");
|
const scriptExecutionId = useRef("");
|
||||||
|
|
@ -97,10 +102,11 @@ const ActivityFeed = ({
|
||||||
setShowScriptDetailsModal(true);
|
setShowScriptDetailsModal(true);
|
||||||
break;
|
break;
|
||||||
case ActivityType.InstalledSoftware:
|
case ActivityType.InstalledSoftware:
|
||||||
// installUuid.current = details.install_uuid ?? "";
|
|
||||||
// console.log("installUuid.current", installUuid.current);
|
|
||||||
setInstalledSoftwareUuid(details.install_uuid ?? "");
|
setInstalledSoftwareUuid(details.install_uuid ?? "");
|
||||||
break;
|
break;
|
||||||
|
case ActivityType.InstalledAppStoreApp:
|
||||||
|
setAppInstallDetails(details);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -197,6 +203,12 @@ const ActivityFeed = ({
|
||||||
onCancel={() => setInstalledSoftwareUuid("")}
|
onCancel={() => setInstalledSoftwareUuid("")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{appInstallDetails && (
|
||||||
|
<AppInstallDetailsModal
|
||||||
|
details={appInstallDetails}
|
||||||
|
onCancel={() => setAppInstallDetails(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { find, lowerCase, noop } from "lodash";
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
import { formatDistanceToNowStrict } from "date-fns";
|
||||||
|
|
||||||
import { ActivityType, IActivity, IActivityDetails } from "interfaces/activity";
|
import { ActivityType, IActivity, IActivityDetails } from "interfaces/activity";
|
||||||
|
import { getInstallStatusPredicate } from "interfaces/software";
|
||||||
import {
|
import {
|
||||||
addGravatarUrlToResource,
|
addGravatarUrlToResource,
|
||||||
formatScriptNameForActivityItem,
|
formatScriptNameForActivityItem,
|
||||||
|
|
@ -16,7 +17,6 @@ import Icon from "components/Icon";
|
||||||
import ReactTooltip from "react-tooltip";
|
import ReactTooltip from "react-tooltip";
|
||||||
import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
|
import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
|
||||||
import { COLORS } from "styles/var/colors";
|
import { COLORS } from "styles/var/colors";
|
||||||
import { getSoftwareInstallStatusPredicate } from "pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem";
|
|
||||||
|
|
||||||
const baseClass = "activity-item";
|
const baseClass = "activity-item";
|
||||||
|
|
||||||
|
|
@ -795,7 +795,7 @@ const TAGGED_TEMPLATES = {
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
added <b>{activity.details?.software_title}</b> (
|
added <b>{activity.details?.software_title}</b> (
|
||||||
{activity.details?.software_package}) software to{" "}
|
{activity.details?.software_package}) to{" "}
|
||||||
{activity.details?.team_name ? (
|
{activity.details?.team_name ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
|
|
@ -812,7 +812,7 @@ const TAGGED_TEMPLATES = {
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
deleted <b>{activity.details?.software_title}</b> (
|
deleted <b>{activity.details?.software_title}</b> (
|
||||||
{activity.details?.software_package}) software from{" "}
|
{activity.details?.software_package}) from{" "}
|
||||||
{activity.details?.team_name ? (
|
{activity.details?.team_name ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
|
|
@ -837,20 +837,22 @@ const TAGGED_TEMPLATES = {
|
||||||
host_display_name: hostName,
|
host_display_name: hostName,
|
||||||
software_title: title,
|
software_title: title,
|
||||||
status,
|
status,
|
||||||
install_uuid,
|
|
||||||
} = details;
|
} = details;
|
||||||
|
|
||||||
|
const showSoftwarePackage =
|
||||||
|
!!details.software_package &&
|
||||||
|
activity.type === ActivityType.InstalledSoftware;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
{getSoftwareInstallStatusPredicate(status)} <b>{title}</b> software on{" "}
|
{getInstallStatusPredicate(status)} <b>{title}</b>
|
||||||
|
{showSoftwarePackage && ` (${details.software_package})`} on{" "}
|
||||||
<b>{hostName}</b>.{" "}
|
<b>{hostName}</b>.{" "}
|
||||||
<Button
|
<Button
|
||||||
className={`${baseClass}__show-query-link`}
|
className={`${baseClass}__show-query-link`}
|
||||||
variant="text-link"
|
variant="text-link"
|
||||||
onClick={() =>
|
onClick={() => onDetailsClick?.(activity.type, details)}
|
||||||
onDetailsClick?.(ActivityType.InstalledSoftware, { install_uuid })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Show details{" "}
|
Show details{" "}
|
||||||
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
|
<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 = (
|
const getDetail = (
|
||||||
|
|
@ -1041,6 +1091,21 @@ const getDetail = (
|
||||||
case ActivityType.InstalledSoftware: {
|
case ActivityType.InstalledSoftware: {
|
||||||
return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick);
|
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: {
|
default: {
|
||||||
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
|
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,12 @@ import React, {
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import FileSaver from "file-saver";
|
import FileSaver from "file-saver";
|
||||||
|
|
||||||
import PATHS from "router/paths";
|
import PATHS from "router/paths";
|
||||||
|
|
||||||
import { AppContext } from "context/app";
|
import { AppContext } from "context/app";
|
||||||
import { NotificationContext } from "context/notification";
|
import { NotificationContext } from "context/notification";
|
||||||
|
|
||||||
import { SoftwareInstallStatus, ISoftwarePackage } from "interfaces/software";
|
import { SoftwareInstallStatus, ISoftwarePackage } from "interfaces/software";
|
||||||
|
|
||||||
import softwareAPI from "services/entities/software";
|
import softwareAPI from "services/entities/software";
|
||||||
|
|
||||||
import { buildQueryStringFromParams } from "utilities/url";
|
import { buildQueryStringFromParams } from "utilities/url";
|
||||||
|
|
@ -22,15 +18,20 @@ import { uploadedFromNow } from "utilities/date_format";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import Dropdown from "components/forms/fields/Dropdown";
|
import Dropdown from "components/forms/fields/Dropdown";
|
||||||
|
|
||||||
import Card from "components/Card";
|
import Card from "components/Card";
|
||||||
import Graphic from "components/Graphic";
|
import Graphic from "components/Graphic";
|
||||||
import TooltipWrapper from "components/TooltipWrapper";
|
import TooltipWrapper from "components/TooltipWrapper";
|
||||||
import DataSet from "components/DataSet";
|
import DataSet from "components/DataSet";
|
||||||
import Icon from "components/Icon";
|
import Icon from "components/Icon";
|
||||||
|
|
||||||
|
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
|
||||||
|
|
||||||
import DeleteSoftwareModal from "../DeleteSoftwareModal";
|
import DeleteSoftwareModal from "../DeleteSoftwareModal";
|
||||||
import AdvancedOptionsModal from "../AdvancedOptionsModal";
|
import AdvancedOptionsModal from "../AdvancedOptionsModal";
|
||||||
|
import {
|
||||||
|
APP_STORE_APP_DROPDOWN_OPTIONS,
|
||||||
|
SOFTWARE_PACAKGE_DROPDOWN_OPTIONS,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
const baseClass = "software-package-card";
|
const baseClass = "software-package-card";
|
||||||
|
|
||||||
|
|
@ -142,30 +143,19 @@ const PackageStatusCount = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DROPDOWN_OPTIONS = [
|
interface IActionsDropdownProps {
|
||||||
{
|
isSoftwarePackage: boolean;
|
||||||
label: "Download",
|
|
||||||
value: "download",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
value: "delete",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Advanced options",
|
|
||||||
value: "advanced",
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const ActionsDropdown = ({
|
|
||||||
onDownloadClick,
|
|
||||||
onDeleteClick,
|
|
||||||
onAdvancedOptionsClick,
|
|
||||||
}: {
|
|
||||||
onDownloadClick: () => void;
|
onDownloadClick: () => void;
|
||||||
onDeleteClick: () => void;
|
onDeleteClick: () => void;
|
||||||
onAdvancedOptionsClick: () => void;
|
onAdvancedOptionsClick: () => void;
|
||||||
}) => {
|
}
|
||||||
|
|
||||||
|
const ActionsDropdown = ({
|
||||||
|
isSoftwarePackage,
|
||||||
|
onDownloadClick,
|
||||||
|
onDeleteClick,
|
||||||
|
onAdvancedOptionsClick,
|
||||||
|
}: IActionsDropdownProps) => {
|
||||||
const onSelect = (value: string) => {
|
const onSelect = (value: string) => {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case "download":
|
case "download":
|
||||||
|
|
@ -189,20 +179,42 @@ const ActionsDropdown = ({
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
placeholder="Actions"
|
placeholder="Actions"
|
||||||
searchable={false}
|
searchable={false}
|
||||||
options={DROPDOWN_OPTIONS}
|
options={
|
||||||
|
isSoftwarePackage
|
||||||
|
? SOFTWARE_PACAKGE_DROPDOWN_OPTIONS
|
||||||
|
: APP_STORE_APP_DROPDOWN_OPTIONS
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ISoftwarePackageCardProps {
|
interface ISoftwarePackageCardProps {
|
||||||
softwarePackage: ISoftwarePackage;
|
name: string;
|
||||||
|
version: string;
|
||||||
|
uploadedAt: string; // TODO: optional?
|
||||||
|
status: {
|
||||||
|
installed: number;
|
||||||
|
pending: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
isSelfService: boolean;
|
||||||
softwareId: number;
|
softwareId: number;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
|
// NOTE: we will only have this if we are working with a software package.
|
||||||
|
softwarePackage?: ISoftwarePackage;
|
||||||
onDelete: () => void;
|
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 = ({
|
const SoftwarePackageCard = ({
|
||||||
|
name,
|
||||||
|
version,
|
||||||
|
uploadedAt,
|
||||||
|
status,
|
||||||
|
isSelfService,
|
||||||
softwarePackage,
|
softwarePackage,
|
||||||
softwareId,
|
softwareId,
|
||||||
teamId,
|
teamId,
|
||||||
|
|
@ -246,7 +258,7 @@ const SoftwarePackageCard = ({
|
||||||
`Byte size (${resp.data.size}) does not match content-length header (${contentLength})`
|
`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, {
|
const file = new File([resp.data], filename, {
|
||||||
type: "application/octet-stream",
|
type: "application/octet-stream",
|
||||||
});
|
});
|
||||||
|
|
@ -260,10 +272,33 @@ const SoftwarePackageCard = ({
|
||||||
}
|
}
|
||||||
FileSaver.saveAs(file);
|
FileSaver.saveAs(file);
|
||||||
} catch (e) {
|
} 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 =
|
const showActions =
|
||||||
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
|
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
|
||||||
|
|
@ -274,45 +309,35 @@ const SoftwarePackageCard = ({
|
||||||
{/* TODO: main-info could be a seperate component as its reused on a couple
|
{/* TODO: main-info could be a seperate component as its reused on a couple
|
||||||
pages already. Come back and pull this into a component */}
|
pages already. Come back and pull this into a component */}
|
||||||
<div className={`${baseClass}__main-info`}>
|
<div className={`${baseClass}__main-info`}>
|
||||||
<Graphic name="file-pkg" />
|
{renderIcon()}
|
||||||
<div className={`${baseClass}__info`}>
|
<div className={`${baseClass}__info`}>
|
||||||
<SoftwareName name={softwarePackage.name} />
|
<SoftwareName name={name} />
|
||||||
<span className={`${baseClass}__details`}>
|
<span className={`${baseClass}__details`}>{renderDetails()}</span>
|
||||||
<span>Version {softwarePackage.version} • </span>
|
|
||||||
<TooltipWrapper
|
|
||||||
tipContent={internationalTimeFormat(
|
|
||||||
new Date(softwarePackage.uploaded_at)
|
|
||||||
)}
|
|
||||||
underline={false}
|
|
||||||
>
|
|
||||||
{uploadedFromNow(softwarePackage.uploaded_at)}
|
|
||||||
</TooltipWrapper>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${baseClass}__package-statuses`}>
|
<div className={`${baseClass}__package-statuses`}>
|
||||||
<PackageStatusCount
|
<PackageStatusCount
|
||||||
softwareId={softwareId}
|
softwareId={softwareId}
|
||||||
status="installed"
|
status="installed"
|
||||||
count={softwarePackage.status.installed}
|
count={status.installed}
|
||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
/>
|
/>
|
||||||
<PackageStatusCount
|
<PackageStatusCount
|
||||||
softwareId={softwareId}
|
softwareId={softwareId}
|
||||||
status="pending"
|
status="pending"
|
||||||
count={softwarePackage.status.pending}
|
count={status.pending}
|
||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
/>
|
/>
|
||||||
<PackageStatusCount
|
<PackageStatusCount
|
||||||
softwareId={softwareId}
|
softwareId={softwareId}
|
||||||
status="failed"
|
status="failed"
|
||||||
count={softwarePackage.status.failed}
|
count={status.failed}
|
||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${baseClass}__actions-wrapper`}>
|
<div className={`${baseClass}__actions-wrapper`}>
|
||||||
{softwarePackage.self_service && (
|
{isSelfService && (
|
||||||
<div className={`${baseClass}__self-service-badge`}>
|
<div className={`${baseClass}__self-service-badge`}>
|
||||||
<Icon
|
<Icon
|
||||||
name="install-self-service"
|
name="install-self-service"
|
||||||
|
|
@ -324,6 +349,7 @@ const SoftwarePackageCard = ({
|
||||||
)}
|
)}
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<ActionsDropdown
|
<ActionsDropdown
|
||||||
|
isSoftwarePackage={!!softwarePackage}
|
||||||
onDownloadClick={onDownloadClick}
|
onDownloadClick={onDownloadClick}
|
||||||
onDeleteClick={onDeleteClick}
|
onDeleteClick={onDeleteClick}
|
||||||
onAdvancedOptionsClick={onAdvancedOptionsClick}
|
onAdvancedOptionsClick={onAdvancedOptionsClick}
|
||||||
|
|
@ -332,9 +358,9 @@ const SoftwarePackageCard = ({
|
||||||
</div>
|
</div>
|
||||||
{showAdvancedOptionsModal && (
|
{showAdvancedOptionsModal && (
|
||||||
<AdvancedOptionsModal
|
<AdvancedOptionsModal
|
||||||
installScript={softwarePackage.install_script}
|
installScript={softwarePackage?.install_script ?? ""}
|
||||||
preInstallQuery={softwarePackage.pre_install_query}
|
preInstallQuery={softwarePackage?.pre_install_query}
|
||||||
postInstallScript={softwarePackage.post_install_script}
|
postInstallScript={softwarePackage?.post_install_script}
|
||||||
onExit={() => setShowAdvancedOptionsModal(false)}
|
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 { AppContext } from "context/app";
|
||||||
|
|
||||||
import {
|
import { ISoftwareTitleDetails, formatSoftwareType } from "interfaces/software";
|
||||||
ISoftwareTitleWithPackageDetail,
|
|
||||||
formatSoftwareType,
|
|
||||||
} from "interfaces/software";
|
|
||||||
import { ignoreAxiosError } from "interfaces/errors";
|
import { ignoreAxiosError } from "interfaces/errors";
|
||||||
import softwareAPI, {
|
import softwareAPI, {
|
||||||
ISoftwareTitleResponse,
|
ISoftwareTitleResponse,
|
||||||
IGetSoftwareTitleQueryKey,
|
IGetSoftwareTitleQueryKey,
|
||||||
} from "services/entities/software";
|
} 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 { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
||||||
|
|
||||||
import Spinner from "components/Spinner";
|
import Spinner from "components/Spinner";
|
||||||
|
|
@ -33,6 +33,7 @@ import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary";
|
||||||
import SoftwareTitleDetailsTable from "./SoftwareTitleDetailsTable";
|
import SoftwareTitleDetailsTable from "./SoftwareTitleDetailsTable";
|
||||||
import DetailsNoHosts from "../components/DetailsNoHosts";
|
import DetailsNoHosts from "../components/DetailsNoHosts";
|
||||||
import SoftwarePackageCard from "./SoftwarePackageCard";
|
import SoftwarePackageCard from "./SoftwarePackageCard";
|
||||||
|
import { getPackageCardInfo } from "./helpers";
|
||||||
|
|
||||||
const baseClass = "software-title-details-page";
|
const baseClass = "software-title-details-page";
|
||||||
|
|
||||||
|
|
@ -83,7 +84,7 @@ const SoftwareTitleDetailsPage = ({
|
||||||
} = useQuery<
|
} = useQuery<
|
||||||
ISoftwareTitleResponse,
|
ISoftwareTitleResponse,
|
||||||
AxiosError,
|
AxiosError,
|
||||||
ISoftwareTitleWithPackageDetail,
|
ISoftwareTitleDetails,
|
||||||
IGetSoftwareTitleQueryKey[]
|
IGetSoftwareTitleQueryKey[]
|
||||||
>(
|
>(
|
||||||
[{ scope: "softwareById", softwareId, teamId: teamIdForApi }],
|
[{ 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(() => {
|
const onDeleteInstaller = useCallback(() => {
|
||||||
if (softwareTitle?.versions?.length) {
|
if (softwareTitle?.versions?.length) {
|
||||||
refetchSoftwareTitle();
|
refetchSoftwareTitle();
|
||||||
|
|
@ -120,85 +124,103 @@ const SoftwareTitleDetailsPage = ({
|
||||||
[handleTeamChange]
|
[handleTeamChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasPermission = Boolean(
|
const renderSoftwarePackageCard = (title: ISoftwareTitleDetails) => {
|
||||||
isOnGlobalTeam || isTeamAdmin || isTeamMaintainer || isTeamObserver
|
const hasPermission = Boolean(
|
||||||
);
|
isOnGlobalTeam || isTeamAdmin || isTeamMaintainer || isTeamObserver
|
||||||
const hasSoftwarePackage = softwareTitle && softwareTitle.software_package;
|
);
|
||||||
const showPackageCard =
|
|
||||||
currentTeamId !== APP_CONTEXT_ALL_TEAMS_ID &&
|
const showPackageCard =
|
||||||
hasPermission &&
|
currentTeamId !== APP_CONTEXT_ALL_TEAMS_ID &&
|
||||||
hasSoftwarePackage;
|
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 = () => {
|
const renderContent = () => {
|
||||||
if (isSoftwareTitleLoading) {
|
if (isSoftwareTitleLoading) {
|
||||||
return <Spinner />;
|
return <Spinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!softwareTitle && !isSoftwareTitleError) {
|
if (isSoftwareTitleError) {
|
||||||
return null;
|
return (
|
||||||
|
<DetailsNoHosts
|
||||||
|
header="Software not detected"
|
||||||
|
details={`No hosts ${
|
||||||
|
teamIdForApi ? "on this team " : ""
|
||||||
|
}have this software installed.`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
<>
|
if (softwareTitle) {
|
||||||
{isPremiumTier && (
|
return (
|
||||||
<TeamsHeader
|
<>
|
||||||
isOnGlobalTeam={isOnGlobalTeam}
|
<SoftwareDetailsSummary
|
||||||
currentTeamId={currentTeamId}
|
title={softwareTitle.name}
|
||||||
userTeams={userTeams}
|
type={formatSoftwareType(softwareTitle)}
|
||||||
onTeamChange={onTeamChange}
|
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
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
{renderSoftwarePackageCard(softwareTitle)}
|
||||||
{isSoftwareTitleError ? (
|
<Card
|
||||||
<DetailsNoHosts
|
borderRadiusSize="xxlarge"
|
||||||
header="Software not detected"
|
includeShadow
|
||||||
details={`No hosts ${
|
className={`${baseClass}__versions-section`}
|
||||||
teamIdForApi ? "on this team " : ""
|
>
|
||||||
}have this software installed.`}
|
<h2>Versions</h2>
|
||||||
/>
|
<SoftwareTitleDetailsTable
|
||||||
) : (
|
router={router}
|
||||||
<>
|
data={softwareTitle.versions ?? []}
|
||||||
<SoftwareDetailsSummary
|
isLoading={isSoftwareTitleLoading}
|
||||||
title={softwareTitle.name}
|
teamIdForApi={teamIdForApi}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
{showPackageCard &&
|
</Card>
|
||||||
softwareTitle.software_package &&
|
</>
|
||||||
currentTeamId && (
|
);
|
||||||
<SoftwarePackageCard
|
}
|
||||||
softwarePackage={softwareTitle.software_package}
|
|
||||||
softwareId={softwareId}
|
return null;
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainContent className={baseClass}>
|
<MainContent className={baseClass}>
|
||||||
|
{isPremiumTier && (
|
||||||
|
<TeamsHeader
|
||||||
|
isOnGlobalTeam={isOnGlobalTeam}
|
||||||
|
currentTeamId={currentTeamId}
|
||||||
|
userTeams={userTeams}
|
||||||
|
onTeamChange={onTeamChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<>{renderContent()}</>
|
<>{renderContent()}</>
|
||||||
</MainContent>
|
</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,
|
ISoftwareTitlesResponse,
|
||||||
ISoftwareVersionsResponse,
|
ISoftwareVersionsResponse,
|
||||||
} from "services/entities/software";
|
} from "services/entities/software";
|
||||||
import {
|
import { ISoftwareTitle, ISoftwareVersion } from "interfaces/software";
|
||||||
ISoftwareTitleWithPackageName,
|
|
||||||
ISoftwareVersion,
|
|
||||||
} from "interfaces/software";
|
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import Dropdown from "components/forms/fields/Dropdown";
|
import Dropdown from "components/forms/fields/Dropdown";
|
||||||
|
|
@ -163,10 +160,7 @@ const SoftwareTable = ({
|
||||||
[determineQueryParamChange, generateNewQueryParams, router, currentPath]
|
[determineQueryParamChange, generateNewQueryParams, router, currentPath]
|
||||||
);
|
);
|
||||||
|
|
||||||
let tableData:
|
let tableData: ISoftwareTitle[] | ISoftwareVersion[] | undefined;
|
||||||
| ISoftwareTitleWithPackageName[]
|
|
||||||
| ISoftwareVersion[]
|
|
||||||
| undefined;
|
|
||||||
let generateTableConfig: ITableConfigGenerator;
|
let generateTableConfig: ITableConfigGenerator;
|
||||||
|
|
||||||
if (data === undefined) {
|
if (data === undefined) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ import { CellProps, Column } from "react-table";
|
||||||
import { InjectedRouter } from "react-router";
|
import { InjectedRouter } from "react-router";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ISoftwareTitleWithPackageName,
|
IAppStoreApp,
|
||||||
|
ISoftware,
|
||||||
|
ISoftwarePackage,
|
||||||
|
ISoftwareTitle,
|
||||||
formatSoftwareType,
|
formatSoftwareType,
|
||||||
} from "interfaces/software";
|
} from "interfaces/software";
|
||||||
import PATHS from "router/paths";
|
import PATHS from "router/paths";
|
||||||
|
|
@ -22,20 +25,17 @@ import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
|
||||||
// NOTE: cellProps come from react-table
|
// NOTE: cellProps come from react-table
|
||||||
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
|
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
|
||||||
|
|
||||||
type ISoftwareTitlesTableConfig = Column<ISoftwareTitleWithPackageName>;
|
type ISoftwareTitlesTableConfig = Column<ISoftwareTitle>;
|
||||||
type ITableStringCellProps = IStringCellProps<ISoftwareTitleWithPackageName>;
|
type ITableStringCellProps = IStringCellProps<ISoftwareTitle>;
|
||||||
type IVersionsCellProps = CellProps<
|
type IVersionsCellProps = CellProps<ISoftwareTitle, ISoftwareTitle["versions"]>;
|
||||||
ISoftwareTitleWithPackageName,
|
|
||||||
ISoftwareTitleWithPackageName["versions"]
|
|
||||||
>;
|
|
||||||
type IVulnerabilitiesCellProps = IVersionsCellProps;
|
type IVulnerabilitiesCellProps = IVersionsCellProps;
|
||||||
type IHostCountCellProps = CellProps<
|
type IHostCountCellProps = CellProps<
|
||||||
ISoftwareTitleWithPackageName,
|
ISoftwareTitle,
|
||||||
ISoftwareTitleWithPackageName["hosts_count"]
|
ISoftwareTitle["hosts_count"]
|
||||||
>;
|
>;
|
||||||
type IViewAllHostsLinkProps = CellProps<ISoftwareTitleWithPackageName>;
|
type IViewAllHostsLinkProps = CellProps<ISoftwareTitle>;
|
||||||
|
|
||||||
type ITableHeaderProps = IHeaderProps<ISoftwareTitleWithPackageName>;
|
type ITableHeaderProps = IHeaderProps<ISoftwareTitle>;
|
||||||
|
|
||||||
export const getVulnerabilities = <
|
export const getVulnerabilities = <
|
||||||
T extends { vulnerabilities: string[] | null }
|
T extends { vulnerabilities: string[] | null }
|
||||||
|
|
@ -57,6 +57,41 @@ export const getVulnerabilities = <
|
||||||
return vulnerabilities;
|
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 = (
|
const generateTableHeaders = (
|
||||||
router: InjectedRouter,
|
router: InjectedRouter,
|
||||||
teamId?: number
|
teamId?: number
|
||||||
|
|
@ -69,29 +104,20 @@ const generateTableHeaders = (
|
||||||
disableSortBy: false,
|
disableSortBy: false,
|
||||||
accessor: "name",
|
accessor: "name",
|
||||||
Cell: (cellProps: ITableStringCellProps) => {
|
Cell: (cellProps: ITableStringCellProps) => {
|
||||||
const {
|
const nameCellData = getSoftwareNameCellData(
|
||||||
id,
|
cellProps.row.original,
|
||||||
name,
|
teamId
|
||||||
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
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SoftwareNameCell
|
<SoftwareNameCell
|
||||||
name={name}
|
name={nameCellData.name}
|
||||||
source={source}
|
source={nameCellData.source}
|
||||||
path={softwareTitleDetailsPath}
|
path={nameCellData.path}
|
||||||
router={router}
|
router={router}
|
||||||
hasPackage={hasPackage}
|
hasPackage={nameCellData.hasPackage}
|
||||||
isSelfService={self_service === true}
|
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 { 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 { 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 Modal from "components/Modal";
|
||||||
import Button from "components/buttons/Button";
|
import Button from "components/buttons/Button";
|
||||||
|
import TabsWrapper from "components/TabsWrapper";
|
||||||
|
|
||||||
import AddSoftwareForm from "../AddSoftwareForm";
|
import AppStoreVpp from "../AppStoreVpp";
|
||||||
import { IAddSoftwareFormData } from "../AddSoftwareForm/AddSoftwareForm";
|
import AddPackage from "../AddPackage";
|
||||||
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;
|
|
||||||
|
|
||||||
const baseClass = "add-software-modal";
|
const baseClass = "add-software-modal";
|
||||||
|
|
||||||
|
|
@ -54,81 +44,6 @@ const AddSoftwareModal = ({
|
||||||
router,
|
router,
|
||||||
onExit,
|
onExit,
|
||||||
}: IAddSoftwareModalProps) => {
|
}: 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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="Add software"
|
title="Add software"
|
||||||
|
|
@ -140,11 +55,20 @@ const AddSoftwareModal = ({
|
||||||
{teamId === APP_CONTEXT_ALL_TEAMS_ID ? (
|
{teamId === APP_CONTEXT_ALL_TEAMS_ID ? (
|
||||||
<AllTeamsMessage onExit={onExit} />
|
<AllTeamsMessage onExit={onExit} />
|
||||||
) : (
|
) : (
|
||||||
<AddSoftwareForm
|
<TabsWrapper className={`${baseClass}__tabs`}>
|
||||||
isUploading={isUploading}
|
<Tabs>
|
||||||
onCancel={onExit}
|
<TabList>
|
||||||
onSubmit={onAddSoftware}
|
<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>
|
</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;
|
name?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
versions?: number;
|
versions?: number;
|
||||||
|
iconUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SoftwareDetailsSummary = ({
|
const SoftwareDetailsSummary = ({
|
||||||
|
|
@ -34,10 +35,11 @@ const SoftwareDetailsSummary = ({
|
||||||
name,
|
name,
|
||||||
source,
|
source,
|
||||||
versions,
|
versions,
|
||||||
|
iconUrl,
|
||||||
}: ISoftwareDetailsSummaryProps) => {
|
}: ISoftwareDetailsSummaryProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={baseClass}>
|
<div className={baseClass}>
|
||||||
<SoftwareIcon name={name} source={source} size="large" />
|
<SoftwareIcon name={name} source={source} url={iconUrl} size="xlarge" />
|
||||||
<dl className={`${baseClass}__info`}>
|
<dl className={`${baseClass}__info`}>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<dl className={`${baseClass}__description-list`}>
|
<dl className={`${baseClass}__description-list`}>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import TooltipWrapper from "components/TooltipWrapper";
|
||||||
|
|
||||||
const generateText = <T extends { version: string }>(versions: T[] | null) => {
|
const generateText = <T extends { version: string }>(versions: T[] | null) => {
|
||||||
if (!versions) {
|
if (!versions) {
|
||||||
return <TextCell value="---" grey italic />;
|
return <TextCell value="---" grey />;
|
||||||
}
|
}
|
||||||
const text =
|
const text =
|
||||||
versions.length !== 1 ? `${versions.length} versions` : versions[0].version;
|
versions.length !== 1 ? `${versions.length} versions` : versions[0].version;
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,13 @@ const baseClass = "vulnerabilities-cell";
|
||||||
const generateCell = (
|
const generateCell = (
|
||||||
vulnerabilities: ISoftwareVulnerability[] | string[] | null
|
vulnerabilities: ISoftwareVulnerability[] | string[] | null
|
||||||
) => {
|
) => {
|
||||||
if (vulnerabilities === null) {
|
if (vulnerabilities === null || vulnerabilities.length === 0) {
|
||||||
return <TextCell value="---" grey italic />;
|
return <TextCell value="---" grey />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let text = "";
|
let text = "";
|
||||||
let italicize = true;
|
let italicize = true;
|
||||||
if (vulnerabilities.length === 0) {
|
if (vulnerabilities.length === 1) {
|
||||||
text = "---";
|
|
||||||
} else if (vulnerabilities.length === 1) {
|
|
||||||
italicize = false;
|
italicize = false;
|
||||||
text =
|
text =
|
||||||
typeof vulnerabilities[0] === "string"
|
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 React from "react";
|
||||||
|
import classnames from "classnames";
|
||||||
|
|
||||||
import getMatchedSoftwareIcon from "../";
|
import getMatchedSoftwareIcon from "../";
|
||||||
|
|
||||||
const baseClass = "software-icon";
|
const baseClass = "software-icon";
|
||||||
|
|
||||||
|
type SoftwareIconSizes = "small" | "medium" | "large" | "xlarge";
|
||||||
|
|
||||||
interface ISoftwareIconProps {
|
interface ISoftwareIconProps {
|
||||||
name?: string;
|
name?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
size?: SoftwareIconSizes;
|
size?: SoftwareIconSizes;
|
||||||
|
/** Accepts an image url to display for a the software icon image. */
|
||||||
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOFTWARE_ICON_SIZES: Record<string, string> = {
|
const SOFTWARE_ICON_SIZES: Record<SoftwareIconSizes, string> = {
|
||||||
medium: "24",
|
small: "24",
|
||||||
meduim_large: "64", // TODO: rename this to large and update large to xlarge
|
medium: "40",
|
||||||
large: "96",
|
large: "64",
|
||||||
} as const;
|
xlarge: "96",
|
||||||
|
};
|
||||||
type SoftwareIconSizes = keyof typeof SOFTWARE_ICON_SIZES;
|
|
||||||
|
|
||||||
const SoftwareIcon = ({
|
const SoftwareIcon = ({
|
||||||
name = "",
|
name = "",
|
||||||
source = "",
|
source = "",
|
||||||
size = "medium",
|
size = "small",
|
||||||
|
url,
|
||||||
}: ISoftwareIconProps) => {
|
}: 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 });
|
const MatchedIcon = getMatchedSoftwareIcon({ name, source });
|
||||||
return (
|
return (
|
||||||
<MatchedIcon
|
<MatchedIcon
|
||||||
width={SOFTWARE_ICON_SIZES[size]}
|
width={SOFTWARE_ICON_SIZES[size]}
|
||||||
height={SOFTWARE_ICON_SIZES[size]}
|
height={SOFTWARE_ICON_SIZES[size]}
|
||||||
viewBox="0 0 32 32"
|
viewBox="0 0 32 32"
|
||||||
className={baseClass}
|
className={classNames}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,44 @@
|
||||||
.software-icon {
|
.software-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: 1px solid $ui-fleet-black-10;
|
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 ChromeOS from "./ChromeOS";
|
||||||
import LinuxOS from "./LinuxOS";
|
import LinuxOS from "./LinuxOS";
|
||||||
import Falcon from "./Falcon";
|
import Falcon from "./Falcon";
|
||||||
|
import AppStore from "./AppStore";
|
||||||
import iOS from "./iOS";
|
import iOS from "./iOS";
|
||||||
import iPadOS from "./iPadOS";
|
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
|
// icon for them, keys refer to application names, and are intended to be fuzzy
|
||||||
// matched in the application logic.
|
// matched in the application logic.
|
||||||
const SOFTWARE_NAME_TO_ICON_MAP = {
|
const SOFTWARE_NAME_TO_ICON_MAP = {
|
||||||
|
appStore: AppStore,
|
||||||
"adobe acrobat reader": AcrobatReader,
|
"adobe acrobat reader": AcrobatReader,
|
||||||
"microsoft excel": Excel,
|
"microsoft excel": Excel,
|
||||||
falcon: Falcon,
|
falcon: Falcon,
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import PATHS from "router/paths";
|
||||||
|
|
||||||
import { ISideNavItem } from "../components/SideNav/SideNav";
|
import { ISideNavItem } from "../components/SideNav/SideNav";
|
||||||
import Integrations from "./cards/Integrations";
|
import Integrations from "./cards/Integrations";
|
||||||
import Mdm from "./cards/MdmSettings/MdmSettings";
|
import MdmSettings from "./cards/MdmSettings";
|
||||||
import AutomaticEnrollment from "./cards/AutomaticEnrollment/AutomaticEnrollment";
|
import AutomaticEnrollment from "./cards/AutomaticEnrollment";
|
||||||
import Calendars from "./cards/Calendars/Calendars";
|
import Calendars from "./cards/Calendars";
|
||||||
|
import Vpp from "./cards/Vpp";
|
||||||
|
|
||||||
const integrationSettingsNavItems: ISideNavItem<any>[] = [
|
const integrationSettingsNavItems: ISideNavItem<any>[] = [
|
||||||
// TODO: types
|
// TODO: types
|
||||||
|
|
@ -18,7 +19,7 @@ const integrationSettingsNavItems: ISideNavItem<any>[] = [
|
||||||
title: "Mobile device management (MDM)",
|
title: "Mobile device management (MDM)",
|
||||||
urlSection: "mdm",
|
urlSection: "mdm",
|
||||||
path: PATHS.ADMIN_INTEGRATIONS_MDM,
|
path: PATHS.ADMIN_INTEGRATIONS_MDM,
|
||||||
Card: Mdm,
|
Card: MdmSettings,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Automatic enrollment",
|
title: "Automatic enrollment",
|
||||||
|
|
@ -32,6 +33,12 @@ const integrationSettingsNavItems: ISideNavItem<any>[] = [
|
||||||
path: PATHS.ADMIN_INTEGRATIONS_CALENDARS,
|
path: PATHS.ADMIN_INTEGRATIONS_CALENDARS,
|
||||||
Card: Calendars,
|
Card: Calendars,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Volume Purchasing Program (VPP)",
|
||||||
|
urlSection: "vpp",
|
||||||
|
path: PATHS.ADMIN_INTEGRATIONS_VPP,
|
||||||
|
Card: Vpp,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default integrationSettingsNavItems;
|
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 { useQuery } from "react-query";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { InjectedRouter } from "react-router";
|
import { InjectedRouter } from "react-router";
|
||||||
|
|
||||||
import PATHS from "router/paths";
|
import PATHS from "router/paths";
|
||||||
import { AppContext } from "context/app";
|
import { AppContext } from "context/app";
|
||||||
import { IConfig } from "interfaces/config";
|
|
||||||
import { IMdmApple } from "interfaces/mdm";
|
import { IMdmApple } from "interfaces/mdm";
|
||||||
import mdmAppleAPI from "services/entities/mdm_apple";
|
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