Merge pull request #3207 from quay/project/gen-sec-key
Add the service key creation to config tool
This commit is contained in:
commit
ec14007268
34 changed files with 1480 additions and 137 deletions
|
@ -155,7 +155,7 @@ def test_mixing_keys_e2e(initialized_db):
|
|||
|
||||
# Approve the key and try again.
|
||||
admin_user = model.user.get_user('devtable')
|
||||
model.service_keys.approve_service_key(key.kid, admin_user, ServiceKeyApprovalType.SUPERUSER)
|
||||
model.service_keys.approve_service_key(key.kid, ServiceKeyApprovalType.SUPERUSER, approver=admin_user)
|
||||
|
||||
valid_token = _token(token_data, key_id='newkey', private_key=private_key)
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import logging
|
|||
from flask import Blueprint, request, abort
|
||||
from flask_restful import Resource, Api
|
||||
from flask_restful.utils.cors import crossdomain
|
||||
from data import model
|
||||
from email.utils import formatdate
|
||||
from calendar import timegm
|
||||
from functools import partial, wraps
|
||||
|
@ -29,6 +30,14 @@ api = ApiExceptionHandlingApi()
|
|||
|
||||
api.init_app(api_bp)
|
||||
|
||||
def log_action(kind, user_or_orgname, metadata=None, repo=None, repo_name=None):
|
||||
if not metadata:
|
||||
metadata = {}
|
||||
|
||||
if repo:
|
||||
repo_name = repo.name
|
||||
|
||||
model.log.log_action(kind, user_or_orgname, repo_name, user_or_orgname, request.remote_addr, metadata)
|
||||
|
||||
def format_date(date):
|
||||
""" Output an RFC822 date format. """
|
||||
|
|
|
@ -2,16 +2,20 @@ import logging
|
|||
import pathvalidate
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
from flask import request, jsonify
|
||||
from flask import request, jsonify, make_response
|
||||
|
||||
from endpoints.exception import NotFound
|
||||
from data.database import ServiceKeyApprovalType
|
||||
from data.model import ServiceKeyDoesNotExist
|
||||
from util.config.validator import EXTRA_CA_DIRECTORY
|
||||
|
||||
from config_app.config_endpoints.exception import InvalidRequest
|
||||
from config_app.config_endpoints.api import resource, ApiResource, nickname
|
||||
from config_app.config_endpoints.api import resource, ApiResource, nickname, log_action, validate_json_request
|
||||
from config_app.config_endpoints.api.superuser_models_pre_oci import pre_oci_model
|
||||
from config_app.config_util.ssl import load_certificate, CertInvalidException
|
||||
from config_app.c_app import config_provider, INIT_SCRIPTS_LOCATION
|
||||
from config_app.c_app import app, config_provider, INIT_SCRIPTS_LOCATION
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -19,131 +23,231 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
@resource('/v1/superuser/customcerts/<certpath>')
|
||||
class SuperUserCustomCertificate(ApiResource):
|
||||
""" Resource for managing a custom certificate. """
|
||||
""" Resource for managing a custom certificate. """
|
||||
|
||||
@nickname('uploadCustomCertificate')
|
||||
def post(self, certpath):
|
||||
uploaded_file = request.files['file']
|
||||
if not uploaded_file:
|
||||
raise InvalidRequest('Missing certificate file')
|
||||
@nickname('uploadCustomCertificate')
|
||||
def post(self, certpath):
|
||||
uploaded_file = request.files['file']
|
||||
if not uploaded_file:
|
||||
raise InvalidRequest('Missing certificate file')
|
||||
|
||||
# Save the certificate.
|
||||
certpath = pathvalidate.sanitize_filename(certpath)
|
||||
if not certpath.endswith('.crt'):
|
||||
raise InvalidRequest('Invalid certificate file: must have suffix `.crt`')
|
||||
# Save the certificate.
|
||||
certpath = pathvalidate.sanitize_filename(certpath)
|
||||
if not certpath.endswith('.crt'):
|
||||
raise InvalidRequest('Invalid certificate file: must have suffix `.crt`')
|
||||
|
||||
logger.debug('Saving custom certificate %s', certpath)
|
||||
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
|
||||
config_provider.save_volume_file(cert_full_path, uploaded_file)
|
||||
logger.debug('Saved custom certificate %s', certpath)
|
||||
logger.debug('Saving custom certificate %s', certpath)
|
||||
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
|
||||
config_provider.save_volume_file(cert_full_path, uploaded_file)
|
||||
logger.debug('Saved custom certificate %s', certpath)
|
||||
|
||||
# Validate the certificate.
|
||||
try:
|
||||
logger.debug('Loading custom certificate %s', certpath)
|
||||
with config_provider.get_volume_file(cert_full_path) as f:
|
||||
load_certificate(f.read())
|
||||
except CertInvalidException:
|
||||
logger.exception('Got certificate invalid error for cert %s', certpath)
|
||||
return '', 204
|
||||
except IOError:
|
||||
logger.exception('Got IO error for cert %s', certpath)
|
||||
return '', 204
|
||||
# Validate the certificate.
|
||||
try:
|
||||
logger.debug('Loading custom certificate %s', certpath)
|
||||
with config_provider.get_volume_file(cert_full_path) as f:
|
||||
load_certificate(f.read())
|
||||
except CertInvalidException:
|
||||
logger.exception('Got certificate invalid error for cert %s', certpath)
|
||||
return '', 204
|
||||
except IOError:
|
||||
logger.exception('Got IO error for cert %s', certpath)
|
||||
return '', 204
|
||||
|
||||
# Call the update script with config dir location to install the certificate immediately.
|
||||
if subprocess.call([os.path.join(INIT_SCRIPTS_LOCATION, 'certs_install.sh')],
|
||||
env={ 'QUAYCONFIG': config_provider.get_config_dir_path() }) != 0:
|
||||
raise Exception('Could not install certificates')
|
||||
# Call the update script with config dir location to install the certificate immediately.
|
||||
if subprocess.call([os.path.join(INIT_SCRIPTS_LOCATION, 'certs_install.sh')],
|
||||
env={ 'QUAYCONFIG': config_provider.get_config_dir_path() }) != 0:
|
||||
raise Exception('Could not install certificates')
|
||||
|
||||
return '', 204
|
||||
return '', 204
|
||||
|
||||
@nickname('deleteCustomCertificate')
|
||||
def delete(self, certpath):
|
||||
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
|
||||
config_provider.remove_volume_file(cert_full_path)
|
||||
return '', 204
|
||||
@nickname('deleteCustomCertificate')
|
||||
def delete(self, certpath):
|
||||
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
|
||||
config_provider.remove_volume_file(cert_full_path)
|
||||
return '', 204
|
||||
|
||||
|
||||
@resource('/v1/superuser/customcerts')
|
||||
class SuperUserCustomCertificates(ApiResource):
|
||||
""" Resource for managing custom certificates. """
|
||||
""" Resource for managing custom certificates. """
|
||||
|
||||
@nickname('getCustomCertificates')
|
||||
def get(self):
|
||||
has_extra_certs_path = config_provider.volume_file_exists(EXTRA_CA_DIRECTORY)
|
||||
extra_certs_found = config_provider.list_volume_directory(EXTRA_CA_DIRECTORY)
|
||||
if extra_certs_found is None:
|
||||
return {
|
||||
'status': 'file' if has_extra_certs_path else 'none',
|
||||
}
|
||||
@nickname('getCustomCertificates')
|
||||
def get(self):
|
||||
has_extra_certs_path = config_provider.volume_file_exists(EXTRA_CA_DIRECTORY)
|
||||
extra_certs_found = config_provider.list_volume_directory(EXTRA_CA_DIRECTORY)
|
||||
if extra_certs_found is None:
|
||||
return {
|
||||
'status': 'file' if has_extra_certs_path else 'none',
|
||||
}
|
||||
|
||||
cert_views = []
|
||||
for extra_cert_path in extra_certs_found:
|
||||
try:
|
||||
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, extra_cert_path)
|
||||
with config_provider.get_volume_file(cert_full_path) as f:
|
||||
certificate = load_certificate(f.read())
|
||||
cert_views.append({
|
||||
'path': extra_cert_path,
|
||||
'names': list(certificate.names),
|
||||
'expired': certificate.expired,
|
||||
})
|
||||
except CertInvalidException as cie:
|
||||
cert_views.append({
|
||||
'path': extra_cert_path,
|
||||
'error': cie.message,
|
||||
})
|
||||
except IOError as ioe:
|
||||
cert_views.append({
|
||||
'path': extra_cert_path,
|
||||
'error': ioe.message,
|
||||
})
|
||||
cert_views = []
|
||||
for extra_cert_path in extra_certs_found:
|
||||
try:
|
||||
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, extra_cert_path)
|
||||
with config_provider.get_volume_file(cert_full_path) as f:
|
||||
certificate = load_certificate(f.read())
|
||||
cert_views.append({
|
||||
'path': extra_cert_path,
|
||||
'names': list(certificate.names),
|
||||
'expired': certificate.expired,
|
||||
})
|
||||
except CertInvalidException as cie:
|
||||
cert_views.append({
|
||||
'path': extra_cert_path,
|
||||
'error': cie.message,
|
||||
})
|
||||
except IOError as ioe:
|
||||
cert_views.append({
|
||||
'path': extra_cert_path,
|
||||
'error': ioe.message,
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'directory',
|
||||
'certs': cert_views,
|
||||
}
|
||||
return {
|
||||
'status': 'directory',
|
||||
'certs': cert_views,
|
||||
}
|
||||
|
||||
|
||||
@resource('/v1/superuser/keys')
|
||||
class SuperUserServiceKeyManagement(ApiResource):
|
||||
""" Resource for managing service keys."""
|
||||
schemas = {
|
||||
'CreateServiceKey': {
|
||||
'id': 'CreateServiceKey',
|
||||
'type': 'object',
|
||||
'description': 'Description of creation of a service key',
|
||||
'required': ['service', 'expiration'],
|
||||
'properties': {
|
||||
'service': {
|
||||
'type': 'string',
|
||||
'description': 'The service authenticating with this key',
|
||||
},
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'The friendly name of a service key',
|
||||
},
|
||||
'metadata': {
|
||||
'type': 'object',
|
||||
'description': 'The key/value pairs of this key\'s metadata',
|
||||
},
|
||||
'notes': {
|
||||
'type': 'string',
|
||||
'description': 'If specified, the extra notes for the key',
|
||||
},
|
||||
'expiration': {
|
||||
'description': 'The expiration date as a unix timestamp',
|
||||
'anyOf': [{'type': 'number'}, {'type': 'null'}],
|
||||
},
|
||||
},
|
||||
""" Resource for managing service keys."""
|
||||
schemas = {
|
||||
'CreateServiceKey': {
|
||||
'id': 'CreateServiceKey',
|
||||
'type': 'object',
|
||||
'description': 'Description of creation of a service key',
|
||||
'required': ['service', 'expiration'],
|
||||
'properties': {
|
||||
'service': {
|
||||
'type': 'string',
|
||||
'description': 'The service authenticating with this key',
|
||||
},
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'The friendly name of a service key',
|
||||
},
|
||||
'metadata': {
|
||||
'type': 'object',
|
||||
'description': 'The key/value pairs of this key\'s metadata',
|
||||
},
|
||||
'notes': {
|
||||
'type': 'string',
|
||||
'description': 'If specified, the extra notes for the key',
|
||||
},
|
||||
'expiration': {
|
||||
'description': 'The expiration date as a unix timestamp',
|
||||
'anyOf': [{'type': 'number'}, {'type': 'null'}],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@nickname('listServiceKeys')
|
||||
def get(self):
|
||||
keys = pre_oci_model.list_all_service_keys()
|
||||
|
||||
return jsonify({
|
||||
'keys': [key.to_dict() for key in keys],
|
||||
})
|
||||
|
||||
@nickname('createServiceKey')
|
||||
@validate_json_request('CreateServiceKey')
|
||||
def post(self):
|
||||
body = request.get_json()
|
||||
|
||||
# Ensure we have a valid expiration date if specified.
|
||||
expiration_date = body.get('expiration', None)
|
||||
if expiration_date is not None:
|
||||
try:
|
||||
expiration_date = datetime.utcfromtimestamp(float(expiration_date))
|
||||
except ValueError as ve:
|
||||
raise InvalidRequest('Invalid expiration date: %s' % ve)
|
||||
|
||||
if expiration_date <= datetime.now():
|
||||
raise InvalidRequest('Expiration date cannot be in the past')
|
||||
|
||||
# Create the metadata for the key.
|
||||
# Since we don't have logins in the config app, we'll just get any of the superusers
|
||||
user = config_provider.get_config().get('SUPER_USERS', [None])[0]
|
||||
if user is None:
|
||||
raise InvalidRequest('No super users exist, cannot create service key without approver')
|
||||
|
||||
metadata = body.get('metadata', {})
|
||||
metadata.update({
|
||||
'created_by': 'Quay Superuser Panel',
|
||||
'creator': user,
|
||||
'ip': request.remote_addr,
|
||||
})
|
||||
|
||||
# Generate a key with a private key that we *never save*.
|
||||
(private_key, key_id) = pre_oci_model.generate_service_key(body['service'], expiration_date,
|
||||
metadata=metadata,
|
||||
name=body.get('name', ''))
|
||||
# Auto-approve the service key.
|
||||
pre_oci_model.approve_service_key(key_id, ServiceKeyApprovalType.SUPERUSER,
|
||||
notes=body.get('notes', ''))
|
||||
|
||||
# Log the creation and auto-approval of the service key.
|
||||
key_log_metadata = {
|
||||
'kid': key_id,
|
||||
'preshared': True,
|
||||
'service': body['service'],
|
||||
'name': body.get('name', ''),
|
||||
'expiration_date': expiration_date,
|
||||
'auto_approved': True,
|
||||
}
|
||||
|
||||
@nickname('listServiceKeys')
|
||||
def get(self):
|
||||
keys = pre_oci_model.list_all_service_keys()
|
||||
log_action('service_key_create', None, key_log_metadata)
|
||||
log_action('service_key_approve', None, key_log_metadata)
|
||||
|
||||
return jsonify({
|
||||
'keys': [key.to_dict() for key in keys],
|
||||
})
|
||||
return jsonify({
|
||||
'kid': key_id,
|
||||
'name': body.get('name', ''),
|
||||
'service': body['service'],
|
||||
'public_key': private_key.publickey().exportKey('PEM'),
|
||||
'private_key': private_key.exportKey('PEM'),
|
||||
})
|
||||
|
||||
@resource('/v1/superuser/approvedkeys/<kid>')
|
||||
class SuperUserServiceKeyApproval(ApiResource):
|
||||
""" Resource for approving service keys. """
|
||||
|
||||
schemas = {
|
||||
'ApproveServiceKey': {
|
||||
'id': 'ApproveServiceKey',
|
||||
'type': 'object',
|
||||
'description': 'Information for approving service keys',
|
||||
'properties': {
|
||||
'notes': {
|
||||
'type': 'string',
|
||||
'description': 'Optional approval notes',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@nickname('approveServiceKey')
|
||||
@validate_json_request('ApproveServiceKey')
|
||||
def post(self, kid):
|
||||
notes = request.get_json().get('notes', '')
|
||||
try:
|
||||
key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes)
|
||||
|
||||
# Log the approval of the service key.
|
||||
key_log_metadata = {
|
||||
'kid': kid,
|
||||
'service': key.service,
|
||||
'name': key.name,
|
||||
'expiration_date': key.expiration_date,
|
||||
}
|
||||
|
||||
# Note: this may not actually be the current person modifying the config, but if they're in the config tool,
|
||||
# they have full access to the DB and could pretend to be any user, so pulling any superuser is likely fine
|
||||
super_user = app.config.get('SUPER_USERS', [None])[0]
|
||||
log_action('service_key_approve', super_user, key_log_metadata)
|
||||
except ServiceKeyDoesNotExist:
|
||||
raise NotFound()
|
||||
except ServiceKeyAlreadyApproved:
|
||||
pass
|
||||
|
||||
return make_response('', 201)
|
||||
|
|
|
@ -3,29 +3,50 @@ from data import model
|
|||
from config_app.config_endpoints.api.superuser_models_interface import SuperuserDataInterface, User, ServiceKey, Approval
|
||||
|
||||
def _create_user(user):
|
||||
if user is None:
|
||||
return None
|
||||
return User(user.username, user.email, user.verified, user.enabled, user.robot)
|
||||
if user is None:
|
||||
return None
|
||||
return User(user.username, user.email, user.verified, user.enabled, user.robot)
|
||||
|
||||
|
||||
def _create_key(key):
|
||||
approval = None
|
||||
if key.approval is not None:
|
||||
approval = Approval(_create_user(key.approval.approver), key.approval.approval_type, key.approval.approved_date,
|
||||
key.approval.notes)
|
||||
approval = None
|
||||
if key.approval is not None:
|
||||
approval = Approval(_create_user(key.approval.approver), key.approval.approval_type, key.approval.approved_date,
|
||||
key.approval.notes)
|
||||
|
||||
return ServiceKey(key.name, key.kid, key.service, key.jwk, key.metadata, key.created_date, key.expiration_date,
|
||||
key.rotation_duration, approval)
|
||||
return ServiceKey(key.name, key.kid, key.service, key.jwk, key.metadata, key.created_date, key.expiration_date,
|
||||
key.rotation_duration, approval)
|
||||
|
||||
class ServiceKeyDoesNotExist(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceKeyAlreadyApproved(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PreOCIModel(SuperuserDataInterface):
|
||||
"""
|
||||
PreOCIModel implements the data model for the SuperUser using a database schema
|
||||
before it was changed to support the OCI specification.
|
||||
"""
|
||||
def list_all_service_keys(self):
|
||||
keys = model.service_keys.list_all_keys()
|
||||
return [_create_key(key) for key in keys]
|
||||
"""
|
||||
PreOCIModel implements the data model for the SuperUser using a database schema
|
||||
before it was changed to support the OCI specification.
|
||||
"""
|
||||
def list_all_service_keys(self):
|
||||
keys = model.service_keys.list_all_keys()
|
||||
return [_create_key(key) for key in keys]
|
||||
|
||||
def approve_service_key(self, kid, approval_type, notes=''):
|
||||
try:
|
||||
key = model.service_keys.approve_service_key(kid, approval_type, notes=notes)
|
||||
return _create_key(key)
|
||||
except model.ServiceKeyDoesNotExist:
|
||||
raise ServiceKeyDoesNotExist
|
||||
except model.ServiceKeyAlreadyApproved:
|
||||
raise ServiceKeyAlreadyApproved
|
||||
|
||||
def generate_service_key(self, service, expiration_date, kid=None, name='', metadata=None, rotation_duration=None):
|
||||
(private_key, key) = model.service_keys.generate_service_key(service, expiration_date, metadata=metadata, name=name)
|
||||
|
||||
return private_key, key.kid
|
||||
|
||||
|
||||
pre_oci_model = PreOCIModel()
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
<span class="datetime-picker-element">
|
||||
<input class="form-control" type="text" ng-model="selected_datetime"/>
|
||||
</span>
|
60
config_app/js/components/datetime-picker/datetime-picker.js
Normal file
60
config_app/js/components/datetime-picker/datetime-picker.js
Normal file
|
@ -0,0 +1,60 @@
|
|||
const templateUrl = require('./datetime-picker.html');
|
||||
/**
|
||||
* An element which displays a datetime picker.
|
||||
*/
|
||||
angular.module('quay-config').directive('datetimePicker', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl,
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'datetime': '=datetime',
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
var datetimeSet = false;
|
||||
|
||||
$(function() {
|
||||
$element.find('input').datetimepicker({
|
||||
'format': 'LLL',
|
||||
'sideBySide': true,
|
||||
'showClear': true,
|
||||
'minDate': new Date(),
|
||||
'debug': false
|
||||
});
|
||||
|
||||
$element.find('input').on("dp.change", function (e) {
|
||||
$scope.$apply(function() {
|
||||
$scope.datetime = e.date ? e.date.unix() : null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$scope.$watch('selected_datetime', function(value) {
|
||||
if (!datetimeSet) { return; }
|
||||
|
||||
if (!value) {
|
||||
if ($scope.datetime) {
|
||||
$scope.datetime = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.datetime = (new Date(value)).getTime()/1000;
|
||||
});
|
||||
|
||||
$scope.$watch('datetime', function(value) {
|
||||
if (!value) {
|
||||
$scope.selected_datetime = null;
|
||||
datetimeSet = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.selected_datetime = moment.unix(value).format('LLL');
|
||||
datetimeSet = true;
|
||||
});
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -46,6 +46,7 @@ export class KubeDeployModalComponent {
|
|||
this.errorMessage = `Could cycle the deployments with the new configuration. Error: ${err.toString()}`;
|
||||
})
|
||||
}).catch(err => {
|
||||
console.log(err)
|
||||
this.state = 'error';
|
||||
this.errorMessage = `Could not deploy the configuration. Error: ${err.toString()}`;
|
||||
})
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
[
|
||||
"javascript",
|
||||
"python",
|
||||
"bash",
|
||||
"nginx",
|
||||
"xml",
|
||||
"shell"
|
||||
]
|
|
@ -0,0 +1,24 @@
|
|||
.markdown-editor-element textarea {
|
||||
height: 300px;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.markdown-editor-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.markdown-editor-buttons button {
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.markdown-editor-buttons button:last-child {
|
||||
margin: 0;
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
<div class="markdown-editor-element">
|
||||
<!-- Write/preview tabs -->
|
||||
<ul class="nav nav-tabs" style="width: 100%;">
|
||||
<li role="presentation" ng-class="$ctrl.editMode == 'write' ? 'active': ''"
|
||||
ng-click="$ctrl.changeEditMode('write')">
|
||||
<a href="#">Write</a>
|
||||
</li>
|
||||
<li role="presentation" ng-class="$ctrl.editMode == 'preview' ? 'active': ''"
|
||||
ng-click="$ctrl.changeEditMode('preview')">
|
||||
<a href="#">Preview</a>
|
||||
</li>
|
||||
<!-- Editing toolbar -->
|
||||
<li style="float: right;">
|
||||
<markdown-toolbar ng-if="$ctrl.editMode == 'write'"
|
||||
(insert-symbol)="$ctrl.insertSymbol($event)"></markdown-toolbar>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" style="padding: 10px 0 0 0;">
|
||||
<div ng-show="$ctrl.editMode == 'write'">
|
||||
<textarea id="markdown-textarea"
|
||||
placeholder="Enter {{ ::$ctrl.fieldTitle }}"
|
||||
ng-model="$ctrl.content"></textarea>
|
||||
</div>
|
||||
<div class="markdown-editor-preview"
|
||||
ng-if="$ctrl.editMode == 'preview'">
|
||||
<markdown-view content="$ctrl.content"></markdown-view>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="markdown-editor-actions">
|
||||
<div class="markdown-editor-buttons">
|
||||
<button type="button" class="btn btn-default"
|
||||
ng-click="$ctrl.discardChanges()">
|
||||
Close
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary"
|
||||
ng-click="$ctrl.saveChanges()">
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,188 @@
|
|||
import { MarkdownEditorComponent, EditMode } from './markdown-editor.component';
|
||||
import { MarkdownSymbol } from '../../../types/common.types';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("MarkdownEditorComponent", () => {
|
||||
var component: MarkdownEditorComponent;
|
||||
var textarea: Mock<ng.IAugmentedJQuery | any>;
|
||||
var documentMock: Mock<HTMLElement & Document>;
|
||||
var $windowMock: Mock<ng.IWindowService>;
|
||||
|
||||
beforeEach(() => {
|
||||
textarea = new Mock<ng.IAugmentedJQuery | any>();
|
||||
documentMock = new Mock<HTMLElement & Document>();
|
||||
$windowMock = new Mock<ng.IWindowService>();
|
||||
const $documentMock: any = [documentMock.Object];
|
||||
component = new MarkdownEditorComponent($documentMock, $windowMock.Object, 'chrome');
|
||||
component.textarea = textarea.Object;
|
||||
});
|
||||
|
||||
describe("onBeforeUnload", () => {
|
||||
|
||||
it("returns false to alert user about losing current changes", () => {
|
||||
component.changeEditMode("write");
|
||||
const allow: boolean = component.onBeforeUnload();
|
||||
|
||||
expect(allow).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngOnDestroy", () => {
|
||||
|
||||
it("removes 'beforeunload' event listener", () => {
|
||||
$windowMock.setup(mock => mock.onbeforeunload).is(() => 1);
|
||||
component.ngOnDestroy();
|
||||
|
||||
expect($windowMock.Object.onbeforeunload.call(this)).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeEditMode", () => {
|
||||
|
||||
it("sets component's edit mode to given mode", () => {
|
||||
const editMode: EditMode = "preview";
|
||||
component.changeEditMode(editMode);
|
||||
|
||||
expect(component.currentEditMode).toEqual(editMode);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertSymbol", () => {
|
||||
var event: {symbol: MarkdownSymbol};
|
||||
var markdownSymbols: {type: MarkdownSymbol, characters: string, shiftBy: number}[];
|
||||
var innerText: string;
|
||||
|
||||
beforeEach(() => {
|
||||
event = {symbol: 'heading1'};
|
||||
innerText = "Here is some text";
|
||||
markdownSymbols = [
|
||||
{type: 'heading1', characters: '# ', shiftBy: 2},
|
||||
{type: 'heading2', characters: '## ', shiftBy: 3},
|
||||
{type: 'heading3', characters: '### ', shiftBy: 4},
|
||||
{type: 'bold', characters: '****', shiftBy: 2},
|
||||
{type: 'italics', characters: '__', shiftBy: 1},
|
||||
{type: 'bulleted-list', characters: '- ', shiftBy: 2},
|
||||
{type: 'numbered-list', characters: '1. ', shiftBy: 3},
|
||||
{type: 'quote', characters: '> ', shiftBy: 2},
|
||||
{type: 'link', characters: '[](url)', shiftBy: 1},
|
||||
{type: 'code', characters: '``', shiftBy: 1},
|
||||
];
|
||||
|
||||
textarea.setup(mock => mock.focus);
|
||||
textarea.setup(mock => mock.substr).is((start, end) => '');
|
||||
textarea.setup(mock => mock.val).is((value?) => innerText);
|
||||
textarea.setup(mock => mock.prop).is((prop) => {
|
||||
switch (prop) {
|
||||
case "selectionStart":
|
||||
return 0;
|
||||
case "selectionEnd":
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
documentMock.setup(mock => mock.execCommand).is((commandID, showUI, value) => false);
|
||||
});
|
||||
|
||||
it("focuses on markdown textarea", () => {
|
||||
component.insertSymbol(event);
|
||||
|
||||
expect(<Spy>textarea.Object.focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("inserts correct characters for given symbol at cursor position", () => {
|
||||
markdownSymbols.forEach((symbol) => {
|
||||
event.symbol = symbol.type;
|
||||
component.insertSymbol(event);
|
||||
|
||||
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText');
|
||||
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false);
|
||||
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(symbol.characters);
|
||||
|
||||
(<Spy>documentMock.Object.execCommand).calls.reset();
|
||||
});
|
||||
});
|
||||
|
||||
it("splices highlighted selection between inserted characters instead of deleting them", () => {
|
||||
markdownSymbols.slice(0, 1).forEach((symbol) => {
|
||||
textarea.setup(mock => mock.prop).is((prop) => {
|
||||
switch (prop) {
|
||||
case "selectionStart":
|
||||
return 0;
|
||||
case "selectionEnd":
|
||||
return innerText.length;
|
||||
}
|
||||
});
|
||||
event.symbol = symbol.type;
|
||||
component.insertSymbol(event);
|
||||
|
||||
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText');
|
||||
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false);
|
||||
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(`${symbol.characters.slice(0, symbol.shiftBy)}${innerText}${symbol.characters.slice(symbol.shiftBy, symbol.characters.length)}`);
|
||||
|
||||
(<Spy>documentMock.Object.execCommand).calls.reset();
|
||||
});
|
||||
});
|
||||
|
||||
it("moves cursor to correct position for given symbol", () => {
|
||||
markdownSymbols.forEach((symbol) => {
|
||||
event.symbol = symbol.type;
|
||||
component.insertSymbol(event);
|
||||
|
||||
expect((<Spy>textarea.Object.prop).calls.argsFor(2)[0]).toEqual('selectionStart');
|
||||
expect((<Spy>textarea.Object.prop).calls.argsFor(2)[1]).toEqual(symbol.shiftBy);
|
||||
expect((<Spy>textarea.Object.prop).calls.argsFor(3)[0]).toEqual('selectionEnd');
|
||||
expect((<Spy>textarea.Object.prop).calls.argsFor(3)[1]).toEqual(symbol.shiftBy);
|
||||
|
||||
(<Spy>textarea.Object.prop).calls.reset();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveChanges", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
component.content = "# Some markdown content";
|
||||
});
|
||||
|
||||
it("emits output event with changed content", (done) => {
|
||||
component.save.subscribe((event: {editedContent: string}) => {
|
||||
expect(event.editedContent).toEqual(component.content);
|
||||
done();
|
||||
});
|
||||
|
||||
component.saveChanges();
|
||||
});
|
||||
});
|
||||
|
||||
describe("discardChanges", () => {
|
||||
|
||||
it("prompts user to confirm discarding changes", () => {
|
||||
const confirmSpy: Spy = $windowMock.setup(mock => mock.confirm).is((message) => false).Spy;
|
||||
component.discardChanges();
|
||||
|
||||
expect(confirmSpy.calls.argsFor(0)[0]).toEqual(`Are you sure you want to discard your changes?`);
|
||||
});
|
||||
|
||||
it("emits output event with no content if user confirms discarding changes", (done) => {
|
||||
$windowMock.setup(mock => mock.confirm).is((message) => true);
|
||||
component.discard.subscribe((event: {}) => {
|
||||
expect(event).toEqual({});
|
||||
done();
|
||||
});
|
||||
|
||||
component.discardChanges();
|
||||
});
|
||||
|
||||
it("does not emit output event if user declines confirmation of discarding changes", (done) => {
|
||||
$windowMock.setup(mock => mock.confirm).is((message) => false);
|
||||
component.discard.subscribe((event: {}) => {
|
||||
fail(`Should not emit output event`);
|
||||
done();
|
||||
});
|
||||
|
||||
component.discardChanges();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
147
config_app/js/components/markdown/markdown-editor.component.ts
Normal file
147
config_app/js/components/markdown/markdown-editor.component.ts
Normal file
|
@ -0,0 +1,147 @@
|
|||
import { Component, Inject, Input, Output, EventEmitter, ViewChild, HostListener, OnDestroy } from 'ng-metadata/core';
|
||||
import { MarkdownSymbol, BrowserPlatform } from './markdown.module';
|
||||
import './markdown-editor.component.css';
|
||||
|
||||
|
||||
/**
|
||||
* An editing interface for Markdown content.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'markdown-editor',
|
||||
templateUrl: require('./markdown-editor.component.html'),
|
||||
})
|
||||
export class MarkdownEditorComponent implements OnDestroy {
|
||||
|
||||
@Input('<') public content: string;
|
||||
|
||||
@Output() public save: EventEmitter<{editedContent: string}> = new EventEmitter();
|
||||
@Output() public discard: EventEmitter<any> = new EventEmitter();
|
||||
|
||||
// Textarea is public for testability, should not be directly accessed
|
||||
@ViewChild('#markdown-textarea') public textarea: ng.IAugmentedJQuery;
|
||||
|
||||
private editMode: EditMode = "write";
|
||||
|
||||
constructor(@Inject('$document') private $document: ng.IDocumentService,
|
||||
@Inject('$window') private $window: ng.IWindowService,
|
||||
@Inject('BrowserPlatform') private browserPlatform: BrowserPlatform) {
|
||||
this.$window.onbeforeunload = this.onBeforeUnload.bind(this);
|
||||
}
|
||||
|
||||
@HostListener('window:beforeunload', [])
|
||||
public onBeforeUnload(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
this.$window.onbeforeunload = () => null;
|
||||
}
|
||||
|
||||
public changeEditMode(newMode: EditMode): void {
|
||||
this.editMode = newMode;
|
||||
}
|
||||
|
||||
public insertSymbol(event: {symbol: MarkdownSymbol}): void {
|
||||
this.textarea.focus();
|
||||
|
||||
const startPos: number = this.textarea.prop('selectionStart');
|
||||
const endPos: number = this.textarea.prop('selectionEnd');
|
||||
const innerText: string = this.textarea.val().slice(startPos, endPos);
|
||||
var shiftBy: number = 0;
|
||||
var characters: string = '';
|
||||
|
||||
switch (event.symbol) {
|
||||
case 'heading1':
|
||||
characters = '# ';
|
||||
shiftBy = 2;
|
||||
break;
|
||||
case 'heading2':
|
||||
characters = '## ';
|
||||
shiftBy = 3;
|
||||
break;
|
||||
case 'heading3':
|
||||
characters = '### ';
|
||||
shiftBy = 4;
|
||||
break;
|
||||
case 'bold':
|
||||
characters = '****';
|
||||
shiftBy = 2;
|
||||
break;
|
||||
case 'italics':
|
||||
characters = '__';
|
||||
shiftBy = 1;
|
||||
break;
|
||||
case 'bulleted-list':
|
||||
characters = '- ';
|
||||
shiftBy = 2;
|
||||
break;
|
||||
case 'numbered-list':
|
||||
characters = '1. ';
|
||||
shiftBy = 3;
|
||||
break;
|
||||
case 'quote':
|
||||
characters = '> ';
|
||||
shiftBy = 2;
|
||||
break;
|
||||
case 'link':
|
||||
characters = '[](url)';
|
||||
shiftBy = 1;
|
||||
break;
|
||||
case 'code':
|
||||
characters = '``';
|
||||
shiftBy = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
const cursorPos: number = startPos + shiftBy;
|
||||
|
||||
if (startPos != endPos) {
|
||||
this.insertText(`${characters.slice(0, shiftBy)}${innerText}${characters.slice(shiftBy, characters.length)}`,
|
||||
startPos,
|
||||
endPos);
|
||||
}
|
||||
else {
|
||||
this.insertText(characters, startPos, endPos);
|
||||
}
|
||||
|
||||
this.textarea.prop('selectionStart', cursorPos);
|
||||
this.textarea.prop('selectionEnd', cursorPos);
|
||||
}
|
||||
|
||||
public saveChanges(): void {
|
||||
this.save.emit({editedContent: this.content});
|
||||
}
|
||||
|
||||
public discardChanges(): void {
|
||||
if (this.$window.confirm(`Are you sure you want to discard your changes?`)) {
|
||||
this.discard.emit({});
|
||||
}
|
||||
}
|
||||
|
||||
public get currentEditMode(): EditMode {
|
||||
return this.editMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert text in such a way that the browser adds it to the 'undo' stack. This has different feature support
|
||||
* depending on the platform.
|
||||
*/
|
||||
private insertText(text: string, startPos: number, endPos: number): void {
|
||||
if (this.browserPlatform === 'firefox') {
|
||||
// FIXME: Ctrl-Z highlights previous text
|
||||
this.textarea.val(<string>this.textarea.val().substr(0, startPos) +
|
||||
text +
|
||||
<string>this.textarea.val().substr(endPos, this.textarea.val().length));
|
||||
}
|
||||
else {
|
||||
// TODO: Test other platforms (IE...)
|
||||
this.$document[0].execCommand('insertText', false, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Type representing the current editing mode.
|
||||
*/
|
||||
export type EditMode = "write" | "preview";
|
|
@ -0,0 +1,14 @@
|
|||
.markdown-input-container .glyphicon-edit {
|
||||
float: right;
|
||||
color: #ddd;
|
||||
transition: color 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.markdown-input-container .glyphicon-edit:hover {
|
||||
cursor: pointer;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.markdown-input-placeholder-editable:hover {
|
||||
cursor: pointer;
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
<div class="markdown-input-container">
|
||||
<div>
|
||||
<span class="glyphicon glyphicon-edit"
|
||||
ng-if="$ctrl.canWrite && !$ctrl.isEditing"
|
||||
ng-click="$ctrl.editContent()"
|
||||
data-title="Edit {{ ::$ctrl.fieldTitle }}" data-placement="left" bs-tooltip></span>
|
||||
<div ng-if="$ctrl.content && !$ctrl.isEditing">
|
||||
<markdown-view content="$ctrl.content"></markdown-view>
|
||||
</div>
|
||||
<!-- Not set and can write -->
|
||||
<span class="markdown-input-placeholder-editable"
|
||||
ng-if="!$ctrl.content && $ctrl.canWrite"
|
||||
ng-click="$ctrl.editContent()">
|
||||
<i>Click to set {{ ::$ctrl.fieldTitle }}</i>
|
||||
</span>
|
||||
<!-- Not set and cannot write -->
|
||||
<span class="markdown-input-placeholder"
|
||||
ng-if="!$ctrl.content && !$ctrl.canWrite">
|
||||
<i>No {{ ::$ctrl.fieldTitle }} has been set</i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Inline editor -->
|
||||
<div ng-if="$ctrl.isEditing" style="margin-top: 20px;">
|
||||
<markdown-editor content="$ctrl.content"
|
||||
(save)="$ctrl.saveContent($event)"
|
||||
(discard)="$ctrl.discardContent($event)"></markdown-editor>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,34 @@
|
|||
import { MarkdownInputComponent } from './markdown-input.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("MarkdownInputComponent", () => {
|
||||
var component: MarkdownInputComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
component = new MarkdownInputComponent();
|
||||
});
|
||||
|
||||
describe("editContent", () => {
|
||||
|
||||
});
|
||||
|
||||
describe("saveContent", () => {
|
||||
var editedContent: string;
|
||||
|
||||
it("emits output event with changed content", (done) => {
|
||||
editedContent = "# Some markdown here";
|
||||
component.contentChanged.subscribe((event: {content: string}) => {
|
||||
expect(event.content).toEqual(editedContent);
|
||||
done();
|
||||
});
|
||||
|
||||
component.saveContent({editedContent: editedContent});
|
||||
});
|
||||
});
|
||||
|
||||
describe("discardContent", () => {
|
||||
|
||||
});
|
||||
});
|
|
@ -0,0 +1,34 @@
|
|||
import { Component, Input, Output, EventEmitter } from 'ng-metadata/core';
|
||||
import './markdown-input.component.css';
|
||||
|
||||
|
||||
/**
|
||||
* Displays editable Markdown content.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'markdown-input',
|
||||
templateUrl: require('./markdown-input.component.html'),
|
||||
})
|
||||
export class MarkdownInputComponent {
|
||||
|
||||
@Input('<') public content: string;
|
||||
@Input('<') public canWrite: boolean;
|
||||
@Input('@') public fieldTitle: string;
|
||||
|
||||
@Output() public contentChanged: EventEmitter<{content: string}> = new EventEmitter();
|
||||
|
||||
private isEditing: boolean = false;
|
||||
|
||||
public editContent(): void {
|
||||
this.isEditing = true;
|
||||
}
|
||||
|
||||
public saveContent(event: {editedContent: string}): void {
|
||||
this.contentChanged.emit({content: event.editedContent});
|
||||
this.isEditing = false;
|
||||
}
|
||||
|
||||
public discardContent(event: any): void {
|
||||
this.isEditing = false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
.markdown-toolbar-element .dropdown-menu li > * {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.markdown-toolbar-element .dropdown-menu li:hover {
|
||||
cursor: pointer;
|
||||
background-color: #e6e6e6;
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
<div class="markdown-toolbar-element">
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<div class="btn-group" role="group">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-default btn-sm dropdown-toggle"
|
||||
data-toggle="dropdown"
|
||||
data-title="Add header" data-container="body" bs-tooltip>
|
||||
<span class="glyphicon glyphicon-text-size"></span>
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading1'})"><h2>Heading</h2></li>
|
||||
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading2'})"><h3>Heading</h3></li>
|
||||
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading3'})"><h4>Heading</h4></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="Bold" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'bold'})">
|
||||
<span class="glyphicon glyphicon-bold"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="Italics" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'italics'})">
|
||||
<span class="glyphicon glyphicon-italic"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="Block quote" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'quote'})">
|
||||
<i class="fa fa-quote-left" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="Code snippet" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'code'})">
|
||||
<span class="glyphicon glyphicon-menu-left" style="margin-right: -6px;"></span>
|
||||
<span class="glyphicon glyphicon-menu-right"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="URL" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'link'})">
|
||||
<span class="glyphicon glyphicon-link"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="Bulleted list" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'bulleted-list'})">
|
||||
<span class="glyphicon glyphicon-list"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="Numbered list" data-container="body" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'numbered-list'})">
|
||||
<i class="fa fa-list-ol" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,11 @@
|
|||
import { MarkdownToolbarComponent } from './markdown-toolbar.component';
|
||||
import { MarkdownSymbol } from '../../../types/common.types';
|
||||
|
||||
|
||||
describe("MarkdownToolbarComponent", () => {
|
||||
var component: MarkdownToolbarComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
component = new MarkdownToolbarComponent();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
import { Component, Input, Output, EventEmitter } from 'ng-metadata/core';
|
||||
import { MarkdownSymbol } from './markdown.module';
|
||||
import './markdown-toolbar.component.css';
|
||||
|
||||
|
||||
/**
|
||||
* Toolbar containing Markdown symbol shortcuts.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'markdown-toolbar',
|
||||
templateUrl: require('./markdown-toolbar.component.html'),
|
||||
})
|
||||
export class MarkdownToolbarComponent {
|
||||
|
||||
@Input('<') public allowUndo: boolean = true;
|
||||
@Output() public insertSymbol: EventEmitter<{symbol: MarkdownSymbol}> = new EventEmitter();
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
.markdown-view-content {
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-view-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code * {
|
||||
font-family: "Lucida Console", Monaco, monospace;
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
<div class="markdown-view-content"
|
||||
ng-bind-html="$ctrl.convertedHTML"></div>
|
|
@ -0,0 +1,79 @@
|
|||
import { MarkdownViewComponent } from './markdown-view.component';
|
||||
import { SimpleChanges } from 'ng-metadata/core';
|
||||
import { Converter, ConverterOptions } from 'showdown';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("MarkdownViewComponent", () => {
|
||||
var component: MarkdownViewComponent;
|
||||
var markdownConverterMock: Mock<Converter>;
|
||||
var $sceMock: Mock<ng.ISCEService>;
|
||||
var $sanitizeMock: ng.sanitize.ISanitizeService;
|
||||
|
||||
beforeEach(() => {
|
||||
markdownConverterMock = new Mock<Converter>();
|
||||
$sceMock = new Mock<ng.ISCEService>();
|
||||
$sanitizeMock = jasmine.createSpy('$sanitizeSpy').and.callFake((html: string) => html);
|
||||
component = new MarkdownViewComponent(markdownConverterMock.Object, $sceMock.Object, $sanitizeMock);
|
||||
});
|
||||
|
||||
describe("ngOnChanges", () => {
|
||||
var changes: SimpleChanges;
|
||||
var markdown: string;
|
||||
var expectedPlaceholder: string;
|
||||
var markdownChars: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
changes = {};
|
||||
markdown = `## Heading\n Code line\n\n- Item\n> Quote\`code snippet\`\n\nThis is my project!`;
|
||||
expectedPlaceholder = `<p style="visibility:hidden">placeholder</p>`;
|
||||
markdownChars = ['#', '-', '>', '`'];
|
||||
markdownConverterMock.setup(mock => mock.makeHtml).is((text) => text);
|
||||
$sceMock.setup(mock => mock.trustAsHtml).is((html) => html);
|
||||
});
|
||||
|
||||
it("calls markdown converter to convert content to HTML when content is changed", () => {
|
||||
changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
|
||||
});
|
||||
|
||||
it("only converts first line of content to HTML if flag is set when content is changed", () => {
|
||||
component.firstLineOnly = true;
|
||||
changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
const expectedHtml: string = markdown.split('\n')
|
||||
.filter(line => line.indexOf(' ') != 0)
|
||||
.filter(line => line.trim().length != 0)
|
||||
.filter(line => markdownChars.indexOf(line.trim()[0]) == -1)[0];
|
||||
|
||||
expect((<Spy>markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(expectedHtml);
|
||||
});
|
||||
|
||||
it("sets converted HTML to be a placeholder if flag is set and content is empty", () => {
|
||||
component.placeholderNeeded = true;
|
||||
changes['content'] = {currentValue: '', previousValue: '', isFirstChange: () => false};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>markdownConverterMock.Object.makeHtml)).not.toHaveBeenCalled();
|
||||
expect((<Spy>$sceMock.Object.trustAsHtml).calls.argsFor(0)[0]).toEqual(expectedPlaceholder);
|
||||
});
|
||||
|
||||
it("sets converted HTML to empty string if placeholder flag is false and content is empty", () => {
|
||||
changes['content'] = {currentValue: '', previousValue: '', isFirstChange: () => false};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
|
||||
});
|
||||
|
||||
it("calls $sanitize service to sanitize changed HTML content", () => {
|
||||
changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>$sanitizeMock).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
|
||||
});
|
||||
});
|
||||
});
|
48
config_app/js/components/markdown/markdown-view.component.ts
Normal file
48
config_app/js/components/markdown/markdown-view.component.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { Component, Input, Inject, OnChanges, SimpleChanges } from 'ng-metadata/core';
|
||||
import { Converter, ConverterOptions } from 'showdown';
|
||||
import './markdown-view.component.css';
|
||||
|
||||
|
||||
/**
|
||||
* Renders Markdown content to HTML.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'markdown-view',
|
||||
templateUrl: require('./markdown-view.component.html'),
|
||||
})
|
||||
export class MarkdownViewComponent implements OnChanges {
|
||||
|
||||
@Input('<') public content: string;
|
||||
@Input('<') public firstLineOnly: boolean = false;
|
||||
@Input('<') public placeholderNeeded: boolean = false;
|
||||
|
||||
private convertedHTML: string = '';
|
||||
private readonly placeholder: string = `<p style="visibility:hidden">placeholder</p>`;
|
||||
private readonly markdownChars: string[] = ['#', '-', '>', '`'];
|
||||
|
||||
constructor(@Inject('markdownConverter') private markdownConverter: Converter,
|
||||
@Inject('$sce') private $sce: ng.ISCEService,
|
||||
@Inject('$sanitize') private $sanitize: ng.sanitize.ISanitizeService) {
|
||||
|
||||
}
|
||||
|
||||
public ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['content']) {
|
||||
if (!changes['content'].currentValue && this.placeholderNeeded) {
|
||||
this.convertedHTML = this.$sce.trustAsHtml(this.placeholder);
|
||||
} else if (this.firstLineOnly && changes['content'].currentValue) {
|
||||
const firstLine: string = changes['content'].currentValue.split('\n')
|
||||
// Skip code lines
|
||||
.filter(line => line.indexOf(' ') != 0)
|
||||
// Skip empty lines
|
||||
.filter(line => line.trim().length != 0)
|
||||
// Skip control lines
|
||||
.filter(line => this.markdownChars.indexOf(line.trim()[0]) == -1)[0];
|
||||
|
||||
this.convertedHTML = this.$sanitize(this.markdownConverter.makeHtml(firstLine));
|
||||
} else {
|
||||
this.convertedHTML = this.$sanitize(this.markdownConverter.makeHtml(changes['content'].currentValue || ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
111
config_app/js/components/markdown/markdown.module.ts
Normal file
111
config_app/js/components/markdown/markdown.module.ts
Normal file
|
@ -0,0 +1,111 @@
|
|||
import { NgModule } from 'ng-metadata/core';
|
||||
import { Converter } from 'showdown';
|
||||
import * as showdown from 'showdown';
|
||||
import { registerLanguage, highlightAuto } from 'highlight.js/lib/highlight';
|
||||
import 'highlight.js/styles/vs.css';
|
||||
const highlightedLanguages: string[] = require('./highlighted-languages.constant.json');
|
||||
|
||||
/**
|
||||
* A type representing a Markdown symbol.
|
||||
*/
|
||||
export type MarkdownSymbol = 'heading1'
|
||||
| 'heading2'
|
||||
| 'heading3'
|
||||
| 'bold'
|
||||
| 'italics'
|
||||
| 'bulleted-list'
|
||||
| 'numbered-list'
|
||||
| 'quote'
|
||||
| 'code'
|
||||
| 'link'
|
||||
| 'code';
|
||||
|
||||
/**
|
||||
* Type representing current browser platform.
|
||||
* TODO: Add more browser platforms.
|
||||
*/
|
||||
export type BrowserPlatform = "firefox"
|
||||
| "chrome";
|
||||
|
||||
/**
|
||||
* Constant representing current browser platform. Used for determining available features.
|
||||
* TODO Only rudimentary implementation, should prefer specific feature detection strategies instead.
|
||||
*/
|
||||
export const browserPlatform: BrowserPlatform = (() => {
|
||||
if (navigator.userAgent.toLowerCase().indexOf('firefox') != -1) {
|
||||
return 'firefox';
|
||||
}
|
||||
else {
|
||||
return 'chrome';
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Dynamically fetch and register a new language with Highlight.js
|
||||
*/
|
||||
export const addHighlightedLanguage = (language: string): Promise<{}> => {
|
||||
return new Promise(async(resolve, reject) => {
|
||||
try {
|
||||
// TODO(alecmerdler): Use `import()` here instead of `require` after upgrading to TypeScript 2.4
|
||||
const langModule = require(`highlight.js/lib/languages/${language}`);
|
||||
registerLanguage(language, langModule);
|
||||
console.debug(`Language ${language} registered for syntax highlighting`);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.debug(`Language ${language} not supported for syntax highlighting`);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Showdown JS extension for syntax highlighting using Highlight.js. Will attempt to register detected languages.
|
||||
*/
|
||||
export const showdownHighlight = (): showdown.FilterExtension => {
|
||||
const htmlunencode = (text: string) => {
|
||||
return (text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>'));
|
||||
};
|
||||
|
||||
const left = '<pre><code\\b[^>]*>';
|
||||
const right = '</code></pre>';
|
||||
const flags = 'g';
|
||||
const replacement = (wholeMatch: string, match: string, leftSide: string, rightSide: string) => {
|
||||
const language: string = leftSide.slice(leftSide.indexOf('language-') + ('language-').length,
|
||||
leftSide.indexOf('"', leftSide.indexOf('language-')));
|
||||
addHighlightedLanguage(language).catch(error => null);
|
||||
|
||||
match = htmlunencode(match);
|
||||
return leftSide + highlightAuto(match).value + rightSide;
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'output',
|
||||
filter: (text, converter, options) => {
|
||||
return (<any>showdown).helper.replaceRecursiveRegExp(text, replacement, left, right, flags);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// Import default syntax-highlighting supported languages
|
||||
highlightedLanguages.forEach((langName) => addHighlightedLanguage(langName));
|
||||
|
||||
|
||||
/**
|
||||
* Markdown editor and view module.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [],
|
||||
declarations: [],
|
||||
providers: [
|
||||
{provide: 'markdownConverter', useValue: new Converter({extensions: [<any>showdownHighlight]})},
|
||||
{provide: 'BrowserPlatform', useValue: browserPlatform},
|
||||
],
|
||||
})
|
||||
export class MarkdownModule {
|
||||
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
<div class="request-service-key-dialog-element">
|
||||
<!-- Modal message dialog -->
|
||||
<div class="co-dialog modal fade">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" ng-show="!working">×</button>
|
||||
<h4 class="modal-title">Create key for service {{ requestKeyInfo.service }}</h4>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" ng-show="working">
|
||||
<div class="cor-loader"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" ng-show="!working">
|
||||
<!-- Step 0 -->
|
||||
<div ng-show="step == 0">
|
||||
<table class="co-option-table">
|
||||
<tr>
|
||||
<td><input type="radio" id="automaticKey" ng-model="requestKind" value="automatic"></td>
|
||||
<td>
|
||||
<label for="automaticKey">Have the service provide a key</label>
|
||||
<div class="help-text">Recommended for <code>{{ requestKeyInfo.service }}</code> installations where the single instance is setup now.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="radio" id="presharedKey" ng-model="requestKind" value="preshared"></td>
|
||||
<td>
|
||||
<label for="presharedKey">Generate shared key</label>
|
||||
<div class="help-text">Recommended for <code>{{ requestKeyInfo.service }}</code> installations where the instances are dynamically started.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 (automatic) -->
|
||||
<div ng-show="step == 1 && requestKind == 'automatic'" style="text-align: center">
|
||||
<div style="margin-top: 20px;">
|
||||
Please start the <code>{{ requestKeyInfo.service }}</code> service now, configured for <a href="https://github.com/coreos/jwtproxy#autogenerated-private-key" ng-safenewtab>autogenerated private key</a>. The key approval process will continue automatically once the service connects to Quay.
|
||||
</div>
|
||||
<div style="margin-top: 20px;">
|
||||
Waiting for service to connect
|
||||
</div>
|
||||
<div style="margin-top: 10px; margin-bottom: 20px;">
|
||||
<div class="cor-loader-inline"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 (automatic) -->
|
||||
<div ng-show="step == 2 && requestKind == 'automatic'" style="text-align: center">
|
||||
A key for service <code>{{ requestKeyInfo.service }}</code> has been automatically generated, approved and saved in the service's keystore.
|
||||
</div>
|
||||
|
||||
<!-- Step 1 (generate) -->
|
||||
<div ng-show="step == 1 && requestKind == 'preshared'">
|
||||
<form name="createForm" ng-submit="createPresharedKey()">
|
||||
<table class="co-form-table">
|
||||
<tr>
|
||||
<td><label for="create-key-name">Key Name:</label></td>
|
||||
<td>
|
||||
<input class="form-control" name="create-key-name" type="text" ng-model="preshared.name" placeholder="Friendly Key Name">
|
||||
<span class="co-help-text">
|
||||
A friendly name for the key for later reference.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="create-key-expiration">Expiration date (optional):</label></td>
|
||||
<td>
|
||||
<span class="datetime-picker" datetime="preshared.expiration"></span>
|
||||
<span class="co-help-text">
|
||||
The date and time that the key expires. If left blank, the key will never expire.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="create-key-notes">Approval Notes (optional):</label></td>
|
||||
<td>
|
||||
<markdown-input content="preshared.notes"
|
||||
can-write="true"
|
||||
(content-changed)="updateNotes($event.content)"
|
||||
field-title="notes"></markdown-input>
|
||||
<span class="co-help-text">
|
||||
Optional notes for additional human-readable information about why the key was created.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 (generate) -->
|
||||
<div ng-show="step == 2 && requestKind == 'preshared'">
|
||||
<div class="co-alert co-alert-warning">
|
||||
The following key has been generated for service <code>{{ requestKeyInfo.service }}</code>.
|
||||
<br><br>
|
||||
Please copy the key's ID and copy/download the key's private contents and place it in the directory with the service's configuration.
|
||||
<br><br>
|
||||
<strong>Once this dialog is closed this private key will not be accessible anywhere else!</strong>
|
||||
</div>
|
||||
|
||||
<label>Key ID:</label>
|
||||
<div class="copy-box" value="createdKey.kid"></div>
|
||||
|
||||
<label>Private Key (PEM):</label>
|
||||
<textarea class="key-display form-control" onclick="this.focus();this.select()" readonly>{{ createdKey.private_key }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" ng-show="!working">
|
||||
<button type="button" class="btn btn-primary" ng-show="step == 1 && requestKind == 'preshared'"
|
||||
ng-disabled="createForm.$invalid"
|
||||
ng-click="createPresharedKey()">
|
||||
Generate Key
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-primary" ng-show="step == 0 && requestKind == 'preshared'"
|
||||
ng-click="showGenerate()">
|
||||
Continue
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-primary" ng-show="step == 0 && requestKind == 'automatic'"
|
||||
ng-click="startApproval()">
|
||||
Start Approval
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-primary" ng-click="downloadPrivateKey(createdKey)" ng-if="createdKey && isDownloadSupported()">
|
||||
<i class="fa fa-download"></i> Download Private Key
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal" ng-show="step == 2">Close</button>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal" ng-show="step != 2">Cancel</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,125 @@
|
|||
const templateUrl = require('./request-service-key-dialog.html');
|
||||
/**
|
||||
* An element which displays a dialog for requesting or creating a service key.
|
||||
*/
|
||||
angular.module('quay-config').directive('requestServiceKeyDialog', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl,
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'requestKeyInfo': '=requestKeyInfo',
|
||||
'keyCreated': '&keyCreated'
|
||||
},
|
||||
controller: function($scope, $element, $timeout, ApiService) {
|
||||
var handleNewKey = function(key) {
|
||||
var data = {
|
||||
'notes': 'Approved during setup of service ' + key.service
|
||||
};
|
||||
|
||||
var params = {
|
||||
'kid': key.kid
|
||||
};
|
||||
|
||||
ApiService.approveServiceKey(data, params).then(function(resp) {
|
||||
$scope.keyCreated({'key': key});
|
||||
$scope.step = 2;
|
||||
}, ApiService.errorDisplay('Could not approve service key'));
|
||||
};
|
||||
|
||||
var checkKeys = function() {
|
||||
var isShown = ($element.find('.modal').data('bs.modal') || {}).isShown;
|
||||
if (!isShown) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: filter by service.
|
||||
ApiService.listServiceKeys().then(function(resp) {
|
||||
var keys = resp['keys'];
|
||||
for (var i = 0; i < keys.length; ++i) {
|
||||
var key = keys[i];
|
||||
if (key.service == $scope.requestKeyInfo.service && !key.approval && key.rotation_duration) {
|
||||
handleNewKey(key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$timeout(checkKeys, 1000);
|
||||
}, ApiService.errorDisplay('Could not list service keys'));
|
||||
};
|
||||
|
||||
$scope.show = function() {
|
||||
$scope.working = false;
|
||||
$scope.step = 0;
|
||||
$scope.requestKind = null;
|
||||
$scope.preshared = {
|
||||
'name': $scope.requestKeyInfo.service + ' Service Key',
|
||||
'notes': 'Created during setup for service `' + $scope.requestKeyInfo.service + '`'
|
||||
};
|
||||
|
||||
$element.find('.modal').modal({});
|
||||
};
|
||||
|
||||
$scope.hide = function() {
|
||||
$scope.loading = false;
|
||||
$element.find('.modal').modal('hide');
|
||||
};
|
||||
|
||||
$scope.showGenerate = function() {
|
||||
$scope.step = 1;
|
||||
};
|
||||
|
||||
$scope.startApproval = function() {
|
||||
$scope.step = 1;
|
||||
checkKeys();
|
||||
};
|
||||
|
||||
$scope.isDownloadSupported = function() {
|
||||
var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent);
|
||||
if (isSafari) {
|
||||
// Doesn't work properly in Safari, sadly.
|
||||
return false;
|
||||
}
|
||||
|
||||
try { return !!new Blob(); } catch(e) {}
|
||||
return false;
|
||||
};
|
||||
|
||||
$scope.downloadPrivateKey = function(key) {
|
||||
var blob = new Blob([key.private_key]);
|
||||
FileSaver.saveAs(blob, key.service + '.pem');
|
||||
};
|
||||
|
||||
$scope.createPresharedKey = function() {
|
||||
$scope.working = true;
|
||||
|
||||
var data = {
|
||||
'name': $scope.preshared.name,
|
||||
'service': $scope.requestKeyInfo.service,
|
||||
'expiration': $scope.preshared.expiration || null,
|
||||
'notes': $scope.preshared.notes
|
||||
};
|
||||
|
||||
ApiService.createServiceKey(data).then(function(resp) {
|
||||
$scope.working = false;
|
||||
$scope.step = 2;
|
||||
$scope.createdKey = resp;
|
||||
$scope.keyCreated({'key': resp});
|
||||
}, ApiService.errorDisplay('Could not create service key'));
|
||||
};
|
||||
|
||||
$scope.updateNotes = function(content) {
|
||||
$scope.preshared.notes = content;
|
||||
};
|
||||
|
||||
$scope.$watch('requestKeyInfo', function(info) {
|
||||
if (info && info.service) {
|
||||
$scope.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -5,12 +5,17 @@ import { ConfigSetupAppComponent } from './components/config-setup-app/config-se
|
|||
import { DownloadTarballModalComponent } from './components/download-tarball-modal/download-tarball-modal.component';
|
||||
import { LoadConfigComponent } from './components/load-config/load-config.component';
|
||||
import { KubeDeployModalComponent } from './components/kube-deploy-modal/kube-deploy-modal.component';
|
||||
import { MarkdownModule } from './components/markdown/markdown.module';
|
||||
import { MarkdownInputComponent } from './components/markdown/markdown-input.component';
|
||||
import { MarkdownViewComponent } from './components/markdown/markdown-view.component';
|
||||
import { MarkdownToolbarComponent } from './components/markdown/markdown-toolbar.component';
|
||||
import { MarkdownEditorComponent } from './components/markdown/markdown-editor.component';
|
||||
|
||||
const quayDependencies: string[] = [
|
||||
const quayDependencies: any[] = [
|
||||
'restangular',
|
||||
'ngCookies',
|
||||
'angularFileUpload',
|
||||
'ngSanitize'
|
||||
'ngSanitize',
|
||||
];
|
||||
|
||||
@NgModule(({
|
||||
|
@ -41,12 +46,19 @@ function provideConfig($provide: ng.auto.IProvideService,
|
|||
|
||||
|
||||
@NgModule({
|
||||
imports: [ DependencyConfig ],
|
||||
imports: [
|
||||
DependencyConfig,
|
||||
MarkdownModule,
|
||||
],
|
||||
declarations: [
|
||||
ConfigSetupAppComponent,
|
||||
DownloadTarballModalComponent,
|
||||
LoadConfigComponent,
|
||||
KubeDeployModalComponent,
|
||||
MarkdownInputComponent,
|
||||
MarkdownViewComponent,
|
||||
MarkdownToolbarComponent,
|
||||
MarkdownEditorComponent,
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
|
|
|
@ -24,6 +24,9 @@
|
|||
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-sanitize.min.js"></script>
|
||||
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-cookies.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0,d3js@3.3.3"></script>
|
||||
<script src="//cdn.jsdelivr.net/g/momentjs"></script>
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.17.37/js/bootstrap-datetimepicker.min.js"></script>
|
||||
|
||||
|
||||
{% for script_path in main_scripts %}
|
||||
<script src="/static/{{ script_path }}"></script>
|
||||
|
|
|
@ -145,7 +145,7 @@ def set_key_expiration(kid, expiration_date):
|
|||
service_key.save()
|
||||
|
||||
|
||||
def approve_service_key(kid, approver, approval_type, notes=''):
|
||||
def approve_service_key(kid, approval_type, approver=None, notes=''):
|
||||
try:
|
||||
with db_transaction():
|
||||
key = db_for_update(ServiceKey.select().where(ServiceKey.kid == kid)).get()
|
||||
|
|
|
@ -118,7 +118,7 @@ class PreOCIModel(SuperuserDataInterface):
|
|||
|
||||
def approve_service_key(self, kid, approver, approval_type, notes=''):
|
||||
try:
|
||||
key = model.service_keys.approve_service_key(kid, approver, approval_type, notes=notes)
|
||||
key = model.service_keys.approve_service_key(kid, approval_type, approver=approver, notes=notes)
|
||||
return _create_key(key)
|
||||
except model.ServiceKeyDoesNotExist:
|
||||
raise ServiceKeyDoesNotExist
|
||||
|
|
|
@ -161,8 +161,7 @@ def __generate_service_key(kid, name, user, timestamp, approval_type, expiration
|
|||
rotation_duration=rotation_duration)
|
||||
|
||||
if approval_type is not None:
|
||||
model.service_keys.approve_service_key(key.kid, user, approval_type,
|
||||
notes='The **test** approval')
|
||||
model.service_keys.approve_service_key(key.kid, approval_type, notes='The **test** approval')
|
||||
|
||||
key_metadata = {
|
||||
'kid': kid,
|
||||
|
@ -820,7 +819,7 @@ def populate_database(minimal=False, with_storage=False):
|
|||
key = model.service_keys.create_service_key('test_service_key', 'test_service_key', 'quay',
|
||||
_TEST_JWK, {}, None)
|
||||
|
||||
model.service_keys.approve_service_key(key.kid, new_user_1, ServiceKeyApprovalType.SUPERUSER,
|
||||
model.service_keys.approve_service_key(key.kid, ServiceKeyApprovalType.SUPERUSER,
|
||||
notes='Test service key for local/test registry testing')
|
||||
|
||||
# Add an app specific token.
|
||||
|
|
|
@ -559,7 +559,7 @@ class KeyServerTestCase(EndpointTestCase):
|
|||
}, data=jwk, expected_code=403)
|
||||
|
||||
# Approve the key.
|
||||
model.service_keys.approve_service_key('kid420', 1, ServiceKeyApprovalType.SUPERUSER)
|
||||
model.service_keys.approve_service_key('kid420', ServiceKeyApprovalType.SUPERUSER, approver=1)
|
||||
|
||||
# Rotate that new key
|
||||
with assert_action_logged('service_key_rotate'):
|
||||
|
@ -598,7 +598,7 @@ class KeyServerTestCase(EndpointTestCase):
|
|||
def test_attempt_delete_service_key_with_expired_key(self):
|
||||
# Generate two keys, approving the first.
|
||||
private_key, _ = model.service_keys.generate_service_key('sample_service', None, kid='first')
|
||||
model.service_keys.approve_service_key('first', 1, ServiceKeyApprovalType.SUPERUSER)
|
||||
model.service_keys.approve_service_key('first', ServiceKeyApprovalType.SUPERUSER, approver=1)
|
||||
model.service_keys.generate_service_key('sample_service', None, kid='second')
|
||||
|
||||
# Mint a JWT with our test payload
|
||||
|
@ -661,7 +661,7 @@ class KeyServerTestCase(EndpointTestCase):
|
|||
expected_code=403, service='sample_service', kid='kid321')
|
||||
|
||||
# Approve the second key.
|
||||
model.service_keys.approve_service_key('kid123', 1, ServiceKeyApprovalType.SUPERUSER)
|
||||
model.service_keys.approve_service_key('kid123', ServiceKeyApprovalType.SUPERUSER, approver=1)
|
||||
|
||||
# Using the credentials of our approved key, delete our unapproved key
|
||||
with assert_action_logged('service_key_delete'):
|
||||
|
|
|
@ -18,8 +18,7 @@ def generate_key(service, name, expiration_date=None, notes=None):
|
|||
metadata=metadata,
|
||||
name=name)
|
||||
# Auto-approve the service key.
|
||||
model.service_keys.approve_service_key(key.kid, None, ServiceKeyApprovalType.AUTOMATIC,
|
||||
notes=notes or '')
|
||||
model.service_keys.approve_service_key(key.kid, ServiceKeyApprovalType.AUTOMATIC, notes=notes or '')
|
||||
|
||||
# Log the creation and auto-approval of the service key.
|
||||
key_log_metadata = {
|
||||
|
|
Reference in a new issue