diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index 89e81d65292a018612f972bd54ab68fa62753d47..3745d407ea12024a1e8df8d0ce09fcb4e74d26de 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -26,7 +26,7 @@ from django.utils import timezone
 from django.contrib.auth.models import User
 from django.contrib.auth.hashers import make_password
 from django.contrib.auth.signals import user_logged_in, user_logged_out
-from django.db import models, IntegrityError
+from django.db import models, IntegrityError, transaction
 from django.db.models import Count
 from django.dispatch import receiver, Signal
 from django.core.exceptions import ObjectDoesNotExist
@@ -278,6 +278,59 @@ class UserProfile(models.Model):
         self.set_meta(meta)
         self.save()
 
+    @transaction.commit_on_success
+    def update_name(self, new_name):
+        """Update the user's name, storing the old name in the history.
+
+        Implicitly saves the model.
+        If the new name is not the same as the old name, do nothing.
+
+        Arguments:
+            new_name (unicode): The new full name for the user.
+
+        Returns:
+            None
+
+        """
+        if self.name == new_name:
+            return
+
+        if self.name:
+            meta = self.get_meta()
+            if 'old_names' not in meta:
+                meta['old_names'] = []
+            meta['old_names'].append([self.name, u"", datetime.now(UTC).isoformat()])
+            self.set_meta(meta)
+
+        self.name = new_name
+        self.save()
+
+    @transaction.commit_on_success
+    def update_email(self, new_email):
+        """Update the user's email and save the change in the history.
+
+        Implicitly saves the model.
+        If the new email is the same as the old email, do not update the history.
+
+        Arguments:
+            new_email (unicode): The new email for the user.
+
+        Returns:
+            None
+        """
+        if self.user.email == new_email:
+            return
+
+        meta = self.get_meta()
+        if 'old_emails' not in meta:
+            meta['old_emails'] = []
+        meta['old_emails'].append([self.user.email, datetime.now(UTC).isoformat()])
+        self.set_meta(meta)
+        self.save()
+
+        self.user.email = new_email
+        self.user.save()
+
 
 class UserSignupSource(models.Model):
     """
@@ -342,6 +395,23 @@ class PendingEmailChange(models.Model):
     new_email = models.CharField(blank=True, max_length=255, db_index=True)
     activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True)
 
+    def request_change(self, email):
+        """Request a change to a user's email.
+
+        Implicitly saves the pending email change record.
+
+        Arguments:
+            email (unicode): The proposed new email for the user.
+
+        Returns:
+            unicode: The activation code to confirm the change.
+
+        """
+        self.new_email = email
+        self.activation_key = uuid.uuid4().hex
+        self.save()
+        return self.activation_key
+
 
 EVENT_NAME_ENROLLMENT_ACTIVATED = 'edx.course.enrollment.activated'
 EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated'
diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py
index dc9d021e5e467155f8a5c79812ffc438f8d2a3aa..9d472c4ffb04cab0acf873f038ac518180157f47 100644
--- a/common/djangoapps/third_party_auth/pipeline.py
+++ b/common/djangoapps/third_party_auth/pipeline.py
@@ -75,10 +75,12 @@ from . import provider
 AUTH_ENTRY_KEY = 'auth_entry'
 AUTH_ENTRY_DASHBOARD = 'dashboard'
 AUTH_ENTRY_LOGIN = 'login'
+AUTH_ENTRY_PROFILE = 'profile'
 AUTH_ENTRY_REGISTER = 'register'
 _AUTH_ENTRY_CHOICES = frozenset([
     AUTH_ENTRY_DASHBOARD,
     AUTH_ENTRY_LOGIN,
+    AUTH_ENTRY_PROFILE,
     AUTH_ENTRY_REGISTER
 ])
 _DEFAULT_RANDOM_PASSWORD_LENGTH = 12
@@ -335,15 +337,17 @@ def parse_query_params(strategy, response, *args, **kwargs):
         'is_login': auth_entry == AUTH_ENTRY_LOGIN,
         # Whether the auth pipeline entered from /register.
         'is_register': auth_entry == AUTH_ENTRY_REGISTER,
+        # Whether the auth pipeline entered from /profile.
+        'is_profile': auth_entry == AUTH_ENTRY_PROFILE,
     }
 
 
 @partial.partial
-def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboard=None, is_login=None, is_register=None, user=None, *args, **kwargs):
+def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboard=None, is_login=None, is_profile=None, is_register=None, user=None, *args, **kwargs):
     """Dispatches user to views outside the pipeline if necessary."""
 
     # We're deliberately verbose here to make it clear what the intended
-    # dispatch behavior is for the three pipeline entry points, given the
+    # dispatch behavior is for the four pipeline entry points, given the
     # current state of the pipeline. Keep in mind the pipeline is re-entrant
     # and values will change on repeated invocations (for example, the first
     # time through the login flow the user will be None so we dispatch to the
@@ -358,7 +362,7 @@ def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboar
     user_unset = user is None
     dispatch_to_login = is_login and (user_unset or user_inactive)
 
-    if is_dashboard:
+    if is_dashboard or is_profile:
         return
 
     if dispatch_to_login:
@@ -373,7 +377,8 @@ def login_analytics(*args, **kwargs):
 
     action_to_event_name = {
         'is_login': 'edx.bi.user.account.authenticated',
-        'is_dashboard': 'edx.bi.user.account.linked'
+        'is_dashboard': 'edx.bi.user.account.linked',
+        'is_profile': 'edx.bi.user.account.linked',
     }
 
     # Note: we assume only one of the `action` kwargs (is_dashboard, is_login) to be
diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py
index 07e46a3bedeacf22f284ad2b4f8eb10f33b8c929..a9b80f1794569ec270dda204f102722929ac6f09 100644
--- a/common/djangoapps/third_party_auth/settings.py
+++ b/common/djangoapps/third_party_auth/settings.py
@@ -51,6 +51,8 @@ _MIDDLEWARE_CLASSES = (
     'third_party_auth.middleware.ExceptionMiddleware',
 )
 _SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard'
+_SOCIAL_AUTH_NEW_ASSOCIATION_REDIRECT_URL = '/profile'
+_SOCIAL_AUTH_DISCONNECT_REDIRECT_URL = '/profile'
 
 
 def _merge_auth_info(django_settings, auth_info):
@@ -95,6 +97,11 @@ def _set_global_settings(django_settings):
     # Where to send the user once social authentication is successful.
     django_settings.SOCIAL_AUTH_LOGIN_REDIRECT_URL = _SOCIAL_AUTH_LOGIN_REDIRECT_URL
 
+    # Change redirects to the profile page if we enable the new dashboard.
+    if django_settings.FEATURES.get('ENABLE_NEW_DASHBOARD', ''):
+        django_settings.SOCIAL_AUTH_NEW_ASSOCIATION_REDIRECT_URL = _SOCIAL_AUTH_NEW_ASSOCIATION_REDIRECT_URL
+        django_settings.SOCIAL_AUTH_DISCONNECT_REDIRECT_URL = _SOCIAL_AUTH_DISCONNECT_REDIRECT_URL
+
     # Inject our customized auth pipeline. All auth backends must work with
     # this pipeline.
     django_settings.SOCIAL_AUTH_PIPELINE = (
diff --git a/common/djangoapps/third_party_auth/tests/test_settings.py b/common/djangoapps/third_party_auth/tests/test_settings.py
index b75e4bc032ae25f61b1d6e516c1b86fe19b5d415..40babdbc1c72e38c72b79a3eebb0a2444ce7468c 100644
--- a/common/djangoapps/third_party_auth/tests/test_settings.py
+++ b/common/djangoapps/third_party_auth/tests/test_settings.py
@@ -13,6 +13,7 @@ _SETTINGS_MAP = {
     'INSTALLED_APPS': _ORIGINAL_INSTALLED_APPS,
     'MIDDLEWARE_CLASSES': _ORIGINAL_MIDDLEWARE_CLASSES,
     'TEMPLATE_CONTEXT_PROCESSORS': _ORIGINAL_TEMPLATE_CONTEXT_PROCESSORS,
+    'FEATURES': {},
 }
 
 
diff --git a/common/djangoapps/user_api/api/__init__.py b/common/djangoapps/user_api/api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/common/djangoapps/user_api/api/account.py b/common/djangoapps/user_api/api/account.py
new file mode 100644
index 0000000000000000000000000000000000000000..aa17e0bf9107c61dc87243600d0661cc2ee38c7f
--- /dev/null
+++ b/common/djangoapps/user_api/api/account.py
@@ -0,0 +1,417 @@
+"""Python API for user accounts.
+
+Account information includes a student's username, password, and email
+address, but does NOT include user profile information (i.e., demographic
+information and preferences).
+
+"""
+from django.db import transaction, IntegrityError
+from django.core.validators import validate_email, validate_slug, ValidationError
+from user_api.models import User, UserProfile, Registration, PendingEmailChange
+from user_api.helpers import intercept_errors
+
+
+USERNAME_MIN_LENGTH = 2
+USERNAME_MAX_LENGTH = 30
+
+EMAIL_MIN_LENGTH = 3
+EMAIL_MAX_LENGTH = 254
+
+PASSWORD_MIN_LENGTH = 2
+PASSWORD_MAX_LENGTH = 75
+
+
+class AccountRequestError(Exception):
+    """There was a problem with the request to the account API. """
+    pass
+
+
+class AccountInternalError(Exception):
+    """An internal error occurred in the account API. """
+    pass
+
+
+class AccountUserAlreadyExists(AccountRequestError):
+    """User with the same username and/or email already exists. """
+    pass
+
+
+class AccountUsernameAlreadyExists(AccountUserAlreadyExists):
+    """An account already exists with the requested username. """
+    pass
+
+
+class AccountEmailAlreadyExists(AccountUserAlreadyExists):
+    """An account already exists with the requested email. """
+    pass
+
+
+class AccountUsernameInvalid(AccountRequestError):
+    """The requested username is not in a valid format. """
+    pass
+
+
+class AccountEmailInvalid(AccountRequestError):
+    """The requested email is not in a valid format. """
+    pass
+
+
+class AccountPasswordInvalid(AccountRequestError):
+    """The requested password is not in a valid format. """
+    pass
+
+
+class AccountUserNotFound(AccountRequestError):
+    """The requested user does not exist. """
+    pass
+
+
+class AccountNotAuthorized(AccountRequestError):
+    """The user is not authorized to perform the requested action. """
+    pass
+
+
+@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
+@transaction.commit_on_success
+def create_account(username, password, email):
+    """Create a new user account.
+
+    This will implicitly create an empty profile for the user.
+
+    WARNING: This function does NOT yet implement all the features
+    in `student/views.py`.  Until it does, please use this method
+    ONLY for tests of the account API, not in production code.
+    In particular, these are currently missing:
+
+    * 3rd party auth
+    * External auth (shibboleth)
+    * Complex password policies (ENFORCE_PASSWORD_POLICY)
+
+    In addition, we assume that some functionality is handled
+    at higher layers:
+
+    * Analytics events
+    * Activation email
+    * Terms of service / honor code checking
+    * Recording demographic info (use profile API)
+    * Auto-enrollment in courses (if invited via instructor dash)
+
+    Args:
+        username (unicode): The username for the new account.
+        password (unicode): The user's password.
+        email (unicode): The email address associated with the account.
+
+    Returns:
+        unicode: an activation key for the account.
+
+    Raises:
+        AccountUserAlreadyExists
+        AccountUsernameInvalid
+        AccountEmailInvalid
+        AccountPasswordInvalid
+
+    """
+    # Validate the username, password, and email
+    # This will raise an exception if any of these are not in a valid format.
+    _validate_username(username)
+    _validate_password(password, username)
+    _validate_email(email)
+
+    # Create the user account, setting them to "inactive" until they activate their account.
+    user = User(username=username, email=email, is_active=False)
+    user.set_password(password)
+
+    try:
+        user.save()
+    except IntegrityError:
+        raise AccountUserAlreadyExists
+
+    # Create a registration to track the activation process
+    # This implicitly saves the registration.
+    registration = Registration()
+    registration.register(user)
+
+    # Create an empty user profile with default values
+    UserProfile(user=user).save()
+
+    # Return the activation key, which the caller should send to the user
+    return registration.activation_key
+
+
+@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
+def account_info(username):
+    """Retrieve information about a user's account.
+
+    Arguments:
+        username (unicode): The username associated with the account.
+
+    Returns:
+        dict: User's account information, if the user was found.
+        None: The user does not exist.
+
+    """
+    try:
+        user = User.objects.get(username=username)
+    except User.DoesNotExist:
+        return None
+    else:
+        return {
+            u'username': username,
+            u'email': user.email,
+            u'is_active': user.is_active,
+        }
+
+
+@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
+def activate_account(activation_key):
+    """Activate a user's account.
+
+    Args:
+        activation_key (unicode): The activation key the user received via email.
+
+    Returns:
+        None
+
+    Raises:
+        AccountNotAuthorized
+
+    """
+    try:
+        registration = Registration.objects.get(activation_key=activation_key)
+    except Registration.DoesNotExist:
+        raise AccountNotAuthorized
+    else:
+        # This implicitly saves the registration
+        registration.activate()
+
+
+@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
+def request_email_change(username, new_email, password):
+    """Request an email change.
+
+    Users must confirm the change before we update their information.
+
+    Args:
+        username (unicode): The username associated with the account.
+        new_email (unicode): The user's new email address.
+        password (unicode): The password the user entered to authorize the change.
+
+    Returns:
+        unicode: an activation key for the account.
+
+    Raises:
+        AccountUserNotFound
+        AccountEmailAlreadyExists
+        AccountEmailInvalid
+        AccountNotAuthorized
+
+    """
+    try:
+        user = User.objects.get(username=username)
+    except User.DoesNotExist:
+        raise AccountUserNotFound
+
+    # Check the user's credentials
+    if not user.check_password(password):
+        raise AccountNotAuthorized
+
+    # Validate the email, raising an exception if it is not in the correct format
+    _validate_email(new_email)
+
+    # Verify that no active account has taken the email in between
+    # the request and the activation.
+    # We'll check again before confirming and persisting the change,
+    # but if the email is already taken by an active account, we should
+    # let the user know as soon as possible.
+    if User.objects.filter(email=new_email, is_active=True).exists():
+        raise AccountEmailAlreadyExists
+
+    try:
+        pending_change = PendingEmailChange.objects.get(user=user)
+    except PendingEmailChange.DoesNotExist:
+        pending_change = PendingEmailChange(user=user)
+
+    # Update the change (re-using the same record if it already exists)
+    # This will generate a new activation key and save the record.
+    return pending_change.request_change(new_email)
+
+
+@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
+@transaction.commit_on_success
+def confirm_email_change(activation_key):
+    """Confirm an email change.
+
+    Users can confirm the change by providing an activation key
+    they received via email.
+
+    Args:
+        activation_key (unicode): The activation key the user received
+            when he/she requested the email change.
+
+    Returns:
+        Tuple: (old_email, new_email)
+
+    Raises:
+        AccountNotAuthorized: The activation code is invalid.
+        AccountEmailAlreadyExists: Someone else has already taken the email address.
+        AccountInternalError
+
+    """
+
+    try:
+        # Activation key has a uniqueness constraint, so we're guaranteed to get
+        # at most one pending change.
+        pending_change = PendingEmailChange.objects.select_related('user').get(
+            activation_key=activation_key
+        )
+    except PendingEmailChange.DoesNotExist:
+        # If there are no changes, then the activation key is invalid
+        raise AccountNotAuthorized
+    else:
+        old_email = pending_change.user.email
+        new_email = pending_change.new_email
+
+        # Verify that no one else has taken the email in between
+        # the request and the activation.
+        # In our production database, email has a uniqueness constraint,
+        # so there is no danger of a race condition here.
+        if User.objects.filter(email=new_email).exists():
+            raise AccountEmailAlreadyExists
+
+        # Update the email history (in the user profile)
+        try:
+            profile = UserProfile.objects.get(user=pending_change.user)
+        except UserProfile.DoesNotExist:
+            raise AccountInternalError(
+                "No profile exists for the user '{username}'".format(
+                    username=pending_change.user.username
+                )
+            )
+        else:
+            profile.update_email(new_email)
+
+        # Delete the pending change, so that the activation code
+        # will be single-use
+        pending_change.delete()
+
+        # Return the old and new email
+        # This allows the caller of the function to notify users at both
+        # the new and old email, which is necessary for security reasons.
+        return (old_email, new_email)
+
+
+def _validate_username(username):
+    """Validate the username.
+
+    Arguments:
+        username (unicode): The proposed username.
+
+    Returns:
+        None
+
+    Raises:
+        AccountUsernameInvalid
+
+    """
+    if not isinstance(username, basestring):
+        raise AccountUsernameInvalid(u"Username must be a string")
+
+    if len(username) < USERNAME_MIN_LENGTH:
+        raise AccountUsernameInvalid(
+            u"Username '{username}' must be at least {min} characters long".format(
+                username=username,
+                min=USERNAME_MIN_LENGTH
+            )
+        )
+    if len(username) > USERNAME_MAX_LENGTH:
+        raise AccountUsernameInvalid(
+            u"Username '{username}' must be at most {max} characters long".format(
+                username=username,
+                max=USERNAME_MAX_LENGTH
+            )
+        )
+    try:
+        validate_slug(username)
+    except ValidationError:
+        raise AccountUsernameInvalid(
+            u"Username '{username}' must contain only A-Z, a-z, 0-9, -, or _ characters"
+        )
+
+
+def _validate_password(password, username):
+    """Validate the format of the user's password.
+
+    Passwords cannot be the same as the username of the account,
+    so we take `username` as an argument.
+
+    Arguments:
+        password (unicode): The proposed password.
+        username (unicode): The username associated with the user's account.
+
+    Returns:
+        None
+
+    Raises:
+        AccountPasswordInvalid
+
+    """
+    if not isinstance(password, basestring):
+        raise AccountPasswordInvalid(u"Password must be a string")
+
+    if len(password) < PASSWORD_MIN_LENGTH:
+        raise AccountPasswordInvalid(
+            u"Password must be at least {min} characters long".format(
+                min=PASSWORD_MIN_LENGTH
+            )
+        )
+
+    if len(password) > PASSWORD_MAX_LENGTH:
+        raise AccountPasswordInvalid(
+            u"Password must be at most {max} characters long".format(
+                max=PASSWORD_MAX_LENGTH
+            )
+        )
+
+    if password == username:
+        raise AccountPasswordInvalid(u"Password cannot be the same as the username")
+
+
+def _validate_email(email):
+    """Validate the format of the email address.
+
+    Arguments:
+        email (unicode): The proposed email.
+
+    Returns:
+        None
+
+    Raises:
+        AccountEmailInvalid
+
+    """
+    if not isinstance(email, basestring):
+        raise AccountEmailInvalid(u"Email must be a string")
+
+    if len(email) < EMAIL_MIN_LENGTH:
+        raise AccountEmailInvalid(
+            u"Email '{email}' must be at least {min} characters long".format(
+                email=email,
+                min=EMAIL_MIN_LENGTH
+            )
+        )
+
+    if len(email) > EMAIL_MAX_LENGTH:
+        raise AccountEmailInvalid(
+            u"Email '{email}' must be at most {max} characters long".format(
+                email=email,
+                max=EMAIL_MAX_LENGTH
+            )
+        )
+
+    try:
+        validate_email(email)
+    except ValidationError:
+        raise AccountEmailInvalid(
+            u"Email '{email}' format is not valid".format(email=email)
+        )
+
diff --git a/common/djangoapps/user_api/api/profile.py b/common/djangoapps/user_api/api/profile.py
new file mode 100644
index 0000000000000000000000000000000000000000..b9b4c934ffb3e279b7ab210c54ec1eb2aacadcb3
--- /dev/null
+++ b/common/djangoapps/user_api/api/profile.py
@@ -0,0 +1,132 @@
+"""Python API for user profiles.
+
+Profile information includes a student's demographic information and preferences,
+but does NOT include basic account information such as username, password, and
+email address.
+
+"""
+from user_api.models import UserProfile
+from user_api.helpers import intercept_errors
+
+
+class ProfileRequestError(Exception):
+    """ The request to the API was not valid. """
+    pass
+
+
+class ProfileUserNotFound(ProfileRequestError):
+    """ The requested user does not exist. """
+    pass
+
+
+class ProfileInvalidField(ProfileRequestError):
+    """ The proposed value for a field is not in a valid format. """
+
+    def __init__(self, field, value):
+        self.field = field
+        self.value = value
+
+    def __str__(self):
+        return u"Invalid value '{value}' for profile field '{field}'".format(
+            value=self.value,
+            field=self.field
+        )
+
+
+class ProfileInternalError(Exception):
+    """ An error occurred in an API call. """
+    pass
+
+
+FULL_NAME_MAX_LENGTH = 255
+
+
+@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
+def profile_info(username):
+    """Retrieve a user's profile information
+
+    Searches either by username or email.
+
+    At least one of the keyword args must be provided.
+
+    Arguments:
+        username (unicode): The username of the account to retrieve.
+
+    Returns:
+        dict: If profile information was found.
+        None: If the provided username did not match any profiles.
+
+    """
+    try:
+        profile = UserProfile.objects.get(user__username=username)
+    except UserProfile.DoesNotExist:
+        return None
+
+    profile_dict = {
+        u'username': profile.user.username,
+        u'email': profile.user.email,
+        u'full_name': profile.name,
+    }
+
+    return profile_dict
+
+
+@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
+def update_profile(username, full_name=None):
+    """Update a user's profile.
+
+    Args:
+        username (unicode): The username associated with the account.
+
+    Keyword Arguments:
+        full_name (unicode): If provided, set the user's full name to this value.
+
+    Returns:
+        None
+
+    Raises:
+        ProfileRequestError: If there is no profile matching the provided username.
+
+    """
+    try:
+        profile = UserProfile.objects.get(user__username=username)
+    except UserProfile.DoesNotExist:
+        raise ProfileUserNotFound
+
+    if full_name is not None:
+        name_length = len(full_name)
+        if name_length > FULL_NAME_MAX_LENGTH or name_length == 0:
+            raise ProfileInvalidField("full_name", full_name)
+        else:
+            profile.update_name(full_name)
+
+
+@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
+def preference_info(username, preference_name):
+    """Retrieve information about a user's preferences.
+
+    Arguments:
+        username (unicode): The username of the account to retrieve.
+        preference_name (unicode): The name of the preference to retrieve.
+
+    Returns:
+        The JSON-deserialized value.
+
+    """
+    pass
+
+
+@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
+def update_preference(username, preference_name, preference_value):
+    """Update a user's preference.
+
+    Arguments:
+        username (unicode): The username of the account to retrieve.
+        preference_name (unicode): The name of the preference to set.
+        preference_value (JSON-serializable): The new value for the preference.
+
+    Returns:
+        None
+
+    """
+    pass
diff --git a/common/djangoapps/user_api/helpers.py b/common/djangoapps/user_api/helpers.py
new file mode 100644
index 0000000000000000000000000000000000000000..1cae378786eda703fb9cb40859e158cf11d1952d
--- /dev/null
+++ b/common/djangoapps/user_api/helpers.py
@@ -0,0 +1,56 @@
+"""
+Helper functions for the account/profile Python APIs.
+This is NOT part of the public API.
+"""
+from functools import wraps
+import logging
+
+LOGGER = logging.getLogger(__name__)
+
+
+def intercept_errors(api_error, ignore_errors=[]):
+    """
+    Function decorator that intercepts exceptions
+    and translates them into API-specific errors (usually an "internal" error).
+
+    This allows callers to gracefully handle unexpected errors from the API.
+
+    This method will also log all errors and function arguments to make
+    it easier to track down unexpected errors.
+
+    Arguments:
+        api_error (Exception): The exception to raise if an unexpected error is encountered.
+
+    Keyword Arguments:
+        ignore_errors (iterable): List of errors to ignore.  By default, intercept every error.
+
+    Returns:
+        function
+
+    """
+    def _decorator(func):
+        @wraps(func)
+        def _wrapped(*args, **kwargs):
+            try:
+                return func(*args, **kwargs)
+            except Exception as ex:
+                # Raise the original exception if it's in our list of "ignored" errors
+                for ignored in ignore_errors:
+                    if isinstance(ex, ignored):
+                        raise
+
+                # Otherwise, log the error and raise the API-specific error
+                msg = (
+                    u"An unexpected error occurred when calling '{func_name}' "
+                    u"with arguments '{args}' and keyword arguments '{kwargs}': "
+                    u"{exception}"
+                ).format(
+                    func_name=func.func_name,
+                    args=args,
+                    kwargs=kwargs,
+                    exception=repr(ex)
+                )
+                LOGGER.exception(msg)
+                raise api_error(msg)
+        return _wrapped
+    return _decorator
diff --git a/common/djangoapps/user_api/models.py b/common/djangoapps/user_api/models.py
index e5a31db820c1a35662aa88fa058ae91e832c9e1e..3c49b43b9a60e14da0d702fe008021cc7ec04f41 100644
--- a/common/djangoapps/user_api/models.py
+++ b/common/djangoapps/user_api/models.py
@@ -4,6 +4,14 @@ from django.db import models
 
 from xmodule_django.models import CourseKeyField
 
+# Currently, the "student" app is responsible for
+# accounts, profiles, enrollments, and the student dashboard.
+# We are trying to move some of this functionality into separate apps,
+# but currently the rest of the system assumes that "student" defines
+# certain models.  For now we will leave the models in "student" and
+# create an alias in "user_api".
+from student.models import UserProfile, Registration, PendingEmailChange  # pylint:disable=unused-import
+
 
 class UserPreference(models.Model):
     """A user's preference, stored as generic text to be processed by client"""
diff --git a/common/djangoapps/user_api/tests/test_account_api.py b/common/djangoapps/user_api/tests/test_account_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..c343749a6f7b1cf747a9b202c510becd6080ec48
--- /dev/null
+++ b/common/djangoapps/user_api/tests/test_account_api.py
@@ -0,0 +1,276 @@
+# -*- coding: utf-8 -*-
+""" Tests for the account API. """
+
+import unittest
+from nose.tools import raises
+import ddt
+from dateutil.parser import parse as parse_datetime
+from django.conf import settings
+from django.test import TestCase
+from user_api.api import account as account_api
+from user_api.models import UserProfile
+
+
+@ddt.ddt
+class AccountApiTest(TestCase):
+
+    USERNAME = u"frank-underwood"
+    PASSWORD = u"ṕáśśẃőŕd"
+    EMAIL = u"frank+underwood@example.com"
+
+    INVALID_USERNAMES = [
+        None,
+        u"",
+        u"a",
+        u"a" * (account_api.USERNAME_MAX_LENGTH + 1),
+        u"invalid_symbol_@",
+        u"invalid-unicode_fŕáńḱ",
+    ]
+
+    INVALID_EMAILS = [
+        None,
+        u"",
+        u"a",
+        "no_domain",
+        "no+domain",
+        "@",
+        "@domain.com",
+        "test@no_extension",
+
+        # Long email -- subtract the length of the @domain
+        # except for one character (so we exceed the max length limit)
+        u"{user}@example.com".format(
+            user=(u'e' * (account_api.EMAIL_MAX_LENGTH - 11))
+        )
+    ]
+
+    INVALID_PASSWORDS = [
+        None,
+        u"",
+        u"a",
+        u"a" * (account_api.PASSWORD_MAX_LENGTH + 1)
+    ]
+
+    def test_activate_account(self):
+        # Create the account, which is initially inactive
+        activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+        account = account_api.account_info(self.USERNAME)
+        self.assertEqual(account, {
+            'username': self.USERNAME,
+            'email': self.EMAIL,
+            'is_active': False
+        })
+
+        # Activate the account and verify that it is now active
+        account_api.activate_account(activation_key)
+        account = account_api.account_info(self.USERNAME)
+        self.assertTrue(account['is_active'])
+
+    def test_change_email(self):
+        # Request an email change
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+        activation_key = account_api.request_email_change(
+            self.USERNAME, u"new+email@example.com", self.PASSWORD
+        )
+
+        # Verify that the email has not yet changed
+        account = account_api.account_info(self.USERNAME)
+        self.assertEqual(account['email'], self.EMAIL)
+
+        # Confirm the change, using the activation code
+        old_email, new_email = account_api.confirm_email_change(activation_key)
+        self.assertEqual(old_email, self.EMAIL)
+        self.assertEqual(new_email, u"new+email@example.com")
+
+        # Verify that the email is changed
+        account = account_api.account_info(self.USERNAME)
+        self.assertEqual(account['email'], u"new+email@example.com")
+
+    def test_confirm_email_change_repeat(self):
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+        activation_key = account_api.request_email_change(
+            self.USERNAME, u"new+email@example.com", self.PASSWORD
+        )
+
+        # Confirm the change once
+        account_api.confirm_email_change(activation_key)
+
+        # Confirm the change again
+        # The activation code should be single-use
+        # so this should raise an error.
+        with self.assertRaises(account_api.AccountNotAuthorized):
+            account_api.confirm_email_change(activation_key)
+
+    def test_create_account_duplicate_username(self):
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+        with self.assertRaises(account_api.AccountUserAlreadyExists):
+            account_api.create_account(self.USERNAME, self.PASSWORD, 'different+email@example.com')
+
+    # Email uniqueness constraints were introduced in a database migration,
+    # which we disable in the unit tests to improve the speed of the test suite.
+    @unittest.skipUnless(settings.SOUTH_TESTS_MIGRATE, "South migrations required")
+    def test_create_account_duplicate_email(self):
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+        with self.assertRaises(account_api.AccountUserAlreadyExists):
+            account_api.create_account("different_user", self.PASSWORD, self.EMAIL)
+
+    def test_username_too_long(self):
+        long_username = 'e' * (account_api.USERNAME_MAX_LENGTH + 1)
+        with self.assertRaises(account_api.AccountUsernameInvalid):
+            account_api.create_account(long_username, self.PASSWORD, self.EMAIL)
+
+    def test_account_info_no_user(self):
+        self.assertIs(account_api.account_info("does_not_exist"), None)
+
+    @raises(account_api.AccountEmailInvalid)
+    @ddt.data(*INVALID_EMAILS)
+    def test_create_account_invalid_email(self, invalid_email):
+        account_api.create_account(self.USERNAME, self.PASSWORD, invalid_email)
+
+    @raises(account_api.AccountPasswordInvalid)
+    @ddt.data(*INVALID_PASSWORDS)
+    def test_create_account_invalid_password(self, invalid_password):
+        account_api.create_account(self.USERNAME, invalid_password, self.EMAIL)
+
+    @raises(account_api.AccountPasswordInvalid)
+    def test_create_account_username_password_equal(self):
+        # Username and password cannot be the same
+        account_api.create_account(self.USERNAME, self.USERNAME, self.EMAIL)
+
+    @raises(account_api.AccountRequestError)
+    @ddt.data(*INVALID_USERNAMES)
+    def test_create_account_invalid_username(self, invalid_username):
+        account_api.create_account(invalid_username, self.PASSWORD, self.EMAIL)
+
+    @raises(account_api.AccountNotAuthorized)
+    def test_activate_account_invalid_key(self):
+        account_api.activate_account(u"invalid")
+
+    @raises(account_api.AccountUserNotFound)
+    def test_request_email_change_no_user(self):
+        account_api.request_email_change(u"no_such_user", self.EMAIL, self.PASSWORD)
+
+    @ddt.data(*INVALID_EMAILS)
+    def test_request_email_change_invalid_email(self, invalid_email):
+        # Create an account with a valid email address
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+
+        # Attempt to change the account to an invalid email
+        with self.assertRaises(account_api.AccountEmailInvalid):
+            account_api.request_email_change(self.USERNAME, invalid_email, self.PASSWORD)
+
+    def test_request_email_change_already_exists(self):
+        # Create two accounts, both activated
+        activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+        account_api.activate_account(activation_key)
+        activation_key = account_api.create_account(u"another_user", u"password", u"another+user@example.com")
+        account_api.activate_account(activation_key)
+
+        # Try to change the first user's email to the same as the second user's
+        with self.assertRaises(account_api.AccountEmailAlreadyExists):
+            account_api.request_email_change(self.USERNAME, u"another+user@example.com", self.PASSWORD)
+
+    def test_request_email_change_duplicates_unactivated_account(self):
+        # Create two accounts, but the second account is inactive
+        activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+        account_api.activate_account(activation_key)
+        account_api.create_account(u"another_user", u"password", u"another+user@example.com")
+
+        # Try to change the first user's email to the same as the second user's
+        # Since the second user has not yet activated, this should succeed.
+        account_api.request_email_change(self.USERNAME, u"another+user@example.com", self.PASSWORD)
+
+    def test_request_email_change_same_address(self):
+        # Create and activate the account
+        activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+        account_api.activate_account(activation_key)
+
+        # Try to change the email address to the current address
+        with self.assertRaises(account_api.AccountEmailAlreadyExists):
+            account_api.request_email_change(self.USERNAME, self.EMAIL, self.PASSWORD)
+
+    def test_request_email_change_wrong_password(self):
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+
+        # Use the wrong password
+        with self.assertRaises(account_api.AccountNotAuthorized):
+            account_api.request_email_change(self.USERNAME, u"new+email@example.com", u"wrong password")
+
+    def test_confirm_email_change_invalid_activation_key(self):
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+        account_api.request_email_change(self.USERNAME, u"new+email@example.com", self.PASSWORD)
+
+        with self.assertRaises(account_api.AccountNotAuthorized):
+            account_api.confirm_email_change(u"invalid")
+
+    def test_confirm_email_change_no_request_pending(self):
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+
+    def test_confirm_email_already_exists(self):
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+
+        # Request a change
+        activation_key = account_api.request_email_change(
+            self.USERNAME, u"new+email@example.com", self.PASSWORD
+        )
+
+        # Another use takes the email before we confirm the change
+        account_api.create_account(u"other_user", u"password", u"new+email@example.com")
+
+        # When we try to confirm our change, we get an error because the email is taken
+        with self.assertRaises(account_api.AccountEmailAlreadyExists):
+            account_api.confirm_email_change(activation_key)
+
+        # Verify that the email was NOT changed
+        self.assertEqual(account_api.account_info(self.USERNAME)['email'], self.EMAIL)
+
+    def test_confirm_email_no_user_profile(self):
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+        activation_key = account_api.request_email_change(
+            self.USERNAME, u"new+email@example.com", self.PASSWORD
+        )
+
+        # This should never happen, but just in case...
+        UserProfile.objects.get(user__username=self.USERNAME).delete()
+
+        with self.assertRaises(account_api.AccountInternalError):
+            account_api.confirm_email_change(activation_key)
+
+    def test_record_email_change_history(self):
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+
+        # Change the email once
+        activation_key = account_api.request_email_change(
+            self.USERNAME, u"new+email@example.com", self.PASSWORD
+        )
+        account_api.confirm_email_change(activation_key)
+
+        # Verify that the old email appears in the history
+        meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
+        self.assertEqual(len(meta['old_emails']), 1)
+        email, timestamp = meta['old_emails'][0]
+        self.assertEqual(email, self.EMAIL)
+        self._assert_is_datetime(timestamp)
+
+        # Change the email again
+        activation_key = account_api.request_email_change(
+            self.USERNAME, u"another_new+email@example.com", self.PASSWORD
+        )
+        account_api.confirm_email_change(activation_key)
+
+        # Verify that both emails appear in the history
+        meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
+        self.assertEqual(len(meta['old_emails']), 2)
+        email, timestamp = meta['old_emails'][1]
+        self.assertEqual(email, "new+email@example.com")
+        self._assert_is_datetime(timestamp)
+
+    def _assert_is_datetime(self, timestamp):
+        if not timestamp:
+            return False
+        try:
+            parse_datetime(timestamp)
+        except ValueError:
+            return False
+        else:
+            return True
diff --git a/common/djangoapps/user_api/tests/test_helpers.py b/common/djangoapps/user_api/tests/test_helpers.py
new file mode 100644
index 0000000000000000000000000000000000000000..7daf5c2a4b08d2127b505971eff90d9bfe04fcde
--- /dev/null
+++ b/common/djangoapps/user_api/tests/test_helpers.py
@@ -0,0 +1,66 @@
+"""
+Tests for helper functions.
+"""
+import mock
+from django.test import TestCase
+from nose.tools import raises
+from user_api.helpers import intercept_errors
+
+
+class FakeInputException(Exception):
+    """Fake exception that should be intercepted. """
+    pass
+
+
+class FakeOutputException(Exception):
+    """Fake exception that should be raised. """
+    pass
+
+
+@intercept_errors(FakeOutputException, ignore_errors=[ValueError])
+def intercepted_function(raise_error=None):
+    """Function used to test the intercept error decorator.
+
+    Keyword Arguments:
+        raise_error (Exception): If provided, raise this exception.
+
+    """
+    if raise_error is not None:
+        raise raise_error
+
+
+class InterceptErrorsTest(TestCase):
+    """
+    Tests for the decorator that intercepts errors.
+    """
+
+    @raises(FakeOutputException)
+    def test_intercepts_errors(self):
+        intercepted_function(raise_error=FakeInputException)
+
+    def test_ignores_no_error(self):
+        intercepted_function()
+
+    @raises(ValueError)
+    def test_ignores_expected_errors(self):
+        intercepted_function(raise_error=ValueError)
+
+    @mock.patch('user_api.helpers.LOGGER')
+    def test_logs_errors(self, mock_logger):
+        expected_log_msg = (
+            u"An unexpected error occurred when calling 'intercepted_function' "
+            u"with arguments '()' and "
+            u"keyword arguments '{'raise_error': <class 'user_api.tests.test_helpers.FakeInputException'>}': "
+            u"FakeInputException()"
+        )
+
+        # Verify that the raised exception has the error message
+        try:
+            intercepted_function(raise_error=FakeInputException)
+        except FakeOutputException as ex:
+            self.assertEqual(ex.message, expected_log_msg)
+
+        # Verify that the error logger is called
+        # This will include the stack trace for the original exception
+        # because it's called with log level "ERROR"
+        mock_logger.exception.assert_called_once_with(expected_log_msg)
diff --git a/common/djangoapps/user_api/tests/test_profile_api.py b/common/djangoapps/user_api/tests/test_profile_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..7421457a4433e5ad1f08eaa3e0bee7f20f30a888
--- /dev/null
+++ b/common/djangoapps/user_api/tests/test_profile_api.py
@@ -0,0 +1,90 @@
+# -*- coding: utf-8 -*-
+""" Tests for the profile API. """
+
+from django.test import TestCase
+import ddt
+from nose.tools import raises
+from dateutil.parser import parse as parse_datetime
+from user_api.api import account as account_api
+from user_api.api import profile as profile_api
+from user_api.models import UserProfile
+
+
+@ddt.ddt
+class ProfileApiTest(TestCase):
+
+    USERNAME = u"frank-underwood"
+    PASSWORD = u"ṕáśśẃőŕd"
+    EMAIL = u"frank+underwood@example.com"
+
+    def test_create_profile(self):
+        # Create a new account, which should have an empty profile by default.
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+
+        # Retrieve the profile, expecting default values
+        profile = profile_api.profile_info(username=self.USERNAME)
+        self.assertEqual(profile, {
+            'username': self.USERNAME,
+            'email': self.EMAIL,
+            'full_name': u'',
+        })
+
+    def test_update_full_name(self):
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+        profile_api.update_profile(self.USERNAME, full_name=u"ȻħȺɍłɇs")
+        profile = profile_api.profile_info(username=self.USERNAME)
+        self.assertEqual(profile['full_name'], u"ȻħȺɍłɇs")
+
+    @raises(profile_api.ProfileInvalidField)
+    @ddt.data('', 'a' * profile_api.FULL_NAME_MAX_LENGTH + 'a')
+    def test_update_full_name_invalid(self, invalid_name):
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+        profile_api.update_profile(self.USERNAME, full_name=invalid_name)
+
+    @raises(profile_api.ProfileUserNotFound)
+    def test_update_profile_no_user(self):
+        profile_api.update_profile(self.USERNAME, full_name="test")
+
+    def test_retrieve_profile_no_user(self):
+        profile = profile_api.profile_info("does not exist")
+        self.assertIs(profile, None)
+
+    def test_record_name_change_history(self):
+        account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+
+        # Change the name once
+        # Since the original name was an empty string, expect that the list
+        # of old names is empty
+        profile_api.update_profile(self.USERNAME, full_name="new name")
+        meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
+        self.assertEqual(meta, {})
+
+        # Change the name again and expect the new name is stored in the history
+        profile_api.update_profile(self.USERNAME, full_name="another new name")
+        meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
+
+        self.assertEqual(len(meta['old_names']), 1)
+        name, rationale, timestamp = meta['old_names'][0]
+        self.assertEqual(name, "new name")
+        self.assertEqual(rationale, u"")
+        self._assert_is_datetime(timestamp)
+
+        # Change the name a third time and expect both names are stored in the history
+        profile_api.update_profile(self.USERNAME, full_name="yet another new name")
+        meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
+
+        self.assertEqual(len(meta['old_names']), 2)
+        name, rationale, timestamp = meta['old_names'][1]
+        self.assertEqual(name, "another new name")
+        self.assertEqual(rationale, u"")
+        self._assert_is_datetime(timestamp)
+
+    def _assert_is_datetime(self, timestamp):
+        if not timestamp:
+            return False
+        try:
+            parse_datetime(timestamp)
+        except ValueError:
+            return False
+        else:
+            return True
diff --git a/lms/djangoapps/student_account/__init__.py b/lms/djangoapps/student_account/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/lms/djangoapps/student_account/test/__init__.py b/lms/djangoapps/student_account/test/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..7d8e587a84d4d3e1412ba3162826ceb6ae63852e
--- /dev/null
+++ b/lms/djangoapps/student_account/test/test_views.py
@@ -0,0 +1,204 @@
+# -*- coding: utf-8 -*-
+""" Tests for student account views. """
+
+from urllib import urlencode
+from mock import patch
+import ddt
+from django.test import TestCase
+from django.conf import settings
+from django.core.urlresolvers import reverse
+
+from util.testing import UrlResetMixin
+from user_api.api import account as account_api
+from user_api.api import profile as profile_api
+
+
+@ddt.ddt
+class StudentAccountViewTest(UrlResetMixin, TestCase):
+    """ Tests for the student account views. """
+
+    USERNAME = u"heisenberg"
+    ALTERNATE_USERNAME = u"walt"
+    PASSWORD = u"ḅḷüëṡḳÿ"
+    OLD_EMAIL = u"walter@graymattertech.com"
+    NEW_EMAIL = u"walt@savewalterwhite.com"
+
+    INVALID_EMAILS = [
+        None,
+        u"",
+        u"a",
+        "no_domain",
+        "no+domain",
+        "@",
+        "@domain.com",
+        "test@no_extension",
+
+        # Long email -- subtract the length of the @domain
+        # except for one character (so we exceed the max length limit)
+        u"{user}@example.com".format(
+            user=(u'e' * (account_api.EMAIL_MAX_LENGTH - 11))
+        )
+    ]
+
+    INVALID_KEY = u"123abc"
+
+    @patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True})
+    def setUp(self):
+        super(StudentAccountViewTest, self).setUp()
+
+        # Create/activate a new account
+        activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.OLD_EMAIL)
+        account_api.activate_account(activation_key)
+
+        # Login
+        result = self.client.login(username=self.USERNAME, password=self.PASSWORD)
+        self.assertTrue(result)
+
+    def _change_email(self, new_email, password):
+        """Request to change the user's email. """
+        data = {}
+
+        if new_email is not None:
+            data['new_email'] = new_email
+        if password is not None:
+            # We can't pass a Unicode object to urlencode, so we encode the Unicode object
+            data['password'] = password.encode('utf-8')
+
+        response = self.client.put(
+            path=reverse('email_change_request'),
+            data=urlencode(data),
+            content_type='application/x-www-form-urlencoded'
+        )
+
+        return response
+
+    def test_index(self):
+        response = self.client.get(reverse('account_index'))
+        self.assertContains(response, "Student Account")
+
+    def test_email_change_request_handler(self):
+        response = self._change_email(self.NEW_EMAIL, self.PASSWORD)
+        self.assertEquals(response.status_code, 204)
+
+        # Verify that the email associated with the account remains unchanged
+        profile_info = profile_api.profile_info(self.USERNAME)
+        self.assertEquals(profile_info['email'], self.OLD_EMAIL)
+
+    def test_email_change_wrong_password(self):
+        response = self._change_email(self.NEW_EMAIL, "wrong password")
+        self.assertEqual(response.status_code, 401)
+
+    def test_email_change_request_internal_error(self):
+        # Patch account API to raise an internal error when an email change is requested
+        with patch('student_account.views.account_api.request_email_change') as mock_call:
+            mock_call.side_effect = account_api.AccountUserNotFound
+            response = self._change_email(self.NEW_EMAIL, self.PASSWORD)
+
+        self.assertEquals(response.status_code, 500)
+
+    def test_email_change_request_email_taken_by_active_account(self):
+        # Create/activate a second user with the new email
+        activation_key = account_api.create_account(self.ALTERNATE_USERNAME, self.PASSWORD, self.NEW_EMAIL)
+        account_api.activate_account(activation_key)
+
+        # Request to change the original user's email to the email now used by the second user
+        response = self._change_email(self.NEW_EMAIL, self.PASSWORD)
+        self.assertEquals(response.status_code, 409)
+
+    def test_email_change_request_email_taken_by_inactive_account(self):
+        # Create a second user with the new email, but don't active them
+        account_api.create_account(self.ALTERNATE_USERNAME, self.PASSWORD, self.NEW_EMAIL)
+
+        # Request to change the original user's email to the email used by the inactive user
+        response = self._change_email(self.NEW_EMAIL, self.PASSWORD)
+        self.assertEquals(response.status_code, 204)
+
+    @ddt.data(*INVALID_EMAILS)
+    def test_email_change_request_email_invalid(self, invalid_email):
+        # Request to change the user's email to an invalid address
+        response = self._change_email(invalid_email, self.PASSWORD)
+        self.assertEquals(response.status_code, 400)
+
+    def test_email_change_confirmation_handler(self):
+        # Get an email change activation key
+        activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.PASSWORD)
+
+        # Follow the link sent in the confirmation email
+        response = self.client.get(reverse('email_change_confirm', kwargs={'key': activation_key}))
+        self.assertContains(response, "Email change successful")
+
+        # Verify that the email associated with the account has changed
+        profile_info = profile_api.profile_info(self.USERNAME)
+        self.assertEquals(profile_info['email'], self.NEW_EMAIL)
+
+    def test_email_change_confirmation_invalid_key(self):
+        # Visit the confirmation page with an invalid key
+        response = self.client.get(reverse('email_change_confirm', kwargs={'key': self.INVALID_KEY}))
+        self.assertContains(response, "Something went wrong")
+
+        # Verify that the email associated with the account has not changed
+        profile_info = profile_api.profile_info(self.USERNAME)
+        self.assertEquals(profile_info['email'], self.OLD_EMAIL)
+
+    def test_email_change_confirmation_email_already_exists(self):
+        # Get an email change activation key
+        email_activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.PASSWORD)
+
+        # Create/activate a second user with the new email
+        account_activation_key = account_api.create_account(self.ALTERNATE_USERNAME, self.PASSWORD, self.NEW_EMAIL)
+        account_api.activate_account(account_activation_key)
+
+        # Follow the link sent to the original user
+        response = self.client.get(reverse('email_change_confirm', kwargs={'key': email_activation_key}))
+        self.assertContains(response, "address you wanted to use is already used")
+
+        # Verify that the email associated with the original account has not changed
+        profile_info = profile_api.profile_info(self.USERNAME)
+        self.assertEquals(profile_info['email'], self.OLD_EMAIL)
+
+    def test_email_change_confirmation_internal_error(self):
+        # Get an email change activation key
+        activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.PASSWORD)
+
+        # Patch account API to return an internal error
+        with patch('student_account.views.account_api.confirm_email_change') as mock_call:
+            mock_call.side_effect = account_api.AccountInternalError
+            response = self.client.get(reverse('email_change_confirm', kwargs={'key': activation_key}))
+
+        self.assertContains(response, "Something went wrong")
+
+    def test_change_email_request_missing_email_param(self):
+        response = self._change_email(None, self.PASSWORD)
+        self.assertEqual(response.status_code, 400)
+
+    def test_change_email_request_missing_password_param(self):
+        response = self._change_email(self.OLD_EMAIL, None)
+        self.assertEqual(response.status_code, 400)
+
+    @ddt.data(
+        ('get', 'account_index'),
+        ('put', 'email_change_request')
+    )
+    @ddt.unpack
+    def test_require_login(self, method, url_name):
+        # Access the page while logged out
+        self.client.logout()
+        url = reverse(url_name)
+        response = getattr(self.client, method)(url, follow=True)
+
+        # Should have been redirected to the login page
+        self.assertEqual(len(response.redirect_chain), 1)
+        self.assertIn('accounts/login?next=', response.redirect_chain[0][0])
+
+    @ddt.data(
+        ('get', 'account_index'),
+        ('put', 'email_change_request')
+    )
+    @ddt.unpack
+    def test_require_http_method(self, correct_method, url_name):
+        wrong_methods = {'get', 'put', 'post', 'head', 'options', 'delete'} - {correct_method}
+        url = reverse(url_name)
+
+        for method in wrong_methods:
+            response = getattr(self.client, method)(url)
+            self.assertEqual(response.status_code, 405)
diff --git a/lms/djangoapps/student_account/urls.py b/lms/djangoapps/student_account/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..90e612926798e5795e4dc5c6542d5470a12e144e
--- /dev/null
+++ b/lms/djangoapps/student_account/urls.py
@@ -0,0 +1,8 @@
+from django.conf.urls import patterns, url
+
+urlpatterns = patterns(
+    'student_account.views',
+    url(r'^$', 'index', name='account_index'),
+    url(r'^email_change_request$', 'email_change_request_handler', name='email_change_request'),
+    url(r'^email_change_confirm/(?P<key>[^/]*)$', 'email_change_confirmation_handler', name='email_change_confirm'),
+)
diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..4fba5f7c8a19ef02b4eb6105e1bbad45559d771f
--- /dev/null
+++ b/lms/djangoapps/student_account/views.py
@@ -0,0 +1,183 @@
+""" Views for a student's account information. """
+
+from django.conf import settings
+from django.http import (
+    QueryDict, HttpResponse,
+    HttpResponseBadRequest, HttpResponseServerError
+)
+from django.core.mail import send_mail
+from django_future.csrf import ensure_csrf_cookie
+from django.contrib.auth.decorators import login_required
+from django.views.decorators.http import require_http_methods
+from edxmako.shortcuts import render_to_response, render_to_string
+from microsite_configuration import microsite
+from user_api.api import account as account_api
+from user_api.api import profile as profile_api
+
+
+@login_required
+@require_http_methods(['GET'])
+def index(request):
+    """Render the account info page.
+
+    Args:
+        request (HttpRequest)
+
+    Returns:
+        HttpResponse: 200 if the index page was sent successfully
+        HttpResponse: 302 if not logged in (redirect to login page)
+        HttpResponse: 405 if using an unsupported HTTP method
+
+    Example usage:
+
+        GET /account
+
+    """
+    return render_to_response(
+        'student_account/index.html', {
+            'disable_courseware_js': True,
+        }
+    )
+
+
+@login_required
+@require_http_methods(['PUT'])
+@ensure_csrf_cookie
+def email_change_request_handler(request):
+    """Handle a request to change the user's email address.
+
+    Args:
+        request (HttpRequest)
+
+    Returns:
+        HttpResponse: 204 if the confirmation email was sent successfully
+        HttpResponse: 302 if not logged in (redirect to login page)
+        HttpResponse: 400 if the format of the new email is incorrect
+        HttpResponse: 401 if the provided password (in the form) is incorrect
+        HttpResponse: 405 if using an unsupported HTTP method
+        HttpResponse: 409 if the provided email is already in use
+        HttpResponse: 500 if the user to which the email change will be applied
+                          does not exist
+
+    Example usage:
+
+        PUT /account/email_change_request
+
+    """
+    put = QueryDict(request.body)
+    user = request.user
+    password = put.get('password')
+
+    username = user.username
+    old_email = profile_api.profile_info(username)['email']
+    new_email = put.get('new_email')
+
+    if new_email is None:
+        return HttpResponseBadRequest("Missing param 'new_email'")
+    if password is None:
+        return HttpResponseBadRequest("Missing param 'password'")
+
+    try:
+        key = account_api.request_email_change(username, new_email, password)
+    except account_api.AccountUserNotFound:
+        return HttpResponseServerError()
+    except account_api.AccountEmailAlreadyExists:
+        return HttpResponse(status=409)
+    except account_api.AccountEmailInvalid:
+        return HttpResponseBadRequest()
+    except account_api.AccountNotAuthorized:
+        return HttpResponse(status=401)
+
+    context = {
+        'key': key,
+        'old_email': old_email,
+        'new_email': new_email,
+    }
+
+    subject = render_to_string('student_account/emails/email_change_request/subject_line.txt', context)
+    subject = ''.join(subject.splitlines())
+    message = render_to_string('student_account/emails/email_change_request/message_body.txt', context)
+
+    from_address = microsite.get_value(
+        'email_from_address',
+        settings.DEFAULT_FROM_EMAIL
+    )
+
+    # Email new address
+    send_mail(subject, message, from_address, [new_email])
+
+    # A 204 is intended to allow input for actions to take place
+    # without causing a change to the user agent's active document view.
+    return HttpResponse(status=204)
+
+
+@login_required
+@require_http_methods(['GET'])
+def email_change_confirmation_handler(request, key):
+    """Complete a change of the user's email address.
+
+    This is called when the activation link included in the confirmation
+    email is clicked.
+
+    Args:
+        request (HttpRequest)
+
+    Returns:
+        HttpResponse: 200 if the email change is successful, the activation key
+                          is invalid, the new email is already in use, or the
+                          user to which the email change will be applied does
+                          not exist
+        HttpResponse: 302 if not logged in (redirect to login page)
+        HttpResponse: 405 if using an unsupported HTTP method
+
+    Example usage:
+
+        GET /account/email_change_confirm/{key}
+
+    """
+    try:
+        old_email, new_email = account_api.confirm_email_change(key)
+    except account_api.AccountNotAuthorized:
+        return render_to_response(
+            'student_account/email_change_failed.html', {
+                'disable_courseware_js': True,
+                'error': 'key_invalid',
+            }
+        )
+    except account_api.AccountEmailAlreadyExists:
+        return render_to_response(
+            'student_account/email_change_failed.html', {
+                'disable_courseware_js': True,
+                'error': 'email_used',
+            }
+        )
+    except account_api.AccountInternalError:
+        return render_to_response(
+            'student_account/email_change_failed.html', {
+                'disable_courseware_js': True,
+                'error': 'internal',
+            }
+        )
+
+    context = {
+        'old_email': old_email,
+        'new_email': new_email,
+    }
+
+    subject = render_to_string('student_account/emails/email_change_confirmation/subject_line.txt', context)
+    subject = ''.join(subject.splitlines())
+    message = render_to_string('student_account/emails/email_change_confirmation/message_body.txt', context)
+
+    from_address = microsite.get_value(
+        'email_from_address',
+        settings.DEFAULT_FROM_EMAIL
+    )
+
+    # Notify both old and new emails of the change
+    send_mail(subject, message, from_address, [old_email, new_email])
+
+    return render_to_response(
+        'student_account/email_change_successful.html', {
+            'disable_courseware_js': True,
+        }
+    )
diff --git a/lms/djangoapps/student_profile/__init__.py b/lms/djangoapps/student_profile/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/lms/djangoapps/student_profile/test/__init__.py b/lms/djangoapps/student_profile/test/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/lms/djangoapps/student_profile/test/test_views.py b/lms/djangoapps/student_profile/test/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..ffb4ab6404cce47616604cf210d3fd938b771cb8
--- /dev/null
+++ b/lms/djangoapps/student_profile/test/test_views.py
@@ -0,0 +1,113 @@
+# -*- coding: utf-8 -*-
+""" Tests for student profile views. """
+
+from urllib import urlencode
+from mock import patch
+import ddt
+from django.test import TestCase
+from django.conf import settings
+from django.core.urlresolvers import reverse
+
+from util.testing import UrlResetMixin
+from user_api.api import account as account_api
+from user_api.api import profile as profile_api
+
+
+@ddt.ddt
+class StudentProfileViewTest(UrlResetMixin, TestCase):
+    """ Tests for the student profile views. """
+
+    USERNAME = u"heisenberg"
+    PASSWORD = u"ḅḷüëṡḳÿ"
+    EMAIL = u"walt@savewalterwhite.com"
+    FULL_NAME = u"𝖂𝖆𝖑𝖙𝖊𝖗 𝖂𝖍𝖎𝖙𝖊"
+
+    @patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True})
+    def setUp(self):
+        super(StudentProfileViewTest, self).setUp()
+
+        # Create/activate a new account
+        activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
+        account_api.activate_account(activation_key)
+
+        # Login
+        result = self.client.login(username=self.USERNAME, password=self.PASSWORD)
+        self.assertTrue(result)
+
+    def test_index(self):
+        response = self.client.get(reverse('profile_index'))
+        self.assertContains(response, "Student Profile")
+
+    def test_name_change_handler(self):
+        # Verify that the name on the account is blank
+        profile_info = profile_api.profile_info(self.USERNAME)
+        self.assertEquals(profile_info['full_name'], '')
+
+        response = self._change_name(self.FULL_NAME)
+        self.assertEquals(response.status_code, 204)
+
+        # Verify that the name on the account has been changed
+        profile_info = profile_api.profile_info(self.USERNAME)
+        self.assertEquals(profile_info['full_name'], self.FULL_NAME)
+
+    def test_name_change_invalid(self):
+        # Name cannot be an empty string
+        response = self._change_name('')
+        self.assertEquals(response.status_code, 400)
+
+    def test_name_change_missing_params(self):
+        response = self._change_name(None)
+        self.assertEquals(response.status_code, 400)
+
+    @patch('student_profile.views.profile_api.update_profile')
+    def test_name_change_internal_error(self, mock_call):
+        # This can't happen if the user is logged in, but test it anyway
+        mock_call.side_effect = profile_api.ProfileUserNotFound
+        response = self._change_name(self.FULL_NAME)
+        self.assertEqual(response.status_code, 500)
+
+    @ddt.data(
+        ('get', 'profile_index'),
+        ('put', 'name_change')
+    )
+    @ddt.unpack
+    def test_require_login(self, method, url_name):
+        # Access the page while logged out
+        self.client.logout()
+        url = reverse(url_name)
+        response = getattr(self.client, method)(url, follow=True)
+
+        # Should have been redirected to the login page
+        self.assertEqual(len(response.redirect_chain), 1)
+        self.assertIn('accounts/login?next=', response.redirect_chain[0][0])
+
+    @ddt.data(
+        ('get', 'profile_index'),
+        ('put', 'name_change')
+    )
+    @ddt.unpack
+    def test_require_http_method(self, correct_method, url_name):
+        wrong_methods = {'get', 'put', 'post', 'head', 'options', 'delete'} - {correct_method}
+        url = reverse(url_name)
+
+        for method in wrong_methods:
+            response = getattr(self.client, method)(url)
+            self.assertEqual(response.status_code, 405)
+
+    def _change_name(self, new_name):
+        """Request a name change.
+
+        Returns:
+            HttpResponse
+
+        """
+        data = {}
+        if new_name is not None:
+            # We can't pass a Unicode object to urlencode, so we encode the Unicode object
+            data['new_name'] = new_name.encode('utf-8')
+
+        return self.client.put(
+            path=reverse('name_change'),
+            data=urlencode(data),
+            content_type= 'application/x-www-form-urlencoded'
+        )
diff --git a/lms/djangoapps/student_profile/urls.py b/lms/djangoapps/student_profile/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..113e2e89ed2653d50566ee62056d48d054fd78e8
--- /dev/null
+++ b/lms/djangoapps/student_profile/urls.py
@@ -0,0 +1,7 @@
+from django.conf.urls import patterns, url
+
+urlpatterns = patterns(
+    'student_profile.views',
+    url(r'^$', 'index', name='profile_index'),
+    url(r'^name_change$', 'name_change_handler', name='name_change'),
+)
diff --git a/lms/djangoapps/student_profile/views.py b/lms/djangoapps/student_profile/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..dc58795a48e35774bf4d78780ecbf73581a51bd4
--- /dev/null
+++ b/lms/djangoapps/student_profile/views.py
@@ -0,0 +1,81 @@
+""" Views for a student's profile information. """
+
+from django.http import (
+    QueryDict, HttpResponse,
+    HttpResponseBadRequest, HttpResponseServerError
+)
+from django_future.csrf import ensure_csrf_cookie
+from django.contrib.auth.decorators import login_required
+from django.views.decorators.http import require_http_methods
+from edxmako.shortcuts import render_to_response
+from user_api.api import profile as profile_api
+from third_party_auth import pipeline
+
+
+@login_required
+@require_http_methods(['GET'])
+def index(request):
+    """Render the profile info page.
+
+    Args:
+        request (HttpRequest)
+
+    Returns:
+        HttpResponse: 200 if successful
+        HttpResponse: 302 if not logged in (redirect to login page)
+        HttpResponse: 405 if using an unsupported HTTP method
+
+    Example usage:
+
+        GET /profile
+
+    """
+    user = request.user
+
+    return render_to_response(
+        'student_profile/index.html', {
+            'disable_courseware_js': True,
+            'provider_user_states': pipeline.get_provider_user_states(user),
+        }
+    )
+
+
+@login_required
+@require_http_methods(['PUT'])
+@ensure_csrf_cookie
+def name_change_handler(request):
+    """Change the user's name.
+
+    Args:
+        request (HttpRequest)
+
+    Returns:
+        HttpResponse: 204 if successful
+        HttpResponse: 302 if not logged in (redirect to login page)
+        HttpResponse: 400 if the provided name is invalid
+        HttpResponse: 405 if using an unsupported HTTP method
+        HttpResponse: 500 if an unexpected error occurs.
+
+    Example usage:
+
+        PUT /profile/name_change
+
+    """
+    put = QueryDict(request.body)
+
+    username = request.user.username
+    new_name = put.get('new_name')
+
+    if new_name is None:
+        return HttpResponseBadRequest("Missing param 'new_name'")
+
+    try:
+        profile_api.update_profile(username, full_name=new_name)
+    except profile_api.ProfileInvalidField:
+        return HttpResponseBadRequest()
+    except profile_api.ProfileUserNotFound:
+        return HttpResponseServerError()
+
+    # A 204 is intended to allow input for actions to take place
+    # without causing a change to the user agent's active document view.
+    return HttpResponse(status=204)
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 9ab1c5b0b9d1de66cb1478175b53fff5e5441e99..ca3e936e1beff5188aceb6d8ceb64c55a7de2507 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -294,6 +294,9 @@ FEATURES = {
     # Video Abstraction Layer used to allow video teams to manage video assets
     # independently of courseware. https://github.com/edx/edx-val
     'ENABLE_VIDEO_ABSTRACTION_LAYER_API': False,
+
+    # Enable the new dashboard, account, and profile pages
+    'ENABLE_NEW_DASHBOARD': False,
 }
 
 # Ignore static asset files on import which match this pattern
@@ -973,11 +976,25 @@ courseware_js = (
     sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/modules/**/*.js'))
 )
 
-main_vendor_js = [
+
+# Before a student accesses courseware, we do not
+# need many of the JS dependencies.  This includes
+# only the dependencies used everywhere in the LMS
+# (including the dashboard/account/profile pages)
+# Currently, this partially duplicates the "main vendor"
+# JavaScript file, so only one of the two should be included
+# on a page at any time.
+# In the future, we will likely refactor this to use
+# RequireJS and an optimizer.
+base_vendor_js = [
+    'js/vendor/jquery.min.js',
+    'js/vendor/jquery.cookie.js',
+]
+
+main_vendor_js = base_vendor_js + [
     'js/vendor/require.js',
     'js/RequireJS-namespace-undefine.js',
     'js/vendor/json2.js',
-    'js/vendor/jquery.min.js',
     'js/vendor/jquery-ui.min.js',
     'js/vendor/jquery.cookie.js',
     'js/vendor/jquery.qtip.min.js',
@@ -1010,6 +1027,12 @@ open_ended_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/open_end
 notes_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/notes/**/*.js'))
 instructor_dash_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/instructor_dashboard/**/*.js'))
 
+# JavaScript used by the student account and profile pages
+# These are not courseware, so they do not need many of the courseware-specific
+# JavaScript modules.
+student_account_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/student_account/**/*.js'))
+student_profile_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/student_profile/**/*.js'))
+
 PIPELINE_CSS = {
     'style-vendor': {
         'source_filenames': [
@@ -1090,9 +1113,6 @@ common_js = set(rooted_glob(COMMON_ROOT / 'static', 'coffee/src/**/*.js')) - set
 project_js = set(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/**/*.js')) - set(courseware_js + discussion_js + staff_grading_js + open_ended_js + notes_js + instructor_dash_js)
 
 
-
-# test_order: Determines the position of this chunk of javascript on
-# the jasmine test page
 PIPELINE_JS = {
     'application': {
 
@@ -1108,53 +1128,54 @@ PIPELINE_JS = {
             'js/src/ie_shim.js',
         ],
         'output_filename': 'js/lms-application.js',
-
-        'test_order': 1,
     },
     'courseware': {
         'source_filenames': courseware_js,
         'output_filename': 'js/lms-courseware.js',
-        'test_order': 2,
+    },
+    'base_vendor': {
+        'source_filenames': base_vendor_js,
+        'output_filename': 'js/lms-base-vendor.js',
     },
     'main_vendor': {
         'source_filenames': main_vendor_js,
         'output_filename': 'js/lms-main_vendor.js',
-        'test_order': 0,
     },
     'module-descriptor-js': {
         'source_filenames': rooted_glob(COMMON_ROOT / 'static/', 'xmodule/descriptors/js/*.js'),
         'output_filename': 'js/lms-module-descriptors.js',
-        'test_order': 8,
     },
     'module-js': {
         'source_filenames': rooted_glob(COMMON_ROOT / 'static', 'xmodule/modules/js/*.js'),
         'output_filename': 'js/lms-modules.js',
-        'test_order': 3,
     },
     'discussion': {
         'source_filenames': discussion_js,
         'output_filename': 'js/discussion.js',
-        'test_order': 4,
     },
     'staff_grading': {
         'source_filenames': staff_grading_js,
         'output_filename': 'js/staff_grading.js',
-        'test_order': 5,
     },
     'open_ended': {
         'source_filenames': open_ended_js,
         'output_filename': 'js/open_ended.js',
-        'test_order': 6,
     },
     'notes': {
         'source_filenames': notes_js,
         'output_filename': 'js/notes.js',
-        'test_order': 7
     },
     'instructor_dash': {
         'source_filenames': instructor_dash_js,
         'output_filename': 'js/instructor_dash.js',
-        'test_order': 9,
+    },
+    'student_account': {
+        'source_filenames': student_account_js,
+        'output_filename': 'js/student_account.js'
+    },
+    'student_profile': {
+        'source_filenames': student_profile_js,
+        'output_filename': 'js/student_profile.js'
     },
 }
 
@@ -1331,6 +1352,7 @@ INSTALLED_APPS = (
     'circuit',
     'courseware',
     'student',
+
     'static_template_view',
     'staticbook',
     'track',
diff --git a/lms/static/js/student_account/account.js b/lms/static/js/student_account/account.js
new file mode 100644
index 0000000000000000000000000000000000000000..172f09db7141f3ef92f535b2ecbe5d7c9a585829
--- /dev/null
+++ b/lms/static/js/student_account/account.js
@@ -0,0 +1,141 @@
+var edx = edx || {};
+
+(function($) {
+    'use strict';
+
+    edx.student = edx.student || {};
+
+    edx.student.account = (function() {
+        var _fn = {
+            init: function() {
+                _fn.ajax.init();
+                _fn.eventHandlers.init();
+            },
+
+            eventHandlers: {
+                init: function() {
+                    _fn.eventHandlers.submit();
+                },
+
+                submit: function() {
+                    $('#email-change-form').submit( _fn.form.submit );
+                }
+            },
+
+            ajax: {
+                init: function() {
+                    var csrftoken = _fn.cookie.get( 'csrftoken' );
+
+                    $.ajaxSetup({
+                        beforeSend: function(xhr, settings) {
+                            if ( settings.type === 'PUT' ) {
+                                xhr.setRequestHeader( 'X-CSRFToken', csrftoken );
+                            }
+                        }
+                    });
+                },
+
+                put: function( url, data ) {
+                    $.ajax({
+                        url: url,
+                        type: 'PUT',
+                        data: data
+                    });
+                }
+            },
+
+            cookie: {
+                get: function( name ) {
+                    return $.cookie(name);
+                }
+            },
+
+            form: {
+                isValid: true,
+
+                submit: function( event ) {
+                    var $email = $('#new-email'),
+                        $password = $('#password'),
+                        data = {
+                            new_email: $email.val(),
+                            password: $password.val()
+                        };
+
+                    event.preventDefault();
+
+                    _fn.form.validate( $('#email-change-form') );
+
+                    if ( _fn.form.isValid ) {
+                        _fn.ajax.put( 'email_change_request', data );
+                    }
+                },
+
+                validate: function( $form ) {
+                    _fn.form.isValid = true;
+                    $form.find('input').each( _fn.valid.input );
+                }
+            },
+
+            regex: {
+                email: function() {
+                    // taken from http://parsleyjs.org/
+                    return /^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$/i;
+                }
+            },
+
+            valid: {
+                email: function( str ) {
+                    var valid = false,
+                        len = str ? str.length : 0,
+                        regex = _fn.regex.email();
+
+                    if ( 0 < len && len < 254 ) {
+                        valid = regex.test( str );
+                    }
+
+                    return valid;
+                },
+
+                input: function() {
+                    var $el = $(this),
+                        validation = $el.data('validate'),
+                        value = $el.val(),
+                        valid = true;
+
+
+                    if ( validation && validation.length > 0 ) {
+                        $el.removeClass('error')
+                            .css('border-color', '#c8c8c8'); // temp. for development
+
+                        // Required field
+                        if ( validation.indexOf('required') > -1 ) {
+                            valid = _fn.valid.required( value );
+                        }
+
+                        // Email address
+                        if ( valid && validation.indexOf('email') > -1 ) {
+                            valid = _fn.valid.email( value );
+                        }
+
+                        if ( !valid ) {
+                            $el.addClass('error')
+                                .css('border-color', '#f00'); // temp. for development
+                            _fn.form.isValid = false;
+                        }
+                    }
+                },
+
+                required: function( str ) {
+                    return ( str && str.length > 0 ) ? true : false;
+                }
+            }
+        };
+
+        return {
+            init: _fn.init
+        };
+    })();
+
+    edx.student.account.init();
+
+})(jQuery);
diff --git a/lms/static/js/student_profile/profile.js b/lms/static/js/student_profile/profile.js
new file mode 100644
index 0000000000000000000000000000000000000000..9a7665878a77ac1e4d55257fc83d98ec7b1ff91d
--- /dev/null
+++ b/lms/static/js/student_profile/profile.js
@@ -0,0 +1,76 @@
+var edx = edx || {};
+
+(function($) {
+    'use strict';
+
+    edx.student = edx.student || {};
+
+    edx.student.profile = (function() {
+
+        var _fn = {
+            init: function() {
+                _fn.ajax.init();
+                _fn.eventHandlers.init();
+            },
+
+            eventHandlers: {
+                init: function() {
+                    _fn.eventHandlers.submit();
+                },
+
+                submit: function() {
+                    $("#name-change-form").submit( _fn.form.submit );
+                }
+            },
+
+            form: {
+                submit: function( event ) {
+                    var $newName = $('#new-name');
+                    var data = {
+                        new_name: $newName.val()
+                    };
+
+                    event.preventDefault();
+                    _fn.ajax.put( 'name_change', data );
+                }
+            },
+
+            ajax: {
+                init: function() {
+                    var csrftoken = _fn.cookie.get( 'csrftoken' );
+
+                    $.ajaxSetup({
+                        beforeSend: function(xhr, settings) {
+                            if ( settings.type === 'PUT' ) {
+                                xhr.setRequestHeader( 'X-CSRFToken', csrftoken );
+                            }
+                        }
+                    });
+                },
+
+                put: function( url, data ) {
+                    $.ajax({
+                        url: url,
+                        type: 'PUT',
+                        data: data
+                    });
+                }
+            },
+
+            cookie: {
+                get: function( name ) {
+                    return $.cookie(name);
+                }
+            },
+
+        };
+
+        return {
+            init: _fn.init
+        };
+
+    })();
+
+    edx.student.profile.init();
+
+})(jQuery);
diff --git a/lms/templates/main.html b/lms/templates/main.html
index 3c5df9f2d131d7a091e0ae8d8eba227ee9d1df30..4c5d50ef1cbb735592f997853c5fff4f82c6409f 100644
--- a/lms/templates/main.html
+++ b/lms/templates/main.html
@@ -59,7 +59,11 @@
   <%static:css group='style-app-extend1'/>
   <%static:css group='style-app-extend2'/>
 
-  <%static:js group='main_vendor'/>
+  % if disable_courseware_js:
+    <%static:js group='base_vendor'/>
+  % else:
+    <%static:js group='main_vendor'/>
+  % endif
 
   <%block name="headextra"/>
 
@@ -131,8 +135,10 @@
   <%include file="${footer_file}" />
 
   <script>window.baseUrl = "${settings.STATIC_URL}";</script>
-  <%static:js group='application'/>
-  <%static:js group='module-js'/>
+  % if not disable_courseware_js:
+    <%static:js group='application'/>
+    <%static:js group='module-js'/>
+  % endif
 
   <%block name="js_extra"/>
 
diff --git a/lms/templates/student_account/email_change_failed.html b/lms/templates/student_account/email_change_failed.html
new file mode 100644
index 0000000000000000000000000000000000000000..5b24f9c26234466a27200d84e912a2e0a3ab787f
--- /dev/null
+++ b/lms/templates/student_account/email_change_failed.html
@@ -0,0 +1,30 @@
+<%! from django.utils.translation import ugettext as _ %>
+<%! from django.core.urlresolvers import reverse %>
+<%inherit file="../main.html" />
+
+<section class="container activation">
+    <section class="message">
+        <h1 class="invalid">${_("Email change failed.")}</h1>
+        <hr class="horizontal-divider">
+
+        <p>
+            % if error is 'key_invalid' or error is 'internal':
+                ${_("Something went wrong. Please contact {support} for help.").format(
+                    support="<a href='mailto:{support_email}'>{support_email}</a>".format(
+                        support_email=settings.TECH_SUPPORT_EMAIL
+                    )
+                )}
+            % elif error is 'email_used':
+                ${_("The email address you wanted to use is already used by another "
+                "{platform_name} account.").format(platform_name=settings.PLATFORM_NAME)}
+            % endif
+        </p>
+
+        <p>
+            ${_("You can try again from the {link_start}account settings{link_end} page.").format(
+                link_start="<a href='{url}'>".format(url=reverse('account_index')),
+                link_end="</a>"
+            )}
+        </p>
+    </section>
+</section>
diff --git a/lms/templates/student_account/email_change_successful.html b/lms/templates/student_account/email_change_successful.html
new file mode 100644
index 0000000000000000000000000000000000000000..5cc4d17444f171580e8a3092aaa4930ddf2131f3
--- /dev/null
+++ b/lms/templates/student_account/email_change_successful.html
@@ -0,0 +1,18 @@
+<%! from django.utils.translation import ugettext as _ %>
+<%! from django.core.urlresolvers import reverse %>
+<%inherit file="../main.html" />
+
+<section class="container activation">
+    <section class="message">
+        <h1 class="valid">${_("Email change successful!")}</h1>
+        <hr class="horizontal-divider">
+
+        <p>
+            ${_("You should see your new email address listed on the "
+                "{link_start}account settings{link_end} page.").format(
+                link_start="<a href='{url}'>".format(url=reverse('account_index')),
+                link_end="</a>",
+            )}
+        </p>
+    </section>
+</section>
diff --git a/lms/templates/student_account/emails/email_change_confirmation/message_body.txt b/lms/templates/student_account/emails/email_change_confirmation/message_body.txt
new file mode 100644
index 0000000000000000000000000000000000000000..c54df4a3690cad3c9de1e3f0d20ef1117b6e0c7e
--- /dev/null
+++ b/lms/templates/student_account/emails/email_change_confirmation/message_body.txt
@@ -0,0 +1,19 @@
+<%! from django.utils.translation import ugettext as _ %>
+
+## TODO: Get sign-off from Product on new copy, and think about
+## turning this into a large, multi-line message for i18n purposes.
+## Greeting
+${_("Hi there,")}
+
+## Preamble
+${_("You successfully changed the email address associated with your"
+    "{platform_name} account from {old_email} to {new_email}.").format(
+        platform_name=settings.PLATFORM_NAME,
+        old_email=old_email,
+        new_email=new_email
+    )
+}
+
+## Farewell
+${_("Thanks,")}
+${_("- The edX Team")}
diff --git a/lms/templates/student_account/emails/email_change_confirmation/subject_line.txt b/lms/templates/student_account/emails/email_change_confirmation/subject_line.txt
new file mode 100644
index 0000000000000000000000000000000000000000..57eeadc97c9b2569bb83914ab8274a0998c1cb9b
--- /dev/null
+++ b/lms/templates/student_account/emails/email_change_confirmation/subject_line.txt
@@ -0,0 +1,3 @@
+<%! from django.utils.translation import ugettext as _ %>
+
+${_("{platform_name} Email Change Successful").format(platform_name=settings.PLATFORM_NAME)}
diff --git a/lms/templates/student_account/emails/email_change_request/message_body.txt b/lms/templates/student_account/emails/email_change_request/message_body.txt
new file mode 100644
index 0000000000000000000000000000000000000000..9c38042a5b4af61693c013035a4a292aa6285e23
--- /dev/null
+++ b/lms/templates/student_account/emails/email_change_request/message_body.txt
@@ -0,0 +1,32 @@
+<%! from django.utils.translation import ugettext as _ %>
+
+## TODO: Get sign-off from Product on new copy, and think about
+## turning this into a large, multi-line message for i18n purposes.
+## Greeting
+${_("Hi there,")}
+
+## Preamble
+${_("There was recently a request to change the email address associated "
+    "with your {platform_name} account from {old_email} to {new_email}. "
+    "If you requested this change, please confirm your new email address "
+    "by following the link below:").format(
+        platform_name=settings.PLATFORM_NAME,
+        old_email=old_email,
+        new_email=new_email
+    )
+}
+
+## Confirmation link
+% if is_secure:
+https://${site}/account/email_change_confirm/${key}
+% else:
+http://${site}/account/email_change_confirm/${key}
+% endif
+
+## Closing
+${_("If you don't want to change the email address associated with your "
+  "account, ignore this message.")}
+
+## Farewell
+${_("Thanks,")}
+${_("- The edX Team")}
diff --git a/lms/templates/student_account/emails/email_change_request/subject_line.txt b/lms/templates/student_account/emails/email_change_request/subject_line.txt
new file mode 100644
index 0000000000000000000000000000000000000000..a409534d948aa229858a6131856e0b29ab746358
--- /dev/null
+++ b/lms/templates/student_account/emails/email_change_request/subject_line.txt
@@ -0,0 +1,3 @@
+<%! from django.utils.translation import ugettext as _ %>
+
+${_("{platform_name} Email Change Request").format(platform_name=settings.PLATFORM_NAME)}
diff --git a/lms/templates/student_account/index.html b/lms/templates/student_account/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..a3403fb473449e009454906c8cb6e76879a9d14d
--- /dev/null
+++ b/lms/templates/student_account/index.html
@@ -0,0 +1,28 @@
+<%! from django.utils.translation import ugettext as _ %>
+<%namespace name='static' file='/static_content.html'/>
+
+<%inherit file="../main.html" />
+
+<%block name="pagetitle">${_("Student Account")}</%block>
+
+<%block name="js_extra">
+    <%static:js group='student_account'/>
+</%block>
+
+<h1>Student Account</h1>
+
+<p>This is a placeholder for the student's account page.</p>
+
+<form id="email-change-form" method="post">
+    <input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}">
+
+    <label for="new-email">${_('New Address')}</label>
+    <input id="new-email" type="text" name="new-email" value="" placeholder="xsy@edx.org" data-validate="required email"/>
+
+    <label for="password">${_('Password')}</label>
+    <input id="password" type="password" name="password" value="" data-validate="required"/>
+
+    <div class="submit-button">
+        <input type="submit" id="email-change-submit" value="${_('Change My Email Address')}">
+    </div>
+</form>
diff --git a/lms/templates/student_profile/index.html b/lms/templates/student_profile/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..c2d829b0562b15876b2b20d9d9ed6f9a8a868f93
--- /dev/null
+++ b/lms/templates/student_profile/index.html
@@ -0,0 +1,68 @@
+<%! from django.utils.translation import ugettext as _ %>
+<%! from third_party_auth import pipeline %>
+<%namespace name='static' file='/static_content.html'/>
+
+<%inherit file="../main.html" />
+
+<%block name="pagetitle">${_("Student Profile")}</%block>
+
+<%block name="js_extra">
+    <%static:js group='student_profile'/>
+</%block>
+
+<h1>Student Profile</h1>
+
+<p>This is a placeholder for the student's profile page.</p>
+
+<form id="name-change-form">
+    <input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}">
+
+    <label for="new-name">${_('Full Name')}</label>
+    <input id="new-name" type="text" name="new-name" value="" placeholder="Xsy" />
+
+    <div class="submit-button">
+        <input type="submit" id="name-change-submit" value="${_('Change My Name')}">
+    </div>
+</form>
+
+<li class="controls--account">
+    <span class="title">
+        ## Translators: this section lists all the third-party authentication providers (for example, Google and LinkedIn) the user can link with or unlink from their edX account.
+        ${_("Connected Accounts")}
+    </span>
+    <span class="data">
+    <span class="third-party-auth">
+        % for state in provider_user_states:
+            <div class="auth-provider">
+                <div class="status">
+                    % if state.has_account:
+                        <i class="icon icon-link"></i> <span class="copy">${_('Linked')}</span>
+                    % else:
+                        <i class="icon icon-unlink"></i><span class="copy">${_('Not Linked')}</span>
+                    % endif
+                </div>
+                <span class="provider">${state.provider.NAME}</span>
+                <span class="control">
+                    <form
+                        action="${pipeline.get_disconnect_url(state.provider.NAME)}"
+                        method="post"
+                        name="${state.get_unlink_form_name()}">
+                        % if state.has_account:
+                            <input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}">
+
+                            <a href="#" onclick="document.${state.get_unlink_form_name()}.submit()">
+                                ## Translators: clicking on this removes the link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn).
+                                ${_("Unlink")}
+                            </a>
+                        % else:
+                            <a href="${pipeline.get_login_url(state.provider.NAME, pipeline.AUTH_ENTRY_PROFILE)}">
+                                ## Translators: clicking on this creates a link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn).
+                                ${_("Link")}
+                            </a>
+                        % endif
+                    </form>
+                </span>
+            </div>
+        % endfor
+    </span>
+</li>
diff --git a/lms/urls.py b/lms/urls.py
index bedf8e0ba97c318d26f8410af8cb5d41fcc75e18..7c0518da3e7046178f2da96463c71c9b3b8d70c1 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -537,6 +537,13 @@ if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
         url(r'', include('third_party_auth.urls')),
     )
 
+# If enabled, expose the URLs for the new dashboard, account, and profile pages
+if settings.FEATURES.get('ENABLE_NEW_DASHBOARD'):
+    urlpatterns += (
+        url(r'^profile/', include('student_profile.urls')),
+        url(r'^account/', include('student_account.urls')),
+    )
+
 urlpatterns = patterns(*urlpatterns)
 
 if settings.DEBUG: