diff --git a/data/model.py b/data/model.py index d6d3c9154..855e85a2d 100644 --- a/data/model.py +++ b/data/model.py @@ -4,8 +4,7 @@ import dateutil.parser import operator from database import * -from util.validation import (validate_email, validate_username, - validate_password) +from util.validation import * logger = logging.getLogger(__name__) @@ -15,18 +14,50 @@ class DataModelException(Exception): pass -def create_user(username, password, email): - pw_hash = bcrypt.hashpw(password, bcrypt.gensalt()) +class InvalidEmailAddressException(DataModelException): + pass + +class InvalidUsernameException(DataModelException): + pass + + +class InvalidPasswordException(DataModelException): + pass + + +def create_user(username, password, email): if not validate_email(email): - raise DataModelException('Invalid email address: %s' % email) + raise InvalidEmailAddressException('Invalid email address: %s' % email) if not validate_username(username): - raise DataModelException('Invalid username: %s' % username) - if not validate_password(password): - raise DataModelException('Invalid password, password must be at least ' + - '8 characters and contain no whitespace.') + raise InvalidUsernameException('Invalid username: %s' % username) + + # We allow password none for the federated login case. + if password is not None and not validate_password(password): + raise InvalidPasswordException(INVALID_PASSWORD_MESSAGE) try: + existing = User.get((User.username == username) | (User.email == email)) + + logger.debug('Existing user with same username or email.') + + # A user already exists with either the same username or email + if existing.username == username: + raise InvalidUsernameException('Username has already been taken: %s' % + username) + raise InvalidEmailAddressException('Email has already been used: %s' % + email) + + except User.DoesNotExist: + # This is actually the happy path + logger.debug('Email and username are unique!') + pass + + try: + pw_hash = None + if password is not None: + pw_hash = bcrypt.hashpw(password, bcrypt.gensalt()) + new_user = User.create(username=username, password_hash=pw_hash, email=email) return new_user @@ -35,18 +66,16 @@ def create_user(username, password, email): def create_federated_user(username, email, service_name, service_id): - try: - new_user = User.create(username=username, email=email, verified=True) + new_user = create_user(username, None, email) + new_user.verified = True + new_user.save() + service = LoginService.get(LoginService.name == service_name) federated_user = FederatedLogin.create(user=new_user, service=service, service_ident=service_id) return new_user - except Exception as ex: - raise DataModelException(ex.message) - - def verify_federated_login(service_name, service_id): selected = FederatedLogin.select(FederatedLogin, User) with_service = selected.join(LoginService) @@ -98,7 +127,9 @@ def verify_user(username, password): except User.DoesNotExist: return None - if bcrypt.hashpw(password, fetched.password_hash) == fetched.password_hash: + if (fetched.password_hash and + bcrypt.hashpw(password, fetched.password_hash) == + fetched.password_hash): return fetched # We weren't able to authorize the user @@ -176,6 +207,9 @@ def get_matching_repositories(repo_term, username=None): def change_password(user, new_password): + if not validate_password(new_password): + raise InvalidPasswordException(INVALID_PASSWORD_MESSAGE) + pw_hash = bcrypt.hashpw(new_password, bcrypt.gensalt()) user.password_hash = pw_hash user.save() diff --git a/endpoints/api.py b/endpoints/api.py index 0e5a9cdd5..db8355b54 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -51,8 +51,35 @@ def get_logged_in_user(): 'username': user.username, 'email': user.email, 'gravatar': compute_hash(user.email), + 'askForPassword': user.password_hash is None, }) +@app.route('/api/user/', methods=['PUT']) +@api_login_required +def change_user_details(): + user = current_user.db_user + + user_data = request.get_json(); + + try: + if user_data['password']: + logger.debug('Changing password for user: %s', user.username) + model.change_password(user, user_data['password']) + except model.InvalidPasswordException, ex: + error_resp = jsonify({ + 'message': ex.message, + }) + error_resp.status_code = 400 + return error_resp + + return jsonify({ + 'verified': user.verified, + 'anonymous': False, + 'username': user.username, + 'email': user.email, + 'gravatar': compute_hash(user.email), + 'askForPassword': user.password_hash is None, + }) @app.route('/api/user/', methods=['POST']) def create_user_api(): @@ -60,7 +87,7 @@ def create_user_api(): existing_user = model.get_user(user_data['username']) if existing_user: error_resp = jsonify({ - 'message': 'The username already exists' + 'message': 'The username already exists' }) error_resp.status_code = 400 return error_resp @@ -72,13 +99,8 @@ def create_user_api(): send_confirmation_email(new_user.username, new_user.email, code.code) return make_response('Created', 201) except model.DataModelException as ex: - message = ex.message - m = re.search('column ([a-zA-Z]+) is not unique', message) - if m and m.group(1): - message = m.group(1) + ' already exists' - error_resp = jsonify({ - 'message': message, + 'message': ex.message, }) error_resp.status_code = 400 return error_resp diff --git a/endpoints/web.py b/endpoints/web.py index f253264b5..d50f15b2c 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -7,7 +7,7 @@ from flask.ext.login import login_user, UserMixin, login_required, logout_user from flask.ext.principal import identity_changed, Identity, AnonymousIdentity from data import model -from app import app, login_manager +from app import app, login_manager, mixpanel logger = logging.getLogger(__name__) @@ -34,8 +34,9 @@ def load_user(username): return None -@app.route('/', methods=['GET']) -def index(): +@app.route('/', methods=['GET'], defaults={'path': ''}) +@app.route('/') # Catch all +def index(path): return send_file('templates/index.html') @@ -135,14 +136,26 @@ def github_oauth_callback(): to_login = model.verify_federated_login('github', github_id) if not to_login: # try to create the user - to_login = model.create_federated_user(username, found_email, 'github', - github_id) + + try: + to_login = model.create_federated_user(username, found_email, 'github', + github_id) + except model.DataModelException, ex: + return render_template('githuberror.html', error_message=ex.message) if common_login(to_login): + # Success + mixpanel.track(to_login.username, 'register', {'service': 'github'}) + + state = request.args.get('state', None) + if state: + logger.debug('Aliasing with state: %s' % state) + mixpanel.alias(to_login.username, state) + return redirect(url_for('index')) # TODO something bad happened, we need to tell the user somehow - return redirect(url_for('signin')) + return render_template('githuberror.html') @app.route('/confirm', methods=['GET']) diff --git a/initdb.py b/initdb.py index cf795a0a0..435eca603 100644 --- a/initdb.py +++ b/initdb.py @@ -72,7 +72,7 @@ if __name__ == '__main__': logger.debug('Populating the DB with test data.') new_user_1 = model.create_user('devtable', 'password', - 'jake@devtable.com') + 'jschorr@devtable.com') new_user_1.verified = True new_user_1.save() diff --git a/static/css/quay.css b/static/css/quay.css index fa7c7f23f..dac9aa59a 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -115,7 +115,7 @@ margin-top: -20px; margin-bottom: 0px; - padding-top: 66px; + padding-top: 46px; min-height: 440px; } @@ -137,11 +137,36 @@ margin-left: 0px; } -.form-signup input.ng-invalid.ng-dirty { +.signin-buttons { + text-align: center; +} + +.landing-signup-button { + margin-bottom: 10px; +} + +.landing-social-alternate { + color: #777; + font-size: 2em; + margin-left: 43px; + line-height: 1em; +} + +.landing-social-alternate .inner-text { + text-align: center; + position: relative; + color: white; + left: -43px; + top: -9px; + font-weight: bold; + font-size: .4em; +} + +form input.ng-invalid.ng-dirty { background-color: #FDD7D9; } -.form-signup input.ng-valid.ng-dirty { +form input.ng-valid.ng-dirty { background-color: #DDFFEE; } @@ -201,7 +226,7 @@ @media (max-height: 768px) { .landing { padding: 20px; - padding-top: 46px; + padding-top: 20px; } } @@ -687,6 +712,11 @@ p.editable:hover i { margin-bottom: 10px; } +.user-admin .form-change-pw input { + margin-top: 12px; + margin-bottom: 12px; +} + #image-history-container .node circle { cursor: pointer; fill: #fff; diff --git a/static/js/app.js b/static/js/app.js index 310d3dd74..16710cc3f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -5,7 +5,8 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', verified: false, anonymous: true, username: null, - email: null + email: null, + askForPassword: false, } var userService = {} @@ -22,6 +23,9 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', '$username': userResponse.username, 'verified': userResponse.verified }); + mixpanel.people.set_once({ + '$created': new Date() + }) } }); }; @@ -41,8 +45,10 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', if ($location.host() === 'quay.io') { keyService['stripePublishableKey'] = 'pk_live_P5wLU0vGdHnZGyKnXlFG4oiu'; + keyService['githubClientId'] = '5a8c08b06c48d89d4d1e'; } else { keyService['stripePublishableKey'] = 'pk_test_uEDHANKm9CHCvVa2DLcipGRh'; + keyService['githubClientId'] = 'cfbc4aca88e5c1b40679'; } return keyService; @@ -116,6 +122,8 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', $analyticsProvider.virtualPageviews(true); + $locationProvider.html5Mode(true); + $routeProvider. when('/repository/:namespace/:name', {templateUrl: '/static/partials/view-repo.html', controller: RepoCtrl}). when('/repository/:namespace/:name/tag/:tag', {templateUrl: '/static/partials/view-repo.html', controller: RepoCtrl}). diff --git a/static/js/controllers.js b/static/js/controllers.js index 7a6da3110..a982aa8e8 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -130,7 +130,7 @@ function RepoListCtrl($scope, Restangular, UserService) { }); } -function LandingCtrl($scope, $timeout, Restangular, UserService) { +function LandingCtrl($scope, $timeout, Restangular, UserService, KeyService) { $('.form-signup').popover(); $scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) { @@ -141,6 +141,13 @@ function LandingCtrl($scope, $timeout, Restangular, UserService) { $scope.user = currentUser; }, true); + angulartics.waitForVendorApi(mixpanel, 500, function(loadedMixpanel) { + var mixpanelId = loadedMixpanel.get_distinct_id(); + $scope.github_state_clause = '&state=' + mixpanelId; + }); + + $scope.githubClientId = KeyService.githubClientId; + $scope.awaitingConfirmation = false; $scope.registering = false; @@ -162,12 +169,6 @@ function LandingCtrl($scope, $timeout, Restangular, UserService) { $scope.registering = false; mixpanel.alias($scope.newUser.username); - mixpanel.people.set_once({ - '$email': $scope.newUser.email, - '$username': $scope.newUser.username, - '$created': new Date(), - 'verified': false - }); }, function(result) { $scope.registering = false; $scope.registerError = result.data.message; @@ -468,9 +469,13 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { }); } -function UserAdminCtrl($scope, Restangular, PlanService, KeyService, $routeParams) { +function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, KeyService, $routeParams) { $scope.plans = PlanService.planList(); + $scope.$watch(function () { return UserService.currentUser(); }, function (currentUser) { + $scope.askForPassword = currentUser.askForPassword; + }, true); + var subscribedToPlan = function(sub) { $scope.subscription = sub; $scope.subscribedPlan = PlanService.getPlan(sub.plan); @@ -557,4 +562,33 @@ function UserAdminCtrl($scope, Restangular, PlanService, KeyService, $routeParam $scope.subscribe(requested); } } + + $scope.updatingUser = false; + $scope.changePasswordSuccess = false; + $('.form-change-pw').popover(); + + $scope.changePassword = function() { + $('.form-change-pw').popover('hide'); + $scope.updatingUser = true; + $scope.changePasswordSuccess = false; + var changePasswordPost = Restangular.one('user/'); + changePasswordPost.customPUT($scope.user).then(function() { + $scope.updatingUser = false; + $scope.changePasswordSuccess = true; + + // Reset the form + $scope.user.password = ''; + $scope.user.repeatPassword = ''; + $scope.changePasswordForm.$setPristine(); + + UserService.load(); + }, function(result) { + $scope.updatingUser = false; + + $scope.changePasswordError = result.data.message; + $timeout(function() { + $('.form-change-pw').popover('show'); + }); + }); + }; } \ No newline at end of file diff --git a/static/partials/landing.html b/static/partials/landing.html index 335819b28..b7e2d6248 100644 --- a/static/partials/landing.html +++ b/static/partials/landing.html @@ -5,7 +5,7 @@

Secure hosting for private docker repositories

Use the docker images your team needs with the safety of private repositories

- +
@@ -16,7 +16,7 @@

Your Top Repositories

@@ -25,9 +25,9 @@ You don't have any private repositories yet!
- +
or
- +
@@ -40,9 +40,14 @@ - -
- + + @@ -152,7 +157,7 @@

Support

diff --git a/static/partials/repo-admin.html b/static/partials/repo-admin.html index 637cb000d..10ebaaee9 100644 --- a/static/partials/repo-admin.html +++ b/static/partials/repo-admin.html @@ -8,7 +8,7 @@
- +

{{repo.namespace}} / {{repo.name}}

diff --git a/static/partials/repo-list.html b/static/partials/repo-list.html index f5343dbf0..7350559c0 100644 --- a/static/partials/repo-list.html +++ b/static/partials/repo-list.html @@ -8,7 +8,7 @@
@@ -16,7 +16,7 @@
@@ -26,7 +26,7 @@

Top Public Repositories

diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index 17c98a070..16df0fe00 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -7,6 +7,11 @@
{{ errorMessage }}
+
+
+
Your account does not currently have a password. You will need to create a password before you will be able to push or pull repositories.
+
+
@@ -52,4 +57,24 @@
+
+
+ +
+
+
+
+ Change Password +
+
+
+ + + + Password changed successfully +
+
+
+
+
diff --git a/static/partials/view-repo.html b/static/partials/view-repo.html index a9f4c4af0..6358dc637 100644 --- a/static/partials/view-repo.html +++ b/static/partials/view-repo.html @@ -18,7 +18,7 @@ {{repo.namespace}} / {{repo.name}} - + diff --git a/templates/githuberror.html b/templates/githuberror.html new file mode 100644 index 000000000..05370326b --- /dev/null +++ b/templates/githuberror.html @@ -0,0 +1,29 @@ + + + + Error Logging in with GitHub - Quay + + + + + + + +
+
+
+

There was an error logging in with GitHub.

+ + {% if error_message %} +
{{ error_message }}
+ {% endif %} + +
+ Please register using the registration form to continue. +
+
+
+ +
+ + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index eb48d4bb9..95986c2b1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,9 +2,12 @@ Quay - Private Docker Repository + + + @@ -60,15 +63,15 @@ mixpanel.init(isProd ? "50ff2b2569faa3a51c8f5724922ffb7e" : "38014a0f27e7bdc3ff8 - Quay + Quay diff --git a/templates/signin.html b/templates/signin.html index 85c63940c..4a107a9c3 100644 --- a/templates/signin.html +++ b/templates/signin.html @@ -7,7 +7,17 @@ + + + +
+ + \ No newline at end of file diff --git a/test.db b/test.db index 6a3735942..f23bc8525 100644 Binary files a/test.db and b/test.db differ diff --git a/util/validation.py b/util/validation.py index 9121a23e2..bbe02e017 100644 --- a/util/validation.py +++ b/util/validation.py @@ -1,6 +1,8 @@ import re import urllib +INVALID_PASSWORD_MESSAGE = 'Invalid password, password must be at least ' + \ + '8 characters and contain no whitespace.' def validate_email(email_address): if re.match(r'[^@]+@[^@]+\.[^@]+', email_address):