From c63a077618c61b4cde7f25ffc650a4b0e1495f44 Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Fri, 10 Jul 2020 12:20:25 +0000
Subject: [PATCH] feat(users): add myth-auth to handle users crud + add admin
 gateway only accessible by login

- overwrite myth/auth config with castopod app needs
- create custom views for users authentication
- add admin area bootstrapped by admin controller
- shift podcast and episodes crud to admin area
- reorganize view layouts
- update docs for database migration
- add myth-auth to DEPENDENCIES.md

closes #11
---
 DEPENDENCIES.md                               |   1 +
 app/Config/App.php                            |  16 ++
 app/Config/Auth.php                           |  42 ++++
 app/Config/Filters.php                        |  12 ++
 app/Config/Routes.php                         | 150 +++++++++++++--
 app/Config/Toolbar.php                        |   1 +
 app/Config/Validation.php                     |   1 +
 app/Controllers/Admin/BaseController.php      |  48 +++++
 app/Controllers/Admin/Episode.php             | 168 ++++++++++++++++
 app/Controllers/Admin/Home.php                |  16 ++
 app/Controllers/Admin/Myaccount.php           |  73 +++++++
 app/Controllers/Admin/Podcast.php             | 181 ++++++++++++++++++
 app/Controllers/Admin/User.php                | 142 ++++++++++++++
 app/Controllers/Analytics.php                 |   4 +-
 app/Controllers/Auth.php                      |  98 ++++++++++
 app/Controllers/BaseController.php            |   4 +-
 app/Controllers/Episode.php                   | 137 +------------
 app/Controllers/Feed.php                      |   5 +
 app/Controllers/Home.php                      |   4 +-
 app/Controllers/Podcast.php                   | 138 +------------
 app/Controllers/UnknownUserAgents.php         |   9 +-
 .../2020-07-03-191500_add_users_podcasts.php  |  48 +++++
 app/Database/Seeds/UserSeeder.php             |  30 +++
 app/Entities/Episode.php                      |   2 +-
 app/Entities/Podcast.php                      |  23 ++-
 app/Entities/UserPodcast.php                  |  18 ++
 app/Helpers/analytics_helper.php              |   1 -
 app/Helpers/media_helper.php                  |   2 +-
 app/Language/en/Episode.php                   |   8 +
 app/Language/en/Home.php                      |   5 +
 app/Language/en/MyAccount.php                 |  11 ++
 app/Language/en/Podcast.php                   |  10 +
 app/Language/en/User.php                      |  28 +++
 app/Models/EpisodeModel.php                   |  29 ++-
 app/Models/PodcastModel.php                   |   6 +-
 app/Models/UserPodcastModel.php               |  23 +++
 .../{layouts/default.php => _layout.php}      |   9 +-
 app/Views/_message_block.php                  |  20 ++
 app/Views/admin/_layout.php                   |  37 ++++
 app/Views/admin/_sidenav.php                  |  54 ++++++
 app/Views/admin/dashboard.php                 |   8 +
 app/Views/{ => admin}/episode/create.php      |   2 +-
 app/Views/{ => admin}/episode/edit.php        |   2 +-
 app/Views/admin/episode/list.php              |  65 +++++++
 .../admin/my_account/change_password.php      |  31 +++
 app/Views/admin/my_account/view.php           |  31 +++
 app/Views/{ => admin}/podcast/create.php      |   2 +-
 app/Views/{ => admin}/podcast/edit.php        |   2 +-
 app/Views/admin/podcast/list.php              |  49 +++++
 app/Views/admin/user/create.php               |  38 ++++
 app/Views/admin/user/list.php                 |  61 ++++++
 app/Views/auth/_layout.php                    |  31 +++
 app/Views/auth/change_password.php            |  29 +++
 app/Views/auth/emails/activation.php          |  11 ++
 app/Views/auth/emails/forgot.php              |  13 ++
 app/Views/auth/forgot.php                     |  25 +++
 app/Views/auth/login.php                      |  44 +++++
 app/Views/auth/register.php                   |  54 ++++++
 app/Views/auth/reset.php                      |  38 ++++
 app/Views/{episode/view.php => episode.php}   |  21 +-
 app/Views/home.php                            |   7 +-
 app/Views/{podcast/view.php => podcast.php}   |  28 +--
 composer.json                                 |   3 +-
 composer.lock                                 | 102 +++++++---
 docs/setup-development.md                     |   2 +-
 65 files changed, 1921 insertions(+), 392 deletions(-)
 create mode 100644 app/Config/Auth.php
 create mode 100644 app/Controllers/Admin/BaseController.php
 create mode 100644 app/Controllers/Admin/Episode.php
 create mode 100644 app/Controllers/Admin/Home.php
 create mode 100644 app/Controllers/Admin/Myaccount.php
 create mode 100644 app/Controllers/Admin/Podcast.php
 create mode 100644 app/Controllers/Admin/User.php
 create mode 100644 app/Controllers/Auth.php
 create mode 100644 app/Database/Migrations/2020-07-03-191500_add_users_podcasts.php
 create mode 100644 app/Database/Seeds/UserSeeder.php
 create mode 100644 app/Entities/UserPodcast.php
 create mode 100644 app/Language/en/MyAccount.php
 create mode 100644 app/Language/en/User.php
 create mode 100644 app/Models/UserPodcastModel.php
 rename app/Views/{layouts/default.php => _layout.php} (77%)
 create mode 100644 app/Views/_message_block.php
 create mode 100644 app/Views/admin/_layout.php
 create mode 100644 app/Views/admin/_sidenav.php
 create mode 100644 app/Views/admin/dashboard.php
 rename app/Views/{ => admin}/episode/create.php (98%)
 rename app/Views/{ => admin}/episode/edit.php (99%)
 create mode 100644 app/Views/admin/episode/list.php
 create mode 100644 app/Views/admin/my_account/change_password.php
 create mode 100644 app/Views/admin/my_account/view.php
 rename app/Views/{ => admin}/podcast/create.php (99%)
 rename app/Views/{ => admin}/podcast/edit.php (99%)
 create mode 100644 app/Views/admin/podcast/list.php
 create mode 100644 app/Views/admin/user/create.php
 create mode 100644 app/Views/admin/user/list.php
 create mode 100644 app/Views/auth/_layout.php
 create mode 100644 app/Views/auth/change_password.php
 create mode 100644 app/Views/auth/emails/activation.php
 create mode 100644 app/Views/auth/emails/forgot.php
 create mode 100644 app/Views/auth/forgot.php
 create mode 100644 app/Views/auth/login.php
 create mode 100644 app/Views/auth/register.php
 create mode 100644 app/Views/auth/reset.php
 rename app/Views/{episode/view.php => episode.php} (51%)
 rename app/Views/{podcast/view.php => podcast.php} (63%)

diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md
index b859ea4540..77fc51eb8d 100644
--- a/DEPENDENCIES.md
+++ b/DEPENDENCIES.md
@@ -13,3 +13,4 @@ Castopod uses the following components:
 - [GeoIP2 PHP API](https://github.com/maxmind/GeoIP2-php) ([Apache License 2.0](https://github.com/maxmind/GeoIP2-php/blob/master/LICENSE))
 - [Quill Rich Text Editor](https://github.com/quilljs/quill) ([BSD 3-Clause "New" or "Revised" License](https://github.com/quilljs/quill/blob/develop/LICENSE))
 - [getID3](https://github.com/JamesHeinrich/getID3) ([GNU General Public License v3](https://github.com/JamesHeinrich/getID3/blob/2.0/licenses/license.gpl-30.txt))
+- [myth-auth](https://github.com/lonnieezell/myth-auth) ([MIT license](https://github.com/lonnieezell/myth-auth/blob/develop/LICENSE.md))
diff --git a/app/Config/App.php b/app/Config/App.php
index a12599ff0e..0f03019e94 100644
--- a/app/Config/App.php
+++ b/app/Config/App.php
@@ -274,4 +274,20 @@ class App extends BaseConfig
 	| Defines the root folder for media files storage
 	*/
     public $mediaRoot = 'media';
+
+    /*
+	|--------------------------------------------------------------------------
+	| Admin gateway
+	|--------------------------------------------------------------------------
+	| Defines a base route for all admin pages
+	*/
+    public $adminGateway = 'admin';
+
+    /*
+	|--------------------------------------------------------------------------
+	| Auth gateway
+	|--------------------------------------------------------------------------
+	| Defines a base route for all authentication related pages
+	*/
+    public $authGateway = 'auth';
 }
diff --git a/app/Config/Auth.php b/app/Config/Auth.php
new file mode 100644
index 0000000000..6c5b78f5a2
--- /dev/null
+++ b/app/Config/Auth.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Config;
+
+class Auth extends \Myth\Auth\Config\Auth
+{
+    //--------------------------------------------------------------------
+    // Views used by Auth Controllers
+    //--------------------------------------------------------------------
+
+    public $views = [
+        'login' => 'auth/login',
+        'register' => 'auth/register',
+        'forgot' => 'auth/forgot',
+        'reset' => 'auth/reset',
+        'emailForgot' => 'auth/emails/forgot',
+        'emailActivation' => 'auth/emails/activation',
+    ];
+
+    //--------------------------------------------------------------------
+    // Layout for the views to extend
+    //--------------------------------------------------------------------
+
+    public $viewLayout = 'auth/_layout';
+
+    //--------------------------------------------------------------------
+    // Allow User Registration
+    //--------------------------------------------------------------------
+    // When enabled (default) any unregistered user may apply for a new
+    // account. If you disable registration you may need to ensure your
+    // controllers and views know not to offer registration.
+    //
+    public $allowRegistration = false;
+
+    //--------------------------------------------------------------------
+    // Require confirmation registration via email
+    //--------------------------------------------------------------------
+    // When enabled, every registered user will receive an email message
+    // with a special link he have to confirm to activate his account.
+    //
+    public $requireActivation = false;
+}
diff --git a/app/Config/Filters.php b/app/Config/Filters.php
index f76ceb2016..fef58ff5f2 100644
--- a/app/Config/Filters.php
+++ b/app/Config/Filters.php
@@ -10,6 +10,9 @@ class Filters extends BaseConfig
         'csrf' => \CodeIgniter\Filters\CSRF::class,
         'toolbar' => \CodeIgniter\Filters\DebugToolbar::class,
         'honeypot' => \CodeIgniter\Filters\Honeypot::class,
+        'login' => \Myth\Auth\Filters\LoginFilter::class,
+        'role' => \Myth\Auth\Filters\RoleFilter::class,
+        'permission' => \Myth\Auth\Filters\PermissionFilter::class,
     ];
 
     // Always applied before every request
@@ -33,4 +36,13 @@ class Filters extends BaseConfig
     // that they should run on, like:
     //    'isLoggedIn' => ['before' => ['account/*', 'profiles/*']],
     public $filters = [];
+
+    public function __construct()
+    {
+        parent::__construct();
+
+        $this->filters = [
+            'login' => ['before' => [config('App')->adminGateway . '*']],
+        ];
+    }
 }
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 5db584e1c8..494a3f62c0 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -24,6 +24,7 @@ $routes->set404Override();
 $routes->setAutoRoute(false);
 $routes->addPlaceholder('podcastName', '[a-zA-Z0-9\_]{1,191}');
 $routes->addPlaceholder('episodeSlug', '[a-zA-Z0-9\-]{1,191}');
+$routes->addPlaceholder('username', '[a-zA-Z0-9 ]{3,}');
 
 /**
  * --------------------------------------------------------------------
@@ -34,28 +35,13 @@ $routes->addPlaceholder('episodeSlug', '[a-zA-Z0-9\-]{1,191}');
 // We get a performance increase by specifying the default
 // route since we don't have to scan directories.
 $routes->get('/', 'Home::index', ['as' => 'home']);
-$routes->add('new-podcast', 'Podcast::create', ['as' => 'podcast_create']);
 
 $routes->group('@(:podcastName)', function ($routes) {
-    $routes->add('/', 'Podcast::view/$1', ['as' => 'podcast_view']);
-    $routes->add('edit', 'Podcast::edit/$1', [
-        'as' => 'podcast_edit',
-    ]);
-    $routes->add('delete', 'Podcast::delete/$1', [
-        'as' => 'podcast_delete',
-    ]);
+    $routes->add('/', 'Podcast/$1', ['as' => 'podcast']);
+
     $routes->add('feed.xml', 'Feed/$1', ['as' => 'podcast_feed']);
-    $routes->add('new-episode', 'Episode::create/$1', [
-        'as' => 'episode_create',
-    ]);
-    $routes->add('episodes/(:episodeSlug)', 'Episode::view/$1/$2', [
-        'as' => 'episode_view',
-    ]);
-    $routes->add('episodes/(:episodeSlug)/edit', 'Episode::edit/$1/$2', [
-        'as' => 'episode_edit',
-    ]);
-    $routes->add('episodes/(:episodeSlug)/delete', 'Episode::delete/$1/$2', [
-        'as' => 'episode_delete',
+    $routes->add('episodes/(:episodeSlug)', 'Episode/$1/$2', [
+        'as' => 'episode',
     ]);
 });
 
@@ -68,6 +54,132 @@ $routes->add('stats/(:num)/(:num)/(:any)', 'Analytics::hit/$1/$2/$3', [
 $routes->add('.well-known/unknown-useragents', 'UnknownUserAgents');
 $routes->add('.well-known/unknown-useragents/(:num)', 'UnknownUserAgents/$1');
 
+// Admin area
+$routes->group(
+    config('App')->adminGateway,
+    ['namespace' => 'App\Controllers\Admin'],
+    function ($routes) {
+        $routes->add('/', 'Home', [
+            'as' => 'admin',
+        ]);
+
+        $routes->add('new-podcast', 'Podcast::create', [
+            'as' => 'podcast_create',
+        ]);
+        $routes->add('podcasts', 'Podcast::list', ['as' => 'podcast_list']);
+
+        $routes->group('podcasts/@(:podcastName)', function ($routes) {
+            $routes->add('edit', 'Podcast::edit/$1', [
+                'as' => 'podcast_edit',
+            ]);
+            $routes->add('delete', 'Podcast::delete/$1', [
+                'as' => 'podcast_delete',
+            ]);
+
+            $routes->add('new-episode', 'Episode::create/$1', [
+                'as' => 'episode_create',
+            ]);
+            $routes->add('episodes', 'Episode::list/$1', [
+                'as' => 'episode_list',
+            ]);
+
+            $routes->add(
+                'episodes/(:episodeSlug)/edit',
+                'Episode::edit/$1/$2',
+                [
+                    'as' => 'episode_edit',
+                ]
+            );
+            $routes->add(
+                'episodes/(:episodeSlug)/delete',
+                'Episode::delete/$1/$2',
+                [
+                    'as' => 'episode_delete',
+                ]
+            );
+        });
+
+        // Users
+        $routes->add('users', 'User::list', ['as' => 'user_list']);
+        $routes->add('new-user', 'User::create', ['as' => 'user_create']);
+
+        $routes->add('users/@(:any)/ban', 'User::ban/$1', [
+            'as' => 'user_ban',
+        ]);
+        $routes->add('users/@(:any)/unban', 'User::unBan/$1', [
+            'as' => 'user_unban',
+        ]);
+        $routes->add(
+            'users/@(:any)/force-pass-reset',
+            'User::forcePassReset/$1',
+            [
+                'as' => 'user_force_pass_reset',
+            ]
+        );
+
+        $routes->add('users/@(:any)/delete', 'User::delete/$1', [
+            'as' => 'user_delete',
+        ]);
+
+        // My account
+        $routes->get('my-account', 'Myaccount', [
+            'as' => 'myAccount',
+        ]);
+        $routes->get(
+            'my-account/change-password',
+            'Myaccount::changePassword/$1',
+            [
+                'as' => 'myAccount_change-password',
+            ]
+        );
+        $routes->post(
+            'my-account/change-password',
+            'Myaccount::attemptChange/$1',
+            [
+                'as' => 'myAccount_change-password',
+            ]
+        );
+    }
+);
+
+/**
+ * Overwriting Myth:auth routes file
+ */
+$routes->group(config('App')->authGateway, function ($routes) {
+    // Login/out
+    $routes->get('login', 'Auth::login', ['as' => 'login']);
+    $routes->post('login', 'Auth::attemptLogin');
+    $routes->get('logout', 'Auth::logout', ['as' => 'logout']);
+
+    // Registration
+    $routes->get('register', 'Auth::register', [
+        'as' => 'register',
+    ]);
+    $routes->post('register', 'Auth::attemptRegister');
+
+    // Activation
+    $routes->get('activate-account', 'Auth::activateAccount', [
+        'as' => 'activate-account',
+    ]);
+    $routes->get('resend-activate-account', 'Auth::resendActivateAccount', [
+        'as' => 'resend-activate-account',
+    ]);
+
+    // Forgot/Resets
+    $routes->get('forgot', 'Auth::forgotPassword', [
+        'as' => 'forgot',
+    ]);
+    $routes->post('forgot', 'Auth::attemptForgot');
+    $routes->get('reset-password', 'Auth::resetPassword', [
+        'as' => 'reset-password',
+    ]);
+    $routes->post('reset-password', 'Auth::attemptReset');
+    $routes->get('change-password', 'Auth::changePassword', [
+        'as' => 'change_pass',
+    ]);
+    $routes->post('change-password', 'Auth::attemptChange');
+});
+
 /**
  * --------------------------------------------------------------------
  * Additional Routing
diff --git a/app/Config/Toolbar.php b/app/Config/Toolbar.php
index 639d431ab2..14d3e7bd3b 100644
--- a/app/Config/Toolbar.php
+++ b/app/Config/Toolbar.php
@@ -25,6 +25,7 @@ class Toolbar extends BaseConfig
         \CodeIgniter\Debug\Toolbar\Collectors\Files::class,
         \CodeIgniter\Debug\Toolbar\Collectors\Routes::class,
         \CodeIgniter\Debug\Toolbar\Collectors\Events::class,
+        \Myth\Auth\Collectors\Auth::class,
     ];
 
     /*
diff --git a/app/Config/Validation.php b/app/Config/Validation.php
index ba4ac7cd4f..d93c623fc2 100644
--- a/app/Config/Validation.php
+++ b/app/Config/Validation.php
@@ -17,6 +17,7 @@ class Validation
         \CodeIgniter\Validation\FormatRules::class,
         \CodeIgniter\Validation\FileRules::class,
         \CodeIgniter\Validation\CreditCardRules::class,
+        \Myth\Auth\Authentication\Passwords\ValidationRules::class,
     ];
 
     /**
diff --git a/app/Controllers/Admin/BaseController.php b/app/Controllers/Admin/BaseController.php
new file mode 100644
index 0000000000..a10692c9e7
--- /dev/null
+++ b/app/Controllers/Admin/BaseController.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Controllers\Admin;
+
+/**
+ * Class BaseController
+ *
+ * BaseController provides a convenient place for loading components
+ * and performing functions that are needed by all your controllers.
+ * Extend this class in any new controllers:
+ *     class Home extends BaseController
+ *
+ * For security be sure to declare any new methods as protected or private.
+ *
+ * @package CodeIgniter
+ */
+
+use CodeIgniter\Controller;
+
+class BaseController extends Controller
+{
+    /**
+     * An array of helpers to be loaded automatically upon
+     * class instantiation. These helpers will be available
+     * to all other controllers that extend BaseController.
+     *
+     * @var array
+     */
+    protected $helpers = ['auth'];
+
+    /**
+     * Constructor.
+     */
+    public function initController(
+        \CodeIgniter\HTTP\RequestInterface $request,
+        \CodeIgniter\HTTP\ResponseInterface $response,
+        \Psr\Log\LoggerInterface $logger
+    ) {
+        // Do Not Edit This Line
+        parent::initController($request, $response, $logger);
+
+        //--------------------------------------------------------------------
+        // Preload any models, libraries, etc, here.
+        //--------------------------------------------------------------------
+        // E.g.:
+        // $this->session = \Config\Services::session();
+    }
+}
diff --git a/app/Controllers/Admin/Episode.php b/app/Controllers/Admin/Episode.php
new file mode 100644
index 0000000000..105aed974e
--- /dev/null
+++ b/app/Controllers/Admin/Episode.php
@@ -0,0 +1,168 @@
+<?php
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Controllers\Admin;
+
+use App\Models\EpisodeModel;
+use App\Models\PodcastModel;
+
+class Episode extends BaseController
+{
+    protected \App\Entities\Podcast $podcast;
+    protected ?\App\Entities\Episode $episode;
+
+    public function _remap($method, ...$params)
+    {
+        $podcast_model = new PodcastModel();
+
+        $this->podcast = $podcast_model->where('name', $params[0])->first();
+
+        if (count($params) > 1) {
+            $episode_model = new EpisodeModel();
+            if (
+                !($episode = $episode_model
+                    ->where([
+                        'podcast_id' => $this->podcast->id,
+                        'slug' => $params[1],
+                    ])
+                    ->first())
+            ) {
+                throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
+            }
+            $this->episode = $episode;
+        }
+
+        return $this->$method();
+    }
+
+    public function list()
+    {
+        $episode_model = new EpisodeModel();
+
+        $data = [
+            'podcast' => $this->podcast,
+            'all_podcast_episodes' => $episode_model
+                ->where('podcast_id', $this->podcast->id)
+                ->find(),
+        ];
+
+        return view('admin/episode/list', $data);
+    }
+
+    public function create()
+    {
+        helper(['form']);
+
+        if (
+            !$this->validate([
+                'enclosure' => 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]',
+                'image' =>
+                    'uploaded[image]|is_image[image]|ext_in[image,jpg,png]|permit_empty',
+                'title' => 'required',
+                'slug' => 'required|regex_match[[a-zA-Z0-9\-]{1,191}]',
+                'description' => 'required',
+                'type' => 'required',
+            ])
+        ) {
+            $data = [
+                'podcast' => $this->podcast,
+            ];
+
+            echo view('admin/episode/create', $data);
+        } else {
+            $new_episode = new \App\Entities\Episode([
+                'podcast_id' => $this->podcast->id,
+                'title' => $this->request->getVar('title'),
+                'slug' => $this->request->getVar('slug'),
+                'enclosure' => $this->request->getFile('enclosure'),
+                'pub_date' => $this->request->getVar('pub_date'),
+                'description' => $this->request->getVar('description'),
+                'image' => $this->request->getFile('image'),
+                'explicit' => $this->request->getVar('explicit') or false,
+                'number' => $this->request->getVar('episode_number'),
+                'season_number' => $this->request->getVar('season_number'),
+                'type' => $this->request->getVar('type'),
+                'author_name' => $this->request->getVar('author_name'),
+                'author_email' => $this->request->getVar('author_email'),
+                'block' => $this->request->getVar('block') or false,
+            ]);
+
+            $episode_model = new EpisodeModel();
+            $episode_model->save($new_episode);
+
+            return redirect()->route('episode_list', [$this->podcast->name]);
+        }
+    }
+
+    public function edit()
+    {
+        helper(['form']);
+
+        if (
+            !$this->validate([
+                'enclosure' =>
+                    'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]|permit_empty',
+                'image' =>
+                    'uploaded[image]|is_image[image]|ext_in[image,jpg,png]|permit_empty',
+                'title' => 'required',
+                'slug' => 'required|regex_match[[a-zA-Z0-9\-]{1,191}]',
+                'description' => 'required',
+                'type' => 'required',
+            ])
+        ) {
+            $data = [
+                'podcast' => $this->podcast,
+                'episode' => $this->episode,
+            ];
+
+            echo view('admin/episode/edit', $data);
+        } else {
+            $this->episode->title = $this->request->getVar('title');
+            $this->episode->slug = $this->request->getVar('slug');
+            $this->episode->pub_date = $this->request->getVar('pub_date');
+            $this->episode->description = $this->request->getVar('description');
+            $this->episode->explicit =
+                ($this->request->getVar('explicit') or false);
+            $this->episode->number = $this->request->getVar('episode_number');
+            $this->episode->season_number = $this->request->getVar(
+                'season_number'
+            )
+                ? $this->request->getVar('season_number')
+                : null;
+            $this->episode->type = $this->request->getVar('type');
+            $this->episode->author_name = $this->request->getVar('author_name');
+            $this->episode->author_email = $this->request->getVar(
+                'author_email'
+            );
+            $this->episode->block = ($this->request->getVar('block') or false);
+
+            $enclosure = $this->request->getFile('enclosure');
+            if ($enclosure->isValid()) {
+                $this->episode->enclosure = $this->request->getFile(
+                    'enclosure'
+                );
+            }
+            $image = $this->request->getFile('image');
+            if ($image) {
+                $this->episode->image = $this->request->getFile('image');
+            }
+
+            $episode_model = new EpisodeModel();
+            $episode_model->save($this->episode);
+
+            return redirect()->route('episode_list', [$this->podcast->name]);
+        }
+    }
+
+    public function delete()
+    {
+        $episode_model = new EpisodeModel();
+        $episode_model->delete($this->episode->id);
+
+        return redirect()->route('episode_list', [$this->podcast->name]);
+    }
+}
diff --git a/app/Controllers/Admin/Home.php b/app/Controllers/Admin/Home.php
new file mode 100644
index 0000000000..6e3b80aa01
--- /dev/null
+++ b/app/Controllers/Admin/Home.php
@@ -0,0 +1,16 @@
+<?php
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Controllers\Admin;
+
+class Home extends BaseController
+{
+    public function index()
+    {
+        return view('admin/dashboard');
+    }
+}
diff --git a/app/Controllers/Admin/Myaccount.php b/app/Controllers/Admin/Myaccount.php
new file mode 100644
index 0000000000..5cd23535dd
--- /dev/null
+++ b/app/Controllers/Admin/Myaccount.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Controllers\Admin;
+
+use Myth\Auth\Config\Services;
+use Myth\Auth\Models\UserModel;
+
+class Myaccount extends BaseController
+{
+    public function index()
+    {
+        return view('admin/my_account/view');
+    }
+
+    public function changePassword()
+    {
+        return view('admin/my_account/change_password');
+    }
+
+    public function attemptChange()
+    {
+        $auth = Services::authentication();
+        $user_model = new UserModel();
+
+        // Validate here first, since some things,
+        // like the password, can only be validated properly here.
+        $rules = [
+            'email' => 'required|valid_email',
+            'password' => 'required',
+            'new_password' => 'required|strong_password',
+            'new_pass_confirm' => 'required|matches[new_password]',
+        ];
+
+        if (!$this->validate($rules)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $user_model->errors());
+        }
+
+        $credentials = [
+            'email' => user()->email,
+            'password' => $this->request->getPost('password'),
+        ];
+
+        if (!$auth->validate($credentials)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $user_model->errors());
+        }
+
+        user()->password = $this->request->getPost('new_password');
+        $user_model->save(user());
+
+        if (!$user_model->save(user())) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $user_model->errors());
+        }
+
+        // Success!
+        return redirect()
+            ->route('myAccount')
+            ->with('message', lang('MyAccount.passwordChangeSuccess'));
+    }
+}
diff --git a/app/Controllers/Admin/Podcast.php b/app/Controllers/Admin/Podcast.php
new file mode 100644
index 0000000000..502f15322a
--- /dev/null
+++ b/app/Controllers/Admin/Podcast.php
@@ -0,0 +1,181 @@
+<?php
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+namespace App\Controllers\Admin;
+
+use App\Entities\UserPodcast;
+use App\Models\CategoryModel;
+use App\Models\LanguageModel;
+use App\Models\PodcastModel;
+
+class Podcast extends BaseController
+{
+    protected ?\App\Entities\Podcast $podcast;
+
+    public function _remap($method, ...$params)
+    {
+        if (count($params) > 0) {
+            $podcast_model = new PodcastModel();
+            if (
+                !($podcast = $podcast_model->where('name', $params[0])->first())
+            ) {
+                throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
+            }
+            $this->podcast = $podcast;
+        }
+
+        return $this->$method();
+    }
+
+    public function list()
+    {
+        $podcast_model = new PodcastModel();
+
+        $data = ['all_podcasts' => $podcast_model->findAll()];
+
+        return view('admin/podcast/list', $data);
+    }
+
+    public function create()
+    {
+        helper(['form', 'misc']);
+        $podcast_model = new PodcastModel();
+
+        if (
+            !$this->validate([
+                'title' => 'required',
+                'name' => 'required|regex_match[[a-zA-Z0-9\_]{1,191}]',
+                'description' => 'required|max_length[4000]',
+                'image' =>
+                    'uploaded[image]|is_image[image]|ext_in[image,jpg,png]',
+                'owner_email' => 'required|valid_email',
+                'type' => 'required',
+            ])
+        ) {
+            $languageModel = new LanguageModel();
+            $categoryModel = new CategoryModel();
+            $data = [
+                'languages' => $languageModel->findAll(),
+                'categories' => $categoryModel->findAll(),
+                'browser_lang' => get_browser_language(
+                    $this->request->getServer('HTTP_ACCEPT_LANGUAGE')
+                ),
+            ];
+
+            echo view('admin/podcast/create', $data);
+        } else {
+            $podcast = new \App\Entities\Podcast([
+                'title' => $this->request->getVar('title'),
+                'name' => $this->request->getVar('name'),
+                'description' => $this->request->getVar('description'),
+                'episode_description_footer' => $this->request->getVar(
+                    'episode_description_footer'
+                ),
+                'image' => $this->request->getFile('image'),
+                'language' => $this->request->getVar('language'),
+                'category' => $this->request->getVar('category'),
+                'explicit' => $this->request->getVar('explicit') or false,
+                'author_name' => $this->request->getVar('author_name'),
+                'author_email' => $this->request->getVar('author_email'),
+                'owner_name' => $this->request->getVar('owner_name'),
+                'owner_email' => $this->request->getVar('owner_email'),
+                'type' => $this->request->getVar('type'),
+                'copyright' => $this->request->getVar('copyright'),
+                'block' => $this->request->getVar('block') or false,
+                'complete' => $this->request->getVar('complete') or false,
+                'custom_html_head' => $this->request->getVar(
+                    'custom_html_head'
+                ),
+            ]);
+
+            $db = \Config\Database::connect();
+
+            $db->transStart();
+
+            $new_podcast_id = $podcast_model->insert($podcast, true);
+
+            $user_podcast_model = new \App\Models\UserPodcastModel();
+            $user_podcast_model->save([
+                'user_id' => user()->id,
+                'podcast_id' => $new_podcast_id,
+            ]);
+
+            $db->transComplete();
+
+            return redirect()->route('podcast_list', [$podcast->name]);
+        }
+    }
+
+    public function edit()
+    {
+        helper(['form', 'misc']);
+
+        if (
+            !$this->validate([
+                'title' => 'required',
+                'name' => 'required|regex_match[[a-zA-Z0-9\_]{1,191}]',
+                'description' => 'required|max_length[4000]',
+                'image' =>
+                    'uploaded[image]|is_image[image]|ext_in[image,jpg,png]|permit_empty',
+                'owner_email' => 'required|valid_email',
+                'type' => 'required',
+            ])
+        ) {
+            $languageModel = new LanguageModel();
+            $categoryModel = new CategoryModel();
+            $data = [
+                'podcast' => $this->podcast,
+                'languages' => $languageModel->findAll(),
+                'categories' => $categoryModel->findAll(),
+            ];
+
+            echo view('admin/podcast/edit', $data);
+        } else {
+            $this->podcast->title = $this->request->getVar('title');
+            $this->podcast->name = $this->request->getVar('name');
+            $this->podcast->description = $this->request->getVar('description');
+            $this->podcast->episode_description_footer = $this->request->getVar(
+                'episode_description_footer'
+            );
+
+            $image = $this->request->getFile('image');
+            if ($image->isValid()) {
+                $this->podcast->image = $this->request->getFile('image');
+            }
+            $this->podcast->language = $this->request->getVar('language');
+            $this->podcast->category = $this->request->getVar('category');
+            $this->podcast->explicit =
+                ($this->request->getVar('explicit') or false);
+            $this->podcast->author_name = $this->request->getVar('author_name');
+            $this->podcast->author_email = $this->request->getVar(
+                'author_email'
+            );
+            $this->podcast->owner_name = $this->request->getVar('owner_name');
+            $this->podcast->owner_email = $this->request->getVar('owner_email');
+            $this->podcast->type = $this->request->getVar('type');
+            $this->podcast->copyright = $this->request->getVar('copyright');
+            $this->podcast->block = ($this->request->getVar('block') or false);
+            $this->podcast->complete =
+                ($this->request->getVar('complete') or false);
+            $this->podcast->custom_html_head = $this->request->getVar(
+                'custom_html_head'
+            );
+
+            $podcast_model = new PodcastModel();
+            $podcast_model->save($this->podcast);
+
+            return redirect()->route('podcast_list', [$this->podcast->name]);
+        }
+    }
+
+    public function delete()
+    {
+        $podcast_model = new PodcastModel();
+        $podcast_model->delete($this->podcast->id);
+
+        return redirect()->route('podcast_list');
+    }
+}
diff --git a/app/Controllers/Admin/User.php b/app/Controllers/Admin/User.php
new file mode 100644
index 0000000000..4faffc2363
--- /dev/null
+++ b/app/Controllers/Admin/User.php
@@ -0,0 +1,142 @@
+<?php
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Controllers\Admin;
+
+use Myth\Auth\Models\UserModel;
+
+class User extends BaseController
+{
+    protected ?\Myth\Auth\Entities\User $user;
+
+    public function _remap($method, ...$params)
+    {
+        if (count($params) > 0) {
+            $user_model = new UserModel();
+            if (
+                !($user = $user_model->where('username', $params[0])->first())
+            ) {
+                throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
+            }
+            $this->user = $user;
+        }
+
+        return $this->$method();
+    }
+
+    public function list()
+    {
+        $user_model = new UserModel();
+
+        $data = ['all_users' => $user_model->findAll()];
+
+        return view('admin/user/list', $data);
+    }
+
+    public function create()
+    {
+        $user_model = new UserModel();
+
+        // Validate here first, since some things,
+        // like the password, can only be validated properly here.
+        $rules = array_merge(
+            $user_model->getValidationRules(['only' => ['username']]),
+            [
+                'email' => 'required|valid_email|is_unique[users.email]',
+                'password' => 'required|strong_password',
+                'pass_confirm' => 'required|matches[password]',
+            ]
+        );
+
+        if (!$this->validate($rules)) {
+            echo view('admin/user/create');
+        } else {
+            // Save the user
+            $user = new \Myth\Auth\Entities\User($this->request->getPost());
+
+            // Activate user
+            $user->activate();
+
+            // Force user to reset his password on first connection
+            $user->force_pass_reset = true;
+            $user->generateResetHash();
+
+            if (!$user_model->save($user)) {
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $user_model->errors());
+            }
+
+            // Success!
+            return redirect()
+                ->route('user_list')
+                ->with('message', lang('User.createSuccess'));
+        }
+    }
+
+    public function forcePassReset()
+    {
+        $user_model = new UserModel();
+
+        $this->user->force_pass_reset = true;
+        $this->user->generateResetHash();
+
+        if (!$user_model->save($this->user)) {
+            return redirect()
+                ->back()
+                ->with('errors', $user_model->errors());
+        }
+
+        // Success!
+        return redirect()
+            ->route('user_list')
+            ->with('message', lang('User.forcePassResetSuccess'));
+    }
+
+    public function ban()
+    {
+        $user_model = new UserModel();
+        $this->user->ban('');
+
+        if (!$user_model->save($this->user)) {
+            return redirect()
+                ->back()
+                ->with('errors', $user_model->errors());
+        }
+
+        return redirect()
+            ->route('user_list')
+            ->with('message', lang('User.banSuccess'));
+    }
+
+    public function unBan()
+    {
+        $user_model = new UserModel();
+        $this->user->unBan();
+
+        if (!$user_model->save($this->user)) {
+            return redirect()
+                ->back()
+                ->with('errors', $user_model->errors());
+        }
+
+        return redirect()
+            ->route('user_list')
+            ->with('message', lang('User.unbanSuccess'));
+    }
+
+    public function delete()
+    {
+        $user_model = new UserModel();
+        $user_model->delete($this->user->id);
+
+        return redirect()
+            ->route('user_list')
+            ->with('message', lang('User.deleteSuccess'));
+    }
+}
diff --git a/app/Controllers/Analytics.php b/app/Controllers/Analytics.php
index f001a45a56..9a2621c24c 100644
--- a/app/Controllers/Analytics.php
+++ b/app/Controllers/Analytics.php
@@ -1,4 +1,4 @@
-<?php namespace App\Controllers;
+<?php
 /**
  * Class Analytics
  * Creates Analytics controller
@@ -7,6 +7,8 @@
  * @link       https://castopod.org/
  */
 
+namespace App\Controllers;
+
 use CodeIgniter\Controller;
 
 class Analytics extends Controller
diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php
new file mode 100644
index 0000000000..f91900654f
--- /dev/null
+++ b/app/Controllers/Auth.php
@@ -0,0 +1,98 @@
+<?php
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Controllers;
+
+use Myth\Auth\Models\UserModel;
+
+class Auth extends \Myth\Auth\Controllers\AuthController
+{
+    /**
+     * An array of helpers to be loaded automatically upon
+     * class instantiation. These helpers will be available
+     * to all other controllers that extend BaseController.
+     *
+     * @var array
+     */
+    protected $helpers = ['auth'];
+
+    /**
+     * Displays the login form, or redirects
+     * the user to their destination/home if
+     * they are already logged in.
+     */
+    public function changePassword()
+    {
+        return view('auth/change_password', [
+            'config' => $this->config,
+            'email' => user()->email,
+            'token' => user()->reset_hash,
+        ]);
+    }
+
+    public function attemptChange()
+    {
+        $users = new UserModel();
+
+        // First things first - log the reset attempt.
+        $users->logResetAttempt(
+            $this->request->getPost('email'),
+            $this->request->getPost('token'),
+            $this->request->getIPAddress(),
+            (string) $this->request->getUserAgent()
+        );
+
+        $rules = [
+            'token' => 'required',
+            'email' => 'required|valid_email',
+            'password' => 'required|strong_password',
+            'pass_confirm' => 'required|matches[password]',
+        ];
+
+        if (!$this->validate($rules)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $users->errors());
+        }
+
+        $user = $users
+            ->where('email', $this->request->getPost('email'))
+            ->where('reset_hash', $this->request->getPost('token'))
+            ->first();
+
+        if (is_null($user)) {
+            return redirect()
+                ->back()
+                ->with('error', lang('Auth.forgotNoUser'));
+        }
+
+        // Reset token still valid?
+        if (
+            !empty($user->reset_expires) &&
+            time() > $user->reset_expires->getTimestamp()
+        ) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('error', lang('Auth.resetTokenExpired'));
+        }
+
+        // Success! Save the new password, and cleanup the reset hash.
+        $user->password = $this->request->getPost('password');
+        $user->reset_hash = null;
+        $user->reset_at = date('Y-m-d H:i:s');
+        $user->reset_expires = null;
+        $user->force_pass_reset = false;
+        $users->save($user);
+
+        return redirect()
+            ->route('login')
+            ->with('message', lang('Auth.resetSuccess'));
+    }
+}
diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php
index 5e3a8c0209..58cd127630 100644
--- a/app/Controllers/BaseController.php
+++ b/app/Controllers/BaseController.php
@@ -1,7 +1,5 @@
 <?php
 
-namespace App\Controllers;
-
 /**
  * Class BaseController
  *
@@ -15,6 +13,8 @@ namespace App\Controllers;
  * @package CodeIgniter
  */
 
+namespace App\Controllers;
+
 use CodeIgniter\Controller;
 
 class BaseController extends Controller
diff --git a/app/Controllers/Episode.php b/app/Controllers/Episode.php
index fc14e8957c..4721a6582c 100644
--- a/app/Controllers/Episode.php
+++ b/app/Controllers/Episode.php
@@ -39,130 +39,7 @@ class Episode extends BaseController
         return $this->$method();
     }
 
-    public function create()
-    {
-        helper(['form']);
-
-        if (
-            !$this->validate([
-                'enclosure' => 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]',
-                'image' =>
-                    'uploaded[image]|is_image[image]|ext_in[image,jpg,png]|permit_empty',
-                'title' => 'required',
-                'slug' => 'required|regex_match[[a-zA-Z0-9\-]{1,191}]',
-                'description' => 'required',
-                'type' => 'required',
-            ])
-        ) {
-            $data = [
-                'podcast' => $this->podcast,
-            ];
-
-            echo view('episode/create', $data);
-        } else {
-            $new_episode = new \App\Entities\Episode([
-                'podcast_id' => $this->podcast->id,
-                'title' => $this->request->getVar('title'),
-                'slug' => $this->request->getVar('slug'),
-                'enclosure' => $this->request->getFile('enclosure'),
-                'pub_date' => $this->request->getVar('pub_date'),
-                'description' => $this->request->getVar('description'),
-                'image' => $this->request->getFile('image'),
-                'explicit' => $this->request->getVar('explicit') or false,
-                'number' => $this->request->getVar('episode_number'),
-                'season_number' => $this->request->getVar('season_number')
-                    ? $this->request->getVar('season_number')
-                    : null,
-                'type' => $this->request->getVar('type'),
-                'author_name' => $this->request->getVar('author_name'),
-                'author_email' => $this->request->getVar('author_email'),
-                'block' => $this->request->getVar('block') or false,
-            ]);
-
-            $episode_model = new EpisodeModel();
-            $episode_model->save($new_episode);
-
-            return redirect()->to(
-                base_url(
-                    route_to(
-                        'episode_view',
-                        $this->podcast->name,
-                        $new_episode->slug
-                    )
-                )
-            );
-        }
-    }
-
-    public function edit()
-    {
-        helper(['form']);
-
-        if (
-            !$this->validate([
-                'enclosure' =>
-                    'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]|permit_empty',
-                'image' =>
-                    'uploaded[image]|is_image[image]|ext_in[image,jpg,png]|permit_empty',
-                'title' => 'required',
-                'slug' => 'required|regex_match[[a-zA-Z0-9\-]{1,191}]',
-                'description' => 'required',
-                'type' => 'required',
-            ])
-        ) {
-            $data = [
-                'podcast' => $this->podcast,
-                'episode' => $this->episode,
-            ];
-
-            echo view('episode/edit', $data);
-        } else {
-            $this->episode->title = $this->request->getVar('title');
-            $this->episode->slug = $this->request->getVar('slug');
-            $this->episode->pub_date = $this->request->getVar('pub_date');
-            $this->episode->description = $this->request->getVar('description');
-            $this->episode->explicit =
-                ($this->request->getVar('explicit') or false);
-            $this->episode->number = $this->request->getVar('episode_number');
-            $this->episode->season_number = $this->request->getVar(
-                'season_number'
-            )
-                ? $this->request->getVar('season_number')
-                : null;
-            $this->episode->type = $this->request->getVar('type');
-            $this->episode->author_name = $this->request->getVar('author_name');
-            $this->episode->author_email = $this->request->getVar(
-                'author_email'
-            );
-            $this->episode->block = ($this->request->getVar('block') or false);
-
-            $enclosure = $this->request->getFile('enclosure');
-            if ($enclosure->isValid()) {
-                $this->episode->enclosure = $this->request->getFile(
-                    'enclosure'
-                );
-            }
-            $image = $this->request->getFile('image');
-            if ($image) {
-                $this->episode->image = $this->request->getFile('image');
-            }
-
-            $episode_model = new EpisodeModel();
-            $episode_model->save($this->episode);
-
-            return redirect()->to(
-                base_url(
-                    route_to(
-                        'episode_view',
-                        $this->podcast->name,
-                        $this->episode->slug
-                    )
-                )
-            );
-        }
-    }
-
-    public function view()
+    public function index()
     {
         // The page cache is set to a decade so it is deleted manually upon podcast update
         $this->cachePage(DECADE);
@@ -173,16 +50,6 @@ class Episode extends BaseController
             'podcast' => $this->podcast,
             'episode' => $this->episode,
         ];
-        return view('episode/view', $data);
-    }
-
-    public function delete()
-    {
-        $episode_model = new EpisodeModel();
-        $episode_model->delete($this->episode->id);
-
-        return redirect()->to(
-            base_url(route_to('podcast_view', $this->podcast->name))
-        );
+        return view('episode', $data);
     }
 }
diff --git a/app/Controllers/Feed.php b/app/Controllers/Feed.php
index e76eb19924..48064d0a6e 100644
--- a/app/Controllers/Feed.php
+++ b/app/Controllers/Feed.php
@@ -1,4 +1,9 @@
 <?php
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
 
 namespace App\Controllers;
 
diff --git a/app/Controllers/Home.php b/app/Controllers/Home.php
index f87459b6fb..0f9ca24fb2 100644
--- a/app/Controllers/Home.php
+++ b/app/Controllers/Home.php
@@ -19,9 +19,7 @@ class Home extends BaseController
 
         // check if there's only one podcast to redirect user to it
         if (count($all_podcasts) == 1) {
-            return redirect()->to(
-                base_url(route_to('podcast_view', $all_podcasts[0]->name))
-            );
+            return redirect()->route('podcast', [$all_podcasts[0]->name]);
         }
 
         // default behavior: list all podcasts on home page
diff --git a/app/Controllers/Podcast.php b/app/Controllers/Podcast.php
index 081f7b9faf..70db9d6059 100644
--- a/app/Controllers/Podcast.php
+++ b/app/Controllers/Podcast.php
@@ -6,8 +6,6 @@
  */
 namespace App\Controllers;
 
-use App\Models\CategoryModel;
-use App\Models\LanguageModel;
 use App\Models\PodcastModel;
 
 class Podcast extends BaseController
@@ -29,131 +27,7 @@ class Podcast extends BaseController
         return $this->$method();
     }
 
-    public function create()
-    {
-        helper(['form', 'misc']);
-        $podcast_model = new PodcastModel();
-
-        if (
-            !$this->validate([
-                'title' => 'required',
-                'name' => 'required|regex_match[[a-zA-Z0-9\_]{1,191}]',
-                'description' => 'required|max_length[4000]',
-                'image' =>
-                    'uploaded[image]|is_image[image]|ext_in[image,jpg,png]',
-                'owner_email' => 'required|valid_email',
-                'type' => 'required',
-            ])
-        ) {
-            $languageModel = new LanguageModel();
-            $categoryModel = new CategoryModel();
-            $data = [
-                'languages' => $languageModel->findAll(),
-                'categories' => $categoryModel->findAll(),
-                'browser_lang' => get_browser_language(
-                    $this->request->getServer('HTTP_ACCEPT_LANGUAGE')
-                ),
-            ];
-
-            echo view('podcast/create', $data);
-        } else {
-            $podcast = new \App\Entities\Podcast([
-                'title' => $this->request->getVar('title'),
-                'name' => $this->request->getVar('name'),
-                'description' => $this->request->getVar('description'),
-                'episode_description_footer' => $this->request->getVar(
-                    'episode_description_footer'
-                ),
-                'image' => $this->request->getFile('image'),
-                'language' => $this->request->getVar('language'),
-                'category' => $this->request->getVar('category'),
-                'explicit' => $this->request->getVar('explicit') or false,
-                'author_name' => $this->request->getVar('author_name'),
-                'author_email' => $this->request->getVar('author_email'),
-                'owner_name' => $this->request->getVar('owner_name'),
-                'owner_email' => $this->request->getVar('owner_email'),
-                'type' => $this->request->getVar('type'),
-                'copyright' => $this->request->getVar('copyright'),
-                'block' => $this->request->getVar('block') or false,
-                'complete' => $this->request->getVar('complete') or false,
-                'custom_html_head' => $this->request->getVar(
-                    'custom_html_head'
-                ),
-            ]);
-
-            $podcast_model->save($podcast);
-
-            return redirect()->to(
-                base_url(route_to('podcast_view', $podcast->name))
-            );
-        }
-    }
-
-    public function edit()
-    {
-        helper(['form', 'misc']);
-
-        if (
-            !$this->validate([
-                'title' => 'required',
-                'name' => 'required|regex_match[[a-zA-Z0-9\_]{1,191}]',
-                'description' => 'required|max_length[4000]',
-                'image' =>
-                    'uploaded[image]|is_image[image]|ext_in[image,jpg,png]|permit_empty',
-                'owner_email' => 'required|valid_email',
-                'type' => 'required',
-            ])
-        ) {
-            $languageModel = new LanguageModel();
-            $categoryModel = new CategoryModel();
-            $data = [
-                'podcast' => $this->podcast,
-                'languages' => $languageModel->findAll(),
-                'categories' => $categoryModel->findAll(),
-            ];
-
-            echo view('podcast/edit', $data);
-        } else {
-            $this->podcast->title = $this->request->getVar('title');
-            $this->podcast->name = $this->request->getVar('name');
-            $this->podcast->description = $this->request->getVar('description');
-            $this->podcast->episode_description_footer = $this->request->getVar(
-                'episode_description_footer'
-            );
-
-            $image = $this->request->getFile('image');
-            if ($image->isValid()) {
-                $this->podcast->image = $this->request->getFile('image');
-            }
-            $this->podcast->language = $this->request->getVar('language');
-            $this->podcast->category = $this->request->getVar('category');
-            $this->podcast->explicit =
-                ($this->request->getVar('explicit') or false);
-            $this->podcast->author_name = $this->request->getVar('author_name');
-            $this->podcast->author_email = $this->request->getVar(
-                'author_email'
-            );
-            $this->podcast->owner_name = $this->request->getVar('owner_name');
-            $this->podcast->owner_email = $this->request->getVar('owner_email');
-            $this->podcast->type = $this->request->getVar('type');
-            $this->podcast->copyright = $this->request->getVar('copyright');
-            $this->podcast->block = ($this->request->getVar('block') or false);
-            $this->podcast->complete =
-                ($this->request->getVar('complete') or false);
-            $this->podcast->custom_html_head = $this->request->getVar(
-                'custom_html_head'
-            );
-
-            $podcast_model = new PodcastModel();
-            $podcast_model->save($this->podcast);
-
-            return redirect()->to(
-                base_url(route_to('podcast_view', $this->podcast->name))
-            );
-        }
-    }
-
-    public function view()
+    public function index()
     {
         // The page cache is set to a decade so it is deleted manually upon podcast update
         $this->cachePage(DECADE);
@@ -164,14 +38,6 @@ class Podcast extends BaseController
             'podcast' => $this->podcast,
             'episodes' => $this->podcast->episodes,
         ];
-        return view('podcast/view', $data);
-    }
-
-    public function delete()
-    {
-        $podcast_model = new PodcastModel();
-        $podcast_model->delete($this->podcast->id);
-
-        return redirect()->to(base_url(route_to('home')));
+        return view('podcast', $data);
     }
 }
diff --git a/app/Controllers/UnknownUserAgents.php b/app/Controllers/UnknownUserAgents.php
index 82e1a2985b..3eb284712a 100644
--- a/app/Controllers/UnknownUserAgents.php
+++ b/app/Controllers/UnknownUserAgents.php
@@ -1,4 +1,11 @@
-<?php namespace App\Controllers;
+<?php
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Controllers;
 use CodeIgniter\Controller;
 
 class UnknownUserAgents extends Controller
diff --git a/app/Database/Migrations/2020-07-03-191500_add_users_podcasts.php b/app/Database/Migrations/2020-07-03-191500_add_users_podcasts.php
new file mode 100644
index 0000000000..16381819dc
--- /dev/null
+++ b/app/Database/Migrations/2020-07-03-191500_add_users_podcasts.php
@@ -0,0 +1,48 @@
+<?php
+/**
+ * Class AddLanguages
+ * Creates languages table in database
+ *
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Database\Migrations;
+
+use CodeIgniter\Database\Migration;
+
+class AddUsersPodcasts extends Migration
+{
+    public function up()
+    {
+        $this->forge->addField([
+            'id' => [
+                'type' => 'BIGINT',
+                'constraint' => 20,
+                'unsigned' => true,
+                'auto_increment' => true,
+            ],
+            'user_id' => [
+                'type' => 'INT',
+                'constraint' => 11,
+                'unsigned' => true,
+            ],
+            'podcast_id' => [
+                'type' => 'BIGINT',
+                'constraint' => 20,
+                'unsigned' => true,
+            ],
+        ]);
+        $this->forge->addPrimaryKey('id');
+        $this->forge->addUniqueKey(['user_id', 'podcast_id']);
+        $this->forge->addForeignKey('user_id', 'users', 'id');
+        $this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
+        $this->forge->createTable('users_podcasts');
+    }
+
+    public function down()
+    {
+        $this->forge->dropTable('users_podcasts');
+    }
+}
diff --git a/app/Database/Seeds/UserSeeder.php b/app/Database/Seeds/UserSeeder.php
new file mode 100644
index 0000000000..826f1332a1
--- /dev/null
+++ b/app/Database/Seeds/UserSeeder.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * Class UserSeeder
+ * Inserts 'admin' user in users table for testing purposes
+ *
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Database\Seeds;
+
+use CodeIgniter\Database\Seeder;
+
+class UserSeeder extends Seeder
+{
+    public function run()
+    {
+        $data = [
+            'username' => 'admin',
+            'email' => 'admin@castopod.com',
+            'password_hash' =>
+                // password: AGUehL3P
+                '$2y$10$TXJEHX/djW8jtzgpDVf7dOOCGo5rv1uqtAYWdwwwkttQcDkAeB2.6',
+            'active' => 1,
+        ];
+
+        $this->db->table('users')->insert($data);
+    }
+}
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index 243bf3bd00..2087271637 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -135,7 +135,7 @@ class Episode extends Entity
     {
         return base_url(
             route_to(
-                'episode_view',
+                'episode',
                 $this->getPodcast()->name,
                 $this->attributes['slug']
             )
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 2a3407d56f..386be65c3f 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -71,7 +71,7 @@ class Podcast extends Entity
 
     public function getLink()
     {
-        return base_url(route_to('podcast_view', $this->attributes['name']));
+        return base_url(route_to('podcast', $this->attributes['name']));
     }
 
     public function getFeedUrl()
@@ -79,12 +79,25 @@ class Podcast extends Entity
         return base_url(route_to('podcast_feed', $this->attributes['name']));
     }
 
+    /**
+     * Returns the podcast's episodes
+     *
+     * @return \App\Entities\Episode[]
+     */
     public function getEpisodes()
     {
-        $episode_model = new EpisodeModel();
+        if (empty($this->id)) {
+            throw new \RuntimeException(
+                'Podcast must be created before getting episodes.'
+            );
+        }
+
+        if (empty($this->permissions)) {
+            $this->episodes = (new EpisodeModel())->getPodcastEpisodes(
+                $this->id
+            );
+        }
 
-        return $episode_model
-            ->where('podcast_id', $this->attributes['id'])
-            ->findAll();
+        return $this->episodes;
     }
 }
diff --git a/app/Entities/UserPodcast.php b/app/Entities/UserPodcast.php
new file mode 100644
index 0000000000..d951557dc9
--- /dev/null
+++ b/app/Entities/UserPodcast.php
@@ -0,0 +1,18 @@
+<?php
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Entities;
+
+use CodeIgniter\Entity;
+
+class UserPodcast extends Entity
+{
+    protected $casts = [
+        'user_id' => 'integer',
+        'podcast_id' => 'integer',
+    ];
+}
diff --git a/app/Helpers/analytics_helper.php b/app/Helpers/analytics_helper.php
index 73ebce65fc..90282c2ada 100644
--- a/app/Helpers/analytics_helper.php
+++ b/app/Helpers/analytics_helper.php
@@ -13,7 +13,6 @@ function set_user_session_country()
 {
     $session = \Config\Services::session();
     $session->start();
-    $db = \Config\Database::connect();
 
     $country = 'N/A';
 
diff --git a/app/Helpers/media_helper.php b/app/Helpers/media_helper.php
index 09edaf196f..3a3628e6bc 100644
--- a/app/Helpers/media_helper.php
+++ b/app/Helpers/media_helper.php
@@ -8,7 +8,7 @@
 /**
  * Saves a file to the corresponding podcast folder in `public/media`
  *
- * @param UploadedFile $file
+ * @param \CodeIgniter\HTTP\Files\UploadedFile $file
  * @param string $podcast_name
  * @param string $file_name
  *
diff --git a/app/Language/en/Episode.php b/app/Language/en/Episode.php
index 8915c8de9b..22ff6a5349 100644
--- a/app/Language/en/Episode.php
+++ b/app/Language/en/Episode.php
@@ -1,9 +1,17 @@
 <?
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
 
 return [
+    'all_podcast_episodes' => 'All podcast episodes',
+    'create_one' => 'Add a new one',
     'back_to_podcast' => 'Go back to podcast',
     'edit' => 'Edit',
     'delete' => 'Delete',
+    'goto_page' => 'Go to page',
     'create' => 'Add an episode',
     'form' => [
         'file' => 'Audio file',
diff --git a/app/Language/en/Home.php b/app/Language/en/Home.php
index 3cb0366cb8..435307ef9d 100644
--- a/app/Language/en/Home.php
+++ b/app/Language/en/Home.php
@@ -1,4 +1,9 @@
 <?
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
 
 return [
     'all_podcasts' => 'All podcasts',
diff --git a/app/Language/en/MyAccount.php b/app/Language/en/MyAccount.php
new file mode 100644
index 0000000000..c1935bcc33
--- /dev/null
+++ b/app/Language/en/MyAccount.php
@@ -0,0 +1,11 @@
+<?
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'passwordChangeSuccess' => 'Password has been successfully changed!',
+    'changePassword' => 'Change my password'
+];
diff --git a/app/Language/en/Podcast.php b/app/Language/en/Podcast.php
index 776420a28e..767d54e089 100644
--- a/app/Language/en/Podcast.php
+++ b/app/Language/en/Podcast.php
@@ -1,11 +1,21 @@
 <?
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
 
 return [
+    'all_podcasts' => 'All podcasts',
+    'no_podcast' => 'No podcast found!',
+    'create_one' => 'Add a new one',
     'create' => 'Create a Podcast',
     'new_episode' => 'New Episode',
     'feed' => 'RSS feed',
     'edit' => 'Edit',
     'delete' => 'Delete',
+    'see_episodes' => 'See episodes',
+    'goto_page' => 'Go to page',
     'form' => [
         'title' => 'Title',
         'name' => 'Name',
diff --git a/app/Language/en/User.php b/app/Language/en/User.php
new file mode 100644
index 0000000000..fcf9134c64
--- /dev/null
+++ b/app/Language/en/User.php
@@ -0,0 +1,28 @@
+<?
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'createSuccess' => 'User created successfully! The new user will be prompted with a password reset during his first login attempt.',
+    'forcePassResetSuccess' => 'The user will be prompted with a password reset during his next login attempt.',
+    'banSuccess' => 'User has been banned.',
+    'unbanSuccess' => 'User has been unbanned.',
+    'forcePassReset' => 'Force pass reset',
+    'ban' => 'Ban',
+    'unban' => 'Unban',
+    'delete' => 'Delete',
+    'create' => 'Create a user',
+    'form' => [
+        'email' => 'Email',
+        'username' => 'Username',
+        'password' => 'Password',
+        'new_password' => 'New Password',
+        'repeat_password' => 'Repeat password',
+        'repeat_new_password' => 'Repeat new password',
+        'submit_create' => 'Create user',
+        'submit_edit' => 'Save',
+    ]
+];
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index bd29dbf608..ed2ea4e9f8 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -61,11 +61,30 @@ class EpisodeModel extends Model
             is_array($data['id']) ? $data['id'][0] : $data['id']
         );
 
-        $cache = \Config\Services::cache();
-
         // delete cache for rss feed, podcast and episode pages
-        $cache->delete(md5($episode->podcast->feed_url));
-        $cache->delete(md5($episode->podcast->link));
-        $cache->delete(md5($episode->link));
+        cache()->delete(md5($episode->podcast->feed_url));
+        cache()->delete(md5($episode->podcast->link));
+        cache()->delete(md5($episode->link));
+
+        // delete model requests cache
+        cache()->delete("{$episode->podcast_id}_episodes");
+    }
+
+    /**
+     * Gets all episodes for a podcast
+     *
+     * @param int $podcastId
+     *
+     * @return \App\Entities\Episode[]
+     */
+    public function getPodcastEpisodes(int $podcastId): array
+    {
+        if (!($found = cache("{$podcastId}_episodes"))) {
+            $found = $this->where('podcast_id', $podcastId)->findAll();
+
+            cache()->save("{$podcastId}_episodes", $found, 300);
+        }
+
+        return $found;
     }
 }
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index fba25f5ce7..09712b6a4e 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -49,11 +49,9 @@ class PodcastModel extends Model
             is_array($data['id']) ? $data['id'][0] : $data['id']
         );
 
-        $cache = \Config\Services::cache();
-
         // delete cache for rss feed and podcast pages
-        $cache->delete(md5($podcast->feed_url));
-        $cache->delete(md5($podcast->link));
+        cache()->delete(md5($podcast->feed_url));
+        cache()->delete(md5($podcast->link));
         // TODO: clear cache for every podcast's episode page?
         // foreach ($podcast->episodes as $episode) {
         //     $cache->delete(md5($episode->link));
diff --git a/app/Models/UserPodcastModel.php b/app/Models/UserPodcastModel.php
new file mode 100644
index 0000000000..32d36b0512
--- /dev/null
+++ b/app/Models/UserPodcastModel.php
@@ -0,0 +1,23 @@
+<?php
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Models;
+
+use CodeIgniter\Model;
+
+class UserPodcastModel extends Model
+{
+    protected $table = 'users_podcasts';
+    protected $primaryKey = 'id';
+
+    protected $allowedFields = ['user_id', 'podcast_id'];
+
+    protected $returnType = 'App\Entities\UserPodcast';
+    protected $useSoftDeletes = false;
+
+    protected $useTimestamps = false;
+}
diff --git a/app/Views/layouts/default.php b/app/Views/_layout.php
similarity index 77%
rename from app/Views/layouts/default.php
rename to app/Views/_layout.php
index 6f23967094..92d581bad9 100644
--- a/app/Views/layouts/default.php
+++ b/app/Views/_layout.php
@@ -14,17 +14,12 @@
 	<header class="border-b">
 		<div class="container flex items-center justify-between px-2 py-4 mx-auto">
 			<a href="<?= route_to('home') ?>" class="text-2xl">Castopod</a>
-			<nav>
-				<a class="px-4 py-2 border hover:bg-gray-100" href="<?= route_to(
-        'podcast_create'
-    ) ?>">New podcast</a>
-			</nav>
 		</div>
 	</header>
 	<main class="container flex-1 px-4 py-10 mx-auto">
 		<?= $this->renderSection('content') ?>
 	</main>
 	<footer class="container px-2 py-4 mx-auto text-sm text-right border-t">
-		Powered by <a class="underline hover:no-underline" href="https://castopod.org">Castopod</a>, a <a class="underline hover:no-underline" href="https://podlibre.org/">Podlibre</a> initiative.
+		Powered by <a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>, a <a class="underline hover:no-underline" href="https://podlibre.org/" target="_blank" rel="noreferrer noopener">Podlibre</a> initiative.
 	</footer>
-</body>
\ No newline at end of file
+</body>
diff --git a/app/Views/_message_block.php b/app/Views/_message_block.php
new file mode 100644
index 0000000000..466b5780b9
--- /dev/null
+++ b/app/Views/_message_block.php
@@ -0,0 +1,20 @@
+<?php if (session()->has('message')): ?>
+	<div class="px-4 py-2 font-semibold text-green-900 bg-green-200 border border-green-700">
+		<?= session('message') ?>
+	</div>
+<?php endif; ?>
+
+<?php if (session()->has('error')): ?>
+	<div class="px-4 py-2 font-semibold text-red-900 bg-red-200 border border-red-700">
+		<?= session('error') ?>
+	</div>
+<?php endif; ?>
+
+<?php if (session()->has('errors')): ?>
+	<ul class="px-4 py-2 font-semibold text-red-900 bg-red-200 border border-red-700">
+	<?php foreach (session('errors') as $error): ?>
+		<li><?= $error ?></li>
+	<?php endforeach; ?>
+	</ul>
+<?php endif;
+?>
diff --git a/app/Views/admin/_layout.php b/app/Views/admin/_layout.php
new file mode 100644
index 0000000000..a96b0a7cf4
--- /dev/null
+++ b/app/Views/admin/_layout.php
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+	<meta charset="UTF-8">
+	<title>Castopod</title>
+	<meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience.">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
+	<link rel="stylesheet" href="/index.css">
+</head>
+
+<body class="flex flex-col min-h-screen mx-auto">
+	<header class="text-white bg-gray-900 border-b">
+		<div class="flex items-center justify-between px-4 py-4 mx-auto">
+			<a href="<?= route_to('home') ?>" class="text-xl">Castopod Admin</a>
+			<nav>
+				<span class="mr-2">Welcome, <?= user()->username ?></span>
+				<a class="px-4 py-2 border hover:bg-gray-800" href="<?= route_to(
+        'logout'
+    ) ?>">Logout</a>
+			</nav>
+		</div>
+	</header>
+	<div class="flex flex-1">
+		<?= view('admin/_sidenav') ?>
+		<main class="container flex-1 px-4 py-6 mx-auto">
+			<div class="mb-4">
+				<?= view('_message_block') ?>
+			</div>
+			<?= $this->renderSection('content') ?>
+		</main>
+	</div>
+	<footer class="container px-2 py-4 mx-auto text-sm text-right border-t">
+		Powered by <a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>, a <a class="underline hover:no-underline" href="https://podlibre.org/" target="_blank" rel="noreferrer noopener">Podlibre</a> initiative.
+	</footer>
+</body>
diff --git a/app/Views/admin/_sidenav.php b/app/Views/admin/_sidenav.php
new file mode 100644
index 0000000000..f68ae5bab7
--- /dev/null
+++ b/app/Views/admin/_sidenav.php
@@ -0,0 +1,54 @@
+<aside class="w-64 px-4 py-6">
+    <nav>
+        <a class="block px-2 py-1 mb-4 -mx-2 text-gray-600 transition duration-200 ease-in-out hover:text-gray-900" href="<?= route_to(
+            'admin'
+        ) ?>">
+            Dashboard
+        </a>
+        <div class="mb-4">
+            <span class="mb-3 text-sm font-bold tracking-wide text-gray-600 uppercase lg:mb-2 lg:text-xs">Podcasts</span>
+            <ul>
+                <li>
+                    <a class="block px-2 py-1 -mx-2 text-gray-600 transition duration-200 ease-in-out hover:text-gray-900" href="<?= route_to(
+                        'podcast_list'
+                    ) ?>">All podcasts</a>
+                </li>
+                <li>
+                    <a class="block px-2 py-1 -mx-2 text-gray-600 transition duration-200 ease-in-out hover:text-gray-900" href="<?= route_to(
+                        'podcast_create'
+                    ) ?>">New podcast</a>
+                </li>
+            </ul>
+        </div>
+        <div class="mb-4">
+            <span class="mb-3 text-sm font-bold tracking-wide text-gray-600 uppercase lg:mb-2 lg:text-xs">Users</span>
+            <ul>
+                <li>
+                    <a class="block px-2 py-1 -mx-2 text-gray-600 transition duration-200 ease-in-out hover:text-gray-900" href="<?= route_to(
+                        'user_list'
+                    ) ?>">All Users</a>
+                </li>
+                <li>
+                    <a class="block px-2 py-1 -mx-2 text-gray-600 transition duration-200 ease-in-out hover:text-gray-900" href="<?= route_to(
+                        'user_create'
+                    ) ?>">New user</a>
+                </li>
+            </ul>
+        </div>
+        <div>
+            <span class="mb-3 text-sm font-bold tracking-wide text-gray-600 uppercase lg:mb-2 lg:text-xs">My Account</span>
+            <ul>
+                <li>
+                    <a class="block px-2 py-1 -mx-2 text-gray-600 transition duration-200 ease-in-out hover:text-gray-900" href="<?= route_to(
+                        'myAccount'
+                    ) ?>">Account info</a>
+                </li>
+                <li>
+                    <a class="block px-2 py-1 -mx-2 text-gray-600 transition duration-200 ease-in-out hover:text-gray-900" href="<?= route_to(
+                        'myAccount_change-password'
+                    ) ?>">Change my password</a>
+                </li>
+            </ul>
+        </div>
+    </nav>
+</aside>
diff --git a/app/Views/admin/dashboard.php b/app/Views/admin/dashboard.php
new file mode 100644
index 0000000000..910003fae6
--- /dev/null
+++ b/app/Views/admin/dashboard.php
@@ -0,0 +1,8 @@
+<?= $this->extend('admin/_layout') ?>
+
+<?= $this->section('content') ?>
+
+<h1 class="text-2xl">Welcome to the admin dashboard!</h1>
+
+<?= $this->endSection() ?>
+
diff --git a/app/Views/episode/create.php b/app/Views/admin/episode/create.php
similarity index 98%
rename from app/Views/episode/create.php
rename to app/Views/admin/episode/create.php
index 606477083b..af1e9d4069 100644
--- a/app/Views/episode/create.php
+++ b/app/Views/admin/episode/create.php
@@ -1,4 +1,4 @@
-<?= $this->extend('layouts/default') ?>
+<?= $this->extend('admin/_layout') ?>
 
 <?= $this->section('content') ?>
 
diff --git a/app/Views/episode/edit.php b/app/Views/admin/episode/edit.php
similarity index 99%
rename from app/Views/episode/edit.php
rename to app/Views/admin/episode/edit.php
index 4ef949f6af..51b91d984d 100644
--- a/app/Views/episode/edit.php
+++ b/app/Views/admin/episode/edit.php
@@ -1,4 +1,4 @@
-<?= $this->extend('layouts/default') ?>
+<?= $this->extend('admin/_layout') ?>
 
 <?= $this->section('content') ?>
 
diff --git a/app/Views/admin/episode/list.php b/app/Views/admin/episode/list.php
new file mode 100644
index 0000000000..81d4c1f9a3
--- /dev/null
+++ b/app/Views/admin/episode/list.php
@@ -0,0 +1,65 @@
+<?= $this->extend('admin/_layout') ?>
+
+<?= $this->section('content') ?>
+
+<div class="flex flex-col py-4">
+    <h1 class="mb-4 text-xl"><?= lang(
+        'Episode.all_podcast_episodes'
+    ) ?> (<?= count($all_podcast_episodes) ?>)</h1>
+    <?php if ($all_podcast_episodes): ?>
+        <?php foreach ($all_podcast_episodes as $episode): ?>
+            <article class="flex-col w-full max-w-lg p-4 mb-4 border shadow">
+                <div class="flex mb-2">
+                    <img src="<?= $episode->image_url ?>" alt="<?= $episode->title ?>" class="object-cover w-32 h-32 mr-4" />
+                    <div class="flex flex-col flex-1">
+                        <a href="<?= route_to(
+                            'episode_edit',
+                            $podcast->name,
+                            $episode->slug
+                        ) ?>">
+                            <h3 class="text-xl font-semibold">
+                                <span class="mr-1 underline hover:no-underline"><?= $episode->title ?></span>
+                                <span class="text-base font-bold text-gray-600">#<?= $episode->number ?></span>
+                            </h3>
+                            <p><?= $episode->description ?></p>
+                        </a>
+                        <audio controls class="mt-auto" preload="none">
+                            <source src="<?= $episode->enclosure_url ?>" type="<?= $episode->enclosure_type ?>">
+                            Your browser does not support the audio tag.
+                        </audio>
+                    </div>
+                </div>
+                <a class="inline-flex px-4 py-2 text-white bg-teal-700 hover:bg-teal-800" href="<?= route_to(
+                    'episode_edit',
+                    $podcast->name,
+                    $episode->slug
+                ) ?>"><?= lang('Episode.edit') ?></a>
+                <a href="<?= route_to(
+                    'episode',
+                    $podcast->name,
+                    $episode->slug
+                ) ?>" class="inline-flex px-4 py-2 text-white bg-gray-700 hover:bg-gray-800"><?= lang(
+    'Episode.goto_page'
+) ?></a>
+                <a href="<?= route_to(
+                    'episode_delete',
+                    $podcast->name,
+                    $episode->slug
+                ) ?>" class="inline-flex px-4 py-2 text-white bg-red-700 hover:bg-red-800"><?= lang(
+    'Episode.delete'
+) ?></a>
+            </article>
+        <?php endforeach; ?>
+    <?php else: ?>
+        <div class="flex items-center">
+            <p class="mr-4 italic"><?= lang('Podcast.no_episode') ?></p>
+            <a class="self-start px-4 py-2 border hover:bg-gray-100" href="<?= route_to(
+                'episode_create',
+                $podcast->name
+            ) ?>"><?= lang('Episode.create_one') ?></a>
+        </div>
+    <?php endif; ?>
+</div>
+
+<?= $this->endSection()
+?>
diff --git a/app/Views/admin/my_account/change_password.php b/app/Views/admin/my_account/change_password.php
new file mode 100644
index 0000000000..28cc35eaaa
--- /dev/null
+++ b/app/Views/admin/my_account/change_password.php
@@ -0,0 +1,31 @@
+<?= $this->extend('admin/_layout') ?>
+
+<?= $this->section('content') ?>
+
+<h1 class="mb-6 text-xl"><?= lang('MyAccount.changePassword') ?></h1>
+
+<form action="<?= route_to(
+    'myAccount_changePassword'
+) ?>" method="post" class="flex flex-col max-w-lg">
+    <?= csrf_field() ?>
+
+    <input type="hidden" name="email" value="<?= user()->email ?>">
+
+    <label for="password"><?= lang('User.form.password') ?></label>
+    <input type="password" name="password" class="mb-4 form-input" id="password" autocomplete="off">
+
+    <label for="new_password"><?= lang('User.form.new_password') ?></label>
+    <input type="password" name="new_password" class="mb-4 form-input" id="new_password" autocomplete="off">
+
+    <label for="pass_confirm"><?= lang(
+        'User.form.repeat_new_password'
+    ) ?></label>
+    <input type="password" name="new_pass_confirm" class="mb-6 form-input" id="new_pass_confirm" autocomplete="off">
+
+    <button type="submit" class="px-4 py-2 ml-auto border">
+        <?= lang('User.form.submit_edit') ?>
+    </button>
+</form>
+
+<?= $this->endSection()
+?>
diff --git a/app/Views/admin/my_account/view.php b/app/Views/admin/my_account/view.php
new file mode 100644
index 0000000000..03deb336a2
--- /dev/null
+++ b/app/Views/admin/my_account/view.php
@@ -0,0 +1,31 @@
+<?= $this->extend('admin/_layout') ?>
+
+<?= $this->section('content') ?>
+
+<div class="px-4 py-5 bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
+    <dt class="text-sm font-medium leading-5 text-gray-500">
+    Email
+    </dt>
+    <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
+    <?= user()->email ?>
+    </dd>
+</div>
+<div class="px-4 py-5 bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
+    <dt class="text-sm font-medium leading-5 text-gray-500">
+    Username
+    </dt>
+    <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
+    <?= user()->username ?>
+    </dd>
+</div>
+<div class="px-4 py-5 bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
+    <dt class="text-sm font-medium leading-5 text-gray-500">
+    Permissions
+    </dt>
+    <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
+    [<?= implode(', ', user()->permissions) ?>]
+    </dd>
+</div>
+
+<?= $this->endSection()
+?>
diff --git a/app/Views/podcast/create.php b/app/Views/admin/podcast/create.php
similarity index 99%
rename from app/Views/podcast/create.php
rename to app/Views/admin/podcast/create.php
index 44ab5ed784..c9eed74eb1 100644
--- a/app/Views/podcast/create.php
+++ b/app/Views/admin/podcast/create.php
@@ -1,4 +1,4 @@
-<?= $this->extend('layouts/default') ?>
+<?= $this->extend('admin/_layout') ?>
 
 <?= $this->section('content') ?>
 
diff --git a/app/Views/podcast/edit.php b/app/Views/admin/podcast/edit.php
similarity index 99%
rename from app/Views/podcast/edit.php
rename to app/Views/admin/podcast/edit.php
index 4c83baf05d..4dcfff666b 100644
--- a/app/Views/podcast/edit.php
+++ b/app/Views/admin/podcast/edit.php
@@ -1,4 +1,4 @@
-<?= $this->extend('layouts/default') ?>
+<?= $this->extend('admin/_layout') ?>
 
 <?= $this->section('content') ?>
 
diff --git a/app/Views/admin/podcast/list.php b/app/Views/admin/podcast/list.php
new file mode 100644
index 0000000000..7510eeef26
--- /dev/null
+++ b/app/Views/admin/podcast/list.php
@@ -0,0 +1,49 @@
+<?= $this->extend('admin/_layout') ?>
+
+<?= $this->section('content') ?>
+
+<h1 class="mb-2 text-xl"><?= lang('Podcast.all_podcasts') ?> (<?= count(
+     $all_podcasts
+ ) ?>)</h1>
+<div class="flex flex-wrap">
+    <?php if ($all_podcasts): ?>
+        <?php foreach ($all_podcasts as $podcast): ?>
+            <article class="w-48 h-full p-2 mb-4 mr-4 border shadow-sm hover:bg-gray-100 hover:shadow">
+                <img alt="<?= $podcast->title ?>" src="<?= $podcast->image_url ?>" class="object-cover w-full h-40 mb-2" />
+                <a href="<?= route_to(
+                    'episode_list',
+                    $podcast->name
+                ) ?>" class="hover:underline">
+                    <h2 class="font-semibold leading-tight"><?= $podcast->title ?></h2>
+                </a>
+                <p class="mb-4 text-gray-600">@<?= $podcast->name ?></p>
+                <a class="inline-flex px-2 py-1 mb-2 text-white bg-teal-700 hover:bg-teal-800" href="<?= route_to(
+                    'podcast_edit',
+                    $podcast->name
+                ) ?>"><?= lang('Podcast.edit') ?></a>
+                <a class="inline-flex px-2 py-1 mb-2 text-white bg-indigo-700 hover:bg-indigo-800" href="<?= route_to(
+                    'episode_list',
+                    $podcast->name
+                ) ?>"><?= lang('Podcast.see_episodes') ?></a>
+                <a class="inline-flex px-2 py-1 text-white bg-gray-700 hover:bg-gray-800" href="<?= route_to(
+                    'podcast',
+                    $podcast->name
+                ) ?>"><?= lang('Podcast.goto_page') ?></a>
+                <a class="inline-flex px-2 py-1 text-white bg-red-700 hover:bg-red-800" href="<?= route_to(
+                    'podcast_delete',
+                    $podcast->name
+                ) ?>"><?= lang('Podcast.delete') ?></a>
+            </article>
+        <?php endforeach; ?>
+    <?php else: ?>
+        <div class="flex items-center">
+            <p class="mr-4 italic"><?= lang('Podcast.no_podcast') ?></p>
+            <a class="self-start px-4 py-2 border hover:bg-gray-100 " href="<?= route_to(
+                'podcast_create'
+            ) ?>"><?= lang('Podcast.create_one') ?></a>
+        </div>
+    <?php endif; ?>
+</div>
+
+<?= $this->endSection()
+?>
diff --git a/app/Views/admin/user/create.php b/app/Views/admin/user/create.php
new file mode 100644
index 0000000000..82bfe99d25
--- /dev/null
+++ b/app/Views/admin/user/create.php
@@ -0,0 +1,38 @@
+<?= $this->extend('admin/_layout') ?>
+
+<?= $this->section('content') ?>
+
+<h1 class="mb-6 text-xl"><?= lang('User.create') ?></h1>
+
+<div class="mb-8">
+     <?= \Config\Services::validation()->listErrors() ?>
+</div>
+
+<form action="<?= route_to(
+    'user_create'
+) ?>" method="post" class="flex flex-col max-w-lg">
+    <?= csrf_field() ?>
+
+    <label for="email"><?= lang('User.form.email') ?></label>
+    <input type="email" class="mb-4 form-input" name="email" id="email" value="<?= old(
+        'email'
+    ) ?>">
+
+    <label for="username"><?= lang('User.form.username') ?></label>
+    <input type="text" class="mb-4 form-input" name="username" id="username" value="<?= old(
+        'username'
+    ) ?>">
+
+    <label for="password"><?= lang('User.form.password') ?></label>
+    <input type="password" name="password" class="mb-4 form-input" id="password" autocomplete="off">
+
+    <label for="pass_confirm"><?= lang('User.form.repeat_password') ?></label>
+    <input type="password" name="pass_confirm" class="mb-6 form-input" id="pass_confirm" autocomplete="off">
+
+    <button type="submit" class="px-4 py-2 ml-auto border">
+        <?= lang('User.form.submit_create') ?>
+    </button>
+</form>
+
+<?= $this->endSection()
+?>
diff --git a/app/Views/admin/user/list.php b/app/Views/admin/user/list.php
new file mode 100644
index 0000000000..a0d121213b
--- /dev/null
+++ b/app/Views/admin/user/list.php
@@ -0,0 +1,61 @@
+<?= $this->extend('admin/_layout') ?>
+
+<?= $this->section('content') ?>
+
+<div class="flex flex-wrap">
+    <?php if ($all_users): ?>
+        <table class="table-auto">
+            <thead>
+                <tr>
+                    <th class="px-4 py-2">Username</th>
+                    <th class="px-4 py-2">Email</th>
+                    <th class="px-4 py-2">Permissions</th>
+                    <th class="px-4 py-2">Banned?</th>
+                    <th class="px-4 py-2">Actions</th>
+                </tr>
+            </thead>
+            <tbody>
+                <?php foreach ($all_users as $user): ?>
+                <tr>
+                    <td class="px-4 py-2 border"><?= $user->username ?></td>
+                    <td class="px-4 py-2 border"><?= $user->email ?></td>
+                    <td class="px-4 py-2 border">[<?= implode(
+                        ', ',
+                        $user->permissions
+                    ) ?>]</td>
+                    <td class="px-4 py-2 border"><?= $user->isBanned()
+                        ? 'Yes'
+                        : 'No' ?></td>
+                    <td class="px-4 py-2 border">
+                        <a class="inline-flex px-2 py-1 mb-2 text-sm text-white bg-teal-700 hover:bg-teal-800" href="<?= route_to(
+                            'user_force_pass_reset',
+                            $user->username
+                        ) ?>"><?= lang('User.forcePassReset') ?></a>
+                        <a class="inline-flex px-2 py-1 mb-2 text-sm text-white bg-orange-700 hover:bg-orange-800" href="<?= route_to(
+                            $user->isBanned() ? 'user_unban' : 'user_ban',
+                            $user->username
+                        ) ?>">
+                        <?= $user->isBanned()
+                            ? lang('User.unban')
+                            : lang('User.ban') ?></a>
+                        <a class="inline-flex px-2 py-1 text-sm text-white bg-red-700 hover:bg-red-800" href="<?= route_to(
+                            'user_delete',
+                            $user->username
+                        ) ?>"><?= lang('User.delete') ?></a>
+                    </td>
+                </tr>
+                <?php endforeach; ?>
+            </tbody>
+        </table>
+    <?php else: ?>
+        <div class="flex items-center">
+            <p class="mr-4 italic"><?= lang('Podcast.no_podcast') ?></p>
+            <a class="self-start px-4 py-2 border hover:bg-gray-100 " href="<?= route_to(
+                'podcast_create'
+            ) ?>"><?= lang('Podcast.create_one') ?></a>
+        </div>
+    <?php endif; ?>
+</div>
+
+<?= $this->endSection()
+?>
diff --git a/app/Views/auth/_layout.php b/app/Views/auth/_layout.php
new file mode 100644
index 0000000000..24d4aff6b3
--- /dev/null
+++ b/app/Views/auth/_layout.php
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+	<meta charset="UTF-8">
+	<title>Castopod Auth</title>
+	<meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience.">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
+	<link rel="stylesheet" href="/index.css">
+</head>
+
+<body class="flex flex-col items-center justify-center min-h-screen mx-auto bg-gray-100">
+	<header class="mb-4">
+		<a href="<?= route_to('home') ?>" class="text-2xl"><?= $this->renderSection(
+    'title'
+) ?></a>
+	</header>
+	<main class="w-full max-w-md px-6 py-4 mx-auto bg-white rounded-lg shadow">
+		<div class="mb-4">
+			<?= view('_message_block') ?>
+		</div>
+		<?= $this->renderSection('content') ?>
+	</main>
+	<footer class="flex flex-col text-sm">
+		<?= $this->renderSection('footer') ?>
+		<p class="py-4 border-t">
+			Powered by <a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>, a <a class="underline hover:no-underline" href="https://podlibre.org/" target="_blank" rel="noreferrer noopener">Podlibre</a> initiative.
+		</p>
+	</footer>
+</body>
diff --git a/app/Views/auth/change_password.php b/app/Views/auth/change_password.php
new file mode 100644
index 0000000000..3ca545b6e7
--- /dev/null
+++ b/app/Views/auth/change_password.php
@@ -0,0 +1,29 @@
+<?= $this->extend($config->viewLayout) ?>
+
+<?= $this->section('title') ?>
+	<?= lang('Auth.resetYourPassword') ?>
+<?= $this->endSection() ?>
+
+
+<?= $this->section('content') ?>
+
+<form action="<?= route_to(
+    'change-password'
+) ?>" method="post" class="flex flex-col">
+    <?= csrf_field() ?>
+
+    <input type="hidden" name="token" value="<?= $token ?>">
+    <input type="hidden" name="email" value="<?= $email ?>">
+
+    <label for="password"><?= lang('Auth.newPassword') ?></label>
+    <input type="password" class="mb-4 form-input" name="password">
+
+    <label for="pass_confirm"><?= lang('Auth.newPasswordRepeat') ?></label>
+    <input type="password" class="mb-6 form-input" name="pass_confirm">
+
+    <button type="submit" class="px-4 py-2 ml-auto border">
+        <?= lang('Auth.resetPassword') ?>
+    </button>
+</form>
+
+<?= $this->endSection() ?>
diff --git a/app/Views/auth/emails/activation.php b/app/Views/auth/emails/activation.php
new file mode 100644
index 0000000000..d76eb17fcf
--- /dev/null
+++ b/app/Views/auth/emails/activation.php
@@ -0,0 +1,11 @@
+<p>This is activation email for your account on <?= base_url() ?>.</p>
+
+<p>To activate your account use this URL.</p>
+
+<p><a href="<?= base_url('activate-account') .
+    '?token=' .
+    $hash ?>">Activate account</a>.</p>
+
+<br>
+
+<p>If you did not registered on this website, you can safely ignore this email.</p>
\ No newline at end of file
diff --git a/app/Views/auth/emails/forgot.php b/app/Views/auth/emails/forgot.php
new file mode 100644
index 0000000000..f6509c83c1
--- /dev/null
+++ b/app/Views/auth/emails/forgot.php
@@ -0,0 +1,13 @@
+<p>Someone requested a password reset at this email address for <?= base_url() ?>.</p>
+
+<p>To reset the password use this code or URL and follow the instructions.</p>
+
+<p>Your Code: <?= $hash ?></p>
+
+<p>Visit the <a href="<?= base_url('reset-password') .
+    '?token=' .
+    $hash ?>">Reset Form</a>.</p>
+
+<br>
+
+<p>If you did not request a password reset, you can safely ignore this email.</p>
diff --git a/app/Views/auth/forgot.php b/app/Views/auth/forgot.php
new file mode 100644
index 0000000000..cef9a08805
--- /dev/null
+++ b/app/Views/auth/forgot.php
@@ -0,0 +1,25 @@
+<?= $this->extend($config->viewLayout) ?>
+
+<?= $this->section('title') ?>
+	<?= lang('Auth.forgotPassword') ?>
+<?= $this->endSection() ?>
+
+
+<?= $this->section('content') ?>
+
+<p class="mb-4"><?= lang('Auth.enterEmailForInstructions') ?></p>
+
+<form action="<?= route_to('forgot') ?>" method="post" class="flex flex-col">
+    <?= csrf_field() ?>
+
+    <label for="email"><?= lang('Auth.emailAddress') ?></label>
+    <input type="email" class="mb-6 form-input" name="email" placeholder="<?= lang(
+        'Auth.email'
+    ) ?>">
+
+    <button type="submit" class="px-4 py-2 ml-auto border">
+        <?= lang('Auth.sendInstructions') ?>
+    </button>
+</form>
+
+<?= $this->endSection() ?>
diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php
new file mode 100644
index 0000000000..2d7fcee16d
--- /dev/null
+++ b/app/Views/auth/login.php
@@ -0,0 +1,44 @@
+<?= $this->extend($config->viewLayout) ?>
+
+<?= $this->section('title') ?>
+	<?= lang('Auth.loginTitle') ?>
+<?= $this->endSection() ?>
+
+
+<?= $this->section('content') ?>
+
+<form action="<?= route_to('login') ?>" method="post" class="flex flex-col">
+	<?= csrf_field() ?>
+
+	<label for="login"><?= lang('Auth.emailOrUsername') ?></label>
+	<input type="text" name="login" class="mb-4 form-input" placeholder="<?= lang(
+     'Auth.emailOrUsername'
+ ) ?>">
+
+	<label for="password"><?= lang('Auth.password') ?></label>
+	<input type="password" name="password" class="mb-6 form-input" placeholder="<?= lang(
+     'Auth.password'
+ ) ?>">
+
+	<button type="submit" class="px-4 py-2 ml-auto border">
+		<?= lang('Auth.loginAction') ?>
+	</button>
+</form>
+
+<?= $this->endSection() ?>
+
+
+<?= $this->section('footer') ?>
+
+<div class="flex flex-col items-center py-4 text-sm text-center">
+	<?php if ($config->allowRegistration): ?>
+		<a class="underline hover:no-underline" href="<?= route_to(
+      'register'
+  ) ?>"><?= lang('Auth.needAnAccount') ?></a>
+	<?php endif; ?>
+	<a class="underline hover:no-underline" href="<?= route_to(
+     'forgot'
+ ) ?>"><?= lang('Auth.forgotYourPassword') ?></a>
+</div>
+
+<?= $this->endSection() ?>
diff --git a/app/Views/auth/register.php b/app/Views/auth/register.php
new file mode 100644
index 0000000000..571aff38fc
--- /dev/null
+++ b/app/Views/auth/register.php
@@ -0,0 +1,54 @@
+<?= $this->extend($config->viewLayout) ?>
+
+<?= $this->section('title') ?>
+	<?= lang('Auth.register') ?>
+<?= $this->endSection() ?>
+
+
+<?= $this->section('content') ?>
+
+<form action="<?= route_to('register') ?>" method="post" class="flex flex-col">
+    <?= csrf_field() ?>
+
+    <label for="email"><?= lang('Auth.email') ?></label>
+    <input type="email" class="mb-4 form-input" name="email" aria-describedby="emailHelp" placeholder="<?= lang(
+        'Auth.email'
+    ) ?>" value="<?= old('email') ?>">
+    <small id="emailHelp" class="mb-4">
+        <?= lang('Auth.weNeverShare') ?>
+    </small>
+
+    <label for="username"><?= lang('Auth.username') ?></label>
+    <input type="text" class="mb-4 form-input" name="username" placeholder="<?= lang(
+        'Auth.username'
+    ) ?>" value="<?= old('username') ?>">
+
+    <label for="password"><?= lang('Auth.password') ?></label>
+    <input type="password" name="password" class="mb-4 form-input" placeholder="<?= lang(
+        'Auth.password'
+    ) ?>" autocomplete="off">
+
+    <label for="pass_confirm"><?= lang('Auth.repeatPassword') ?></label>
+    <input type="password" name="pass_confirm" class="mb-6 form-input" placeholder="<?= lang(
+        'Auth.repeatPassword'
+    ) ?>" autocomplete="off">
+
+    <button type="submit" class="px-4 py-2 ml-auto border">
+        <?= lang('Auth.register') ?>
+    </button>
+</form>
+
+<?= $this->endSection() ?>
+
+
+<?= $this->section('footer') ?>
+
+<p class="py-4 text-sm text-center">
+    <?= lang(
+        'Auth.alreadyRegistered'
+    ) ?> <a class="underline hover:no-underline" href="<?= route_to(
+     'login'
+ ) ?>"><?= lang('Auth.signIn') ?></a>
+</p>
+
+<?= $this->endSection() ?>
diff --git a/app/Views/auth/reset.php b/app/Views/auth/reset.php
new file mode 100644
index 0000000000..d2bfbce9b4
--- /dev/null
+++ b/app/Views/auth/reset.php
@@ -0,0 +1,38 @@
+<?= $this->extend($config->viewLayout) ?>
+
+<?= $this->section('title') ?>
+    <?= lang('Auth.resetYourPassword') ?>
+<?= $this->endSection() ?>
+
+
+<?= $this->section('content') ?>
+
+<p class="mb-4"><?= lang('Auth.enterCodeEmailPassword') ?></p>
+
+<form action="<?= route_to(
+    'reset-password'
+) ?>" method="post" class="flex flex-col">
+    <?= csrf_field() ?>
+
+    <label for="token"><?= lang('Auth.token') ?></label>
+    <input type="text" class="mb-4 form-input" name="token" placeholder="<?= lang(
+        'Auth.token'
+    ) ?>" value="<?= old('token', $token ?? '') ?>">
+
+    <label for="email"><?= lang('Auth.email') ?></label>
+    <input type="email" class="mb-4 form-input" name="email" placeholder="<?= lang(
+        'Auth.email'
+    ) ?>" value="<?= old('email') ?>">
+
+    <label for="password"><?= lang('Auth.newPassword') ?></label>
+    <input type="password" class="mb-4 form-input" name="password">
+
+    <label for="pass_confirm"><?= lang('Auth.newPasswordRepeat') ?></label>
+    <input type="password" class="mb-6 form-input" name="pass_confirm">
+
+    <button type="submit" class="px-4 py-2 ml-auto border">
+        <?= lang('Auth.resetPassword') ?>
+    </button>
+</form>
+
+<?= $this->endSection() ?>
diff --git a/app/Views/episode/view.php b/app/Views/episode.php
similarity index 51%
rename from app/Views/episode/view.php
rename to app/Views/episode.php
index f0732d64fa..aea7ddd9cb 100644
--- a/app/Views/episode/view.php
+++ b/app/Views/episode.php
@@ -1,9 +1,9 @@
-<?= $this->extend('layouts/default') ?>
+<?= $this->extend('_layout') ?>
 
 <?= $this->section('content') ?>
 
 <a class="underline hover:no-underline" href="<?= route_to(
-    'podcast_view',
+    'podcast',
     $podcast->name
 ) ?>">< <?= lang('Episode.back_to_podcast') ?></a>
 <h1 class="text-2xl font-semibold"><?= $episode->title ?></h1>
@@ -13,18 +13,5 @@
   Your browser does not support the audio tag.
 </audio>
 
-<a class="inline-flex px-4 py-2 text-white bg-teal-700 hover:bg-teal-800" href="<?= route_to(
-    'episode_edit',
-    $podcast->name,
-    $episode->slug
-) ?>"><?= lang('Episode.edit') ?></a>
-<a href="<?= route_to(
-    'episode_delete',
-    $podcast->name,
-    $episode->slug
-) ?>" class="inline-flex px-4 py-2 text-white bg-red-700 hover:bg-red-800"><?= lang(
-    'Episode.delete'
-) ?></a>
-
-
-<?= $this->endSection() ?>
+<?= $this->endSection()
+?>
diff --git a/app/Views/home.php b/app/Views/home.php
index 5cb1d1846d..1cd0048d96 100644
--- a/app/Views/home.php
+++ b/app/Views/home.php
@@ -1,4 +1,4 @@
-<?= $this->extend('layouts/default') ?>
+<?= $this->extend('_layout') ?>
 
 <?= $this->section('content') ?>
 
@@ -8,7 +8,7 @@
 <section class="flex flex-wrap">
     <?php if ($podcasts): ?>
         <?php foreach ($podcasts as $podcast): ?>
-            <a href="<?= route_to('podcast_view', $podcast->name) ?>">
+            <a href="<?= route_to('podcast', $podcast->name) ?>">
                 <article class="w-48 h-full p-2 mb-4 mr-4 border shadow-sm hover:bg-gray-100 hover:shadow">
                     <img alt="<?= $podcast->title ?>" src="<?= $podcast->image_url ?>" class="object-cover w-full h-40 mb-2" />
                     <h2 class="font-semibold leading-tight"><?= $podcast->title ?></h2>
@@ -21,4 +21,5 @@
     <?php endif; ?>
 </section>
 
-<?= $this->endSection() ?>
+<?= $this->endSection()
+?>
diff --git a/app/Views/podcast/view.php b/app/Views/podcast.php
similarity index 63%
rename from app/Views/podcast/view.php
rename to app/Views/podcast.php
index 8dda6411b1..b4ba052aa7 100644
--- a/app/Views/podcast/view.php
+++ b/app/Views/podcast.php
@@ -1,25 +1,13 @@
-<?= $this->extend('layouts/default') ?>
+<?= $this->extend('_layout') ?>
 
 <?= $this->section('content') ?>
 <header class="py-4 border-b">
     <h1 class="text-2xl"><?= $podcast->title ?></h1>
     <img src="<?= $podcast->image_url ?>" alt="Podcast cover" class="object-cover w-40 h-40 mb-6" />
-<a class="inline-flex px-4 py-2 border hover:bg-gray-100" href="<?= route_to(
-    'episode_create',
-    $podcast->name
-) ?>"><?= lang('Podcast.new_episode') ?></a>
-<a class="inline-flex px-4 py-2 bg-orange-500 hover:bg-orange-600" href="<?= route_to(
-    'podcast_feed',
-    $podcast->name
-) ?>"><?= lang('Podcast.feed') ?></a>
-<a class="inline-flex px-4 py-2 text-white bg-teal-700 hover:bg-teal-800" href="<?= route_to(
-    'podcast_edit',
-    $podcast->name
-) ?>"><?= lang('Podcast.edit') ?></a>
-<a class="inline-flex px-4 py-2 text-white bg-red-700 hover:bg-red-800" href="<?= route_to(
-    'podcast_delete',
-    $podcast->name
-) ?>"><?= lang('Podcast.delete') ?></a>
+    <a class="inline-flex px-4 py-2 bg-orange-500 hover:bg-orange-600" href="<?= route_to(
+        'podcast_feed',
+        $podcast->name
+    ) ?>"><?= lang('Podcast.feed') ?></a>
 </header>
 
 <section class="flex flex-col py-4">
@@ -31,11 +19,7 @@
             <article class="flex w-full max-w-lg p-4 mb-4 border shadow">
                 <img src="<?= $episode->image_url ?>" alt="<?= $episode->title ?>" class="object-cover w-32 h-32 mr-4" />
                 <div class="flex flex-col flex-1">
-                    <a href="<?= route_to(
-                        'episode_view',
-                        $podcast->name,
-                        $episode->slug
-                    ) ?>">
+                    <a href="<?= $episode->link ?>">
                         <h3 class="text-xl font-semibold">
                             <span class="mr-1 underline hover:no-underline"><?= $episode->title ?></span>
                             <span class="text-base font-bold text-gray-600">#<?= $episode->number ?></span>
diff --git a/composer.json b/composer.json
index 15ed68bb90..3c0f646cd0 100644
--- a/composer.json
+++ b/composer.json
@@ -9,7 +9,8 @@
     "codeigniter4/framework": "^4",
     "james-heinrich/getid3": "~2.0.0-dev",
     "whichbrowser/parser": "^2.0",
-    "geoip2/geoip2": "~2.0"
+    "geoip2/geoip2": "~2.0",
+    "myth/auth": "1.0-beta.2"
   },
   "require-dev": {
     "mikey179/vfsstream": "1.6.*",
diff --git a/composer.lock b/composer.lock
index 19b6d1e112..16d8fd9a7c 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "8db0ba517a2c2b9718293a386c05c746",
+    "content-hash": "a03d5be6665057254fa301cada96586e",
     "packages": [
         {
             "name": "codeigniter4/framework",
@@ -533,6 +533,56 @@
             "homepage": "https://github.com/maxmind/web-service-common-php",
             "time": "2020-05-06T14:07:26+00:00"
         },
+        {
+            "name": "myth/auth",
+            "version": "1.0-beta.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/lonnieezell/myth-auth.git",
+                "reference": "b110088785ba22a82264e1df444621f3e1618f95"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/lonnieezell/myth-auth/zipball/b110088785ba22a82264e1df444621f3e1618f95",
+                "reference": "b110088785ba22a82264e1df444621f3e1618f95",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "require-dev": {
+                "codeigniter4/codeigniter4": "dev-develop",
+                "fzaninotto/faker": "^1.9@dev",
+                "mockery/mockery": "^1.0",
+                "phpunit/phpunit": "^7.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Myth\\Auth\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Lonnie Ezell",
+                    "email": "lonnieje@gmail.com",
+                    "homepage": "http://newmythmedia.com",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Flexible authentication/authorization system for CodeIgniter 4.",
+            "homepage": "https://github.com/lonnieezell/myth-auth",
+            "keywords": [
+                "Authentication",
+                "authorization",
+                "codeigniter"
+            ],
+            "time": "2019-12-12T05:12:25+00:00"
+        },
         {
             "name": "psr/cache",
             "version": "1.0.1",
@@ -805,20 +855,20 @@
         },
         {
             "name": "myclabs/deep-copy",
-            "version": "1.9.5",
+            "version": "1.10.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/myclabs/DeepCopy.git",
-                "reference": "b2c28789e80a97badd14145fda39b545d83ca3ef"
+                "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/b2c28789e80a97badd14145fda39b545d83ca3ef",
-                "reference": "b2c28789e80a97badd14145fda39b545d83ca3ef",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
+                "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1"
+                "php": "^7.1 || ^8.0"
             },
             "replace": {
                 "myclabs/deep-copy": "self.version"
@@ -849,7 +899,13 @@
                 "object",
                 "object graph"
             ],
-            "time": "2020-01-17T21:11:47+00:00"
+            "funding": [
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-06-29T13:22:24+00:00"
         },
         {
             "name": "phar-io/manifest",
@@ -955,25 +1011,25 @@
         },
         {
             "name": "phpdocumentor/reflection-common",
-            "version": "2.1.0",
+            "version": "2.2.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
-                "reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b"
+                "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/6568f4687e5b41b054365f9ae03fcb1ed5f2069b",
-                "reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+                "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1"
+                "php": "^7.2 || ^8.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.x-dev"
+                    "dev-2.x": "2.x-dev"
                 }
             },
             "autoload": {
@@ -1000,7 +1056,7 @@
                 "reflection",
                 "static analysis"
             ],
-            "time": "2020-04-27T09:25:28+00:00"
+            "time": "2020-06-27T09:03:43+00:00"
         },
         {
             "name": "phpdocumentor/reflection-docblock",
@@ -1057,25 +1113,24 @@
         },
         {
             "name": "phpdocumentor/type-resolver",
-            "version": "1.2.0",
+            "version": "1.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/TypeResolver.git",
-                "reference": "30441f2752e493c639526b215ed81d54f369d693"
+                "reference": "e878a14a65245fbe78f8080eba03b47c3b705651"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/30441f2752e493c639526b215ed81d54f369d693",
-                "reference": "30441f2752e493c639526b215ed81d54f369d693",
+                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/e878a14a65245fbe78f8080eba03b47c3b705651",
+                "reference": "e878a14a65245fbe78f8080eba03b47c3b705651",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.2",
+                "php": "^7.2 || ^8.0",
                 "phpdocumentor/reflection-common": "^2.0"
             },
             "require-dev": {
-                "ext-tokenizer": "^7.2",
-                "mockery/mockery": "~1"
+                "ext-tokenizer": "*"
             },
             "type": "library",
             "extra": {
@@ -1099,7 +1154,7 @@
                 }
             ],
             "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
-            "time": "2020-06-19T20:22:09+00:00"
+            "time": "2020-06-27T10:12:23+00:00"
         },
         {
             "name": "phpspec/prophecy",
@@ -2293,7 +2348,8 @@
     "aliases": [],
     "minimum-stability": "stable",
     "stability-flags": {
-        "james-heinrich/getid3": 20
+        "james-heinrich/getid3": 20,
+        "myth/auth": 10
     },
     "prefer-stable": false,
     "prefer-lowest": false,
diff --git a/docs/setup-development.md b/docs/setup-development.md
index a2673ae716..554f2263cf 100644
--- a/docs/setup-development.md
+++ b/docs/setup-development.md
@@ -98,7 +98,7 @@ Build the database with the migrate command:
 
 ```bash
 # loads the database schema during first migration
-docker-compose run --rm app php spark migrate
+docker-compose run --rm app php spark migrate -all
 ```
 
 Populate the database with the required data:
-- 
GitLab