diff --git a/static/directives/config/config-numeric-field.html b/config_app/js/config-field-templates/config-numeric-field.html
similarity index 100%
rename from static/directives/config/config-numeric-field.html
rename to config_app/js/config-field-templates/config-numeric-field.html
diff --git a/config_app/js/config-field-templates/config-parsed-field.html b/config_app/js/config-field-templates/config-parsed-field.html
new file mode 100644
index 000000000..766b0a8a2
--- /dev/null
+++ b/config_app/js/config-field-templates/config-parsed-field.html
@@ -0,0 +1 @@
+
diff --git a/config_app/js/config-field-templates/config-password-field.html b/config_app/js/config-field-templates/config-password-field.html
new file mode 100644
index 000000000..9f467c1f4
--- /dev/null
+++ b/config_app/js/config-field-templates/config-password-field.html
@@ -0,0 +1,9 @@
+
diff --git a/config_app/js/config-field-templates/config-service-key-field.html b/config_app/js/config-field-templates/config-service-key-field.html
new file mode 100644
index 000000000..25c66a980
--- /dev/null
+++ b/config_app/js/config-field-templates/config-service-key-field.html
@@ -0,0 +1,30 @@
+
diff --git a/static/directives/config/config-string-field.html b/config_app/js/config-field-templates/config-string-field.html
similarity index 100%
rename from static/directives/config/config-string-field.html
rename to config_app/js/config-field-templates/config-string-field.html
diff --git a/config_app/js/config-field-templates/config-string-list-field.html b/config_app/js/config-field-templates/config-string-list-field.html
new file mode 100644
index 000000000..de29dfb91
--- /dev/null
+++ b/config_app/js/config-field-templates/config-string-list-field.html
@@ -0,0 +1,6 @@
+
diff --git a/static/directives/config/config-variable-field.html b/config_app/js/config-field-templates/config-variable-field.html
similarity index 100%
rename from static/directives/config/config-variable-field.html
rename to config_app/js/config-field-templates/config-variable-field.html
diff --git a/config_app/js/core-config-setup/config-setup-tool.html b/config_app/js/core-config-setup/config-setup-tool.html
new file mode 100644
index 000000000..255ab14bd
--- /dev/null
+++ b/config_app/js/core-config-setup/config-setup-tool.html
@@ -0,0 +1,1861 @@
+
+
+
+
+
+
+
+
+
+ Save Configuration Changes
+ Configuration Saved
+
+
+
+
+ {{ configform.$error['required'].length }} configuration fields remaining
+
+
+ Invalid configuration field
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ serviceInfo.service.title }}
+
+
+ {{ serviceInfo.errorMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/js/core-config-setup.js b/config_app/js/core-config-setup/core-config-setup.js
similarity index 52%
rename from static/js/core-config-setup.js
rename to config_app/js/core-config-setup/core-config-setup.js
index 7d22d9fed..fe5fc512c 100644
--- a/static/js/core-config-setup.js
+++ b/config_app/js/core-config-setup/core-config-setup.js
@@ -1,16 +1,39 @@
-angular.module("core-config-setup", ['angularFileUpload'])
- .directive('configSetupTool', function() {
+import * as URI from 'urijs';
+import * as angular from 'angular';
+const templateUrl = require('./config-setup-tool.html');
+const urlParsedField = require('../config-field-templates/config-parsed-field.html');
+const urlVarField = require('../config-field-templates/config-variable-field.html');
+const urlListField = require('../config-field-templates/config-list-field.html');
+const urlFileField = require('../config-field-templates/config-file-field.html');
+const urlBoolField = require('../config-field-templates/config-bool-field.html');
+const urlNumericField = require('../config-field-templates/config-numeric-field.html');
+const urlContactField = require('../config-field-templates/config-contact-field.html');
+const urlContactsField = require('../config-field-templates/config-contacts-field.html');
+const urlMapField = require('../config-field-templates/config-map-field.html');
+const urlServiceKeyField = require('../config-field-templates/config-service-key-field.html');
+const urlStringField = require('../config-field-templates/config-string-field.html');
+const urlPasswordField = require('../config-field-templates/config-password-field.html');
+
+const urlStringListField = require('../config-field-templates/config-string-list-field.html');
+const urlCertField = require('../config-field-templates/config-certificates-field.html');
+
+
+angular.module("quay-config")
+ .directive('configSetupTool', () => {
var directiveDefinitionObject = {
priority: 1,
- templateUrl: '/static/directives/config/config-setup-tool.html',
+ templateUrl,
replace: true,
transclude: true,
restrict: 'C',
scope: {
'isActive': '=isActive',
- 'configurationSaved': '&configurationSaved'
+ 'configurationSaved': '&configurationSaved',
+ 'setupCompleted': '&setupCompleted',
},
controller: function($rootScope, $scope, $element, $timeout, ApiService) {
+ var authPassword = null;
+
$scope.HOSTNAME_REGEX = '^[a-zA-Z-0-9\.]+(:[0-9]+)?$';
$scope.GITHOST_REGEX = '^https?://([a-zA-Z0-9]+\.?\/?)+$';
@@ -19,24 +42,32 @@ angular.module("core-config-setup", ['angularFileUpload'])
{'id': 'registry-storage', 'title': 'Registry Storage'},
+ {'id': 'time-machine', 'title': 'Time Machine'},
+
+ {'id': 'access', 'title': 'Access Settings'},
+
{'id': 'ssl', 'title': 'SSL certificate and key', 'condition': function(config) {
return config.PREFERRED_URL_SCHEME == 'https';
}},
{'id': 'ldap', 'title': 'LDAP Authentication', 'condition': function(config) {
return config.AUTHENTICATION_TYPE == 'LDAP';
- }, 'password': true},
+ }},
{'id': 'jwt', 'title': 'JWT Authentication', 'condition': function(config) {
return config.AUTHENTICATION_TYPE == 'JWT';
- }, 'password': true},
+ }},
{'id': 'keystone', 'title': 'Keystone Authentication', 'condition': function(config) {
return config.AUTHENTICATION_TYPE == 'Keystone';
- }, 'password': true},
+ }},
- {'id': 'mail', 'title': 'E-mail Support', 'condition': function(config) {
- return config.FEATURE_MAILING;
+ {'id': 'apptoken-auth', 'title': 'App Token Authentication', 'condition': function(config) {
+ return config.AUTHENTICATION_TYPE == 'AppToken';
+ }},
+
+ {'id': 'signer', 'title': 'ACI Signing', 'condition': function(config) {
+ return config.FEATURE_ACI_CONVERSION;
}},
{'id': 'github-login', 'title': 'Github (Enterprise) Authentication', 'condition': function(config) {
@@ -57,7 +88,27 @@ angular.module("core-config-setup", ['angularFileUpload'])
{'id': 'gitlab-trigger', 'title': 'GitLab Build Triggers', 'condition': function(config) {
return config.FEATURE_GITLAB_BUILD;
- }}
+ }},
+
+ {'id': 'security-scanner', 'title': 'Quay Security Scanner', 'condition': function(config) {
+ return config.FEATURE_SECURITY_SCANNER;
+ }},
+
+ {'id': 'bittorrent', 'title': 'BitTorrent downloads', 'condition': function(config) {
+ return config.FEATURE_BITTORRENT;
+ }},
+
+ {'id': 'oidc-login', 'title': 'OIDC Login(s)', 'condition': function(config) {
+ return $scope.getOIDCProviders(config).length > 0;
+ }},
+
+ {'id': 'actionlogarchiving', 'title': 'Action Log Rotation', 'condition': function(config) {
+ return config.FEATURE_ACTION_LOG_ROTATION;
+ }},
+
+ {'id': 'repomirroring', 'title': 'Repository Mirroring', 'condition': function(config) {
+ return config.FEATURE_REPOSITORY_MIRRORING;
+ }},
];
$scope.STORAGE_CONFIG_FIELDS = {
@@ -66,10 +117,20 @@ angular.module("core-config-setup", ['angularFileUpload'])
],
'S3Storage': [
- {'name': 's3_access_key', 'title': 'AWS Access Key', 'placeholder': 'accesskeyhere', 'kind': 'text'},
- {'name': 's3_secret_key', 'title': 'AWS Secret Key', 'placeholder': 'secretkeyhere', 'kind': 'text'},
{'name': 's3_bucket', 'title': 'S3 Bucket', 'placeholder': 'my-cool-bucket', 'kind': 'text'},
- {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'}
+ {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'},
+ {'name': 's3_access_key', 'title': 'AWS Access Key (optional if using IAM)', 'placeholder': 'accesskeyhere', 'kind': 'text', 'optional': true},
+ {'name': 's3_secret_key', 'title': 'AWS Secret Key (optional if using IAM)', 'placeholder': 'secretkeyhere', 'kind': 'password', 'optional': true},
+ {'name': 'host', 'title': 'S3 Host', 'placeholder': 's3.amazonaws.com', 'kind': 'text', 'optional': true},
+ {'name': 'port', 'title': 'S3 Port', 'placeholder': '443', 'kind': 'text', 'pattern': '^[0-9]+$', 'optional': true}
+ ],
+
+ 'AzureStorage': [
+ {'name': 'azure_container', 'title': 'Azure Storage Container', 'placeholder': 'container', 'kind': 'text'},
+ {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/container', 'kind': 'text'},
+ {'name': 'azure_account_name', 'title': 'Azure Account Name', 'placeholder': 'accountnamehere', 'kind': 'text'},
+ {'name': 'azure_account_key', 'title': 'Azure Account Key', 'placeholder': 'accountkeyhere', 'kind': 'text', 'optional': true},
+ {'name': 'sas_token', 'title': 'Azure SAS Token', 'placeholder': 'sastokenhere', 'kind': 'text', 'optional': true},
],
'GoogleCloudStorage': [
@@ -79,8 +140,19 @@ angular.module("core-config-setup", ['angularFileUpload'])
{'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'}
],
+ 'RHOCSStorage': [
+ {'name': 'hostname', 'title': 'NooBaa Server Hostname', 'placeholder': 'my.noobaa.hostname', 'kind': 'text'},
+ {'name': 'port', 'title': 'Custom Port (optional)', 'placeholder': '443', 'kind': 'text', 'pattern': '^[0-9]+$', 'optional': true},
+ {'name': 'is_secure', 'title': 'Is Secure', 'placeholder': 'Require SSL', 'kind': 'bool'},
+ {'name': 'access_key', 'title': 'Access Key', 'placeholder': 'accesskeyhere', 'kind': 'text'},
+ {'name': 'secret_key', 'title': 'Secret Key', 'placeholder': 'secretkeyhere', 'kind': 'text'},
+ {'name': 'bucket_name', 'title': 'Bucket Name', 'placeholder': 'my-cool-bucket', 'kind': 'text'},
+ {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'}
+ ],
+
'RadosGWStorage': [
{'name': 'hostname', 'title': 'Rados Server Hostname', 'placeholder': 'my.rados.hostname', 'kind': 'text'},
+ {'name': 'port', 'title': 'Custom Port (optional)', 'placeholder': '443', 'kind': 'text', 'pattern': '^[0-9]+$', 'optional': true},
{'name': 'is_secure', 'title': 'Is Secure', 'placeholder': 'Require SSL', 'kind': 'bool'},
{'name': 'access_key', 'title': 'Access Key', 'placeholder': 'accesskeyhere', 'kind': 'text', 'help_url': 'http://ceph.com/docs/master/radosgw/admin/'},
{'name': 'secret_key', 'title': 'Secret Key', 'placeholder': 'secretkeyhere', 'kind': 'text'},
@@ -89,17 +161,45 @@ angular.module("core-config-setup", ['angularFileUpload'])
],
'SwiftStorage': [
- {'name': 'auth_url', 'title': 'Swift Auth URL', 'placeholder': '', 'kind': 'text'},
- {'name': 'swift_container', 'title': 'Swift Container Name', 'placeholder': 'mycontainer', 'kind': 'text'},
+ {'name': 'auth_version', 'title': 'Swift Auth Version', 'kind': 'option', 'values': [1, 2, 3]},
+ {'name': 'auth_url', 'title': 'Swift Auth URL', 'placeholder': 'http://swiftdomain/auth/v1.0', 'kind': 'text'},
+ {'name': 'swift_container', 'title': 'Swift Container Name', 'placeholder': 'mycontainer', 'kind': 'text',
+ 'help_text': 'The swift container for all objects. Must already exist inside Swift.'},
+
{'name': 'storage_path', 'title': 'Storage Path', 'placeholder': '/path/inside/container', 'kind': 'text'},
- {'name': 'swift_user', 'title': 'Username', 'placeholder': 'accesskeyhere', 'kind': 'text'},
- {'name': 'swift_password', 'title': 'Password/Key', 'placeholder': 'secretkeyhere', 'kind': 'text'},
+ {'name': 'swift_user', 'title': 'Username', 'placeholder': 'accesskeyhere', 'kind': 'text',
+ 'help_text': 'Note: For Swift V1, this is "username:password" (-U on the CLI).'},
+ {'name': 'swift_password', 'title': 'Key/Password', 'placeholder': 'secretkeyhere', 'kind': 'text',
+ 'help_text': 'Note: For Swift V1, this is the API token (-K on the CLI).'},
{'name': 'ca_cert_path', 'title': 'CA Cert Filename', 'placeholder': 'conf/stack/swift.cert', 'kind': 'text', 'optional': true},
+
+ {'name': 'temp_url_key', 'title': 'Temp URL Key (optional)', 'placholder': 'key-here', 'kind': 'text', 'optional': true,
+ 'help_url': 'https://coreos.com/products/enterprise-registry/docs/latest/swift-temp-url.html',
+ 'help_text': 'If enabled, will allow for faster pulls directly from Swift.'},
+
{'name': 'os_options', 'title': 'OS Options', 'kind': 'map',
- 'keys': ['tenant_id', 'auth_token', 'service_type', 'endpoint_type', 'tenant_name', 'object_storage_url', 'region_name']}
- ]
+ 'keys': ['tenant_id', 'auth_token', 'service_type', 'endpoint_type', 'tenant_name', 'object_storage_url', 'region_name',
+ 'project_id', 'project_name', 'project_domain_name', 'user_domain_name', 'user_domain_id']}
+ ],
+
+ 'CloudFrontedS3Storage': [
+ {'name': 's3_bucket', 'title': 'S3 Bucket', 'placeholder': 'my-cool-bucket', 'kind': 'text'},
+ {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'},
+ {'name': 's3_access_key', 'title': 'AWS Access Key (optional if using IAM)', 'placeholder': 'accesskeyhere', 'kind': 'text', 'optional': true},
+ {'name': 's3_secret_key', 'title': 'AWS Secret Key (optional if using IAM)', 'placeholder': 'secretkeyhere', 'kind': 'text', 'optional': true},
+ {'name': 'host', 'title': 'S3 Host', 'placeholder': 's3.amazonaws.com', 'kind': 'text', 'optional': true},
+ {'name': 'port', 'title': 'S3 Port', 'placeholder': '443', 'kind': 'text', 'pattern': '^[0-9]+$', 'optional': true},
+
+ {'name': 'cloudfront_distribution_domain', 'title': 'CloudFront Distribution Domain Name', 'placeholder': 'somesubdomain.cloudfront.net', 'pattern': '^([0-9a-zA-Z]+\\.)+[0-9a-zA-Z]+$', 'kind': 'text'},
+ {'name': 'cloudfront_key_id', 'title': 'CloudFront Key ID', 'placeholder': 'APKATHISISAKEYID', 'kind': 'text'},
+ {'name': 'cloudfront_privatekey_filename', 'title': 'CloudFront Private Key', 'filesuffix': 'cloudfront-signing-key.pem', 'kind': 'file'},
+ ],
+ };
+
+ $scope.enableFeature = function(config, feature) {
+ config[feature] = true;
};
$scope.validateHostname = function(hostname) {
@@ -115,9 +215,63 @@ angular.module("core-config-setup", ['angularFileUpload'])
'$hasChanges': false
};
+ $scope.hasfile = {};
$scope.validating = null;
$scope.savingConfiguration = false;
+ $scope.removeOIDCProvider = function(provider) {
+ delete $scope.config[provider];
+ };
+
+ $scope.addOIDCProvider = () => {
+ bootbox.prompt('Enter an ID for the OIDC provider', function(result) {
+ if (!result) {
+ return;
+ }
+
+ result = result.toUpperCase();
+
+ if (!result.match(/^[A-Z0-9]+$/)) {
+ bootbox.alert('Invalid ID for OIDC provider: must be alphanumeric');
+ return;
+ }
+
+ if (result == 'GITHUB' || result == 'GOOGLE') {
+ bootbox.alert('Invalid ID for OIDC provider: cannot be a reserved name');
+ return;
+ }
+
+ var key = result + '_LOGIN_CONFIG';
+ if ($scope.config[key]) {
+ bootbox.alert('Invalid ID for OIDC provider: already exists');
+ return;
+ }
+
+ $scope.config[key] = {};
+ });
+ };
+
+ $scope.getOIDCProviderId = function(key) {
+ var index = key.indexOf('_LOGIN_CONFIG');
+ if (index <= 0) {
+ return null;
+ }
+
+ return key.substr(0, index).toLowerCase();
+ };
+
+ $scope.getOIDCProviders = function(config) {
+ var keys = Object.keys(config || {});
+ return keys.filter(function(key) {
+ if (key == 'GITHUB_LOGIN_CONFIG' || key == 'GOOGLE_LOGIN_CONFIG') {
+ // Has custom UI and config.
+ return false;
+ }
+
+ return !!$scope.getOIDCProviderId(key);
+ });
+ };
+
$scope.getServices = function(config) {
var services = [];
if (!config) { return services; }
@@ -167,77 +321,50 @@ angular.module("core-config-setup", ['angularFileUpload'])
'password': opt_password || ''
};
+ var errorDisplay = ApiService.errorDisplay(
+ 'Could not validate configuration. Please report this error.',
+ function() {
+ authPassword = null;
+ });
+
ApiService.scValidateConfig(data, params).then(function(resp) {
serviceInfo.status = resp.status ? 'success' : 'error';
serviceInfo.errorMessage = $.trim(resp.reason || '');
- }, ApiService.errorDisplay('Could not validate configuration. Please report this error.'));
+
+ if (!resp.status) {
+ authPassword = null;
+ }
+
+ }, errorDisplay);
};
$scope.checkValidateAndSave = function() {
if ($scope.configform.$valid) {
+ saveStorageConfig();
$scope.validateAndSave();
return;
}
- $element.find("input.ng-invalid:first")[0].scrollIntoView();
- $element.find("input.ng-invalid:first").focus();
+ var query = $element.find("input.ng-invalid:first");
+
+ if (query && query.length) {
+ query[0].scrollIntoView();
+ query.focus();
+ }
};
$scope.validateAndSave = function() {
$scope.validating = $scope.getServices($scope.config);
- var requirePassword = false;
- for (var i = 0; i < $scope.validating.length; ++i) {
- var serviceInfo = $scope.validating[i];
- if (serviceInfo.service.password) {
- requirePassword = true;
- break;
- }
- }
-
- if (!requirePassword) {
- $scope.performValidateAndSave();
- return;
- }
-
- var box = bootbox.dialog({
- "message": 'Please enter your superuser password to validate your auth configuration:' +
- '
',
- "title": 'Enter Password',
- "buttons": {
- "success": {
- "label": "Validate Config",
- "className": "btn-success",
- "callback": function() {
- $scope.performValidateAndSave($('#validatePassword').val());
- }
- },
- "close": {
- "label": "Cancel",
- "className": "btn-default",
- "callback": function() {
- }
- }
- }
- });
-
- box.bind('shown.bs.modal', function(){
- box.find("input").focus();
- box.find("form").submit(function() {
- if (!$('#validatePassword').val()) { return; }
-
- box.modal('hide');
- verifyNow();
- });
- });
+ $scope.performValidateAndSave();
};
$scope.performValidateAndSave = function(opt_password) {
$scope.savingConfiguration = false;
$scope.validating = $scope.getServices($scope.config);
+ authPassword = opt_password;
+
$('#validateAndSaveModal').modal({
keyboard: false,
backdrop: 'static'
@@ -258,15 +385,119 @@ angular.module("core-config-setup", ['angularFileUpload'])
var data = {
'config': $scope.config,
- 'hostname': window.location.host
+ 'hostname': window.location.host,
+ 'password': authPassword || ''
};
+ var errorDisplay = ApiService.errorDisplay(
+ 'Could not save configuration. Please report this error.',
+ function() {
+ authPassword = null;
+ });
+
ApiService.scUpdateConfig(data).then(function(resp) {
+ authPassword = null;
+
$scope.savingConfiguration = false;
$scope.mapped.$hasChanges = false;
+
$('#validateAndSaveModal').modal('hide');
- $scope.configurationSaved({'config': $scope.config});
- }, ApiService.errorDisplay('Could not save configuration. Please report this error.'));
+
+ $scope.setupCompleted();
+ }, errorDisplay);
+ };
+
+ // Convert storage config to an array
+ var initializeStorageConfig = function($scope) {
+ var config = $scope.config.DISTRIBUTED_STORAGE_CONFIG || {};
+ var defaultLocations = $scope.config.DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS || [];
+ var preference = $scope.config.DISTRIBUTED_STORAGE_PREFERENCE || [];
+
+ $scope.serverStorageConfig = angular.copy(config);
+ $scope.storageConfig = [];
+
+ Object.keys(config).forEach(function(location) {
+ $scope.storageConfig.push({
+ location: location,
+ defaultLocation: defaultLocations.indexOf(location) >= 0,
+ data: angular.copy(config[location]),
+ error: {},
+ });
+ });
+
+ if (!$scope.storageConfig.length) {
+ $scope.addStorageConfig('default');
+ return;
+ }
+
+ // match DISTRIBUTED_STORAGE_PREFERENCE order first, remaining are
+ // ordered by unicode point value
+ $scope.storageConfig.sort(function(a, b) {
+ var indexA = preference.indexOf(a.location);
+ var indexB = preference.indexOf(b.location);
+
+ if (indexA > -1 && indexB > -1) return indexA < indexB ? -1 : 1;
+ if (indexA > -1) return -1;
+ if (indexB > -1) return 1;
+
+ return a.location < b.location ? -1 : 1;
+ });
+ };
+
+ $scope.allowChangeLocationStorageConfig = function(location) {
+ if (!$scope.serverStorageConfig[location]) { return true };
+
+ // allow user to change location ID if another exists with the same ID
+ return $scope.storageConfig.filter(function(sc) {
+ return sc.location === location;
+ }).length >= 2;
+ };
+
+ $scope.allowRemoveStorageConfig = function(location) {
+ return $scope.storageConfig.length > 1 && $scope.allowChangeLocationStorageConfig(location);
+ };
+
+ $scope.canAddStorageConfig = function() {
+ return $scope.config &&
+ $scope.config.FEATURE_STORAGE_REPLICATION &&
+ $scope.storageConfig &&
+ (!$scope.storageConfig.length || $scope.storageConfig.length < 10);
+ };
+
+ $scope.addStorageConfig = function(location) {
+ var storageType = 'LocalStorage';
+
+ // Use last storage type by default
+ if ($scope.storageConfig.length) {
+ storageType = $scope.storageConfig[$scope.storageConfig.length-1].data[0];
+ }
+
+ $scope.storageConfig.push({
+ location: location || '',
+ defaultLocation: false,
+ data: [storageType, {}],
+ error: {},
+ });
+ };
+
+ $scope.removeStorageConfig = function(sc) {
+ $scope.storageConfig.splice($scope.storageConfig.indexOf(sc), 1);
+ };
+
+ var saveStorageConfig = function() {
+ var config = {};
+ var defaultLocations = [];
+ var preference = [];
+
+ $scope.storageConfig.forEach(function(sc) {
+ config[sc.location] = sc.data;
+ if (sc.defaultLocation) defaultLocations.push(sc.location);
+ preference.push(sc.location);
+ });
+
+ $scope.config.DISTRIBUTED_STORAGE_CONFIG = config;
+ $scope.config.DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS = defaultLocations;
+ $scope.config.DISTRIBUTED_STORAGE_PREFERENCE = preference;
};
var gitlabSelector = function(key) {
@@ -336,6 +567,36 @@ angular.module("core-config-setup", ['angularFileUpload'])
$scope.mapped['redis']['host'] = getKey(config, 'BUILDLOGS_REDIS.host') || getKey(config, 'USER_EVENTS_REDIS.host');
$scope.mapped['redis']['port'] = getKey(config, 'BUILDLOGS_REDIS.port') || getKey(config, 'USER_EVENTS_REDIS.port');
$scope.mapped['redis']['password'] = getKey(config, 'BUILDLOGS_REDIS.password') || getKey(config, 'USER_EVENTS_REDIS.password');
+
+ $scope.mapped['TLS_SETTING'] = 'none';
+ if (config['PREFERRED_URL_SCHEME'] == 'https') {
+ if (config['EXTERNAL_TLS_TERMINATION'] === true) {
+ $scope.mapped['TLS_SETTING'] = 'external-tls';
+ } else {
+ $scope.mapped['TLS_SETTING'] = 'internal-tls';
+ }
+ }
+ };
+
+ var tlsSetter = function(value) {
+ if (value == null || !$scope.config) { return; }
+
+ switch (value) {
+ case 'none':
+ $scope.config['PREFERRED_URL_SCHEME'] = 'http';
+ delete $scope.config['EXTERNAL_TLS_TERMINATION'];
+ return;
+
+ case 'external-tls':
+ $scope.config['PREFERRED_URL_SCHEME'] = 'https';
+ $scope.config['EXTERNAL_TLS_TERMINATION'] = true;
+ return;
+
+ case 'internal-tls':
+ $scope.config['PREFERRED_URL_SCHEME'] = 'https';
+ delete $scope.config['EXTERNAL_TLS_TERMINATION'];
+ return;
+ }
};
var redisSetter = function(keyname) {
@@ -365,23 +626,17 @@ angular.module("core-config-setup", ['angularFileUpload'])
$scope.$watch('mapped.GITHUB_LOGIN_KIND', githubSelector('GITHUB_LOGIN_CONFIG'));
$scope.$watch('mapped.GITHUB_TRIGGER_KIND', githubSelector('GITHUB_TRIGGER_CONFIG'));
$scope.$watch('mapped.GITLAB_TRIGGER_KIND', gitlabSelector('GITLAB_TRIGGER_KIND'));
+ $scope.$watch('mapped.TLS_SETTING', tlsSetter);
$scope.$watch('mapped.redis.host', redisSetter('host'));
$scope.$watch('mapped.redis.port', redisSetter('port'));
$scope.$watch('mapped.redis.password', redisSetter('password'));
- // Add a watch to remove any fields not allowed by the current storage configuration.
- // We have to do this otherwise extra fields (which are not allowed) can end up in the
- // configuration.
- $scope.$watch('config.DISTRIBUTED_STORAGE_CONFIG.local[0]', function(value) {
- // Remove any fields not associated with the current kind.
- if (!value || !$scope.STORAGE_CONFIG_FIELDS[value]
- || !$scope.config.DISTRIBUTED_STORAGE_CONFIG
- || !$scope.config.DISTRIBUTED_STORAGE_CONFIG.local
- || !$scope.config.DISTRIBUTED_STORAGE_CONFIG.local[1]) { return; }
-
- var allowedFields = $scope.STORAGE_CONFIG_FIELDS[value];
- var configObject = $scope.config.DISTRIBUTED_STORAGE_CONFIG.local[1];
+ // Remove extra extra fields (which are not allowed) from storage config.
+ var updateFields = function(sc) {
+ var type = sc.data[0];
+ var configObject = sc.data[1];
+ var allowedFields = $scope.STORAGE_CONFIG_FIELDS[type];
// Remove any fields not allowed.
for (var fieldName in configObject) {
@@ -404,8 +659,65 @@ angular.module("core-config-setup", ['angularFileUpload'])
configObject[allowedFields[i].name] = configObject[allowedFields[i].name] || false;
}
}
+ };
+
+ // Validate and update storage config on update.
+ var refreshStorageConfig = function() {
+ if (!$scope.config || !$scope.storageConfig) return;
+
+ var locationCounts = {};
+ var errors = [];
+ var valid = true;
+
+ $scope.storageConfig.forEach(function(sc) {
+ // remove extra fields from storage config
+ updateFields(sc);
+
+ if (!locationCounts[sc.location]) locationCounts[sc.location] = 0;
+ locationCounts[sc.location]++;
+ });
+
+ // validate storage config
+ $scope.storageConfig.forEach(function(sc) {
+ var error = {};
+
+ if ($scope.config.FEATURE_STORAGE_REPLICATION && sc.data[0] === 'LocalStorage') {
+ error.engine = 'Replication to a locally mounted directory is unsupported as it is only accessible on a single machine.';
+ valid = false;
+ }
+
+ if (locationCounts[sc.location] > 1) {
+ error.location = 'Location ID must be unique.';
+ valid = false;
+ }
+
+ errors.push(error);
+ });
+
+ $scope.storageConfigError = errors;
+ $scope.configform.$setValidity('storageConfig', valid);
+ };
+
+ $scope.$watch('config.INTERNAL_OIDC_SERVICE_ID', function(service_id) {
+ if (service_id) {
+ $scope.config['FEATURE_DIRECT_LOGIN'] = false;
+ }
});
+ $scope.$watch('config.FEATURE_STORAGE_REPLICATION', function() {
+ refreshStorageConfig();
+ });
+
+ $scope.$watch('config.FEATURE_USER_CREATION', function(value) {
+ if (!value) {
+ $scope.config['FEATURE_INVITE_ONLY_USER_CREATION'] = false;
+ }
+ });
+
+ $scope.$watch('storageConfig', function() {
+ refreshStorageConfig();
+ }, true);
+
$scope.$watch('config', function(value) {
$scope.mapped['$hasChanges'] = true;
}, true);
@@ -416,6 +728,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
ApiService.scGetConfig().then(function(resp) {
$scope.config = resp['config'] || {};
initializeMappedLogic($scope.config);
+ initializeStorageConfig($scope);
$scope.mapped['$hasChanges'] = false;
}, ApiService.errorDisplay('Could not load config'));
});
@@ -428,7 +741,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
.directive('configParsedField', function ($timeout) {
var directiveDefinitionObject = {
priority: 0,
- templateUrl: '/static/directives/config/config-parsed-field.html',
+ templateUrl: urlParsedField,
replace: false,
transclude: true,
restrict: 'C',
@@ -472,7 +785,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
.directive('configVariableField', function () {
var directiveDefinitionObject = {
priority: 0,
- templateUrl: '/static/directives/config/config-variable-field.html',
+ templateUrl: urlVarField,
replace: false,
transclude: true,
restrict: 'C',
@@ -521,7 +834,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
.directive('variableSection', function () {
var directiveDefinitionObject = {
priority: 0,
- templateUrl: '/static/directives/config/config-variable-field.html',
+ templateUrl: urlVarField,
priority: 1,
require: '^configVariableField',
replace: false,
@@ -542,7 +855,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
.directive('configListField', function () {
var directiveDefinitionObject = {
priority: 0,
- templateUrl: '/static/directives/config/config-list-field.html',
+ templateUrl: urlListField,
replace: false,
transclude: false,
restrict: 'C',
@@ -550,7 +863,8 @@ angular.module("core-config-setup", ['angularFileUpload'])
'binding': '=binding',
'placeholder': '@placeholder',
'defaultValue': '@defaultValue',
- 'itemTitle': '@itemTitle'
+ 'itemTitle': '@itemTitle',
+ 'itemPattern': '@itemPattern'
},
controller: function($scope, $element) {
$scope.removeItem = function(item) {
@@ -577,6 +891,20 @@ angular.module("core-config-setup", ['angularFileUpload'])
$scope.newItemName = null;
};
+ $scope.patternMap = {};
+
+ $scope.getRegexp = function(pattern) {
+ if (!pattern) {
+ pattern = '.*';
+ }
+
+ if ($scope.patternMap[pattern]) {
+ return $scope.patternMap[pattern];
+ }
+
+ return $scope.patternMap[pattern] = new RegExp(pattern);
+ };
+
$scope.$watch('binding', function(binding) {
if (!binding && $scope.defaultValue) {
$scope.binding = eval($scope.defaultValue);
@@ -590,21 +918,27 @@ angular.module("core-config-setup", ['angularFileUpload'])
.directive('configFileField', function () {
var directiveDefinitionObject = {
priority: 0,
- templateUrl: '/static/directives/config/config-file-field.html',
+ templateUrl: urlFileField,
replace: false,
transclude: false,
restrict: 'C',
scope: {
'filename': '@filename',
'skipCheckFile': '@skipCheckFile',
- 'hasFile': '=hasFile'
+ 'hasFile': '=hasFile',
+ 'binding': '=?binding'
},
controller: function($scope, $element, Restangular, $upload) {
$scope.hasFile = false;
+ var setHasFile = function(hasFile) {
+ $scope.hasFile = hasFile;
+ $scope.binding = hasFile ? $scope.filename : null;
+ };
+
$scope.onFileSelect = function(files) {
if (files.length < 1) {
- $scope.hasFile = false;
+ setHasFile(false);
return;
}
@@ -618,17 +952,17 @@ angular.module("core-config-setup", ['angularFileUpload'])
$scope.uploadProgress = parseInt(100.0 * evt.loaded / evt.total);
if ($scope.uploadProgress == 100) {
$scope.uploadProgress = null;
- $scope.hasFile = true;
+ setHasFile(true);
}
}).success(function(data, status, headers, config) {
$scope.uploadProgress = null;
- $scope.hasFile = true;
+ setHasFile(true);
});
};
var loadStatus = function(filename) {
Restangular.one('superuser/config/file/' + filename).get().then(function(resp) {
- $scope.hasFile = resp['exists'];
+ setHasFile(false);
});
};
@@ -643,9 +977,9 @@ angular.module("core-config-setup", ['angularFileUpload'])
.directive('configBoolField', function () {
var directiveDefinitionObject = {
priority: 0,
- templateUrl: '/static/directives/config/config-bool-field.html',
+ templateUrl: urlBoolField,
replace: false,
- transclude: false,
+ transclude: true,
restrict: 'C',
scope: {
'binding': '=binding'
@@ -659,14 +993,14 @@ angular.module("core-config-setup", ['angularFileUpload'])
.directive('configNumericField', function () {
var directiveDefinitionObject = {
priority: 0,
- templateUrl: '/static/directives/config/config-numeric-field.html',
+ templateUrl: urlNumericField,
replace: false,
transclude: false,
restrict: 'C',
scope: {
'binding': '=binding',
'placeholder': '@placeholder',
- 'defaultValue': '@defaultValue'
+ 'defaultValue': '@defaultValue',
},
controller: function($scope, $element) {
$scope.bindinginternal = 0;
@@ -694,7 +1028,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
.directive('configContactsField', function () {
var directiveDefinitionObject = {
priority: 0,
- templateUrl: '/static/directives/config/config-contacts-field.html',
+ templateUrl: urlContactsField,
priority: 1,
replace: false,
transclude: false,
@@ -757,7 +1091,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
.directive('configContactField', function () {
var directiveDefinitionObject = {
priority: 0,
- templateUrl: '/static/directives/config/config-contact-field.html',
+ templateUrl: urlContactField,
priority: 1,
replace: false,
transclude: true,
@@ -842,10 +1176,10 @@ angular.module("core-config-setup", ['angularFileUpload'])
return directiveDefinitionObject;
})
- .directive('configMapField', function () {
+ .directive('configMapField', function () {
var directiveDefinitionObject = {
priority: 0,
- templateUrl: '/static/directives/config/config-map-field.html',
+ templateUrl: urlMapField,
replace: false,
transclude: false,
restrict: 'C',
@@ -878,10 +1212,92 @@ angular.module("core-config-setup", ['angularFileUpload'])
return directiveDefinitionObject;
})
+ .directive('configServiceKeyField', function (ApiService) {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlServiceKeyField,
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ 'serviceName': '@serviceName',
+ },
+ controller: function($scope, $element) {
+ $scope.foundKeys = [];
+ $scope.loading = false;
+ $scope.loadError = false;
+ $scope.hasValidKey = false;
+ $scope.hasValidKeyStr = null;
+
+ $scope.updateKeys = function() {
+ $scope.foundKeys = [];
+ $scope.loading = true;
+
+ ApiService.listServiceKeys().then(function(resp) {
+ $scope.loading = false;
+ $scope.loadError = false;
+
+ resp['keys'].forEach(function(key) {
+ if (key['service'] == $scope.serviceName) {
+ $scope.foundKeys.push(key);
+ }
+ });
+
+ $scope.hasValidKey = checkValidKey($scope.foundKeys);
+ $scope.hasValidKeyStr = $scope.hasValidKey ? 'true' : '';
+ }, function() {
+ $scope.loading = false;
+ $scope.loadError = true;
+ });
+ };
+
+ // Perform initial loading of the keys.
+ $scope.updateKeys();
+
+ $scope.isKeyExpired = function(key) {
+ if (key.expiration_date != null) {
+ var expiration_date = moment(key.expiration_date);
+ return moment().isAfter(expiration_date);
+ }
+ return false;
+ };
+
+ $scope.showRequestServiceKey = function(opt_newKey) {
+ $scope.requestKeyInfo = {
+ 'service': $scope.serviceName,
+ 'newKey': opt_newKey
+ };
+ };
+
+ $scope.handleKeyCreated = function() {
+ $scope.updateKeys();
+ };
+
+ var checkValidKey = function(keys) {
+ for (var i = 0; i < keys.length; ++i) {
+ var key = keys[i];
+ if (!key.approval) {
+ continue;
+ }
+
+ if ($scope.isKeyExpired(key)) {
+ continue;
+ }
+
+ return true;
+ }
+
+ return false;
+ };
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
.directive('configStringField', function () {
var directiveDefinitionObject = {
priority: 0,
- templateUrl: '/static/directives/config/config-string-field.html',
+ templateUrl: urlStringField,
replace: false,
transclude: false,
restrict: 'C',
@@ -894,16 +1310,26 @@ angular.module("core-config-setup", ['angularFileUpload'])
'isOptional': '=isOptional'
},
controller: function($scope, $element) {
+ var firstSet = true;
+
+ $scope.patternMap = {};
+
$scope.getRegexp = function(pattern) {
if (!pattern) {
pattern = '.*';
}
- return new RegExp(pattern);
+
+ if ($scope.patternMap[pattern]) {
+ return $scope.patternMap[pattern];
+ }
+
+ return $scope.patternMap[pattern] = new RegExp(pattern);
};
$scope.$watch('binding', function(binding) {
- if (!binding && $scope.defaultValue) {
+ if (firstSet && !binding && $scope.defaultValue) {
$scope.binding = $scope.defaultValue;
+ firstSet = false;
}
$scope.errorMessage = $scope.validator({'value': binding || ''});
@@ -911,4 +1337,118 @@ angular.module("core-config-setup", ['angularFileUpload'])
}
};
return directiveDefinitionObject;
- });
\ No newline at end of file
+ })
+
+ .directive('configPasswordField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlPasswordField,
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ 'binding': '=binding',
+ 'placeholder': '@placeholder',
+ 'defaultValue': '@defaultValue',
+ 'validator': '&validator',
+ 'isOptional': '=isOptional'
+ },
+ controller: function($scope, $element) {
+ var firstSet = true;
+
+ $scope.$watch('binding', function(binding) {
+ if (firstSet && !binding && $scope.defaultValue) {
+ $scope.binding = $scope.defaultValue;
+ firstSet = false;
+ }
+ $scope.errorMessage = $scope.validator({'value': binding || ''});
+ });
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configStringListField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlStringListField,
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ 'binding': '=binding',
+ 'itemTitle': '@itemTitle',
+ 'itemDelimiter': '@itemDelimiter',
+ 'placeholder': '@placeholder',
+ 'isOptional': '=isOptional'
+ },
+ controller: function($scope, $element) {
+ $scope.$watch('internalBinding', function(value) {
+ if (value) {
+ $scope.binding = value.split($scope.itemDelimiter);
+ }
+ });
+
+ $scope.$watch('binding', function(value) {
+ if (value) {
+ $scope.internalBinding = value.join($scope.itemDelimiter);
+ }
+ });
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configCertificatesField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlCertField,
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ },
+ controller: function($scope, $element, $upload, ApiService, UserService) {
+ $scope.resetUpload = 0;
+ $scope.certsUploading = false;
+
+ var loadCertificates = function() {
+ $scope.certificatesResource = ApiService.getCustomCertificatesAsResource().get(function(resp) {
+ $scope.certInfo = resp;
+ $scope.certsUploading = false;
+ });
+ };
+
+ loadCertificates();
+
+ $scope.handleCertsSelected = function(files, callback) {
+ $scope.certsUploading = true;
+ $upload.upload({
+ url: '/api/v1/superuser/customcerts/' + files[0].name,
+ method: 'POST',
+ data: {'_csrf_token': window.__token},
+ file: files[0]
+ }).success(function() {
+ callback(true);
+ $scope.resetUpload++;
+ loadCertificates();
+ }).error(function(r) {
+ bootbox.alert('Could not upload certificate')
+ callback(false);
+ $scope.resetUpload++;
+ loadCertificates();
+ });
+ };
+
+ $scope.deleteCert = function(path) {
+ var errorDisplay = ApiService.errorDisplay('Could not delete certificate');
+ var params = {
+ 'certpath': path
+ };
+
+ ApiService.deleteCustomCertificate(null, params).then(loadCertificates, errorDisplay);
+ };
+ }
+ };
+ return directiveDefinitionObject;
+ });
diff --git a/config_app/js/main.ts b/config_app/js/main.ts
new file mode 100644
index 000000000..cdd326001
--- /dev/null
+++ b/config_app/js/main.ts
@@ -0,0 +1,37 @@
+// imports shims, etc
+import 'core-js';
+
+import * as angular from 'angular';
+import { ConfigAppModule } from './config-app.module';
+import { bundle } from 'ng-metadata/core';
+
+// load all app dependencies
+require('../static/lib/angular-file-upload.min.js');
+require('../../static/js/tar');
+
+const ng1QuayModule: string = bundle(ConfigAppModule, []).name;
+angular.module('quay-config', [ng1QuayModule])
+ .run(() => {
+ });
+
+declare var require: any;
+function requireAll(r) {
+ r.keys().forEach(r);
+}
+
+// load all services
+requireAll(require.context('./services', true, /\.js$/));
+
+
+// load all the components after services
+requireAll(require.context('./setup', true, /\.js$/));
+requireAll(require.context('./core-config-setup', true, /\.js$/));
+requireAll(require.context('./components', true, /\.js$/));
+
+// load config-app specific css
+requireAll(require.context('../static/css', true, /\.css$/));
+
+
+// Load all the main quay css
+requireAll(require.context('../../static/css', true, /\.css$/));
+requireAll(require.context('../../static/lib', true, /\.css$/));
diff --git a/config_app/js/services/angular-poll-channel.js b/config_app/js/services/angular-poll-channel.js
new file mode 100644
index 000000000..697a04e15
--- /dev/null
+++ b/config_app/js/services/angular-poll-channel.js
@@ -0,0 +1,107 @@
+/**
+ * Specialized class for conducting an HTTP poll, while properly preventing multiple calls.
+ */
+angular.module('quay-config').factory('AngularPollChannel',
+ ['ApiService', '$timeout', 'DocumentVisibilityService', 'CORE_EVENT', '$rootScope',
+ function(ApiService, $timeout, DocumentVisibilityService, CORE_EVENT, $rootScope) {
+ var _PollChannel = function(scope, requester, opt_sleeptime) {
+ this.scope_ = scope;
+ this.requester_ = requester;
+ this.sleeptime_ = opt_sleeptime || (60 * 1000 /* 60s */);
+ this.timer_ = null;
+
+ this.working = false;
+ this.polling = false;
+ this.skipping = false;
+
+ var that = this;
+
+ var visibilityHandler = $rootScope.$on(CORE_EVENT.DOC_VISIBILITY_CHANGE, function() {
+ // If the poll channel was skipping because the visibility was hidden, call it immediately.
+ if (that.skipping && !DocumentVisibilityService.isHidden()) {
+ that.call_();
+ }
+ });
+
+ scope.$on('$destroy', function() {
+ that.stop();
+ visibilityHandler();
+ });
+ };
+
+ _PollChannel.prototype.setSleepTime = function(sleepTime) {
+ this.sleeptime_ = sleepTime;
+ this.stop();
+ this.start(true);
+ };
+
+ _PollChannel.prototype.stop = function() {
+ if (this.timer_) {
+ $timeout.cancel(this.timer_);
+ this.timer_ = null;
+ this.polling = false;
+ }
+
+ this.skipping = false;
+ this.working = false;
+ };
+
+ _PollChannel.prototype.start = function(opt_skipFirstCall) {
+ // Make sure we invoke call outside the normal digest cycle, since
+ // we'll call $scope.$apply ourselves.
+ var that = this;
+ setTimeout(function() {
+ if (opt_skipFirstCall) {
+ that.setupTimer_();
+ return;
+ }
+
+ that.call_();
+ }, 0);
+ };
+
+ _PollChannel.prototype.call_ = function() {
+ if (this.working) { return; }
+
+ // If the document is currently hidden, skip the call.
+ if (DocumentVisibilityService.isHidden()) {
+ this.skipping = true;
+ this.setupTimer_();
+ return;
+ }
+
+ var that = this;
+ this.working = true;
+
+ $timeout(function() {
+ that.requester_(function(status) {
+ if (status) {
+ that.working = false;
+ that.skipping = false;
+ that.setupTimer_();
+ } else {
+ that.stop();
+ }
+ });
+ }, 0);
+ };
+
+ _PollChannel.prototype.setupTimer_ = function() {
+ if (this.timer_) { return; }
+
+ var that = this;
+ this.polling = true;
+ this.timer_ = $timeout(function() {
+ that.timer_ = null;
+ that.call_();
+ }, this.sleeptime_)
+ };
+
+ var service = {
+ 'create': function(scope, requester, opt_sleeptime) {
+ return new _PollChannel(scope, requester, opt_sleeptime);
+ }
+ };
+
+ return service;
+}]);
diff --git a/config_app/js/services/api-service.js b/config_app/js/services/api-service.js
new file mode 100644
index 000000000..814e25a45
--- /dev/null
+++ b/config_app/js/services/api-service.js
@@ -0,0 +1,335 @@
+/**
+ * Service which exposes the server-defined API as a nice set of helper methods and automatic
+ * callbacks. Any method defined on the server is exposed here as an equivalent method. Also
+ * defines some helper functions for working with API responses.
+ */
+angular.module('quay-config').factory('ApiService', ['Restangular', '$q', 'UtilService', function(Restangular, $q, UtilService) {
+ var apiService = {};
+
+ if (!window.__endpoints) {
+ return apiService;
+ }
+
+ var getResource = function(getMethod, operation, opt_parameters, opt_background) {
+ var resource = {};
+ resource.withOptions = function(options) {
+ this.options = options;
+ return this;
+ };
+
+ resource.get = function(processor, opt_errorHandler) {
+ var options = this.options;
+ var result = {
+ 'loading': true,
+ 'value': null,
+ 'hasError': false
+ };
+
+ getMethod(options, opt_parameters, opt_background, true).then(function(resp) {
+ result.value = processor(resp);
+ result.loading = false;
+ }, function(resp) {
+ result.hasError = true;
+ result.loading = false;
+ if (opt_errorHandler) {
+ opt_errorHandler(resp);
+ }
+ });
+
+ return result;
+ };
+
+ return resource;
+ };
+
+ var buildUrl = function(path, parameters) {
+ // We already have /api/v1/ on the URLs, so remove them from the paths.
+ path = path.substr('/api/v1/'.length, path.length);
+
+ // Build the path, adjusted with the inline parameters.
+ var used = {};
+ var url = '';
+ for (var i = 0; i < path.length; ++i) {
+ var c = path[i];
+ if (c == '{') {
+ var end = path.indexOf('}', i);
+ var varName = path.substr(i + 1, end - i - 1);
+
+ if (!parameters[varName]) {
+ throw new Error('Missing parameter: ' + varName);
+ }
+
+ used[varName] = true;
+ url += parameters[varName];
+ i = end;
+ continue;
+ }
+
+ url += c;
+ }
+
+ // Append any query parameters.
+ var isFirst = true;
+ for (var paramName in parameters) {
+ if (!parameters.hasOwnProperty(paramName)) { continue; }
+ if (used[paramName]) { continue; }
+
+ var value = parameters[paramName];
+ if (value) {
+ url += isFirst ? '?' : '&';
+ url += paramName + '=' + encodeURIComponent(value)
+ isFirst = false;
+ }
+ }
+
+ return url;
+ };
+
+ var getGenericOperationName = function(userOperationName) {
+ return userOperationName.replace('User', '');
+ };
+
+ var getMatchingUserOperationName = function(orgOperationName, method, userRelatedResource) {
+ if (userRelatedResource) {
+ if (userRelatedResource[method.toLowerCase()]) {
+ return userRelatedResource[method.toLowerCase()]['operationId'];
+ }
+ }
+
+ throw new Error('Could not find user operation matching org operation: ' + orgOperationName);
+ };
+
+ var freshLoginInProgress = [];
+ var reject = function(msg) {
+ for (var i = 0; i < freshLoginInProgress.length; ++i) {
+ freshLoginInProgress[i].deferred.reject({'data': {'message': msg}});
+ }
+ freshLoginInProgress = [];
+ };
+
+ var retry = function() {
+ for (var i = 0; i < freshLoginInProgress.length; ++i) {
+ freshLoginInProgress[i].retry();
+ }
+ freshLoginInProgress = [];
+ };
+
+ var freshLoginFailCheck = function(opName, opArgs) {
+ return function(resp) {
+ var deferred = $q.defer();
+
+ // If the error is a fresh login required, show the dialog.
+ // TODO: remove error_type (old style error)
+ var fresh_login_required = resp.data['title'] == 'fresh_login_required' || resp.data['error_type'] == 'fresh_login_required';
+ if (resp.status == 401 && fresh_login_required) {
+ var retryOperation = function() {
+ apiService[opName].apply(apiService, opArgs).then(function(resp) {
+ deferred.resolve(resp);
+ }, function(resp) {
+ deferred.reject(resp);
+ });
+ };
+
+ var verifyNow = function() {
+ if (!$('#freshPassword').val()) {
+ return;
+ }
+
+ var info = {
+ 'password': $('#freshPassword').val()
+ };
+
+ $('#freshPassword').val('');
+
+ // Conduct the sign in of the user.
+ apiService.verifyUser(info).then(function() {
+ // On success, retry the operations. if it succeeds, then resolve the
+ // deferred promise with the result. Otherwise, reject the same.
+ retry();
+ }, function(resp) {
+ // Reject with the sign in error.
+ reject('Invalid verification credentials');
+ });
+ };
+
+ // Add the retry call to the in progress list. If there is more than a single
+ // in progress call, we skip showing the dialog (since it has already been
+ // shown).
+ freshLoginInProgress.push({
+ 'deferred': deferred,
+ 'retry': retryOperation
+ })
+
+ if (freshLoginInProgress.length > 1) {
+ return deferred.promise;
+ }
+
+ var box = bootbox.dialog({
+ "message": 'It has been more than a few minutes since you last logged in, ' +
+ 'so please verify your password to perform this sensitive operation:' +
+ '
',
+ "title": 'Please Verify',
+ "buttons": {
+ "verify": {
+ "label": "Verify",
+ "className": "btn-success btn-continue",
+ "callback": verifyNow
+ },
+ "close": {
+ "label": "Cancel",
+ "className": "btn-default",
+ "callback": function() {
+ reject('Verification canceled')
+ }
+ }
+ }
+ });
+
+ box.bind('shown.bs.modal', function(){
+ box.find("input").focus();
+ box.find("form").submit(function() {
+ if (!$('#freshPassword').val()) { return; }
+
+ box.modal('hide');
+ verifyNow();
+ });
+ });
+
+ // Return a new promise. We'll accept or reject it based on the result
+ // of the login.
+ return deferred.promise;
+ }
+
+ // Otherwise, we just 'raise' the error via the reject method on the promise.
+ return $q.reject(resp);
+ };
+ };
+
+ var buildMethodsForOperation = function(operation, method, path, resourceMap) {
+ var operationName = operation['operationId'];
+ var urlPath = path['x-path'];
+
+ // Add the operation itself.
+ apiService[operationName] = function(opt_options, opt_parameters, opt_background, opt_forceget, opt_responseType) {
+ var one = Restangular.one(buildUrl(urlPath, opt_parameters));
+
+ if (opt_background || opt_responseType) {
+ let httpConfig = {};
+
+ if (opt_background) {
+ httpConfig['ignoreLoadingBar'] = true;
+ }
+ if (opt_responseType) {
+ httpConfig['responseType'] = opt_responseType;
+ }
+
+ one.withHttpConfig(httpConfig);
+ }
+
+ var opObj = one[opt_forceget ? 'get' : 'custom' + method.toUpperCase()](opt_options);
+
+ // If the operation requires_fresh_login, then add a specialized error handler that
+ // will defer the operation's result if sudo is requested.
+ if (operation['x-requires-fresh-login']) {
+ opObj = opObj.catch(freshLoginFailCheck(operationName, arguments));
+ }
+ return opObj;
+ };
+
+ // If the method for the operation is a GET, add an operationAsResource method.
+ if (method == 'get') {
+ apiService[operationName + 'AsResource'] = function(opt_parameters, opt_background) {
+ var getMethod = apiService[operationName];
+ return getResource(getMethod, operation, opt_parameters, opt_background);
+ };
+ }
+
+ // If the operation has a user-related operation, then make a generic operation for this operation
+ // that can call both the user and the organization versions of the operation, depending on the
+ // parameters given.
+ if (path['x-user-related']) {
+ var userOperationName = getMatchingUserOperationName(operationName, method, resourceMap[path['x-user-related']]);
+ var genericOperationName = getGenericOperationName(userOperationName);
+ apiService[genericOperationName] = function(orgname, opt_options, opt_parameters, opt_background) {
+ if (orgname) {
+ if (orgname.name) {
+ orgname = orgname.name;
+ }
+
+ var params = jQuery.extend({'orgname' : orgname}, opt_parameters || {}, opt_background);
+ return apiService[operationName](opt_options, params);
+ } else {
+ return apiService[userOperationName](opt_options, opt_parameters, opt_background);
+ }
+ };
+ }
+ };
+
+
+ var allowedMethods = ['get', 'post', 'put', 'delete'];
+ var resourceMap = {};
+ var forEachOperation = function(callback) {
+ for (var path in window.__endpoints) {
+ if (!window.__endpoints.hasOwnProperty(path)) {
+ continue;
+ }
+
+ for (var method in window.__endpoints[path]) {
+ if (!window.__endpoints[path].hasOwnProperty(method)) {
+ continue;
+ }
+
+ if (allowedMethods.indexOf(method.toLowerCase()) < 0) { continue; }
+ callback(window.__endpoints[path][method], method, window.__endpoints[path]);
+ }
+ }
+ };
+
+ // Build the map of resource names to their objects.
+ forEachOperation(function(operation, method, path) {
+ resourceMap[path['x-name']] = path;
+ });
+
+ // Construct the methods for each API endpoint.
+ forEachOperation(function(operation, method, path) {
+ buildMethodsForOperation(operation, method, path, resourceMap);
+ });
+
+ apiService.getErrorMessage = function(resp, defaultMessage) {
+ var message = defaultMessage;
+ if (resp && resp['data']) {
+ //TODO: remove error_message and error_description (old style error)
+ message = resp['data']['detail'] || resp['data']['error_message'] || resp['data']['message'] || resp['data']['error_description'] || message;
+ }
+
+ return message;
+ };
+
+ apiService.errorDisplay = function(defaultMessage, opt_handler) {
+ return function(resp) {
+ var message = apiService.getErrorMessage(resp, defaultMessage);
+ if (opt_handler) {
+ var handlerMessage = opt_handler(resp);
+ if (handlerMessage) {
+ message = handlerMessage;
+ }
+ }
+
+ message = UtilService.stringToHTML(message);
+ bootbox.dialog({
+ "message": message,
+ "title": defaultMessage || 'Request Failure',
+ "buttons": {
+ "close": {
+ "label": "Close",
+ "className": "btn-primary"
+ }
+ }
+ });
+ };
+ };
+
+ return apiService;
+}]);
diff --git a/config_app/js/services/container-service.js b/config_app/js/services/container-service.js
new file mode 100644
index 000000000..31b495176
--- /dev/null
+++ b/config_app/js/services/container-service.js
@@ -0,0 +1,43 @@
+/**
+ * Helper service for working with the registry's container. Only works in enterprise.
+ */
+angular.module('quay-config')
+ .factory('ContainerService', ['ApiService', '$timeout', 'Restangular',
+ function(ApiService, $timeout, Restangular) {
+ var containerService = {};
+ containerService.restartContainer = function(callback) {
+ ApiService.errorDisplay('Removed Endpoint. This error should never be seen.')
+ };
+
+ containerService.scheduleStatusCheck = function(callback, opt_config) {
+ $timeout(function() {
+ containerService.checkStatus(callback, opt_config);
+ }, 2000);
+ };
+
+ containerService.checkStatus = function(callback, opt_config) {
+ var errorHandler = function(resp) {
+ if (resp.status == 404 || resp.status == 502 || resp.status == -1) {
+ // Container has not yet come back up, so we schedule another check.
+ containerService.scheduleStatusCheck(callback, opt_config);
+ return;
+ }
+
+ return ApiService.errorDisplay('Cannot load status. Please report this to support')(resp);
+ };
+
+ // If config is specified, override the API base URL from this point onward.
+ // TODO: Find a better way than this. This is safe, since this will only be called
+ // for a restart, but it is still ugly.
+ if (opt_config && opt_config['SERVER_HOSTNAME']) {
+ var scheme = opt_config['PREFERRED_URL_SCHEME'] || 'http';
+ var baseUrl = scheme + '://' + opt_config['SERVER_HOSTNAME'] + '/api/v1/';
+ Restangular.setBaseUrl(baseUrl);
+ }
+
+ ApiService.scRegistryStatus(null, null, /* background */true)
+ .then(callback, errorHandler);
+ };
+
+ return containerService;
+ }]);
diff --git a/config_app/js/services/cookie-service.js b/config_app/js/services/cookie-service.js
new file mode 100644
index 000000000..af904124a
--- /dev/null
+++ b/config_app/js/services/cookie-service.js
@@ -0,0 +1,23 @@
+/**
+ * Helper service for working with cookies.
+ */
+angular.module('quay-config').factory('CookieService', ['$cookies', function($cookies) {
+ var cookieService = {};
+ cookieService.putPermanent = function(name, value) {
+ document.cookie = escape(name) + "=" + escape(value) + "; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/";
+ };
+
+ cookieService.putSession = function(name, value) {
+ $cookies.put(name, value);
+ };
+
+ cookieService.clear = function(name) {
+ $cookies.remove(name);
+ };
+
+ cookieService.get = function(name) {
+ return $cookies.get(name);
+ };
+
+ return cookieService;
+}]);
diff --git a/config_app/js/services/document-visibility-service.js b/config_app/js/services/document-visibility-service.js
new file mode 100644
index 000000000..59d935d8b
--- /dev/null
+++ b/config_app/js/services/document-visibility-service.js
@@ -0,0 +1,60 @@
+/**
+ * Helper service which fires off events when the document's visibility changes, as well as allowing
+ * other Angular code to query the state of the document's visibility directly.
+ */
+angular.module('quay-config').constant('CORE_EVENT', {
+ DOC_VISIBILITY_CHANGE: 'core.event.doc_visibility_change'
+});
+
+angular.module('quay-config').factory('DocumentVisibilityService', ['$rootScope', '$document', 'CORE_EVENT',
+ function($rootScope, $document, CORE_EVENT) {
+ var document = $document[0],
+ features,
+ detectedFeature;
+
+ function broadcastChangeEvent() {
+ $rootScope.$broadcast(CORE_EVENT.DOC_VISIBILITY_CHANGE,
+ document[detectedFeature.propertyName]);
+ }
+
+ features = {
+ standard: {
+ eventName: 'visibilitychange',
+ propertyName: 'hidden'
+ },
+ moz: {
+ eventName: 'mozvisibilitychange',
+ propertyName: 'mozHidden'
+ },
+ ms: {
+ eventName: 'msvisibilitychange',
+ propertyName: 'msHidden'
+ },
+ webkit: {
+ eventName: 'webkitvisibilitychange',
+ propertyName: 'webkitHidden'
+ }
+ };
+
+ Object.keys(features).some(function(feature) {
+ if (document[features[feature].propertyName] !== undefined) {
+ detectedFeature = features[feature];
+ return true;
+ }
+ });
+
+ if (detectedFeature) {
+ $document.on(detectedFeature.eventName, broadcastChangeEvent);
+ }
+
+ return {
+ /**
+ * Is the window currently hidden or not.
+ */
+ isHidden: function() {
+ if (detectedFeature) {
+ return document[detectedFeature.propertyName];
+ }
+ }
+ };
+}]);
\ No newline at end of file
diff --git a/config_app/js/services/features-config.js b/config_app/js/services/features-config.js
new file mode 100644
index 000000000..e655f32bf
--- /dev/null
+++ b/config_app/js/services/features-config.js
@@ -0,0 +1,91 @@
+/**
+ * Feature flags.
+ */
+angular.module('quay-config').factory('Features', [function() {
+ if (!window.__features) {
+ return {};
+ }
+
+ var features = window.__features;
+ features.getFeature = function(name, opt_defaultValue) {
+ var value = features[name];
+ if (value == null) {
+ return opt_defaultValue;
+ }
+ return value;
+ };
+
+ features.hasFeature = function(name) {
+ return !!features.getFeature(name);
+ };
+
+ features.matchesFeatures = function(list) {
+ for (var i = 0; i < list.length; ++i) {
+ var value = features.getFeature(list[i]);
+ if (!value) {
+ return false;
+ }
+ }
+ return true;
+ };
+
+ return features;
+}]);
+
+/**
+ * Application configuration.
+ */
+angular.module('quay-config').factory('Config', ['Features', function(Features) {
+ if (!window.__config) {
+ return {};
+ }
+
+ var config = window.__config;
+ config.getDomain = function() {
+ return config['SERVER_HOSTNAME'];
+ };
+
+ config.getHost = function(opt_auth) {
+ var auth = opt_auth;
+ if (auth) {
+ auth = auth + '@';
+ }
+
+ return config['PREFERRED_URL_SCHEME'] + '://' + auth + config['SERVER_HOSTNAME'];
+ };
+
+ config.getHttp = function() {
+ return config['PREFERRED_URL_SCHEME'];
+ };
+
+ config.getUrl = function(opt_path) {
+ var path = opt_path || '';
+ return config['PREFERRED_URL_SCHEME'] + '://' + config['SERVER_HOSTNAME'] + path;
+ };
+
+ config.getValue = function(name, opt_defaultValue) {
+ var value = config[name];
+ if (value == null) {
+ return opt_defaultValue;
+ }
+ return value;
+ };
+
+ config.getEnterpriseLogo = function(opt_defaultValue) {
+ if (!config.ENTERPRISE_LOGO_URL) {
+ if (opt_defaultValue) {
+ return opt_defaultValue;
+ }
+
+ if (Features.BILLING) {
+ return '/static/img/quay-horizontal-color.svg';
+ } else {
+ return '/static/img/QuayEnterprise_horizontal_color.svg';
+ }
+ }
+
+ return config.ENTERPRISE_LOGO_URL;
+ };
+
+ return config;
+}]);
\ No newline at end of file
diff --git a/config_app/js/services/services.types.ts b/config_app/js/services/services.types.ts
new file mode 100644
index 000000000..217824f6b
--- /dev/null
+++ b/config_app/js/services/services.types.ts
@@ -0,0 +1,15 @@
+export interface AngularPollChannel {
+ create: PollConstructor
+}
+
+type PollConstructor = (scope: MockAngularScope, requester: ShouldContinueCallback, opt_sleeptime?: number) => PollHandle;
+type MockAngularScope = {
+ '$on': Function
+};
+type ShouldContinueCallback = (boolean) => void;
+
+export interface PollHandle {
+ start(opt_skipFirstCall?: boolean): void,
+ stop(): void,
+ setSleepTime(sleepTime: number): void,
+}
diff --git a/config_app/js/services/user-service.js b/config_app/js/services/user-service.js
new file mode 100644
index 000000000..8c222b955
--- /dev/null
+++ b/config_app/js/services/user-service.js
@@ -0,0 +1,177 @@
+import * as Raven from 'raven-js';
+
+
+/**
+ * Service which monitors the current user session and provides methods for returning information
+ * about the user.
+ */
+angular.module('quay-config')
+ .factory('UserService', ['ApiService', 'CookieService', '$rootScope', 'Config', '$location', '$timeout',
+
+function(ApiService, CookieService, $rootScope, Config, $location, $timeout) {
+ var userResponse = {
+ verified: false,
+ anonymous: true,
+ username: null,
+ email: null,
+ organizations: [],
+ logins: [],
+ beforeload: true
+ };
+
+ var userService = {};
+
+ userService.hasEverLoggedIn = function() {
+ return CookieService.get('quay.loggedin') == 'true';
+ };
+
+ userService.updateUserIn = function(scope, opt_callback) {
+ scope.$watch(function () { return userService.currentUser(); }, function (currentUser) {
+ if (currentUser) {
+ $timeout(function(){
+ scope.user = currentUser;
+ if (opt_callback) {
+ opt_callback(currentUser);
+ }
+ }, 0, false);
+ };
+ }, true);
+ };
+
+ userService.load = function(opt_callback) {
+ var handleUserResponse = function(loadedUser) {
+ userResponse = loadedUser;
+
+ if (!userResponse.anonymous) {
+ if (Config.MIXPANEL_KEY) {
+ try {
+ mixpanel.identify(userResponse.username);
+ mixpanel.people.set({
+ '$email': userResponse.email,
+ '$username': userResponse.username,
+ 'verified': userResponse.verified
+ });
+ mixpanel.people.set_once({
+ '$created': new Date()
+ })
+ } catch (e) {
+ window.console.log(e);
+ }
+ }
+
+ if (Config.MARKETO_MUNCHKIN_ID && userResponse['marketo_user_hash']) {
+ var associateLeadBody = {'Email': userResponse.email};
+ if (window.Munchkin !== undefined) {
+ try {
+ Munchkin.munchkinFunction(
+ 'associateLead',
+ associateLeadBody,
+ userResponse['marketo_user_hash']
+ );
+ } catch (e) {
+ }
+ } else {
+ window.__quay_munchkin_queue.push([
+ 'associateLead',
+ associateLeadBody,
+ userResponse['marketo_user_hash']
+ ]);
+ }
+ }
+
+ if (window.Raven !== undefined) {
+ try {
+ Raven.setUser({
+ email: userResponse.email,
+ id: userResponse.username
+ });
+ } catch (e) {
+ window.console.log(e);
+ }
+ }
+
+ CookieService.putPermanent('quay.loggedin', 'true');
+ } else {
+ if (window.Raven !== undefined) {
+ Raven.setUser();
+ }
+ }
+
+ // If the loaded user has a prompt, redirect them to the update page.
+ if (loadedUser.prompts && loadedUser.prompts.length) {
+ $location.path('/updateuser');
+ return;
+ }
+
+ if (opt_callback) {
+ opt_callback(loadedUser);
+ }
+ };
+
+ ApiService.getLoggedInUser().then(function(loadedUser) {
+ handleUserResponse(loadedUser);
+ }, function() {
+ handleUserResponse({'anonymous': true});
+ });
+ };
+
+ userService.isOrganization = function(name) {
+ return !!userService.getOrganization(name);
+ };
+
+ userService.getOrganization = function(name) {
+ if (!userResponse || !userResponse.organizations) { return null; }
+ for (var i = 0; i < userResponse.organizations.length; ++i) {
+ var org = userResponse.organizations[i];
+ if (org.name == name) {
+ return org;
+ }
+ }
+
+ return null;
+ };
+
+ userService.isNamespaceAdmin = function(namespace) {
+ if (namespace == userResponse.username) {
+ return true;
+ }
+
+ var org = userService.getOrganization(namespace);
+ if (!org) {
+ return false;
+ }
+
+ return org.is_org_admin;
+ };
+
+ userService.isKnownNamespace = function(namespace) {
+ if (namespace == userResponse.username) {
+ return true;
+ }
+
+ var org = userService.getOrganization(namespace);
+ return !!org;
+ };
+
+ userService.getNamespace = function(namespace) {
+ var org = userService.getOrganization(namespace);
+ if (org) {
+ return org;
+ }
+
+ if (namespace == userResponse.username) {
+ return userResponse;
+ }
+
+ return null;
+ };
+
+ userService.currentUser = function() {
+ return userResponse;
+ };
+
+ // Update the user in the root scope.
+ userService.updateUserIn($rootScope);
+
+ return userService;
+}]);
diff --git a/config_app/js/services/util-service.js b/config_app/js/services/util-service.js
new file mode 100644
index 000000000..34f0a4191
--- /dev/null
+++ b/config_app/js/services/util-service.js
@@ -0,0 +1,83 @@
+/**
+ * Service which exposes various utility methods.
+ */
+angular.module('quay-config').factory('UtilService', ['$sanitize',
+ function($sanitize) {
+ var utilService = {};
+
+ var adBlockEnabled = null;
+
+ utilService.isAdBlockEnabled = function(callback) {
+ if (adBlockEnabled !== null) {
+ callback(adBlockEnabled);
+ return;
+ }
+
+ if(typeof blockAdBlock === 'undefined') {
+ callback(true);
+ return;
+ }
+
+ var bab = new BlockAdBlock({
+ checkOnLoad: false,
+ resetOnEnd: true
+ });
+
+ bab.onDetected(function() { adBlockEnabled = true; callback(true); });
+ bab.onNotDetected(function() { adBlockEnabled = false; callback(false); });
+ bab.check();
+ };
+
+ utilService.isEmailAddress = function(val) {
+ var emailRegex = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
+ return emailRegex.test(val);
+ };
+
+ utilService.escapeHtmlString = function(text) {
+ var textStr = (text || '').toString();
+ var adjusted = textStr.replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+ return adjusted;
+ };
+
+ utilService.stringToHTML = function(text) {
+ text = utilService.escapeHtmlString(text);
+ text = text.replace(/\n/g, '
');
+ return text;
+ };
+
+ utilService.getRestUrl = function(args) {
+ var url = '';
+ for (var i = 0; i < arguments.length; ++i) {
+ if (i > 0) {
+ url += '/';
+ }
+ url += encodeURI(arguments[i])
+ }
+ return url;
+ };
+
+ utilService.textToSafeHtml = function(text) {
+ return $sanitize(utilService.escapeHtmlString(text));
+ };
+
+ return utilService;
+ }])
+ .factory('CoreDialog', [() => {
+ var service = {};
+ service['fatal'] = function(title, message) {
+ bootbox.dialog({
+ "title": title,
+ "message": "
" + message,
+ "buttons": {},
+ "className": "co-dialog fatal-error",
+ "closeButton": false
+ });
+ };
+
+ return service;
+ }]);
diff --git a/config_app/js/setup/setup.component.js b/config_app/js/setup/setup.component.js
new file mode 100644
index 000000000..65a076f31
--- /dev/null
+++ b/config_app/js/setup/setup.component.js
@@ -0,0 +1,319 @@
+import * as URI from 'urijs';
+const templateUrl = require('./setup.html');
+
+(function() {
+ /**
+ * The Setup page provides a nice GUI walkthrough experience for setting up Red Hat Quay.
+ */
+
+ angular.module('quay-config').directive('setup', () => {
+ const directiveDefinitionObject = {
+ priority: 1,
+ templateUrl,
+ replace: true,
+ transclude: true,
+ restrict: 'C',
+ scope: {
+ 'isActive': '=isActive',
+ 'configurationSaved': '&configurationSaved',
+ 'setupCompleted': '&setupCompleted',
+ },
+ controller: SetupCtrl,
+ };
+
+ return directiveDefinitionObject;
+ })
+
+ function SetupCtrl($scope, $timeout, ApiService, Features, UserService, ContainerService, CoreDialog) {
+ // if (!Features.SUPER_USERS) {
+ // return;
+ // }
+
+ $scope.HOSTNAME_REGEX = '^[a-zA-Z-0-9_\.\-]+(:[0-9]+)?$';
+
+ $scope.validateHostname = function(hostname) {
+ if (hostname.indexOf('127.0.0.1') == 0 || hostname.indexOf('localhost') == 0) {
+ return 'Please specify a non-localhost hostname. "localhost" will refer to the container, not your machine.'
+ }
+
+ return null;
+ };
+
+ // Note: The values of the enumeration are important for isStepFamily. For example,
+ // *all* states under the "configuring db" family must start with "config-db".
+ $scope.States = {
+ // Loading the state of the product.
+ 'LOADING': 'loading',
+
+ // The configuration directory is missing.
+ 'MISSING_CONFIG_DIR': 'missing-config-dir',
+
+ // The config.yaml exists but it is invalid.
+ 'INVALID_CONFIG': 'config-invalid',
+
+ // DB is being configured.
+ 'CONFIG_DB': 'config-db',
+
+ // DB information is being validated.
+ 'VALIDATING_DB': 'config-db-validating',
+
+ // DB information is being saved to the config.
+ 'SAVING_DB': 'config-db-saving',
+
+ // A validation error occurred with the database.
+ 'DB_ERROR': 'config-db-error',
+
+ // Database is being setup.
+ 'DB_SETUP': 'setup-db',
+
+ // An error occurred when setting up the database.
+ 'DB_SETUP_ERROR': 'setup-db-error',
+
+ // A superuser is being configured.
+ 'CREATE_SUPERUSER': 'create-superuser',
+
+ // The superuser is being created.
+ 'CREATING_SUPERUSER': 'create-superuser-creating',
+
+ // An error occurred when setting up the superuser.
+ 'SUPERUSER_ERROR': 'create-superuser-error',
+
+ // The superuser was created successfully.
+ 'SUPERUSER_CREATED': 'create-superuser-created',
+
+ // General configuration is being setup.
+ 'CONFIG': 'config',
+
+ // The configuration is fully valid.
+ 'VALID_CONFIG': 'valid-config',
+
+ // The product is ready for use.
+ 'READY': 'ready'
+ }
+
+ $scope.csrf_token = window.__token;
+ $scope.currentStep = $scope.States.LOADING;
+ $scope.errors = {};
+ $scope.stepProgress = [];
+ $scope.hasSSL = false;
+ $scope.hostname = null;
+ $scope.currentConfig = null;
+
+ $scope.currentState = {
+ 'hasDatabaseSSLCert': false
+ };
+
+ $scope.$watch('currentStep', function(currentStep) {
+ $scope.stepProgress = $scope.getProgress(currentStep);
+
+ switch (currentStep) {
+ case $scope.States.CONFIG:
+ $('#setupModal').modal('hide');
+ break;
+
+ case $scope.States.MISSING_CONFIG_DIR:
+ $scope.showMissingConfigDialog();
+ break;
+
+ case $scope.States.INVALID_CONFIG:
+ $scope.showInvalidConfigDialog();
+ break;
+
+ case $scope.States.DB_SETUP:
+ $scope.performDatabaseSetup();
+ // Fall-through.
+
+ case $scope.States.CREATE_SUPERUSER:
+ case $scope.States.CONFIG_DB:
+ case $scope.States.VALID_CONFIG:
+ case $scope.States.READY:
+ $('#setupModal').modal({
+ keyboard: false,
+ backdrop: 'static'
+ });
+ break;
+ }
+ });
+
+ $scope.restartContainer = function(state) {
+ $scope.currentStep = state;
+ ContainerService.restartContainer(function() {
+ $scope.checkStatus()
+ });
+ };
+
+ $scope.showSuperuserPanel = function() {
+ $('#setupModal').modal('hide');
+ var prefix = $scope.hasSSL ? 'https' : 'http';
+ var hostname = $scope.hostname;
+ if (!hostname) {
+ hostname = document.location.hostname;
+ if (document.location.port) {
+ hostname = hostname + ':' + document.location.port;
+ }
+ }
+
+ window.location = prefix + '://' + hostname + '/superuser';
+ };
+
+ $scope.configurationSaved = function(config) {
+ $scope.hasSSL = config['PREFERRED_URL_SCHEME'] == 'https';
+ $scope.hostname = config['SERVER_HOSTNAME'];
+ $scope.currentConfig = config;
+
+ $scope.currentStep = $scope.States.VALID_CONFIG;
+ };
+
+ $scope.getProgress = function(step) {
+ var isStep = $scope.isStep;
+ var isStepFamily = $scope.isStepFamily;
+ var States = $scope.States;
+
+ return [
+ isStepFamily(step, States.CONFIG_DB),
+ isStepFamily(step, States.DB_SETUP),
+ isStepFamily(step, States.CREATE_SUPERUSER),
+ isStep(step, States.CONFIG),
+ isStep(step, States.VALID_CONFIG),
+ isStep(step, States.READY)
+ ];
+ };
+
+ $scope.isStepFamily = function(step, family) {
+ if (!step) { return false; }
+ return step.indexOf(family) == 0;
+ };
+
+ $scope.isStep = function(step) {
+ for (var i = 1; i < arguments.length; ++i) {
+ if (arguments[i] == step) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ $scope.beginSetup = function() {
+ $scope.currentStep = $scope.States.CONFIG_DB;
+ };
+
+ $scope.showInvalidConfigDialog = function() {
+ var message = "The
config.yaml
file found in
conf/stack
could not be parsed."
+ var title = "Invalid configuration file";
+ CoreDialog.fatal(title, message);
+ };
+
+
+ $scope.showMissingConfigDialog = function() {
+ var message = "A volume should be mounted into the container at
/conf/stack
: " +
+ "
docker run -v /path/to/config:/conf/stack " +
+ "
Once fixed, restart the container. For more information, " +
+ "
" +
+ "Read the Setup Guide "
+
+ var title = "Missing configuration volume";
+ CoreDialog.fatal(title, message);
+ };
+
+ $scope.parseDbUri = function(value) {
+ if (!value) { return null; }
+
+ // Format: mysql+pymysql://
:@/
+ var uri = URI(value);
+ return {
+ 'kind': uri.protocol(),
+ 'username': uri.username(),
+ 'password': uri.password(),
+ 'server': uri.host(),
+ 'database': uri.path() ? uri.path().substr(1) : ''
+ };
+ };
+
+ $scope.serializeDbUri = function(fields) {
+ if (!fields['server']) { return ''; }
+ if (!fields['database']) { return ''; }
+
+ var uri = URI();
+ try {
+ uri = uri && uri.host(fields['server']);
+ uri = uri && uri.protocol(fields['kind']);
+ uri = uri && uri.username(fields['username']);
+ uri = uri && uri.password(fields['password']);
+ uri = uri && uri.path('/' + (fields['database'] || ''));
+ uri = uri && uri.toString();
+ } catch (ex) {
+ return '';
+ }
+
+ return uri;
+ };
+
+ $scope.createSuperUser = function() {
+ $scope.currentStep = $scope.States.CREATING_SUPERUSER;
+ ApiService.scCreateInitialSuperuser($scope.superUser, null).then(function(resp) {
+ $scope.checkStatus();
+ }, function(resp) {
+ $scope.currentStep = $scope.States.SUPERUSER_ERROR;
+ $scope.errors.SuperuserCreationError = ApiService.getErrorMessage(resp, 'Could not create superuser');
+ });
+ };
+
+ $scope.performDatabaseSetup = function() {
+ $scope.currentStep = $scope.States.DB_SETUP;
+ ApiService.scSetupDatabase(null, null).then(function(resp) {
+ if (resp['error']) {
+ $scope.currentStep = $scope.States.DB_SETUP_ERROR;
+ $scope.errors.DatabaseSetupError = resp['error'];
+ } else {
+ $scope.currentStep = $scope.States.CREATE_SUPERUSER;
+ }
+ }, ApiService.errorDisplay('Could not setup database. Please report this to support.'))
+ };
+
+ $scope.validateDatabase = function() {
+ $scope.currentStep = $scope.States.VALIDATING_DB;
+ $scope.databaseInvalid = null;
+
+ var data = {
+ 'config': {
+ 'DB_URI': $scope.databaseUri
+ },
+ };
+
+ if ($scope.currentState.hasDatabaseSSLCert) {
+ data['config']['DB_CONNECTION_ARGS'] = {
+ 'ssl': {
+ 'ca': 'conf/stack/database.pem'
+ }
+ };
+ }
+
+ var params = {
+ 'service': 'database'
+ };
+
+ ApiService.scValidateConfig(data, params).then(function(resp) {
+ var status = resp.status;
+
+ if (status) {
+ $scope.currentStep = $scope.States.SAVING_DB;
+ ApiService.scUpdateConfig(data, null).then(function(resp) {
+ $scope.checkStatus();
+ }, ApiService.errorDisplay('Cannot update config. Please report this to support'));
+ } else {
+ $scope.currentStep = $scope.States.DB_ERROR;
+ $scope.errors.DatabaseValidationError = resp.reason;
+ }
+ }, ApiService.errorDisplay('Cannot validate database. Please report this to support'));
+ };
+
+ $scope.checkStatus = function() {
+ ContainerService.checkStatus(function(resp) {
+ $scope.currentStep = resp['status'];
+ }, $scope.currentConfig);
+ };
+
+ // Load the initial status.
+ $scope.checkStatus();
+ };
+})();
diff --git a/static/partials/setup.html b/config_app/js/setup/setup.html
similarity index 90%
rename from static/partials/setup.html
rename to config_app/js/setup/setup.html
index 50e507ad4..7b989804f 100644
--- a/static/partials/setup.html
+++ b/config_app/js/setup/setup.html
@@ -1,22 +1,21 @@
+
-
+
- Enterprise Registry Setup
+ Red Hat Quay Setup
-
+
-
-
-
+
Almost done!
@@ -24,7 +23,9 @@
+ configuration-saved="configurationSaved(config)"
+ setup-completed="setupCompleted()"
+ >
@@ -38,14 +39,12 @@
-
-
-
+
-
Setup
+
Setup