Merge pull request #2086 from coreos-inc/user-info

Add collection of user metadata: name and company
This commit is contained in:
josephschorr 2016-11-09 13:15:07 -05:00 committed by GitHub
commit 45b1148118
14 changed files with 178 additions and 33 deletions

View file

@ -222,6 +222,9 @@ class DefaultConfig(object):
# Feature Flag: Whether to proxy all direct download URLs in storage via the registry's nginx. # Feature Flag: Whether to proxy all direct download URLs in storage via the registry's nginx.
FEATURE_PROXY_STORAGE = False FEATURE_PROXY_STORAGE = False
# Feature Flag: Whether to collect and support user metadata.
FEATURE_USER_METADATA = False
# The namespace to use for library repositories. # The namespace to use for library repositories.
# Note: This must remain 'library' until Docker removes their hard-coded namespace for libraries. # Note: This must remain 'library' until Docker removes their hard-coded namespace for libraries.
# See: https://github.com/docker/docker/blob/master/registry/session.go#L320 # See: https://github.com/docker/docker/blob/master/registry/session.go#L320

View file

@ -337,6 +337,9 @@ class User(BaseModel):
enabled = BooleanField(default=True) enabled = BooleanField(default=True)
invoice_email_address = CharField(null=True, index=True) invoice_email_address = CharField(null=True, index=True)
name = CharField(null=True)
company = CharField(null=True)
def delete_instance(self, recursive=False, delete_nullable=False): def delete_instance(self, recursive=False, delete_nullable=False):
# If we are deleting a robot account, only execute the subset of queries necessary. # If we are deleting a robot account, only execute the subset of queries necessary.
if self.robot: if self.robot:
@ -372,6 +375,12 @@ class User(BaseModel):
Namespace = User.alias() Namespace = User.alias()
class UserPromptTypes(object):
CONFIRM_USERNAME = 'confirm_username'
ENTER_NAME = 'enter_name'
ENTER_COMPANY = 'enter_company'
class UserPromptKind(BaseModel): class UserPromptKind(BaseModel):
name = CharField(index=True) name = CharField(index=True)

View file

@ -0,0 +1,44 @@
"""Add user metadata fields
Revision ID: 491a530df230
Revises: 6c7014e84a5e
Create Date: 2016-11-04 18:03:05.237408
"""
# revision identifiers, used by Alembic.
revision = '491a530df230'
down_revision = '6c7014e84a5e'
from alembic import op
import sqlalchemy as sa
from util.migrate import UTF8CharField
def upgrade(tables):
### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('company', UTF8CharField(length=255), nullable=True))
op.add_column('user', sa.Column('name', UTF8CharField(length=255), nullable=True))
### end Alembic commands ###
op.bulk_insert(tables.userpromptkind,
[
{'name':'enter_name'},
{'name':'enter_company'},
])
def downgrade(tables):
### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'name')
op.drop_column('user', 'company')
### end Alembic commands ###
op.execute(
(tables.userpromptkind.delete()
.where(tables.userpromptkind.c.name == op.inline_literal('enter_name')))
)
op.execute(
(tables.userpromptkind.delete()
.where(tables.userpromptkind.c.name == op.inline_literal('enter_company')))
)

View file

@ -6,13 +6,14 @@ import uuid
from peewee import JOIN_LEFT_OUTER, IntegrityError, fn from peewee import JOIN_LEFT_OUTER, IntegrityError, fn
from uuid import uuid4 from uuid import uuid4
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum
from data.database import (User, LoginService, FederatedLogin, RepositoryPermission, TeamMember, from data.database import (User, LoginService, FederatedLogin, RepositoryPermission, TeamMember,
Team, Repository, TupleSelector, TeamRole, Namespace, Visibility, Team, Repository, TupleSelector, TeamRole, Namespace, Visibility,
EmailConfirmation, Role, db_for_update, random_string_generator, EmailConfirmation, Role, db_for_update, random_string_generator,
UserRegion, ImageStorageLocation, QueueItem, TeamMemberInvite, UserRegion, ImageStorageLocation, QueueItem, TeamMemberInvite,
ServiceKeyApproval, OAuthApplication, RepositoryBuildTrigger, ServiceKeyApproval, OAuthApplication, RepositoryBuildTrigger,
UserPromptKind, UserPrompt) UserPromptKind, UserPrompt, UserPromptTypes)
from data.model import (DataModelException, InvalidPasswordException, InvalidRobotException, from data.model import (DataModelException, InvalidPasswordException, InvalidRobotException,
InvalidUsernameException, InvalidEmailAddressException, InvalidUsernameException, InvalidEmailAddressException,
TooManyLoginAttemptsException, db_transaction, TooManyLoginAttemptsException, db_transaction,
@ -28,17 +29,16 @@ logger = logging.getLogger(__name__)
EXPONENTIAL_BACKOFF_SCALE = timedelta(seconds=1) EXPONENTIAL_BACKOFF_SCALE = timedelta(seconds=1)
def hash_password(password, salt=None): def hash_password(password, salt=None):
salt = salt or bcrypt.gensalt() salt = salt or bcrypt.gensalt()
return bcrypt.hashpw(password.encode('utf-8'), salt) return bcrypt.hashpw(password.encode('utf-8'), salt)
def create_user(username, password, email, auto_verify=False, email_required=True): def create_user(username, password, email, auto_verify=False, email_required=True, prompts=tuple()):
""" Creates a regular user, if allowed. """ """ Creates a regular user, if allowed. """
if not validate_password(password): if not validate_password(password):
raise InvalidPasswordException(INVALID_PASSWORD_MESSAGE) raise InvalidPasswordException(INVALID_PASSWORD_MESSAGE)
created = create_user_noverify(username, email, email_required=email_required) created = create_user_noverify(username, email, email_required=email_required, prompts=prompts)
created.password_hash = hash_password(password) created.password_hash = hash_password(password)
created.verified = auto_verify created.verified = auto_verify
created.save() created.save()
@ -46,7 +46,7 @@ def create_user(username, password, email, auto_verify=False, email_required=Tru
return created return created
def create_user_noverify(username, email, email_required=True): def create_user_noverify(username, email, email_required=True, prompts=tuple()):
if email_required: if email_required:
if not validate_email(email): if not validate_email(email):
raise InvalidEmailAddressException('Invalid email address: %s' % email) raise InvalidEmailAddressException('Invalid email address: %s' % email)
@ -76,7 +76,11 @@ def create_user_noverify(username, email, email_required=True):
logger.debug('Email and username are unique!') logger.debug('Email and username are unique!')
try: try:
return User.create(username=username, email=email) new_user = User.create(username=username, email=email)
for prompt in prompts:
create_user_prompt(new_user, prompt)
return new_user
except Exception as ex: except Exception as ex:
raise DataModelException(ex.message) raise DataModelException(ex.message)
@ -103,6 +107,15 @@ def change_password(user, new_password):
notification.delete_notifications_by_kind(user, 'password_required') notification.delete_notifications_by_kind(user, 'password_required')
def get_default_user_prompts(features):
prompts = set()
if features.USER_METADATA:
prompts.add(UserPromptTypes.ENTER_NAME)
prompts.add(UserPromptTypes.ENTER_COMPANY)
return prompts
def has_user_prompts(user): def has_user_prompts(user):
try: try:
UserPrompt.select().where(UserPrompt.user == user).get() UserPrompt.select().where(UserPrompt.user == user).get()
@ -110,6 +123,7 @@ def has_user_prompts(user):
except UserPrompt.DoesNotExist: except UserPrompt.DoesNotExist:
return False return False
def has_user_prompt(user, prompt_name): def has_user_prompt(user, prompt_name):
prompt_kind = UserPromptKind.get(name=prompt_name) prompt_kind = UserPromptKind.get(name=prompt_name)
@ -341,16 +355,28 @@ def list_entity_robot_permission_teams(entity_name, include_permissions=False):
return TupleSelector(query, fields) return TupleSelector(query, fields)
def update_user_metadata(user, name=None, company=None):
""" Updates the metadata associated with the user, including his/her name and company. """
with db_transaction():
user.name = name or user.name
user.company = company or user.company
user.save()
# Remove any prompts associated with the user's metadata being needed.
remove_user_prompt(user, UserPromptTypes.ENTER_NAME)
remove_user_prompt(user, UserPromptTypes.ENTER_COMPANY)
def create_federated_user(username, email, service_name, service_ident, def create_federated_user(username, email, service_name, service_ident,
set_password_notification, metadata={}, set_password_notification, metadata={},
email_required=True, confirm_username=False): email_required=True, prompts=tuple()):
new_user = create_user_noverify(username, email, email_required=email_required) prompts = set(prompts)
prompts.add(UserPromptTypes.CONFIRM_USERNAME)
new_user = create_user_noverify(username, email, email_required=email_required, prompts=prompts)
new_user.verified = True new_user.verified = True
new_user.save() new_user.save()
if confirm_username:
create_user_prompt(new_user, 'confirm_username')
service = LoginService.get(LoginService.name == service_name) service = LoginService.get(LoginService.name == service_name)
FederatedLogin.create(user=new_user, service=service, FederatedLogin.create(user=new_user, service=service,
service_ident=service_ident, service_ident=service_ident,

View file

@ -1,4 +1,5 @@
import logging import logging
import features
from collections import namedtuple from collections import namedtuple
@ -49,11 +50,12 @@ class FederatedUsers(object):
logger.error('Unable to pick a username for user: %s', username) logger.error('Unable to pick a username for user: %s', username)
return (None, 'Unable to pick a username. Please report this to your administrator.') return (None, 'Unable to pick a username. Please report this to your administrator.')
prompts = model.user.get_default_user_prompts(features)
db_user = model.user.create_federated_user(valid_username, email, self._federated_service, db_user = model.user.create_federated_user(valid_username, email, self._federated_service,
username, username,
set_password_notification=False, set_password_notification=False,
email_required=self._requires_email, email_required=self._requires_email,
confirm_username=True) prompts=prompts)
else: else:
# Update the db attributes from the federated service. # Update the db attributes from the federated service.
if email: if email:

View file

@ -260,8 +260,9 @@ class SuperUserList(ApiResource):
# Create the user. # Create the user.
username = user_information['username'] username = user_information['username']
email = user_information.get('email') email = user_information.get('email')
prompts = model.user.get_default_user_prompts(features)
user = model.user.create_user(username, password, email, auto_verify=not features.MAILING, user = model.user.create_user(username, password, email, auto_verify=not features.MAILING,
email_required=features.MAILING) email_required=features.MAILING, prompts=prompts)
# If mailing is turned on, send the user a verification email. # If mailing is turned on, send the user a verification email.
if features.MAILING: if features.MAILING:

View file

@ -17,7 +17,7 @@ from auth.permissions import (AdministerOrganizationPermission, CreateRepository
UserAdminPermission, UserReadPermission, SuperUserPermission) UserAdminPermission, UserReadPermission, SuperUserPermission)
from data import model from data import model
from data.billing import get_plan from data.billing import get_plan
from data.database import Repository as RepositoryTable from data.database import Repository as RepositoryTable, UserPromptTypes
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
log_action, internal_only, require_user_admin, parse_args, log_action, internal_only, require_user_admin, parse_args,
query_param, require_scope, format_date, show_if, query_param, require_scope, format_date, show_if,
@ -180,8 +180,8 @@ class User(ApiResource):
}, },
'invite_code': { 'invite_code': {
'type': 'string', 'type': 'string',
'description': 'The optional invite code' 'description': 'The optional invite code',
} },
} }
}, },
'UpdateUser': { 'UpdateUser': {
@ -212,7 +212,15 @@ class User(ApiResource):
'invoice_email_address': { 'invoice_email_address': {
'type': ['string', 'null'], 'type': ['string', 'null'],
'description': 'Custom email address for receiving invoices', 'description': 'Custom email address for receiving invoices',
} },
'name': {
'type': 'string',
'description': 'The optional entered name for the user',
},
'company': {
'type': 'string',
'description': 'The optional entered company for the user',
},
}, },
}, },
'UserView': { 'UserView': {
@ -326,6 +334,9 @@ class User(ApiResource):
else: else:
model.user.update_email(user, new_email, auto_verify=not features.MAILING) model.user.update_email(user, new_email, auto_verify=not features.MAILING)
if 'name' in user_data or 'company' in user_data:
model.user.update_user_metadata(user, user_data.get('name'), user_data.get('company'))
# Check for username rename. A username can be renamed if the feature is enabled OR the user # Check for username rename. A username can be renamed if the feature is enabled OR the user
# currently has a confirm_username prompt. # currently has a confirm_username prompt.
if 'username' in user_data: if 'username' in user_data:
@ -370,10 +381,12 @@ class User(ApiResource):
raise request_error(message='Email address is required') raise request_error(message='Email address is required')
try: try:
prompts = model.user.get_default_user_prompts(features)
new_user = model.user.create_user(user_data['username'], user_data['password'], new_user = model.user.create_user(user_data['username'], user_data['password'],
user_data.get('email'), user_data.get('email'),
auto_verify=not features.MAILING, auto_verify=not features.MAILING,
email_required=features.MAILING) email_required=features.MAILING,
prompts=prompts)
email_address_confirmed = handle_invite_code(invite_code, new_user) email_address_confirmed = handle_invite_code(invite_code, new_user)
if features.MAILING and not email_address_confirmed: if features.MAILING and not email_address_confirmed:

View file

@ -1,5 +1,4 @@
import logging import logging
import requests import requests
from flask import request, redirect, url_for, Blueprint from flask import request, redirect, url_for, Blueprint
@ -63,9 +62,11 @@ def conduct_oauth_login(service, user_id, username, email, metadata={}):
new_username = valid new_username = valid
break break
prompts = model.user.get_default_user_prompts(features)
to_login = model.user.create_federated_user(new_username, email, service_name.lower(), to_login = model.user.create_federated_user(new_username, email, service_name.lower(),
user_id, set_password_notification=True, user_id, set_password_notification=True,
metadata=metadata, confirm_username=True) metadata=metadata,
prompts=prompts)
# Success, tell analytics # Success, tell analytics
analytics.track(to_login.username, 'register', {'service': service_name.lower()}) analytics.track(to_login.username, 'register', {'service': service_name.lower()})

View file

@ -399,7 +399,10 @@ def confirm_email():
user_analytics.change_email(old_email, new_email) user_analytics.change_email(old_email, new_email)
common_login(user) common_login(user)
return redirect(url_for('web.user', tab='email') if new_email else url_for('web.index')) if model.user.has_user_prompts(user):
return redirect(url_for('web.updateuser'))
else:
return redirect(url_for('web.user', tab='email') if new_email else url_for('web.index'))
@web.route('/recovery', methods=['GET']) @web.route('/recovery', methods=['GET'])

View file

@ -397,6 +397,8 @@ def initialize_database():
LabelSourceType.create(name='internal') LabelSourceType.create(name='internal')
UserPromptKind.create(name='confirm_username') UserPromptKind.create(name='confirm_username')
UserPromptKind.create(name='enter_name')
UserPromptKind.create(name='enter_company')
def wipe_database(): def wipe_database():

View file

@ -21,7 +21,11 @@
UserService.updateUserIn($scope, function(user) { UserService.updateUserIn($scope, function(user) {
if (!user.anonymous) { if (!user.anonymous) {
$location.path('/repository/'); if (user.prompts && user.prompts.length) {
$location.path('/updateuser/');
} else {
$location.path('/repository/');
}
} }
}); });

View file

@ -10,6 +10,7 @@
function UpdateUserCtrl($scope, UserService, $location, ApiService) { function UpdateUserCtrl($scope, UserService, $location, ApiService) {
$scope.state = 'loading'; $scope.state = 'loading';
$scope.metadata = {};
UserService.updateUserIn($scope, function(user) { UserService.updateUserIn($scope, function(user) {
if (!user.anonymous) { if (!user.anonymous) {
@ -45,15 +46,17 @@
}); });
}; };
$scope.updateUsername = function(username) { $scope.updateUser = function(data) {
$scope.state = 'updating'; $scope.state = 'updating';
var data = { ApiService.changeUserDetails(data).then(function() {
'username': username UserService.load(function(updated) {
}; if (updated.prompts.length) {
$scope.state = 'editing';
ApiService.changeUserDetails(data).then(function() { } else {
window.location = '/'; $location.url('/');
}, ApiService.errorDisplay('Could not update username')); }
});
}, ApiService.errorDisplay('Could not update user information'));
}; };
$scope.hasPrompt = function(user, prompt_name) { $scope.hasPrompt = function(user, prompt_name) {

View file

@ -1,6 +1,6 @@
<div class="cor-loader-inline" ng-if="user.anonymous || state == 'updating'"></div> <div class="cor-loader-inline" ng-if="user.anonymous || state == 'updating'"></div>
<!-- TODO: Support additional kinds of prompts here -->
<!-- Confirm username -->
<div class="update-user" ng-show="hasPrompt(user, 'confirm_username') && state != 'updating'"> <div class="update-user" ng-show="hasPrompt(user, 'confirm_username') && state != 'updating'">
<h2>Confirm Username</h2> <h2>Confirm Username</h2>
<p> <p>
@ -8,7 +8,7 @@
Docker CLI guidelines for use as a namespace in <span class="registry-title"></span>. Docker CLI guidelines for use as a namespace in <span class="registry-title"></span>.
</p> </p>
<p>Please confirm the selected username or enter a different username below:</p> <p>Please confirm the selected username or enter a different username below:</p>
<form name="usernameForm" ng-submit="updateUsername(username)"> <form name="usernameForm" ng-submit="updateUser({'username': username})">
<div class="namespace-input" binding="username" is-back-incompat="isBackIncompat" <div class="namespace-input" binding="username" is-back-incompat="isBackIncompat"
namespace-title="Username" style="margin-bottom: 20px;" namespace-title="Username" style="margin-bottom: 20px;"
has-external-error="state == 'existing'"></div> has-external-error="state == 'existing'"></div>
@ -33,4 +33,38 @@
<i class="fa fa-exclamation-triangle"></i> Note: Usernames with dots or dashes are incompatible with Docker verion 1.8 or older <i class="fa fa-exclamation-triangle"></i> Note: Usernames with dots or dashes are incompatible with Docker verion 1.8 or older
</span> </span>
</form> </form>
</div>
<!-- Enter metadata -->
<div class="update-user" ng-show="!hasPrompt(user, 'confirm_username') && (hasPrompt(user, 'enter_name') || hasPrompt(user, 'enter_company')) && state != 'updating'">
<h2>Tell us a bit more about yourself</h2>
<div>This information will be displayed in your user profile.</div>
<form name="metadataForm" ng-submit="updateUser(metadata)" style="margin-top: 20px;">
<div class="form-group nested">
<label for="name">Name</label>
<div class="field-row">
<span class="field-container">
<input type="text" class="form-control" placeholder="Name" ng-model="metadata.name"></span>
</span>
</div>
</div>
<div class="form-group nested">
<label for="firstName">Company</label>
<div class="field-row">
<span class="field-container">
<input type="text" class="form-control" placeholder="Company name" ng-model="metadata.company"></span>
</span>
</div>
</div>
<div style="margin-top: 20px">
<input type="submit" class="btn btn-primary" value="Save Details"
ng-disabled="!metadata.name && !metadata.company">
<button class="btn btn-default"
ng-click="updateUser({'company': '', 'name': ''})">
No thanks</button>
</div>
</form>
</div> </div>

Binary file not shown.