diff --git a/bin/create-user b/bin/create-user
index 09fbe9e10eaa73c52eef42de7a76e1383aee81e2..7a8851b4a7b4d5eba08eb895387b72adddea61fb 100755
--- a/bin/create-user
+++ b/bin/create-user
@@ -4,6 +4,7 @@ import argparse
 import sys
 
 import mo.db as db
+import mo.email
 import mo.users
 import mo.util
 
@@ -13,8 +14,7 @@ parser.add_argument(dest='first_name', help='křestní jméno (jedno nebo více)
 parser.add_argument(dest='last_name', help='příjmení (jedno nebo více)')
 parser.add_argument('--org', default=False, action='store_true', help='přidělí uživateli organizátorská práva')
 parser.add_argument('--admin', default=False, action='store_true', help='přidělí uživateli správcovská práva')
-parser.add_argument('--passwd', type=str, help='nastaví počáteční heslo')
-parser.add_argument('--mail', default=False, action='store_true', help='pošle uživateli mail o založení účtu')
+parser.add_argument('--passwd', type=str, help='nastaví počáteční heslo (jinak pošle aktivační mail)')
 
 args = parser.parse_args()
 email = mo.users.normalize_email(args.email)
@@ -45,11 +45,9 @@ mo.util.log(db.LogType.user, user.user_id, {
 
 if args.passwd is not None:
     mo.users.set_password(user, args.passwd)
-
-if args.mail:
-    token = mo.users.ask_reset_password(user)
+    token = mo.users.make_activation_token(user)
 
 session.commit()
 
-if args.mail:
-    mo.util.send_new_account_email(user, token)
+if args.passwd is None:
+    mo.email.send_new_account_email(user, token)
diff --git a/bin/reset-user b/bin/reset-user
index 0b6f8c7085edba31ab12e3d3b63f13d417d7c463..bea8fe06f8d75ac09566f699700a399d7b85e740 100755
--- a/bin/reset-user
+++ b/bin/reset-user
@@ -1,16 +1,15 @@
 #!/usr/bin/env python3
 
 import argparse
-import sys
 
-import mo.config as config
+import mo.config
+import mo.email
 import mo.db as db
 import mo.users
 import mo.util
 
-parser = argparse.ArgumentParser(description='Resetuje uživateli heslo a pošle mail')
+parser = argparse.ArgumentParser(description='Pošle uživateli nový aktivační mail')
 parser.add_argument(dest='email', help='e-mailová adresa')
-parser.add_argument('--new', default=False, action='store_true', help='pošle mail o založení účtu')
 parser.add_argument('--mail-instead', metavar='EMAIL', default=None, help='pošle mail někomu jinému')
 
 args = parser.parse_args()
@@ -22,13 +21,10 @@ user = mo.users.user_by_email(args.email)
 if user is None:
     mo.util.die('Tento uživatel neexistuje')
 
-token = mo.users.ask_reset_password(user)
+token = mo.users.make_activation_token(user)
 session.commit()
 
 if args.mail_instead:
     mo.config.MAIL_INSTEAD = args.mail_instead
 
-if args.new:
-    mo.util.send_new_account_email(user, token)
-else:
-    mo.util.send_password_reset_email(user, token)
+mo.email.send_new_account_email(user, token)
diff --git a/db/db.ddl b/db/db.ddl
index fd3ce6c2c8f86c9b736993a36e2911491ddd23f6..b20d86522fb9feecd227c592cc519bcf22335de7 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -1,9 +1,19 @@
 -- CREATE ROLE mo_osmo LOGIN PASSWORD 'pass';
 -- CREATE DATABASE mo_osmo WITH OWNER=mo_osmo;
 -- GRANT mo_osmo TO some_admin;
+-- CREATE EXTENSION unaccent;
 
 SET ROLE mo_osmo;
 
+-- Funkce pro odakcentování textu pomocí extension unaccent.
+-- Je immutable, takže se dá používat i v indexech.
+-- Zdroj: http://stackoverflow.com/questions/11005036/does-postgresql-support-accent-insensitive-collations
+CREATE OR REPLACE FUNCTION f_unaccent(text)
+  RETURNS text AS
+$func$
+SELECT unaccent('unaccent', $1)
+$func$  LANGUAGE sql IMMUTABLE SET search_path = public, pg_temp;
+
 -- Uživatelský účet
 CREATE TABLE users (
 	user_id		serial		PRIMARY KEY,
@@ -15,7 +25,7 @@ CREATE TABLE users (
 	is_test		boolean		NOT NULL DEFAULT false,		-- testovací účastník, není vidět ve výsledkovkách
 	created_at	timestamp with time zone	NOT NULL DEFAULT CURRENT_TIMESTAMP,
 	last_login_at	timestamp with time zone	DEFAULT NULL,
-	reset_at	timestamp with time zone	DEFAULT NULL,	-- poslední požadavek na reset hesla
+	reset_at	timestamp with time zone	DEFAULT NULL,	-- poslední reset/aktivace nebo žádost o ně
 	password_hash	varchar(255)	DEFAULT NULL,			-- heš hesla (je-li nastaveno)
 	note		text		NOT NULL DEFAULT ''		-- poznámka viditelná pro orgy
 );
@@ -45,6 +55,8 @@ CREATE TABLE places (
 );
 
 CREATE INDEX places_parent_index ON places (parent);
+-- XXX: Potřebujeme operator class text_pattern_ops, aby index fungoval na prefixové LIKE
+CREATE INDEX places_noacc_index ON places ((lower(f_unaccent(name))) text_pattern_ops);
 
 -- Rekurzivní dotaz na nadřazené regiony:
 --	WITH RECURSIVE parent_regions(parent, place_id) AS (
@@ -89,6 +101,12 @@ CREATE TYPE score_mode AS ENUM (
 	'mo'				-- jednoznačné pořadí podle pravidel MO
 );
 
+CREATE TYPE enroll_mode AS ENUM (	-- režim přihlašování účastníků
+	'manual',			-- přihlašuje organizátor
+	'register',			-- účastník se registruje
+	'confirm'			-- účastník se registruje, ale org ho musí potvrdit
+);
+
 CREATE TABLE rounds (
 	round_id	serial		PRIMARY KEY,
 	master_round_id	int		DEFAULT NULL REFERENCES rounds(round_id),
@@ -109,6 +127,8 @@ CREATE TABLE rounds (
 	score_successful_limit	int	DEFAULT NULL,			-- bodový limit na označení za úspěšného řešitele
 	points_step	numeric(2,1)	NOT NULL DEFAULT 1,		-- s jakou přesností jsou přidělovány body (celé aneb 1, 0.5, 0.1)
 	has_messages	boolean		NOT NULL DEFAULT false,		-- má zprávičky
+	enroll_mode	enroll_mode	NOT NULL DEFAULT 'manual',	-- režim přihlašování (pro vyšší kola vždy 'manual')
+	enroll_advert	varchar(255)	NOT NULL DEFAULT '',		-- popis v přihlašovacím formuláři
 	UNIQUE (year, category, seq, part)
 );
 
@@ -133,17 +153,19 @@ CREATE TABLE participants (
 	school		int		NOT NULL REFERENCES places(place_id),
 	birth_year	int		NOT NULL,
 	grade		varchar(20)	NOT NULL,			-- třída ve tvaru "X/Y"
+	registered_on	timestamp with time zone	DEFAULT NULL,	-- kdy se účastník přihlásil (NULL, pokud ho přihlásil organizátor)
 	PRIMARY KEY (user_id, year)
 );
 
 -- Účast v soutěžním kole
 
 CREATE TYPE part_state AS ENUM (
-	'registered',		-- sám se přihlásil
-	'invited',		-- pozván
-	'refused',		-- odmítl účast
-	'present',		-- soutěžil
-	'absent',		-- bez omluvy nedorazil
+	'registered',		-- sám se přihlásil, čeká na potvrzení organizátorem
+	-- 'invited',		-- pozván (už nepoužíváme)
+	'active',		-- soutěží (přihlášku zadal/potvrdil organizátor)
+	'refused',		-- organizátor odmítl přihlášku
+	-- 'present',		-- soutěžil (už nepoužíváme)
+	'absent',		-- nedorazil
 	'disqualified'		-- diskvalifikovaný
 );
 
@@ -312,3 +334,24 @@ CREATE TABLE messages (
 	markdown	text		NOT NULL,
 	html		text		NOT NULL
 );
+
+-- Požadavky na registraci a změny vlastností účtu
+
+CREATE TYPE reg_req_type AS ENUM (
+	'register',
+	'change_email',
+	'reset_password'
+);
+
+CREATE TABLE reg_requests (
+	reg_id		serial		PRIMARY KEY,
+	type		reg_req_type	NOT NULL,
+	created_at	timestamp with time zone NOT NULL,
+	expires_at	timestamp with time zone NOT NULL,
+	used_at		timestamp with time zone DEFAULT NULL,
+	email		varchar(255)	DEFAULT NULL,			-- adresa, kterou potvrzujeme
+	captcha_token	varchar(255)	DEFAULT NULL,			-- token pro fázi 1 registrace
+	email_token	varchar(255)	UNIQUE NOT NULL,		-- token pro fázi 2 registrace
+	user_id		int		DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE,
+	client		varchar(255)	NOT NULL			-- kdo si registraci vyžádal
+);
diff --git a/db/upgrade-20210712.sql b/db/upgrade-20210712.sql
new file mode 100644
index 0000000000000000000000000000000000000000..ba6a4bf99c41e051a1479908b03299212f7a9c99
--- /dev/null
+++ b/db/upgrade-20210712.sql
@@ -0,0 +1,35 @@
+SET ROLE 'mo_osmo';
+
+CREATE TYPE enroll_mode AS ENUM (	-- režim přihlašování účastníků
+	'manual',			-- přihlašuje organizátor
+	'register',			-- účastník se registruje
+	'confirm'			-- účastník se registruje, ale org ho musí potvrdit
+);
+
+ALTER TABLE rounds ADD COLUMN enroll_mode enroll_mode NOT NULL DEFAULT 'manual';
+ALTER TABLE rounds ADD COLUMN enroll_advert varchar(255) NOT NULL DEFAULT '';
+
+ALTER TYPE part_state ADD VALUE 'active' AFTER 'invited';
+
+ALTER TABLE participants ADD COLUMN registered_on timestamp with time zone DEFAULT NULL;
+
+UPDATE participations SET state='active' WHERE state IN ('registered', 'invited');
+
+CREATE TYPE reg_req_type AS ENUM (
+	'register',
+	'change_email',
+	'reset_passwd'
+);
+
+CREATE TABLE reg_requests (
+	reg_id		serial		PRIMARY KEY,
+	type		reg_req_type	NOT NULL,
+	created_at	timestamp with time zone NOT NULL,
+	expires_at	timestamp with time zone NOT NULL,
+	used_at		timestamp with time zone DEFAULT NULL,
+	email		varchar(255)	DEFAULT NULL,
+	captcha_token	varchar(255)	DEFAULT NULL,
+	email_token	varchar(255)	UNIQUE NOT NULL,
+	user_id		int		DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE,
+	client		varchar(255)	NOT NULL
+);
diff --git a/db/upgrade-20210819.sql b/db/upgrade-20210819.sql
new file mode 100644
index 0000000000000000000000000000000000000000..5fa30fbde2453527317f8b821fd5a7a7ac76884d
--- /dev/null
+++ b/db/upgrade-20210819.sql
@@ -0,0 +1,12 @@
+SET ROLE 'mo_osmo';
+
+-- Musí udělat správce (ale uvnitř naší DB):
+-- CREATE EXTENSION unaccent;
+
+CREATE OR REPLACE FUNCTION f_unaccent(text)
+  RETURNS text AS
+$func$
+SELECT unaccent('unaccent', $1)
+$func$  LANGUAGE sql IMMUTABLE SET search_path = public, pg_temp;
+
+CREATE INDEX places_noacc_index ON places ((lower(f_unaccent(name))) text_pattern_ops);
diff --git a/etc/config.py.example b/etc/config.py.example
index 9b9e4ec05976ee108f147230b9bcea884241682c..f7db910c8d24c676a30bba7a76e80c983ab6f1db 100644
--- a/etc/config.py.example
+++ b/etc/config.py.example
@@ -42,12 +42,14 @@ MAX_BATCH_CONTENT_LENGTH = 1000000000
 # Adresář, do kterého ukládáme data (pro vývoj relativní, pro instalaci absolutní)
 DATA_DIR = 'data'
 
-# Jak často se má provádět periodická kontrola dávek [s]
-JOB_GC_PERIOD = 60
+# Jak často se má spouštět garbage collector na dávky a tokeny [s]
+GC_PERIOD = 60
 
 # Za jak dlouho expiruje dokončená dávka [min]
 JOB_EXPIRATION = 5
 
-# Automatické přihlašování účastníků do testovací soutěže
-# (kolo aktuální_ročník-T-1, celostátní soutěž)
-AUTO_REGISTER_TEST = False
+# Kolik nejvýše dovolujeme registrací za minutu
+REG_MAX_PER_MINUTE = 10
+
+# Jak dlouho vydrží tokeny používané při registraci a změnách e-mailu [min]
+REG_TOKEN_VALIDITY = 10
diff --git a/mo/__init__.py b/mo/__init__.py
index 4feeb4d59dae535b432a9997001356cac8336a09..98d16b9ad30321087b30790a71fe913ae7eec036 100644
--- a/mo/__init__.py
+++ b/mo/__init__.py
@@ -3,7 +3,7 @@
 import datetime
 
 # Aktuální ročník
-current_year = 70
+current_year = 71
 
 # Referenční čas nastavovaný v initu requestu (web) nebo při volání skriptu
 now: datetime.datetime
diff --git a/mo/db.py b/mo/db.py
index c1de5014635fe58d8c326daed91d22dcad52c7cc..000d01ca6fd196edc465f7d3a1afa00177514c47 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -16,12 +16,13 @@ from sqlalchemy.orm.attributes import get_history
 from sqlalchemy.dialects.postgresql import JSONB
 from sqlalchemy.ext.declarative import declarative_base
 from sqlalchemy.sql.expression import CTE
+from sqlalchemy.sql.functions import ReturnTypeFromArgs
 from sqlalchemy.sql.sqltypes import Numeric
 from typing import Optional, List, Tuple
 
 import mo
 from mo.place_level import place_levels, PlaceLevel
-from mo.util_format import timedelta, time_and_timedelta
+from mo.util_format import timeformat_short, timedelta, time_and_timedelta
 
 # HACK: Work-around for https://github.com/dropbox/sqlalchemy-stubs/issues/114
 from typing import TYPE_CHECKING, TypeVar, Type, Any
@@ -34,6 +35,11 @@ else:
     from sqlalchemy import Enum
 
 
+# Funkce f_unaccent je definovaná v db.ddl, naučme Alchymii volat ji:
+class f_unaccent(ReturnTypeFromArgs):
+    pass
+
+
 Base = declarative_base()
 metadata = Base.metadata
 
@@ -99,6 +105,7 @@ class Place(Base):
     nuts = Column(String(255), unique=True, server_default=text("NULL::character varying"))
     note = Column(Text, nullable=False, server_default=text("''::text"))
 
+    parent_place = relationship('Place', primaryjoin='Place.parent == Place.place_id', remote_side='Place.place_id')
     children = relationship('Place')
     school = relationship('School', uselist=False, back_populates='place')
 
@@ -203,6 +210,22 @@ round_score_mode_names = {
 }
 
 
+class RoundEnrollMode(MOEnum):
+    manual = auto()
+    register = auto()
+    confirm = auto()
+
+    def friendly_name(self) -> str:
+        return round_enroll_mode_names[self]
+
+
+round_enroll_mode_names = {
+    RoundEnrollMode.manual: "Jen organizátoři",
+    RoundEnrollMode.register: "Účastníci sami",
+    RoundEnrollMode.confirm: "Potvrzení organizátorem",
+}
+
+
 # V DB jako numeric(2,1), používá se tak snadněji, než enum
 round_points_step_names = {
     decimal.Decimal('1'): "Celé body",
@@ -237,6 +260,8 @@ class Round(Base):
     score_successful_limit = Column(Numeric)
     points_step = Column(Numeric, nullable=False)
     has_messages = Column(Boolean, nullable=False, server_default=text("false"))
+    enroll_mode = Column(Enum(RoundEnrollMode, name='enroll_mode'), nullable=False, server_default=text("'basic'::enroll_mode"))
+    enroll_advert = Column(String(255), nullable=False, server_default=text("''::text"))
 
     master = relationship('Round', primaryjoin='Round.master_round_id == Round.round_id', remote_side='Round.round_id', post_update=True)
 
@@ -281,6 +306,14 @@ class Round(Base):
             return round_points_step_names[self.points_step]
         return str(self.points_step)
 
+    def format_times(self) -> str:
+        times = []
+        if self.ct_tasks_start is not None:
+            times.append('od ' + timeformat_short(self.ct_tasks_start))
+        if self.ct_submit_end is not None:
+            times.append('do ' + timeformat_short(self.ct_submit_end))
+        return " ".join(times)
+
 
 class User(Base):
     __tablename__ = 'users'
@@ -403,6 +436,7 @@ class Participant(Base):
     school = Column(Integer, ForeignKey('places.place_id'), nullable=False)
     birth_year = Column(Integer, nullable=False)
     grade = Column(String(20), nullable=False)
+    registered_on = Column(DateTime(True))
 
     user = relationship('User')
     school_place = relationship('Place', primaryjoin='Participant.school == Place.place_id')
@@ -410,9 +444,8 @@ class Participant(Base):
 
 class PartState(MOEnum):
     registered = auto()
-    invited = auto()
+    active = auto()
     refused = auto()
-    present = auto()
     absent = auto()
     disqualified = auto()
 
@@ -422,9 +455,8 @@ class PartState(MOEnum):
 
 part_state_names = {
     PartState.registered: 'přihlášený',
-    PartState.invited: 'pozvaný',
+    PartState.active: 'soutěží',
     PartState.refused: 'odmítnutý',
-    PartState.present: 'přítomný',
     PartState.absent: 'nepřítomný',
     PartState.disqualified: 'diskvalifikovaný',
 }
@@ -510,6 +542,12 @@ class UserRole(Base):
 
         return " ".join(parts)
 
+    def applies_to(self, at: Optional[Place] = None, year: Optional[int] = None, cat: Optional[str] = None, seq: Optional[int] = None) -> bool:
+        return ((at is None or self.place_id == at.place_id)
+                and (self.year is None or year is None or self.year == year)
+                and (self.category is None or cat is None or self.category == cat or (self.category == 'Z' and cat.startswith('Z')))
+                and (self.seq is None or seq is None or self.seq == seq))
+
 
 class PaperType(MOEnum):
     solution = auto()
@@ -644,6 +682,29 @@ class Message(Base):
     created_by_user = relationship('User')
 
 
+class RegReqType(MOEnum):
+    register = auto()
+    change_email = auto()
+    reset_passwd = auto()
+
+
+class RegRequest(Base):
+    __tablename__ = 'reg_requests'
+
+    reg_id = Column(Integer, primary_key=True, server_default=text("nextval('reg_requests_reg_id_seq'::regclass)"))
+    type = Column(Enum(RegReqType, name='reg_req_type'), nullable=False)
+    created_at = Column(DateTime(True), nullable=False)
+    expires_at = Column(DateTime(True), nullable=False)
+    used_at = Column(DateTime(True))
+    captcha_token = Column(Text)
+    email = Column(Text)
+    email_token = Column(Text, nullable=False, unique=True)
+    user_id = Column(Integer, ForeignKey('users.user_id'))
+    client = Column(Text, nullable=False)
+
+    user = relationship('User')
+
+
 _engine: Optional[Engine] = None
 _session: Optional[Session] = None
 flask_db: Any = None
diff --git a/mo/email.py b/mo/email.py
new file mode 100644
index 0000000000000000000000000000000000000000..14660593e398ef3a92c71ac12569afb198995e89
--- /dev/null
+++ b/mo/email.py
@@ -0,0 +1,156 @@
+# Rozesílání e-mailových notifikací všeho druhu
+
+import datetime
+import email.message
+import email.headerregistry
+import subprocess
+import textwrap
+import urllib.parse
+
+import mo.db as db
+import mo.config as config
+from mo.util import logger
+
+
+def send_user_email(user: db.User, subject: str, body: str) -> bool:
+    logger.info(f'Mail: "{subject}" -> {user.email}')
+
+    mail_from = getattr(config, 'MAIL_FROM', None)
+    if mail_from is None:
+        logger.error('Mail: V configu chybí nastavení MAIL_FROM')
+        return False
+
+    msg = email.message.EmailMessage()
+    msg['From'] = email.headerregistry.Address(
+        display_name='Odevzdávací Systém MO',
+        addr_spec=mail_from,
+    )
+    msg['To'] = [
+        email.headerregistry.Address(
+            display_name=user.full_name(),
+            addr_spec=user.email,
+        )
+    ]
+    msg['Reply-To'] = email.headerregistry.Address(
+        display_name='Správce OSMO',
+        addr_spec=config.MAIL_CONTACT,
+    )
+    msg['Subject'] = 'OSMO – ' + subject
+    msg['Date'] = datetime.datetime.now()
+
+    msg.set_content(body, cte='quoted-printable')
+
+    mail_instead = getattr(config, 'MAIL_INSTEAD', None)
+    if mail_instead is None:
+        send_to = user.email
+    else:
+        send_to = mail_instead
+
+    sm = subprocess.Popen(
+        [
+            '/usr/sbin/sendmail',
+            '-oi',
+            '-f',
+            mail_from,
+            send_to,
+        ],
+        stdin=subprocess.PIPE,
+    )
+    sm.communicate(msg.as_bytes())
+
+    if sm.returncode != 0:
+        logger.error('Mail: Sendmail failed with return code {}'.format(sm.returncode))
+        return False
+
+    return True
+
+
+def activate_url(token: str) -> str:
+    return config.WEB_ROOT + 'acct/activate?' + urllib.parse.urlencode({'token': token}, safe=':')
+
+
+def confirm_url(type: str, token: str) -> str:
+    return config.WEB_ROOT + f'acct/confirm/{type}?' + urllib.parse.urlencode({'token': token}, safe=':')
+
+
+def contestant_list_url(contest: db.Contest, registered_only: bool) -> str:
+    url = config.WEB_ROOT + f'org/contest/c/{contest.contest_id}/ucastnici'
+    if registered_only:
+        url += '?participation_state=registered'
+    return url
+
+
+def send_new_account_email(user: db.User, token: str) -> bool:
+    return send_user_email(user, 'Založen nový účet', textwrap.dedent('''\
+        Vítejte!
+
+        Právě Vám byl založen účet v Odevzdávacím systému Matematické olympiády.
+        Nastavte si prosím heslo na následující stránce:
+
+                {}
+
+        Váš OSMO
+    '''.format(activate_url(token))))
+
+
+def send_password_reset_email(user: db.User, token: str) -> bool:
+    return send_user_email(user, 'Obnova hesla', textwrap.dedent('''\
+        Někdo (pravděpodobně Vy) požádal o obnovení hesla k Vašemu účtu v Odevzdávacím
+        systému Matematické olympiády. Heslo si můžete nastavit, případně požadavek
+        zrušit, na následující stránce:
+
+                {}
+
+        Váš OSMO
+    '''.format(confirm_url('p', token))))
+
+
+def send_confirm_create_email(user: db.User, token: str) -> bool:
+    return send_user_email(user, 'Založení účtu', textwrap.dedent('''\
+        Někdo (pravděpodobně Vy) požádal o založení účtu s touto e-mailovou adresou
+        v Odevzdávacím systému Matematické olympiády. Pokud účet chcete založit,
+        následujte tento odkaz:
+
+                {}
+
+        Váš OSMO
+    '''.format(confirm_url('r', 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_url('e', token))))
+
+
+def send_join_notify_email(dest: db.User, who: db.User, contest: db.Contest) -> bool:
+    round = contest.round
+    place = contest.place
+    if contest.round.enroll_mode == db.RoundEnrollMode.confirm:
+        confirm = 'Přihlášku je potřeba potvrdit v seznamu účastníků soutěže.'
+        url = 'Přihlášky k potvrzení: ' + contestant_list_url(contest, True)
+    else:
+        confirm = 'Přihláška byla schválena automaticky.'
+        url = 'Seznam účastníků: ' + contestant_list_url(contest, False)
+
+    return send_user_email(dest, f'Nový účastník kategorie {round.category}', textwrap.dedent(f'''\
+        Nový účastník se přihlásil do MO v oblasti, kterou garantujete.
+
+        Jméno:      {who.full_name()}
+        E-mail:     {who.email}
+        Kategorie:  {round.category}
+        Kolo:       {round.name}
+        Místo:      {place.name}
+
+        {confirm}
+
+        {url}
+
+        Váš OSMO
+    '''))
diff --git a/mo/imports.py b/mo/imports.py
index 332845371d95707b1e7413dc63a52414fe76b523..bc09f3b0b99c64b3720788bba4e5a3f3dace7654 100644
--- a/mo/imports.py
+++ b/mo/imports.py
@@ -10,6 +10,7 @@ from typing import List, Optional, Any, Dict, Type, Union
 import mo.csv
 from mo.csv import FileFormat, MissingHeaderError
 import mo.db as db
+import mo.email
 import mo.rights
 import mo.users
 import mo.util
@@ -372,9 +373,9 @@ class Import:
         for uid in self.new_user_ids:
             u = sess.query(db.User).get(uid)
             if u and not u.password_hash and not u.reset_at:
-                token = mo.users.ask_reset_password(u)
+                token = mo.users.make_activation_token(u)
                 sess.commit()
-                mo.util.send_new_account_email(u, token)
+                mo.email.send_new_account_email(u, token)
             else:
                 sess.rollback()
 
diff --git a/mo/rights.py b/mo/rights.py
index 29553db03b62c9409ca58aeba7be6db0bc36d827..85531c939e79deca4fd56533ff7467a3a8c2790a 100644
--- a/mo/rights.py
+++ b/mo/rights.py
@@ -325,10 +325,7 @@ class Gatekeeper:
         rights.rights = set()
 
         def try_role(role: db.UserRole, at: Optional[db.Place]):
-            if ((at is None or role.place_id == at.place_id)
-                    and (role.year is None or year is None or role.year == year)
-                    and (role.category is None or cat is None or role.category == cat or (role.category == 'Z' and cat.startswith('Z')))
-                    and (role.seq is None or seq is None or role.seq == seq)
+            if (role.applies_to(at=at, year=year, cat=cat, seq=seq)
                     and (min_role is None or role_order_by_type[min_role] >= role_order_by_type[role.role])):
                 rights.user_roles.append(role)
                 r = roles_by_type[role.role]
diff --git a/mo/score.py b/mo/score.py
index 01a26d786d8157cb36364e19f69d24ace9abf42c..e9bc006d0b5239aca53467a04a51dc9c114218cf 100644
--- a/mo/score.py
+++ b/mo/score.py
@@ -106,7 +106,7 @@ class Score:
     def __init__(
         self, round: db.Round, contest: Optional[db.Contest] = None,
         # Ze kterých stavů chceme výsledkovku počítat
-        part_states: List[db.PartState] = [db.PartState.registered, db.PartState.invited, db.PartState.present],
+        part_states: List[db.PartState] = [db.PartState.registered, db.PartState.active],
     ):
         self.round = round
         self.contest = contest
diff --git a/mo/tokens.py b/mo/tokens.py
index afe36b846b40dbfadf2b81672f62a16e6da87d31..97a7077544e26d86f2fad2545163deab08245db8 100644
--- a/mo/tokens.py
+++ b/mo/tokens.py
@@ -1,5 +1,6 @@
 # Podepsané tokeny
 
+import hashlib
 import hmac
 import urllib.parse
 from typing import Optional, List
@@ -8,9 +9,7 @@ import mo.config as config
 
 
 def _sign_token(token: str, use: str) -> str:
-    key = '-'.join(('sign-token', use, config.SECRET_KEY))
-    mac = hmac.HMAC(key.encode('us-ascii'), token.encode('us-ascii'), 'sha256')
-    return mac.hexdigest()[:16]
+    return sign(token, 'token-' + use)
 
 
 def sign_token(fields: List[str], use: str) -> str:
@@ -27,3 +26,18 @@ def verify_token(token: str, use: str) -> Optional[List[str]]:
     if _sign_token(':'.join(enc_fields), use) != sign:
         return None
     return [urllib.parse.unquote(f) for f in enc_fields]
+
+
+def sign(msg: str, use: str) -> str:
+    """Podpis parametrizovaný tajným klíčem a účelem."""
+
+    key = use + '#' + config.SECRET_KEY
+    mac = hmac.HMAC(key.encode('us-ascii'), msg.encode('us-ascii'), 'sha256')
+    return mac.hexdigest()[:16]
+
+
+def hash(msg: str, use: str) -> str:
+    """Hešovací funkce parametrizovaná tajným klíčem a účelem."""
+
+    m = '#'.join((use, config.SECRET_KEY, msg)).encode('us-ascii')
+    return hashlib.sha256(m).hexdigest()
diff --git a/mo/users.py b/mo/users.py
index 6fef5ec9ad8280e6f5bf6693abdb76782885de6e..f7d789c73f28f501dce83274d97e28cd4dee110e 100644
--- a/mo/users.py
+++ b/mo/users.py
@@ -2,12 +2,15 @@
 
 import bcrypt
 import datetime
+import dateutil.tz
 import email.errors
 import email.headerregistry
 import re
+import secrets
 from typing import Optional, Tuple
 
 import mo
+import mo.config as config
 import mo.db as db
 import mo.util
 from mo.util import logger
@@ -107,7 +110,7 @@ def find_or_create_participation(user: db.User, contest: db.Contest, place: Opti
 
     is_new = pions == []
     if is_new:
-        pion = db.Participation(user=user, contest=contest, place_id=place.place_id, state=db.PartState.invited)
+        pion = db.Participation(user=user, contest=contest, place_id=place.place_id, state=db.PartState.active)
         sess.add(pion)
         logger.info(f'{reason.title()}: Založena účast user=#{user.user_id} contest=#{contest.contest_id} place=#{place.place_id}')
         mo.util.log(
@@ -165,18 +168,24 @@ def user_by_uid(uid: int) -> db.User:
     return db.get_session().query(db.User).get(uid)
 
 
-def set_password(user: db.User, passwd: str):
+password_help = 'Heslo musí mít alespoň 8 znaků. Doporučujeme kombinovat velká a malá písmena a číslice.'
+
+
+def validate_password(passwd: str) -> bool:
+    return len(passwd) >= 8
+
+
+def set_password(user: db.User, passwd: str, reset: bool = False):
     salt = bcrypt.gensalt()
     hashed = bcrypt.hashpw(passwd.encode('utf-8'), salt)
     user.password_hash = hashed.decode('us-ascii')
-    user.reset_at = None
-    user.last_login_at = datetime.datetime.now()
-
-    mo.util.log(
-        type=db.LogType.user,
-        what=user.user_id,
-        details={"action": "set-passwd"},
-    )
+    if reset:
+        user.reset_at = mo.now
+        mo.util.log(
+            type=db.LogType.user,
+            what=user.user_id,
+            details={'action': 'do-reset'},
+        )
 
 
 def check_password(user: db.User, passwd: str):
@@ -185,63 +194,68 @@ def check_password(user: db.User, passwd: str):
 
 
 def login(user: db.User):
-    user.last_login_at = datetime.datetime.now()
-    user.reset_at = None
-
-
-def ask_reset_password(user: db.User) -> str:
-    user.reset_at = datetime.datetime.now()
-    when = int(user.reset_at.timestamp())
-    token = mo.tokens.sign_token([str(user.user_id), str(when)], 'reset')
-
-    mo.util.log(
-        type=db.LogType.user,
-        what=user.user_id,
-        details={'action': 'ask-reset'},
-    )
+    user.last_login_at = mo.now
 
-    return token
 
+def make_activation_token(user: db.User) -> str:
+    user.reset_at = mo.now
+    when = int(mo.now.timestamp())
+    return mo.tokens.sign_token([str(user.user_id), str(when)], 'activate')
 
-def check_reset_password(token: str) -> Optional[db.User]:
-    # Někteří klienti při kopírování adresy z mailu do prohlížeče
-    # přidávají divné Unicodové znaky (přepnutí směru psaní atd., viz issue #58).
-    token = re.sub(r'[^!-~]', "", token)
 
-    fields = mo.tokens.verify_token(token, 'reset')
+def check_activation_token(token: str) -> Optional[db.User]:
+    token = mo.util.clean_up_token(token)
+    fields = mo.tokens.verify_token(token, 'activate')
     if not fields or len(fields) != 2:
         return None
-    user = db.get_session().query(db.User).filter_by(user_id=int(fields[0])).first()
+    user_id = int(fields[0])
+    token_time = datetime.datetime.fromtimestamp(int(fields[1]), tz=dateutil.tz.UTC)
 
-    if user.password_hash is None:
-        reset_token_validity_time = datetime.timedelta(days=28)
+    user = user_by_uid(user_id)
+    if not user:
+        return None
+    elif token_time < mo.now - datetime.timedelta(days=28):
+        return None
     else:
-        reset_token_validity_time = datetime.timedelta(hours=24)
+        return user
 
-    now = datetime.datetime.now().astimezone()
 
-    if (user
-            and user.reset_at is not None
-            and fields[1] == str(int(user.reset_at.timestamp()))
-            and now - user.reset_at < reset_token_validity_time):
-        return user
-    else:
+def new_reg_request(type: db.RegReqType, client: str) -> Optional[db.RegRequest]:
+    sess = db.get_session()
+
+    # Zatím jen jednoduchý rate limit, časem možno vylepšit
+    in_last_minute = db.get_count(sess.query(db.RegRequest).filter(db.RegRequest.created_at >= mo.now - datetime.timedelta(minutes=1)))
+    if in_last_minute >= config.REG_MAX_PER_MINUTE:
         return None
 
+    email_token = mo.tokens.sign_token([str(int(mo.now.timestamp())), secrets.token_hex(16)], 'reg-request')
 
-def cancel_reset_password(user: db.User):
-    user.reset_at = None
-    mo.util.log(
-        type=db.LogType.user,
-        what=user.user_id,
-        details={'action': 'cancel-reset'},
+    return db.RegRequest(
+        type=type,
+        created_at=mo.now,
+        expires_at=mo.now + datetime.timedelta(minutes=config.REG_TOKEN_VALIDITY),
+        email_token=email_token,
+        client=client,
     )
 
 
-def do_reset_password(user: db.User):
-    user.reset_at = None
-    mo.util.log(
-        type=db.LogType.user,
-        what=user.user_id,
-        details={'action': 'do-reset'},
-    )
+def expire_reg_requests():
+    sess = db.get_session()
+    conn = sess.connection()
+    table = db.RegRequest.__table__
+    conn.execute(table.delete().where(table.c.expires_at < mo.now))
+    sess.commit()
+
+
+def request_reset_password(user: db.User, client: str) -> Optional[db.RegRequest]:
+    logger.info('Login: Požadavek na reset hesla pro <%s>', user.email)
+    rr = new_reg_request(db.RegReqType.reset_passwd, client)
+    if rr:
+        db.get_session().add(rr)
+        rr.user_id = user.user_id
+        mo.util.log(
+            type=db.LogType.user,
+            what=user.user_id,
+            details={'action': 'ask-reset'},
+        )
+    return rr
diff --git a/mo/util.py b/mo/util.py
index 10c2d63896d8f70cb31de877a5a4ced9458a4e7f..56223feec89de5e9f8660f67b658dc0be0d5469a 100644
--- a/mo/util.py
+++ b/mo/util.py
@@ -4,18 +4,13 @@ from dataclasses import dataclass
 import datetime
 import decimal
 import dateutil.tz
-import email.message
-import email.headerregistry
 import locale
 import logging
 import os
 import re
 import secrets
-import subprocess
 import sys
 from typing import Any, Optional, NoReturn, Tuple, List
-import textwrap
-import urllib.parse
 
 import mo
 import mo.db as db
@@ -60,88 +55,6 @@ def log(type: db.LogType, what: int, details: Any):
     db.get_session().add(entry)
 
 
-def send_user_email(user: db.User, subject: str, body: str) -> bool:
-    logger.info(f'Mail: "{subject}" -> {user.email}')
-
-    mail_from = getattr(config, 'MAIL_FROM', None)
-    if mail_from is None:
-        logger.error('Mail: V configu chybí nastavení MAIL_FROM')
-        return False
-
-    msg = email.message.EmailMessage()
-    msg['From'] = email.headerregistry.Address(
-        display_name='Odevzdávací Systém MO',
-        addr_spec=mail_from,
-    )
-    msg['To'] = [
-        email.headerregistry.Address(
-            display_name=user.full_name(),
-            addr_spec=user.email,
-        )
-    ]
-    msg['Reply-To'] = email.headerregistry.Address(
-        display_name='Správce OSMO',
-        addr_spec=config.MAIL_CONTACT,
-    )
-    msg['Subject'] = 'OSMO – ' + subject
-    msg['Date'] = datetime.datetime.now()
-
-    msg.set_content(body, cte='quoted-printable')
-
-    mail_instead = getattr(config, 'MAIL_INSTEAD', None)
-    if mail_instead is None:
-        send_to = user.email
-    else:
-        send_to = mail_instead
-
-    sm = subprocess.Popen(
-        [
-            '/usr/sbin/sendmail',
-            '-oi',
-            '-f',
-            mail_from,
-            send_to,
-        ],
-        stdin=subprocess.PIPE,
-    )
-    sm.communicate(msg.as_bytes())
-
-    if sm.returncode != 0:
-        logger.error('Mail: Sendmail failed with return code {}'.format(sm.returncode))
-        return False
-
-    return True
-
-
-def password_reset_url(token: str) -> str:
-    return config.WEB_ROOT + 'auth/reset?' + urllib.parse.urlencode({'token': token}, safe=':')
-
-
-def send_new_account_email(user: db.User, token: str) -> bool:
-    return send_user_email(user, 'Založen nový účet', textwrap.dedent('''\
-        Vítejte!
-
-        Právě Vám byl založen účet v Odevzdávacím systému Matematické olympiády.
-        Nastavte si prosím heslo na následující stránce:
-
-                {}
-
-        Váš OSMO
-    '''.format(password_reset_url(token))))
-
-
-def send_password_reset_email(user: db.User, token: str) -> bool:
-    return send_user_email(user, 'Obnova hesla', textwrap.dedent('''\
-        Někdo (pravděpodobně Vy) požádal o obnovení hesla k Vašemu účtu v Odevzdávacím
-        systému Matematické olympiády. Heslo si můžete nastavit, případně požadavek
-        zrušit, na následující stránce:
-
-                {}
-
-        Váš OSMO
-    '''.format(password_reset_url(token))))
-
-
 def die(msg: str) -> NoReturn:
     print(msg, file=sys.stderr)
     sys.exit(1)
@@ -268,3 +181,9 @@ def parse_int_list(a: str, maxim: int = 200) -> List[int]:
             raise mo.CheckError("Větší číslo nemůže být před menším")
         r += [c[0]] if len(c) == 1 else range(c[0], c[1] + 1)
     return r
+
+
+def clean_up_token(token: str) -> str:
+    # Někteří klienti při kopírování adresy z mailu do prohlížeče
+    # přidávají divné Unicodové znaky (přepnutí směru psaní atd., viz issue #58).
+    return re.sub(r'[^!-~]', "", token)
diff --git a/mo/util_format.py b/mo/util_format.py
index 7b20df1ca28b965a0c012909aa50377e20125c95..149e77a1d2a93166d0b454fe95bd1bda17b45224 100644
--- a/mo/util_format.py
+++ b/mo/util_format.py
@@ -41,6 +41,13 @@ def timeformat(dt: datetime) -> str:
         return dt.astimezone().strftime("%Y-%m-%d %H:%M")
 
 
+def timeformat_short(dt: datetime) -> str:
+    if dt is None:
+        return '–'
+    else:
+        return dt.astimezone().strftime("%Y-%m-%d")
+
+
 def timedelta(d: datetime, ref: Optional[datetime] = None, descriptive: bool = False) -> str:
     """Vyrábí česky formátované řetězece 'za 3 minuty', 'před 27 dny' a podobně
     z rozdílu daného datetime a referenčního času (například now).
diff --git a/mo/web/__init__.py b/mo/web/__init__.py
index 0121788742a13b59bd58ebf83ff33f27e7eb9a0e..782dba3ebd9d1dc16d5b6fda3839e897eca294c3 100644
--- a/mo/web/__init__.py
+++ b/mo/web/__init__.py
@@ -115,6 +115,7 @@ app.assets.add_assets([
     'bootstrap.min.css',
     'mo.css',
     'js/news-reloader.js',
+    'js/osmo.js',
 ])
 
 
@@ -141,7 +142,7 @@ def init_request():
         if not user:
             # Uživatel mezitím přestal existovat
             app.logger.error('Zrušena session pro neexistujícího uživatele uid=%s', session['uid'])
-            return mo.web.auth.logout()
+            return mo.web.acct.logout()
     else:
         user = None
 
@@ -171,18 +172,28 @@ app.before_request(init_request)
 
 ### UWSGI glue ###
 
+# Čas od času se probudíme a spustíme garbage collector:
+#    - projdeme joby pro případ, že by se ztratil signál
+#    - expirujeme zastaralé joby
+#    - expirujeme zastaralé registrační tokeny
+@app.cli.command('gc')
+def gc():
+    """Run garbage collector."""
+
+    mo.now = mo.util.get_now()
+    mo.jobs.process_jobs()
+    mo.users.expire_reg_requests()
+
+
 try:
     import uwsgi
     from uwsgidecorators import timer, signal
 
-    # Čas od času se probudíme a projdeme joby pro případ, že by se ztratil signál.
-    # Také při tom expirujeme zastaralé joby.
-    @timer(config.JOB_GC_PERIOD, target='mule')
+    @timer(config.GC_PERIOD, target='mule')
     def mule_timer(signum):
         # app.logger.debug('Mule: Timer tick')
         with app.app_context():
-            mo.now = mo.util.get_now()
-            mo.jobs.process_jobs()
+            garbage_collect()
 
     # Obykle při vložení jobu dostaneme signál.
     @signal(42, target='mule')
@@ -205,7 +216,8 @@ except ImportError:
 
 
 # Většina webu je v samostatných modulech
-import mo.web.auth
+import mo.web.api
+import mo.web.acct
 import mo.web.jinja
 import mo.web.menu
 import mo.web.misc
diff --git a/mo/web/acct.py b/mo/web/acct.py
new file mode 100644
index 0000000000000000000000000000000000000000..cd3bc74e15d377bc877887ec678a6f6a2fbcab9d
--- /dev/null
+++ b/mo/web/acct.py
@@ -0,0 +1,629 @@
+import datetime
+import dateutil.tz
+from enum import Enum, auto
+from flask import render_template, request, g, redirect, url_for, session
+from flask.helpers import flash
+from flask_wtf import FlaskForm
+import html
+from markupsafe import Markup
+import random
+import secrets
+from sqlalchemy.orm import joinedload
+from typing import Optional, Dict
+import werkzeug.exceptions
+import wtforms
+from wtforms import validators, ValidationError
+from wtforms.fields.html5 import EmailField
+
+import mo.config as config
+import mo.db as db
+import mo.rights
+import mo.tokens
+import mo.users
+import mo.util
+from mo.web import app, NeedLoginError
+import mo.web.fields as mo_fields
+
+
+class LoginForm(FlaskForm):
+    next = wtforms.HiddenField()
+    email = mo_fields.Email(validators=[validators.DataRequired()])
+    passwd = wtforms.PasswordField('Heslo')
+    submit = wtforms.SubmitField('Přihlásit se')
+    reset = wtforms.SubmitField('Zapomenuté heslo')
+
+
+def login_and_redirect(user: db.User, flash_msg: Optional[str] = None, url: Optional[str] = None):
+    session.clear()
+    session['uid'] = user.user_id
+    if not url:
+        if user.is_admin or user.is_org:
+            url = url_for('org_index')
+        else:
+            url = url_for('index')
+    else:
+        url = request.script_root + url
+    if flash_msg:
+        flash(flash_msg, 'success')
+    return redirect(url)
+
+
+@app.route('/acct/login', methods=('GET', 'POST'))
+def login():
+    form = LoginForm(email=request.args.get('email'))
+
+    if not form.validate_on_submit():
+        return render_template('login.html', form=form, error=None)
+
+    email = form.email.data
+    user = mo.users.user_by_email(email)
+
+    if not user:
+        app.logger.error('Login: Neznámý uživatel <%s>', email)
+        flash('Neznámý uživatel', 'danger')
+    elif form.reset.data:
+        rr = mo.users.request_reset_password(user, request.remote_addr)
+        if rr:
+            db.get_session().commit()
+            mo.email.send_password_reset_email(user, rr.email_token)
+            flash('Na uvedenou adresu byl odeslán e-mail s odkazem na obnovu hesla.', 'success')
+        else:
+            flash('Příliš časté požadavky na obnovu hesla.', 'danger')
+    elif not form.passwd.data or not mo.users.check_password(user, form.passwd.data):
+        app.logger.error('Login: Špatné heslo pro uživatele <%s>', email)
+        flash('Chybné heslo', 'danger')
+    else:
+        if user.is_admin:
+            typ = ' (admin)'
+        elif user.is_org:
+            typ = ' (org)'
+        elif user.is_test:
+            typ = ' (test)'
+        else:
+            typ = ""
+        app.logger.info('Login: Přihlásil se uživatel #%s <%s>%s', user.user_id, email, typ)
+        mo.users.login(user)
+        db.get_session().commit()
+        return login_and_redirect(user, url=form.next.data)
+
+    return render_template('login.html', form=form)
+
+
+@app.route('/acct/logout', methods=('POST',))
+def logout():
+    session.clear()
+    return redirect(url_for('index'))
+
+
+@app.route('/acct/incarnate/<int:id>', methods=('POST',))
+def incarnate(id):
+    if not g.user.is_admin:
+        raise werkzeug.exceptions.Forbidden()
+
+    new_user = db.get_session().query(db.User).get(id)
+    if not new_user:
+        raise werkzeug.exceptions.NotFound()
+
+    app.logger.info('Login: Uživatel #%s se převtělil na #%s', g.user.user_id, new_user.user_id)
+    return login_and_redirect(new_user, flash_msg='Převtělení proběhlo')
+
+
+@app.route('/user/settings')
+def user_settings():
+    sess = db.get_session()
+
+    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_email, 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.email.send_confirm_change_email(user, rr.email_token)
+            else:
+                app.logger.info('Settings: Rate limit')
+                flash('Příliš mnoho požadavků na změny e-mailu. 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)
+def handle_need_login(e):
+    form = LoginForm()
+    form.next.data = request.path
+    return render_template('login.html', form=form), e.code
+
+
+class ResetForm(FlaskForm):
+    email = wtforms.StringField('E-mail', description='Účet pro který se nastavuje nové heslo', render_kw={"disabled": "disabled"})
+    new_passwd = mo_fields.NewPassword(validators=[validators.DataRequired()])
+    new_passwd2 = mo_fields.RepeatPassword(validators=[validators.DataRequired()])
+    submit = wtforms.SubmitField('Nastavit heslo')
+
+
+# URL je explicitně uvedeno v mo.email.activate_url
+@app.route('/acct/activate', methods=('GET', 'POST'))
+def activate():
+    token = request.args.get('token')
+    if not token:
+        flash('Chybí token pro aktivaci účtu', 'danger')
+        return redirect(url_for('login'))
+
+    user = mo.users.check_activation_token(token)
+    if not user:
+        flash('Neplatný kód pro aktivaci účtu. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.', 'danger')
+        return redirect(url_for('login'))
+
+    if user.last_login_at is not None:
+        flash('Tento účet už byl aktivován. Pokud neznáte heslo, použijte tlačítko pro obnovu hesla.', 'danger')
+        return redirect(url_for('login'))
+
+    form = ResetForm(email=user.email)
+    ok = form.validate_on_submit()
+    if not ok:
+        return render_template('acct_activate.html', form=form)
+
+    app.logger.info('Login: Aktivace účtu uživatele <%s>', user.email)
+    mo.users.set_password(user, form.new_passwd.data, reset=True)
+    mo.users.login(user)
+    db.get_session().commit()
+    return login_and_redirect(user, flash_msg='Nastavení nového hesla a přihlášení do systému proběhlo úspěšně')
+
+
+class RegStatus(Enum):
+    new = auto()
+    ok = auto()
+    expired = auto()
+    already_exists = auto()
+    # Jen v 1. kroku:
+    rate_limited = auto()
+    wrong_captcha = auto()
+    # Jen v 2. kroku:
+    already_spent = auto()
+
+
+class Reg1:
+    create_time: datetime.datetime
+    seed: str
+    status: RegStatus
+    email_token: str
+    x: int
+    y: int
+
+    def __init__(self, from_token: Optional[str] = None):
+        self.status = self._parse_token(from_token)
+        if self.status == RegStatus.ok:
+            self._gen_captcha()
+        else:
+            self._reset()
+
+    def _reset(self):
+        self.create_time = mo.now
+        self.seed = secrets.token_hex(16)
+        app.logger.debug(f'Reg1: Nový token: seed={self.seed}')
+        self._gen_captcha()
+
+    def as_token(self) -> str:
+        return mo.tokens.sign_token([str(int(self.create_time.timestamp())), self.seed], 'reg1')
+
+    def _parse_token(self, token: Optional[str]) -> RegStatus:
+        if token is None:
+            return RegStatus.new
+
+        fields = mo.tokens.verify_token(token, 'reg1')
+        if not fields:
+            app.logger.debug(f'Reg1: Neplatný token: {token}')
+            return RegStatus.new
+
+        self.create_time = datetime.datetime.fromtimestamp(int(fields[0]), tz=dateutil.tz.UTC)
+        self.seed = fields[1]
+        app.logger.debug(f'Reg1: Přijat token: {self.create_time} {self.seed}')
+
+        token_age = mo.now - self.create_time
+        if token_age > datetime.timedelta(minutes=config.REG_TOKEN_VALIDITY):
+            app.logger.debug('Reg1: Token expiroval')
+            return RegStatus.expired
+
+        return RegStatus.ok
+
+    def _init_rng(self) -> random.Random:
+        rng = random.Random()
+        rng.seed(mo.tokens.hash('rng-init', self.seed))
+        return rng
+
+    def _gen_captcha(self):
+        rng = self._init_rng()
+        self.x = rng.randrange(1, 10)
+        self.y = rng.randrange(1, 10)
+        app.logger.debug(f'Reg1: Captcha: {self.x}*{self.y}')
+
+    def captcha_task(self) -> str:
+        cisla = ['nula', 'jedna', 'dva', 'tři', 'čtyři', 'pět', 'šest', 'sedm', 'osm', 'devět',
+                 'deset', 'jedenáct', 'dvanáct', 'třináct', 'čtrnáct', 'patnáct', 'šestnáct', 'sedmnáct', 'osmnáct', 'devatenáct']
+        return f'Napište číslem, kolik je {cisla[self.x]} krát {cisla[self.y]}.'
+
+    def captcha_check_answer(self, answer: str) -> bool:
+        correct = self.x * self.y
+        if answer == str(correct):
+            app.logger.debug(f'Reg1: Captcha: {self.x}*{self.y}={answer} správně')
+            return True
+        else:
+            app.logger.debug(f'Reg1: Captcha: {self.x}*{self.y}={answer} špatně')
+            return False
+
+    def create_reg_request(self, email: str) -> bool:
+        sess = db.get_session()
+        rr = sess.query(db.RegRequest).with_for_update().filter_by(captcha_token=self.seed).one_or_none()
+        if rr:
+            self._reset()
+            self.status = RegStatus.expired
+            sess.rollback()
+            app.logger.info('Reg1: Captcha token použit znovu')
+            return False
+
+        rr = mo.users.new_reg_request(db.RegReqType.register, request.remote_addr)
+        if not rr:
+            self._reset()
+            self.status = RegStatus.rate_limited
+            sess.rollback()
+            app.logger.info('Reg1: Rate limit')
+            return False
+
+        self.email_token = rr.email_token
+        rr.email = email
+        rr.captcha_token = self.seed
+        sess.add(rr)
+        sess.commit()
+        return True
+
+    def process(self, email: str, captcha: str) -> bool:
+        # XXX: Nejdříve zapisujeme registraci do DB, a teprve pak ověřujeme captchu.
+        # Tímto způsobem je těžší captchu obejít (protože je rate-limitovaná), ale
+        # zase je snazší páchat DoS útok na celou registraci (protože je rate-limitovaná).
+
+        if not self.create_reg_request(email):
+            return False
+
+        if not self.captcha_check_answer(captcha):
+            self._reset()
+            self.status = RegStatus.wrong_captcha
+            return False
+
+        if mo.users.user_by_email(email):
+            self._reset()
+            self.status = RegStatus.already_exists
+            app.logger.info(f'Reg1: Účet s e-mailem {email} už existuje')
+            return False
+
+        return True
+
+
+class Reg1Form(FlaskForm):
+    email = mo_fields.Email(validators=[validators.DataRequired()])
+    token = wtforms.HiddenField()
+    captcha = wtforms.StringField('Kontrolní odpověď', validators=[validators.DataRequired()])
+    submit = wtforms.SubmitField('Vytvořit účet')
+
+
+@app.route('/acct/create', methods=('GET', 'POST'))
+def create_acct():
+    form = Reg1Form()
+    reg1 = Reg1(form.token.data)
+
+    if reg1.status == RegStatus.ok and form.validate_on_submit() and reg1.process(form.email.data, form.captcha.data):
+        app.logger.debug(f'Reg1: E-mailový token {reg1.email_token}')
+        flash('Odeslán e-mail s odkazem na založení účtu.', 'success')
+        user = db.User(email=form.email.data, first_name='Nový', last_name='Uživatel')
+        mo.email.send_confirm_create_email(user, reg1.email_token)
+        return redirect(url_for('confirm_reg'))
+
+    form.captcha.description = reg1.captcha_task()
+    if reg1.status != RegStatus.ok:
+        form.token.data = reg1.as_token()
+        form.captcha.data = ""
+
+    if reg1.status == RegStatus.expired:
+        flash('Vypršela platnost formuláře, vyplňte ho prosím znovu.', 'danger')
+    elif reg1.status == RegStatus.rate_limited:
+        flash('Přichází příliš mnoho registrací najednou, zkuste to prosím za chvíli znovu.', 'danger')
+    elif reg1.status == RegStatus.already_exists:
+        form.email.errors.append('Účet s touto adresou už existuje. ' + Markup('<a href="' + html.escape(url_for('login', email=form.email.data)) + '">Přihlásit se.</a>'))
+    elif reg1.status == RegStatus.wrong_captcha:
+        form.captcha.errors.append('Chybný výsledek. Zkuste to znovu: ' + reg1.captcha_task())
+
+    return render_template('acct_reg1.html', form=form)
+
+
+class Reg2:
+    reg_type: db.RegReqType
+    status: RegStatus
+    rr: db.RegRequest
+    user: db.User
+
+    messages: Dict[db.RegReqType, Dict[RegStatus, str]] = {
+        db.RegReqType.register: {
+            RegStatus.new: 'Chybný registrační kód. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.',
+            RegStatus.expired: 'Vypršela platnost registrace, vyplňte ji prosím znovu.',
+            RegStatus.already_spent: 'Tento odkaz na potvrzení registrace byl již využit.',
+            RegStatus.already_exists: 'Účet s touto adresou už existuje.',
+        },
+        db.RegReqType.change_email: {
+            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.',
+        },
+        db.RegReqType.reset_passwd: {
+            RegStatus.new: 'Chybný kód pro obnovení hesla. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.',
+            RegStatus.expired: 'Vypršela platnost kódu pro obnovení hesla, požádejte prosím o obnovu znovu.',
+            RegStatus.already_spent: 'Tento odkaz na obnovení hesla byl již využit.',
+        },
+    }
+
+    def __init__(self, token: str, expected_type: db.RegReqType):
+        self.reg_type = expected_type
+        self.status = self._parse_token(token)
+        if self.status == RegStatus.ok:
+            self.status = self._load_rr(token)
+
+    def _parse_token(self, token: Optional[str]) -> RegStatus:
+        if not token:
+            return RegStatus.new
+
+        token = mo.util.clean_up_token(token)
+        fields = mo.tokens.verify_token(token, 'reg-request')
+        if not fields:
+            app.logger.debug(f'Reg2: Neplatný token: {token}')
+            return RegStatus.new
+
+        create_time = datetime.datetime.fromtimestamp(int(fields[0]), tz=dateutil.tz.UTC)
+        app.logger.debug(f'Reg2: Přijat token: {token}')
+
+        token_age = mo.now - create_time
+        if token_age > datetime.timedelta(minutes=config.REG_TOKEN_VALIDITY):
+            app.logger.debug('Reg2: Token expiroval')
+            return RegStatus.expired
+
+        return RegStatus.ok
+
+    def _load_rr(self, token: str) -> RegStatus:
+        sess = db.get_session()
+        rr = sess.query(db.RegRequest).with_for_update().filter_by(email_token=token).one_or_none()
+        if not rr:
+            app.logger.info('Reg2: Registrace nenalezena')
+            return RegStatus.expired
+
+        if rr.expires_at < mo.now:
+            app.logger.info('Reg2: Registrace expirovala')
+            return RegStatus.expired
+
+        if rr.used_at is not None:
+            app.logger.info('Reg2: Registrace spotřebována')
+            return RegStatus.already_spent
+
+        if rr.type != self.reg_type:
+            app.logger.info('Reg2: Token špatného typu')
+            return RegStatus.new
+
+        self.rr = rr
+        return RegStatus.ok
+
+    def create(self, first_name: str, last_name: str, passwd: str) -> bool:
+        rr = self.rr
+        assert rr.email is not None
+        email = mo.users.normalize_email(rr.email)      # Pro jistotu
+        sess = db.get_session()
+
+        if db.get_session().query(db.User).with_for_update().filter_by(email=email).one_or_none():
+            # Účet mohl začít existovat mezi 1. a 2. krokem registrace
+            app.logger.info(f'Reg2: Účet s e-mailem {email} začal během registrace existovat')
+            self.status = RegStatus.already_exists
+            return False
+
+        user = db.User(
+            email=email,
+            first_name=first_name,
+            last_name=last_name,
+        )
+        mo.users.set_password(user, passwd)
+
+        rr.used_at = mo.now
+        sess.add(user)
+        sess.flush()
+
+        app.logger.info(f'Reg2: Založen uživatel user=#{user.user_id} email=<{user.email}>')
+        mo.util.log(
+            type=db.LogType.user,
+            what=user.user_id,
+            details={'action': 'register', 'new': db.row2dict(user)},
+        )
+
+        sess.commit()
+        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 change_passwd(self, new_passwd: str):
+        sess = db.get_session()
+        user = self.rr.user
+
+        app.logger.info(f'Reg2: Uživatel #{user.user_id} si resetoval heslo')
+        mo.users.set_password(user, new_passwd, reset=True)
+        mo.users.login(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:
+            flash(msgs[self.status], 'danger')
+
+
+class Reg2Form(FlaskForm):
+    email = wtforms.StringField('E-mail', render_kw={"disabled": "disabled"})
+    first_name = mo_fields.FirstName(validators=[validators.DataRequired()])
+    last_name = mo_fields.LastName(validators=[validators.DataRequired()])
+    new_passwd = mo_fields.NewPassword('Heslo', validators=[validators.DataRequired()])
+    new_passwd2 = mo_fields.RepeatPassword(validators=[validators.DataRequired()])
+    submit = wtforms.SubmitField('Vytvořit účet')
+
+
+# URL je explicitně uvedeno v mo.email.activate_url
+@app.route('/acct/confirm/r', methods=('GET', 'POST'))
+def confirm_reg():
+    token = request.args.get('token')
+    if token is None:
+        return render_template('acct_reg2.html', form=None)
+
+    reg2 = Reg2(token, db.RegReqType.register)
+    if reg2.status != RegStatus.ok:
+        reg2.flash_message()
+        return redirect(url_for('create_acct'))
+
+    form = Reg2Form()
+    if form.validate_on_submit():
+        if reg2.create(form.first_name.data, form.last_name.data, form.new_passwd.data):
+            flash('Založení účtu a přihlášení do systému proběhlo úspěšně.', 'success')
+            app.logger.info(f'Login: Přihlásil se uživatel <{reg2.user.email}> po založení účtu')
+            return login_and_redirect(reg2.user, flash_msg='Účet úspěšně založen.')
+
+    reg2.flash_message()
+    form.email.data = reg2.rr.email
+
+    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')
+
+
+# URL je explicitně uvedeno v mo.email.activate_url
+@app.route('/acct/confirm/e', methods=('GET', 'POST'))
+def confirm_email():
+    reg2 = Reg2(request.args.get('token'), db.RegReqType.change_email)
+    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)
+
+
+class CancelResetForm(FlaskForm):
+    cancel = wtforms.SubmitField('Zrušit obnovu hesla')
+
+
+# URL je explicitně uvedeno v mo.email.activate_url
+@app.route('/acct/confirm/p', methods=('GET', 'POST'))
+def confirm_reset():
+    reg2 = Reg2(request.args.get('token'), db.RegReqType.reset_passwd)
+    if reg2.status != RegStatus.ok:
+        reg2.flash_message()
+        return redirect(url_for('login'))
+
+    form = ResetForm(email=reg2.rr.user.email)
+    if form.validate_on_submit() and form.submit.data:
+        reg2.change_passwd(form.new_passwd.data)
+        return login_and_redirect(reg2.rr.user, flash_msg='Nastavení nového hesla a přihlášení do systému proběhlo úspěšně')
+
+    cform = CancelResetForm()
+    if cform.validate_on_submit() and cform.cancel.data:
+        reg2.spend_request()
+        flash('Požadavek na změnu hesla zrušen.', 'success')
+        return redirect(url_for('user_settings'))
+
+    return render_template('acct_reset_passwd.html', form=form, cancel_form=cform)
+
+
+@app.errorhandler(werkzeug.exceptions.Forbidden)
+def handle_forbidden(e):
+    return render_template('forbidden.html')
diff --git a/mo/web/api.py b/mo/web/api.py
new file mode 100644
index 0000000000000000000000000000000000000000..b6ba68baabdc576c2a0cfb71d6ccd0c2f9495192
--- /dev/null
+++ b/mo/web/api.py
@@ -0,0 +1,77 @@
+from flask import request
+from flask.json import jsonify
+from sqlalchemy import func
+from sqlalchemy.orm import joinedload
+import werkzeug.exceptions
+
+import mo.db as db
+from mo.util_format import inflect_with_number
+from mo.web import app
+
+
+@app.route('/api/')
+def api_root():
+    """Slouží jako prefix pro konstrukci URL v JavaScriptu."""
+    raise werkzeug.exceptions.NotFound()
+
+
+@app.route('/api/find-town')
+def api_find_town():
+    query = request.args.get('q')
+    if query is None or len(query) < 2:
+        return jsonify(error='Zadejte alespoň 2 znaky jména obce.')
+    elif '%' in query:
+        return jsonify(error='Nepovolené znaky ve jménu obce.')
+    else:
+        max_places = 50
+        places = (db.get_session().query(db.Place)
+                  .filter_by(level=3)
+                  .filter(func.lower(db.f_unaccent(db.Place.name)).like(func.lower(db.f_unaccent(query + '%'))))
+                  .options(joinedload(db.Place.parent_place))
+                  .order_by(db.Place.name, db.Place.place_id)
+                  .limit(max_places)
+                  .all())
+        if not places:
+            return jsonify(error='Nenalezena žádná obec.')
+        # XXX: Nemůže se stát, že nastane přesná shoda a k tomu příliš mnoho nepřesných?
+        if len(places) >= max_places:
+            return jsonify(error='Nalezeno příliš mnoho obcí. Zadejte prosím více znaků jména.')
+
+        res = []
+        for p in places:
+            name = p.name
+            if p.name != p.parent_place.name:
+                name += f' (okres {p.parent_place.name})'
+            res.append([p.place_id, name])
+
+        msg = inflect_with_number(len(res), 'Nalezena %s obec.', 'Nalezeny %s obce.', 'Nalezeno %s obcí.')
+
+        return jsonify(found=res, msg=msg)
+
+
+@app.route('/api/get-schools')
+def api_get_schools():
+    town = request.args.get('town')
+    if town is None or not town.isnumeric():
+        raise werkzeug.exceptions.BadRequest()
+    town_id = int(town)
+
+    places = (db.get_session().query(db.Place)
+              .filter_by(level=4, type=db.PlaceType.school, parent=town_id)
+              .options(joinedload(db.Place.school))
+              .order_by(db.Place.name)
+              .all())
+
+    zs = []
+    ss = []
+    for p in places:
+        s = {
+            'id': p.place_id,
+            'name': p.name,
+        }
+        if p.school.is_zs:
+            zs.append(s)
+        if p.school.is_ss:
+            ss.append(s)
+
+    return jsonify(zs=zs, ss=ss)
diff --git a/mo/web/auth.py b/mo/web/auth.py
deleted file mode 100644
index 1185c38e60bb1fafedd20353f55e035972a2434e..0000000000000000000000000000000000000000
--- a/mo/web/auth.py
+++ /dev/null
@@ -1,179 +0,0 @@
-import datetime
-
-from flask import render_template, request, g, redirect, url_for, session
-from flask.helpers import flash
-from flask_wtf import FlaskForm
-import werkzeug.exceptions
-import wtforms
-from wtforms.fields.html5 import EmailField
-import wtforms.validators as validators
-from sqlalchemy.orm import joinedload
-from typing import Optional
-
-import mo.util
-import mo.db as db
-import mo.rights
-import mo.users
-from mo.web import app, NeedLoginError
-
-
-class LoginForm(FlaskForm):
-    next = wtforms.HiddenField()
-    email = EmailField('E-mail', validators=[validators.DataRequired()])
-    passwd = wtforms.PasswordField('Heslo')
-    submit = wtforms.SubmitField('Přihlásit se')
-    reset = wtforms.SubmitField('Zapomenuté heslo')
-
-
-def login_and_redirect(user: db.User, url: Optional[str] = None):
-    session.clear()
-    session['uid'] = user.user_id
-    if not url:
-        if user.is_admin or user.is_org:
-            url = url_for('org_index')
-        else:
-            url = url_for('index')
-    else:
-        url = request.script_root + url
-    return redirect(url)
-
-
-@app.route('/auth/login', methods=('GET', 'POST'))
-def login():
-    form = LoginForm(email=request.args.get('email'))
-
-    if not form.validate_on_submit():
-        return render_template('login.html', form=form, error=None)
-
-    email = form.email.data
-    user = mo.users.user_by_email(email)
-
-    if not user:
-        app.logger.error('Login: Neznámý uživatel <%s>', email)
-        flash('Neznámý uživatel', 'danger')
-    elif form.reset.data:
-        app.logger.info('Login: Požadavek na reset hesla pro <%s>', email)
-
-        min_time_between_resets = datetime.timedelta(minutes=1)
-        now = datetime.datetime.now().astimezone()
-        if (user.reset_at is not None
-                and now - user.reset_at < min_time_between_resets):
-            flash('Poslední požadavek na obnovení hesla byl odeslán příliš nedávno', 'danger')
-        else:
-            token = mo.users.ask_reset_password(user)
-            db.get_session().commit()
-
-            mo.util.send_password_reset_email(user, token)
-            flash('Na uvedenou adresu byl odeslán e-mail s odkazem na obnovu hesla', 'success')
-
-    elif not form.passwd.data or not mo.users.check_password(user, form.passwd.data):
-        app.logger.error('Login: Špatné heslo pro uživatele <%s>', email)
-        flash('Chybné heslo', 'danger')
-    else:
-        if user.is_admin:
-            typ = ' (admin)'
-        elif user.is_org:
-            typ = ' (org)'
-        elif user.is_test:
-            typ = ' (test)'
-        else:
-            typ = ""
-        app.logger.info('Login: Přihlásil se uživatel #%s <%s>%s', user.user_id, email, typ)
-        mo.users.login(user)
-        db.get_session().commit()
-        return login_and_redirect(user, url=form.next.data)
-
-    return render_template('login.html', form=form)
-
-
-@app.route('/auth/logout', methods=('POST',))
-def logout():
-    session.clear()
-    return redirect(url_for('index'))
-
-
-@app.route('/auth/incarnate/<int:id>', methods=('POST',))
-def incarnate(id):
-    if not g.user.is_admin:
-        raise werkzeug.exceptions.Forbidden()
-
-    new_user = db.get_session().query(db.User).get(id)
-    if not new_user:
-        raise werkzeug.exceptions.NotFound()
-
-    app.logger.info('Login: Uživatel #%s se převtělil na #%s', g.user.user_id, new_user.user_id)
-    return login_and_redirect(new_user)
-
-
-@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)
-
-
-@app.errorhandler(NeedLoginError)
-def handle_need_login(e):
-    form = LoginForm()
-    form.next.data = request.path
-    return render_template('login.html', form=form), e.code
-
-
-class ResetForm(FlaskForm):
-    email = EmailField('E-mail', description='Účet pro který se nastavuje nové heslo', render_kw={"disabled": "disabled"})
-    token = wtforms.HiddenField()
-    passwd = wtforms.PasswordField('Nové heslo', description='Heslo musí mít alespoň 8 znaků. Doporučujeme kombinovat velká a malá písmena a číslice.')
-    submit = wtforms.SubmitField('Nastavit heslo')
-    cancel = wtforms.SubmitField('Zrušit obnovu hesla')
-
-
-@app.route('/auth/reset', methods=('GET', 'POST'))
-def reset():
-    token = request.args.get('token')
-    if not token:
-        flash('Žádný token pro resetování hesla', 'danger')
-        return redirect(url_for('login'))
-
-    user = mo.users.check_reset_password(token)
-    if not user:
-        flash('Neplatný požadavek na obnovu hesla', 'danger')
-        return redirect(url_for('login'))
-
-    form = ResetForm(token=token, email=user.email)
-    ok = form.validate_on_submit()
-    if not ok:
-        return render_template('reset.html', form=form)
-
-    if form.cancel.data:
-        mo.users.cancel_reset_password(user)
-        app.logger.info('Login: Zrušen reset hesla pro uživatele <%s>', user.email)
-        db.get_session().commit()
-        flash('Obnova hesla zrušena', 'warning')
-        return redirect(url_for('login'))
-    elif len(form.passwd.data) < 8:
-        flash('Heslo musí být aspoň 8 znaků dlouhé', 'danger')
-        return render_template('reset.html', form=form)
-    else:
-        mo.users.do_reset_password(user)
-        mo.users.set_password(user, form.passwd.data)
-        app.logger.info('Login: Reset hesla pro uživatele <%s>', user.email)
-        mo.util.log(
-            type=db.LogType.user,
-            what=user.user_id,
-            details={'action': 'reset-passwd'},
-        )
-        mo.users.login(user)
-        app.logger.info('Login: Přihlásil se uživatel <%s> po resetování hesla', user.email)
-        db.get_session().commit()
-        flash('Nastavení nového hesla a přihlášení do systému proběhlo úspěšně', 'success')
-        return login_and_redirect(user)
-
-
-@app.errorhandler(werkzeug.exceptions.Forbidden)
-def handle_forbidden(e):
-    return render_template('forbidden.html')
diff --git a/mo/web/fields.py b/mo/web/fields.py
index e6f21da098528f367c161d9421dc366ee78b8135..1a931335a4e06fcc40cb3c985c5c99a32caecf68 100644
--- a/mo/web/fields.py
+++ b/mo/web/fields.py
@@ -133,8 +133,30 @@ class School(Place):
 
     def load_place(field) -> None:
         field.place = None
+        field.place_error = ""
         if field.data:
             try:
                 field.place = mo.users.validate_and_find_school(field.data)
             except mo.CheckError as e:
                 field.place_error = str(e)
+
+
+class NewPassword(wtforms.PasswordField):
+    def __init__(self, label="Nové heslo", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+    def pre_validate(field, form):
+        if field.data:
+            if not mo.users.validate_password(field.data):
+                raise wtforms.ValidationError(mo.users.password_help)
+
+
+class RepeatPassword(wtforms.PasswordField):
+    """Pro validaci hledá ve formuláři form.new_passwd a s ním porovnává."""
+
+    def __init__(self, label="Zopakujte heslo", validators=None, **kwargs):
+        super().__init__(label, validators, **kwargs)
+
+    def pre_validate(field, form):
+        if field.data != form.new_passwd.data:
+            raise wtforms.ValidationError('Hesla se neshodují.')
diff --git a/mo/web/jinja.py b/mo/web/jinja.py
index 503283165a419441381f12753fa4137640c72ae4..c81543bad49d165a1fd15c3582881a363bd5fd18 100644
--- a/mo/web/jinja.py
+++ b/mo/web/jinja.py
@@ -24,6 +24,7 @@ app.jinja_env.trim_blocks = True
 # Filtry definované v mo.util_format
 
 app.jinja_env.filters.update(timeformat=util_format.timeformat)
+app.jinja_env.filters.update(timeformat_short=util_format.timeformat_short)
 app.jinja_env.filters.update(inflected=util_format.inflect_number)
 app.jinja_env.filters.update(inflected_by=util_format.inflect_by_number)
 app.jinja_env.filters.update(timedelta=util_format.timedelta)
@@ -93,6 +94,11 @@ def yes_no(a: bool) -> str:
     return "ano" if a else "ne"
 
 
+@app.template_filter()
+def jsescape(js: Any) -> str:
+    return Markup(json_pretty(js))
+
+
 @app.template_filter()
 def json_pretty(js: Any) -> str:
     return json.dumps(js, sort_keys=True, indent=4, ensure_ascii=False)
diff --git a/mo/web/menu.py b/mo/web/menu.py
index f9b08f4fbb0cecb18177bc9589a294245d4249fc..d4760913e2b797f5a73d2b41d6208a79f7d9fe86 100644
--- a/mo/web/menu.py
+++ b/mo/web/menu.py
@@ -43,7 +43,8 @@ def get_menu():
             name += " [admin]"
         items.append(MenuItem(url_for('user_settings'), name, classes=["right"]))
     else:
-        items.append(MenuItem(url_for('login'), "Přihlásit se", active_prefix="/auth/", classes=["right"]))
+        items.append(MenuItem(url_for('create_acct'), "Založit účet", classes=["right"]))
+        items.append(MenuItem(url_for('login'), "Přihlásit se", active_prefix="/acct/", classes=["right"]))
 
     active = None
     for item in items:
diff --git a/mo/web/org.py b/mo/web/org.py
index 72c67429e74bc812683b708653fb918e54036d56..78d5c77c8a22783b8dbadbc8186ee52a8e4bea11 100644
--- a/mo/web/org.py
+++ b/mo/web/org.py
@@ -78,8 +78,8 @@ school_export_columns = (
 )
 
 
-@app.route('/org/export/skoly')
-def org_export_skoly():
+@app.route('/org/export/schools')
+def org_export_shools():
     sess = db.get_session()
     format = request.args.get('format', 'en_csv')
 
diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index 7e6c9e37c5eba5d50e1edd75c2769905999af834..3eb31e2eecdf393c9657e25483005fb6022f7fe7 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -442,10 +442,11 @@ def org_contest_import(id: int):
     )
 
 
-@app.route('/org/contest/c/<int:id>/ucastnici', methods=('GET', 'POST'))
-@app.route('/org/contest/c/<int:id>/site/<int:site_id>/ucastnici', methods=('GET', 'POST'))
-@app.route('/org/contest/c/<int:id>/ucastnici/emails', endpoint="org_contest_list_emails")
-@app.route('/org/contest/c/<int:id>/site/<int:site_id>/ucastnici/emails', endpoint="org_contest_list_emails")
+# URL je explicitně uvedeno v mo.email.contestant_list_url
+@app.route('/org/contest/c/<int:id>/participants', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:id>/site/<int:site_id>/participants', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:id>/participants/emails', endpoint="org_contest_list_emails")
+@app.route('/org/contest/c/<int:id>/site/<int:site_id>/participants/emails', endpoint="org_contest_list_emails")
 def org_contest_list(id: int, site_id: Optional[int] = None):
     contest, master_contest, site, rr = get_contest_site_rr(id, site_id, Right.view_contestants)
     can_edit = rr.have_right(Right.manage_contest) and request.endpoint != 'org_contest_list_emails'
@@ -1497,7 +1498,7 @@ def org_contest_advance(contest_id: int):
 
         prev_pion_query = (sess.query(db.Participation)
                            .filter(db.Participation.contest_id.in_([c.contest_id for c in prev_contests]))
-                           .filter(db.Participation.state.in_((db.PartState.registered, db.PartState.invited, db.PartState.present))))
+                           .filter_by(state=db.PartState.active))
         prev_pions = prev_pion_query.all()
 
         if form.boundary.data > 0:
@@ -1540,7 +1541,7 @@ def org_contest_advance(contest_id: int):
                         user_id=pp.user_id,
                         contest_id=contest.contest_id,
                         place_id=contest.place.place_id,
-                        state=db.PartState.invited,
+                        state=db.PartState.active,
                     )
                     .on_conflict_do_nothing()
                     .returning(db.Participation.contest_id)
@@ -1650,8 +1651,8 @@ class ParticipantAddForm(FlaskForm):
         self.participation_place.description = f'Pokud účastník soutěží někde jinde než {contest.place.name_locative()}, vyplňte <a href="{url_for("org_place", id=contest.place.place_id)}">kód místa</a>. Dozor na tomto místě pak může za účastníka odevzdávat řešení.'
 
 
-@app.route('/org/contest/c/<int:id>/ucastnici/pridat', methods=('GET', 'POST'))
-@app.route('/org/contest/c/<int:id>/site/<int:site_id>/ucastnici/pridat', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:id>/participants/new', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:id>/site/<int:site_id>/participants/new', methods=('GET', 'POST'))
 def org_contest_add_user(id: int, site_id: Optional[int] = None):
     contest, master_contest, site, rr = get_contest_site_rr(id, site_id, right_needed=Right.manage_contest)
 
@@ -1674,6 +1675,8 @@ def org_contest_add_user(id: int, site_id: Optional[int] = None):
             db.get_session().commit()
             if is_new_user:
                 flash("Založen nový uživatel.", "info")
+                token = mo.users.make_activation_token(user)
+                mo.email.send_new_account_email(user, token)
             if is_new_participant:
                 flash("Založena nová registrace do ročníku.", "info")
             if is_new_participation:
diff --git a/mo/web/org_round.py b/mo/web/org_round.py
index b4da3657181f26e13febaa369f5fb1c09b88133b..f70de3839bd7929168f8134bfd101d1c0e9b7739 100644
--- a/mo/web/org_round.py
+++ b/mo/web/org_round.py
@@ -55,7 +55,15 @@ def org_rounds():
     sess = db.get_session()
 
     rounds = sess.query(db.Round).filter_by(year=mo.current_year).order_by(db.Round.year, db.Round.category, db.Round.seq, db.Round.part)
-    return render_template('org_rounds.html', rounds=rounds)
+    return render_template('org_rounds.html', rounds=rounds, history=False)
+
+
+@app.route('/org/contest/history')
+def org_rounds_history():
+    sess = db.get_session()
+
+    rounds = sess.query(db.Round).order_by(db.Round.year.desc(), db.Round.category, db.Round.seq, db.Round.part)
+    return render_template('org_rounds.html', rounds=rounds, history=True)
 
 
 class TaskDeleteForm(FlaskForm):
@@ -144,14 +152,26 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool:
     )
     app.logger.info(f"Soutěž #{contest.contest_id} založena: {db.row2dict(contest)}")
 
-    # Přidání soutěže do podkol ve skupině
-    subrounds = round.master.get_group_rounds()
+    create_subcontests(round.master, contest)
+
+    sess.commit()
+    flash(f'Založena soutěž {place.name_locative()}', 'success')
+    return True
+
+
+# XXX: Používá se i v registraci účastníků
+def create_subcontests(master_round: db.Round, master_contest: db.Contest):
+    if master_round.part == 0:
+        return
+
+    sess = db.get_session()
+    subrounds = master_round.get_group_rounds()
     for subround in subrounds:
         subcontest = db.Contest(
             round_id=subround.round_id,
-            master_contest_id=contest.contest_id,
-            place_id=contest.place_id,
-            state=state,
+            master_contest_id=master_contest.contest_id,
+            place_id=master_contest.place_id,
+            state=master_contest.state,
         )
         sess.add(subcontest)
         sess.flush()
@@ -161,11 +181,7 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool:
             what=subcontest.contest_id,
             details={'action': 'add', 'contest': db.row2dict(subcontest)},
         )
-        app.logger.info(f"Soutěž #{subcontest.contest_id} založena: {db.row2dict(subcontest)}")
-
-    sess.commit()
-    flash(f'Založena soutěž {place.name_locative()}', 'success')
-    return True
+        app.logger.info(f"Podsoutěž #{subcontest.contest_id} založena: {db.row2dict(subcontest)}")
 
 
 @app.route('/org/contest/r/<int:id>/', methods=('GET', 'POST'))
@@ -446,6 +462,8 @@ class RoundEditForm(FlaskForm):
         "Přesnost bodování", choices=db.round_points_step_choices, coerce=decimal.Decimal,
         description="Ovlivňuje možnost zadávání nových bodů, již uložené body nezmění"
     )
+    enroll_mode = wtforms.SelectField("Režim přihlašování", choices=db.RoundEnrollMode.choices(), coerce=db.RoundEnrollMode.coerce)
+    enroll_advert = wtforms.StringField("Popis v přihlášce")
     has_messages = wtforms.BooleanField("Zprávičky pro účastníky (aktivuje možnost vytvářet novinky zobrazované účastníkům)")
     submit = wtforms.SubmitField('Uložit')
 
@@ -483,6 +501,8 @@ def org_round_edit(id: int):
         del form.score_winner_limit
         del form.score_successful_limit
         del form.points_step
+        # ani nastavení přihlašování
+        del form.enroll_mode
     if form.validate_on_submit():
         form.populate_obj(round)
 
diff --git a/mo/web/org_users.py b/mo/web/org_users.py
index e885537a033c07024aed303dd034d15903535b95..2d00c0106e5da3f59976b7947c0c97e9f985ccd7 100644
--- a/mo/web/org_users.py
+++ b/mo/web/org_users.py
@@ -14,6 +14,7 @@ from wtforms.validators import Required
 
 import mo
 import mo.db as db
+import mo.email
 from mo.rights import Right
 import mo.util
 import mo.users
@@ -316,14 +317,15 @@ class ResendInviteForm(FlaskForm):
     resend_invite = SubmitField()
 
     def do(self, user: db.User):
-        token = mo.users.ask_reset_password(user)
-        db.get_session().commit()
-        if user.last_login_at is None and mo.util.send_new_account_email(user, token):
-            flash('Uvítací e-mail s odkazem pro nastavení hesla odeslán na {}'.format(user.email), 'success')
-        elif mo.util.send_password_reset_email(user, token):
-            flash('E-mail s odkazem pro resetování hesla odeslán na {}'.format(user.email), 'success')
+        if user.last_login_at is None:
+            token = mo.users.make_activation_token(user)
+            db.get_session().commit()
+            if mo.email.send_new_account_email(user, token):
+                flash('Uvítací e-mail s odkazem na aktivaci účtu odeslán na {}.'.format(user.email), 'success')
+            else:
+                flash('Problém při odesílání e-mailu s odkazem na aktivaci účtu.', 'danger')
         else:
-            flash('Problém při odesílání e-mailu s odkazem pro nastavení hesla', 'danger')
+            flash('Tento uživatel už má účet aktivovaný.', 'danger')
 
 
 @app.route('/org/org/<int:id>/', methods=('GET', 'POST'))
@@ -339,7 +341,7 @@ def org_org(id: int):
     can_assign_rights = rr.have_right(Right.assign_rights)
 
     resend_invite_form: Optional[ResendInviteForm] = None
-    if rr.can_edit_user(user):
+    if user.last_login_at is None and rr.can_edit_user(user):
         resend_invite_form = ResendInviteForm()
         if resend_invite_form.resend_invite.data and resend_invite_form.validate_on_submit():
             resend_invite_form.do(user)
@@ -425,7 +427,7 @@ def org_user(id: int):
     rr = g.gatekeeper.rights_generic()
 
     resend_invite_form: Optional[ResendInviteForm] = None
-    if rr.can_edit_user(user):
+    if user.last_login_at is None and rr.can_edit_user(user):
         resend_invite_form = ResendInviteForm()
         if resend_invite_form.resend_invite.data and resend_invite_form.validate_on_submit():
             resend_invite_form.do(user)
@@ -567,17 +569,14 @@ def org_user_new():
                 details={'action': 'new', 'user': db.row2dict(new_user)},
             )
 
+            token = mo.users.make_activation_token(new_user)
             sess.commit()
             flash('Nový uživatel vytvořen', 'success')
 
-            # Send password (re)set link
-            token = mo.users.ask_reset_password(new_user)
-            db.get_session().commit()
-
-            if mo.util.send_new_account_email(new_user, token):
-                flash('E-mail s odkazem pro nastavení hesla odeslán na {}'.format(new_user.email), 'success')
+            if mo.email.send_new_account_email(new_user, token):
+                flash('E-mail s odkazem na aktivaci účtu odeslán na {}.'.format(new_user.email), 'success')
             else:
-                flash('Problém při odesílání e-mailu s odkazem pro nastavení hesla', 'danger')
+                flash('Problém při odesílání e-mailu s odkazem na aktivaci účtu.', 'danger')
 
             if is_org:
                 return redirect(url_for('org_org', id=new_user.user_id))
diff --git a/mo/web/templates/acct_activate.html b/mo/web/templates/acct_activate.html
new file mode 100644
index 0000000000000000000000000000000000000000..1117b6c929457c315e2a02dd79f7beb42bb87d4b
--- /dev/null
+++ b/mo/web/templates/acct_activate.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% block title %}Aktivace nového účtu{% endblock %}
+{% block body %}
+
+{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }}
+
+{% endblock %}
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/acct_reg1.html b/mo/web/templates/acct_reg1.html
new file mode 100644
index 0000000000000000000000000000000000000000..558cac93abb32164cb83b2ebe28b8b6bd9637f74
--- /dev/null
+++ b/mo/web/templates/acct_reg1.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% block title %}Založení účtu{% endblock %}
+{% block body %}
+
+<p>Nejprve vyplňte svou e-mailovou adresu, která také bude sloužit jako přihlašovací jméno.
+Na ni vám pošleme ověřovací e-mail.
+
+{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }}
+
+{% endblock %}
diff --git a/mo/web/templates/acct_reg2.html b/mo/web/templates/acct_reg2.html
new file mode 100644
index 0000000000000000000000000000000000000000..3372d16f9d72f49a5632cbae7cd6bf772298e422
--- /dev/null
+++ b/mo/web/templates/acct_reg2.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% block title %}Založení účtu{% endblock %}
+{% block body %}
+
+{% if form %}
+
+<p>S údaji o účtu budeme zacházet v souladu se <a href='{{ url_for('doc_gdpr') }}'>zásadami
+zpracování osobních údajů</a>.
+
+{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }}
+
+{% else %}
+
+<p>Počkejte prosím, až vám přijde e-mail a klikněte na odkaz v něm uvedený.
+
+{% endif %}
+
+{% endblock %}
diff --git a/mo/web/templates/acct_reset_passwd.html b/mo/web/templates/acct_reset_passwd.html
new file mode 100644
index 0000000000000000000000000000000000000000..1342edeada97075dacdba3ecbe58076d737b5551
--- /dev/null
+++ b/mo/web/templates/acct_reset_passwd.html
@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% block title %}Nastavení nového hesla{% endblock %}
+{% block body %}
+
+{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }}
+{{ wtf.quick_form(cancel_form, form_type='horizontal') }}
+
+{% endblock %}
diff --git a/mo/web/templates/doc_about.html b/mo/web/templates/doc_about.html
index 51f0d0fe392a22e807cf07286591395985d306b3..2aa703cc204d238049c05d2aeff71811da8c2347 100644
--- a/mo/web/templates/doc_about.html
+++ b/mo/web/templates/doc_about.html
@@ -12,6 +12,8 @@
 <p>z <a href='https://www.mff.cuni.cz/'>Matematicko-fyzikální fakulty</a> <a href='https://www.cuni.cz/'>Univerzity Karlovy</a> v Praze.
 MFF UK také děkujeme za poskytnutí serveru, kde systém běží.
 
+<h3>Správce systému</h3>
+
 <p>Veškeré připomínky k chodu systému a nápady na další rozvoj
 prosím posílejte e-mailem na {{ config.MAIL_CONTACT|mailto }}.
 
diff --git a/mo/web/templates/login.html b/mo/web/templates/login.html
index ccef7950b1482dfac0a648d15cf7f523ea98c96c..255154712a7761f2ea35da7088cd61299eab746e 100644
--- a/mo/web/templates/login.html
+++ b/mo/web/templates/login.html
@@ -1,6 +1,6 @@
 {% extends "base.html" %}
 {% import "bootstrap/wtf.html" as wtf %}
-{% block title %}Login{% endblock %}
+{% block title %}Přihlášení uživatele{% endblock %}
 {% block body %}
 
 	<form method="POST" class="form form-horizontal" action="{{ url_for('login') }}">
@@ -11,6 +11,7 @@
 		<div class="btn-group col-lg-offset-2">
 			{{ wtf.form_field(form.submit, class="btn btn-primary") }}
 			{{ wtf.form_field(form.reset) }}
+			<a class='btn btn-default' href='{{ url_for('create_acct') }}'>Založit nový účet</a>
 		</div>
 	</form>
 
diff --git a/mo/web/templates/main.html b/mo/web/templates/main.html
index 631596ce2444769b6c4a03a855e80dcc07d191cb..e742505d32c975a8f3520fb528a94372a05acc76 100644
--- a/mo/web/templates/main.html
+++ b/mo/web/templates/main.html
@@ -1,15 +1,11 @@
 {% extends "base.html" %}
 {% block title %}Vítejte{% endblock %}
 {% block body %}
-
 	<p>Na tomto webu je možné odevzdávat řešení úloh Matematické olympiády.
-	Momentálně je dostupný pouze uživatelům, kteří získali účet při postupu
-	do dalšího kola MO.
 
-	<p>Pokud účet máte, tak se prosím přihlašte.
+	<p>Na odevzdávání potřebujete účet. Pokud už ho máte, tak se prosím přihlašte.
+	V opačném případě si účet založte.
 
-	<p>Pokud jste postoupili, ale účet dosud nemáte, zkontrolujte prosím
-	svou složku na nevyžádanou poštu, zda pozvánka neuvízla tam. Nepomůže-li
-	to, obraťte se prosím na svého učitele matematiky nebo na správce
-	tohoto systému (odkaz viz patička).
+	<p><a class='btn btn-primary' href='{{ url_for('login') }}'>Přihlásit se</a>
+	<a class='btn btn-primary' href='{{ url_for('create_acct') }}'>Založit nový účet</a>
 {% endblock %}
diff --git a/mo/web/templates/org_index.html b/mo/web/templates/org_index.html
index 73a21f886d3be32aea08d14005d44e0d0e0e022d..e4a5b0f5011b8458a24583502c2dadb683287f0e 100644
--- a/mo/web/templates/org_index.html
+++ b/mo/web/templates/org_index.html
@@ -41,9 +41,9 @@
 <li><a href='{{ url_for('doc_garant') }}'>Návod pro garanty</a> (může se hodit i ostatním organizátorům)
 <li><a href='{{ url_for('static', filename='doc/import-navod.pdf') }}'>Podrobnější návod k importům</a> (PDF)
 <li>Export všech škol:
-	<a href='{{ url_for('org_export_skoly', format='en_csv') }}'>CSV s čárkami</a>,
-	<a href='{{ url_for('org_export_skoly', format='cs_csv') }}'>CSV se středníky</a>,
-	<a href='{{ url_for('org_export_skoly', format='tsv') }}'>TSV</a>
+	<a href='{{ url_for('org_export_schools', format='en_csv') }}'>CSV s čárkami</a>,
+	<a href='{{ url_for('org_export_schools', format='cs_csv') }}'>CSV se středníky</a>,
+	<a href='{{ url_for('org_export_schools', format='tsv') }}'>TSV</a>
 <li><a href='https://docs.google.com/document/d/1XXk7Od-ZKtfmfNa-9FpFjUqmy0Ekzf2-2q3EpSWyn1w/edit?usp=sharing'>Návod na tvorbu PDF</a>
 </ul>
 
diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html
index 2d13b18136d95d201618c4a06af1acfce84f6a19..ddddc7df8f2f622a83a951de62106679b7c53834 100644
--- a/mo/web/templates/org_round.html
+++ b/mo/web/templates/org_round.html
@@ -34,6 +34,8 @@
 	{% with state=round.ct_state() %}
 	<tr><td>Stav pro účastníky<td class='rstate-{{state.name}}'>{{ state.friendly_name() }}
 	{% endwith %}
+	<tr><td>Režim přihlašování<td>{{ round.enroll_mode.friendly_name() }}
+	<tr><td>Popis v přihlášce<td>{{ round.enroll_advert }}
 </table>
 <table class=data style="float: left;">
 	<thead>
diff --git a/mo/web/templates/org_rounds.html b/mo/web/templates/org_rounds.html
index fd336634167c91193585aa49edd2a173495440df..d4b415dc7e863a0df32fd04f885522194ae67780 100644
--- a/mo/web/templates/org_rounds.html
+++ b/mo/web/templates/org_rounds.html
@@ -23,4 +23,10 @@
 			<td class='rstate-{{r.state.name}}'>{{ r.state.friendly_name() }}
 	{% endfor %}
 	</table>
+
+{% if history %}
+	<p><a class='btn btn-default' href='{{ url_for('org_rounds') }}'>Aktuální ročník</a>
+{% else %}
+	<p><a class='btn btn-default' href='{{ url_for('org_rounds_history') }}'>Všechny ročníky</a>
+{% endif %}
 {% endblock %}
diff --git a/mo/web/templates/org_user.html b/mo/web/templates/org_user.html
index 966d4b94591f593b6f012aca424ba3a5ddaabc6b..05463d5fedf7d748cda5aef70109032cae7decbb 100644
--- a/mo/web/templates/org_user.html
+++ b/mo/web/templates/org_user.html
@@ -24,7 +24,7 @@
 <form method=POST class='btn-group' onsubmit='return confirm("Poslat účastníkovi e-mail s odkazem na vytvoření hesla?");'>
 	{{ resend_invite_form.csrf_token }}
 	<button class="btn btn-default" type='submit' name='resend_invite' value='yes'>
-		{% if user.last_login_at %}Resetovat heslo{% else %}Znovu poslat zvací e-mail{% endif %}
+		Znovu poslat zvací e-mail
 	</button>
 </form>
 {% endif %}
diff --git a/mo/web/templates/reset.html b/mo/web/templates/reset.html
deleted file mode 100644
index bb382b0a53a39d60c42807dcbc086493163dcdb6..0000000000000000000000000000000000000000
--- a/mo/web/templates/reset.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{% extends "base.html" %}
-{% import "bootstrap/wtf.html" as wtf %}
-{% block title %}Nastavení nového hesla{% endblock %}
-{% block body %}
-
-	<form method="POST" class="form form-horizontal" action="">
-		{{ form.csrf_token }}
-		{{ form.token() }}
-		{{ wtf.form_field(form.email, form_type='horizontal') }}
-		{{ wtf.form_field(form.passwd, form_type='horizontal') }}
-		<div class="btn-group col-lg-offset-2">
-			{{ wtf.form_field(form.submit, class="btn btn-primary") }}
-			{{ wtf.form_field(form.cancel) }}
-		</div>
-	</form>
-
-{% 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 %}
diff --git a/mo/web/templates/user_contest.html b/mo/web/templates/user_contest.html
index 147bc9ab0432159148d4dbaf17a7d67d99bcde44..848ceac4682196f019e08b8e60e16f8b79443f4a 100644
--- a/mo/web/templates/user_contest.html
+++ b/mo/web/templates/user_contest.html
@@ -29,7 +29,18 @@
 	{% endif %}
 {% endif %}
 
-{% if state == RoundState.preparing %}
+{% if part_state == PartState.registered %}
+<p>
+	Vaše přihláška do této soutěže <b>dosud nebyla potvrzena organizátory.</b>
+	Vyčkejte prosím.
+	{% set state = RoundState.preparing %}
+</p>
+{% elif part_state == PartState.refused %}
+<p>
+	Vaše přihláška do této soutěže <b>byla odmítnuta organizátory.</b>
+	{% set state = RoundState.preparing %}
+</p>
+{% elif state == RoundState.preparing %}
 <p>
 	Soutěžní kolo se <b>připravuje</b>{% if round.ct_tasks_start and round.ct_tasks_start > g.now %},
 	začne <b>{{ round.ct_tasks_start|time_and_timedelta }}</b>{% endif %}.
@@ -64,12 +75,12 @@ Pokud si s tvorbou PDF nevíte rady, zkuste se podívat do <a href='https://docs
 <p>Soutěž se nachází v neznámém stavu. To by se nemělo stát :)
 {% endif %}
 
+{% if state != RoundState.preparing %}
+
 {% if contest.ct_task_statement_available() %}
 <p>Můžete si stáhnout <a href='{{ url_for('user_task_statement', id=contest.contest_id) }}'>zadání úloh</a>.
 {% endif %}
 
-{% if state != RoundState.preparing %}
-
 <h3>Úlohy</h3>
 
 <table class="table table-bordered table-hover">
diff --git a/mo/web/templates/user_index.html b/mo/web/templates/user_index.html
index 27e5b95e2e5372058f77a9fa9bbd0f852a974d15..440b9a16f664150213f456f110b2329d6680a3cd 100644
--- a/mo/web/templates/user_index.html
+++ b/mo/web/templates/user_index.html
@@ -42,4 +42,6 @@
 	<p>Momentálně se neúčastníte žádného kola MO.
 {% endif %}
 
+<p><a class="btn btn-primary" href="{{ url_for('user_join') }}">Přihlásit se do další kategorie</a>
+
 {% endblock %}
diff --git a/mo/web/templates/user_join_list.html b/mo/web/templates/user_join_list.html
new file mode 100644
index 0000000000000000000000000000000000000000..3f7796f59683b234b2c938110ac261f9c1d0db85
--- /dev/null
+++ b/mo/web/templates/user_join_list.html
@@ -0,0 +1,40 @@
+{% extends "base.html" %}
+{% block title %}Přihláška do MO{% endblock %}
+{% block body %}
+
+{% if available_rounds %}
+	<p>Zde si můžete vybrat, do kterých kategorií olympíády se přihlásíte.
+
+	<table class="table">
+		<thead>
+			<tr>
+				<th>Kategorie
+				<th>Popis
+				<th>Kolo
+				<th>Termíny
+				<th>Odkazy
+		<tbody>
+			{% for round in available_rounds %}
+			<tr>
+				<td><b>{{ round.category }}</b>
+				<td>{{ round.enroll_advert }}</b>
+				<td>{{ round.name }}
+				<td>{{ round.format_times() }}
+				{% if round.round_id in pcrs_by_round_id %}
+					<td>Již přihlášen
+				{% else %}
+					<td><a href='{{ url_for('user_join_round', round_id=round.round_id) }}' class='btn btn-xs btn-primary'>Přihlásit se</a>
+				{% endif %}
+			{% endfor %}
+	</table>
+{% else %}
+	<p>V tomto školním roce zatím nejsou otevřené žádné kategorie olympiády.
+	Zkuste to prosím později.
+{% endif %}
+
+{% if pcrs_by_round_id %}
+{# Není-li účastník přihlášen v žádné soutěží, user_index přesměrovává zpět na tuto stránku. #}
+<p><a class="btn btn-default" href="{{ url_for('user_index') }}">Zpět</a>
+{% endif %}
+
+{% endblock %}
diff --git a/mo/web/templates/user_join_round.html b/mo/web/templates/user_join_round.html
new file mode 100644
index 0000000000000000000000000000000000000000..d11437659439ccec3c18258a53db3b52b894a7a8
--- /dev/null
+++ b/mo/web/templates/user_join_round.html
@@ -0,0 +1,71 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% block head %}
+<script src='{{ asset_url('js/osmo.js') }}'></script>
+<script>
+	osmo_api_root = {{ url_for('api_root')|jsescape }};
+	osmo_school = new OSMOSchool();
+	{% if form.town_query.data %}
+		osmo_school.prefill_town_query = {{ form.town_query.data|jsescape }};
+		{% if form.town_list.data %}
+		osmo_school.prefill_town_list = {{ form.town_list.data|jsescape }};
+		{% if form.school.data %}
+		osmo_school.prefill_school = {{ form.school.data|jsescape }};
+		{% endif %}
+		{% endif %}
+	{% endif %}
+	document.addEventListener('DOMContentLoaded', () => { osmo_school.init() });
+</script>
+{% endblock %}
+{% block title %}Přihláška{% endblock %}
+{% block body %}
+
+<h3 style='margin-bottom: 21px'>{{ round.name }} kategorie {{ round.category }} {% if round.enroll_advert %} ({{ round.enroll_advert }}){% endif %}</h3>
+
+<form action="" method="POST" role="form" class="form form-horizontal">
+	{{ form.csrf_token }}
+	{% if form.school %}
+		<div id='town_query-group' class='form-group row required{% if form.school.errors %} has-error{% endif %}'>
+			<label class='col-sm-2 control-label' for='town_query'>Škola</label>
+			<div class='col-sm-8'>
+				<input autofocus="" class="form-control" id="town_query" name="town_query" type="text" value="">
+				<div id='town_query-help' class='help-block'>
+					Zadejte prvních pár znaků jména obce a zmáčkněte Hledat.
+					Pokud se vám nedaří školu najít (například proto, že studujete v zahraničí),
+					informujte prosím <a href='{{ url_for('doc_about') }}'>správce OSMO</a>.
+				</div>
+			</div>
+			<div class='col-sm-2'>
+				<button class='btn btn-primary' type='button' onclick='osmo_school.find_town(false)'>
+					Hledat
+				</button>
+			</div>
+		</div>
+		<div id='town_list-div' class='form-group row' style='display: none'>
+			<div class='col-sm-offset-2 col-sm-10'>
+				<select id='town_list' name='town_list' onchange='osmo_school.town_picked()'>
+				</select>
+			</div>
+		</div>
+		<div id='school-div' name='school' class='form-group row' style='display: none'>
+			<div class='col-sm-offset-2 col-sm-10'>
+				<select id='school' name='school'>
+				</select>
+			</div>
+		</div>
+		{{ wtf.form_field(form.grade, form_type='horizontal', horizontal_columns=('sm', 2, 10)) }}
+		{{ wtf.form_field(form.birth_year, form_type='horizontal', horizontal_columns=('sm', 2, 10)) }}
+		<p>Přihlášením do soutěže udělujete souhlas se <a href='{{ url_for('doc_gdpr') }}'>zpracováním osobních údajů</a>.
+		U nezletilých účastníků musí přihlášku odeslat zákonný zástupce.
+	{% else %}
+		<p>Vaše osobní údaje už známe z ostatních kategorií. Stačí tedy potvrdit přihlášení.
+	{% endif %}
+	<div class='form-group'>
+		<div class='col-sm-12'>
+			{{ wtf.form_field(form.submit, form_type='inline', button_map={'submit': 'primary'}) }}
+			<a class="btn btn-default" href="{{ url_for('user_join') }}">Zpět</a>
+		</div>
+	</div>
+</form>
+
+{% endblock %}
diff --git a/mo/web/user.py b/mo/web/user.py
index 9d5c2df8165c2e152e59c72f43a97f44e15ce242..ee53d404bccf1edcc9f0fd24b9aeb363a922ee66 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -1,4 +1,4 @@
-from flask import render_template, jsonify, g, redirect, url_for, flash
+from flask import render_template, jsonify, g, redirect, url_for, flash, request
 from flask_wtf import FlaskForm
 import flask_wtf.file
 from sqlalchemy import and_
@@ -6,24 +6,28 @@ from sqlalchemy.orm import joinedload
 from typing import List, Tuple
 import werkzeug.exceptions
 import wtforms
+from wtforms.validators import Required
 
 import mo
 import mo.config as config
+import mo.email
 import mo.db as db
 import mo.submit
 import mo.util
 from mo.util import logger
 from mo.util_format import time_and_timedelta
 from mo.web import app
+import mo.web.fields as mo_fields
+import mo.web.org_round
 import mo.web.util
 
 
 @app.route('/user/')
 def user_index():
     pcrs = load_pcrs()
-    if getattr(config, 'AUTO_REGISTER_TEST', False) and not any(round.category == 'T' for pion, contest, round in pcrs):
-        if register_to_test():
-            pcrs = load_pcrs()
+
+    if not pcrs:
+        return redirect(url_for('user_join'))
 
     return render_template(
         'user_index.html',
@@ -39,41 +43,212 @@ def load_pcrs() -> List[Tuple[db.Participation, db.Contest, db.Round]]:
             .filter(db.Participation.user == g.user)
             .filter(db.Round.year == mo.current_year)
             .options(joinedload(db.Contest.place))
-            .order_by(db.Round.year.desc(), db.Round.category, db.Round.seq, db.Round.part)
+            .order_by(db.Round.category, db.Round.seq, db.Round.part)
             .all())
 
 
-def register_to_test() -> bool:
+@app.route('/user/join/')
+def user_join():
+    available_rounds: List[db.Round] = (
+        db.get_session().query(db.Round)
+        .select_from(db.Round)
+        .filter_by(year=mo.current_year)
+        .filter(db.Round.enroll_mode.in_([db.RoundEnrollMode.register, db.RoundEnrollMode.confirm]))
+        .filter_by(state=db.RoundState.running)
+        .order_by(db.Round.category, db.Round.seq)
+        .all())
+    available_rounds = [r for r in available_rounds if not r.is_subround()]
+
+    pcrs = load_pcrs()
+    pcrs_by_round_id = {pcr[1].round_id: pcr for pcr in pcrs}
+
+    return render_template(
+        'user_join_list.html',
+        available_rounds=available_rounds,
+        pcrs_by_round_id=pcrs_by_round_id,
+    )
+
+
+class JoinRoundForm(FlaskForm):
+    # Zadávání školy je JS hack implementovaný v šabloně. Fields definují jen rozhraní.
+    school = mo_fields.School("Škola", validators=[Required()])
+    town_query = wtforms.HiddenField()
+    town_list = wtforms.HiddenField()
+
+    grade = mo_fields.Grade("Třída", validators=[Required()])
+    birth_year = mo_fields.BirthYear("Rok narození", validators=[Required()])
+    submit = wtforms.SubmitField('Přihlásit se')
+
+
+@app.route('/user/join/<int:round_id>/', methods=('GET', 'POST'))
+def user_join_round(round_id):
     sess = db.get_session()
-    round = sess.query(db.Round).filter_by(year=mo.current_year, category='T', seq=1, part=0).one_or_none()
+    round = sess.query(db.Round).get(round_id)
     if not round:
-        app.logger.error(f'Nemohu najít kolo {mo.current_year}-T-1')
-        return False
+        raise werkzeug.exceptions.NotFound()
 
-    if round.level != 0:
-        app.logger.error(f'Kolo {round.round_code_short()} není na celostátní úrovni')
-        return False
+    if (round.year != mo.current_year
+            or round.part > 1
+            or round.enroll_mode not in [db.RoundEnrollMode.register, db.RoundEnrollMode.confirm]
+            or round.state != db.RoundState.running):
+        flash('Do této kategorie se není možné přihlásit.', 'danger')
+        return redirect(url_for('user_register'))
 
-    contest = sess.query(db.Contest).filter_by(round=round).limit(1).one_or_none()
-    if not contest:
-        app.logger.error(f'Kolo {round.round_code_short()} nemá soutěž')
-        return False
+    pion = (sess.query(db.Participation)
+            .select_from(db.Participation)
+            .filter_by(user=g.user)
+            .join(db.Participation.contest)
+            .filter(db.Contest.round == round)
+            .with_for_update()
+            .one_or_none())
+    if pion:
+        flash('Do této kategorie už jste přihlášen.', 'info')
+        return redirect(url_for('user_join'))
+
+    pant = (sess.query(db.Participant)
+            .filter_by(user=g.user, year=round.year)
+            .with_for_update()
+            .one_or_none())
+
+    form = JoinRoundForm()
+    if pant:
+        del form.school
+        del form.grade
+        del form.birth_year
+
+    if form.validate_on_submit():
+        if form.submit.data:
+            if not pant:
+                pant = join_create_pant(form)
+                sess.add(pant)
+            contest = join_create_contest(round, pant)
+            join_create_pion(contest)
+            sess.commit()
+            join_notify(contest)
+
+            msg = 'Přihláška přijata.'
+            if round.enroll_mode == db.RoundEnrollMode.confirm:
+                msg += ' Ještě ji musí potvrdit organizátor soutěže.'
+            flash(msg, 'success')
+            return redirect(url_for('user_index'))
+    elif not pant and request.method == 'GET':
+        # Pokusíme se předvyplnit data z minulých ročníků
+        prev_pant = (sess.query(db.Participant)
+                     .filter_by(user=g.user)
+                     .options(joinedload(db.Participant.school_place, db.Place.parent_place))
+                     .order_by(db.Participant.year.desc())
+                     .limit(1).one_or_none())
+        if prev_pant:
+            form.school.data = f'#{prev_pant.school}'
+            town = prev_pant.school_place.parent_place
+            form.town_query.data = town.name
+            form.town_list.data = str(town.place_id)
+            form.birth_year.data = prev_pant.birth_year
+
+    return render_template(
+        'user_join_round.html',
+        round=round,
+        form=form,
+    )
 
-    pion = db.Participation(user=g.user, contest=contest, place=contest.place, state=db.PartState.registered)
-    sess.add(pion)
-    sess.flush()
+
+def join_create_pant(form: JoinRoundForm) -> db.Participant:
+    assert form.school.place is not None
+    pant = db.Participant(user=g.user,
+                          year=mo.current_year,
+                          school_place=form.school.place,
+                          grade=form.grade.data,
+                          birth_year=form.birth_year.data)
+
+    logger.info(f'Join: Účastník #{g.user.user_id} se přihlásil do {pant.year}. ročníku')
     mo.util.log(
         type=db.LogType.participant,
         what=g.user.user_id,
-        details={'action': 'add-to-contest', 'new': db.row2dict(pion)},
+        details={'action': 'create-participant', 'reason': 'user-join', 'new': db.row2dict(pant)},
     )
-    sess.commit()
 
-    app.logger.info(f'Účastník #{g.user.user_id} automaticky registrován do soutěže #{contest.contest_id}')
-    return True
+    return pant
 
 
-def get_contest(id: int) -> db.Contest:
+def join_create_contest(round: db.Round, pant: db.Participant) -> db.Contest:
+    sess = db.get_session()
+
+    place = pant.school_place
+    if place.level != round.level:
+        parents = db.get_place_parents(pant.school_place)
+        places = [p for p in parents if p.level == round.level]
+        assert len(places) == 1
+        place = places[0]
+    # XXX: Z rekurzivního dotazu nedostaneme plnohodnotný db.Place, ale jenom named tuple, tak musíme pracovat s ID.
+    place_id = place.place_id
+
+    assert round.part <= 1
+    c = (sess.query(db.Contest)
+         .filter_by(round=round, place_id=place_id)
+         .with_for_update()
+         .one_or_none())
+    if not c:
+        c = db.Contest(
+            round=round,
+            place_id=place_id,
+            state=db.RoundState.running,
+        )
+        sess.add(c)
+        sess.flush()
+        c.master = c
+
+        logger.info(f'Join: Automaticky založena soutěž #{c.contest_id} {round.round_code()} pro místo #{place_id}')
+        mo.util.log(
+            type=db.LogType.contest,
+            what=c.contest_id,
+            details={'action': 'created', 'reason': 'user-join'},
+        )
+
+        mo.web.org_round.create_subcontests(round, c)
+
+    return c
+
+
+def join_create_pion(c: db.Contest) -> None:
+    sess = db.get_session()
+
+    if c.round.enroll_mode == db.RoundEnrollMode.register:
+        state = db.PartState.active
+    else:
+        state = db.PartState.registered
+    p = db.Participation(user=g.user, contest=c, place=c.place, state=state)
+    sess.add(p)
+
+    logger.info(f'Join: Účastník #{g.user.user_id} přihlášen do soutěže #{c.contest_id}')
+    mo.util.log(
+        type=db.LogType.participant,
+        what=g.user.user_id,
+        details={'action': 'add-to-contest', 'reason': 'user-join', 'new': db.row2dict(p)},
+    )
+
+
+def join_notify(c: db.Contest) -> None:
+    sess = db.get_session()
+    r = c.round
+    place = c.place
+    while place is not None:
+        uroles = (sess.query(db.UserRole)
+                  .filter(db.UserRole.role.in_((db.RoleType.garant, db.RoleType.garant_kraj, db.RoleType.garant_okres, db.RoleType.garant_skola)))
+                  .filter_by(place_id=place.place_id)
+                  .options(joinedload(db.UserRole.user))
+                  .all())
+        notify = [ur.user for ur in uroles if ur.applies_to(at=place, year=r.year, cat=r.category, seq=r.seq)]
+        if notify:
+            for org in notify:
+                logger.info(f'Join: Notifikuji orga <{org.email}> pro místo {place.get_code()}')
+                mo.email.send_join_notify_email(org, g.user, c)
+            return
+        place = place.parent_place
+
+    logger.warn('Join: Není komu poslat mail')
+
+
+def get_contest_pion(id: int, require_reg: bool = True) -> Tuple[db.Contest, db.Participation]:
     contest = (db.get_session().query(db.Contest)
                .options(joinedload(db.Contest.place),
                         joinedload(db.Contest.round))
@@ -82,13 +257,20 @@ def get_contest(id: int) -> db.Contest:
     if not contest:
         raise werkzeug.exceptions.NotFound()
 
-    # FIXME: Kontrolovat nějak pion.state?
     pion = (db.get_session().query(db.Participation)
             .filter_by(user=g.user, contest_id=contest.master_contest_id)
             .one_or_none())
     if not pion:
         raise werkzeug.exceptions.Forbidden()
 
+    if require_reg and pion.state in [db.PartState.registered, db.PartState.refused]:
+        raise werkzeug.exceptions.Forbidden()
+
+    return contest, pion
+
+
+def get_contest(id: int) -> db.Contest:
+    contest, _ = get_contest_pion(id)
     return contest
 
 
@@ -105,7 +287,7 @@ def get_task(contest: db.Contest, id: int) -> db.Task:
 @app.route('/user/contest/<int:id>/')
 def user_contest(id: int):
     sess = db.get_session()
-    contest = get_contest(id)
+    contest, pion = get_contest_pion(id, require_reg=False)
 
     messages = sess.query(db.Message).filter_by(round_id=contest.round_id).order_by(db.Message.created_at).all()
 
@@ -121,6 +303,7 @@ def user_contest(id: int):
     return render_template(
         'user_contest.html',
         contest=contest,
+        part_state=pion.state,
         task_sols=task_sols,
         messages=messages,
         max_submit_size=config.MAX_CONTENT_LENGTH,
diff --git a/static/js/osmo.js b/static/js/osmo.js
new file mode 100644
index 0000000000000000000000000000000000000000..02605c9ebe58004a6cd636d08056304cad4bf116
--- /dev/null
+++ b/static/js/osmo.js
@@ -0,0 +1,154 @@
+/*
+ *  JavaScriptové funkce pro OSMO
+ */
+
+'use strict';
+
+let osmo_api_root = undefined;
+
+/*** Výběr škol ***/
+
+class OSMOSchool {
+	constructor() {
+		this.prefill_town_query = undefined;
+		this.prefill_town_list = undefined;
+		this.prefill_school = undefined;
+	}
+
+	find_town_error(msg) {
+		document.getElementById('town_query-group').classList.add('has-error');
+		document.getElementById('town_query-help').innerText = msg;
+	}
+
+	async do_find_town(query) {
+		let resp = undefined;
+		try {
+			resp = await fetch(osmo_api_root + 'find-town?q=' + encodeURIComponent(query));
+		} catch (err) {
+			console.log('OSMO: Search failed: ' + err);
+			this.find_town_error('Spojení se serverem selhalo.');
+			return;
+		}
+
+		if (resp.status !== 200) {
+			console.log('OSMO: Search status: ' + resp.status);
+			this.find_town_error('Spojení se serverem selhalo.');
+			return;
+		}
+
+		const ans = await resp.json();
+		if (ans.error !== undefined) {
+			this.find_town_error(ans.error);
+			return;
+		}
+
+		const list = document.getElementById('town_list');
+		const opts = list.options;
+		opts.length = 0;
+		opts.add(new Option('Vyberte obec ze seznamu', ""));
+		for (const t of ans.found) {
+			opts.add(new Option(t[1], t[0]));
+		}
+
+		document.getElementById('town_list-div').style.display = 'block';
+		document.getElementById('town_query-help').innerText = ans.msg
+
+		if (this.prefill_town_list !== undefined) {
+			list.value = this.prefill_town_list;
+			this.prefill_town_list = undefined;
+			this.town_picked();
+		} else if (ans.found.length == 1) {
+			list.selectedIndex = 1;
+			this.town_picked();
+		}
+	}
+
+	find_town(during_init) {
+		const query = document.getElementById('town_query').value;
+
+		document.getElementById('town_list-div').style.display = 'none';
+		document.getElementById('school-div').style.display = 'none';
+		document.getElementById('town_query-help').innerText = 'Hledám...';
+		if (!during_init) {
+			document.getElementById('town_query-group').classList.remove('has-error');
+		}
+
+		this.do_find_town(query);
+	}
+
+	town_keydown(event) {
+		if (event.key === 'Enter') {
+			event.preventDefault();
+			this.find_town(false);
+		}
+	}
+
+	async do_get_schools(town_id) {
+		let resp = undefined;
+		try {
+			resp = await fetch(osmo_api_root + 'get-schools?town=' + encodeURIComponent(town_id));
+		} catch (err) {
+			console.log('OSMO: Search failed: ' + err);
+			return;
+		}
+
+		if (resp.status !== 200) {
+			console.log('OSMO: Search status: ' + resp.status);
+			return;
+		}
+
+		const ans = await resp.json();
+		const list = document.getElementById('school');
+		const opts = list.options;
+		list.replaceChildren();
+		if (ans.zs.length > 0 || ans.ss.length > 0) {
+			opts.add(new Option('Vyberte školu ze seznamu', ""));
+			if (ans.zs.length > 0) {
+				const g = document.createElement('optgroup');
+				g.label = 'Základní školy'
+				for (const s of ans.zs) {
+					g.append(new Option(s.name, '#' + s.id));
+				}
+				opts.add(g);
+			}
+			if (ans.ss.length > 0) {
+				const g = document.createElement('optgroup');
+				g.label = 'Střední školy'
+				for (const s of ans.ss) {
+					g.append(new Option(s.name, '#' + s.id));
+				}
+				opts.add(g);
+			}
+		} else {
+			opts.add(new Option('V této obci nejsou žádné školy.', ""));
+		}
+
+		document.getElementById('school-div').style.display = 'block';
+
+		if (this.prefill_school !== undefined) {
+			list.value = this.prefill_school;
+			this.prefill_school = undefined;
+		}
+	}
+
+	town_picked() {
+		const town_id = document.getElementById('town_list').value;
+		document.getElementById('school-div').style.display = 'none';
+		if (town_id != "") {
+			this.do_get_schools(town_id);
+		}
+	}
+
+	init() {
+		console.log('OSMO: Init schools');
+
+		var tq = document.getElementById('town_query');
+		tq.addEventListener('keydown', (event) => { this.town_keydown(event); });
+
+		if (this.prefill_town_query !== undefined) {
+			tq.value = this.prefill_town_query;
+			this.prefill_town_query = undefined;
+			this.find_town(true);
+		}
+	}
+}