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:
Jahziel Villasana-Espinoza 2024-07-25 12:52:49 -04:00 committed by GitHub
commit 6a31d4eb44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
173 changed files with 8687 additions and 1219 deletions

View 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
View file

@ -0,0 +1 @@
- Adds DB updates to support the VPP software feature.

View file

@ -0,0 +1 @@
- Adds functionality for the `GET /software/app_store_apps` and `POST /software/app_store_apps` endpoints.

View file

@ -0,0 +1 @@
- Adds functionality for installing App Store apps to the VPP feature.

View file

@ -0,0 +1 @@
- Adds global activity support for VPP related activities.

View file

@ -0,0 +1 @@
* Add support for VPP to gitops config

View file

@ -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.

View file

@ -0,0 +1 @@
- GitOps supports VPP app associations

View 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`.

View file

@ -0,0 +1 @@
- add ability to add/remove/disable vpp in the fleet UI.

View file

@ -0,0 +1 @@
- add UI to support the apple vpp feature on the software pages.

View file

@ -0,0 +1 @@
- add UI updates for VPP feature on host software and my device pages.

View file

@ -692,10 +692,10 @@ func TestGetSoftwareTitles(t *testing.T) {
apiVersion: "1"
kind: software_title
spec:
- hosts_count: 2
- app_store_app: null
hosts_count: 2
id: 0
name: foo
self_service: false
software_package: null
source: chrome_extensions
versions:
@ -713,10 +713,10 @@ spec:
vulnerabilities:
- cve-123-456-003
versions_count: 3
- hosts_count: 0
- app_store_app: null
hosts_count: 0
id: 0
name: bar
self_service: false
software_package: null
source: deb_packages
versions:
@ -761,8 +761,8 @@ spec:
]
}
],
"self_service": false,
"software_package": null
"software_package": null,
"app_store_app": null
},
{
"id": 0,
@ -774,11 +774,11 @@ spec:
{
"id": 0,
"version": "0.0.3",
"vulnerabilities": null
"vulnerabilities": null
}
],
"self_service": false,
"software_package": null
"software_package": null,
"app_store_app": null
}
]
}

View file

@ -2,6 +2,9 @@ package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@ -16,6 +19,7 @@ import (
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mock"
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
@ -261,6 +265,12 @@ func TestBasicTeamGitOps(t *testing.T) {
const secret = "TestSecret"
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error {
return nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil }
ds.BatchSetMDMProfilesFunc = func(
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration,
@ -747,7 +757,12 @@ func TestFullTeamGitOps(t *testing.T) {
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
return nil
}
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error {
return nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
enrolledSecrets = secrets
return nil
@ -867,6 +882,13 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) {
return nil
}
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error {
return nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
const (
fleetServerURL = "https://fleet.example.com"
orgName = "GitOps Test"
@ -1149,6 +1171,13 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) {
}, nil
}
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error {
return nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
globalFile := "./testdata/gitops/global_config_no_paths.yml"
teamFile := "./testdata/gitops/team_config_no_paths.yml"
@ -1191,11 +1220,18 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) {
{"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"},
{"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field TeamSpecSoftware.self_service of type bool"},
{"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field TeamSpecSoftware.packages of type bool"},
}
for _, c := range cases {
t.Run(filepath.Base(c.file), func(t *testing.T) {
setupFullGitOpsPremiumServer(t)
ds, _, _ := setupFullGitOpsPremiumServer(t)
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error {
return nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
_, err := runAppNoChecks([]string{"gitops", "-f", c.file})
if c.wantErr == "" {
@ -1207,6 +1243,99 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) {
}
}
func TestTeamVPPAppsGitOps(t *testing.T) {
config := &appleVPPConfigSrvConf{
Assets: []vpp.Asset{
{
AdamID: "1",
PricingParam: "STDQ",
AvailableCount: 12,
},
{
AdamID: "2",
PricingParam: "STDQ",
AvailableCount: 3,
},
},
SerialNumbers: []string{"123", "456"},
}
startVPPApplyServer(t, config)
cases := []struct {
file string
wantErr string
tokenExpiration time.Time
}{
{"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour)},
{"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour)},
{"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(24 * time.Hour)},
{"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(-24 * time.Hour)},
{"testdata/gitops/team_vpp_valid_app.yml", "VPP token expired", time.Now().Add(-24 * time.Hour)},
{"testdata/gitops/team_vpp_invalid_app.yml", "app not available on vpp account", time.Now().Add(24 * time.Hour)},
}
for _, c := range cases {
t.Run(filepath.Base(c.file), func(t *testing.T) {
ds, _, _ := setupFullGitOpsPremiumServer(t)
token, err := createVPPDataToken(c.tokenExpiration, "fleet", "ca")
require.NoError(t, err)
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error {
return nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
asset := map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetVPPToken: {
Name: fleet.MDMAssetVPPToken,
Value: token,
},
}
return asset, nil
}
_, err = runAppNoChecks([]string{"gitops", "-f", c.file})
if c.wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, c.wantErr)
}
})
}
}
func createVPPDataToken(expiration time.Time, orgName, location string) ([]byte, error) {
var randBytes [32]byte
_, err := rand.Read(randBytes[:])
if err != nil {
return nil, fmt.Errorf("generating random bytes: %w", err)
}
token := base64.StdEncoding.EncodeToString(randBytes[:])
raw := fleet.VPPTokenRaw{
OrgName: orgName,
Token: token,
ExpDate: expiration.Format("2006-01-02T15:04:05Z0700"),
}
rawJson, err := json.Marshal(raw)
if err != nil {
return nil, fmt.Errorf("marshalling vpp raw token: %w", err)
}
base64Token := base64.StdEncoding.EncodeToString(rawJson)
dataToken := fleet.VPPTokenData{Token: base64Token, Location: location}
dataTokenJson, err := json.Marshal(dataToken)
if err != nil {
return nil, fmt.Errorf("marshalling vpp data token: %w", err)
}
return dataTokenJson, nil
}
func TestCustomSettingsGitOps(t *testing.T) {
cases := []struct {
file string
@ -1243,6 +1372,12 @@ func TestCustomSettingsGitOps(t *testing.T) {
}
return ret, nil
}
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error {
return nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
_, err := runAppNoChecks([]string{"gitops", "-f", c.file})
if c.wantErr == "" {
@ -1288,6 +1423,140 @@ func startSoftwareInstallerServer(t *testing.T) {
t.Setenv("SOFTWARE_INSTALLER_URL", srv.URL)
}
type appleVPPConfigSrvConf struct {
Assets []vpp.Asset
SerialNumbers []string
}
func startVPPApplyServer(t *testing.T, config *appleVPPConfigSrvConf) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "associate") {
var associations vpp.AssociateAssetsRequest
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&associations); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if len(associations.Assets) == 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
res := vpp.ErrorResponse{
ErrorNumber: 9718,
ErrorMessage: "This request doesn't contain an asset, which is a required argument. Change the request to provide an asset.",
}
if err := json.NewEncoder(w).Encode(res); err != nil {
panic(err)
}
return
}
if len(associations.SerialNumbers) == 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
res := vpp.ErrorResponse{
ErrorNumber: 9719,
ErrorMessage: "Either clientUserIds or serialNumbers are required arguments. Change the request to provide assignable users and devices.",
}
if err := json.NewEncoder(w).Encode(res); err != nil {
panic(err)
}
return
}
var badAssets []vpp.Asset
for _, reqAsset := range associations.Assets {
var found bool
for _, goodAsset := range config.Assets {
if reqAsset == goodAsset {
found = true
}
}
if !found {
badAssets = append(badAssets, reqAsset)
}
}
var badSerials []string
for _, reqSerial := range associations.SerialNumbers {
var found bool
for _, goodSerial := range config.SerialNumbers {
if reqSerial == goodSerial {
found = true
}
}
if !found {
badSerials = append(badSerials, reqSerial)
}
}
if len(badAssets) != 0 || len(badSerials) != 0 {
errMsg := "error associating assets."
if len(badAssets) > 0 {
var badAdamIds []string
for _, asset := range badAssets {
badAdamIds = append(badAdamIds, asset.AdamID)
}
errMsg += fmt.Sprintf(" assets don't exist on account: %s.", strings.Join(badAdamIds, ", "))
}
if len(badSerials) > 0 {
errMsg += fmt.Sprintf(" bad serials: %s.", strings.Join(badSerials, ", "))
}
res := vpp.ErrorResponse{
ErrorInfo: vpp.ResponseErrorInfo{
Assets: badAssets,
ClientUserIds: []string{"something"},
SerialNumbers: badSerials,
},
// Not sure what error should be returned on each
// error type
ErrorNumber: 1,
ErrorMessage: errMsg,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(res); err != nil {
panic(err)
}
}
return
}
if strings.Contains(r.URL.Path, "assets") {
// Then we're responding to GetAssets
w.Header().Set("Content-Type", "application/json")
encoder := json.NewEncoder(w)
err := encoder.Encode(map[string][]vpp.Asset{"assets": config.Assets})
if err != nil {
panic(err)
}
return
}
resp := []byte(`{"locationName": "Fleet Location One"}`)
if strings.Contains(r.URL.RawQuery, "invalidToken") {
// This replicates the response sent back from Apple's VPP endpoints when an invalid
// token is passed. For more details see:
// https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes
// https://developer.apple.com/documentation/devicemanagement/client_config
// https://developer.apple.com/documentation/devicemanagement/errorresponse
// Note that the Apple server returns 200 in this case.
resp = []byte(`{"errorNumber": 9622,"errorMessage": "Invalid authentication token"}`)
}
if strings.Contains(r.URL.RawQuery, "serverError") {
resp = []byte(`{"errorNumber": 9603,"errorMessage": "Internal server error"}`)
w.WriteHeader(http.StatusInternalServerError)
}
_, _ = w.Write(resp)
}))
t.Setenv("FLEET_DEV_VPP_URL", srv.URL)
t.Cleanup(srv.Close)
}
func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, **fleet.Team) {
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
require.NoError(t, err)
@ -1322,6 +1591,12 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig,
savedAppConfig = &appConfigCopy
return nil
}
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error {
return nil
}
ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
return nil
}
var savedTeam *fleet.Team

View file

@ -61,7 +61,6 @@
}
},
"scripts": null,
"software": null,
"user_count": 99,
"host_count": 42
}
@ -145,7 +144,6 @@
}
},
"scripts": null,
"software": null,
"user_count": 87,
"host_count": 43
}

View file

@ -36,7 +36,6 @@ spec:
macos_setup_assistant:
scripts: null
secrets: null
software: null
webhook_settings:
host_status_webhook: null
name: team1
@ -86,7 +85,6 @@ spec:
enable_release_device_manually: false
macos_setup_assistant:
scripts: null
software: null
webhook_settings:
host_status_webhook: null
name: team2

View file

@ -116,12 +116,13 @@ policies:
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
software:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
self_service: true
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
self_service: true

View file

@ -13,6 +13,7 @@ controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/notfound.sh
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/notfound.sh

View file

@ -13,5 +13,6 @@ controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
self_service: "not a boolean"
packages:
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
self_service: "not a boolean"

View file

@ -13,9 +13,10 @@ controls:
policies:
queries:
software:
- install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh
packages:
- install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh

View file

@ -13,4 +13,5 @@ controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/notfound.deb
packages:
- url: ${SOFTWARE_INSTALLER_URL}/notfound.deb

View file

@ -13,8 +13,9 @@ controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
post_install_script:
path: lib/notfound.sh
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
post_install_script:
path: lib/notfound.sh

View file

@ -13,10 +13,11 @@ controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_multiple.yml
post_install_script:
path: lib/post_install_ruby.sh
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_multiple.yml
post_install_script:
path: lib/post_install_ruby.sh

View file

@ -13,8 +13,9 @@ controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/notfound.yml
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/notfound.yml

View file

@ -13,4 +13,5 @@ controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb
packages:
- url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb

View file

@ -13,4 +13,5 @@ controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
packages:
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt

View file

@ -13,12 +13,13 @@ controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
self_service: true
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
self_service: true

View 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"

View 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"

View 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:

View file

@ -36,7 +36,6 @@ spec:
grace_period_days: null
scripts: null
secrets: null
software: null
webhook_settings:
host_status_webhook: null
name: tm1
@ -77,7 +76,6 @@ spec:
grace_period_days: null
scripts: null
secrets: null
software: null
webhook_settings:
host_status_webhook: null
name: tm2

View file

@ -36,7 +36,6 @@ spec:
grace_period_days: null
scripts: null
secrets: null
software: null
webhook_settings:
host_status_webhook: null
name: tm1
@ -77,7 +76,6 @@ spec:
grace_period_days: null
scripts: null
secrets: null
software: null
webhook_settings:
host_status_webhook: null
name: tm2

View file

@ -36,7 +36,6 @@ spec:
custom_settings: null
scripts: null
secrets: null
software: null
webhook_settings:
host_status_webhook: null
name: tm1

View file

@ -35,7 +35,6 @@ spec:
grace_period_days: null
scripts: null
secrets: null
software: null
webhook_settings:
host_status_webhook: null
name: tm1

View file

@ -1200,7 +1200,7 @@ Generated when a software installer is deleted from Fleet.
This activity contains the following fields:
- "software_title": Name of the software.
- "software_package": Filename of the installer.
- "team_name": Name of the team to which this software was added. `null if it was added to no team.
- "team_name": Name of the team to which this software was added. `null` if it was added to no team.
- "team_id": The ID of the team to which this software was added. `null` if it was added to no team.
- "self_service": Whether the software was available for installation by the end user.
@ -1216,6 +1216,83 @@ This activity contains the following fields:
}
```
## enabled_vpp
Generated when the VPP feature is enabled in Fleet.
## disabled_vpp
Generated when the VPP feature is disabled in Fleet.
## added_app_store_app
Generated when an App Store app is added to Fleet.
This activity contains the following fields:
- "software_title": Name of the App Store app.
- "app_store_id": ID of the app on the Apple App Store.
- "team_name": Name of the team to which this App Store app was added, or `null` if it was added to no team.
- "team_id": ID of the team to which this App Store app was added, or `null`if it was added to no team.
#### Example
```json
{
"software_title": "Logic Pro",
"app_store_id": "1234567",
"team_name": "Workstations",
"team_id": 1
}
```
## deleted_app_store_app
Generated when an App Store app is deleted from Fleet.
This activity contains the following fields:
- "software_title": Name of the App Store app.
- "app_store_id": ID of the app on the Apple App Store.
- "team_name": Name of the team from which this App Store app was deleted, or `null` if it was deleted from no team.
- "team_id": ID of the team from which this App Store app was deleted, or `null`if it was deleted from no team.
#### Example
```json
{
"software_title": "Logic Pro",
"app_store_id": "1234567",
"team_name": "Workstations",
"team_id": 1
}
```
## installed_app_store_app
Generated when an App Store app is installed on a device.
This activity contains the following fields:
- host_id: ID of the host on which the app was installed.
- host_display_name: Display name of the host.
- software_title: Name of the App Store app.
- app_store_id: ID of the app on the Apple App Store.
- command_uuid: UUID of the MDM command used to install the app.
#### Example
```json
{
"host_id": 42,
"host_display_name": "Anna's MacBook Pro",
"software_title": "Logic Pro",
"app_store_id": "1234567",
"command_uuid": "98765432-1234-1234-1234-1234567890ab"
}
```
<meta name="title" value="Audit logs">
<meta name="pageOrderInSection" value="1400">

View file

@ -14,11 +14,14 @@ import (
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
"github.com/go-kit/log/level"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
)
@ -85,15 +88,60 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t
return fleet.NewInvalidArgumentError("team_id", "is required and can't be zero")
}
// we authorize with SoftwareInstaller here, but it uses the same AuthzType
// as VPPApp, so this is correct for both software installers and VPP apps.
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
return err
}
// first, look for a software installer
meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false)
if err != nil {
if fleet.IsNotFound(err) {
// no software installer, look for a VPP app
meta, err := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting software app metadata")
}
return svc.deleteVPPApp(ctx, teamID, meta)
}
return ctxerr.Wrap(ctx, err, "getting software installer metadata")
}
return svc.deleteSoftwareInstaller(ctx, meta)
}
func (svc *Service) deleteVPPApp(ctx context.Context, teamID *uint, meta *fleet.VPPAppStoreApp) error {
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
}
if err := svc.ds.DeleteVPPAppFromTeam(ctx, teamID, meta.AppStoreID); err != nil {
return ctxerr.Wrap(ctx, err, "deleting VPP app")
}
var teamName *string
if teamID != nil {
t, err := svc.ds.Team(ctx, *teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting team name for deleted VPP app")
}
teamName = &t.Name
}
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityDeletedAppStoreApp{
AppStoreID: meta.AppStoreID,
SoftwareTitle: meta.Name,
TeamName: teamName,
TeamID: teamID,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for deleted VPP app")
}
return nil
}
func (svc *Service) deleteSoftwareInstaller(ctx context.Context, meta *fleet.SoftwareInstaller) error {
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
@ -217,7 +265,6 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
// fleetd is required to install software so if the host is
// enrolled via plain osquery we return an error
svc.authz.SkipAuthorization(ctx)
// TODO(roberto): for cleanup task, confirm with product error message.
return fleet.NewUserMessageError(errors.New("Host doesn't have fleetd installed"), http.StatusUnprocessableEntity)
}
@ -228,19 +275,159 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false)
if err != nil {
if !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "finding software installer for title")
}
installer = nil
}
// if we found an installer, use that
if installer != nil {
return svc.installSoftwareTitleUsingInstaller(ctx, host, installer)
}
vppApp, err := svc.ds.GetVPPAppByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false)
if err != nil {
// if we couldn't find an installer or a VPP app, return a bad
// request error
if fleet.IsNotFound(err) {
return &fleet.BadRequestError{
Message: "Software title has no package added. Please add software package to install.",
Message: "Couldn't install software. Software title is not available for install. Please add software package or App Store app to install.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "couldn't find an installer for software title",
ctx, err, "couldn't find an installer or VPP app for software title",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
return ctxerr.Wrap(ctx, err, "finding software installer for title")
return ctxerr.Wrap(ctx, err, "finding VPP app for title")
}
return svc.installSoftwareFromVPP(ctx, host, vppApp)
}
func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp) error {
if host.FleetPlatform() != "darwin" {
return &fleet.BadRequestError{
Message: "VPP apps can only be installed only on macOS hosts.",
InternalErr: ctxerr.NewWithData(
ctx, "invalid host platform for requested installer",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": vppApp.TitleID},
),
}
}
config, err := svc.ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "fetching config to check MDM status")
}
if !config.MDM.EnabledAndConfigured {
return fleet.NewUserMessageError(errors.New("Couldn't install. MDM is turned off. Please make sure that MDM is turned on to install App Store apps."), http.StatusUnprocessableEntity)
}
mdmConnected, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host)
if err != nil {
return ctxerr.Wrapf(ctx, err, "checking MDM status for host %d", host.ID)
}
if !mdmConnected {
return &fleet.BadRequestError{
Message: "VPP apps can only be installed only on hosts enrolled in MDM.",
InternalErr: ctxerr.NewWithData(
ctx, "VPP install attempted on non-MDM host",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": vppApp.TitleID},
),
}
}
token, err := svc.getVPPToken(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting VPP token")
}
// at this moment, neither the UI or the back-end are prepared to
// handle [asyncronous errors][1] on assignment, so before assigning a
// device to a license, we need to:
//
// 1. Check if the app is already assigned to the serial number.
// 2. If it's not assigned yet, check if we have enough licenses.
//
// A race still might happen, so async error checking needs to be
// implemented anyways at some point.
//
// [1]: https://developer.apple.com/documentation/devicemanagement/app_and_book_management/handling_error_responses#3729433
assignments, err := vpp.GetAssignments(token, &vpp.AssignmentFilter{AdamID: vppApp.AdamID, SerialNumber: host.HardwareSerial})
if err != nil {
return ctxerr.Wrap(ctx, err, "getting assignments from VPP API")
}
var eventID string
// this app is not assigned to this device, check if we have licenses
// left and assign it.
if len(assignments) == 0 {
assets, err := vpp.GetAssets(token, &vpp.AssetFilter{AdamID: vppApp.AdamID})
if err != nil {
return ctxerr.Wrap(ctx, err, "getting assets from VPP API")
}
if len(assets) == 0 {
level.Debug(svc.logger).Log(
"msg", "trying to assign VPP asset to host",
"adam_id", vppApp.AdamID,
"host_serial", host.HardwareSerial,
)
return &fleet.BadRequestError{
Message: "Couldn't add software. <app_store_id> isn't available in Apple Business Manager. Please purchase license in Apple Business Manager and try again.",
InternalErr: ctxerr.Errorf(ctx, "VPP API didn't return any assets for adamID %s", vppApp.AdamID),
}
}
if len(assets) > 1 {
return ctxerr.Errorf(ctx, "VPP API returned more than one asset for adamID %s", vppApp.AdamID)
}
if assets[0].AvailableCount <= 0 {
return &fleet.BadRequestError{
Message: "Couldn't install. No available licenses. Please purchase license in Apple Business Manager and try again.",
InternalErr: ctxerr.NewWithData(
ctx, "license available count <= 0",
map[string]any{
"host_id": host.ID,
"team_id": host.TeamID,
"adam_id": vppApp.AdamID,
"count": assets[0].AvailableCount,
},
),
}
}
eventID, err = vpp.AssociateAssets(token, &vpp.AssociateAssetsRequest{Assets: assets, SerialNumbers: []string{host.HardwareSerial}})
if err != nil {
return ctxerr.Wrapf(ctx, err, "associating asset with adamID %s to host %s", vppApp.AdamID, host.HardwareSerial)
}
}
user := authz.UserFromContext(ctx)
// add command to install
cmdUUID := uuid.NewString()
err = svc.mdmAppleCommander.InstallApplication(ctx, []string{host.UUID}, cmdUUID, vppApp.AdamID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "sending command to install VPP %s application to host with serial %s", vppApp.AdamID, host.HardwareSerial)
}
err = svc.ds.InsertHostVPPSoftwareInstall(ctx, host.ID, user.ID, vppApp.AdamID, cmdUUID, eventID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "inserting host vpp software install for host with serial %s and app with adamID %s", host.HardwareSerial, vppApp.AdamID)
}
return nil
}
func (svc *Service) installSoftwareTitleUsingInstaller(ctx context.Context, host *fleet.Host, installer *fleet.SoftwareInstaller) error {
ext := filepath.Ext(installer.Name)
requiredPlatform := packageExtensionToPlatform(ext)
if requiredPlatform == "" {
@ -251,14 +438,14 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
if host.FleetPlatform() != requiredPlatform {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform),
InternalErr: ctxerr.WrapWithData(
ctx, err, "invalid host platform for requested installer",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
InternalErr: ctxerr.NewWithData(
ctx, "invalid host platform for requested installer",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": installer.TitleID},
),
}
}
_, err = svc.ds.InsertSoftwareInstallRequest(ctx, hostID, installer.InstallerID, false)
_, err := svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, installer.InstallerID, false)
return ctxerr.Wrap(ctx, err, "inserting software install request")
}

View file

@ -1083,6 +1083,7 @@ func (svc *Service) createTeamFromSpec(
Integrations: fleet.TeamIntegrations{
GoogleCalendar: spec.Integrations.GoogleCalendar,
},
Software: spec.Software,
},
Secrets: secrets,
})
@ -1243,8 +1244,18 @@ func (svc *Service) editTeamFromSpec(
team.Config.Scripts = spec.Scripts
}
if spec.Software.Set {
team.Config.Software = spec.Software
if spec.Software != nil {
if team.Config.Software == nil {
team.Config.Software = &fleet.TeamSpecSoftware{}
}
if spec.Software.Packages.Set {
team.Config.Software.Packages = spec.Software.Packages
}
if spec.Software.AppStoreApps.Set {
team.Config.Software.AppStoreApps = spec.Software.AppStoreApps
}
}
if secrets != nil {

299
ee/server/service/vpp.go Normal file
View 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
}

View file

@ -1,4 +1,5 @@
import { IMdmApple } from "interfaces/mdm";
import { IGetVppInfoResponse, IVppApp } from "services/entities/mdm_apple";
const DEFAULT_MDM_APPLE_MOCK: IMdmApple = {
common_name: "APSP:12345",
@ -13,4 +14,28 @@ export const createMockMdmApple = (
return { ...DEFAULT_MDM_APPLE_MOCK, ...overrides };
};
const DEFAULT_MDM_APPLE_VPP_INFO_MOCK: IGetVppInfoResponse = {
org_name: "test org",
renew_date: "2024-09-19T00:00:00Z",
location: "test location",
};
export const createMockVppInfo = (
overrides?: Partial<IGetVppInfoResponse>
): IGetVppInfoResponse => {
return { ...DEFAULT_MDM_APPLE_VPP_INFO_MOCK, ...overrides };
};
const DEFAULT_MDM_APPLE_VPP_APP_MOCK: IVppApp = {
name: "Test App",
icon_url: "https://via.placeholder.com/512",
latest_version: "1.0",
app_store_id: 1,
added: false,
};
export const createMockVppApp = (overrides?: Partial<IVppApp>): IVppApp => {
return { ...DEFAULT_MDM_APPLE_VPP_APP_MOCK, ...overrides };
};
export default createMockMdmApple;

View file

@ -1,4 +1,49 @@
import { IConfig } from "interfaces/config";
import { IConfig, IMdmConfig } from "interfaces/config";
const DEFAULT_CONFIG_MDM_MOCK: IMdmConfig = {
enable_disk_encryption: false,
windows_enabled_and_configured: true,
apple_bm_default_team: "Apples",
apple_bm_enabled_and_configured: true,
apple_bm_terms_expired: false,
enabled_and_configured: true,
macos_updates: {
minimum_version: "",
deadline: "",
},
macos_settings: {
custom_settings: null,
enable_disk_encryption: false,
},
macos_setup: {
bootstrap_package: "",
enable_end_user_authentication: false,
macos_setup_assistant: null,
enable_release_device_manually: false,
},
macos_migration: {
enable: false,
mode: "",
webhook_url: "",
},
windows_updates: {
deadline_days: null,
grace_period_days: null,
},
end_user_authentication: {
entity_id: "",
issuer_uri: "",
metadata: "",
metadata_url: "",
idp_name: "",
},
};
export const createMockMdmConfig = (
overrides?: Partial<IMdmConfig>
): IMdmConfig => {
return { ...DEFAULT_CONFIG_MDM_MOCK, ...overrides };
};
const DEFAULT_CONFIG_MOCK: IConfig = {
org_info: {
@ -136,44 +181,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = {
enable_software_inventory: true,
},
fleet_desktop: { transparency_url: "https://fleetdm.com/transparency" },
mdm: {
enable_disk_encryption: false,
windows_enabled_and_configured: true,
apple_bm_default_team: "Apples",
apple_bm_enabled_and_configured: true,
apple_bm_terms_expired: false,
enabled_and_configured: true,
macos_updates: {
minimum_version: "",
deadline: "",
},
macos_settings: {
custom_settings: null,
enable_disk_encryption: false,
},
macos_setup: {
bootstrap_package: "",
enable_end_user_authentication: false,
macos_setup_assistant: null,
enable_release_device_manually: false,
},
macos_migration: {
enable: false,
mode: "",
webhook_url: "",
},
windows_updates: {
deadline_days: null,
grace_period_days: null,
},
end_user_authentication: {
entity_id: "",
issuer_uri: "",
metadata: "",
metadata_url: "",
idp_name: "",
},
},
mdm: createMockMdmConfig(),
};
const createMockConfig = (overrides?: Partial<IConfig>): IConfig => {

View file

@ -1,6 +1,7 @@
import { IDeviceUser } from "interfaces/host";
import { IDeviceSoftware } from "interfaces/software";
import { IGetDeviceSoftwareResponse } from "services/entities/device_user";
import { createMockHostSoftwarePackage } from "./hostMock";
const DEFAULT_DEVICE_USER_MOCK: IDeviceUser = {
email: "test@test.com",
@ -16,16 +17,12 @@ const createMockDeviceUser = (
const DEFAULT_DEVICE_SOFTWARE_MOCK: IDeviceSoftware = {
id: 1,
name: "mock software 1.app",
self_service: false,
source: "apps",
bundle_identifier: "com.app.mock",
status: null,
last_install: null,
installed_versions: null,
package: {
name: "mock software 1",
version: "1.0.0",
},
software_package: createMockHostSoftwarePackage(),
app_store_app: null,
};
export const createMockDeviceSoftware = (

View file

@ -5,7 +5,11 @@ import { pick } from "lodash";
import { normalizeEmptyValues } from "utilities/helpers";
import { HOST_SUMMARY_DATA } from "utilities/constants";
import { IGetHostSoftwareResponse } from "services/entities/hosts";
import { IHostSoftware } from "interfaces/software";
import {
IHostAppStoreApp,
IHostSoftware,
IHostSoftwarePackage,
} from "interfaces/software";
const DEFAULT_HOST_PROFILE_MOCK: IHostMdmProfile = {
profile_uuid: "123-abc",
@ -136,18 +140,45 @@ export const createMockHostSummary = (overrides?: Partial<IHost>) => {
);
};
const DEFAULT_HOST_SOFTWARE_MOCK: IHostSoftware = {
id: 1,
const DEFAULT_HOST_SOFTWARE_PACKAGE_MOCK: IHostSoftwarePackage = {
name: "mock software.app",
package_available_for_install: "mockSoftware.app",
version: "1.0.0",
self_service: false,
source: "apps",
bundle_identifier: "com.test.mock",
status: "installed",
icon_url: "https://example.com/icon.png",
last_install: {
install_uuid: "123-abc",
installed_at: "2022-01-01T12:00:00Z",
},
};
export const createMockHostSoftwarePackage = (
overrides?: Partial<IHostSoftwarePackage>
): IHostSoftwarePackage => {
return { ...DEFAULT_HOST_SOFTWARE_PACKAGE_MOCK, ...overrides };
};
const DEFAULT_HOST_APP_STORE_APP_MOCK: IHostAppStoreApp = {
app_store_id: "123456789",
version: "1.0.0",
self_service: false,
icon_url: "https://via.placeholder.com/512",
last_install: null,
};
export const createMockHostAppStoreApp = (
overrides?: Partial<IHostAppStoreApp>
): IHostAppStoreApp => {
return { ...DEFAULT_HOST_APP_STORE_APP_MOCK, ...overrides };
};
const DEFAULT_HOST_SOFTWARE_MOCK: IHostSoftware = {
id: 1,
name: "mock software.app",
software_package: createMockHostSoftwarePackage(),
app_store_app: null,
source: "apps",
bundle_identifier: "com.test.mock",
status: "installed",
installed_versions: [
{
version: "1.0.0",

View file

@ -1,11 +1,12 @@
import {
ISoftware,
ISoftwareVersion,
ISoftwareTitleWithPackageDetail,
ISoftwareTitleWithPackageName,
ISoftwareVulnerability,
ISoftwareTitleVersion,
ISoftwarePackage,
ISoftwareTitle,
ISoftwareTitleDetails,
IAppStoreApp,
} from "interfaces/software";
import {
ISoftwareTitlesResponse,
@ -44,53 +45,6 @@ export const createMockSoftwareTitleVersion = (
return { ...DEFAULT_SOFTWARE_TITLE_VERSION_MOCK, ...overrides };
};
type MockSoftwareTitle =
| Partial<ISoftwareTitleWithPackageDetail>
| Partial<ISoftwareTitleWithPackageName>;
const DEFAULT_SOFTWARE_TITLE_MOCK = {
id: 1,
name: "mock software 1.app",
software_package: null,
versions_count: 1,
source: "apps",
hosts_count: 1,
browser: "chrome",
versions: [createMockSoftwareTitleVersion()],
};
export const createMockSoftwareTitle = <
T extends
| Partial<ISoftwareTitleWithPackageDetail>
| Partial<ISoftwareTitleWithPackageName>
>(
overrides: T
) => {
const mock = {
...DEFAULT_SOFTWARE_TITLE_MOCK,
...overrides,
};
return mock;
};
const DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK: ISoftwareTitlesResponse = {
counts_updated_at: "2020-01-01T00:00:00.000Z",
count: 1,
software_titles: [
createMockSoftwareTitle({ software_package: null, self_service: false }),
],
meta: {
has_next_results: false,
has_previous_results: false,
},
};
export const createMockSoftwareTitlesReponse = (
overrides?: Partial<ISoftwareTitlesResponse>
): ISoftwareTitlesResponse => {
return { ...DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK, ...overrides };
};
const DEFAULT_SOFTWARE_VULNERABILITY_MOCK = {
cve: "CVE-2020-0001",
details_link: "https://test.com",
@ -145,17 +99,48 @@ export const createMockSoftwareVersionsReponse = (
return { ...DEFAULT_SOFTWARE_VERSIONS_RESPONSE_MOCK, ...overrides };
};
const DEFAULT_SOFTWARE_TITLE_RESPONSE = {
software_title: createMockSoftwareTitle({
software_package: null,
} as Partial<ISoftwareTitleWithPackageDetail>),
const DEFAULT_APP_STORE_APP_MOCK: IAppStoreApp = {
name: "test app",
app_store_id: 1,
icon_url: "https://via.placeholder.com/512",
latest_version: "1.2.3",
status: {
installed: 1,
pending: 2,
failed: 3,
},
};
export const createMockAppStoreApp = (overrides?: Partial<IAppStoreApp>) => {
return { ...DEFAULT_APP_STORE_APP_MOCK, ...overrides };
};
const DEFAULT_SOFTWARE_TITLE_DETAILS_MOCK: ISoftwareTitleDetails = {
id: 1,
name: "test.app",
software_package: null,
app_store_app: null,
source: "test_package",
hosts_count: 1,
versions: [createMockSoftwareTitleVersion()],
bundle_identifier: "com.test.Desktop",
versions_count: 1,
};
export const createMockSoftwareTitleDetails = (
overrides?: Partial<ISoftwareTitleDetails>
) => {
return { ...DEFAULT_SOFTWARE_TITLE_DETAILS_MOCK, ...overrides };
};
const DEFAULT_SOFTWARE_TITLE_RESPONSE: ISoftwareTitleResponse = {
software_title: createMockSoftwareTitleDetails(),
};
export const createMockSoftwareTitleResponse = (
overrides: Partial<ISoftwareTitleWithPackageDetail> = {}
overrides?: Partial<ISoftwareTitleResponse>
): ISoftwareTitleResponse => {
const mock = DEFAULT_SOFTWARE_TITLE_RESPONSE.software_title;
return { software_title: { ...mock, ...overrides } };
return { ...DEFAULT_SOFTWARE_TITLE_RESPONSE, ...overrides };
};
const DEFAULT_SOFTWARE_VERSION_RESPONSE = {
@ -168,7 +153,7 @@ export const createMockSoftwareVersionResponse = (
return { ...DEFAULT_SOFTWARE_VERSION_RESPONSE, ...overrides };
};
const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = {
const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
name: "TestPackage-1.2.3.pkg",
version: "1.2.3",
uploaded_at: "2020-01-01T00:00:00.000Z",
@ -177,6 +162,7 @@ const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = {
post_install_script:
"sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123",
self_service: false,
icon_url: null,
status: {
installed: 1,
pending: 2,
@ -187,5 +173,42 @@ const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = {
export const createMockSoftwarePackage = (
overrides?: Partial<ISoftwarePackage>
) => {
return { ...DEFAULT_SOFTWAREPACKAGE_MOCK, ...overrides };
return { ...DEFAULT_SOFTWARE_PACKAGE_MOCK, ...overrides };
};
const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = {
id: 1,
name: "mock software 1.app",
versions_count: 1,
source: "apps",
hosts_count: 1,
browser: "chrome",
versions: [createMockSoftwareTitleVersion()],
software_package: createMockSoftwarePackage(),
app_store_app: null,
};
export const createMockSoftwareTitle = (
overrides?: Partial<ISoftwareTitle>
): ISoftwareTitle => {
return {
...DEFAULT_SOFTWARE_TITLE_MOCK,
...overrides,
};
};
const DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK: ISoftwareTitlesResponse = {
counts_updated_at: "2020-01-01T00:00:00.000Z",
count: 1,
software_titles: [createMockSoftwareTitle()],
meta: {
has_next_results: false,
has_previous_results: false,
},
};
export const createMockSoftwareTitlesReponse = (
overrides?: Partial<ISoftwareTitlesResponse>
): ISoftwareTitlesResponse => {
return { ...DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK, ...overrides };
};

View file

@ -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 couldnt 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>
);
};

View file

@ -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;
}
}

View file

@ -0,0 +1 @@
export { AppInstallDetails, AppInstallDetailsModal } from "./AppInstallDetails";

View file

@ -4,7 +4,6 @@ import { useQuery } from "react-query";
import {
ISoftwareInstallResult,
ISoftwareInstallResults,
SoftwareInstallStatus,
} from "interfaces/software";
import softwareAPI from "services/entities/software";
@ -14,22 +13,14 @@ import Icon from "components/Icon";
import Textarea from "components/Textarea";
import DataError from "components/DataError/DataError";
import Spinner from "components/Spinner/Spinner";
import { IconNames } from "components/icons";
import {
INSTALL_DETAILS_STATUS_ICONS,
SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS,
getInstallDetailsStatusPredicate,
} from "../constants";
const baseClass = "software-install-details";
const STATUS_ICONS: Record<SoftwareInstallStatus, IconNames> = {
pending: "pending-outline",
installed: "success-outline",
failed: "error-outline",
} as const;
const STATUS_PREDICATES: Record<SoftwareInstallStatus, string> = {
pending: "will install",
installed: "installed",
failed: "failed to install",
} as const;
const StatusMessage = ({
result: { host_display_name, software_package, software_title, status },
}: {
@ -37,32 +28,26 @@ const StatusMessage = ({
}) => {
return (
<div className={`${baseClass}__status-message`}>
<Icon name={STATUS_ICONS[status]} />
<Icon name={INSTALL_DETAILS_STATUS_ICONS[status]} />
<span>
Fleet {STATUS_PREDICATES[status]} <b>{software_title}</b> (
{software_package}) on <b>{host_display_name}</b>
Fleet {getInstallDetailsStatusPredicate(status)} <b>{software_title}</b>{" "}
({software_package}) on <b>{host_display_name}</b>
{status === "pending" ? " when it comes online" : ""}.
</span>
</div>
);
};
const OUTPUT_DISPLAY_LABELS = {
pre_install_query_output: "Pre-install condition",
output: "Software install output",
post_install_script_output: "Post-install script output",
} as const;
const Output = ({
displayKey,
result,
}: {
displayKey: keyof typeof OUTPUT_DISPLAY_LABELS;
displayKey: keyof typeof SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS;
result: ISoftwareInstallResult;
}) => {
return (
<div className={`${baseClass}__script-output`}>
{OUTPUT_DISPLAY_LABELS[displayKey]}:
{SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS[displayKey]}:
<Textarea className={`${baseClass}__output-textarea`}>
{result[displayKey]}
</Textarea>

View file

@ -7,6 +7,10 @@
align-items: center;
gap: $pad-small;
margin: 0;
.icon {
padding-top: 3px;
align-self: flex-start;
}
}
&__script-output {
padding-top: $pad-xlarge;

View file

@ -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;

View file

@ -20,6 +20,7 @@ type ISupportedGraphicNames = Extract<
| "file-pkg"
| "file-p7m"
| "file-pem"
| "file-vpp"
>;
export const FileDetails = ({

View file

@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
align-items: center;
border-radius: $border-radius;
border-radius: $border-radius-medium;
background-color: $ui-fleet-blue-10;
border: 1px solid $ui-fleet-black-10;
padding: $pad-xlarge $pad-large;
@ -43,6 +43,7 @@
}
&__message {
margin: 0;
color: $ui-fleet-black-75;
}
&__additional-info {

View file

@ -61,6 +61,7 @@ interface ISoftwareNameCellProps {
router?: InjectedRouter;
hasPackage?: boolean;
isSelfService?: boolean;
iconUrl?: string;
}
const SoftwareNameCell = ({
@ -70,6 +71,7 @@ const SoftwareNameCell = ({
router,
hasPackage = false,
isSelfService = false,
iconUrl,
}: ISoftwareNameCellProps) => {
// NO path or router means it's not clickable. return
// a non-clickable cell early
@ -95,7 +97,7 @@ const SoftwareNameCell = ({
customOnClick={onClickSoftware}
value={
<>
<SoftwareIcon name={name} source={source} />
<SoftwareIcon name={name} source={source} url={iconUrl} />
<span className="software-name">{name}</span>
{hasPackage && (
<InstallIconWithTooltip isSelfService={isSelfService} />

View file

@ -1,4 +1,4 @@
import React from "react";
import React, { ReactNode } from "react";
import classnames from "classnames";
import TooltipWrapper from "components/TooltipWrapper";
@ -6,7 +6,7 @@ import TooltipWrapper from "components/TooltipWrapper";
const baseClass = "radio";
export interface IRadioProps {
label: string;
label: ReactNode;
value: string;
id: string;
onChange: (value: string) => void;

View 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;

View file

@ -12,6 +12,7 @@ import FilePdf from "./FilePdf";
import FilePkg from "./FilePkg";
import FileP7m from "./FileP7m";
import FilePem from "./FilePem";
import FileVpp from "./FileVpp";
import EmptyHosts from "./EmptyHosts";
import EmptyTeams from "./EmptyTeams";
import EmptyPacks from "./EmptyPacks";
@ -39,6 +40,7 @@ export const GRAPHIC_MAP = {
"file-pkg": FilePkg,
"file-p7m": FileP7m,
"file-pem": FilePem,
"file-vpp": FileVpp,
// Other graphics
"collecting-results": CollectingResults,
};

View file

@ -77,6 +77,11 @@ export enum ActivityType {
AddedSoftware = "added_software",
DeletedSoftware = "deleted_software",
InstalledSoftware = "installed_software",
EnabledVpp = "enabled_vpp",
DisabledVpp = "disabled_vpp",
AddedAppStoreApp = "added_app_store_app",
DeletedAppStoreApp = "deleted_app_store_app",
InstalledAppStoreApp = "installed_app_store_app",
}
// This is a subset of ActivityType that are shown only for the host past activities
@ -84,12 +89,14 @@ export type IHostPastActivityType =
| ActivityType.RanScript
| ActivityType.LockedHost
| ActivityType.UnlockedHost
| ActivityType.InstalledSoftware;
| ActivityType.InstalledSoftware
| ActivityType.InstalledAppStoreApp;
// This is a subset of ActivityType that are shown only for the host upcoming activities
export type IHostUpcomingActivityType =
| ActivityType.RanScript
| ActivityType.InstalledSoftware;
| ActivityType.InstalledSoftware
| ActivityType.InstalledAppStoreApp;
export interface IActivity {
created_at: string;
@ -156,4 +163,6 @@ export interface IActivityDetails {
status?: string;
install_uuid?: string;
self_service?: boolean;
command_uuid?: string;
app_store_id?: number;
}

View file

@ -37,6 +37,9 @@ interface ICustomSetting {
export interface IMdmConfig {
enable_disk_encryption: boolean;
/** `enabled_and_configured` only tells us if Apples MDM has been enabled and
configured correctly. The naming is slightly confusing but at one point we
only supported apple mdm, so thats why it's name the way it is. */
enabled_and_configured: boolean;
apple_bm_default_team?: string;
apple_bm_terms_expired: boolean;

View file

@ -160,3 +160,22 @@ export enum BootstrapPackageStatus {
PENDING = "pending",
FAILED = "failed",
}
/**
* IMdmCommandResult is the shape of an mdm command result object
* returned by the Fleet API.
*/
export interface IMdmCommandResult {
host_uuid: string;
command_uuid: string;
/** Status is the status of the command. It can be one of Acknowledged, Error, or NotNow for
// Apple, or 200, 400, etc for Windows. */
status: string;
updated_at: string;
request_type: string;
hostname: string;
/** Payload is a base64-encoded string containing the MDM command request */
payload: string;
/** Result is a base64-enconded string containing the MDM command response */
result: string;
}

View file

@ -1,5 +1,8 @@
import { startCase } from "lodash";
import PropTypes from "prop-types";
import { IconNames } from "components/icons";
import vulnerabilityInterface from "./vulnerability";
export default PropTypes.shape({
@ -58,6 +61,7 @@ export interface ISoftwarePackage {
pre_install_query?: string;
post_install_script?: string;
self_service: boolean;
icon_url: string | null;
status: {
installed: number;
pending: number;
@ -65,28 +69,46 @@ export interface ISoftwarePackage {
};
}
interface ISoftwareTitle {
export const isSoftwarePackage = (
data: ISoftwarePackage | IAppStoreApp
): data is ISoftwarePackage =>
(data as ISoftwarePackage).install_script !== undefined;
export interface IAppStoreApp {
name: string;
app_store_id: number;
latest_version: string;
icon_url: string;
status: {
installed: number;
pending: number;
failed: number;
};
}
export interface ISoftwareTitle {
id: number;
name: string;
software_package: ISoftwarePackage | string | null;
versions_count: number;
source: string;
hosts_count: number;
versions: ISoftwareTitleVersion[] | null;
browser: string;
self_service?: boolean;
}
export interface ISoftwareTitleWithPackageName
extends Omit<ISoftwareTitle, "software_package" | "self-service"> {
software_package: string | null;
self_service: boolean;
}
export interface ISoftwareTitleWithPackageDetail
extends Omit<ISoftwareTitle, "software_package" | "self-service"> {
software_package: ISoftwarePackage | null;
self_service?: never;
app_store_app: IAppStoreApp | null;
browser?: string;
}
export interface ISoftwareTitleDetails {
id: number;
name: string;
software_package: ISoftwarePackage | null;
app_store_app: IAppStoreApp | null;
source: string;
hosts_count: number;
versions: ISoftwareTitleVersion[] | null;
bundle_identifier?: string;
browser?: string;
versions_count?: number;
}
export interface ISoftwareVulnerability {
@ -213,6 +235,11 @@ export interface ISoftwareLastInstall {
installed_at: string;
}
export interface IAppLastInstall {
command_uuid: string;
installed_at: string;
}
export interface ISoftwareInstallVersion {
version: string;
last_opened_at: string | null;
@ -220,24 +247,77 @@ export interface ISoftwareInstallVersion {
installed_paths: string[];
}
export interface IHostSoftwarePackage {
name: string;
self_service: boolean;
icon_url: string;
version: string;
last_install: ISoftwareLastInstall | null;
}
export interface IHostAppStoreApp {
app_store_id: string;
self_service: boolean;
icon_url: string;
version: string;
last_install: IAppLastInstall | null;
}
export interface IHostSoftware {
id: number;
name: string;
package_available_for_install?: string | null;
self_service: boolean;
software_package: IHostSoftwarePackage | null;
app_store_app: IHostAppStoreApp | null;
source: string;
bundle_identifier?: string;
status: SoftwareInstallStatus | null;
last_install: ISoftwareLastInstall | null;
installed_versions: ISoftwareInstallVersion[] | null;
}
export type IDeviceSoftware = Omit<
IHostSoftware,
"package_available_for_install"
> & {
package: {
name: string;
version: string;
};
export type IDeviceSoftware = IHostSoftware;
const INSTALL_STATUS_PREDICATES: Record<SoftwareInstallStatus, string> = {
failed: "failed to install",
installed: "installed",
pending: "told Fleet to install",
} as const;
export const getInstallStatusPredicate = (status: string | undefined) => {
if (!status) {
return INSTALL_STATUS_PREDICATES.pending;
}
return (
INSTALL_STATUS_PREDICATES[status.toLowerCase() as SoftwareInstallStatus] ||
INSTALL_STATUS_PREDICATES.pending
);
};
export const INSTALL_STATUS_ICONS: Record<SoftwareInstallStatus, IconNames> = {
pending: "pending-outline",
installed: "success-outline",
failed: "error-outline",
} as const;
type IHostSoftwarePackageWithLastInstall = IHostSoftwarePackage & {
last_install: ISoftwareLastInstall;
};
export const hasHostSoftwarePackageLastInstall = (
software: IHostSoftware
): software is IHostSoftware & {
software_package: IHostSoftwarePackageWithLastInstall;
} => {
return !!software.software_package?.last_install;
};
type IHostAppWithLastInstall = IHostAppStoreApp & {
last_install: IAppLastInstall;
};
export const hasHostSoftwareAppLastInstall = (
software: IHostSoftware
): software is IHostSoftware & {
app_store_app: IHostAppWithLastInstall;
} => {
return !!software.app_store_app?.last_install;
};

View file

@ -16,7 +16,8 @@ import Spinner from "components/Spinner";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
import { SoftwareInstallDetailsModal } from "pages/SoftwarePage/components/SoftwareInstallDetails";
import { AppInstallDetailsModal } from "components/ActivityDetails/InstallDetails/AppInstallDetails";
import { SoftwareInstallDetailsModal } from "components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails";
import ActivityItem from "./ActivityItem";
import ScriptDetailsModal from "./components/ScriptDetailsModal/ScriptDetailsModal";
@ -37,6 +38,10 @@ const ActivityFeed = ({
const [showShowQueryModal, setShowShowQueryModal] = useState(false);
const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false);
const [installedSoftwareUuid, setInstalledSoftwareUuid] = useState("");
const [
appInstallDetails,
setAppInstallDetails,
] = useState<IActivityDetails | null>(null);
const queryShown = useRef("");
const queryImpact = useRef<string | undefined>(undefined);
const scriptExecutionId = useRef("");
@ -97,10 +102,11 @@ const ActivityFeed = ({
setShowScriptDetailsModal(true);
break;
case ActivityType.InstalledSoftware:
// installUuid.current = details.install_uuid ?? "";
// console.log("installUuid.current", installUuid.current);
setInstalledSoftwareUuid(details.install_uuid ?? "");
break;
case ActivityType.InstalledAppStoreApp:
setAppInstallDetails(details);
break;
default:
break;
}
@ -197,6 +203,12 @@ const ActivityFeed = ({
onCancel={() => setInstalledSoftwareUuid("")}
/>
)}
{appInstallDetails && (
<AppInstallDetailsModal
details={appInstallDetails}
onCancel={() => setAppInstallDetails(null)}
/>
)}
</div>
);
};

View file

@ -3,6 +3,7 @@ import { find, lowerCase, noop } from "lodash";
import { formatDistanceToNowStrict } from "date-fns";
import { ActivityType, IActivity, IActivityDetails } from "interfaces/activity";
import { getInstallStatusPredicate } from "interfaces/software";
import {
addGravatarUrlToResource,
formatScriptNameForActivityItem,
@ -16,7 +17,6 @@ import Icon from "components/Icon";
import ReactTooltip from "react-tooltip";
import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
import { COLORS } from "styles/var/colors";
import { getSoftwareInstallStatusPredicate } from "pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem";
const baseClass = "activity-item";
@ -795,7 +795,7 @@ const TAGGED_TEMPLATES = {
<>
{" "}
added <b>{activity.details?.software_title}</b> (
{activity.details?.software_package}) software to{" "}
{activity.details?.software_package}) to{" "}
{activity.details?.team_name ? (
<>
{" "}
@ -812,7 +812,7 @@ const TAGGED_TEMPLATES = {
<>
{" "}
deleted <b>{activity.details?.software_title}</b> (
{activity.details?.software_package}) software from{" "}
{activity.details?.software_package}) from{" "}
{activity.details?.team_name ? (
<>
{" "}
@ -837,20 +837,22 @@ const TAGGED_TEMPLATES = {
host_display_name: hostName,
software_title: title,
status,
install_uuid,
} = details;
const showSoftwarePackage =
!!details.software_package &&
activity.type === ActivityType.InstalledSoftware;
return (
<>
{" "}
{getSoftwareInstallStatusPredicate(status)} <b>{title}</b> software on{" "}
{getInstallStatusPredicate(status)} <b>{title}</b>
{showSoftwarePackage && ` (${details.software_package})`} on{" "}
<b>{hostName}</b>.{" "}
<Button
className={`${baseClass}__show-query-link`}
variant="text-link"
onClick={() =>
onDetailsClick?.(ActivityType.InstalledSoftware, { install_uuid })
}
onClick={() => onDetailsClick?.(activity.type, details)}
>
Show details{" "}
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
@ -858,6 +860,54 @@ const TAGGED_TEMPLATES = {
</>
);
},
enabledVpp: () => {
return (
<>
{" "}
enabled <b>Volume Purchasing Program (VPP)</b>.
</>
);
},
disabledVpp: () => {
return (
<>
{" "}
disabled <b>Volume Purchasing Program (VPP)</b>.
</>
);
},
addedAppStoreApp: (activity: IActivity) => {
return (
<>
{" "}
added <b>{activity.details?.software_title}</b> to{" "}
{activity.details?.team_name ? (
<>
{" "}
the <b>{activity.details?.team_name}</b> team.
</>
) : (
"no team."
)}
</>
);
},
deletedAppStoreApp: (activity: IActivity) => {
return (
<>
{" "}
deleted <b>{activity.details?.software_title}</b> from{" "}
{activity.details?.team_name ? (
<>
{" "}
the <b>{activity.details?.team_name}</b> team.
</>
) : (
"no team."
)}
</>
);
},
};
const getDetail = (
@ -1041,6 +1091,21 @@ const getDetail = (
case ActivityType.InstalledSoftware: {
return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick);
}
case ActivityType.AddedAppStoreApp: {
return TAGGED_TEMPLATES.addedAppStoreApp(activity);
}
case ActivityType.DeletedAppStoreApp: {
return TAGGED_TEMPLATES.deletedAppStoreApp(activity);
}
case ActivityType.InstalledAppStoreApp: {
return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick);
}
case ActivityType.EnabledVpp: {
return TAGGED_TEMPLATES.enabledVpp();
}
case ActivityType.DisabledVpp: {
return TAGGED_TEMPLATES.disabledVpp();
}
default: {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);

View file

@ -4,16 +4,12 @@ import React, {
useLayoutEffect,
useState,
} from "react";
import FileSaver from "file-saver";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { SoftwareInstallStatus, ISoftwarePackage } from "interfaces/software";
import softwareAPI from "services/entities/software";
import { buildQueryStringFromParams } from "utilities/url";
@ -22,15 +18,20 @@ import { uploadedFromNow } from "utilities/date_format";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import Card from "components/Card";
import Graphic from "components/Graphic";
import TooltipWrapper from "components/TooltipWrapper";
import DataSet from "components/DataSet";
import Icon from "components/Icon";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import DeleteSoftwareModal from "../DeleteSoftwareModal";
import AdvancedOptionsModal from "../AdvancedOptionsModal";
import {
APP_STORE_APP_DROPDOWN_OPTIONS,
SOFTWARE_PACAKGE_DROPDOWN_OPTIONS,
} from "./helpers";
const baseClass = "software-package-card";
@ -142,30 +143,19 @@ const PackageStatusCount = ({
);
};
const DROPDOWN_OPTIONS = [
{
label: "Download",
value: "download",
},
{
label: "Delete",
value: "delete",
},
{
label: "Advanced options",
value: "advanced",
},
] as const;
const ActionsDropdown = ({
onDownloadClick,
onDeleteClick,
onAdvancedOptionsClick,
}: {
interface IActionsDropdownProps {
isSoftwarePackage: boolean;
onDownloadClick: () => void;
onDeleteClick: () => void;
onAdvancedOptionsClick: () => void;
}) => {
}
const ActionsDropdown = ({
isSoftwarePackage,
onDownloadClick,
onDeleteClick,
onAdvancedOptionsClick,
}: IActionsDropdownProps) => {
const onSelect = (value: string) => {
switch (value) {
case "download":
@ -189,20 +179,42 @@ const ActionsDropdown = ({
onChange={onSelect}
placeholder="Actions"
searchable={false}
options={DROPDOWN_OPTIONS}
options={
isSoftwarePackage
? SOFTWARE_PACAKGE_DROPDOWN_OPTIONS
: APP_STORE_APP_DROPDOWN_OPTIONS
}
/>
</div>
);
};
interface ISoftwarePackageCardProps {
softwarePackage: ISoftwarePackage;
name: string;
version: string;
uploadedAt: string; // TODO: optional?
status: {
installed: number;
pending: number;
failed: number;
};
isSelfService: boolean;
softwareId: number;
teamId: number;
// NOTE: we will only have this if we are working with a software package.
softwarePackage?: ISoftwarePackage;
onDelete: () => void;
}
// NOTE: This component is depeent on having either a software package
// (ISoftwarePackage) or an app store app (IAppStoreApp). If we add more types
// of packages we should consider refactoring this to be more dynamic.
const SoftwarePackageCard = ({
name,
version,
uploadedAt,
status,
isSelfService,
softwarePackage,
softwareId,
teamId,
@ -246,7 +258,7 @@ const SoftwarePackageCard = ({
`Byte size (${resp.data.size}) does not match content-length header (${contentLength})`
);
}
const filename = softwarePackage.name;
const filename = name;
const file = new File([resp.data], filename, {
type: "application/octet-stream",
});
@ -260,10 +272,33 @@ const SoftwarePackageCard = ({
}
FileSaver.saveAs(file);
} catch (e) {
console.log(e);
renderFlash("error", "Couldnt 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} &bull; </span>
<TooltipWrapper
tipContent={internationalTimeFormat(new Date(uploadedAt))}
underline={false}
>
{uploadedFromNow(uploadedAt)}
</TooltipWrapper>
</>
);
};
const showActions =
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
@ -274,45 +309,35 @@ const SoftwarePackageCard = ({
{/* TODO: main-info could be a seperate component as its reused on a couple
pages already. Come back and pull this into a component */}
<div className={`${baseClass}__main-info`}>
<Graphic name="file-pkg" />
{renderIcon()}
<div className={`${baseClass}__info`}>
<SoftwareName name={softwarePackage.name} />
<span className={`${baseClass}__details`}>
<span>Version {softwarePackage.version} &bull; </span>
<TooltipWrapper
tipContent={internationalTimeFormat(
new Date(softwarePackage.uploaded_at)
)}
underline={false}
>
{uploadedFromNow(softwarePackage.uploaded_at)}
</TooltipWrapper>
</span>
<SoftwareName name={name} />
<span className={`${baseClass}__details`}>{renderDetails()}</span>
</div>
</div>
<div className={`${baseClass}__package-statuses`}>
<PackageStatusCount
softwareId={softwareId}
status="installed"
count={softwarePackage.status.installed}
count={status.installed}
teamId={teamId}
/>
<PackageStatusCount
softwareId={softwareId}
status="pending"
count={softwarePackage.status.pending}
count={status.pending}
teamId={teamId}
/>
<PackageStatusCount
softwareId={softwareId}
status="failed"
count={softwarePackage.status.failed}
count={status.failed}
teamId={teamId}
/>
</div>
</div>
<div className={`${baseClass}__actions-wrapper`}>
{softwarePackage.self_service && (
{isSelfService && (
<div className={`${baseClass}__self-service-badge`}>
<Icon
name="install-self-service"
@ -324,6 +349,7 @@ const SoftwarePackageCard = ({
)}
{showActions && (
<ActionsDropdown
isSoftwarePackage={!!softwarePackage}
onDownloadClick={onDownloadClick}
onDeleteClick={onDeleteClick}
onAdvancedOptionsClick={onAdvancedOptionsClick}
@ -332,9 +358,9 @@ const SoftwarePackageCard = ({
</div>
{showAdvancedOptionsModal && (
<AdvancedOptionsModal
installScript={softwarePackage.install_script}
preInstallQuery={softwarePackage.pre_install_query}
postInstallScript={softwarePackage.post_install_script}
installScript={softwarePackage?.install_script ?? ""}
preInstallQuery={softwarePackage?.pre_install_query}
postInstallScript={softwarePackage?.post_install_script}
onExit={() => setShowAdvancedOptionsModal(false)}
/>
)}

View file

@ -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;

View file

@ -12,16 +12,16 @@ import useTeamIdParam from "hooks/useTeamIdParam";
import { AppContext } from "context/app";
import {
ISoftwareTitleWithPackageDetail,
formatSoftwareType,
} from "interfaces/software";
import { ISoftwareTitleDetails, formatSoftwareType } from "interfaces/software";
import { ignoreAxiosError } from "interfaces/errors";
import softwareAPI, {
ISoftwareTitleResponse,
IGetSoftwareTitleQueryKey,
} from "services/entities/software";
import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
import {
APP_CONTEXT_ALL_TEAMS_ID,
APP_CONTEXT_NO_TEAM_ID,
} from "interfaces/team";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import Spinner from "components/Spinner";
@ -33,6 +33,7 @@ import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary";
import SoftwareTitleDetailsTable from "./SoftwareTitleDetailsTable";
import DetailsNoHosts from "../components/DetailsNoHosts";
import SoftwarePackageCard from "./SoftwarePackageCard";
import { getPackageCardInfo } from "./helpers";
const baseClass = "software-title-details-page";
@ -83,7 +84,7 @@ const SoftwareTitleDetailsPage = ({
} = useQuery<
ISoftwareTitleResponse,
AxiosError,
ISoftwareTitleWithPackageDetail,
ISoftwareTitleDetails,
IGetSoftwareTitleQueryKey[]
>(
[{ scope: "softwareById", softwareId, teamId: teamIdForApi }],
@ -100,6 +101,9 @@ const SoftwareTitleDetailsPage = ({
}
);
const hasSoftwarePackage = !!softwareTitle?.software_package;
const hasAppStoreApp = !!softwareTitle?.app_store_app;
const onDeleteInstaller = useCallback(() => {
if (softwareTitle?.versions?.length) {
refetchSoftwareTitle();
@ -120,85 +124,103 @@ const SoftwareTitleDetailsPage = ({
[handleTeamChange]
);
const hasPermission = Boolean(
isOnGlobalTeam || isTeamAdmin || isTeamMaintainer || isTeamObserver
);
const hasSoftwarePackage = softwareTitle && softwareTitle.software_package;
const showPackageCard =
currentTeamId !== APP_CONTEXT_ALL_TEAMS_ID &&
hasPermission &&
hasSoftwarePackage;
const renderSoftwarePackageCard = (title: ISoftwareTitleDetails) => {
const hasPermission = Boolean(
isOnGlobalTeam || isTeamAdmin || isTeamMaintainer || isTeamObserver
);
const showPackageCard =
currentTeamId !== APP_CONTEXT_ALL_TEAMS_ID &&
hasPermission &&
(hasSoftwarePackage || hasAppStoreApp);
if (showPackageCard) {
const packageCardData = getPackageCardInfo(title);
return (
<SoftwarePackageCard
softwarePackage={packageCardData.softwarePackage}
name={packageCardData.name}
version={packageCardData.version}
uploadedAt={packageCardData.uploadedAt}
status={packageCardData.status}
isSelfService={packageCardData.isSelfService}
softwareId={softwareId}
teamId={currentTeamId ?? APP_CONTEXT_NO_TEAM_ID}
onDelete={onDeleteInstaller}
/>
);
}
return null;
};
const renderContent = () => {
if (isSoftwareTitleLoading) {
return <Spinner />;
}
if (!softwareTitle && !isSoftwareTitleError) {
return null;
if (isSoftwareTitleError) {
return (
<DetailsNoHosts
header="Software not detected"
details={`No hosts ${
teamIdForApi ? "on this team " : ""
}have this software installed.`}
/>
);
}
return (
<>
{isPremiumTier && (
<TeamsHeader
isOnGlobalTeam={isOnGlobalTeam}
currentTeamId={currentTeamId}
userTeams={userTeams}
onTeamChange={onTeamChange}
if (softwareTitle) {
return (
<>
<SoftwareDetailsSummary
title={softwareTitle.name}
type={formatSoftwareType(softwareTitle)}
versions={softwareTitle.versions?.length ?? 0}
hosts={softwareTitle.hosts_count}
queryParams={{
software_title_id: softwareId,
team_id: teamIdForApi,
}}
name={softwareTitle.name}
source={softwareTitle.source}
iconUrl={
softwareTitle.app_store_app
? softwareTitle.app_store_app.icon_url
: undefined
}
/>
)}
{isSoftwareTitleError ? (
<DetailsNoHosts
header="Software not detected"
details={`No hosts ${
teamIdForApi ? "on this team " : ""
}have this software installed.`}
/>
) : (
<>
<SoftwareDetailsSummary
title={softwareTitle.name}
type={formatSoftwareType(softwareTitle)}
versions={softwareTitle.versions?.length ?? 0}
hosts={softwareTitle.hosts_count}
queryParams={{
software_title_id: softwareId,
team_id: teamIdForApi,
}}
name={softwareTitle.name}
source={softwareTitle.source}
{renderSoftwarePackageCard(softwareTitle)}
<Card
borderRadiusSize="xxlarge"
includeShadow
className={`${baseClass}__versions-section`}
>
<h2>Versions</h2>
<SoftwareTitleDetailsTable
router={router}
data={softwareTitle.versions ?? []}
isLoading={isSoftwareTitleLoading}
teamIdForApi={teamIdForApi}
/>
{showPackageCard &&
softwareTitle.software_package &&
currentTeamId && (
<SoftwarePackageCard
softwarePackage={softwareTitle.software_package}
softwareId={softwareId}
teamId={currentTeamId}
onDelete={onDeleteInstaller}
/>
)}
<Card
borderRadiusSize="xxlarge"
includeShadow
className={`${baseClass}__versions-section`}
>
<h2>Versions</h2>
<SoftwareTitleDetailsTable
router={router}
data={softwareTitle.versions ?? []}
isLoading={isSoftwareTitleLoading}
teamIdForApi={teamIdForApi}
/>
</Card>
</>
)}
</>
);
</Card>
</>
);
}
return null;
};
return (
<MainContent className={baseClass}>
{isPremiumTier && (
<TeamsHeader
isOnGlobalTeam={isOnGlobalTeam}
currentTeamId={currentTeamId}
userTeams={userTeams}
onTeamChange={onTeamChange}
/>
)}
<>{renderContent()}</>
</MainContent>
);

View file

@ -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,
};
};

View file

@ -19,10 +19,7 @@ import {
ISoftwareTitlesResponse,
ISoftwareVersionsResponse,
} from "services/entities/software";
import {
ISoftwareTitleWithPackageName,
ISoftwareVersion,
} from "interfaces/software";
import { ISoftwareTitle, ISoftwareVersion } from "interfaces/software";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
@ -163,10 +160,7 @@ const SoftwareTable = ({
[determineQueryParamChange, generateNewQueryParams, router, currentPath]
);
let tableData:
| ISoftwareTitleWithPackageName[]
| ISoftwareVersion[]
| undefined;
let tableData: ISoftwareTitle[] | ISoftwareVersion[] | undefined;
let generateTableConfig: ITableConfigGenerator;
if (data === undefined) {

View file

@ -3,7 +3,10 @@ import { CellProps, Column } from "react-table";
import { InjectedRouter } from "react-router";
import {
ISoftwareTitleWithPackageName,
IAppStoreApp,
ISoftware,
ISoftwarePackage,
ISoftwareTitle,
formatSoftwareType,
} from "interfaces/software";
import PATHS from "router/paths";
@ -22,20 +25,17 @@ import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
type ISoftwareTitlesTableConfig = Column<ISoftwareTitleWithPackageName>;
type ITableStringCellProps = IStringCellProps<ISoftwareTitleWithPackageName>;
type IVersionsCellProps = CellProps<
ISoftwareTitleWithPackageName,
ISoftwareTitleWithPackageName["versions"]
>;
type ISoftwareTitlesTableConfig = Column<ISoftwareTitle>;
type ITableStringCellProps = IStringCellProps<ISoftwareTitle>;
type IVersionsCellProps = CellProps<ISoftwareTitle, ISoftwareTitle["versions"]>;
type IVulnerabilitiesCellProps = IVersionsCellProps;
type IHostCountCellProps = CellProps<
ISoftwareTitleWithPackageName,
ISoftwareTitleWithPackageName["hosts_count"]
ISoftwareTitle,
ISoftwareTitle["hosts_count"]
>;
type IViewAllHostsLinkProps = CellProps<ISoftwareTitleWithPackageName>;
type IViewAllHostsLinkProps = CellProps<ISoftwareTitle>;
type ITableHeaderProps = IHeaderProps<ISoftwareTitleWithPackageName>;
type ITableHeaderProps = IHeaderProps<ISoftwareTitle>;
export const getVulnerabilities = <
T extends { vulnerabilities: string[] | null }
@ -57,6 +57,41 @@ export const getVulnerabilities = <
return vulnerabilities;
};
/**
* Gets the data needed to render the software name cell.
*/
const getSoftwareNameCellData = (
softwareTitle: ISoftwareTitle,
teamId?: number
) => {
const teamQueryParam = buildQueryStringFromParams({ team_id: teamId });
const softwareTitleDetailsPath = `${PATHS.SOFTWARE_TITLE_DETAILS(
softwareTitle.id.toString()
)}?${teamQueryParam}`;
const { software_package, app_store_app } = softwareTitle;
let hasPackage = false;
let isSelfService = false;
let iconUrl: string | null = null;
if (software_package) {
hasPackage = true;
isSelfService = software_package.self_service;
} else if (app_store_app) {
hasPackage = true;
isSelfService = false;
iconUrl = app_store_app.icon_url;
}
return {
name: softwareTitle.name,
source: softwareTitle.source,
path: softwareTitleDetailsPath,
hasPackage: hasPackage && !!teamId,
isSelfService,
iconUrl,
};
};
const generateTableHeaders = (
router: InjectedRouter,
teamId?: number
@ -69,29 +104,20 @@ const generateTableHeaders = (
disableSortBy: false,
accessor: "name",
Cell: (cellProps: ITableStringCellProps) => {
const {
id,
name,
source,
software_package,
self_service,
} = cellProps.row.original;
const teamQueryParam = buildQueryStringFromParams({ team_id: teamId });
const softwareTitleDetailsPath = `${PATHS.SOFTWARE_TITLE_DETAILS(
id.toString()
)}?${teamQueryParam}`;
const hasPackage = Boolean(software_package) && !!teamId; // teamId is required for package installation
const nameCellData = getSoftwareNameCellData(
cellProps.row.original,
teamId
);
return (
<SoftwareNameCell
name={name}
source={source}
path={softwareTitleDetailsPath}
name={nameCellData.name}
source={nameCellData.source}
path={nameCellData.path}
router={router}
hasPackage={hasPackage}
isSelfService={self_service === true}
hasPackage={nameCellData.hasPackage}
isSelfService={nameCellData.isSelfService}
iconUrl={nameCellData.iconUrl ?? undefined}
/>
);
},

View 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;

View file

@ -0,0 +1,3 @@
.add-package {
margin-top: $pad-large;
}

View file

@ -0,0 +1 @@
export { default } from "./AddPackage";

View file

@ -1,25 +1,15 @@
import React, { useContext, useEffect, useState } from "react";
import React from "react";
import { InjectedRouter } from "react-router";
import { AxiosResponse } from "axios";
import { Tab, TabList, TabPanel, Tabs } from "react-tabs";
import PATHS from "router/paths";
import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
import softwareAPI from "services/entities/software";
import { NotificationContext } from "context/notification";
import { QueryParams, buildQueryStringFromParams } from "utilities/url";
import { IApiError } from "interfaces/errors";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import TabsWrapper from "components/TabsWrapper";
import AddSoftwareForm from "../AddSoftwareForm";
import { IAddSoftwareFormData } from "../AddSoftwareForm/AddSoftwareForm";
import { getErrorMessage } from "./helpers";
// 8 minutes + 15 seconds to account for extra roundtrip time.
const UPLOAD_TIMEOUT = (8 * 60 + 15) * 1000;
const MAX_FILE_SIZE_MB = 500;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
import AppStoreVpp from "../AppStoreVpp";
import AddPackage from "../AddPackage";
const baseClass = "add-software-modal";
@ -54,81 +44,6 @@ const AddSoftwareModal = ({
router,
onExit,
}: IAddSoftwareModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
let timeout: NodeJS.Timeout;
const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// Next line with e.returnValue is included for legacy support
// e.g.Chrome / Edge < 119
e.returnValue = true;
};
// set up event listener to prevent user from leaving page while uploading
if (isUploading) {
addEventListener("beforeunload", beforeUnloadHandler);
timeout = setTimeout(() => {
removeEventListener("beforeunload", beforeUnloadHandler);
}, UPLOAD_TIMEOUT);
} else {
removeEventListener("beforeunload", beforeUnloadHandler);
}
// clean up event listener and timeout on component unmount
return () => {
removeEventListener("beforeunload", beforeUnloadHandler);
clearTimeout(timeout);
};
}, [isUploading]);
const onAddSoftware = async (formData: IAddSoftwareFormData) => {
setIsUploading(true);
if (formData.software && formData.software.size > MAX_FILE_SIZE_BYTES) {
renderFlash(
"error",
`Couldnt add. The maximum file size is ${MAX_FILE_SIZE_MB} MB.`
);
onExit();
setIsUploading(false);
return;
}
// TODO: confirm we are deleting the second sentence (not modifying it) for non-self-service installers
try {
await softwareAPI.addSoftwarePackage(formData, teamId, UPLOAD_TIMEOUT);
renderFlash(
"success",
<>
<b>{formData.software?.name}</b> successfully added.
{formData.selfService
? " The end user can install from Fleet Desktop."
: ""}
</>
);
onExit();
const newQueryParams: QueryParams = { team_id: teamId };
if (formData.selfService) {
newQueryParams.self_service = true;
} else {
newQueryParams.available_for_install = true;
}
router.push(
`${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(newQueryParams)}`
);
} catch (e) {
renderFlash("error", getErrorMessage(e));
onExit();
}
setIsUploading(false);
};
return (
<Modal
title="Add software"
@ -140,11 +55,20 @@ const AddSoftwareModal = ({
{teamId === APP_CONTEXT_ALL_TEAMS_ID ? (
<AllTeamsMessage onExit={onExit} />
) : (
<AddSoftwareForm
isUploading={isUploading}
onCancel={onExit}
onSubmit={onAddSoftware}
/>
<TabsWrapper className={`${baseClass}__tabs`}>
<Tabs>
<TabList>
<Tab>Package</Tab>
<Tab>App Store (VPP)</Tab>
</TabList>
<TabPanel>
<AddPackage teamId={teamId} router={router} onExit={onExit} />
</TabPanel>
<TabPanel>
<AppStoreVpp teamId={teamId} router={router} onExit={onExit} />
</TabPanel>
</Tabs>
</TabsWrapper>
)}
</>
</Modal>

View file

@ -0,0 +1,7 @@
.add-software-modal {
// have to use this selector to override the default styles
.component__tabs-wrapper {
margin-bottom: 0;
}
}

View file

@ -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) isnt 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&apos;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;

View file

@ -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;
}
}
}

View file

@ -0,0 +1,32 @@
import React from "react";
import { getErrorReason } from "interfaces/errors";
const ADD_SOFTWARE_ERROR_PREFIX = "Couldnt 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;
};

View file

@ -0,0 +1 @@
export { default } from "./AppStoreVpp";

View file

@ -24,6 +24,7 @@ interface ISoftwareDetailsSummaryProps {
name?: string;
source?: string;
versions?: number;
iconUrl?: string;
}
const SoftwareDetailsSummary = ({
@ -34,10 +35,11 @@ const SoftwareDetailsSummary = ({
name,
source,
versions,
iconUrl,
}: ISoftwareDetailsSummaryProps) => {
return (
<div className={baseClass}>
<SoftwareIcon name={name} source={source} size="large" />
<SoftwareIcon name={name} source={source} url={iconUrl} size="xlarge" />
<dl className={`${baseClass}__info`}>
<h1>{title}</h1>
<dl className={`${baseClass}__description-list`}>

View file

@ -5,7 +5,7 @@ import TooltipWrapper from "components/TooltipWrapper";
const generateText = <T extends { version: string }>(versions: T[] | null) => {
if (!versions) {
return <TextCell value="---" grey italic />;
return <TextCell value="---" grey />;
}
const text =
versions.length !== 1 ? `${versions.length} versions` : versions[0].version;

View file

@ -12,15 +12,13 @@ const baseClass = "vulnerabilities-cell";
const generateCell = (
vulnerabilities: ISoftwareVulnerability[] | string[] | null
) => {
if (vulnerabilities === null) {
return <TextCell value="---" grey italic />;
if (vulnerabilities === null || vulnerabilities.length === 0) {
return <TextCell value="---" grey />;
}
let text = "";
let italicize = true;
if (vulnerabilities.length === 0) {
text = "---";
} else if (vulnerabilities.length === 1) {
if (vulnerabilities.length === 1) {
italicize = false;
text =
typeof vulnerabilities[0] === "string"

View 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;

View file

@ -1,34 +1,57 @@
import React from "react";
import classnames from "classnames";
import getMatchedSoftwareIcon from "../";
const baseClass = "software-icon";
type SoftwareIconSizes = "small" | "medium" | "large" | "xlarge";
interface ISoftwareIconProps {
name?: string;
source?: string;
size?: SoftwareIconSizes;
/** Accepts an image url to display for a the software icon image. */
url?: string;
}
const SOFTWARE_ICON_SIZES: Record<string, string> = {
medium: "24",
meduim_large: "64", // TODO: rename this to large and update large to xlarge
large: "96",
} as const;
type SoftwareIconSizes = keyof typeof SOFTWARE_ICON_SIZES;
const SOFTWARE_ICON_SIZES: Record<SoftwareIconSizes, string> = {
small: "24",
medium: "40",
large: "64",
xlarge: "96",
};
const SoftwareIcon = ({
name = "",
source = "",
size = "medium",
size = "small",
url,
}: ISoftwareIconProps) => {
const classNames = classnames(baseClass, `${baseClass}__${size}`);
// If we are given a url to render as the icon, we need to render it
// differently than the svg icons. We will use an img tag instead with the
// src set to the url.
if (url) {
const imgClasses = classnames(
`${baseClass}__software-img`,
`${baseClass}__software-img-${size}`
);
return (
<div className={classNames}>
<img className={imgClasses} src={url} alt="" />
</div>
);
}
const MatchedIcon = getMatchedSoftwareIcon({ name, source });
return (
<MatchedIcon
width={SOFTWARE_ICON_SIZES[size]}
height={SOFTWARE_ICON_SIZES[size]}
viewBox="0 0 32 32"
className={baseClass}
className={classNames}
/>
);
};

View file

@ -1,5 +1,44 @@
.software-icon {
flex-shrink: 0;
border: 1px solid $ui-fleet-black-10;
border-radius: 8px;
&__small {
border-radius: $border-radius;
}
&__medium {
border-radius: $border-radius-xlarge;
}
&__large {
border-radius: $border-radius-xxlarge;
}
&__xlarge {
border-radius: $border-radius-xxlarge;
}
&__software-img {
display: block;
}
// we use this selector to give higher specifity than the selector
// ".core-wrapper a img" which is in /styles/global/_styles.scss
// TODO: we should change ".core-wrapper a img" selector in that file
// as it too generic and can affect other parts of the app.
> img.software-icon__software-img-small {
width: 22px;
height: 22px;
border-radius: $border-radius-small;
padding: 1px;
margin-left: 0;
}
>img.software-icon__software-img-xlarge {
width: 88px;
height: 88px;
border-radius: $border-radius-xxlarge;
padding: $pad-xsmall;
margin-left: 0;
}
}

View file

@ -20,6 +20,7 @@ import Zoom from "./Zoom";
import ChromeOS from "./ChromeOS";
import LinuxOS from "./LinuxOS";
import Falcon from "./Falcon";
import AppStore from "./AppStore";
import iOS from "./iOS";
import iPadOS from "./iPadOS";
@ -33,6 +34,7 @@ const LINUX_OS_NAME_TO_ICON_MAP = HOST_LINUX_PLATFORMS.reduce(
// icon for them, keys refer to application names, and are intended to be fuzzy
// matched in the application logic.
const SOFTWARE_NAME_TO_ICON_MAP = {
appStore: AppStore,
"adobe acrobat reader": AcrobatReader,
"microsoft excel": Excel,
falcon: Falcon,

View file

@ -2,9 +2,10 @@ import PATHS from "router/paths";
import { ISideNavItem } from "../components/SideNav/SideNav";
import Integrations from "./cards/Integrations";
import Mdm from "./cards/MdmSettings/MdmSettings";
import AutomaticEnrollment from "./cards/AutomaticEnrollment/AutomaticEnrollment";
import Calendars from "./cards/Calendars/Calendars";
import MdmSettings from "./cards/MdmSettings";
import AutomaticEnrollment from "./cards/AutomaticEnrollment";
import Calendars from "./cards/Calendars";
import Vpp from "./cards/Vpp";
const integrationSettingsNavItems: ISideNavItem<any>[] = [
// TODO: types
@ -18,7 +19,7 @@ const integrationSettingsNavItems: ISideNavItem<any>[] = [
title: "Mobile device management (MDM)",
urlSection: "mdm",
path: PATHS.ADMIN_INTEGRATIONS_MDM,
Card: Mdm,
Card: MdmSettings,
},
{
title: "Automatic enrollment",
@ -32,6 +33,12 @@ const integrationSettingsNavItems: ISideNavItem<any>[] = [
path: PATHS.ADMIN_INTEGRATIONS_CALENDARS,
Card: Calendars,
},
{
title: "Volume Purchasing Program (VPP)",
urlSection: "vpp",
path: PATHS.ADMIN_INTEGRATIONS_VPP,
Card: Vpp,
},
];
export default integrationSettingsNavItems;

View file

@ -1,11 +1,10 @@
import React, { useCallback, useContext, useState } from "react";
import React, { useContext } from "react";
import { useQuery } from "react-query";
import { AxiosError } from "axios";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { IConfig } from "interfaces/config";
import { IMdmApple } from "interfaces/mdm";
import mdmAppleAPI from "services/entities/mdm_apple";

View file

@ -0,0 +1 @@
export { default } from "./MdmSettings";

View file

@ -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();
});
});

View 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&apos;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;

View file

@ -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();
});
});

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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&apos;t appear in Fleet.
Apps won&apos;t be uninstalled from hosts. If you want to enable VPP
integration again, you&apos;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;

View file

@ -0,0 +1 @@
export { default } from "./DisableVppModal";

View file

@ -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;

View file

@ -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