From 1bf25f25c1b76bf9d45242982438a9cfb59c168f Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Sun, 4 Jan 2015 14:38:41 -0500 Subject: [PATCH] WIP --- app.py | 22 +- auth/permissions.py | 3 + config.py | 15 +- data/database.py | 5 + endpoints/api/__init__.py | 2 + endpoints/api/suconfig.py | 273 ++++++++ external_libraries.py | 6 +- static/css/quay.css | 22 + .../directives/config/config-setup-tool.html | 84 +-- static/js/controllers.js | 149 +++++ static/js/core-config-setup.js | 11 +- static/partials/super-user.html | 616 +++++++++++------- test/data/test.db | Bin 251904 -> 251904 bytes util/configutil.py | 70 ++ 14 files changed, 942 insertions(+), 336 deletions(-) create mode 100644 endpoints/api/suconfig.py create mode 100644 util/configutil.py diff --git a/app.py b/app.py index cdac98a27..0733cdd8a 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,6 @@ import logging import os import json -import yaml from flask import Flask as BaseFlask, Config as BaseConfig, request, Request from flask.ext.principal import Principal @@ -20,6 +19,7 @@ from util.exceptionlog import Sentry from util.queuemetrics import QueueMetrics from util.names import urn_generator from util.oauth import GoogleOAuthConfig, GithubOAuthConfig +from util.configutil import import_yaml from data.billing import Billing from data.buildlogs import BuildLogs from data.archivedlogs import LogArchive @@ -32,18 +32,7 @@ class Config(BaseConfig): """ Flask config enhanced with a `from_yamlfile` method """ def from_yamlfile(self, config_file): - with open(config_file) as f: - c = yaml.load(f) - if not c: - logger.debug('Empty YAML config file') - return - - if isinstance(c, str): - raise Exception('Invalid YAML config file: ' + str(c)) - - for key in c.iterkeys(): - if key.isupper(): - self[key] = c[key] + import_yaml(self, config_file) class Flask(BaseFlask): """ Extends the Flask class to implement our custom Config class. """ @@ -53,11 +42,12 @@ class Flask(BaseFlask): return Config(root_path, self.default_config) -OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml' -OVERRIDE_CONFIG_PY_FILENAME = 'conf/stack/config.py' +OVERRIDE_CONFIG_DIRECTORY = 'conf/stack/' +OVERRIDE_CONFIG_YAML_FILENAME = OVERRIDE_CONFIG_DIRECTORY + 'config.yaml' +OVERRIDE_CONFIG_PY_FILENAME = OVERRIDE_CONFIG_DIRECTORY + 'config.py' OVERRIDE_CONFIG_KEY = 'QUAY_OVERRIDE_CONFIG' -LICENSE_FILENAME = 'conf/stack/license.enc' +LICENSE_FILENAME = OVERRIDE_CONFIG_DIRECTORY + 'license.enc' app = Flask(__name__) diff --git a/auth/permissions.py b/auth/permissions.py index ae398092d..efa135155 100644 --- a/auth/permissions.py +++ b/auth/permissions.py @@ -90,6 +90,9 @@ class QuayDeferredPermissionUser(Identity): logger.debug('Loading user permissions after deferring.') user_object = model.get_user_by_uuid(self.id) + if user_object is None: + return super(QuayDeferredPermissionUser, self).can(permission) + # Add the superuser need, if applicable. if (user_object.username is not None and user_object.username in app.config.get('SUPER_USERS', [])): diff --git a/config.py b/config.py index e475be9c7..d2aeeb907 100644 --- a/config.py +++ b/config.py @@ -48,8 +48,9 @@ class DefaultConfig(object): AVATAR_KIND = 'local' - REGISTRY_TITLE = 'Quay.io' - REGISTRY_TITLE_SHORT = 'Quay.io' + REGISTRY_TITLE = 'CoreOS Enterprise Registry' + REGISTRY_TITLE_SHORT = 'Enterprise Registry' + CONTACT_INFO = [ 'mailto:support@quay.io', 'irc://chat.freenode.net:6665/quayio', @@ -132,6 +133,9 @@ class DefaultConfig(object): # Super user config. Note: This MUST BE an empty list for the default config. SUPER_USERS = [] + # Feature Flag: Whether super users are supported. + FEATURE_SUPER_USERS = True + # Feature Flag: Whether billing is required. FEATURE_BILLING = False @@ -147,9 +151,6 @@ class DefaultConfig(object): # Feature flag, whether to enable olark chat FEATURE_OLARK_CHAT = False - # Feature Flag: Whether super users are supported. - FEATURE_SUPER_USERS = False - # Feature Flag: Whether to support GitHub build triggers. FEATURE_GITHUB_BUILD = False @@ -194,4 +195,6 @@ class DefaultConfig(object): SYSTEM_SERVICES_PATH = "conf/init/" # Services that should not be shown in the logs view. - SYSTEM_SERVICE_BLACKLIST = ['tutumdocker', 'dockerfilebuild'] \ No newline at end of file + SYSTEM_SERVICE_BLACKLIST = ['tutumdocker', 'dockerfilebuild'] + + DEBUGGING = True \ No newline at end of file diff --git a/data/database.py b/data/database.py index b49c8a594..ab23b3d7d 100644 --- a/data/database.py +++ b/data/database.py @@ -70,6 +70,11 @@ read_slave = Proxy() db_random_func = CallableProxy() +def validate_database_url(url): + driver = _db_from_url(url, {}) + driver.connect() + driver.close() + def _db_from_url(url, db_kwargs): parsed_url = make_url(url) diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 821a18f05..f2c7bc663 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -385,8 +385,10 @@ import endpoints.api.repoemail import endpoints.api.repotoken import endpoints.api.robot import endpoints.api.search +import endpoints.api.suconfig import endpoints.api.superuser import endpoints.api.tag import endpoints.api.team import endpoints.api.trigger import endpoints.api.user + diff --git a/endpoints/api/suconfig.py b/endpoints/api/suconfig.py new file mode 100644 index 000000000..97ce96b7b --- /dev/null +++ b/endpoints/api/suconfig.py @@ -0,0 +1,273 @@ +import logging +import os +import json + +from flask import abort +from endpoints.api import (ApiResource, nickname, resource, internal_only, show_if, hide_if, + require_fresh_login, request, validate_json_request) + +from endpoints.common import common_login +from app import app, OVERRIDE_CONFIG_YAML_FILENAME, OVERRIDE_CONFIG_DIRECTORY +from data import model +from data.database import User, validate_database_url +from auth.permissions import SuperUserPermission +from auth.auth_context import get_authenticated_user +from util.configutil import (import_yaml, export_yaml, add_enterprise_config_defaults, + set_config_value) + +import features + +logger = logging.getLogger(__name__) + +CONFIG_FILE_WHITELIST = ['ssl.key', 'ssl.cert'] + +def database_is_valid(): + try: + User.select().limit(1) + return True + except: + return False + + +def database_has_users(): + return bool(list(User.select().limit(1))) + + +@resource('/v1/superuser/registrystatus') +@internal_only +@show_if(features.SUPER_USERS) +@hide_if(features.BILLING) # Make sure it is never allowed in prod. +class SuperUserRegistryStatus(ApiResource): + """ Resource for determining the status of the registry, such as if config exists, + if a database is configured, and if it has any defined users. + """ + @nickname('scRegistryStatus') + def get(self): + """ Returns whether a valid configuration, database and users exist. """ + current_user = get_authenticated_user() + + return { + 'dir_exists': os.path.exists(OVERRIDE_CONFIG_DIRECTORY), + 'file_exists': os.path.exists(OVERRIDE_CONFIG_YAML_FILENAME), + 'is_testing': app.config['TESTING'], + 'valid_db': database_is_valid(), + 'ready': current_user and current_user.username in app.config['SUPER_USERS'] + } + + +@resource('/v1/superuser/config') +@internal_only +@show_if(features.SUPER_USERS) +@hide_if(features.BILLING) # Make sure it is never allowed in prod. +class SuperUserGetConfig(ApiResource): + """ Resource for fetching and updating the current configuration, if any. """ + schemas = { + 'UpdateConfig': { + 'id': 'UpdateConfig', + 'type': 'object', + 'description': 'Updates the YAML config file', + 'required': [ + 'config' + ], + 'properties': { + 'config': { + 'type': 'object' + } + }, + }, + } + + @require_fresh_login + @nickname('scGetConfig') + def get(self): + """ Returns the currently defined configuration, if any. """ + if SuperUserPermission().can(): + config_object = {} + try: + import_yaml(config_object, OVERRIDE_CONFIG_YAML_FILENAME) + except Exception: + config_object = None + + return { + 'config': config_object + } + + abort(403) + + @nickname('scUpdateConfig') + @validate_json_request('UpdateConfig') + def put(self): + """ Updates the config.yaml file. """ + # Note: This method is called to set the database configuration before super users exists, + # so we also allow it to be called if there is no valid registry configuration setup. + if not os.path.exists(OVERRIDE_CONFIG_YAML_FILENAME) or SuperUserPermission().can(): + config_object = request.get_json()['config'] + + # Add any enterprise defaults missing from the config. + add_enterprise_config_defaults(config_object) + + # Write the configuration changes to the YAML file. + export_yaml(config_object, OVERRIDE_CONFIG_YAML_FILENAME) + + return { + 'exists': True, + 'config': config_object + } + + abort(403) + + +@resource('/v1/superuser/config/file/') +@internal_only +@show_if(features.SUPER_USERS) +@hide_if(features.BILLING) # Make sure it is never allowed in prod. +class SuperUserConfigFile(ApiResource): + """ Resource for fetching the status of config files and overriding them. """ + @nickname('scConfigFileExists') + def get(self, filename): + """ Returns whether the configuration file with the given name exists. """ + if not filename in CONFIG_FILE_WHITELIST: + abort(404) + + if SuperUserPermission().can(): + return { + 'exists': os.path.exists(os.path.join(OVERRIDE_CONFIG_DIRECTORY, filename)) + } + + abort(403) + + @nickname('scUpdateConfigFile') + def post(self, filename): + """ Updates the configuration file with the given name. """ + if not filename in CONFIG_FILE_WHITELIST: + abort(404) + + if SuperUserPermission().can(): + uploaded_file = request.files['file'] + if not uploaded_file: + abort(404) + + uploaded_file.save(os.path.join(OVERRIDE_CONFIG_DIRECTORY, filename)) + return { + 'status': True + } + + abort(403) + + +@resource('/v1/superuser/config/createsuperuser') +@internal_only +@show_if(features.SUPER_USERS) +@hide_if(features.BILLING) # Make sure it is never allowed in prod. +class SuperUserCreateInitialSuperUser(ApiResource): + """ Resource for creating the initial super user. """ + schemas = { + 'CreateSuperUser': { + 'id': 'CreateSuperUser', + 'type': 'object', + 'description': 'Information for creating the initial super user', + 'required': [ + 'username', + 'password', + 'email' + ], + 'properties': { + 'username': { + 'type': 'string', + 'description': 'The username for the superuser' + }, + 'password': { + 'type': 'string', + 'description': 'The password for the superuser' + }, + 'email': { + 'type': 'string', + 'description': 'The e-mail address for the superuser' + }, + }, + }, + } + + @nickname('scCreateInitialSuperuser') + @validate_json_request('CreateSuperUser') + def post(self): + """ Creates the initial super user, updates the underlying configuration and + sets the current session to have that super user. """ + + # Special security check: This method is only accessible when: + # - There is a valid config YAML file. + # - There are currently no users in the database (clean install) + # + # We do this special security check because at the point this method is called, the database + # is clean but does not (yet) have any super users for our permissions code to check against. + if os.path.exists(OVERRIDE_CONFIG_YAML_FILENAME) and not database_has_users(): + data = request.get_json() + username = data['username'] + password = data['password'] + email = data['email'] + + # Create the user in the database. + superuser = model.create_user(username, password, email, auto_verify=True) + + # Add the user to the config. + set_config_value(OVERRIDE_CONFIG_YAML_FILENAME, 'SUPER_USERS', [username]) + app.config['SUPER_USERS'] = [username] + + # Conduct login with that user. + common_login(superuser) + + return { + 'status': True + } + + + abort(403) + + +@resource('/v1/superuser/config/validate/') +@internal_only +@show_if(features.SUPER_USERS) +@hide_if(features.BILLING) # Make sure it is never allowed in prod. +class SuperUserConfigValidate(ApiResource): + """ Resource for validating a block of configuration against an external service. """ + schemas = { + 'ValidateConfig': { + 'id': 'ValidateConfig', + 'type': 'object', + 'description': 'Validates configuration', + 'required': [ + 'config' + ], + 'properties': { + 'config': { + 'type': 'object' + } + }, + }, + } + + @nickname('scValidateConfig') + @validate_json_request('ValidateConfig') + def post(self, service): + """ Validates the given config for the given service. """ + # Note: This method is called to validate the database configuration before super users exists, + # so we also allow it to be called if there is no valid registry configuration setup. Note that + # this is also safe since this method does not access any information not given in the request. + if not os.path.exists(OVERRIDE_CONFIG_YAML_FILENAME) or SuperUserPermission().can(): + config = request.get_json()['config'] + if service == 'database': + try: + validate_database_url(config['DB_URI']) + return { + 'status': True + } + except Exception as ex: + logger.exception('Could not validate database') + return { + 'status': False, + 'reason': str(ex) + } + + return {} + + abort(403) \ No newline at end of file diff --git a/external_libraries.py b/external_libraries.py index 7e071424c..3fa48c44a 100644 --- a/external_libraries.py +++ b/external_libraries.py @@ -24,9 +24,9 @@ EXTERNAL_CSS = [ ] EXTERNAL_FONTS = [ - 'netdna.bootstrapcdn.com/font-awesome/4.0.3/fonts/fontawesome-webfont.woff?v=4.0.3', - 'netdna.bootstrapcdn.com/font-awesome/4.0.3/fonts/fontawesome-webfont.ttf?v=4.0.3', - 'netdna.bootstrapcdn.com/font-awesome/4.0.3/fonts/fontawesome-webfont.svg?v=4.0.3', + 'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.woff?v=4.2.0', + 'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.ttf?v=4.2.0', + 'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.svg?v=4.2.0', ] diff --git a/static/css/quay.css b/static/css/quay.css index ac6d205fe..d085ab206 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -4896,3 +4896,25 @@ i.slack-icon { .system-log-download-panel a { margin-top: 20px; } + +.initial-setup-modal .quay-spinner { + vertical-align: middle; + margin-right: 10px; + display: inline-block; +} + +.initial-setup-modal .valid-database p { + font-size: 18px; +} + +.initial-setup-modal .valid-database .verified { + font-size: 16px; + margin-bottom: 16px; +} + +.initial-setup-modal .valid-database .verified i.fa { + font-size: 26px; + margin-right: 10px; + vertical-align: middle; + color: rgb(53, 186, 53); +} diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html index 4fd2bda7b..ffd51bf3d 100644 --- a/static/directives/config/config-setup-tool.html +++ b/static/directives/config/config-setup-tool.html @@ -6,19 +6,6 @@
- - - - -
Secret Key: - -
- This should be a UUID or some other secret string -
-
- -
Enterprise Logo URL: @@ -140,73 +127,6 @@ - - -
-
- Database -
-
- - -
-

A MySQL RDBMS or Postgres installation with an empty database is required. The schema will be created the first time the registry image is run with valid configuration.

-
- - -
- - - - - - - - - - - - - - - - - - - - - - -
Database Type: - -
Database Server: - -
Database Name: - -
Username: - -
The user must have full access to the database
-
Password: - -
-
- -
-
-
-
-
@@ -448,11 +368,11 @@
Authentication:
- +
- +
Username: diff --git a/static/js/controllers.js b/static/js/controllers.js index eb0d99916..6d9dca926 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -2818,6 +2818,7 @@ function SuperUserAdminCtrl($scope, $timeout, ApiService, Features, UserService, // Monitor any user changes and place the current user into the scope. UserService.updateUserIn($scope); + $scope.configStatus = null; $scope.logsCounter = 0; $scope.newUser = {}; $scope.createdUser = null; @@ -2993,6 +2994,154 @@ function SuperUserAdminCtrl($scope, $timeout, ApiService, Features, UserService, }, ApiService.errorDisplay('Cannot send recovery email')) }; + + $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 '' }; + + try { + var uri = URI(); + 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.configStep = 'creating-superuser'; + ApiService.scCreateInitialSuperuser($scope.superUser, null).then(function(resp) { + UserService.load(); + $('#createSuperuserModal').modal('hide'); + $scope.checkContainerStatus(); + }, ApiService.errorDisplay('Could not create superuser')); + }; + + $scope.checkContainerStatus = function() { + var errorHandler = function(resp) { + if (resp.status == 404 && $scope.configStep == 'valid-database') { + // Container has not yet come back up, so we schedule another check. + $scope.waitForValidConfig(); + return; + } + + return ApiService.errorDisplay('Cannot load status. Please report this to support')(resp); + }; + + ApiService.scRegistryStatus(null, null).then(function(resp) { + $scope.configStatus = resp; + + // !dir_exists -> No mounted directory. + if (!$scope.configStatus.dir_exists) { + bootbox.dialog({ + "message": "No volume was found mounted at path /conf/stack. " + + "Please rerun the container with the volume mounted and refresh this page." + + "

For more information: " + + "" + + "Enterprise Registry Setup Guide", + "title": "Missing mounted configuration volume", + "buttons": {}, + "closeButton": false + }); + return; + } + + // is_testing = False -> valid config + // ready = False -> no valid superusers yet + if (!$scope.configStatus.is_testing && !$scope.configStatus.ready) { + $('#initializeConfigModal').modal('hide'); + + $scope.superUser = {}; + $scope.configStep = 'create-superuser'; + $('#createSuperuserModal').modal({ + keyboard: false, + backdrop: 'static' + }); + return; + } + + // file_exists -> config file, but possibly invalid DB + // valid_db = False -> invalid DB + // is_testing = True -> still in testing mode + if (!$scope.configStatus.file_exists || !$scope.configStatus.valid_db || + $scope.configStatus.is_testing) { + $('#createSuperuserModal').modal('hide'); + + $scope.databaseUri = ''; + $scope.configStep = 'enter-database'; + + // Handle the case where they have entered a valid DB config, refreshed, but have not + // yet restarted the DB container. + if ($scope.configStatus.file_exists && $scope.configStatus.is_testing) { + $scope.waitForValidConfig(); + } + + $('#initializeConfigModal').modal({ + keyboard: false, + backdrop: 'static' + }); + return; + } + }, errorHandler, /* background */true); + }; + + $scope.waitForValidConfig = function() { + $scope.configStep = 'valid-database'; + $timeout(function() { + $scope.checkContainerStatus(); + }, 3000); + }; + + $scope.validateDatabase = function() { + $scope.configStep = 'validating-database'; + $scope.databaseInvalid = null; + + var data = { + 'config': { + 'DB_URI': $scope.databaseUri + } + }; + + var params = { + 'service': 'database' + }; + + ApiService.scValidateConfig(data, params).then(function(resp) { + var status = resp.status; + + if (status) { + $scope.configStep = 'updating-config'; + ApiService.scUpdateConfig(data, null).then(function(resp) { + $scope.waitForValidConfig(); + }, ApiService.errorDisplay('Cannot update config. Please report this to support')); + } else { + $scope.configStep = 'invalid-database'; + $scope.databaseInvalid = resp.reason; + } + }, ApiService.errorDisplay('Cannot validate database. Please report this to support')); + }; + + // Load the configuration status. + $scope.checkContainerStatus(); } function TourCtrl($scope, $location) { diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js index d540e160b..366101d8d 100644 --- a/static/js/core-config-setup.js +++ b/static/js/core-config-setup.js @@ -78,10 +78,11 @@ angular.module("core-config-setup", ['angularFileUpload']) $transclude(function(clone, scope) { $scope.childScope = scope; + $scope.childScope['fields'] = {}; $element.append(clone); }); - $scope.childScope.$watch(function(value) { + $scope.childScope.$watch('fields', function(value) { // Note: We need the timeout here because Angular starts the digest of the // parent scope AFTER the child scope, which means it can end up one action // behind. The timeout ensures that the parent scope will be fully digest-ed @@ -89,13 +90,13 @@ angular.module("core-config-setup", ['angularFileUpload']) $timeout(function() { $scope.binding = $scope.serializer({'fields': value}); }); - }); + }, true); $scope.$watch('binding', function(value) { var parsed = $scope.parser({'value': value}); for (var key in parsed) { if (parsed.hasOwnProperty(key)) { - $scope.childScope[key] = parsed[key]; + $scope.childScope['fields'][key] = parsed[key]; } } }); @@ -240,7 +241,7 @@ angular.module("core-config-setup", ['angularFileUpload']) $scope.uploadProgress = 0; $scope.upload = $upload.upload({ - url: '/api/v1/configfile', + url: '/api/v1/superuser/config/file', method: 'POST', data: { filename: $scope.filename }, file: files[0], @@ -257,7 +258,7 @@ angular.module("core-config-setup", ['angularFileUpload']) }; var loadStatus = function(filename) { - Restangular.one('configfile/' + filename).get().then(function(resp) { + Restangular.one('superuser/config/file/' + filename).get().then(function(resp) { $scope.hasFile = resp['exists']; }); }; diff --git a/static/partials/super-user.html b/static/partials/super-user.html index 2d4c6db52..1ba95a39e 100644 --- a/static/partials/super-user.html +++ b/static/partials/super-user.html @@ -1,265 +1,433 @@ -
-
- - Enterprise Registry Management -
+
+
+
+
+ + Enterprise Registry Management +
-
-
- - - - - - - - - - - - - - - -
+
+
+ + + + + + + + + + + + + + + +
-
- -
-
-
- - -
-
- -
- - - -
- Select a service above to view its local logs - - -
-
-
-
- - -
-
-
- - -
-
-
- - -
- You have deployed more repositories than your plan allows. Please - upgrade your subscription by contacting CoreOS Sales. +
+ +
+
-
- You are at your current plan's number of allowed repositories. It might be time to think about - upgrading your subscription by contacting CoreOS Sales. -
+ +
+
-
- You are nearing the number of allowed deployed repositories. It might be time to think about - upgrading your subscription by contacting CoreOS Sales. -
+
+ + - For more information: See Here. -
+
+ Select a service above to view its local logs - -
-
-
- {{ usersError }} -
-
-
-
- Showing {{(users | filter:search | limitTo:100).length}} of - {{(users | filter:search).length}} matching users + -
- +
+
+
+
+ + +
+
+
+ + +
+
+
+ + +
+ You have deployed more repositories than your plan allows. Please + upgrade your subscription by contacting CoreOS Sales. +
+ +
+ You are at your current plan's number of allowed repositories. It might be time to think about + upgrading your subscription by contacting CoreOS Sales. +
+ +
+ You are nearing the number of allowed deployed repositories. It might be time to think about + upgrading your subscription by contacting CoreOS Sales. +
+ + For more information: See Here. +
+ + +
+
+
+ {{ usersError }} +
+
+
+
+ Showing {{(users | filter:search | limitTo:100).length}} of + {{(users | filter:search).length}} matching users +
+
+ +
+
- -
- - - - - - - - - - - - - - -
UsernameE-mail address
- - - - - You - - - Superuser - - - {{ current_user.username }} - - {{ current_user.email }} - - - - Change Password - - - Send Recovery Email - - - Delete User - - -
-
-
-
-
- - - - - - - -
- - -
+ +