diff --git a/mo/util.py b/mo/util.py
index d047aee40027a6f11076e0a93eccb03c555b22e7..42d0a813fb13934c43b1c03d2fafc8efff4e6ae1 100644
--- a/mo/util.py
+++ b/mo/util.py
@@ -118,7 +118,11 @@ def password_reset_url(token: str) -> str:
 
 
 def confirm_create_url(token: str) -> str:
-    return config.WEB_ROOT + 'auth/confirm?' + urllib.parse.urlencode({'token': token}, safe=':')
+    return config.WEB_ROOT + 'auth/confirm/r?' + urllib.parse.urlencode({'token': token}, safe=':')
+
+
+def confirm_email_url(token: str) -> str:
+    return config.WEB_ROOT + 'auth/confirm/e?' + urllib.parse.urlencode({'token': token}, safe=':')
 
 
 def send_new_account_email(user: db.User, token: str) -> bool:
@@ -158,6 +162,18 @@ def send_confirm_create_email(user: db.User, token: str) -> bool:
     '''.format(confirm_create_url(token))))
 
 
+def send_confirm_change_email(user: db.User, token: str) -> bool:
+    return send_user_email(user, 'Změna e-mailové adresy', textwrap.dedent('''\
+        Někdo (pravděpodobně Vy) požádal o nastavení e-mailové adresy k účtu
+        v Odevzdávacím systému Matematické olympiády na tuto adresu.
+        Pokud změnu chcete provést, následujte tento odkaz:
+
+                {}
+
+        Váš OSMO
+    '''.format(confirm_email_url(token))))
+
+
 def die(msg: str) -> NoReturn:
     print(msg, file=sys.stderr)
     sys.exit(1)
diff --git a/mo/web/auth.py b/mo/web/auth.py
index 379fa2df4e9b6f607e0685468941fcd4cb72dfea..3ecc1f5cf5cf5db474de975e2f7fde5f51415535 100644
--- a/mo/web/auth.py
+++ b/mo/web/auth.py
@@ -118,13 +118,73 @@ def incarnate(id):
 @app.route('/user/settings')
 def user_settings():
     sess = db.get_session()
-    roles = []
-    if g.user:
-        roles = (sess.query(db.UserRole)
-                 .filter_by(user_id=g.user.user_id)
-                 .options(joinedload(db.UserRole.place))
-                 .all())
-    return render_template('settings.html', roles=roles, roles_by_type=mo.rights.roles_by_type)
+
+    roles = (sess.query(db.UserRole)
+             .filter_by(user_id=g.user.user_id)
+             .options(joinedload(db.UserRole.place))
+             .all())
+
+    if g.user.is_org or g.user.is_admin:
+        pant = None
+    else:
+        pant = sess.query(db.Participant).get((g.user.user_id, mo.current_year))
+
+    return render_template('settings.html', user=g.user, pant=pant, roles=roles, roles_by_type=mo.rights.roles_by_type)
+
+
+class SettingsForm(FlaskForm):
+    email = mo_fields.Email(validators=[validators.DataRequired()])
+    current_passwd = wtforms.PasswordField('Aktuální heslo', validators=[validators.DataRequired()])
+    new_passwd = mo_fields.NewPassword(
+        description=mo.users.password_help + ' Pokud nechcete heslo měnit, ponechte toto políčko prázdné.',
+    )
+    new_passwd2 = mo_fields.RepeatPassword()
+    submit = wtforms.SubmitField('Nastavit')
+
+    def validate_current_passwd(form, field):
+        if not mo.users.check_password(g.user, field.data):
+            raise ValidationError('Chybné heslo.')
+
+
+@app.route('/user/settings/change', methods=('GET', 'POST'))
+def user_settings_change():
+    sess = db.get_session()
+    user = g.user
+
+    form = SettingsForm()
+    if not form.submit.data:
+        form.email.data = user.email
+
+    if form.validate_on_submit():
+        ok = True
+        if form.new_passwd.data:
+            app.logger.info(f'Settings: Změněno heslo uživatele #{user.user_id}')
+            mo.users.set_password(user, form.new_passwd.data)
+            mo.util.log(
+                type=db.LogType.user,
+                what=user.user_id,
+                details={'action': 'change-passwd'},
+            )
+            sess.commit()
+            flash('Heslo změněno.', 'success')
+        if form.email.data != user.email:
+            rr = mo.users.new_reg_request(db.RegReqType.change, request.remote_addr)
+            if rr:
+                rr.user_id = user.user_id
+                rr.email = form.email.data
+                sess.add(rr)
+                sess.commit()
+                app.logger.info(f'Settings: Požadavek na změnu e-mailu uživatele #{user.user_id}')
+                flash('Odeslán e-mail s odkazem na potvrzení nové adresy.', 'success')
+                mo.util.send_confirm_change_email(user, rr.email_token)
+            else:
+                app.logger.info('Settings: Rate limit')
+                flash('Příliš mnoho požadavků na změny hesla. Počkejte prosím chvíli a zkuste to znovu.', 'danger')
+                ok = False
+        if ok:
+            return redirect(url_for('user_settings'))
+
+    return render_template('settings_change.html', form=form)
 
 
 @app.errorhandler(NeedLoginError)
@@ -361,6 +421,11 @@ class Reg2:
             RegStatus.already_spent: 'Tento odkaz na potvrzení registrace byl již využit.',
             RegStatus.already_exists: 'Účet s touto adresou už existuje.',
         },
+        db.RegReqType.change: {
+            RegStatus.new: 'Chybný potvrzovací kód. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.',
+            RegStatus.expired: 'Vypršela platnost potvrzovacího kódu, požádejte prosím o změnu e-mailu znovu.',
+            RegStatus.already_spent: 'Tento odkaz na potvrzení změny e-mailu byl již využit.',
+        },
     }
 
     def __init__(self, token: str, expected_type: db.RegReqType):
@@ -445,6 +510,28 @@ class Reg2:
         self.user = user
         return True
 
+    def change_email(self):
+        sess = db.get_session()
+        user = g.user
+        user.email = self.rr.email
+
+        app.logger.info(f'Reg2: Uživatel #{user.user_id} si změnil email na <{user.email}>')
+        mo.util.log(
+            type=db.LogType.user,
+            what=user.user_id,
+            details={
+                'action': 'change-settings',
+                'changes': db.get_object_changes(user),
+            },
+        )
+
+        self.rr.used_at = mo.now
+        sess.commit()
+
+    def spend_request(self):
+        self.rr.used_at = mo.now
+        db.get_session().commit()
+
     def flash_message(self):
         msgs = self.messages[self.reg_type]
         if self.status in msgs:
@@ -484,6 +571,36 @@ def confirm_reg():
     return render_template('acct_reg2.html', form=form)
 
 
+class ConfirmEmailForm(FlaskForm):
+    orig_email = wtforms.StringField('Původní e-mail', render_kw={"disabled": "disabled"})
+    new_email = wtforms.StringField('Nový e-mail', render_kw={"disabled": "disabled"})
+    submit = wtforms.SubmitField('Potvrdit změnu')
+    cancel = wtforms.SubmitField('Zrušit požadavek')
+
+
+@app.route('/auth/confirm/e', methods=('GET', 'POST'))
+def confirm_email():
+    reg2 = Reg2(request.args.get('token'), db.RegReqType.change)
+    if reg2.status != RegStatus.ok:
+        reg2.flash_message()
+        return redirect(url_for('user_settings'))
+
+    form = ConfirmEmailForm()
+    if form.validate_on_submit():
+        if form.submit.data:
+            reg2.change_email()
+            flash('E-mail změněn.', 'success')
+        elif form.cancel.data:
+            reg2.spend_request()
+            flash('Požadavek na změnu e-mailu zrušen.', 'success')
+        return redirect(url_for('user_settings'))
+
+    form.orig_email.data = g.user.email
+    form.new_email.data = reg2.rr.email
+
+    return render_template('acct_confirm_email.html', form=form)
+
+
 @app.errorhandler(werkzeug.exceptions.Forbidden)
 def handle_forbidden(e):
     return render_template('forbidden.html')
diff --git a/mo/web/templates/acct_confirm_email.html b/mo/web/templates/acct_confirm_email.html
new file mode 100644
index 0000000000000000000000000000000000000000..616790bd47f7d09913cef924e357c53fd5ea304e
--- /dev/null
+++ b/mo/web/templates/acct_confirm_email.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% block title %}Změna e-mailu{% endblock %}
+{% block body %}
+
+{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }}
+
+{% endblock %}
diff --git a/mo/web/templates/settings.html b/mo/web/templates/settings.html
index 654eff586fe0292c5e248981087baa8f2f3f8cec..32c42fa002f3a512104eff3a8c1ac125abd5a921 100644
--- a/mo/web/templates/settings.html
+++ b/mo/web/templates/settings.html
@@ -1,14 +1,34 @@
 {% extends "base.html" %}
-{% block title %}Uživatel {{ g.user.full_name() }}{% endblock %}
+{% block title %}Uživatel {{ user.full_name() }}{% endblock %}
 {% block body %}
 
+	<h3>Osobní údaje</h3>
+
+	<table class=table>
+	<tr><th>Jméno<td>{{ user.first_name }}
+	<tr><th>Příjmení<td>{{ user.last_name }}
+	<tr><th>E-mail<td>{{ user.email }}
+{% if pant %}
+	<tr><th>Škola<td>{{ pant.school_place.name }}
+	<tr><th>Ročník<td>{{ pant.grade }}
+	<tr><th>Rok narození<td>{{ pant.birth_year }}
+{% endif %}
+	</table>
+
+	<p><a class='btn btn-primary' href='{{ url_for('user_settings_change') }}'>Změnit e-mail nebo heslo</a>
+
+	<p>Pokud potřebujete změnit jiné údaje, ozvěte se svému učiteli nebo garantovi.
+	Neuspějete-li u nich, napište správci OSMO (kontakt viz patička stránky).
+
+{% if user.is_admin or user.is_org %}
+
 	<h3>Práva</h3>
-{% if g.user.is_admin %}
+{% if user.is_admin %}
 	<p>Správce systému
 {% endif %}
-{% if g.user.is_org %}
+{% if user.is_org %}
 	<p>Organizátor s následujícími rolemi:
-	<table class=data>
+	<table class=table>
 	<tr>
 		<th>Role
 		<th>Oblast
@@ -25,8 +45,7 @@
 	{% endfor %}
 	</table>
 {% endif %}
-{% if not g.user.is_admin and not g.user.is_org %}
-	<p>Běžný uživatel
+
 {% endif %}
 
 {% endblock %}
diff --git a/mo/web/templates/settings_change.html b/mo/web/templates/settings_change.html
new file mode 100644
index 0000000000000000000000000000000000000000..568ca0fa6402ee006d649222486e398baaf91e74
--- /dev/null
+++ b/mo/web/templates/settings_change.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% block title %}Změna osobních údajů{% endblock %}
+{% block body %}
+
+{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }}
+
+{% endblock %}