diff --git a/lib/AccountManager/App.pm b/lib/AccountManager/App.pm
index 96bebd25d09ba3918621ecf5f3330c87338be3fb..903c262e2d64a97d03f78ce1179c4934f2c5581a 100644
--- a/lib/AccountManager/App.pm
+++ b/lib/AccountManager/App.pm
@@ -1,834 +1,89 @@
 package AccountManager::App;
 
-use strict;
-use warnings;
+use Mojo::Base qw(Mojolicious);
 
-use CGI::Simple;
-use CGI::Simple::Cookie;
-use DateTime;
 use English qw(-no_match_vars);
-use Log::Any::Adapter;
-use List::MoreUtils qw(any uniq);
-use Template;
+use Mojolicious::Plugin::TemplateToolkit;
+use Mojolicious::Plugin::ClientIP;
 use Template::Constants qw(:chomp);
+use Syntax::Keyword::Try;
 use UNIVERSAL::require;
 
-use AccountManager::Account;
-use AccountManager::Metadata;
-use AccountManager::Entity;
-use AccountManager::Token;
-use AccountManager::Tools;
-use AccountManager::L10N;
-use AccountManager::Template::Plugin::Quote;
+use constant {
+    ACCESSCHECK_VERSION => '1.2.0'
+};
 
-# Format de type URL HTTP ou URN
-my %patterns = (
-    entityid => qr{
-        ^
-        (?:
-            https?://[\w.:/-]+
-        |
-            urn:[\w.:-]+
-        )
-        $
-    }x
-);
-
-my %actions = (
-    home               => 'req_home',
-    status             => 'req_status',
-    select_sp          => 'req_select_sp',
-    select_email       => 'req_select_email',
-    complete_challenge => 'req_complete_challenge',
-    create_accounts    => 'req_create_accounts',
-    download_accounts  => 'req_download_accounts',
-);
-
-my $version = '1.2.0';
-
-sub new {
-    my ($pkg, %args) = @_;
-
-    my $self = {
-        configuration => $args{configuration},
-    };
-    bless $self, $pkg;
-
-    if ($self->{configuration}->{logger}) {
-        Log::Any::Adapter->set(
-            'File',
-            $self->{configuration}->{logger}->{file},
-            log_level => $self->{configuration}->{logger}->{level}
-        );
-    } else {
-        warn "no logger in configuration, logging disabled\n";
-    }
-
-    $self->{logger} = Log::Any->get_logger();
-    $self->{cgi}    = CGI::Simple->new();
-
-
-    my $lang;
-    my $cookies = CGI::Simple::Cookie->fetch();
-    if ($self->{cgi}->param('lang')) {
-        $lang = $self->{cgi}->param('lang');
-        $self->{lh} = AccountManager::L10N->get_handle($lang);
-        $self->{logger}->debugf("setting language from parameter: %s", $lang);
-    } elsif ($cookies->{lang}) {
-        $lang = $cookies->{lang}->value();
-        $self->{lh} = AccountManager::L10N->get_handle($lang);
-        $self->{logger}->debugf("setting language from cookie: %s", $lang);
-    } elsif ($lang = $ENV{HTTP_ACCEPT_LANGUAGE}) {
-        $lang = I18N::LangTags::Detect::detect();
-        if ($lang =~ /^(\w\w)-(\w\w)$/) {
-            $lang = $1;
-        }
-        $self->{lh} = AccountManager::L10N->get_handle($lang);
-        $self->{logger}->debugf("setting language from HTTP_ACCEPT_LANGUAGE header: %s", $lang);
-    } else {
-        $self->{lh} = AccountManager::L10N->get_handle('en');
-        $self->{logger}->debugf("using default language");
-    }
-    $self->{lh}->load_custom_lexicon($args{custom_l10n});
-
-    if (!$self->{configuration}->{mailer}) {
-        $self->{logger}->fatal(
-            "No mailer defined in configuration, aborting"
-        );
-        $self->respond(
-            template => 'errors.tt2.html',
-            data     => {
-                errors  => [ 'internal' ]
-            }
-        );
-    }
-
-    if (!$self->{configuration}->{idp}) {
-        $self->{logger}->fatal(
-            "No IDP defined in configuration, aborting"
-        );
-        $self->respond(
-            template => 'errors.tt2.html',
-            data     => {
-                errors  => [ 'internal' ]
-            }
-        );
-    }
-
-    if (!$self->{configuration}->{database}) {
-        $self->{logger}->fatal(
-            "No database defined in configuration, aborting"
-        );
-        $self->respond(
-            template => 'errors.tt2.html',
-            data     => {
-                errors => [ 'internal' ]
-            }
-        );
-    } else {
-        AccountManager::DB->register_db(
-            driver   => $self->{configuration}->{database}->{type},
-            database => $self->{configuration}->{database}->{name},
-            host     => $self->{configuration}->{database}->{host},
-            username => $self->{configuration}->{database}->{username},
-            password => $self->{configuration}->{database}->{password},
-            options  => $self->{configuration}->{database}->{options} ?
-                [ split(/, */, $self->{configuration}->{database}->{options}) ] : undef,
-        );
-    }
-
-    $self->{db} = AccountManager::DB->new();
-
-    return $self;
-}
-
-sub run {
-    my ($self) = @_;
-
-    if ($self->{logger}->is_debug()) {
-        Data::Dump->require();
-        my %parameters = $self->{cgi}->Vars();
-        $self->{logger}->debugf("input parameters: %s", Data::Dump::dump(\%parameters));
-    }
-
-    # process requested action
-    my $action = $self->{cgi}->param('action') || 'home';
-    if ($actions{$action}) {
-        $self->{logger}->debug("Processing action '$action'");
-        my $method = $actions{$action};
-        $self->$method();
-    } else {
-        $self->abort(
-            logs => "Unknown action '$action'",
-            user => "Unknown action '$action'"
-        );
-    }
-
-    return 1;
-}
-
-## Return HTML content
-sub respond {
-    my ($self, %in) = @_;
-
-    $in{data}->{app} = {
-        url           => $ENV{SCRIPT_NAME},
-        login_url     => $self->{configuration}->{app}->{login_url},
-        name          => $self->{configuration}->{app}->{name},
-        support_email => $self->{configuration}->{app}->{support_email},
-        version       => $version,
-    };
-    $in{data}->{lh} = $self->{lh};
-
-    my $templates_dir   = $self->{configuration}->{setup}->{templates_dir};
-    my $templates_theme = $self->{configuration}->{setup}->{templates_theme} || 'default';
-
-    my $tt2 = Template->new({
-        ENCODING     => 'utf8',
-        PRE_CHOMP    => CHOMP_ONE,
-        INCLUDE_PATH => [
-            sprintf("%s/web/%s", $templates_dir, $templates_theme),
-            sprintf("%s/web", $templates_dir),
-            sprintf("%s/accounts", $templates_dir)
-        ]
-    });
-
-    $self->{logger}->debug("Responding with template '$in{template}'");
-
-    binmode(STDOUT, ":encoding(UTF-8)");
-
-    my $cookie = CGI::Simple::Cookie->new(
-        -name    => 'lang',
-        -value   => $self->{lh}->language_tag(),
-        -expires => undef,
-    );
-
-    print $self->{cgi}->header(
-        -type    => 'text/html',
-        -charset => 'utf8',
-        -cookie  => [ $cookie ]
-    );
-
-    unless ($tt2->process($in{template}, $in{data}, \*STDOUT)) {
-        printf "Content-type: text/plain\n\n Error: %s", $tt2->error();
-        $self->{logger}->errorf("Web parser error : %s", $tt2->error());
-    }
-
-    exit 0;
-}
-
-sub abort {
+sub startup {
     my $self = shift;
-    my %args = @_;
-
-    $self->{logger}->error($args{log}) if $args{log};
-
-    $self->respond(
-        template => 'errors.tt2.html',
-        data => {
-            errors => [ $args{user} ]
-        }
-    );
-}
-
-
-sub req_select_sp {
-    my ($self, %args) = @_;
-
-    $self->check_authentication(action => 'select_sp')
-        if $self->{configuration}->{app}->{login_url};
-
-    my $sps = AccountManager::Entity->get_entities(
-        db => $self->{db},
-        query => [
-            type => 'sp',
-        ],
-        sort_by => 'display_name'
-    );
-
-    my $idp;
-    if ($ENV{HTTP_SHIB_IDENTITY_PROVIDER}) {
-        my $idps = AccountManager::Entity->get_entities(
-            db    => $self->{db},
-            query => [
-                type     => 'idp',
-                entityid => $ENV{HTTP_SHIB_IDENTITY_PROVIDER},
-            ]
-        );
-        $idp = $idps->[0];
-    }
-
-    $self->respond(
-        template => 'select_sp.tt2.html',
-        data     => {
-            action => 'select_sp',
-            sps    => $sps,
-            idp    => $idp,
-        }
-    );
-}
-
-sub req_select_email {
-    my ($self, %args) = @_;
-
-    $self->check_authentication(action => 'select_email')
-        if $self->{configuration}->{app}->{login_url};
-
-    my $entityid = $self->get_parameter(name => 'entityid');
-
-    my $sp = AccountManager::Entity->new(
-        db       => $self->{db},
-        entityid => $entityid
-    );
-    $self->abort(
-        log  => sprintf("No such SP '%s' in database", $entityid),
-        user => "no_such_entity"
-    ) if !$sp->load(speculative => 1);
-
-    # override metadata contacts if needed
-    my $contacts =
-        $self->{configuration}->{$entityid}->{contacts} ||
-        $self->{configuration}->{service}->{contacts};
-    if ($contacts) {
-        if ($contacts =~ /^\+(.+)/) {
-            # complement original contacts
-            $sp->contacts($sp->contacts(), split(/, */, $1));
-        } else {
-            # replace original contacts
-            $sp->contacts(split(/, */, $contacts));
-        }
-    }
 
-    $self->respond(
-        template => 'select_email.tt2.html',
-        data     => {
-            action   => 'select_email',
-            sp       => $sp,
-            entityid => $entityid,
+    $self->plugin('INIConfig', { file => $ENV{MOJO_CONFIG} || 'conf/manager.conf' });
+
+    $self->plugin(
+        'TemplateToolkit',
+        {
+            name     => 'tt2',
+            template => {
+                ABSOLUTE     => 1,
+                ENCODING     => 'utf8',
+                PRE_CHOMP    => CHOMP_ONE,
+                PLUGIN_BASE => 'AccountManager::Template::Plugin',
+            }
         }
     );
-}
-
-sub req_complete_challenge {
-    my ($self, %args) = @_;
-
-    $self->check_authentication(action => 'complete_challenge')
-        if $self->{configuration}->{app}->{login_url};
-
-    my $entityid   = $self->get_parameter(name => 'entityid');
-    my $email      = $self->get_parameter(name => 'email');
-
-    my $sp = AccountManager::Entity->new(
-        db       => $self->{db},
-        entityid => $entityid,
-    );
-    $self->abort(
-        log  => sprintf("No such SP '%s' in database", $entityid),
-        user => "no_such_entity"
-    ) if !$sp->load(speculative => 1);
-
-    # override metadata contacts if needed
-    my $contacts =
-        $self->{configuration}->{$entityid}->{contacts} ||
-        $self->{configuration}->{service}->{contacts};
-    if ($contacts) {
-        if ($contacts =~ /^\+(.+)/) {
-            # complement original contacts
-            $sp->contacts($sp->contacts(), split(/, */, $1));
-        } else {
-            # replace original contacts
-            $sp->contacts(split(/, */, $contacts));
-        }
-    }
-
-    ## Check that email is a known contact for this SP
-    $self->abort(
-        log  => "Requested a token for SP $entityid with unautorized address $email",
-        user => "internal",
-    ) if !$sp->is_contact($email);
 
-    # delete any previous token for the same email/service couple
-    my $old_token = AccountManager::Token->new(
-        db            => $self->{db},
-        email_address => $email,
-        entityid      => $entityid,
-    );
+    $self->plugin('ClientIP');
 
-    if ($old_token->load(speculative => 1)) {
-        $self->abort(
-            log  => "Failed to delete old authentication token",
-            user => "internal"
-        ) if !$old_token->delete();
-    }
+    my $config = $self->config();
 
-    # compute a new token
-    my $validity_period =
-        $self->{configuration}->{service}->{tokens_validity_period};
-    my $token = AccountManager::Token->new(
-        db              => $self->{db},
-        email_address   => $email,
-        entityid        => $entityid,
-        creation_date   => DateTime->now(),
-        expiration_date => DateTime->now()->add(hours => $validity_period),
-        secret          => AccountManager::Tools::generate_secret(20)
+    $self->log(
+        Mojo::Log->new(
+            path  =>  $config->{logger}->{file},
+            level => ($config->{logger}->{level} || 'trace')
+        )
     );
 
-    $self->abort(
-        log  => "Failed to save creation authentication token",
-        user => "internal"
-    ) if !$token->save();
+    my $theme = $config->{setup}->{templates_theme} || 'default';
+    my $base_templates_dir  = $self->home()->child('templates');
 
-    my $theme_templates_dir = sprintf(
-        "%s/mail/%s",
-        $self->{configuration}->{setup}->{templates_dir},
-        $self->{configuration}->{setup}->{templates_theme} || 'default'
-    );
-    my $default_templates_dir = sprintf(
-        "%s/mail",
-        $self->{configuration}->{setup}->{templates_dir},
-    );
-    my $templates_dir = -d $theme_templates_dir ?
-        $theme_templates_dir  :
-        $default_templates_dir;
+    my $renderer = $self->renderer();
+    $renderer->default_handler('tt2');
+    $renderer->paths([
+        $base_templates_dir->child('web', $theme),
+        $base_templates_dir->child('web'),
+        $base_templates_dir->child('accounts'),
+    ]);
 
-    # build content
-    my $tt2 = Template->new({
-        ENCODING     => 'utf8',
-        PRE_CHOMP    => CHOMP_ONE,
-        INCLUDE_PATH => $templates_dir
-    });
-    my $source_ip = get_source_address();
-    my $user =
-        $ENV{'HTTP_DISPLAYNAME'} ? $ENV{'HTTP_DISPLAYNAME'} :
-        $ENV{'displayName'}      ? $ENV{'displayName'}      :
-        undef;
-    my $idp =
-        $ENV{'HTTP_SHIB_IDENTITY_PROVIDER'} ? $ENV{'HTTP_SHIB_IDENTITY_PROVIDER'} :
-        $ENV{'Shib-Identity-Provider'}      ? $ENV{'Shib-Identity-Provider'}      :
-        undef;
-    my $data = {
+    $self->defaults(
         app => {
-            url           => $self->{configuration}->{app}->{url},
-            support_email => $self->{configuration}->{app}->{support_email},
-            version       => $self->{configuration}->{app}->{version},
-            name          => $self->{configuration}->{app}->{name},
-        },
-        user      => $user,
-        source_ip => $source_ip,
-        idp       => {
-            entityid => $idp,
+            support_url   => $config->{app}->{support_url},
+            support_email => $config->{app}->{support_email},
+            login_url     => $config->{app}->{login_url},
+            logout_url    => $config->{app}->{logout_url},
+            name          => $config->{app}->{name},
+            version       => ACCESSCHECK_VERSION
         },
-        sp        => {
-            entityid => $entityid,
-        },
-        to        => $email,
-        token     => $token->secret(),
-        challenge_url => sprintf(
-            '%s?action=complete_challenge&entityid=%s&email=%s',
-            $self->{configuration}->{app}->{url},
-            $entityid,
-            $email,
-        ),
-        lh        => $self->{lh},
-    };
-    my $text_content;
-    my $html_content;
-    $tt2->process('send_authentication_token.tt2.txt',  $data, \$text_content);
-    $tt2->process('send_authentication_token.tt2.html', $data, \$html_content);
-
-    # wrap in message
-    Email::MIME->require();
-    Email::Sender::Simple->require();
-
-    my $message = Email::MIME->create(
-        header_str => [
-            'From'         => sprintf(
-                '%s <%s>',
-                $self->{configuration}->{app}->{name},
-                $self->{configuration}->{mailer}->{from}
-            ),
-            'To'           => $email,
-            'Subject'      => sprintf(
-                '[%s] %s',
-                $self->{configuration}->{app}->{name},
-                $self->{lh}->maketext("Test accounts request")
-            ),
-            'Content-Type' => 'multipart/alternative'
-        ],
-        parts => [
-            Email::MIME->create(
-                attributes => {
-                    content_type => "text/plain",
-                    charset      => 'utf-8',
-                    encoding     => 'quoted-printable'
-                },
-                body_str => $text_content
-            ),
-            Email::MIME->create(
-                attributes => {
-                    content_type => "text/html",
-                    charset      => 'utf-8',
-                    encoding     => 'quoted-printable'
-                },
-                body_str => $html_content
-            ),
-        ]
-    );
-
-    eval {
-        local $ENV{PATH} = '/bin:/sbin:/usr/bin:/usr/sbin';
-        Email::Sender::Simple->send($message);
-    };
-    $self->abort(
-        log  => "Mail notification error: $EVAL_ERROR",
-        user => "mail_notification_failure"
-    ) if $EVAL_ERROR;
-
-    $self->{logger}->infof(
-        "Token send to %s for entityid=%s;token=%s",
-        $email,
-        $entityid,
-        $token->secret(),
-    );
-
-    my @profiles =
-        map { m/([^\/]+).tt2$/ }
-        glob($self->{configuration}->{setup}->{templates_dir} . '/accounts/*.tt2' );
-
-    $self->respond(
-        template  => 'complete_challenge.tt2.html',
-        data     => {
-            action     => 'complete_challenge',
-            entityid   => $entityid,
-            email      => $email,
-            validity   => $self->{configuration}->{service}->{account_validity_period},
-            profiles   => \@profiles
-        }
-    );
-}
-
-sub req_create_accounts {
-    my ($self, %args) = @_;
-
-    $self->check_authentication(action => 'create_accounts')
-        if $self->{configuration}->{app}->{login_url};
-
-    my $entityid = $self->get_parameter(name => 'entityid');
-    my $token    = $self->get_parameter(name => 'token');
-    my $email    = $self->get_parameter(name => 'email');
-    my $validity = $self->get_parameter(name => 'validity');
-    my @profiles = $self->get_multivalued_parameter(name => 'profiles');
-
-    $self->check_token(token => $token, entityid => $entityid);
-
-    my $sp = AccountManager::Entity->new(
-        db       => $self->{db},
-        entityid => $entityid,
-    );
-    $self->abort(
-        log  => sprintf("No such SP '%s' in database", $entityid),
-        user => "no_such_entity"
-    ) if !$sp->load(speculative => 1);
-
-    ## create test accounts
-    my @accounts;
-
-    my $creation_date  = DateTime->now();
-    my $token_expiration_date = DateTime->now()->add(
-        hours => $self->{configuration}->{service}->{tokens_validity_period}
-    );
-    my $account_expiration_date = DateTime->now()->add(
-        days => $validity
-    );
-
-    my $download_token = AccountManager::Token->new(
-        db              => $self->{db},
-        email_address   => $email,
-        entityid        => $entityid,
-        creation_date   => $creation_date,
-        expiration_date => $token_expiration_date,
-        secret          => AccountManager::Tools::generate_secret(20)
-    );
-
-    $self->abort(
-        log  => "Failed to save download authentication token",
-        user => "internal"
-    ) if !$download_token->save();
-
-    my $key = AccountManager::Tools::generate_secret(10);
-
-    foreach my $profile (@profiles) {
-        my $password = AccountManager::Tools::generate_password(10);
-        my $account = AccountManager::Account->new(
-            db              => $self->{db},
-            profile         => $profile,
-            entityid        => $entityid,
-            scope           => $self->{configuration}->{idp}->{scope},
-            password        => $password,
-            password_crypt  => AccountManager::Tools::encrypt($password, $key),
-            password_hash   => AccountManager::Tools::sha256_hash($password),
-            token           => $download_token->secret(),
-            creation_date   => $creation_date,
-            expiration_date => $account_expiration_date,
-        );
-        next unless $account->save();
-        push @accounts, $account;
-    }
-
-    $self->abort(
-        log  => "Failed to create test accounts for SP $entityid",
-        user => "accounts_creation_failure"
-    ) if !@accounts;
-
-    ## Update simpleSAMLphp configuration to enable test accounts
-    my $accounts = AccountManager::Account->get_accounts(db => $self->{db});
-
-    eval {
-        AccountManager::Tools::update_ssp_authsources(
-            $self->{configuration}->{setup}->{templates_dir},
-            $self->{configuration}->{setup}->{accounts_file},
-            $accounts
-        );
-    };
-    $self->abort(
-        log  => "Failed to create simpleSAMLphp configuration file: $EVAL_ERROR",
-        user => "accounts_creation_failure"
-    ) if $EVAL_ERROR;
-
-    $self->{logger}->infof(
-        "Token validated for entityid=%s",
-        $entityid,
-    );
-
-    my $download_url = sprintf(
-        "%s?action=download_accounts&entityid=%s&token=%s&key=%s",
-        $self->{configuration}->{app}->{url},
-        $entityid,
-        $download_token->secret(),
-        $key
-    );
-
-    $self->respond(
-        template => 'create_accounts.tt2.html',
-        data     => {
-            action   => 'create_accounts',
-            accounts => \@accounts,
-            idp      => {
-                name => $self->{configuration}->{idp}->{name},
-            },
-            sp       => {
-                entityid => $entityid,
-                url      => $sp->information_url(),
-            },
-            email        => $email,
-            download_url => $download_url,
-            days         => $validity,
-        }
-    );
-}
-
-sub req_download_accounts {
-    my ($self) = @_;
-
-    $self->check_authentication(action => 'download_accounts')
-        if $self->{configuration}->{app}->{login_url};
-
-    my $entityid = $self->get_parameter(name => 'entityid');
-    my $token    = $self->get_parameter(name => 'token');
-    my $key      = $self->get_parameter(name => 'key');
-
-    $self->check_token(token => $token, entityid => $entityid);
-
-    # load accounts from database
-    my $accounts = AccountManager::Account->get_accounts(
-        db    => $self->{db},
-        query => [
-            token => $token
-        ],
-    );
-
-    foreach my $account (@$accounts) {
-        my $password = AccountManager::Tools::decrypt(
-            $account->password_crypt(), $key
-        );
-        $account->password($password);
-    }
-
-    binmode(STDOUT, ":encoding(UTF-8)");
-
-    print $self->{cgi}->header(
-        -type                => 'text/csv',
-        -content_disposition => 'attachment; filename="accounts.csv"'
     );
 
-    my $templates_dir = $self->{configuration}->{setup}->{templates_dir};
 
-    my $tt2 = Template->new({
-        ENCODING     => 'utf8',
-        PRE_CHOMP    => CHOMP_ONE,
-        INCLUDE_PATH => [
-            sprintf("%s/other", $templates_dir),
-            sprintf("%s/accounts", $templates_dir),
-        ],
-    });
+    my $routes = $self->routes();
 
-    unless ($tt2->process("accounts.csv.tt2", { accounts => $accounts }, \*STDOUT)) {
-        printf "Content-type: text/plain\n\n Error: %s", $tt2->error();
-        $self->{logger}->errorf("Web parser error : %s", $tt2->error());
-    }
-}
+    $routes->get('/')->to(controller => 'home', action => 'run')->name('home');
+    $routes->get('/status')->to(controller => 'status', action => 'run')->name('status');
+    $routes->get('/step1')->to(controller => 'step1', action => 'run')->name('step1');
+    $routes->get('/step2')->to(controller => 'step2', action => 'run')->name('step2');
+    $routes->get('/step3')->to(controller => 'step3', action => 'run')->name('step3');
+    $routes->get('/step4')->to(controller => 'step4', action => 'run')->name('step4');
+    $routes->get('/step5')->to(controller => 'step5', action => 'run')->name('step5');
 
-## Return the homepage of the service
-sub req_home {
-    my ($self) = @_;
+    $self->helper(
+        string_to_list => sub {
+            my $self = shift;
+            my $value = shift;
 
-    $self->respond(
-        template => 'home.tt2.html',
-        data => {
-            action => 'home'
+            return defined $value ? split(/, */, $value) : ();
         }
     );
-}
-
-sub req_status {
-    my ($self) = @_;
-
-    Net::IP->require();
-    my $source_ip_string = get_source_address();
-    my $source_ip = Net::IP->new($source_ip_string);
-    my @allowed_ips_strings = $self->{configuration}->{status}->{allowed} ?
-        split(/, */, $self->{configuration}->{status}->{allowed}) : ();
-    my @allowed_ips = map { Net::IP->new($_) } @allowed_ips_strings;
-
-    if (any { $_->overlaps($source_ip) } @allowed_ips) {
-        Sys::Hostname->require();
-        JSON->require();
-        print $self->{cgi}->header(
-            -type    => 'application/json',
-            -charset => 'utf8',
-        );
-        my $status =  $self->{configuration}->{status}->{disabled} ? 'disabled' : 'available';
-        print JSON->new()->encode({
-            status => $status,
-            host   => Sys::Hostname::hostname()
-        });
-    } else {
-        $self->{logger}->errorf("Unauthorized access from %s", $source_ip_string);
-        print $self->{cgi}->header(
-            -status  => '403 unauthorized',
-            -type    => 'text/plain',
-        );
-        print "Unauthorized access";
-    }
-
-    exit 0;
-}
-
-sub get_parameter {
-    my ($self, %args) = @_;
-
-    my $name  = $args{name};
-    my $value = $self->{cgi}->param($name);
-
-    $self->abort(
-        log  => "Missing parameter: $name",
-        user => "missing_$name"
-    ) if !$value;
-
-    if ($patterns{$name}) {
-        $self->abort(
-            log  => "Incorrect parameter format: $name",
-            user => "format_$name"
-        ) if $value !~ $patterns{$name};
-    }
-
-    return $value;
-}
-
-sub get_multivalued_parameter {
-    my ($self, %args) = @_;
-
-    my $name  = $args{name};
-    my @values = $self->{cgi}->param($name);
-
-    $self->abort(
-        log  => "Missing parameter: $name",
-        user => "missing_$name"
-    ) if !@values;
-
-    if ($patterns{$name}) {
-        $self->abort(
-            log  => "Incorrect parameter format: $name",
-            user => "format_$name"
-        ) if any { $_ !~ $patterns{$name} } @values;
-    }
-
-    return @values;
-}
-
-sub get_metadata_file {
-    my ($self, %args) = @_;
-
-    my $federation = $args{federation};
-
-    my $file = $self->{configuration}->{$federation}->{metadata};
-
-    $self->abort(
-        log  => "Incorrect parameter: federation",
-        user => "invalid_federation"
-    ) if !$file;
-
-    return $file;
-}
-
-sub check_token {
-    my ($self, %args) = @_;
-
-    my $secret = $args{token};
-
-    my $token = AccountManager::Token->new(
-        db     => $self->{db},
-        secret => $secret
-    );
-
-    $self->abort(
-        log  => "No such authentication token $secret",
-        user => "wrong_token"
-    ) if !$token->load(speculative => 1);
-
-    $self->abort(
-        log  => "Authentication token $secret cannot be used for SP $args{entityid}",
-        user => "wrong_token_for_sp"
-    ) if $token->entityid() ne $args{entityid};
-
-    ## delete the token
-    unless ($token->delete()) {
-        $self->{logger}->errorf(
-            "Failed to delete authentication token %s",
-            $secret
-        );
-    }
-}
-
-sub check_authentication {
-    my $self = shift;
-    my %args = @_;
-
-    $self->abort(
-        log  => "unauthenticated user for action $args{action}",
-        user => "unauthenticated"
-    ) if !$ENV{HTTP_SHIB_IDENTITY_PROVIDER};
-
-    $self->abort(
-        log  => "no displayName attribute for identity provider $ENV{HTTP_SHIB_IDENTITY_PROVIDER}",
-        user => "no_displayname_attribute"
-    ) if !$ENV{'HTTP_DISPLAYNAME'};
-}
 
-sub get_source_address {
-    return $ENV{HTTP_X_FORWARDED_FOR} ?
-        (split(/, /, $ENV{HTTP_X_FORWARDED_FOR}))[0] :
-        $ENV{REMOTE_ADDR};
 }
 
 1;
diff --git a/lib/AccountManager/App/Controller.pm b/lib/AccountManager/App/Controller.pm
new file mode 100644
index 0000000000000000000000000000000000000000..6b40e974b690044b27f4cf147ca25ed2e943818f
--- /dev/null
+++ b/lib/AccountManager/App/Controller.pm
@@ -0,0 +1,143 @@
+package AccountManager::App::Controller;
+
+use Mojo::Base qw(Mojolicious::Controller);
+
+use English qw(-no_match_vars);
+use Syntax::Keyword::Try;
+
+use AccountManager::DB;
+use AccountManager::L10N;
+use AccountManager::Token;
+
+sub init_l10n {
+    my $self = shift;
+
+    my $log = $self->app()->log();
+
+    # lang identification first, as needed for any further error message
+    my ($l10n, $lang);
+    if ($self->param('lang')) {
+        $lang = $self->param('lang');
+        $l10n = AccountManager::L10N->get_handle($lang);
+        $log->debug(sprintf("setting language from parameter: %s", $lang));
+    } elsif ($self->session('lang')) {
+        $lang = $self->session('lang');
+        $l10n = AccountManager::L10N->get_handle($lang);
+        $log->debug(sprintf("setting language from session: %s", $lang));
+    } elsif ($self->req()->headers->header('Accept-Language')) {
+        $l10n = AccountManager::L10N->get_handle();
+        $lang = $l10n->language_tag();
+        $log->debug(sprintf("setting language from Accept-Language header: %s", $lang));
+    } else {
+        $lang = 'en';
+        $l10n = AccountManager::L10N->get_handle($lang);
+    }
+
+    $self->session(lang => $lang);
+    $self->stash(lang => $lang);
+    $self->stash(l10n => $l10n);
+
+    return $l10n;
+}
+
+sub init_db {
+    my $self = shift;
+
+    my $config = $self->app()->config();
+
+    AccountManager::DB->register_db(
+        driver   => $config->{database}->{type},
+        database => $config->{database}->{name},
+        host     => $config->{database}->{host},
+        password => $config->{database}->{password},
+        username => $config->{database}->{username},
+        options  => [ $self->string_to_list($config->{database}->{options}) ]
+    );
+
+    my $db;
+    try {
+        $db = AccountManager::DB->new();
+    } catch {
+    }
+
+    $self->stash(db => $db);
+
+    return $db;
+}
+
+sub check_authentication {
+    my $self = shift;
+
+    my $idp = 
+        $ENV{'Shib_Identity_Provider'} ||                          # local SP
+        $self->req()->headers()->header('Shib-Identity-Provider'); # remote SP
+
+    return $self->abort(
+        status       => 401,
+        log_message  => sprintf("unauthenticated user for action %s",  $self->current_route()),
+        user_message => Registry::Error::AuthenticationRequired->new()
+    ) if !$idp;
+
+    return 1;
+
+}
+
+sub check_token {
+    my ($self, %args) = @_;
+
+    my $secret = $args{token};
+
+    my $token = AccountManager::Token->new(
+        db     => $self->{db},
+        secret => $secret
+    );
+
+    return $self->abort(
+        status       => 400,
+        log_message  => "No such authentication token $secret",
+        user_message => "wrong_token"
+    ) if !$token->load(speculative => 1);
+
+    return $self->abort(
+        status       => 400,
+        log_message  => "Authentication token $secret cannot be used for SP $args{entityid}",
+        user_message => "wrong_token_for_sp"
+    ) if $token->entityid() ne $args{entityid};
+
+    ## delete the token
+    try {
+        $token->delete();
+    } catch {
+        $self->app()->log()->error(
+            sprintf("Failed to delete authentication token %s", $secret)
+        );
+    }
+
+    return 1;
+}
+
+sub abort {
+    my $self = shift;
+    my %args = @_;
+
+    my $status = $args{status} || 200;
+    my $format = $args{format} || 'html';
+
+    my $db = $self->stash('db');
+    $db->rollback() if $db && $db->in_transaction();
+
+    $self->app()->log()->error($args{log_message}) if $args{log_message};
+
+    $self->stash(error => $args{user_message});
+    $self->render(status => $status, template => 'errors', format => 'html');
+
+    return;
+}
+
+sub loc {
+    my $self = shift;
+
+    return $self->stash('l10n')->maketext(@_);
+}
+
+1;
diff --git a/lib/AccountManager/App/Home.pm b/lib/AccountManager/App/Home.pm
new file mode 100644
index 0000000000000000000000000000000000000000..40f479ce7cb4c6a21fc78b947b9c92cc5ae893e0
--- /dev/null
+++ b/lib/AccountManager/App/Home.pm
@@ -0,0 +1,24 @@
+package AccountManager::App::Home;
+
+=head1 NAME
+
+AccountManager::App::Home - Home page controller
+
+=head1 DESCRIPTION
+
+=cut
+
+use Mojo::Base qw(AccountManager::App::Controller);
+
+use English qw(-no_match_vars);
+use Syntax::Keyword::Try;
+
+sub run {
+    my $self = shift;
+
+    $self->init_l10n();
+
+    $self->render(status => 200, template => 'home', format => 'html');
+}
+
+1;
diff --git a/lib/AccountManager/App/Status.pm b/lib/AccountManager/App/Status.pm
new file mode 100644
index 0000000000000000000000000000000000000000..38d755c1c4c9d998faf482a407c24d56693e7316
--- /dev/null
+++ b/lib/AccountManager/App/Status.pm
@@ -0,0 +1,67 @@
+package AccountManager::App::Status;
+
+=head1 NAME
+
+AccountManager::App::Status - Health monitoring controller
+
+=head1 DESCRIPTION
+
+Health monitoring page
+
+Access: restricted by IP address
+
+=cut
+
+use Mojo::Base qw(AccountManager::App::Controller);
+
+use English qw(-no_match_vars);
+use List::MoreUtils qw(none);
+use Net::IP;
+use Sys::Hostname;
+use Syntax::Keyword::Try;
+
+=head1 INSTANCE METHODS
+
+=head2 run()
+
+Return the health status of the frontend.
+
+=cut
+
+sub run {
+    my $self  = shift;
+
+    my $config  = $self->app()->config();
+
+    if (!$config->{status}) {
+        $self->render(
+            status => 403,
+            test   => "unauthorized access"
+        );
+        return;
+    }
+
+    my $client_ip = Net::IP->new($self->client_ip());
+    my @allowed_ips =
+        map { Net::IP->new($_) }
+        $self->string_to_list($config->{status}->{allowed});
+
+    if (none { $_->overlaps($client_ip) } @allowed_ips) {
+        $self->render(
+            status => 403,
+            test   => "unauthorized access"
+        );
+        return;
+    }
+
+    my $status = $config->{status}->{disabled} ? 'disabled' : 'available';
+    
+    my $health = {
+        status => $status,
+        host   => hostname(),
+    };
+
+    $self->render(status => 200, json => $health);
+}
+
+1;
diff --git a/lib/AccountManager/App/Step1.pm b/lib/AccountManager/App/Step1.pm
new file mode 100644
index 0000000000000000000000000000000000000000..7345756b0ab465a7ec01cb97c3b361fc4abe0723
--- /dev/null
+++ b/lib/AccountManager/App/Step1.pm
@@ -0,0 +1,55 @@
+package AccountManager::App::Step1;
+
+use Mojo::Base qw(AccountManager::App::Controller);
+
+use English qw(-no_match_vars);
+use Syntax::Keyword::Try;
+
+use AccountManager::Entity;
+
+sub run {
+    my $self = shift;
+
+    my $config = $self->app()->config();
+    my $log    = $self->app()->log();
+
+    $self->init_db();
+    $self->init_l10n();
+
+    if ($config->{app}->{login_url}) {
+        return if !$self->check_authentication();
+    }
+
+    my $db = $self->stash('db');
+
+    my $sps = AccountManager::Entity->get_entities(
+        db    => $db,
+        query => [
+            type => 'sp',
+        ],
+        sort_by => 'display_name'
+    );
+
+    my $idp;
+    if ($ENV{HTTP_SHIB_IDENTITY_PROVIDER}) {
+        my $idps = AccountManager::Entity->get_entities(
+            db    => $db,
+            query => [
+                type     => 'idp',
+                entityid => $ENV{HTTP_SHIB_IDENTITY_PROVIDER},
+            ]
+        );
+        $idp = $idps->[0];
+    }
+
+    $self->stash(sps => $sps);
+    $self->stash(idp => $idp);
+
+    $self->render(
+        status   => 200,
+        template => 'select_sp',
+        format   => 'html'
+    );
+}
+
+1;
diff --git a/lib/AccountManager/App/Step2.pm b/lib/AccountManager/App/Step2.pm
new file mode 100644
index 0000000000000000000000000000000000000000..78d7601651058de78cf8a72d2ba6e724c399c1ad
--- /dev/null
+++ b/lib/AccountManager/App/Step2.pm
@@ -0,0 +1,59 @@
+package AccountManager::App::Step2;
+
+use Mojo::Base qw(AccountManager::App::Controller);
+
+use English qw(-no_match_vars);
+use Syntax::Keyword::Try;
+
+use AccountManager::Entity;
+
+sub run {
+    my $self = shift;
+
+    my $config = $self->app()->config();
+    my $log    = $self->app()->log();
+
+    $self->init_db();
+    $self->init_l10n();
+
+    if ($config->{app}->{login_url}) {
+        return if !$self->check_authentication();
+    }
+
+    my $entityid = $self->param('entityid');
+    my $db       = $self->stash('db');
+
+    my $sp = AccountManager::Entity->new(
+        db       => $db,
+        entityid => $entityid
+    );
+    return $self->abort(
+        log_message  => sprintf("No such SP '%s' in database", $entityid),
+        user_message => "no_such_entity"
+    ) if !$sp->load(speculative => 1);
+
+    # override metadata contacts if needed
+    my $contacts =
+        $config->{$entityid}->{contacts} ||
+        $config->{service}->{contacts};
+    if ($contacts) {
+        if ($contacts =~ /^\+(.+)/) {
+            # complement original contacts
+            $sp->contacts($sp->contacts(), split(/, */, $1));
+        } else {
+            # replace original contacts
+            $sp->contacts(split(/, */, $contacts));
+        }
+    }
+
+    $self->stash(sp => $sp);
+    $self->stash(entityid => $entityid);
+
+    $self->render(
+        status   => 200,
+        template => 'select_email',
+        format   => 'html'
+    );
+}
+
+1;
diff --git a/lib/AccountManager/App/Step3.pm b/lib/AccountManager/App/Step3.pm
new file mode 100644
index 0000000000000000000000000000000000000000..cbf2af737be91a214e8084ce1af51110731250b8
--- /dev/null
+++ b/lib/AccountManager/App/Step3.pm
@@ -0,0 +1,206 @@
+package AccountManager::App::Step3;
+
+use Mojo::Base qw(AccountManager::App::Controller);
+
+use DateTime;
+use Email::MIME;
+use Email::Sender::Simple;
+use English qw(-no_match_vars);
+use Syntax::Keyword::Try;
+use Template::Constants qw(:chomp);
+
+use AccountManager::Entity;
+use AccountManager::Token;
+use AccountManager::Tools;
+
+sub run {
+    my $self = shift;
+
+    my $config = $self->app()->config();
+    my $log    = $self->app()->log();
+
+    $self->init_db();
+    $self->init_l10n();
+
+    if ($config->{app}->{login_url}) {
+        return if !$self->check_authentication();
+    }
+
+    my $entityid = $self->param('entityid');
+    my $email    = $self->param('email');
+    my $db       = $self->stash('db');
+    my $l10n     = $self->stash('l10n');
+
+    my $sp = AccountManager::Entity->new(
+        db       => $db,
+        entityid => $entityid
+    );
+    return $self->abort(
+        log_message  => sprintf("No such SP '%s' in database", $entityid),
+        user_message => "no_such_entity"
+    ) if !$sp->load(speculative => 1);
+
+    # override metadata contacts if needed
+    my $contacts =
+        $config->{$entityid}->{contacts} ||
+        $config->{service}->{contacts};
+    if ($contacts) {
+        if ($contacts =~ /^\+(.+)/) {
+            # complement original contacts
+            $sp->contacts($sp->contacts(), split(/, */, $1));
+        } else {
+            # replace original contacts
+            $sp->contacts(split(/, */, $contacts));
+        }
+    }
+
+    ## Check that email is a known contact for this SP
+    return $self->abort(
+        log_message  => "Requested a token for SP $entityid with unautorized address $email",
+        user_message => "internal",
+    ) if !$sp->is_contact($email);
+
+    # delete any previous token for the same email/service couple
+    my $old_token = AccountManager::Token->new(
+        db            => $db,
+        email_address => $email,
+        entityid      => $entityid,
+    );
+
+    if ($old_token->load(speculative => 1)) {
+        try {
+            $old_token->delete();
+        } catch  {
+            return $self->abort(
+                log_message  => "Failed to delete old authentication token",
+                user_message => "internal"
+            );
+        }
+    }
+
+    # compute a new token
+    my $validity_period =
+        $config->{service}->{tokens_validity_period};
+    my $token = AccountManager::Token->new(
+        db              => $db,
+        email_address   => $email,
+        entityid        => $entityid,
+        creation_date   => DateTime->now(),
+        expiration_date => DateTime->now()->add(hours => $validity_period),
+        secret          => AccountManager::Tools::generate_secret(20)
+    );
+
+    try {
+        $token->save();
+    } catch {
+        return $self->abort(
+            log_message  => "Failed to save creation authentication token",
+            user_message => "internal"
+        );
+    }
+
+    # build content
+    my $theme              = $config->{setup}->{templates_theme} || 'default';
+    my $base_templates_dir = $self->app()->home()->child('templates');
+    my $tt2 = Template->new({
+        ENCODING     => 'utf8',
+        PRE_CHOMP    => CHOMP_ONE,
+        INCLUDE_PATH => [
+            $base_templates_dir->child('mail', $theme),
+            $base_templates_dir->child('mail'),
+        ]
+    });
+    my $user =
+        $ENV{'HTTP_DISPLAYNAME'} ? $ENV{'HTTP_DISPLAYNAME'} :
+        $ENV{'displayName'}      ? $ENV{'displayName'}      :
+        undef;
+    my $idp =
+        $ENV{'HTTP_SHIB_IDENTITY_PROVIDER'} ? $ENV{'HTTP_SHIB_IDENTITY_PROVIDER'} :
+        $ENV{'Shib-Identity-Provider'}      ? $ENV{'Shib-Identity-Provider'}      :
+        undef;
+    my $data = {
+        app => {
+            url           => $config->{app}->{url},
+            support_email => $config->{app}->{support_email},
+            version       => $config->{app}->{version},
+            name          => $config->{app}->{name},
+        },
+        user          => $user,
+        source_ip     => $self->client_ip(),
+        idp           => { entityid => $idp, },
+        sp            => { entityid => $entityid, },
+        to            => $email,
+        token         => $token->secret(),
+        challenge_url => $self->url_for('step3')->query(entityid => $entityid, email => $email)->to_abs(),
+        lh            => $l10n
+    };
+    my $text_content;
+    my $html_content;
+    $tt2->process('send_authentication_token.tt2.txt',  $data, \$text_content);
+    $tt2->process('send_authentication_token.tt2.html', $data, \$html_content);
+
+    my $message = Email::MIME->create(
+        header_str => [
+            'From'         => sprintf('%s <%s>', $config->{app}->{name}, $config->{mailer}->{from}),
+            'To'           => $email,
+            'Subject'      => sprintf('[%s] %s', $config->{app}->{name}, $l10n->maketext("Test accounts request")),
+            'Content-Type' => 'multipart/alternative'
+        ],
+        parts => [
+            Email::MIME->create(
+                attributes => {
+                    content_type => "text/plain",
+                    charset      => 'utf-8',
+                    encoding     => 'quoted-printable'
+                },
+                body_str => $text_content
+            ),
+            Email::MIME->create(
+                attributes => {
+                    content_type => "text/html",
+                    charset      => 'utf-8',
+                    encoding     => 'quoted-printable'
+                },
+                body_str => $html_content
+            ),
+        ]
+    );
+
+    try {
+        local $ENV{PATH} = '/bin:/sbin:/usr/bin:/usr/sbin';
+        Email::Sender::Simple->send($message);
+    } catch($error) {
+        return $self->abort(
+            log_message  => "Mail notification error: $error",
+            user_message => "mail_notification_failure"
+        );
+    }
+
+    $log->info(
+        sprintf(
+            "Token send to %s for entityid=%s;token=%s",
+            $email,
+            $entityid,
+            $token->secret(),
+        )
+    );
+
+    my $profiles = $base_templates_dir
+        ->child('accounts')
+        ->list()
+        ->map(sub { m/([^\/]+).tt2$/})
+        ->to_array();
+
+    $self->stash(entityid => $entityid);
+    $self->stash(email    => $email);
+    $self->stash(validity => $config->{service}->{account_validity_period});
+    $self->stash(profiles => $profiles);
+
+    $self->render(
+        status   => 200,
+        template => 'complete_challenge',
+        format   => 'html'
+    );
+}
+
+1;
diff --git a/lib/AccountManager/App/Step4.pm b/lib/AccountManager/App/Step4.pm
new file mode 100644
index 0000000000000000000000000000000000000000..bb689683b48833ab7f1896f2e8d230807491cf94
--- /dev/null
+++ b/lib/AccountManager/App/Step4.pm
@@ -0,0 +1,141 @@
+package AccountManager::App::Step4;
+
+use Mojo::Base qw(AccountManager::App::Controller);
+
+use DateTime;
+use Email::MIME;
+use Email::Sender::Simple;
+use English qw(-no_match_vars);
+use Syntax::Keyword::Try;
+use Template::Constants qw(:chomp);
+
+use AccountManager::Account;
+use AccountManager::Entity;
+use AccountManager::Token;
+use AccountManager::Tools;
+
+sub run {
+    my $self = shift;
+
+    my $config = $self->app()->config();
+    my $log    = $self->app()->log();
+
+    $self->init_db();
+    $self->init_l10n();
+
+    if ($config->{app}->{login_url}) {
+        return if !$self->check_authentication();
+    }
+
+    my $entityid = $self->param('entityid');
+    my $email    = $self->param('email');
+    my $token    = $self->param('token');
+    my $validity = $self->param('validity');
+    my $profiles = $self->every_param('profiles');
+    my $db       = $self->stash('db');
+    my $l10n     = $self->stash('l10n');
+
+    return if !$self->check_token(token => $token, entityid => $entityid);
+
+    my $sp = AccountManager::Entity->new(
+        db       => $db,
+        entityid => $entityid,
+    );
+    return $self->abort(
+        log_message  => sprintf("No such SP '%s' in database", $entityid),
+        user_message => "no_such_entity"
+    ) if !$sp->load(speculative => 1);
+
+    ## create test accounts
+    my @accounts;
+
+    my $creation_date  = DateTime->now();
+    my $token_expiration_date = DateTime->now()->add(
+        hours => $config->{service}->{tokens_validity_period}
+    );
+    my $account_expiration_date = DateTime->now()->add(
+        days => $validity
+    );
+
+    my $download_token = AccountManager::Token->new(
+        db              => $db,
+        email_address   => $email,
+        entityid        => $entityid,
+        creation_date   => $creation_date,
+        expiration_date => $token_expiration_date,
+        secret          => AccountManager::Tools::generate_secret(20)
+    );
+
+    try {
+        $download_token->save();
+    } catch {
+        return $self->abort(
+            log_message  => "Failed to save download authentication token",
+            user_message => "internal"
+        );
+    }
+
+    my $key = AccountManager::Tools::generate_secret(10);
+
+    foreach my $profile (@$profiles) {
+        my $password = AccountManager::Tools::generate_password(10);
+        my $account = AccountManager::Account->new(
+            db              => $db,
+            profile         => $profile,
+            entityid        => $entityid,
+            scope           => $config->{idp}->{scope},
+            password        => $password,
+            password_crypt  => AccountManager::Tools::encrypt($password, $key),
+            password_hash   => AccountManager::Tools::sha256_hash($password),
+            token           => $download_token->secret(),
+            creation_date   => $creation_date,
+            expiration_date => $account_expiration_date,
+        );
+        next unless $account->save();
+        push @accounts, $account;
+    }
+
+    return $self->abort(
+        log_message  => "Failed to create test accounts for SP $entityid",
+        user_message => "accounts_creation_failure"
+    ) if !@accounts;
+
+    ## Update simpleSAMLphp configuration to enable test accounts
+    my $accounts = AccountManager::Account->get_accounts(db => $db);
+
+    try {
+        AccountManager::Tools::update_ssp_authsources(
+            $self->app()->home()->child('templates'),
+            $config->{setup}->{accounts_file},
+            $accounts
+        );
+    } catch($error) {
+        return $self->abort(
+            log_message  => "Failed to create simpleSAMLphp configuration file: $error",
+            user_message => "accounts_creation_failure"
+        );
+    }
+
+    $log->info(sprintf("Token validated for entityid=%s", $entityid));
+
+    $self->stash(accounts => \@accounts);
+    $self->stash(idp      => { name => $config->{idp}->{name} });
+    $self->stash(sp       => { entityid => $entityid, url => $sp->information_url() });
+    $self->stash(email    => $email);
+    $self->stash(days     => $validity);
+    $self->stash(
+        download_url => $self->url_for('step5')->query(
+            entityid => $entityid,
+            token    => $download_token->secret(),
+            key      => $key
+        )
+    );
+
+    $self->render(
+        status   => 200,
+        template => 'create_accounts',
+        format   => 'html'
+    );
+}
+
+1;
diff --git a/lib/AccountManager/App/Step5.pm b/lib/AccountManager/App/Step5.pm
new file mode 100644
index 0000000000000000000000000000000000000000..120d7ad16b1c1e55cf9a65c976a8d7262042d8ff
--- /dev/null
+++ b/lib/AccountManager/App/Step5.pm
@@ -0,0 +1,66 @@
+package AccountManager::App::Step5;
+
+use Mojo::Base qw(AccountManager::App::Controller);
+
+use DateTime;
+use Email::MIME;
+use Email::Sender::Simple;
+use English qw(-no_match_vars);
+use Syntax::Keyword::Try;
+use Template::Constants qw(:chomp);
+
+use AccountManager::Account;
+use AccountManager::Tools;
+
+sub run {
+    my $self = shift;
+
+    my $app    = $self->app();
+    my $config = $app->config();
+    my $log    = $app->log();
+
+    $self->init_db();
+    $self->init_l10n();
+
+    if ($config->{app}->{login_url}) {
+        return if !$self->check_authentication();
+    }
+
+    my $entityid = $self->param('entityid');
+    my $token    = $self->param('token');
+    my $key      = $self->param('key');
+    my $db       = $self->stash('db');
+
+    return if !$self->check_token(token => $token, entityid => $entityid);
+
+    # load accounts from database
+    my $accounts = AccountManager::Account->get_accounts(
+        db    => $db,
+        query => [
+            token => $token
+        ],
+    );
+
+    foreach my $account (@$accounts) {
+        my $password = AccountManager::Tools::decrypt(
+            $account->password_crypt(), $key
+        );
+        $account->password($password);
+    }
+
+    $app->types()->type(csv => 'text/csv');
+    $app->renderer()->paths([
+        $app->home()->child('templates', 'other'),
+        $app->home()->child('templates', 'accounts'),
+    ]);
+
+    $self->stash(accounts => $accounts);
+
+    $self->render(
+        status   => 200,
+        template => 'accounts',
+        format   => 'csv'
+    );
+}
+
+1;
diff --git a/lib/Makefile.am b/lib/Makefile.am
index 64a8c99d2e04abf5df3ec2866b0476df5ffc0a1f..b41a76165675c857743cb99f6332d4e38b1a0449 100644
--- a/lib/Makefile.am
+++ b/lib/Makefile.am
@@ -1,6 +1,6 @@
-modulesdir = $(datadir)/access-check/lib
+applibdir = $(pkgdatadir)/lib
 
-nobase_modules_DATA = \
+nobase_applib_DATA = \
 	AccountManager/Token.pm \
 	AccountManager/DB.pm \
 	AccountManager/DB/Object.pm \
@@ -12,6 +12,14 @@ nobase_modules_DATA = \
 	AccountManager/L10N/en.pm \
 	AccountManager/L10N/fr.pm \
 	AccountManager/App.pm \
+	AccountManager/App/Home.pm \
+	AccountManager/App/Controller.pm \
+	AccountManager/App/Status.pm \
+	AccountManager/App/Step1.pm \
+	AccountManager/App/Step2.pm \
+	AccountManager/App/Step3.pm \
+	AccountManager/App/Step4.pm \
+	AccountManager/App/Step5.pm \
 	AccountManager/Template/Plugin/Quote.pm
 
-EXTRA_DIST = $(nobase_modules_DATA)
+EXTRA_DIST = $(nobase_applib_DATA)