diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index fbd4eb36dabad02ea4ff83c0302048b29bbbe5b5..afed9ca16a4b40091a20cc159b468d289163a305 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -29,6 +29,7 @@
     "bmewburn.vscode-intelephense-client",
     "bradlc.vscode-tailwindcss",
     "breezelin.phpstan",
+    "DavidAnson.vscode-markdownlint",
     "dbaeumer.vscode-eslint",
     "eamodio.gitlens",
     "esbenp.prettier-vscode",
@@ -41,6 +42,7 @@
     "runem.lit-plugin",
     "streetsidesoftware.code-spell-checker",
     "stylelint.vscode-stylelint",
-    "wayou.vscode-todo-highlight"
+    "wayou.vscode-todo-highlight",
+    "yzhang.markdown-all-in-one"
   ]
 }
diff --git a/app/Common.php b/app/Common.php
index 1f3b17c2ab3af27e73c9ffe5fb0f210f2e59155b..6876d96818887d4e18b054bf97e38cbac8750a85 100644
--- a/app/Common.php
+++ b/app/Common.php
@@ -29,6 +29,10 @@ if (! function_exists('view')) {
      */
     function view(string $name, array $data = [], array $options = []): string
     {
+        if (array_key_exists('theme', $options)) {
+            Theme::setTheme($options['theme']);
+        }
+
         $path = Theme::path();
 
         /** @var CodeIgniter\View\View $renderer */
@@ -55,6 +59,8 @@ if (! function_exists('lang')) {
      *
      * @param array<int|string, string> $args
      *
+     * TODO: remove, and escape args when necessary
+     *
      * @return string|string[]
      */
     function lang(string $line, array $args = [], ?string $locale = null, bool $escape = true): string | array
diff --git a/app/Config/Email.php b/app/Config/Email.php
index 2f651dcaf2e5b57be7804e6c69358d1c067df362..c8713ee8c1997812836915723de8e9817c601aeb 100644
--- a/app/Config/Email.php
+++ b/app/Config/Email.php
@@ -77,7 +77,7 @@ class Email extends BaseConfig
     /**
      * Type of mail, either 'text' or 'html'
      */
-    public string $mailType = 'text';
+    public string $mailType = 'html';
 
     /**
      * Character set (utf-8, iso-8859-1, etc.)
diff --git a/app/Config/Events.php b/app/Config/Events.php
index 004e14533ba8c57bfde7961752d75259a12f5697..07332c3d9ef5e10c109c3274bbc286156021f6d5 100644
--- a/app/Config/Events.php
+++ b/app/Config/Events.php
@@ -9,7 +9,6 @@ use App\Entities\Post;
 use App\Models\EpisodeModel;
 use CodeIgniter\Events\Events;
 use CodeIgniter\Exceptions\FrameworkException;
-use Modules\Auth\Entities\User;
 
 /*
  * --------------------------------------------------------------------
@@ -56,21 +55,6 @@ Events::on('pre_system', static function () {
     }
 });
 
-Events::on('login', static function (User $user): void {
-    helper('auth');
-    // set interact_as_actor_id value
-    $userPodcasts = $user->podcasts;
-    if ($userPodcasts = $user->podcasts) {
-        set_interact_as_actor($userPodcasts[0]->actor_id);
-    }
-});
-
-Events::on('logout', static function (User $user): void {
-    helper('auth');
-    // remove user's interact_as_actor session
-    remove_interact_as_actor();
-});
-
 /*
  * --------------------------------------------------------------------
  * Fediverse events
diff --git a/app/Config/Filters.php b/app/Config/Filters.php
index 54fa52b3b44aca751f9f40c983c0e0889507dc25..fc7064929be770e15b3d006380913a8de467c0ef 100644
--- a/app/Config/Filters.php
+++ b/app/Config/Filters.php
@@ -15,8 +15,6 @@ use Modules\Auth\Filters\PermissionFilter;
 use Modules\Fediverse\Filters\AllowCorsFilter;
 use Modules\Fediverse\Filters\FediverseFilter;
 use Modules\PremiumPodcasts\Filters\PodcastUnlockFilter;
-use Myth\Auth\Filters\LoginFilter;
-use Myth\Auth\Filters\RoleFilter;
 
 class Filters extends BaseConfig
 {
@@ -31,8 +29,6 @@ class Filters extends BaseConfig
         'honeypot' => Honeypot::class,
         'invalidchars' => InvalidChars::class,
         'secureheaders' => SecureHeaders::class,
-        'login' => LoginFilter::class,
-        'role' => RoleFilter::class,
         'permission' => PermissionFilter::class,
         'fediverse' => FediverseFilter::class,
         'allow-cors' => AllowCorsFilter::class,
@@ -86,7 +82,7 @@ class Filters extends BaseConfig
         parent::__construct();
 
         $this->filters = [
-            'login' => [
+            'session' => [
                 'before' => [config('Admin')->gateway . '*', config('Analytics')->gateway . '*'],
             ],
             'podcast-unlock' => [
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 01530037374dd01113d099a877f4ace0c6f1de31..803c062579f3511d8cc000cf133d022058831e79 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -214,7 +214,7 @@ $routes->get('/pages/(:slug)', 'PageController/$1', [
 $routes->group('@(:podcastHandle)', static function ($routes): void {
     $routes->post('posts/new', 'PostController::attemptCreate/$1', [
         'as' => 'post-attempt-create',
-        'filter' => 'permission:podcast-manage_publications',
+        'filter' => 'permission:podcast#.manage-publications',
     ]);
     // Post
     $routes->group('posts/(:uuid)', static function ($routes): void {
@@ -251,14 +251,14 @@ $routes->group('@(:podcastHandle)', static function ($routes): void {
         // Actions
         $routes->post('action', 'PostController::attemptAction/$1/$2', [
             'as' => 'post-attempt-action',
-            'filter' => 'permission:podcast-interact_as',
+            'filter' => 'permission:podcast#.interact-as',
         ]);
         $routes->post(
             'block-actor',
             'PostController::attemptBlockActor/$1/$2',
             [
                 'as' => 'post-attempt-block-actor',
-                'filter' => 'permission:fediverse-block_actors',
+                'filter' => 'permission:fediverse.manage-blocks',
             ],
         );
         $routes->post(
@@ -266,12 +266,12 @@ $routes->group('@(:podcastHandle)', static function ($routes): void {
             'PostController::attemptBlockDomain/$1/$2',
             [
                 'as' => 'post-attempt-block-domain',
-                'filter' => 'permission:fediverse-block_domains',
+                'filter' => 'permission:fediverse.manage-blocks',
             ],
         );
         $routes->post('delete', 'PostController::attemptDelete/$1/$2', [
             'as' => 'post-attempt-delete',
-            'filter' => 'permission:podcast-manage_publications',
+            'filter' => 'permission:podcast#.manage-publications',
         ]);
         $routes->get(
             'remote/(:postAction)',
diff --git a/app/Config/Security.php b/app/Config/Security.php
index 0b986a4167c6b3f19ee989d85ec91d5df5f3c6d8..29b51d2fbbb778dcb6186807ca190567323e1adc 100644
--- a/app/Config/Security.php
+++ b/app/Config/Security.php
@@ -17,7 +17,7 @@ class Security extends BaseConfig
      *
      * @var 'cookie'|'session'
      */
-    public string $csrfProtection = 'cookie';
+    public string $csrfProtection = 'session';
 
     /**
      * --------------------------------------------------------------------------
diff --git a/app/Config/Validation.php b/app/Config/Validation.php
index ae8d9e693fe937c9a3329cf3f1e4b396bd9666b8..d9157b1aa8865f9a8ee6d05e783f27ab21fe1c5c 100644
--- a/app/Config/Validation.php
+++ b/app/Config/Validation.php
@@ -11,7 +11,6 @@ use CodeIgniter\Validation\CreditCardRules;
 use CodeIgniter\Validation\FileRules;
 use CodeIgniter\Validation\FormatRules;
 use CodeIgniter\Validation\Rules;
-use Myth\Auth\Authentication\Passwords\ValidationRules as PasswordRules;
 
 class Validation extends BaseConfig
 {
@@ -27,7 +26,6 @@ class Validation extends BaseConfig
         CreditCardRules::class,
         AppRules::class,
         AppFileRules::class,
-        PasswordRules::class,
     ];
 
     /**
diff --git a/app/Controllers/ActorController.php b/app/Controllers/ActorController.php
index 9e2138e11712ad7d5e3ad2c8a62ad49171c5ebdc..3ac8547cf40b7ad2518df37c9ccefa4f1031249b 100644
--- a/app/Controllers/ActorController.php
+++ b/app/Controllers/ActorController.php
@@ -20,12 +20,12 @@ class ActorController extends FediverseActorController
     /**
      * @var string[]
      */
-    protected $helpers = ['auth', 'svg', 'components', 'misc', 'seo'];
+    protected $helpers = ['svg', 'components', 'misc', 'seo'];
 
     public function follow(): string
     {
         // Prevent analytics hit when authenticated
-        if (! can_user_interact()) {
+        if (! auth()->loggedIn()) {
             // @phpstan-ignore-next-line
             $this->registerPodcastWebpageHit($this->actor->podcast->id);
         }
diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php
index fd82bc085bb77838aeee51b08aa78699a317efcf..fba12dd86e95a47bae5fdec3b82fb891f012519a 100644
--- a/app/Controllers/BaseController.php
+++ b/app/Controllers/BaseController.php
@@ -34,5 +34,7 @@ abstract class BaseController extends Controller
         parent::initController($request, $response, $logger);
 
         Theme::setTheme('app');
+
+        $this->helpers = array_merge($this->helpers, ['setting']);
     }
 }
diff --git a/app/Controllers/CreditsController.php b/app/Controllers/CreditsController.php
index 7bfe5b844c68de8a599a53368d24e02b99398eaa..1d41afd23f1d2442b35192a6b9af90cd9f433163 100644
--- a/app/Controllers/CreditsController.php
+++ b/app/Controllers/CreditsController.php
@@ -23,7 +23,7 @@ class CreditsController extends BaseController
 
         $cacheName = implode(
             '_',
-            array_filter(['page', 'credits', $locale, can_user_interact() ? 'authenticated' : null]),
+            array_filter(['page', 'credits', $locale, auth()->loggedIn() ? 'authenticated' : null]),
         );
 
         if (! ($found = cache($cacheName))) {
diff --git a/app/Controllers/EpisodeCommentController.php b/app/Controllers/EpisodeCommentController.php
index b0617336886477ec2330ff6a84de71c0ed885940..9aaf2b5a7d305476b77ded931eab3cceb6582485 100644
--- a/app/Controllers/EpisodeCommentController.php
+++ b/app/Controllers/EpisodeCommentController.php
@@ -79,7 +79,7 @@ class EpisodeCommentController extends BaseController
     public function view(): string
     {
         // Prevent analytics hit when authenticated
-        if (! can_user_interact()) {
+        if (! auth()->loggedIn()) {
             $this->registerPodcastWebpageHit($this->podcast->id);
         }
 
@@ -91,7 +91,8 @@ class EpisodeCommentController extends BaseController
                 "comment#{$this->comment->id}",
                 service('request')
                     ->getLocale(),
-                can_user_interact() ? 'authenticated' : null,
+                auth()
+                    ->loggedIn() ? 'authenticated' : null,
             ]),
         );
 
@@ -105,7 +106,7 @@ class EpisodeCommentController extends BaseController
             ];
 
             // if user is logged in then send to the authenticated activity view
-            if (can_user_interact()) {
+            if (auth()->loggedIn()) {
                 helper('form');
                 return view('episode/comment', $data);
             }
diff --git a/app/Controllers/EpisodeController.php b/app/Controllers/EpisodeController.php
index 3d30b4e54c20c8fccf2da4c24d4c55e973ec184a..6b9c5cf01a95fc69d9ac2fc0d406959ccef78643 100644
--- a/app/Controllers/EpisodeController.php
+++ b/app/Controllers/EpisodeController.php
@@ -66,7 +66,7 @@ class EpisodeController extends BaseController
     public function index(): string
     {
         // Prevent analytics hit when authenticated
-        if (! can_user_interact()) {
+        if (! auth()->loggedIn()) {
             $this->registerPodcastWebpageHit($this->episode->podcast_id);
         }
 
@@ -79,7 +79,8 @@ class EpisodeController extends BaseController
                 service('request')
                     ->getLocale(),
                 is_unlocked($this->podcast->handle) ? 'unlocked' : null,
-                can_user_interact() ? 'authenticated' : null,
+                auth()
+                    ->loggedIn() ? 'authenticated' : null,
             ]),
         );
 
@@ -94,7 +95,7 @@ class EpisodeController extends BaseController
                 $this->podcast->id,
             );
 
-            if (can_user_interact()) {
+            if (auth()->loggedIn()) {
                 helper('form');
 
                 return view('episode/comments', $data);
@@ -115,7 +116,7 @@ class EpisodeController extends BaseController
     public function activity(): string
     {
         // Prevent analytics hit when authenticated
-        if (! can_user_interact()) {
+        if (! auth()->loggedIn()) {
             $this->registerPodcastWebpageHit($this->episode->podcast_id);
         }
 
@@ -129,7 +130,8 @@ class EpisodeController extends BaseController
                 service('request')
                     ->getLocale(),
                 is_unlocked($this->podcast->handle) ? 'unlocked' : null,
-                can_user_interact() ? 'authenticated' : null,
+                auth()
+                    ->loggedIn() ? 'authenticated' : null,
             ]),
         );
 
@@ -144,7 +146,7 @@ class EpisodeController extends BaseController
                 $this->podcast->id,
             );
 
-            if (can_user_interact()) {
+            if (auth()->loggedIn()) {
                 helper('form');
 
                 return view('episode/activity', $data);
@@ -167,7 +169,7 @@ class EpisodeController extends BaseController
         header('Content-Security-Policy: frame-ancestors http://*:* https://*:*');
 
         // Prevent analytics hit when authenticated
-        if (! can_user_interact()) {
+        if (! auth()->loggedIn()) {
             $this->registerPodcastWebpageHit($this->episode->podcast_id);
         }
 
diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php
index 724a274aad010a04b8a1b0ce0a0793cce09d36de..b2f28b55e626a02de841bad3294da004f1be8399 100644
--- a/app/Controllers/HomeController.php
+++ b/app/Controllers/HomeController.php
@@ -13,13 +13,20 @@ namespace App\Controllers;
 use App\Models\PodcastModel;
 use CodeIgniter\HTTP\RedirectResponse;
 use Config\Services;
+use Exception;
 
 class HomeController extends BaseController
 {
     public function index(): RedirectResponse | string
     {
-        $db = db_connect();
-        if ($db->getDatabase() === '' || ! $db->tableExists('podcasts')) {
+        $sortOptions = ['activity', 'created_desc', 'created_asc'];
+        $sortBy = in_array($this->request->getGet('sort'), $sortOptions, true) ? $this->request->getGet(
+            'sort'
+        ) : 'activity';
+
+        try {
+            $allPodcasts = (new PodcastModel())->getAllPodcasts($sortBy);
+        } catch (Exception) {
             // Database connection has not been set or could not find the podcasts table
             // Redirecting to install page because it is likely that Castopod has not been installed yet.
             // NB: as base_url wouldn't have been defined here, redirect to install wizard manually
@@ -27,13 +34,6 @@ class HomeController extends BaseController
             return redirect()->to(rtrim(host_url(), '/') . $route);
         }
 
-        $sortOptions = ['activity', 'created_desc', 'created_asc'];
-        $sortBy = in_array($this->request->getGet('sort'), $sortOptions, true) ? $this->request->getGet(
-            'sort'
-        ) : 'activity';
-
-        $allPodcasts = (new PodcastModel())->getAllPodcasts($sortBy);
-
         // check if there's only one podcast to redirect user to it
         if (count($allPodcasts) === 1) {
             return redirect()->route('podcast-activity', [$allPodcasts[0]->handle]);
diff --git a/app/Controllers/MapController.php b/app/Controllers/MapController.php
index 2a8c06b0805390dcc849b4d15d8c43c954516412..cf64312e6b68ee8bfeca2ec1ff96173dc7334ebb 100644
--- a/app/Controllers/MapController.php
+++ b/app/Controllers/MapController.php
@@ -24,7 +24,8 @@ class MapController extends BaseController
                 'map',
                 service('request')
                     ->getLocale(),
-                can_user_interact() ? 'authenticated' : null,
+                auth()
+                    ->loggedIn() ? 'authenticated' : null,
             ]),
         );
 
diff --git a/app/Controllers/PageController.php b/app/Controllers/PageController.php
index 7fc1424ab3918c6f9b3ae4f0f442fb1b3bb37d4d..27f71e959ec2d8e3a35f8cac7c55fbafa7dd4ce6 100644
--- a/app/Controllers/PageController.php
+++ b/app/Controllers/PageController.php
@@ -44,7 +44,8 @@ class PageController extends BaseController
                 $this->page->slug,
                 service('request')
                     ->getLocale(),
-                can_user_interact() ? 'authenticated' : null,
+                auth()
+                    ->loggedIn() ? 'authenticated' : null,
             ]),
         );
 
diff --git a/app/Controllers/PodcastController.php b/app/Controllers/PodcastController.php
index 4e2450f49e7fc2aaeea5a192b3828cfef47e8312..06377d4bd149f8a581935e56610cd7bf3fea3ea4 100644
--- a/app/Controllers/PodcastController.php
+++ b/app/Controllers/PodcastController.php
@@ -62,7 +62,7 @@ class PodcastController extends BaseController
     public function activity(): string
     {
         // Prevent analytics hit when authenticated
-        if (! can_user_interact()) {
+        if (! auth()->loggedIn()) {
             $this->registerPodcastWebpageHit($this->podcast->id);
         }
 
@@ -75,7 +75,8 @@ class PodcastController extends BaseController
                 service('request')
                     ->getLocale(),
                 is_unlocked($this->podcast->handle) ? 'unlocked' : null,
-                can_user_interact() ? 'authenticated' : null,
+                auth()
+                    ->loggedIn() ? 'authenticated' : null,
             ]),
         );
 
@@ -87,7 +88,7 @@ class PodcastController extends BaseController
             ];
 
             // if user is logged in then send to the authenticated activity view
-            if (can_user_interact()) {
+            if (auth()->loggedIn()) {
                 helper('form');
 
                 return view('podcast/activity', $data);
@@ -111,7 +112,7 @@ class PodcastController extends BaseController
     public function about(): string
     {
         // Prevent analytics hit when authenticated
-        if (! can_user_interact()) {
+        if (! auth()->loggedIn()) {
             $this->registerPodcastWebpageHit($this->podcast->id);
         }
 
@@ -124,7 +125,8 @@ class PodcastController extends BaseController
                 service('request')
                     ->getLocale(),
                 is_unlocked($this->podcast->handle) ? 'unlocked' : null,
-                can_user_interact() ? 'authenticated' : null,
+                auth()
+                    ->loggedIn() ? 'authenticated' : null,
             ]),
         );
 
@@ -138,7 +140,7 @@ class PodcastController extends BaseController
             ];
 
             // // if user is logged in then send to the authenticated activity view
-            if (can_user_interact()) {
+            if (auth()->loggedIn()) {
                 helper('form');
 
                 return view('podcast/about', $data);
@@ -162,7 +164,7 @@ class PodcastController extends BaseController
     public function episodes(): string
     {
         // Prevent analytics hit when authenticated
-        if (! can_user_interact()) {
+        if (! auth()->loggedIn()) {
             $this->registerPodcastWebpageHit($this->podcast->id);
         }
 
@@ -191,7 +193,8 @@ class PodcastController extends BaseController
                 service('request')
                     ->getLocale(),
                 is_unlocked($this->podcast->handle) ? 'unlocked' : null,
-                can_user_interact() ? 'authenticated' : null,
+                auth()
+                    ->loggedIn() ? 'authenticated' : null,
             ]),
         );
 
@@ -264,7 +267,7 @@ class PodcastController extends BaseController
                 ),
             ];
 
-            if (can_user_interact()) {
+            if (auth()->loggedIn()) {
                 return view('podcast/episodes', $data);
             }
 
diff --git a/app/Controllers/PostController.php b/app/Controllers/PostController.php
index 3db4ab275c52d90bc0dda12d5087917d909d9819..b2bf8e8d41b30d98ac315c775431288aaa3447ff 100644
--- a/app/Controllers/PostController.php
+++ b/app/Controllers/PostController.php
@@ -70,7 +70,7 @@ class PostController extends FediversePostController
     public function view(): string
     {
         // Prevent analytics hit when authenticated
-        if (! can_user_interact()) {
+        if (! auth()->loggedIn()) {
             $this->registerPodcastWebpageHit($this->podcast->id);
         }
 
@@ -85,7 +85,8 @@ class PostController extends FediversePostController
                 "post#{$this->post->id}",
                 service('request')
                     ->getLocale(),
-                can_user_interact() ? 'authenticated' : null,
+                auth()
+                    ->loggedIn() ? 'authenticated' : null,
             ]),
         );
 
@@ -97,7 +98,7 @@ class PostController extends FediversePostController
             ];
 
             // if user is logged in then send to the authenticated activity view
-            if (can_user_interact()) {
+            if (auth()->loggedIn()) {
                 helper('form');
                 return view('post/post', $data);
             }
@@ -239,7 +240,7 @@ class PostController extends FediversePostController
     public function remoteAction(string $action): string
     {
         // Prevent analytics hit when authenticated
-        if (! can_user_interact()) {
+        if (! auth()->loggedIn()) {
             $this->registerPodcastWebpageHit($this->podcast->id);
         }
 
diff --git a/app/Database/Migrations/2020-05-29-120000_add_media.php b/app/Database/Migrations/2021-05-29-120000_add_media.php
similarity index 96%
rename from app/Database/Migrations/2020-05-29-120000_add_media.php
rename to app/Database/Migrations/2021-05-29-120000_add_media.php
index 177827c5be66f96757c407b98666cb30a94e5ba0..732f51e2e13b39dec51d4f996f91a3c4df1d3fee 100644
--- a/app/Database/Migrations/2020-05-29-120000_add_media.php
+++ b/app/Database/Migrations/2021-05-29-120000_add_media.php
@@ -55,10 +55,12 @@ class AddMedia extends Migration
             ],
             'uploaded_by' => [
                 'type' => 'INT',
+                'constraint' => 11,
                 'unsigned' => true,
             ],
             'updated_by' => [
                 'type' => 'INT',
+                'constraint' => 11,
                 'unsigned' => true,
             ],
             'uploaded_at' => [
diff --git a/app/Database/Migrations/2020-05-29-152000_add_categories.php b/app/Database/Migrations/2021-05-29-152000_add_categories.php
similarity index 100%
rename from app/Database/Migrations/2020-05-29-152000_add_categories.php
rename to app/Database/Migrations/2021-05-29-152000_add_categories.php
diff --git a/app/Database/Migrations/2020-05-30-101000_add_languages.php b/app/Database/Migrations/2021-05-30-101000_add_languages.php
similarity index 100%
rename from app/Database/Migrations/2020-05-30-101000_add_languages.php
rename to app/Database/Migrations/2021-05-30-101000_add_languages.php
diff --git a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php b/app/Database/Migrations/2021-05-30-101500_add_podcasts.php
similarity index 100%
rename from app/Database/Migrations/2020-05-30-101500_add_podcasts.php
rename to app/Database/Migrations/2021-05-30-101500_add_podcasts.php
diff --git a/app/Database/Migrations/2020-06-05-170000_add_episodes.php b/app/Database/Migrations/2021-06-05-170000_add_episodes.php
similarity index 100%
rename from app/Database/Migrations/2020-06-05-170000_add_episodes.php
rename to app/Database/Migrations/2021-06-05-170000_add_episodes.php
diff --git a/app/Database/Migrations/2020-06-05-190000_add_platforms.php b/app/Database/Migrations/2021-06-05-190000_add_platforms.php
similarity index 100%
rename from app/Database/Migrations/2020-06-05-190000_add_platforms.php
rename to app/Database/Migrations/2021-06-05-190000_add_platforms.php
diff --git a/app/Database/Migrations/2020-06-05-200000_add_podcasts_platforms.php b/app/Database/Migrations/2021-06-05-200000_add_podcasts_platforms.php
similarity index 100%
rename from app/Database/Migrations/2020-06-05-200000_add_podcasts_platforms.php
rename to app/Database/Migrations/2021-06-05-200000_add_podcasts_platforms.php
diff --git a/app/Database/Migrations/2020-08-17-150000_add_pages.php b/app/Database/Migrations/2021-08-17-150000_add_pages.php
similarity index 100%
rename from app/Database/Migrations/2020-08-17-150000_add_pages.php
rename to app/Database/Migrations/2021-08-17-150000_add_pages.php
diff --git a/app/Database/Migrations/2020-09-29-150000_add_podcasts_categories.php b/app/Database/Migrations/2021-09-29-150000_add_podcasts_categories.php
similarity index 100%
rename from app/Database/Migrations/2020-09-29-150000_add_podcasts_categories.php
rename to app/Database/Migrations/2021-09-29-150000_add_podcasts_categories.php
diff --git a/app/Database/Migrations/2020-12-25-120000_add_persons.php b/app/Database/Migrations/2021-12-25-120000_add_persons.php
similarity index 100%
rename from app/Database/Migrations/2020-12-25-120000_add_persons.php
rename to app/Database/Migrations/2021-12-25-120000_add_persons.php
diff --git a/app/Database/Migrations/2020-12-25-130000_add_podcasts_persons.php b/app/Database/Migrations/2021-12-25-130000_add_podcasts_persons.php
similarity index 100%
rename from app/Database/Migrations/2020-12-25-130000_add_podcasts_persons.php
rename to app/Database/Migrations/2021-12-25-130000_add_podcasts_persons.php
diff --git a/app/Database/Migrations/2020-12-25-140000_add_episodes_persons.php b/app/Database/Migrations/2021-12-25-140000_add_episodes_persons.php
similarity index 100%
rename from app/Database/Migrations/2020-12-25-140000_add_episodes_persons.php
rename to app/Database/Migrations/2021-12-25-140000_add_episodes_persons.php
diff --git a/app/Database/Migrations/2020-12-25-150000_add_credits_view.php b/app/Database/Migrations/2021-12-25-150000_add_credits_view.php
similarity index 100%
rename from app/Database/Migrations/2020-12-25-150000_add_credits_view.php
rename to app/Database/Migrations/2021-12-25-150000_add_credits_view.php
diff --git a/app/Database/Migrations/2021-02-23-100000_add_episode_id_to_posts.php b/app/Database/Migrations/2022-02-23-100000_add_episode_id_to_posts.php
similarity index 100%
rename from app/Database/Migrations/2021-02-23-100000_add_episode_id_to_posts.php
rename to app/Database/Migrations/2022-02-23-100000_add_episode_id_to_posts.php
diff --git a/app/Database/Migrations/2021-03-09-113000_add_created_by_to_posts.php b/app/Database/Migrations/2022-03-09-113000_add_created_by_to_posts.php
similarity index 100%
rename from app/Database/Migrations/2021-03-09-113000_add_created_by_to_posts.php
rename to app/Database/Migrations/2022-03-09-113000_add_created_by_to_posts.php
diff --git a/app/Database/Seeds/AppSeeder.php b/app/Database/Seeds/AppSeeder.php
index 4be06109d211cb8bfe5383241aeac49062a6ef43..2c52a18de5bc43d4bc013b72f525485689597682 100644
--- a/app/Database/Seeds/AppSeeder.php
+++ b/app/Database/Seeds/AppSeeder.php
@@ -18,7 +18,6 @@ class AppSeeder extends Seeder
 {
     public function run(): void
     {
-        $this->call('AuthSeeder');
         $this->call('CategorySeeder');
         $this->call('LanguageSeeder');
         $this->call('PlatformSeeder');
diff --git a/app/Database/Seeds/AuthSeeder.php b/app/Database/Seeds/AuthSeeder.php
deleted file mode 100644
index 32181d1879c53f162e753fc39f9706e19d559ffe..0000000000000000000000000000000000000000
--- a/app/Database/Seeds/AuthSeeder.php
+++ /dev/null
@@ -1,328 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * Class PermissionSeeder Inserts permissions
- *
- * @copyright  2020 Ad Aures
- * @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 AuthSeeder extends Seeder
-{
-    /**
-     * @var array<string, string>[]
-     */
-    protected array $groups = [
-        [
-            'name' => 'superadmin',
-            'description' =>
-                'Somebody who has access to all the castopod instance features',
-        ],
-        [
-            'name' => 'podcast_admin',
-            'description' =>
-                'Somebody who has access to all the features within a given podcast',
-        ],
-    ];
-
-    /**
-     * Build permissions array as a list of:
-     *
-     * ``` context => [ [action, description], [action, description], ... ] ```
-     *
-     * @var array<string, array<string, string|string[]>[]>
-     */
-    protected array $permissions = [
-        'settings' => [
-            [
-                'name' => 'view',
-                'description' => 'View settings options',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'manage',
-                'description' => 'Update general settings',
-                'has_permission' => ['superadmin'],
-            ],
-        ],
-        'users' => [
-            [
-                'name' => 'create',
-                'description' => 'Create a user',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'list',
-                'description' => 'List all users',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'view',
-                'description' => 'View any user info',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'manage_authorizations',
-                'description' => 'Add or remove roles/permissions to a user',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'manage_bans',
-                'description' => 'Ban / unban a user',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'force_pass_reset',
-                'description' =>
-                    'Force a user to update his password upon next login',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'delete',
-                'description' =>
-                    'Delete user without removing him from database',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'delete_permanently',
-                'description' =>
-                    'Delete all occurrences of a user from the database',
-                'has_permission' => ['superadmin'],
-            ],
-        ],
-        'pages' => [
-            [
-                'name' => 'manage',
-                'description' => 'List / create / edit / delete pages',
-                'has_permission' => ['superadmin'],
-            ],
-        ],
-        'podcasts' => [
-            [
-                'name' => 'create',
-                'description' => 'Add a new podcast',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'import',
-                'description' => 'Import a new podcast from an external feed',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'list',
-                'description' => 'List all podcasts and their episodes',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'view',
-                'description' => 'View any podcast and their contributors list',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'delete',
-                'description' => 'Delete any podcast from the database',
-                'has_permission' => ['superadmin'],
-            ],
-        ],
-        'episodes' => [
-            [
-                'name' => 'list',
-                'description' => 'List all episodes of any podcast',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'view',
-                'description' => 'View any episode of any podcast',
-                'has_permission' => ['superadmin'],
-            ],
-        ],
-        'podcast' => [
-            [
-                'name' => 'view',
-                'description' => 'View a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'edit',
-                'description' => 'Edit a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'manage_subscriptions',
-                'description' =>
-                    'Add / edit / remove podcast subscriptions',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'manage_contributors',
-                'description' =>
-                    'Add / remove contributors to a podcast and edit their roles',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'manage_platforms',
-                'description' => 'Set / remove platform links of a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'manage_publications',
-                'description' =>
-                    'Publish a podcast and publish / unpublish its episodes & posts',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'interact_as',
-                'description' =>
-                    'Interact as the podcast to favourite / share or reply to posts.',
-                'has_permission' => ['podcast_admin'],
-            ],
-        ],
-        'podcast_episodes' => [
-            [
-                'name' => 'list',
-                'description' => 'List all episodes of a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'view',
-                'description' => 'View any episode of a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'create',
-                'description' => 'Add new episodes for a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'edit',
-                'description' => 'Edit an episode of a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'delete',
-                'description' =>
-                    'Delete all occurrences of an episode of a podcast from the database',
-                'has_permission' => ['podcast_admin'],
-            ],
-        ],
-        'person' => [
-            [
-                'name' => 'create',
-                'description' => 'Add a new person',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'list',
-                'description' => 'List all persons',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'view',
-                'description' => 'View any person',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'edit',
-                'description' => 'Edit a person',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'delete',
-                'description' =>
-                    'Delete permanently any person from the database',
-                'has_permission' => ['superadmin'],
-            ],
-        ],
-        'fediverse' => [
-            [
-                'name' => 'block_actors',
-                'description' =>
-                    'Block fediverse actors from interacting with the instance.',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'block_domains',
-                'description' =>
-                    'Block fediverse domains from interacting with the instance.',
-                'has_permission' => ['superadmin'],
-            ],
-        ],
-    ];
-
-    public function run(): void
-    {
-        $groupId = 0;
-        $dataGroups = [];
-        foreach ($this->groups as $group) {
-            $dataGroups[] = [
-                'id' => ++$groupId,
-                'name' => $group['name'],
-                'description' => $group['description'],
-            ];
-        }
-
-        // Map permissions to a format the `auth_permissions` table expects
-        $dataPermissions = [];
-        $dataGroupsPermissions = [];
-        $permissionId = 0;
-        foreach ($this->permissions as $context => $actions) {
-            foreach ($actions as $action) {
-                $dataPermissions[] = [
-                    'id' => ++$permissionId,
-                    'name' => $context . '-' . $action['name'],
-                    'description' => $action['description'],
-                ];
-
-                foreach ($action['has_permission'] as $role) {
-                    // link permission to specified groups
-                    $dataGroupsPermissions[] = [
-                        'group_id' => $this->getGroupIdByName($role, $dataGroups),
-                        'permission_id' => $permissionId,
-                    ];
-                }
-            }
-        }
-
-        if ($this->db->table('auth_groups')->countAll() < count($dataPermissions)) {
-            $this->db
-                ->table('auth_permissions')
-                ->ignore(true)
-                ->insertBatch($dataPermissions);
-        }
-
-        if ($this->db->table('auth_groups')->countAll() < count($dataGroups)) {
-            $this->db
-                ->table('auth_groups')
-                ->ignore(true)
-                ->insertBatch($dataGroups);
-        }
-
-        if ($this->db->table('auth_groups_permissions')->countAll() < count($dataGroupsPermissions)) {
-            $this->db
-                ->table('auth_groups_permissions')
-                ->ignore(true)
-                ->insertBatch($dataGroupsPermissions);
-        }
-    }
-
-    /**
-     * @param array<string, string|int>[] $dataGroups
-     */
-    public static function getGroupIdByName(string $name, array $dataGroups): ?int
-    {
-        foreach ($dataGroups as $group) {
-            if ($group['name'] === $name) {
-                return $group['id'];
-            }
-        }
-
-        return null;
-    }
-}
diff --git a/app/Database/Seeds/TestSeeder.php b/app/Database/Seeds/TestSeeder.php
index e37198db2b0abdbc707e7926cde471b174c6a6ae..57a52219a470234b264757853bf86ffcb4bdeb3a 100644
--- a/app/Database/Seeds/TestSeeder.php
+++ b/app/Database/Seeds/TestSeeder.php
@@ -18,24 +18,32 @@ class TestSeeder extends Seeder
 {
     public function run(): void
     {
+        helper('setting');
+
         /**
-         * Inserts an active user with the following credentials: username: admin password: AGUehL3P
+         * Inserts an owner with the following credentials: admin: `admin@example.com` password: `AGUehL3P`
          */
         $this->db->table('users')
             ->insert([
                 'id' => 1,
                 'username' => 'admin',
-                'email' => 'admin@example.com',
-                'password_hash' =>
-                    '$2y$10$TXJEHX/djW8jtzgpDVf7dOOCGo5rv1uqtAYWdwwwkttQcDkAeB2.6',
-                'active' => 1,
+                'is_owner' => 1,
+            ]);
+
+        $this->db->table('auth_identities')
+            ->insert([
+                'id' => 1,
+                'user_id' => 1,
+                'type' => 'email_password',
+                'secret' => 'admin@example.com',
+                'secret2' => '$2y$10$TXJEHX/djW8jtzgpDVf7dOOCGo5rv1uqtAYWdwwwkttQcDkAeB2.6',
             ]);
 
         $this->db
             ->table('auth_groups_users')
             ->insert([
-                'group_id' => 1,
                 'user_id' => 1,
+                'group' => setting('AuthGroups.mostPowerfulGroup'),
             ]);
     }
 }
diff --git a/app/Entities/Clip/BaseClip.php b/app/Entities/Clip/BaseClip.php
index efe88b4e614db2ac6d1da2e4730f1278cd7dfecc..51ccc0a56c3f39a52f54d601d20ec0be6219af7f 100644
--- a/app/Entities/Clip/BaseClip.php
+++ b/app/Entities/Clip/BaseClip.php
@@ -17,11 +17,11 @@ use App\Entities\Podcast;
 use App\Models\EpisodeModel;
 use App\Models\MediaModel;
 use App\Models\PodcastModel;
-use App\Models\UserModel;
 use CodeIgniter\Entity\Entity;
 use CodeIgniter\Files\File;
 use CodeIgniter\I18n\Time;
-use Modules\Auth\Entities\User;
+use CodeIgniter\Shield\Entities\User;
+use Modules\Auth\Models\UserModel;
 
 /**
  * @property int $id
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 22e5d3b17d75f561e79d1e2a4981e7007d52679c..efcdb88de08cf59f787fc3a20ab7e9ee0539ad3e 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -18,18 +18,18 @@ use App\Models\EpisodeModel;
 use App\Models\MediaModel;
 use App\Models\PersonModel;
 use App\Models\PlatformModel;
-use App\Models\UserModel;
 use CodeIgniter\Entity\Entity;
 use CodeIgniter\Files\File;
 use CodeIgniter\HTTP\Files\UploadedFile;
 use CodeIgniter\I18n\Time;
+use CodeIgniter\Shield\Entities\User;
 use League\CommonMark\Environment\Environment;
 use League\CommonMark\Extension\Autolink\AutolinkExtension;
 use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
 use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
 use League\CommonMark\Extension\SmartPunct\SmartPunctExtension;
 use League\CommonMark\MarkdownConverter;
-use Modules\Auth\Entities\User;
+use Modules\Auth\Models\UserModel;
 use Modules\PremiumPodcasts\Entities\Subscription;
 use Modules\PremiumPodcasts\Models\SubscriptionModel;
 use RuntimeException;
@@ -100,6 +100,8 @@ class Podcast extends Entity
 {
     protected string $link;
 
+    protected string $at_handle;
+
     protected ?Actor $actor = null;
 
     protected ?Image $cover = null;
@@ -208,6 +210,11 @@ class Podcast extends Entity
         'updated_by' => 'integer',
     ];
 
+    public function getAtHandle(): string
+    {
+        return '@' . $this->handle;
+    }
+
     /**
      * @noRector ReturnTypeDeclarationRector
      */
diff --git a/app/Helpers/auth_helper.php b/app/Helpers/auth_helper.php
deleted file mode 100644
index 3c1bc815d11a47748156effce6fd499e6ef0a1dc..0000000000000000000000000000000000000000
--- a/app/Helpers/auth_helper.php
+++ /dev/null
@@ -1,89 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-use App\Models\ActorModel;
-use Modules\Auth\Entities\User;
-use Modules\Fediverse\Entities\Actor;
-
-if (! function_exists('user')) {
-    /**
-     * Returns the User instance for the current logged in user.
-     */
-    function user(): ?User
-    {
-        $authenticate = service('authentication');
-        $authenticate->check();
-        return $authenticate->user();
-    }
-}
-
-if (! function_exists('set_interact_as_actor')) {
-    /**
-     * Sets the actor id of which the user is acting as
-     */
-    function set_interact_as_actor(int $actorId): void
-    {
-        $authenticate = service('authentication');
-        $authenticate->check();
-
-        $session = session();
-        $session->set('interact_as_actor_id', $actorId);
-    }
-}
-
-if (! function_exists('remove_interact_as_actor')) {
-    /**
-     * Removes the actor id of which the user is acting as
-     */
-    function remove_interact_as_actor(): void
-    {
-        $session = session();
-        $session->remove('interact_as_actor_id');
-    }
-}
-
-if (! function_exists('interact_as_actor_id')) {
-    /**
-     * Sets the podcast id of which the user is acting as
-     */
-    function interact_as_actor_id(): int
-    {
-        $authenticate = service('authentication');
-        $authenticate->check();
-
-        $session = session();
-        return $session->get('interact_as_actor_id');
-    }
-}
-
-if (! function_exists('interact_as_actor')) {
-    /**
-     * Get the actor the user is currently interacting as
-     */
-    function interact_as_actor(): Actor | false
-    {
-        $authenticate = service('authentication');
-        $authenticate->check();
-
-        $session = session();
-        if ($session->has('interact_as_actor_id')) {
-            return model(ActorModel::class, false)->getActorById($session->get('interact_as_actor_id'));
-        }
-
-        return false;
-    }
-}
-
-if (! function_exists('can_user_interact')) {
-    function can_user_interact(): bool
-    {
-        return (bool) interact_as_actor();
-    }
-}
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index 78d52672d00c2a1674ff09ede849ec2ba458d498..300ffdf3ba4c888a90bfedfaf987491794f61cee 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -11,7 +11,6 @@ declare(strict_types=1);
 namespace App\Models;
 
 use App\Entities\Podcast;
-use CodeIgniter\Database\Query;
 use CodeIgniter\HTTP\URI;
 use CodeIgniter\Model;
 use phpseclib\Crypt\RSA;
@@ -205,15 +204,14 @@ class PodcastModel extends Model
     /**
      * Gets all the podcasts a given user is contributing to
      *
+     * @param string[] $userPodcastIds
      * @return Podcast[] podcasts
      */
-    public function getUserPodcasts(int $userId): array
+    public function getUserPodcasts(int $userId, array $userPodcastIds): array
     {
         $cacheName = "user{$userId}_podcasts";
         if (! ($found = cache($cacheName))) {
-            $found = $this->select('podcasts.*')
-                ->join('podcasts_users', 'podcasts_users.podcast_id = podcasts.id')
-                ->where('podcasts_users.user_id', $userId)
+            $found = $userPodcastIds === [] ? [] : $this->whereIn('id', $userPodcastIds)
                 ->findAll();
 
             cache()
@@ -223,76 +221,18 @@ class PodcastModel extends Model
         return $found;
     }
 
-    public function addPodcastContributor(int $userId, int $podcastId, int $groupId): Query | bool
+    public function getContributorGroup(int $userId, int $podcastId): int | false
     {
-        cache()->delete("podcast#{$podcastId}_contributors");
-
-        $data = [
-            'user_id' => $userId,
-            'podcast_id' => $podcastId,
-            'group_id' => $groupId,
-        ];
-
-        return $this->db->table('podcasts_users')
-            ->insert($data);
-    }
-
-    public function updatePodcastContributor(int $userId, int $podcastId, int $groupId): bool
-    {
-        cache()->delete("podcast#{$podcastId}_contributors");
-
-        return $this->db
-            ->table('podcasts_users')
-            ->where([
-                'user_id' => $userId,
-                'podcast_id' => $podcastId,
-            ])
-            ->update([
-                'group_id' => $groupId,
-            ]);
-    }
-
-    public function removePodcastContributor(int $userId, int $podcastId): string | bool
-    {
-        cache()->delete("podcast#{$podcastId}_contributors");
-
-        return $this->db
-            ->table('podcasts_users')
-            ->where([
-                'user_id' => $userId,
-                'podcast_id' => $podcastId,
-            ])
-            ->delete();
-    }
-
-    public function getContributorGroupId(int $userId, int | string $podcastId): int | false
-    {
-        if (! is_numeric($podcastId)) {
-            // identifier is the podcast name, request must be a join
-            $userPodcast = $this->db
-                ->table('podcasts_users')
-                ->select('group_id, user_id')
-                ->join('podcasts', 'podcasts.id = podcasts_users.podcast_id')
-                ->where([
-                    'user_id' => $userId,
-                    'handle' => $podcastId,
-                ])
-                ->get()
-                ->getResultObject();
-        } else {
-            $userPodcast = $this->db
-                ->table('podcasts_users')
-                ->select('group_id')
-                ->where([
-                    'user_id' => $userId,
-                    'podcast_id' => $podcastId,
-                ])
-                ->get()
-                ->getResultObject();
-        }
+        $userPodcast = $this->db
+            ->table('auth_groups_users')
+            ->select('user_id, group')
+            ->where('user_id', $userId)
+            ->like('group', "podcast#{$podcastId}")
+            ->get()
+            ->getResultObject();
 
         return $userPodcast !== []
-            ? (int) $userPodcast[0]->group_id
+            ? (int) $userPodcast[0]->group
             : false;
     }
 
diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php
deleted file mode 100644
index 13685b89a3941c731eee0abe32b08bae72daae28..0000000000000000000000000000000000000000
--- a/app/Models/UserModel.php
+++ /dev/null
@@ -1,55 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-namespace App\Models;
-
-use Modules\Auth\Entities\User;
-use Myth\Auth\Models\UserModel as MythAuthUserModel;
-
-class UserModel extends MythAuthUserModel
-{
-    /**
-     * @var string
-     */
-    protected $returnType = User::class;
-
-    /**
-     * @return User[]
-     */
-    public function getPodcastContributors(int $podcastId): array
-    {
-        $cacheName = "podcast#{$podcastId}_contributors";
-        if (! ($found = cache($cacheName))) {
-            $found = $this->select('users.*, auth_groups.name as podcast_role')
-                ->join('podcasts_users', 'podcasts_users.user_id = users.id')
-                ->join('auth_groups', 'auth_groups.id = podcasts_users.group_id')
-                ->where('podcasts_users.podcast_id', $podcastId)
-                ->findAll();
-
-            cache()
-                ->save($cacheName, $found, DECADE);
-        }
-
-        return $found;
-    }
-
-    public function getPodcastContributor(int $userId, int $podcastId): ?User
-    {
-        // @phpstan-ignore-next-line
-        return $this->select('users.*, podcasts_users.podcast_id as podcast_id, auth_groups.name as podcast_role')
-            ->join('podcasts_users', 'podcasts_users.user_id = users.id')
-            ->join('auth_groups', 'auth_groups.id = podcasts_users.group_id')
-            ->where([
-                'users.id' => $userId,
-                'podcast_id' => $podcastId,
-            ])
-            ->first();
-    }
-}
diff --git a/app/Resources/icons/shield-user.svg b/app/Resources/icons/shield-user.svg
new file mode 100644
index 0000000000000000000000000000000000000000..37cf8289269513d599182e2fdd50e51c4daca374
--- /dev/null
+++ b/app/Resources/icons/shield-user.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+    <path fill="none" d="M0 0h24v24H0z"/>
+    <path d="M3.783 2.826L12 1l8.217 1.826a1 1 0 0 1 .783.976v9.987a6 6 0 0 1-2.672 4.992L12 23l-6.328-4.219A6 6 0 0 1 3 13.79V3.802a1 1 0 0 1 .783-.976zM12 11a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm-4.473 5h8.946a4.5 4.5 0 0 0-8.946 0z"/>
+</svg>
\ No newline at end of file
diff --git a/app/Views/errors/html/error_403.php b/app/Views/errors/html/error_403.php
new file mode 100644
index 0000000000000000000000000000000000000000..0a8d31b4348ab98b128741603042d524e46bf796
--- /dev/null
+++ b/app/Views/errors/html/error_403.php
@@ -0,0 +1,29 @@
+<?= helper(['components', 'svg']) ?>
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+
+    <title>403 Forbidden</title>
+    <link rel='stylesheet' type='text/css' href='<?= route_to('themes-colors-css') ?>' />
+    <?= service('vite')->asset('styles/index.css', 'css') ?>
+</head>
+
+<body class="flex flex-col items-center justify-center min-h-screen px-2 text-center bg-base theme-<?= service('settings')
+        ->get('App.theme') ?>">
+    <?= svg('castopod-mascot_confused', 'h-64') ?>
+    <h1 class="mt-4 text-3xl font-bold font-display md:text-4xl lg:text-5xl">403 - Forbidden</h1>
+
+    <p class="mb-6 text-lg text-skin-muted md:text-xl lg:text-2xl">
+        <?php if (isset($message) && $message !== '(null)'): ?>
+            <?= esc($message) ?>
+        <?php else: ?>
+            You do not have sufficient permissions to access that page.
+        <?php endif; ?>
+    </p>
+    <a href="<?= previous_url() ?>" class="inline-flex items-center justify-center px-3 py-1 text-sm font-semibold rounded-full shadow-xs text-accent-contrast focus:ring-accent md:px-4 md:py-2 md:text-base bg-accent-base hover:bg-accent-hover"><?= lang('Common.go_back') ?></a>
+</body>
+
+</html>
diff --git a/app/Views/errors/html/error_404.php b/app/Views/errors/html/error_404.php
index ed288ac1e06ad47422d613a0ba745328fe6a6ae8..bbf30689c6bece14be4a08d79d306a68a75aa14e 100644
--- a/app/Views/errors/html/error_404.php
+++ b/app/Views/errors/html/error_404.php
@@ -14,7 +14,7 @@
 <body class="flex flex-col items-center justify-center min-h-screen px-2 text-center bg-base theme-<?= service('settings')
         ->get('App.theme') ?>">
     <?= svg('castopod-mascot_confused', 'h-64') ?>
-    <h1 class="text-3xl font-bold font-display md:text-4xl lg:text-5xl">404 - File Not Found</h1>
+    <h1 class="mt-4 text-3xl font-bold font-display md:text-4xl lg:text-5xl">404 - File Not Found</h1>
 
     <p class="mb-6 text-lg text-skin-muted md:text-xl lg:text-2xl">
         <?php if (isset($message) && $message !== '(null)'): ?>
diff --git a/app/Views/errors/html/production.php b/app/Views/errors/html/production.php
index 1ddf8306eaaf749c6a1825e4ab6586a2545017d1..0b6bc13bbdcc73f8569f6c6a0ea3cba054cbd067 100644
--- a/app/Views/errors/html/production.php
+++ b/app/Views/errors/html/production.php
@@ -10,14 +10,14 @@
 	<title>Whoops!</title>
 	<link rel='stylesheet' type='text/css' href='<?= route_to('themes-colors-css') ?>' />
     <?= service('vite')->asset('styles/index.css', 'css') ?>
-	<?php if (service('authentication')->isLoggedIn()): ?>
+	<?php if (auth()->loggedIn()): ?>
 		<?= service('vite')->asset('js/error.ts', 'js') ?>
 	<?php endif; ?>
 </head>
 
 <body class="flex flex-col items-center justify-center min-h-screen px-4 bg-base gap-y-12 theme-<?= service('settings')
         ->get('App.theme') ?>">
-	<?php if (service('authentication')->isLoggedIn()): ?>
+	<?php if (auth()->loggedIn()): ?>
 	<div class="flex flex-col items-center justify-center flex-1 gap-6">
 		<div class="flex flex-col items-center">
 			<?= svg('castopod-mascot_confused', 'w-full max-w-xs p-6') ?>
diff --git a/composer.json b/composer.json
index 318e1240907c069ea90bb15ef172822d68b4d962..1519add37a14c078c0cdbf30d95a7ff267d731c9 100644
--- a/composer.json
+++ b/composer.json
@@ -11,7 +11,6 @@
     "james-heinrich/getid3": "^2.0.x-dev",
     "whichbrowser/parser": "^v2.1.7",
     "geoip2/geoip2": "v2.13.0",
-    "myth/auth": "dev-develop",
     "league/commonmark": "^2.3.5",
     "vlucas/phpdotenv": "^v5.4.1",
     "league/html-to-markdown": "^v5.1.0",
@@ -23,7 +22,8 @@
     "essence/essence": "^3.5.4",
     "codeigniter4/settings": "^v2.1.0",
     "chrisjean/php-ico": "^1.0.4",
-    "melbahja/seo": "^v2.1.1"
+    "melbahja/seo": "^v2.1.1",
+    "codeigniter4/shield": "dev-develop"
   },
   "require-dev": {
     "mikey179/vfsstream": "^v1.6.11",
diff --git a/composer.lock b/composer.lock
index 0eaf6f049ce1c6ccbd067958b9ab617634461dea..253d0e0827712e49353fbc658b740f0f612157d4 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": "caa3b9ff10584fe03c7be1176713b427",
+  "content-hash": "51482dcb24c719550a1f0aa7e7580dfc",
   "packages": [
     {
       "name": "adaures/ipcat-php",
@@ -286,6 +286,70 @@
       },
       "time": "2021-11-22T17:30:18+00:00"
     },
+    {
+      "name": "codeigniter4/shield",
+      "version": "dev-develop",
+      "source": {
+        "type": "git",
+        "url": "https://github.com/codeigniter4/shield.git",
+        "reference": "f4cdfb672b600a032a6f0bfc0b7735411bee0cae"
+      },
+      "dist": {
+        "type": "zip",
+        "url": "https://api.github.com/repos/codeigniter4/shield/zipball/f4cdfb672b600a032a6f0bfc0b7735411bee0cae",
+        "reference": "f4cdfb672b600a032a6f0bfc0b7735411bee0cae",
+        "shasum": ""
+      },
+      "require": {
+        "codeigniter4/settings": "^2.0",
+        "php": "^7.4.3 || ^8.0"
+      },
+      "provide": {
+        "codeigniter4/authentication-implementation": "1.0"
+      },
+      "require-dev": {
+        "codeigniter4/devkit": "^1.0",
+        "codeigniter4/framework": "^4.2.3",
+        "mockery/mockery": "^1.0"
+      },
+      "default-branch": true,
+      "type": "library",
+      "autoload": {
+        "files": [
+          "src/Helpers/auth_helper.php",
+          "src/Helpers/email_helper.php"
+        ],
+        "psr-4": {
+          "CodeIgniter\\Shield\\": "src"
+        },
+        "exclude-from-classmap": ["**/Database/Migrations/**"]
+      },
+      "notification-url": "https://packagist.org/downloads/",
+      "license": ["MIT"],
+      "authors": [
+        {
+          "name": "Lonnie Ezell",
+          "email": "lonnieje@gmail.com",
+          "role": "Developer"
+        }
+      ],
+      "description": "Authentication and Authorization for CodeIgniter 4",
+      "homepage": "https://github.com/codeigniter4/shield",
+      "keywords": [
+        "Authentication",
+        "authorization",
+        "codeigniter",
+        "codeigniter4"
+      ],
+      "support": {
+        "docs": "https://github.com/codeigniter4/shield/blob/develop/docs/index.md",
+        "forum": "https://github.com/codeigniter4/shield/discussions",
+        "issues": "https://github.com/codeigniter4/shield/issues",
+        "slack": "https://codeigniterchat.slack.com",
+        "source": "https://github.com/codeigniter4/shield"
+      },
+      "time": "2022-10-05T10:11:44+00:00"
+    },
     {
       "name": "composer/ca-bundle",
       "version": "1.3.4",
@@ -1367,73 +1431,6 @@
       },
       "time": "2021-05-10T16:28:01+00:00"
     },
-    {
-      "name": "myth/auth",
-      "version": "dev-develop",
-      "source": {
-        "type": "git",
-        "url": "https://github.com/lonnieezell/myth-auth.git",
-        "reference": "cc94231f5284e9578967aba4796f018809669c84"
-      },
-      "dist": {
-        "type": "zip",
-        "url": "https://api.github.com/repos/lonnieezell/myth-auth/zipball/cc94231f5284e9578967aba4796f018809669c84",
-        "reference": "cc94231f5284e9578967aba4796f018809669c84",
-        "shasum": ""
-      },
-      "require": {
-        "php": "^7.4 || ^8.0"
-      },
-      "provide": {
-        "codeigniter4/authentication-implementation": "1.0"
-      },
-      "require-dev": {
-        "codeigniter4/codeigniter4-standard": "^1.0",
-        "codeigniter4/devkit": "^1.0",
-        "codeigniter4/framework": "^4.1",
-        "mockery/mockery": "^1.0"
-      },
-      "default-branch": true,
-      "type": "library",
-      "autoload": {
-        "psr-4": {
-          "Myth\\Auth\\": "src"
-        },
-        "exclude-from-classmap": ["**/Database/Migrations/**"]
-      },
-      "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"],
-      "support": {
-        "issues": "https://github.com/lonnieezell/myth-auth/issues",
-        "source": "https://github.com/lonnieezell/myth-auth/tree/develop"
-      },
-      "funding": [
-        {
-          "url": "https://github.com/lonnieezell",
-          "type": "github"
-        },
-        {
-          "url": "https://github.com/mgatner",
-          "type": "github"
-        },
-        {
-          "url": "https://www.patreon.com/lonnieezell",
-          "type": "patreon"
-        }
-      ],
-      "time": "2022-08-01T17:23:52+00:00"
-    },
     {
       "name": "nette/schema",
       "version": "v1.2.2",
@@ -6684,8 +6681,8 @@
   "minimum-stability": "stable",
   "stability-flags": {
     "james-heinrich/getid3": 20,
-    "myth/auth": 20,
-    "michalsn/codeigniter4-uuid": 20
+    "michalsn/codeigniter4-uuid": 20,
+    "codeigniter4/shield": 20
   },
   "prefer-stable": true,
   "prefer-lowest": false,
diff --git a/docs/.gitlab-ci.yml b/docs/.gitlab-ci.yml
index d7f739a28a2cdccb35c2df867888f5f040a7820d..f9c992b7704e4829ba489c34d8153327abe79ef5 100644
--- a/docs/.gitlab-ci.yml
+++ b/docs/.gitlab-ci.yml
@@ -22,7 +22,6 @@ build:
   script:
     - npm run build
   except:
-    - develop
     - main
     - beta
     - alpha
@@ -40,7 +39,6 @@ build-production:
       - docs/.vitepress/dist
     expire_in: 30 mins
   only:
-    - develop
     - main
     - beta
     - alpha
@@ -72,7 +70,6 @@ deploy:
     - rsync -avzuh -e "ssh -p $SSH_PORT" $SOURCE_FOLDER $USER@$HOST:$TEMP_DIRECTORY --progress
     - ssh $USER@$HOST -p $SSH_PORT "rsync -rtv $TEMP_DIRECTORY $DIRECTORY"
   only:
-    - develop
     - main
     - beta
     - alpha
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index c76d83352397760b890d094a28f06e6e3648331d..f74cf504c5ffee9b0505366d82a2070bce9bd67a 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -179,6 +179,7 @@ function getGuideSidebarEn() {
         },
         { text: "Security", link: "/getting-started/security" },
         { text: "Update", link: "/getting-started/update" },
+        { text: "Auth", link: "/getting-started/auth" },
       ],
     },
     {
@@ -207,6 +208,7 @@ function getGuideSidebarFr() {
         },
         { text: "Sécurité", link: "/fr/getting-started/security" },
         { text: "Mise à jour", link: "/fr/getting-started/update" },
+        { text: "Authentification", link: "/fr/getting-started/auth" },
       ],
     },
     {
@@ -235,6 +237,7 @@ function getGuideSidebarPtBR() {
         },
         { text: "Segurança", link: "/pt-BR/getting-started/security" },
         { text: "Atualizar", link: "/pt-BR/getting-started/update" },
+        { text: "Autenticação", link: "/pt-BR/getting-started/auth" },
       ],
     },
     {
@@ -263,6 +266,7 @@ function getGuideSidebarNnNO() {
         },
         { text: "Sikkerhet", link: "/nn-NO/getting-started/security" },
         { text: "Oppdaterer", link: "/nn-NO/getting-started/update" },
+        { text: "Autentisering", link: "/pt-BR/getting-started/auth" },
       ],
     },
     {
diff --git a/docs/src/getting-started/auth.md b/docs/src/getting-started/auth.md
new file mode 100644
index 0000000000000000000000000000000000000000..00eeb819726f0e62f45e966b940623ee6dc46c35
--- /dev/null
+++ b/docs/src/getting-started/auth.md
@@ -0,0 +1,86 @@
+---
+title: Authentication & Authorization
+sidebarDepth: 3
+---
+
+# Authentication & Authorization
+
+Castopod handles authentication and authorization using `codeigniter/shield`
+coupled with custom rules. Roles and permissions are defined at two levels:
+
+1. [instance wide](#1-instance-wide-roles-and-permissions)
+2. [per podcast](#2-per-podcast-roles-and-permissions)
+
+## 1. Instance wide roles and permissions
+
+### Instance roles
+
+<!-- AUTH-INSTANCE-ROLES-LIST:START - Do not remove or modify this section -->
+
+| role        | description                         | permissions                                                                                |
+| ----------- | ----------------------------------- | ------------------------------------------------------------------------------------------ |
+| Super admin | Has complete control over Castopod. | admin.\*, podcasts.\*, users.manage, persons.manage, pages.manage, fediverse.manage-blocks |
+| Manager     | Manages Castopod's content.         | podcasts.create, podcasts.import, persons.manage, pages.manage                             |
+| Podcaster   | General users of Castopod.          | admin.access                                                                               |
+
+<!-- AUTH-INSTANCE-ROLES-LIST:END -->
+
+### Instance permissions
+
+<!-- AUTH-INSTANCE-PERMISSIONS-LIST:START - Do not remove or modify this section -->
+
+| permission              | description                                                        |
+| ----------------------- | ------------------------------------------------------------------ |
+| admin.access            | Can access the Castopod admin area.                                |
+| admin.settings          | Can access the Castopod settings.                                  |
+| users.manage            | Can manage Castopod users.                                         |
+| persons.manage          | Can manage persons.                                                |
+| pages.manage            | Can manage pages.                                                  |
+| podcasts.view           | Can view all podcasts.                                             |
+| podcasts.create         | Can create new podcasts.                                           |
+| podcasts.import         | Can import podcasts.                                               |
+| fediverse.manage-blocks | Can block fediverse actors/domains from interacting with Castopod. |
+
+<!-- AUTH-INSTANCE-PERMISSIONS-LIST:END -->
+
+## 2. Per podcast roles and permissions
+
+### Per podcast roles
+
+<!-- AUTH-PODCAST-ROLES-LIST:START - Do not remove or modify this section -->
+
+| role   | description                                               | permissions                                                                                                                                                                                                                                                           |
+| ------ | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Admin  | Has complete control of podcast #{id}.                    | \*                                                                                                                                                                                                                                                                    |
+| Editor | Manages content and publications of podcast #{id}.        | view, edit, manage-import, manage-persons, manage-platforms, manage-publications, interact-as, episodes.view, episodes.create, episodes.edit, episodes.delete, episodes.manage-persons, episodes.manage-clips, episodes.manage-publications, episodes.manage-comments |
+| Author | Manages content of podcast #{id} but cannot publish them. | view, manage-persons, episodes.view, episodes.create, episodes.edit, episodes.manage-persons, episodes.manage-clips                                                                                                                                                   |
+| Guest  | General contributor of the podcast #{id}.                 | view, episodes.view                                                                                                                                                                                                                                                   |
+
+<!-- AUTH-PODCAST-ROLES-LIST:END -->
+
+### Per podcast permissions
+
+<!-- AUTH-PODCAST-PERMISSIONS-LIST:START - Do not remove or modify this section -->
+
+| permission                   | description                                                              |
+| ---------------------------- | ------------------------------------------------------------------------ |
+| view                         | Can view dashboard and analytics of podcast #{id}.                       |
+| edit                         | Can edit podcast #{id}.                                                  |
+| delete                       | Can delete podcast #{id}.                                                |
+| manage-import                | Can synchronize imported podcast #{id}.                                  |
+| manage-persons               | Can manage subscriptions of podcast #{id}.                               |
+| manage-subscriptions         | Can manage subscriptions of podcast #{id}.                               |
+| manage-contributors          | Can manage contributors of podcast #{id}.                                |
+| manage-platforms             | Can set/remove platform links of podcast #{id}.                          |
+| manage-publications          | Can publish podcast #{id}.                                               |
+| interact-as                  | Can interact as the podcast #{id} to favourite, share or reply to posts. |
+| episodes.view                | Can view dashboard and analytics of podcast #{id}.                       |
+| episodes.create              | Can create episodes for podcast #{id}.                                   |
+| episodes.edit                | Can edit podcast #{id}.                                                  |
+| episodes.delete              | Can delete podcast #{id}.                                                |
+| episodes.manage-persons      | Can manage subscriptions of podcast #{id}.                               |
+| episodes.manage-clips        | Can manage video clips or soundbites of podcast #{id}.                   |
+| episodes.manage-publications | Can publish podcast #{id}.                                               |
+| episodes.manage-comments     | Can create/remove episode comments of podcast #{id}.                     |
+
+<!-- AUTH-PODCAST-PERMISSIONS-LIST:END -->
diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php
index ad767a0d11e6bafe049d925998eec0241583fc0f..7bf22166899ae68703e5fad77a09a3dc4a91fccd 100644
--- a/modules/Admin/Config/Routes.php
+++ b/modules/Admin/Config/Routes.php
@@ -25,60 +25,60 @@ $routes->group(
         $routes->group('settings', static function ($routes): void {
             $routes->get('/', 'SettingsController', [
                 'as' => 'settings-general',
-                'filter' => 'permission:settings-manage',
+                'filter' => 'permission:admin.settings',
             ]);
             $routes->post('instance', 'SettingsController::attemptInstanceEdit', [
                 'as' => 'settings-instance',
-                'filter' => 'permission:settings-manage',
+                'filter' => 'permission:admin.settings',
             ]);
             $routes->get('instance-delete-icon', 'SettingsController::deleteIcon', [
                 'as' => 'settings-instance-delete-icon',
-                'filter' => 'permission:settings-manage',
+                'filter' => 'permission:admin.settings',
             ]);
             $routes->post('instance-images-regenerate', 'SettingsController::regenerateImages', [
                 'as' => 'settings-images-regenerate',
-                'filter' => 'permission:settings-manage',
+                'filter' => 'permission:admin.settings',
             ]);
             $routes->post('instance-housekeeping-run', 'SettingsController::runHousekeeping', [
                 'as' => 'settings-housekeeping-run',
-                'filter' => 'permission:settings-manage',
+                'filter' => 'permission:admin.settings',
             ]);
             $routes->get('theme', 'SettingsController::theme', [
                 'as' => 'settings-theme',
-                'filter' => 'permission:settings-manage',
+                'filter' => 'permission:admin.settings',
             ]);
             $routes->post('theme', 'SettingsController::attemptSetInstanceTheme', [
                 'as' => 'settings-theme',
-                'filter' => 'permission:settings-manage',
+                'filter' => 'permission:admin.settings',
             ]);
         });
         $routes->group('persons', static function ($routes): void {
             $routes->get('/', 'PersonController', [
                 'as' => 'person-list',
-                'filter' => 'permission:person-list',
+                'filter' => 'permission:persons.manage',
             ]);
             $routes->get('new', 'PersonController::create', [
                 'as' => 'person-create',
-                'filter' => 'permission:person-create',
+                'filter' => 'permission:persons.manage',
             ]);
             $routes->post('new', 'PersonController::attemptCreate', [
-                'filter' => 'permission:person-create',
+                'filter' => 'permission:persons.manage',
             ]);
             $routes->group('(:num)', static function ($routes): void {
                 $routes->get('/', 'PersonController::view/$1', [
                     'as' => 'person-view',
-                    'filter' => 'permission:person-view',
+                    'filter' => 'permission:persons.manage',
                 ]);
                 $routes->get('edit', 'PersonController::edit/$1', [
                     'as' => 'person-edit',
-                    'filter' => 'permission:person-edit',
+                    'filter' => 'permission:persons.manage',
                 ]);
                 $routes->post('edit', 'PersonController::attemptEdit/$1', [
-                    'filter' => 'permission:person-edit',
+                    'filter' => 'permission:persons.manage',
                 ]);
                 $routes->add('delete', 'PersonController::delete/$1', [
                     'as' => 'person-delete',
-                    'filter' => 'permission:person-delete',
+                    'filter' => 'permission:persons.manage',
                 ]);
             });
         });
@@ -89,31 +89,31 @@ $routes->group(
             ]);
             $routes->get('new', 'PodcastController::create', [
                 'as' => 'podcast-create',
-                'filter' => 'permission:podcasts-create',
+                'filter' => 'permission:podcasts.create',
             ]);
             $routes->post('new', 'PodcastController::attemptCreate', [
-                'filter' => 'permission:podcasts-create',
+                'filter' => 'permission:podcasts.create',
             ]);
             $routes->get('import', 'PodcastImportController', [
                 'as' => 'podcast-import',
-                'filter' => 'permission:podcasts-import',
+                'filter' => 'permission:podcasts.import',
             ]);
             $routes->post('import', 'PodcastImportController::attemptImport', [
-                'filter' => 'permission:podcasts-import',
+                'filter' => 'permission:podcasts.import',
             ]);
             // Podcast
             // Use ids in admin area to help permission and group lookups
             $routes->group('(:num)', static function ($routes): void {
                 $routes->get('/', 'PodcastController::view/$1', [
                     'as' => 'podcast-view',
-                    'filter' => 'permission:podcasts-view,podcast-view',
+                    'filter' => 'permission:podcast#.view',
                 ]);
                 $routes->get('edit', 'PodcastController::edit/$1', [
                     'as' => 'podcast-edit',
-                    'filter' => 'permission:podcast-edit',
+                    'filter' => 'permission:podcast#.edit',
                 ]);
                 $routes->post('edit', 'PodcastController::attemptEdit/$1', [
-                    'filter' => 'permission:podcast-edit',
+                    'filter' => 'permission:podcast#.edit',
                 ]);
                 $routes->get(
                     'publish',
@@ -121,7 +121,7 @@ $routes->group(
                     [
                         'as' => 'podcast-publish',
                         'filter' =>
-                            'permission:podcast-manage_publications',
+                            'permission:podcast#.manage-publications',
                     ],
                 );
                 $routes->post(
@@ -129,7 +129,7 @@ $routes->group(
                     'PodcastController::attemptPublish/$1',
                     [
                         'filter' =>
-                            'permission:podcast-manage_publications',
+                            'permission:podcast#.manage-publications',
                     ],
                 );
                 $routes->get(
@@ -138,7 +138,7 @@ $routes->group(
                     [
                         'as' => 'podcast-publish_edit',
                         'filter' =>
-                            'permission:podcast-manage_publications',
+                            'permission:podcast#.manage-publications',
                     ],
                 );
                 $routes->post(
@@ -146,7 +146,7 @@ $routes->group(
                     'PodcastController::attemptPublishEdit/$1',
                     [
                         'filter' =>
-                            'permission:podcast-manage_publications',
+                            'permission:podcast#.manage-publications',
                     ],
                 );
                 $routes->get(
@@ -155,34 +155,34 @@ $routes->group(
                     [
                         'as' => 'podcast-publish-cancel',
                         'filter' =>
-                            'permission:podcast-manage_publications',
+                            'permission:podcast#.manage-publications',
                     ],
                 );
                 $routes->get('edit/delete-banner', 'PodcastController::deleteBanner/$1', [
                     'as' => 'podcast-banner-delete',
-                    'filter' => 'permission:podcast-edit',
+                    'filter' => 'permission:podcast#.edit',
                 ]);
                 $routes->get('delete', 'PodcastController::delete/$1', [
                     'as' => 'podcast-delete',
-                    'filter' => 'permission:podcasts-delete',
+                    'filter' => 'permission:podcast#.delete',
                 ]);
                 $routes->post('delete', 'PodcastController::attemptDelete/$1', [
-                    'filter' => 'permission:podcasts-delete',
+                    'filter' => 'permission:podcast#.delete',
                 ]);
                 $routes->get('update', 'PodcastImportController::updateImport/$1', [
                     'as' => 'podcast-update-feed',
-                    'filter' => 'permission:podcasts-import',
+                    'filter' => 'permission:podcast#.manage-import',
                 ]);
                 $routes->group('persons', static function ($routes): void {
                     $routes->get('/', 'PodcastPersonController/$1', [
                         'as' => 'podcast-persons-manage',
-                        'filter' => 'permission:podcast-edit',
+                        'filter' => 'permission:podcast#.manage-persons',
                     ]);
                     $routes->post(
                         '/',
                         'PodcastPersonController::attemptAdd/$1',
                         [
-                            'filter' => 'permission:podcast-edit',
+                            'filter' => 'permission:podcast#.manage-persons',
                         ],
                     );
                     $routes->get(
@@ -190,21 +190,21 @@ $routes->group(
                         'PodcastPersonController::remove/$1/$2',
                         [
                             'as' => 'podcast-person-remove',
-                            'filter' => 'permission:podcast-edit',
+                            'filter' => 'permission:podcast#.manage-persons',
                         ],
                     );
                 });
                 $routes->group('analytics', static function ($routes): void {
                     $routes->get('/', 'PodcastController::viewAnalytics/$1', [
                         'as' => 'podcast-analytics',
-                        'filter' => 'permission:podcasts-view,podcast-view',
+                        'filter' => 'permission:podcast#.view',
                     ]);
                     $routes->get(
                         'webpages',
                         'PodcastController::viewAnalyticsWebpages/$1',
                         [
                             'as' => 'podcast-analytics-webpages',
-                            'filter' => 'permission:podcasts-view,podcast-view',
+                            'filter' => 'permission:podcast#.view',
                         ],
                     );
                     $routes->get(
@@ -212,7 +212,7 @@ $routes->group(
                         'PodcastController::viewAnalyticsLocations/$1',
                         [
                             'as' => 'podcast-analytics-locations',
-                            'filter' => 'permission:podcasts-view,podcast-view',
+                            'filter' => 'permission:podcast#.view',
                         ],
                     );
                     $routes->get(
@@ -220,7 +220,7 @@ $routes->group(
                         'PodcastController::viewAnalyticsUniqueListeners/$1',
                         [
                             'as' => 'podcast-analytics-unique-listeners',
-                            'filter' => 'permission:podcasts-view,podcast-view',
+                            'filter' => 'permission:podcast#.view',
                         ],
                     );
                     $routes->get(
@@ -228,7 +228,7 @@ $routes->group(
                         'PodcastController::viewAnalyticsListeningTime/$1',
                         [
                             'as' => 'podcast-analytics-listening-time',
-                            'filter' => 'permission:podcasts-view,podcast-view',
+                            'filter' => 'permission:podcast#.view',
                         ],
                     );
                     $routes->get(
@@ -236,7 +236,7 @@ $routes->group(
                         'PodcastController::viewAnalyticsTimePeriods/$1',
                         [
                             'as' => 'podcast-analytics-time-periods',
-                            'filter' => 'permission:podcasts-view,podcast-view',
+                            'filter' => 'permission:podcast#.view',
                         ],
                     );
                     $routes->get(
@@ -244,7 +244,7 @@ $routes->group(
                         'PodcastController::viewAnalyticsPlayers/$1',
                         [
                             'as' => 'podcast-analytics-players',
-                            'filter' => 'permission:podcasts-view,podcast-view',
+                            'filter' => 'permission:podcast#.view',
                         ],
                     );
                 });
@@ -253,17 +253,17 @@ $routes->group(
                     $routes->get('/', 'EpisodeController::list/$1', [
                         'as' => 'episode-list',
                         'filter' =>
-                            'permission:episodes-list,podcast_episodes-list',
+                            'permission:podcast#.episodes.view',
                     ]);
                     $routes->get('new', 'EpisodeController::create/$1', [
                         'as' => 'episode-create',
-                        'filter' => 'permission:podcast_episodes-create',
+                        'filter' => 'permission:podcast#.episodes.create',
                     ]);
                     $routes->post(
                         'new',
                         'EpisodeController::attemptCreate/$1',
                         [
-                            'filter' => 'permission:podcast_episodes-create',
+                            'filter' => 'permission:podcast#.episodes.create',
                         ],
                     );
                     // Episode
@@ -271,17 +271,17 @@ $routes->group(
                         $routes->get('/', 'EpisodeController::view/$1/$2', [
                             'as' => 'episode-view',
                             'filter' =>
-                                'permission:episodes-view,podcast_episodes-view',
+                                'permission:podcast#.episodes.view',
                         ]);
                         $routes->get('edit', 'EpisodeController::edit/$1/$2', [
                             'as' => 'episode-edit',
-                            'filter' => 'permission:podcast_episodes-edit',
+                            'filter' => 'permission:podcast#.episodes.edit',
                         ]);
                         $routes->post(
                             'edit',
                             'EpisodeController::attemptEdit/$1/$2',
                             [
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.edit',
                             ],
                         );
                         $routes->get(
@@ -290,7 +290,7 @@ $routes->group(
                             [
                                 'as' => 'episode-publish',
                                 'filter' =>
-                                    'permission:podcast-manage_publications',
+                                    'permission:podcast#.episodes.manage-publications',
                             ],
                         );
                         $routes->post(
@@ -298,7 +298,7 @@ $routes->group(
                             'EpisodeController::attemptPublish/$1/$2',
                             [
                                 'filter' =>
-                                    'permission:podcast-manage_publications',
+                                    'permission:podcast#.episodes.manage-publications',
                             ],
                         );
                         $routes->get(
@@ -307,7 +307,7 @@ $routes->group(
                             [
                                 'as' => 'episode-publish_edit',
                                 'filter' =>
-                                    'permission:podcast-manage_publications',
+                                    'permission:podcast#.episodes.manage-publications',
                             ],
                         );
                         $routes->post(
@@ -315,7 +315,7 @@ $routes->group(
                             'EpisodeController::attemptPublishEdit/$1/$2',
                             [
                                 'filter' =>
-                                    'permission:podcast-manage_publications',
+                                    'permission:podcast#.episodes.manage-publications',
                             ],
                         );
                         $routes->get(
@@ -324,7 +324,7 @@ $routes->group(
                             [
                                 'as' => 'episode-publish-cancel',
                                 'filter' =>
-                                    'permission:podcast-manage_publications',
+                                    'permission:podcast#.episodes.manage-publications',
                             ],
                         );
                         $routes->get(
@@ -350,7 +350,7 @@ $routes->group(
                             [
                                 'as' => 'episode-unpublish',
                                 'filter' =>
-                                    'permission:podcast-manage_publications',
+                                    'permission:podcast#.episodes.manage-publications',
                             ],
                         );
                         $routes->post(
@@ -358,7 +358,7 @@ $routes->group(
                             'EpisodeController::attemptUnpublish/$1/$2',
                             [
                                 'filter' =>
-                                    'permission:podcast-manage_publications',
+                                    'permission:podcast#.episodes.manage-publications',
                             ],
                         );
                         $routes->get(
@@ -367,7 +367,7 @@ $routes->group(
                             [
                                 'as' => 'episode-delete',
                                 'filter' =>
-                                    'permission:podcast_episodes-delete',
+                                    'permission:podcast#.episodes.delete',
                             ],
                         );
                         $routes->post(
@@ -375,7 +375,7 @@ $routes->group(
                             'EpisodeController::attemptDelete/$1/$2',
                             [
                                 'filter' =>
-                                    'permission:podcast_episodes-delete',
+                                    'permission:podcast#.episodes.delete',
                             ],
                         );
                         $routes->get(
@@ -383,7 +383,7 @@ $routes->group(
                             'EpisodeController::transcriptDelete/$1/$2',
                             [
                                 'as' => 'transcript-delete',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.edit',
                             ],
                         );
                         $routes->get(
@@ -391,7 +391,7 @@ $routes->group(
                             'EpisodeController::chaptersDelete/$1/$2',
                             [
                                 'as' => 'chapters-delete',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.edit',
                             ],
                         );
                         $routes->get(
@@ -399,7 +399,7 @@ $routes->group(
                             'SoundbiteController::list/$1/$2',
                             [
                                 'as' => 'soundbites-list',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.manage-clips',
                             ],
                         );
                         $routes->get(
@@ -407,7 +407,7 @@ $routes->group(
                             'SoundbiteController::create/$1/$2',
                             [
                                 'as' => 'soundbites-create',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.manage-clips',
                             ],
                         );
                         $routes->post(
@@ -415,7 +415,7 @@ $routes->group(
                             'SoundbiteController::attemptCreate/$1/$2',
                             [
                                 'as' => 'soundbites-create',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.manage-clips',
                             ],
                         );
                         $routes->get(
@@ -423,7 +423,7 @@ $routes->group(
                             'SoundbiteController::delete/$1/$2/$3',
                             [
                                 'as' => 'soundbites-delete',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.manage-clips',
                             ],
                         );
                         $routes->get(
@@ -431,7 +431,7 @@ $routes->group(
                             'VideoClipsController::list/$1/$2',
                             [
                                 'as' => 'video-clips-list',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.manage-clips',
                             ],
                         );
                         $routes->get(
@@ -439,7 +439,7 @@ $routes->group(
                             'VideoClipsController::create/$1/$2',
                             [
                                 'as' => 'video-clips-create',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.manage-clips',
                             ],
                         );
                         $routes->post(
@@ -447,7 +447,7 @@ $routes->group(
                             'VideoClipsController::attemptCreate/$1/$2',
                             [
                                 'as' => 'video-clips-create',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.manage-clips',
                             ],
                         );
                         $routes->get(
@@ -455,7 +455,7 @@ $routes->group(
                             'VideoClipsController::view/$1/$2/$3',
                             [
                                 'as' => 'video-clip',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.manage-clips',
                             ],
                         );
                         $routes->get(
@@ -463,7 +463,7 @@ $routes->group(
                             'VideoClipsController::retry/$1/$2/$3',
                             [
                                 'as' => 'video-clip-retry',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.manage-clips',
                             ],
                         );
                         $routes->get(
@@ -471,7 +471,7 @@ $routes->group(
                             'VideoClipsController::delete/$1/$2/$3',
                             [
                                 'as' => 'video-clip-delete',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.manage-clips',
                             ],
                         );
                         $routes->get(
@@ -479,20 +479,20 @@ $routes->group(
                             'EpisodeController::embed/$1/$2',
                             [
                                 'as' => 'embed-add',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.edit',
                             ],
                         );
                         $routes->group('persons', static function ($routes): void {
                             $routes->get('/', 'EpisodePersonController/$1/$2', [
                                 'as' => 'episode-persons-manage',
-                                'filter' => 'permission:podcast_episodes-edit',
+                                'filter' => 'permission:podcast#.episodes.manage-persons',
                             ]);
                             $routes->post(
                                 '/',
                                 'EpisodePersonController::attemptAdd/$1/$2',
                                 [
                                     'filter' =>
-                                        'permission:podcast_episodes-edit',
+                                        'permission:podcast#.episodes.manage-persons',
                                 ],
                             );
                             $routes->get(
@@ -501,7 +501,7 @@ $routes->group(
                                 [
                                     'as' => 'episode-person-remove',
                                     'filter' =>
-                                        'permission:podcast_episodes-edit',
+                                        'permission:podcast#.episodes.manage-persons',
                                 ],
                             );
                         });
@@ -511,7 +511,7 @@ $routes->group(
                                 'EpisodeController::attemptCommentCreate/$1/$2',
                                 [
                                     'as' => 'comment-attempt-create',
-                                    'filter' => 'permission:podcast-manage_publications',
+                                    'filter' => 'permission:podcast#.episodes.manage-comments',
                                 ]
                             );
                             $routes->post(
@@ -519,7 +519,7 @@ $routes->group(
                                 'EpisodeController::attemptCommentReply/$1/$2/$3',
                                 [
                                     'as' => 'comment-attempt-reply',
-                                    'filter' => 'permission:podcast-manage_publications',
+                                    'filter' => 'permission:podcast#.episodes.manage-comments',
                                 ]
                             );
                             $routes->post(
@@ -527,73 +527,19 @@ $routes->group(
                                 'EpisodeController::attemptCommentDelete/$1/$2',
                                 [
                                     'as' => 'comment-attempt-delete',
-                                    'filter' => 'permission:podcast-manage_publications',
+                                    'filter' => 'permission:podcast#.episodes.manage-comments',
                                 ]
                             );
                         });
                     });
                 });
-                // Podcast contributors
-                $routes->group('contributors', static function ($routes): void {
-                    $routes->get('/', 'ContributorController::list/$1', [
-                        'as' => 'contributor-list',
-                        'filter' =>
-                            'permission:podcasts-view,podcast-manage_contributors',
-                    ]);
-                    $routes->get('add', 'ContributorController::add/$1', [
-                        'as' => 'contributor-add',
-                        'filter' => 'permission:podcast-manage_contributors',
-                    ]);
-                    $routes->post(
-                        'add',
-                        'ContributorController::attemptAdd/$1',
-                        [
-                            'filter' =>
-                                'permission:podcast-manage_contributors',
-                        ],
-                    );
-                    // Contributor
-                    $routes->group('(:num)', static function ($routes): void {
-                        $routes->get('/', 'ContributorController::view/$1/$2', [
-                            'as' => 'contributor-view',
-                            'filter' =>
-                                'permission:podcast-manage_contributors',
-                        ]);
-                        $routes->get(
-                            'edit',
-                            'ContributorController::edit/$1/$2',
-                            [
-                                'as' => 'contributor-edit',
-                                'filter' =>
-                                    'permission:podcast-manage_contributors',
-                            ],
-                        );
-                        $routes->post(
-                            'edit',
-                            'ContributorController::attemptEdit/$1/$2',
-                            [
-                                'filter' =>
-                                    'permission:podcast-manage_contributors',
-                            ],
-                        );
-                        $routes->get(
-                            'remove',
-                            'ContributorController::remove/$1/$2',
-                            [
-                                'as' => 'contributor-remove',
-                                'filter' =>
-                                    'permission:podcast-manage_contributors',
-                            ],
-                        );
-                    });
-                });
                 $routes->group('platforms', static function ($routes): void {
                     $routes->get(
                         '/',
                         'PodcastPlatformController::platforms/$1/podcasting',
                         [
                             'as' => 'platforms-podcasting',
-                            'filter' => 'permission:podcast-manage_platforms',
+                            'filter' => 'permission:podcast#.manage-platforms',
                         ],
                     );
                     $routes->get(
@@ -601,7 +547,7 @@ $routes->group(
                         'PodcastPlatformController::platforms/$1/social',
                         [
                             'as' => 'platforms-social',
-                            'filter' => 'permission:podcast-manage_platforms',
+                            'filter' => 'permission:podcast#.manage-platforms',
                         ],
                     );
                     $routes->get(
@@ -609,7 +555,7 @@ $routes->group(
                         'PodcastPlatformController::platforms/$1/funding',
                         [
                             'as' => 'platforms-funding',
-                            'filter' => 'permission:podcast-manage_platforms',
+                            'filter' => 'permission:podcast#.manage-platforms',
                         ],
                     );
                     $routes->post(
@@ -617,7 +563,7 @@ $routes->group(
                         'PodcastPlatformController::attemptPlatformsUpdate/$1/$2',
                         [
                             'as' => 'platforms-save',
-                            'filter' => 'permission:podcast-manage_platforms',
+                            'filter' => 'permission:podcast#.manage-platforms',
                         ],
                     );
                     $routes->get(
@@ -625,7 +571,7 @@ $routes->group(
                         'PodcastPlatformController::removePodcastPlatform/$1/$2',
                         [
                             'as' => 'podcast-platform-remove',
-                            'filter' => 'permission:podcast-manage_platforms',
+                            'filter' => 'permission:podcast#.manage-platforms',
                         ],
                     );
                 });
@@ -633,12 +579,15 @@ $routes->group(
                 $routes->group('notifications', static function ($routes): void {
                     $routes->get('/', 'NotificationController::list/$1', [
                         'as' => 'notification-list',
+                        'filter' => 'permission:podcast#.view',
                     ]);
                     $routes->get('(:num)/mark-as-read', 'NotificationController::markAsRead/$1/$2', [
                         'as' => 'notification-mark-as-read',
+                        'filter' => 'permission:podcast#.manage-notifications',
                     ]);
                     $routes->get('mark-all-as-read', 'NotificationController::markAllAsRead/$1', [
                         'as' => 'notification-mark-all-as-read',
+                        'filter' => 'permission:podcast#.manage-notifications',
                     ]);
                 });
             });
@@ -653,7 +602,7 @@ $routes->group(
                 'FediverseController::blockedActors',
                 [
                     'as' => 'fediverse-blocked-actors',
-                    'filter' => 'permission:fediverse-block_actors',
+                    'filter' => 'permission:fediverse.manage-blocks',
                 ],
             );
             $routes->get(
@@ -661,7 +610,7 @@ $routes->group(
                 'FediverseController::blockedDomains',
                 [
                     'as' => 'fediverse-blocked-domains',
-                    'filter' => 'permission:fediverse-block_domains',
+                    'filter' => 'permission:fediverse.manage-blocks',
                 ],
             );
         });
@@ -669,13 +618,14 @@ $routes->group(
         $routes->group('pages', static function ($routes): void {
             $routes->get('/', 'PageController::list', [
                 'as' => 'page-list',
+                'filter' => 'permission:pages.manage',
             ]);
             $routes->get('new', 'PageController::create', [
                 'as' => 'page-create',
-                'filter' => 'permission:pages-manage',
+                'filter' => 'permission:pages.manage',
             ]);
             $routes->post('new', 'PageController::attemptCreate', [
-                'filter' => 'permission:pages-manage',
+                'filter' => 'permission:pages.manage',
             ]);
             $routes->group('(:num)', static function ($routes): void {
                 $routes->get('/', 'PageController::view/$1', [
@@ -683,78 +633,16 @@ $routes->group(
                 ]);
                 $routes->get('edit', 'PageController::edit/$1', [
                     'as' => 'page-edit',
-                    'filter' => 'permission:pages-manage',
+                    'filter' => 'permission:pages.manage',
                 ]);
                 $routes->post('edit', 'PageController::attemptEdit/$1', [
-                    'filter' => 'permission:pages-manage',
+                    'filter' => 'permission:pages.manage',
                 ]);
                 $routes->get('delete', 'PageController::delete/$1', [
                     'as' => 'page-delete',
-                    'filter' => 'permission:pages-manage',
+                    'filter' => 'permission:pages.manage',
                 ]);
             });
         });
-        // Users
-        $routes->group('users', static function ($routes): void {
-            $routes->get('/', 'UserController::list', [
-                'as' => 'user-list',
-                'filter' => 'permission:users-list',
-            ]);
-            $routes->get('new', 'UserController::create', [
-                'as' => 'user-create',
-                'filter' => 'permission:users-create',
-            ]);
-            $routes->post('new', 'UserController::attemptCreate', [
-                'filter' => 'permission:users-create',
-            ]);
-            // User
-            $routes->group('(:num)', static function ($routes): void {
-                $routes->get('/', 'UserController::view/$1', [
-                    'as' => 'user-view',
-                    'filter' => 'permission:users-view',
-                ]);
-                $routes->get('edit', 'UserController::edit/$1', [
-                    'as' => 'user-edit',
-                    'filter' => 'permission:users-manage_authorizations',
-                ]);
-                $routes->post('edit', 'UserController::attemptEdit/$1', [
-                    'filter' => 'permission:users-manage_authorizations',
-                ]);
-                $routes->get('ban', 'UserController::ban/$1', [
-                    'as' => 'user-ban',
-                    'filter' => 'permission:users-manage_bans',
-                ]);
-                $routes->get('unban', 'UserController::unBan/$1', [
-                    'as' => 'user-unban',
-                    'filter' => 'permission:users-manage_bans',
-                ]);
-                $routes->get(
-                    'force-pass-reset',
-                    'UserController::forcePassReset/$1',
-                    [
-                        'as' => 'user-force_pass_reset',
-                        'filter' => 'permission:users-force_pass_reset',
-                    ],
-                );
-                $routes->get('delete', 'UserController::delete/$1', [
-                    'as' => 'user-delete',
-                    'filter' => 'permission:users-delete',
-                ]);
-            });
-        });
-        // My account
-        $routes->group('my-account', static function ($routes): void {
-            $routes->get('/', 'MyAccountController', [
-                'as' => 'my-account',
-            ]);
-            $routes->get(
-                'change-password',
-                'MyAccountController::changePassword/$1',
-                [
-                    'as' => 'change-password',
-                ],
-            );
-            $routes->post('change-password', 'MyAccountController::attemptChange/$1');
-        });
     },
 );
diff --git a/modules/Admin/Controllers/ContributorController.php b/modules/Admin/Controllers/ContributorController.php
deleted file mode 100644
index 71dfc073db85602bffee6eeae177f7e00b514995..0000000000000000000000000000000000000000
--- a/modules/Admin/Controllers/ContributorController.php
+++ /dev/null
@@ -1,203 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-namespace Modules\Admin\Controllers;
-
-use App\Entities\Podcast;
-use App\Models\PodcastModel;
-use App\Models\UserModel;
-use CodeIgniter\Exceptions\PageNotFoundException;
-use CodeIgniter\HTTP\RedirectResponse;
-use Exception;
-use Modules\Auth\Authorization\GroupModel;
-use Modules\Auth\Entities\User;
-
-class ContributorController extends BaseController
-{
-    protected Podcast $podcast;
-
-    protected ?User $user;
-
-    public function _remap(string $method, string ...$params): mixed
-    {
-        if ($params === []) {
-            throw PageNotFoundException::forPageNotFound();
-        }
-
-        if (($podcast = (new PodcastModel())->getPodcastById((int) $params[0])) === null) {
-            throw PageNotFoundException::forPageNotFound();
-        }
-
-        $this->podcast = $podcast;
-
-        if (count($params) <= 1) {
-            return $this->{$method}();
-        }
-
-        if (($this->user = (new UserModel())->getPodcastContributor((int) $params[1], (int) $params[0])) !== null) {
-            return $this->{$method}();
-        }
-
-        throw PageNotFoundException::forPageNotFound();
-    }
-
-    public function list(): string
-    {
-        $data = [
-            'podcast' => $this->podcast,
-        ];
-
-        replace_breadcrumb_params([
-            0 => $this->podcast->title,
-        ]);
-        return view('contributor/list', $data);
-    }
-
-    public function view(): string
-    {
-        $data = [
-            'podcast' => $this->podcast,
-            'contributor' => (new UserModel())->getPodcastContributor($this->user->id, $this->podcast->id),
-        ];
-
-        replace_breadcrumb_params([
-            0 => $this->podcast->title,
-            1 => $this->user->username,
-        ]);
-        return view('contributor/view', $data);
-    }
-
-    public function add(): string
-    {
-        helper('form');
-
-        $users = (new UserModel())->findAll();
-        $userOptions = array_reduce(
-            $users,
-            static function ($result, $user) {
-                $result[$user->id] = $user->username;
-                return $result;
-            },
-            [],
-        );
-
-        $roles = (new GroupModel())->getContributorRoles();
-        $roleOptions = array_reduce(
-            $roles,
-            static function ($result, $role) {
-                $result[$role->id] = lang('Contributor.roles.' . $role->name);
-                return $result;
-            },
-            [],
-        );
-
-        $data = [
-            'podcast' => $this->podcast,
-            'userOptions' => $userOptions,
-            'roleOptions' => $roleOptions,
-        ];
-
-        replace_breadcrumb_params([
-            0 => $this->podcast->title,
-        ]);
-        return view('contributor/add', $data);
-    }
-
-    public function attemptAdd(): RedirectResponse
-    {
-        try {
-            (new PodcastModel())->addPodcastContributor(
-                (int) $this->request->getPost('user'),
-                $this->podcast->id,
-                (int) $this->request->getPost('role'),
-            );
-        } catch (Exception) {
-            return redirect()
-                ->back()
-                ->withInput()
-                ->with('errors', [lang('Contributor.messages.alreadyAddedError')]);
-        }
-
-        return redirect()->route('contributor-list', [$this->podcast->id]);
-    }
-
-    public function edit(): string
-    {
-        helper('form');
-
-        $roles = (new GroupModel())->getContributorRoles();
-        $roleOptions = array_reduce(
-            $roles,
-            static function ($result, $role) {
-                $result[$role->id] = lang('Contributor.roles.' . $role->name);
-                return $result;
-            },
-            [],
-        );
-
-        $data = [
-            'podcast' => $this->podcast,
-            'user' => $this->user,
-            'contributorGroupId' => (new PodcastModel())->getContributorGroupId(
-                $this->user->id,
-                $this->podcast->id,
-            ),
-            'roleOptions' => $roleOptions,
-        ];
-
-        replace_breadcrumb_params([
-            0 => $this->podcast->title,
-            1 => $this->user->username,
-        ]);
-        return view('contributor/edit', $data);
-    }
-
-    public function attemptEdit(): RedirectResponse
-    {
-        (new PodcastModel())->updatePodcastContributor(
-            $this->user->id,
-            $this->podcast->id,
-            (int) $this->request->getPost('role'),
-        );
-
-        return redirect()->route('contributor-edit', [$this->podcast->id, $this->user->id])->with(
-            'message',
-            lang('Contributor.messages.editSuccess')
-        );
-    }
-
-    public function remove(): RedirectResponse
-    {
-        if ($this->podcast->created_by === $this->user->id) {
-            return redirect()
-                ->back()
-                ->with('errors', [lang('Contributor.messages.removeOwnerError')]);
-        }
-
-        $podcastModel = new PodcastModel();
-        if (
-            ! $podcastModel->removePodcastContributor($this->user->id, $this->podcast->id)
-        ) {
-            return redirect()
-                ->back()
-                ->with('errors', $podcastModel->errors());
-        }
-
-        return redirect()
-            ->route('contributor-list', [$this->podcast->id])
-            ->with(
-                'message',
-                lang('Contributor.messages.removeSuccess', [
-                    'username' => $this->user->username,
-                    'podcastTitle' => $this->podcast->title,
-                ]),
-            );
-    }
-}
diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php
index 6c14d7f38597a5db4fd932b90549035c63cd7a36..821c7bd751aed4e19d8c347d3e31d7ae88de898a 100644
--- a/modules/Admin/Controllers/EpisodeController.php
+++ b/modules/Admin/Controllers/EpisodeController.php
@@ -95,7 +95,7 @@ class EpisodeController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('episode/list', $data);
     }
@@ -108,7 +108,7 @@ class EpisodeController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => $this->episode->title,
         ]);
         return view('episode/view', $data);
@@ -125,7 +125,7 @@ class EpisodeController extends BaseController
             'nextEpisodeNumber' => (new EpisodeModel())->getNextEpisodeNumber($this->podcast->id, $currentSeasonNumber),
         ];
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('episode/create', $data);
     }
@@ -261,7 +261,7 @@ class EpisodeController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => $this->episode->title,
         ]);
         return view('episode/edit', $data);
@@ -438,7 +438,7 @@ class EpisodeController extends BaseController
             ];
 
             replace_breadcrumb_params([
-                0 => $this->podcast->title,
+                0 => $this->podcast->at_handle,
                 1 => $this->episode->title,
             ]);
             return view('episode/publish', $data);
@@ -551,7 +551,7 @@ class EpisodeController extends BaseController
             ];
 
             replace_breadcrumb_params([
-                0 => $this->podcast->title,
+                0 => $this->podcast->at_handle,
                 1 => $this->episode->title,
             ]);
             return view('episode/publish_edit', $data);
@@ -851,7 +851,7 @@ class EpisodeController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => $this->episode->title,
         ]);
         return view('episode/delete', $data);
@@ -949,7 +949,7 @@ class EpisodeController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => $this->episode->title,
         ]);
         return view('episode/embed', $data);
diff --git a/modules/Admin/Controllers/EpisodePersonController.php b/modules/Admin/Controllers/EpisodePersonController.php
index ee46c04d78baf3ff93f5c363ad9a86b104584f53..0e0e862570650e50314fd1709812f439ff1c7159 100644
--- a/modules/Admin/Controllers/EpisodePersonController.php
+++ b/modules/Admin/Controllers/EpisodePersonController.php
@@ -59,7 +59,7 @@ class EpisodePersonController extends BaseController
             'taxonomyOptions' => (new PersonModel())->getTaxonomyOptions(),
         ];
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => $this->episode->title,
         ]);
         return view('episode/persons', $data);
diff --git a/modules/Admin/Controllers/NotificationController.php b/modules/Admin/Controllers/NotificationController.php
index f4bc5558a2511a07abce5ea29942c709e5de8489..ea04b6c37e54ec38b969a7b937d5ea54acee78ba 100644
--- a/modules/Admin/Controllers/NotificationController.php
+++ b/modules/Admin/Controllers/NotificationController.php
@@ -67,7 +67,7 @@ class NotificationController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
 
         return view('podcast/notifications', $data);
diff --git a/modules/Admin/Controllers/PodcastController.php b/modules/Admin/Controllers/PodcastController.php
index 7e9726affa10f5223a63af454cbbcedbd8cf9b42..4acc02fabe5501b0de93d84a03dbdc8db139b204 100644
--- a/modules/Admin/Controllers/PodcastController.php
+++ b/modules/Admin/Controllers/PodcastController.php
@@ -23,7 +23,6 @@ use App\Models\PostModel;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
 use CodeIgniter\I18n\Time;
-use Config\Services;
 use Modules\Analytics\Models\AnalyticsPodcastByCountryModel;
 use Modules\Analytics\Models\AnalyticsPodcastByEpisodeModel;
 use Modules\Analytics\Models\AnalyticsPodcastByHourModel;
@@ -56,13 +55,13 @@ class PodcastController extends BaseController
 
     public function list(): string
     {
-        if (! has_permission('podcasts-list')) {
+        if (auth()->user()->can('podcasts.view')) {
             $data = [
-                'podcasts' => (new PodcastModel())->getUserPodcasts((int) user_id()),
+                'podcasts' => (new PodcastModel())->findAll(),
             ];
         } else {
             $data = [
-                'podcasts' => (new PodcastModel())->findAll(),
+                'podcasts' => get_user_podcasts(auth()->user()),
             ];
         }
 
@@ -76,7 +75,7 @@ class PodcastController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('podcast/view', $data);
     }
@@ -88,7 +87,7 @@ class PodcastController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('podcast/analytics/index', $data);
     }
@@ -100,7 +99,7 @@ class PodcastController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('podcast/analytics/webpages', $data);
     }
@@ -112,7 +111,7 @@ class PodcastController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('podcast/analytics/locations', $data);
     }
@@ -124,7 +123,7 @@ class PodcastController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('podcast/analytics/unique_listeners', $data);
     }
@@ -136,7 +135,7 @@ class PodcastController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('podcast/analytics/listening_time', $data);
     }
@@ -148,7 +147,7 @@ class PodcastController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('podcast/analytics/time_periods', $data);
     }
@@ -160,7 +159,7 @@ class PodcastController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('podcast/analytics/players', $data);
     }
@@ -253,10 +252,11 @@ class PodcastController extends BaseController
                 ->with('errors', $podcastModel->errors());
         }
 
-        $authorize = Services::authorization();
-        $podcastAdminGroup = $authorize->group('podcast_admin');
-
-        $podcastModel->addPodcastContributor(user_id(), $newPodcastId, (int) $podcastAdminGroup->id);
+        // generate podcast roles and permissions
+        // before setting current user as podcast admin
+        config('AuthGroups')
+            ->generatePodcastAuthorizations($newPodcastId);
+        add_podcast_group(auth()->user(), (int) $newPodcastId, setting('AuthGroups.mostPowerfulPodcastGroup'));
 
         // set Podcast categories
         (new CategoryModel())->setPodcastCategories(
@@ -264,10 +264,6 @@ class PodcastController extends BaseController
             $this->request->getPost('other_categories') ?? [],
         );
 
-        // set interact as the newly created podcast actor
-        $createdPodcast = (new PodcastModel())->getPodcastById($newPodcastId);
-        set_interact_as_actor($createdPodcast->actor_id);
-
         $db->transComplete();
 
         return redirect()->route('podcast-view', [$newPodcastId])->with(
@@ -290,7 +286,7 @@ class PodcastController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('podcast/edit', $data);
     }
@@ -444,7 +440,7 @@ class PodcastController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('podcast/delete', $data);
     }
@@ -576,15 +572,6 @@ class PodcastController extends BaseController
             }
         }
 
-        if ($this->podcast->actor_id === interact_as_actor_id()) {
-            //set interact to the most recently created podcast actor
-            $mostRecentPodcast = (new PodcastModel())->orderBy('created_at', 'desc')
-                ->first();
-            if ($mostRecentPodcast !== null) {
-                set_interact_as_actor($mostRecentPodcast->actor_id);
-            }
-        }
-
         $db->transComplete();
 
         //delete podcast media files and folder
@@ -620,7 +607,7 @@ class PodcastController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
 
         return view('podcast/publish', $data);
@@ -754,7 +741,7 @@ class PodcastController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
 
         return view('podcast/publish_edit', $data);
diff --git a/modules/Admin/Controllers/PodcastImportController.php b/modules/Admin/Controllers/PodcastImportController.php
index 39b0f09a9a7a0581fe87d5d09e299531c0f6ccfd..f7515a4b33656e6dcb53cfd1bce1a95c2fe1f31b 100644
--- a/modules/Admin/Controllers/PodcastImportController.php
+++ b/modules/Admin/Controllers/PodcastImportController.php
@@ -23,7 +23,6 @@ use App\Models\PlatformModel;
 use App\Models\PodcastModel;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
-use Config\Services;
 use ErrorException;
 use League\HTMLToMarkdown\HtmlConverter;
 
@@ -201,10 +200,11 @@ class PodcastImportController extends BaseController
                 ->with('errors', $podcastModel->errors());
         }
 
-        $authorize = Services::authorization();
-        $podcastAdminGroup = $authorize->group('podcast_admin');
-
-        $podcastModel->addPodcastContributor(user_id(), $newPodcastId, (int) $podcastAdminGroup->id);
+        // set current user as podcast admin
+        // 1. create new group
+        config('AuthGroups')
+            ->generatePodcastAuthorizations($newPodcastId);
+        add_podcast_group(auth()->user(), $newPodcastId, 'admin');
 
         $podcastsPlatformsData = [];
         $platformTypes = [
@@ -460,9 +460,7 @@ class PodcastImportController extends BaseController
             }
         }
 
-        // set interact as the newly imported podcast actor
         $importedPodcast = (new PodcastModel())->getPodcastById($newPodcastId);
-        set_interact_as_actor($importedPodcast->actor_id);
 
         // set podcast publication date
         $importedPodcast->published_at = $firstEpisodePublicationDate ?? $importedPodcast->created_at;
diff --git a/modules/Admin/Controllers/PodcastPersonController.php b/modules/Admin/Controllers/PodcastPersonController.php
index 722ba28affe50a6c984ad97bbe3375a776ca39b2..1c5d8213771e0b9f57333c25ce873d6b4eeb6653 100644
--- a/modules/Admin/Controllers/PodcastPersonController.php
+++ b/modules/Admin/Controllers/PodcastPersonController.php
@@ -47,7 +47,7 @@ class PodcastPersonController extends BaseController
             'taxonomyOptions' => (new PersonModel())->getTaxonomyOptions(),
         ];
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('podcast/persons', $data);
     }
diff --git a/modules/Admin/Controllers/PodcastPlatformController.php b/modules/Admin/Controllers/PodcastPlatformController.php
index 08b8f961658bdf25a8051392e081a725bd8f88bd..6193642086420f8b4833997b1b81993be034fc26 100644
--- a/modules/Admin/Controllers/PodcastPlatformController.php
+++ b/modules/Admin/Controllers/PodcastPlatformController.php
@@ -53,7 +53,7 @@ class PodcastPlatformController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
 
         return view('podcast/platforms', $data);
diff --git a/modules/Admin/Controllers/SoundbiteController.php b/modules/Admin/Controllers/SoundbiteController.php
index 0016de24dfddf6259eb80eae8ca3e7cbf799a9cd..e389e645c7832b792e37168e6e34ec91c5b9e3b0 100644
--- a/modules/Admin/Controllers/SoundbiteController.php
+++ b/modules/Admin/Controllers/SoundbiteController.php
@@ -77,7 +77,7 @@ class SoundbiteController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => $this->episode->title,
         ]);
         return view('episode/soundbites_list', $data);
@@ -93,7 +93,7 @@ class SoundbiteController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => $this->episode->title,
         ]);
         return view('episode/soundbites_new', $data);
diff --git a/modules/Admin/Controllers/UserController.php b/modules/Admin/Controllers/UserController.php
deleted file mode 100644
index 3dc70d44f6ff9086cf0aca14b9fc934346f79fb5..0000000000000000000000000000000000000000
--- a/modules/Admin/Controllers/UserController.php
+++ /dev/null
@@ -1,258 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-namespace Modules\Admin\Controllers;
-
-use App\Models\UserModel;
-use CodeIgniter\Exceptions\PageNotFoundException;
-use CodeIgniter\HTTP\RedirectResponse;
-use Config\Services;
-use Modules\Auth\Authorization\GroupModel;
-use Modules\Auth\Entities\User;
-
-class UserController extends BaseController
-{
-    protected ?User $user;
-
-    public function _remap(string $method, string ...$params): mixed
-    {
-        if ($params === []) {
-            return $this->{$method}();
-        }
-
-        if ($this->user = (new UserModel())->find($params[0])) {
-            return $this->{$method}();
-        }
-
-        throw PageNotFoundException::forPageNotFound();
-    }
-
-    public function list(): string
-    {
-        $data = [
-            'users' => (new UserModel())->findAll(),
-        ];
-
-        return view('user/list', $data);
-    }
-
-    public function view(): string
-    {
-        $data = [
-            'user' => $this->user,
-        ];
-
-        replace_breadcrumb_params([
-            0 => $this->user->username,
-        ]);
-        return view('user/view', $data);
-    }
-
-    public function create(): string
-    {
-        helper('form');
-
-        $data = [
-            'roles' => (new GroupModel())->getUserRoles(),
-        ];
-
-        return view('user/create', $data);
-    }
-
-    public function attemptCreate(): RedirectResponse
-    {
-        $userModel = new UserModel();
-
-        // Validate here first, since some things,
-        // like the password, can only be validated properly here.
-        $rules = array_merge(
-            $userModel->getValidationRules([
-                'only' => ['username'],
-            ]),
-            [
-                'email' => 'required|valid_email|is_unique[users.email]',
-                'password' => 'required|strong_password',
-            ],
-        );
-
-        if (! $this->validate($rules)) {
-            return redirect()
-                ->back()
-                ->withInput()
-                ->with('errors', $this->validator->getErrors());
-        }
-
-        // Save the user
-        $user = new User($this->request->getPost());
-
-        // Activate user
-        $user->activate();
-
-        // Force user to reset his password on first connection
-        $user->forcePasswordReset();
-
-        if (! $userModel->insert($user)) {
-            return redirect()
-                ->back()
-                ->withInput()
-                ->with('errors', $userModel->errors());
-        }
-
-        // Success!
-        return redirect()
-            ->route('user-list')
-            ->with('message', lang('User.messages.createSuccess', [
-                'username' => $user->username,
-            ]));
-    }
-
-    public function edit(): string
-    {
-        helper('form');
-
-        $roles = (new GroupModel())->getUserRoles();
-        $roleOptions = array_reduce(
-            $roles,
-            static function ($result, $role) {
-                $result[$role->name] = lang('User.roles.' . $role->name);
-                return $result;
-            },
-            [],
-        );
-
-        $data = [
-            'user' => $this->user,
-            'roleOptions' => $roleOptions,
-        ];
-
-        replace_breadcrumb_params([
-            0 => $this->user->username,
-        ]);
-        return view('user/edit', $data);
-    }
-
-    public function attemptEdit(): RedirectResponse
-    {
-        $authorize = Services::authorization();
-
-        $roles = $this->request->getPost('roles');
-
-        if ($this->user->isOwner) {
-            return redirect()
-                ->back()
-                ->with('errors', [
-                    lang('User.messages.editOwnerError', [
-                        'username' => $this->user->username,
-                    ]),
-                ]);
-        }
-
-        $authorize->setUserGroups($this->user->id, $roles ?? []);
-
-        // Success!
-        return redirect()
-            ->route('user-list')
-            ->with('message', lang('User.messages.rolesEditSuccess', [
-                'username' => $this->user->username,
-            ]));
-    }
-
-    public function forcePassReset(): RedirectResponse
-    {
-        $userModel = new UserModel();
-        $this->user->forcePasswordReset();
-
-        if (! $userModel->update($this->user->id, $this->user)) {
-            return redirect()
-                ->back()
-                ->with('errors', $userModel->errors());
-        }
-
-        // Success!
-        return redirect()
-            ->route('user-list')
-            ->with(
-                'message',
-                lang('User.messages.forcePassResetSuccess', [
-                    'username' => $this->user->username,
-                ]),
-            );
-    }
-
-    public function ban(): RedirectResponse
-    {
-        $authorize = Services::authorization();
-        if ($authorize->inGroup('superadmin', $this->user->id)) {
-            return redirect()
-                ->back()
-                ->with('errors', [
-                    lang('User.messages.banSuperAdminError', [
-                        'username' => $this->user->username,
-                    ]),
-                ]);
-        }
-
-        $userModel = new UserModel();
-        // TODO: add ban reason?
-        $this->user->ban('');
-
-        if (! $userModel->update($this->user->id, $this->user)) {
-            return redirect()
-                ->back()
-                ->with('errors', $userModel->errors());
-        }
-
-        return redirect()
-            ->route('user-list')
-            ->with('message', lang('User.messages.banSuccess', [
-                'username' => $this->user->username,
-            ]));
-    }
-
-    public function unBan(): RedirectResponse
-    {
-        $userModel = new UserModel();
-        $this->user->unBan();
-
-        if (! $userModel->update($this->user->id, $this->user)) {
-            return redirect()
-                ->back()
-                ->with('errors', $userModel->errors());
-        }
-
-        return redirect()
-            ->route('user-list')
-            ->with('message', lang('User.messages.unbanSuccess', [
-                'username' => $this->user->username,
-            ]));
-    }
-
-    public function delete(): RedirectResponse
-    {
-        $authorize = Services::authorization();
-        if ($authorize->inGroup('superadmin', $this->user->id)) {
-            return redirect()
-                ->back()
-                ->with('errors', [
-                    lang('User.messages.deleteSuperAdminError', [
-                        'username' => $this->user->username,
-                    ]),
-                ]);
-        }
-
-        (new UserModel())->delete($this->user->id);
-
-        return redirect()
-            ->back()
-            ->with('message', lang('User.messages.deleteSuccess', [
-                'username' => $this->user->username,
-            ]));
-    }
-}
diff --git a/modules/Admin/Controllers/VideoClipsController.php b/modules/Admin/Controllers/VideoClipsController.php
index 49878800bbf048f808eca8504aa8f69657ae5c79..02ab95cf44155bcf2e7a185365f7843493a4dd86 100644
--- a/modules/Admin/Controllers/VideoClipsController.php
+++ b/modules/Admin/Controllers/VideoClipsController.php
@@ -82,7 +82,7 @@ class VideoClipsController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => $this->episode->title,
         ]);
         return view('episode/video_clips_list', $data);
@@ -99,7 +99,7 @@ class VideoClipsController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => $this->episode->title,
             2 => $videoClip->title,
         ]);
@@ -114,7 +114,7 @@ class VideoClipsController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => $this->episode->title,
         ]);
 
diff --git a/modules/Admin/Language/en/Breadcrumb.php b/modules/Admin/Language/en/Breadcrumb.php
index f3269bfa4e45e583492211d3e7177cd933dcaaeb..823ccd65acc88198e297aa574633fae9727067b9 100644
--- a/modules/Admin/Language/en/Breadcrumb.php
+++ b/modules/Admin/Language/en/Breadcrumb.php
@@ -28,6 +28,7 @@ return [
     'publish-date-edit' => 'edit publication date',
     'unpublish' => 'unpublish',
     'delete' => 'delete',
+    'remove' => 'remove',
     'fediverse' => 'fediverse',
     'block-lists' => 'block lists',
     'users' => 'users',
diff --git a/modules/Admin/Language/id/User.php b/modules/Admin/Language/id/User.php
deleted file mode 100644
index 585d6799fc98d3f7c485c26b9ed1439b28813a3b..0000000000000000000000000000000000000000
--- a/modules/Admin/Language/id/User.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-return [
-    'edit_roles' => "Edit {username}'s roles",
-    'forcePassReset' => 'Force pass reset',
-    'ban' => 'Ban',
-    'unban' => 'Unban',
-    'delete' => 'Delete',
-    'create' => 'New user',
-    'view' => "{username}'s info",
-    'all_users' => 'All users',
-    'list' => [
-        'user' => 'User',
-        'roles' => 'Roles',
-        'banned' => 'Banned?',
-    ],
-    'form' => [
-        'email' => 'Email',
-        'username' => 'Username',
-        'password' => 'Password',
-        'new_password' => 'New Password',
-        'roles' => 'Roles',
-        'permissions' => 'Permissions',
-        'submit_create' => 'Create user',
-        'submit_edit' => 'Save',
-        'submit_password_change' => 'Change!',
-    ],
-    'roles' => [
-        'superadmin' => 'Super admin',
-    ],
-    'messages' => [
-        'createSuccess' =>
-            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
-            "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
-        'banSuccess' => '{username} has been banned.',
-        'unbanSuccess' => '{username} has been unbanned.',
-        'editOwnerError' =>
-            '{username} is the instance owner, you cannot edit its roles.',
-        'banSuperAdminError' =>
-            '{username} is a superadmin, one does not simply ban a superadmin…',
-        'deleteSuperAdminError' =>
-            '{username} is a superadmin, one does not simply delete a superadmin…',
-        'deleteSuccess' => '{username} has been deleted.',
-    ],
-];
diff --git a/modules/Admin/Language/it/User.php b/modules/Admin/Language/it/User.php
deleted file mode 100644
index 585d6799fc98d3f7c485c26b9ed1439b28813a3b..0000000000000000000000000000000000000000
--- a/modules/Admin/Language/it/User.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-return [
-    'edit_roles' => "Edit {username}'s roles",
-    'forcePassReset' => 'Force pass reset',
-    'ban' => 'Ban',
-    'unban' => 'Unban',
-    'delete' => 'Delete',
-    'create' => 'New user',
-    'view' => "{username}'s info",
-    'all_users' => 'All users',
-    'list' => [
-        'user' => 'User',
-        'roles' => 'Roles',
-        'banned' => 'Banned?',
-    ],
-    'form' => [
-        'email' => 'Email',
-        'username' => 'Username',
-        'password' => 'Password',
-        'new_password' => 'New Password',
-        'roles' => 'Roles',
-        'permissions' => 'Permissions',
-        'submit_create' => 'Create user',
-        'submit_edit' => 'Save',
-        'submit_password_change' => 'Change!',
-    ],
-    'roles' => [
-        'superadmin' => 'Super admin',
-    ],
-    'messages' => [
-        'createSuccess' =>
-            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
-            "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
-        'banSuccess' => '{username} has been banned.',
-        'unbanSuccess' => '{username} has been unbanned.',
-        'editOwnerError' =>
-            '{username} is the instance owner, you cannot edit its roles.',
-        'banSuperAdminError' =>
-            '{username} is a superadmin, one does not simply ban a superadmin…',
-        'deleteSuperAdminError' =>
-            '{username} is a superadmin, one does not simply delete a superadmin…',
-        'deleteSuccess' => '{username} has been deleted.',
-    ],
-];
diff --git a/modules/Admin/Language/nl/User.php b/modules/Admin/Language/nl/User.php
deleted file mode 100644
index 585d6799fc98d3f7c485c26b9ed1439b28813a3b..0000000000000000000000000000000000000000
--- a/modules/Admin/Language/nl/User.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-return [
-    'edit_roles' => "Edit {username}'s roles",
-    'forcePassReset' => 'Force pass reset',
-    'ban' => 'Ban',
-    'unban' => 'Unban',
-    'delete' => 'Delete',
-    'create' => 'New user',
-    'view' => "{username}'s info",
-    'all_users' => 'All users',
-    'list' => [
-        'user' => 'User',
-        'roles' => 'Roles',
-        'banned' => 'Banned?',
-    ],
-    'form' => [
-        'email' => 'Email',
-        'username' => 'Username',
-        'password' => 'Password',
-        'new_password' => 'New Password',
-        'roles' => 'Roles',
-        'permissions' => 'Permissions',
-        'submit_create' => 'Create user',
-        'submit_edit' => 'Save',
-        'submit_password_change' => 'Change!',
-    ],
-    'roles' => [
-        'superadmin' => 'Super admin',
-    ],
-    'messages' => [
-        'createSuccess' =>
-            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
-            "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
-        'banSuccess' => '{username} has been banned.',
-        'unbanSuccess' => '{username} has been unbanned.',
-        'editOwnerError' =>
-            '{username} is the instance owner, you cannot edit its roles.',
-        'banSuperAdminError' =>
-            '{username} is a superadmin, one does not simply ban a superadmin…',
-        'deleteSuperAdminError' =>
-            '{username} is a superadmin, one does not simply delete a superadmin…',
-        'deleteSuccess' => '{username} has been deleted.',
-    ],
-];
diff --git a/modules/Admin/Language/oc/User.php b/modules/Admin/Language/oc/User.php
deleted file mode 100644
index 585d6799fc98d3f7c485c26b9ed1439b28813a3b..0000000000000000000000000000000000000000
--- a/modules/Admin/Language/oc/User.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-return [
-    'edit_roles' => "Edit {username}'s roles",
-    'forcePassReset' => 'Force pass reset',
-    'ban' => 'Ban',
-    'unban' => 'Unban',
-    'delete' => 'Delete',
-    'create' => 'New user',
-    'view' => "{username}'s info",
-    'all_users' => 'All users',
-    'list' => [
-        'user' => 'User',
-        'roles' => 'Roles',
-        'banned' => 'Banned?',
-    ],
-    'form' => [
-        'email' => 'Email',
-        'username' => 'Username',
-        'password' => 'Password',
-        'new_password' => 'New Password',
-        'roles' => 'Roles',
-        'permissions' => 'Permissions',
-        'submit_create' => 'Create user',
-        'submit_edit' => 'Save',
-        'submit_password_change' => 'Change!',
-    ],
-    'roles' => [
-        'superadmin' => 'Super admin',
-    ],
-    'messages' => [
-        'createSuccess' =>
-            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
-            "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
-        'banSuccess' => '{username} has been banned.',
-        'unbanSuccess' => '{username} has been unbanned.',
-        'editOwnerError' =>
-            '{username} is the instance owner, you cannot edit its roles.',
-        'banSuperAdminError' =>
-            '{username} is a superadmin, one does not simply ban a superadmin…',
-        'deleteSuperAdminError' =>
-            '{username} is a superadmin, one does not simply delete a superadmin…',
-        'deleteSuccess' => '{username} has been deleted.',
-    ],
-];
diff --git a/modules/Admin/Language/pt/User.php b/modules/Admin/Language/pt/User.php
deleted file mode 100644
index 585d6799fc98d3f7c485c26b9ed1439b28813a3b..0000000000000000000000000000000000000000
--- a/modules/Admin/Language/pt/User.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-return [
-    'edit_roles' => "Edit {username}'s roles",
-    'forcePassReset' => 'Force pass reset',
-    'ban' => 'Ban',
-    'unban' => 'Unban',
-    'delete' => 'Delete',
-    'create' => 'New user',
-    'view' => "{username}'s info",
-    'all_users' => 'All users',
-    'list' => [
-        'user' => 'User',
-        'roles' => 'Roles',
-        'banned' => 'Banned?',
-    ],
-    'form' => [
-        'email' => 'Email',
-        'username' => 'Username',
-        'password' => 'Password',
-        'new_password' => 'New Password',
-        'roles' => 'Roles',
-        'permissions' => 'Permissions',
-        'submit_create' => 'Create user',
-        'submit_edit' => 'Save',
-        'submit_password_change' => 'Change!',
-    ],
-    'roles' => [
-        'superadmin' => 'Super admin',
-    ],
-    'messages' => [
-        'createSuccess' =>
-            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
-            "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
-        'banSuccess' => '{username} has been banned.',
-        'unbanSuccess' => '{username} has been unbanned.',
-        'editOwnerError' =>
-            '{username} is the instance owner, you cannot edit its roles.',
-        'banSuperAdminError' =>
-            '{username} is a superadmin, one does not simply ban a superadmin…',
-        'deleteSuperAdminError' =>
-            '{username} is a superadmin, one does not simply delete a superadmin…',
-        'deleteSuccess' => '{username} has been deleted.',
-    ],
-];
diff --git a/modules/Admin/Language/ru/User.php b/modules/Admin/Language/ru/User.php
deleted file mode 100644
index 585d6799fc98d3f7c485c26b9ed1439b28813a3b..0000000000000000000000000000000000000000
--- a/modules/Admin/Language/ru/User.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-return [
-    'edit_roles' => "Edit {username}'s roles",
-    'forcePassReset' => 'Force pass reset',
-    'ban' => 'Ban',
-    'unban' => 'Unban',
-    'delete' => 'Delete',
-    'create' => 'New user',
-    'view' => "{username}'s info",
-    'all_users' => 'All users',
-    'list' => [
-        'user' => 'User',
-        'roles' => 'Roles',
-        'banned' => 'Banned?',
-    ],
-    'form' => [
-        'email' => 'Email',
-        'username' => 'Username',
-        'password' => 'Password',
-        'new_password' => 'New Password',
-        'roles' => 'Roles',
-        'permissions' => 'Permissions',
-        'submit_create' => 'Create user',
-        'submit_edit' => 'Save',
-        'submit_password_change' => 'Change!',
-    ],
-    'roles' => [
-        'superadmin' => 'Super admin',
-    ],
-    'messages' => [
-        'createSuccess' =>
-            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
-            "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
-        'banSuccess' => '{username} has been banned.',
-        'unbanSuccess' => '{username} has been unbanned.',
-        'editOwnerError' =>
-            '{username} is the instance owner, you cannot edit its roles.',
-        'banSuperAdminError' =>
-            '{username} is a superadmin, one does not simply ban a superadmin…',
-        'deleteSuperAdminError' =>
-            '{username} is a superadmin, one does not simply delete a superadmin…',
-        'deleteSuccess' => '{username} has been deleted.',
-    ],
-];
diff --git a/modules/Admin/Language/sk/User.php b/modules/Admin/Language/sk/User.php
deleted file mode 100644
index 585d6799fc98d3f7c485c26b9ed1439b28813a3b..0000000000000000000000000000000000000000
--- a/modules/Admin/Language/sk/User.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-return [
-    'edit_roles' => "Edit {username}'s roles",
-    'forcePassReset' => 'Force pass reset',
-    'ban' => 'Ban',
-    'unban' => 'Unban',
-    'delete' => 'Delete',
-    'create' => 'New user',
-    'view' => "{username}'s info",
-    'all_users' => 'All users',
-    'list' => [
-        'user' => 'User',
-        'roles' => 'Roles',
-        'banned' => 'Banned?',
-    ],
-    'form' => [
-        'email' => 'Email',
-        'username' => 'Username',
-        'password' => 'Password',
-        'new_password' => 'New Password',
-        'roles' => 'Roles',
-        'permissions' => 'Permissions',
-        'submit_create' => 'Create user',
-        'submit_edit' => 'Save',
-        'submit_password_change' => 'Change!',
-    ],
-    'roles' => [
-        'superadmin' => 'Super admin',
-    ],
-    'messages' => [
-        'createSuccess' =>
-            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
-            "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
-        'banSuccess' => '{username} has been banned.',
-        'unbanSuccess' => '{username} has been unbanned.',
-        'editOwnerError' =>
-            '{username} is the instance owner, you cannot edit its roles.',
-        'banSuperAdminError' =>
-            '{username} is a superadmin, one does not simply ban a superadmin…',
-        'deleteSuperAdminError' =>
-            '{username} is a superadmin, one does not simply delete a superadmin…',
-        'deleteSuccess' => '{username} has been deleted.',
-    ],
-];
diff --git a/modules/Admin/Language/sv/User.php b/modules/Admin/Language/sv/User.php
deleted file mode 100644
index 585d6799fc98d3f7c485c26b9ed1439b28813a3b..0000000000000000000000000000000000000000
--- a/modules/Admin/Language/sv/User.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-return [
-    'edit_roles' => "Edit {username}'s roles",
-    'forcePassReset' => 'Force pass reset',
-    'ban' => 'Ban',
-    'unban' => 'Unban',
-    'delete' => 'Delete',
-    'create' => 'New user',
-    'view' => "{username}'s info",
-    'all_users' => 'All users',
-    'list' => [
-        'user' => 'User',
-        'roles' => 'Roles',
-        'banned' => 'Banned?',
-    ],
-    'form' => [
-        'email' => 'Email',
-        'username' => 'Username',
-        'password' => 'Password',
-        'new_password' => 'New Password',
-        'roles' => 'Roles',
-        'permissions' => 'Permissions',
-        'submit_create' => 'Create user',
-        'submit_edit' => 'Save',
-        'submit_password_change' => 'Change!',
-    ],
-    'roles' => [
-        'superadmin' => 'Super admin',
-    ],
-    'messages' => [
-        'createSuccess' =>
-            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
-            "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
-        'banSuccess' => '{username} has been banned.',
-        'unbanSuccess' => '{username} has been unbanned.',
-        'editOwnerError' =>
-            '{username} is the instance owner, you cannot edit its roles.',
-        'banSuperAdminError' =>
-            '{username} is a superadmin, one does not simply ban a superadmin…',
-        'deleteSuperAdminError' =>
-            '{username} is a superadmin, one does not simply delete a superadmin…',
-        'deleteSuccess' => '{username} has been deleted.',
-    ],
-];
diff --git a/modules/Analytics/Config/Analytics.php b/modules/Analytics/Config/Analytics.php
index ab263a08a77e3a2f16edf5267940e582600955a0..6caea6b0c981dfb344098e13386c8cadad6b0e11 100644
--- a/modules/Analytics/Config/Analytics.php
+++ b/modules/Analytics/Config/Analytics.php
@@ -20,9 +20,9 @@ class Analytics extends BaseConfig
      * @var array<string, string>
      */
     public array $routeFilters = [
-        'analytics-full-data' => 'permission:podcasts-view,podcast-view',
-        'analytics-data' => 'permission:podcasts-view,podcast-view',
-        'analytics-filtered-data' => 'permission:podcasts-view,podcast-view',
+        'analytics-full-data' => 'permission:podcast#.view',
+        'analytics-data' => 'permission:podcast#.view',
+        'analytics-filtered-data' => 'permission:podcast#.view',
     ];
 
     /**
diff --git a/modules/Auth/Auth.php b/modules/Auth/Auth.php
new file mode 100644
index 0000000000000000000000000000000000000000..09e3258116d47f06e4a7a2001bac7ff130ace9c2
--- /dev/null
+++ b/modules/Auth/Auth.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Auth;
+
+use CodeIgniter\Router\RouteCollection;
+use CodeIgniter\Shield\Auth as ShieldAuth;
+
+class Auth extends ShieldAuth
+{
+    /**
+     * Will set the routes in your application to use
+     * the Shield auth routes.
+     *
+     * Usage (in Config/Routes.php):
+     *      - auth()->routes($routes);
+     *      - auth()->routes($routes, ['except' => ['login', 'register']])
+     */
+    public function routes(RouteCollection &$routes, array $config = []): void
+    {
+        $authRoutes = config('AuthRoutes')
+            ->routes;
+
+        $routes->group(config('Auth')->gateway, [
+            'namespace' => 'Modules\Auth\Controllers',
+        ], static function (RouteCollection $routes) use ($authRoutes, $config): void {
+            foreach ($authRoutes as $name => $row) {
+                if (! isset($config['except']) || ! in_array($name, $config['except'], true)) {
+                    foreach ($row as $params) {
+                        $options = isset($params[3])
+                            ? [
+                                'as' => $params[3],
+                            ]
+                            : null;
+                        $routes->{$params[0]}($params[1], $params[2], $options);
+                    }
+                }
+            }
+        });
+    }
+}
diff --git a/modules/Auth/Authorization/FlatAuthorization.php b/modules/Auth/Authorization/FlatAuthorization.php
deleted file mode 100644
index 933a27287736186b786e5eefba00a0bcf108c47a..0000000000000000000000000000000000000000
--- a/modules/Auth/Authorization/FlatAuthorization.php
+++ /dev/null
@@ -1,54 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Modules\Auth\Authorization;
-
-use Myth\Auth\Authorization\FlatAuthorization as MythAuthFlatAuthorization;
-
-class FlatAuthorization extends MythAuthFlatAuthorization
-{
-    /**
-     * The group model to use. Usually the class noted below (or an extension thereof) but can be any compatible
-     * CodeIgniter Model.
-     *
-     * @var PermissionModel
-     */
-    protected $permissionModel;
-
-    /**
-     * Checks a group to see if they have the specified permission.
-     */
-    public function groupHasPermission(int | string $permission, int $groupId): bool
-    {
-        // Get the Permission ID
-        $permissionId = $this->getPermissionID($permission);
-
-        if (! is_numeric($permissionId)) {
-            return false;
-        }
-
-        return $this->permissionModel->doesGroupHavePermission($groupId, $permissionId);
-    }
-
-    /**
-     * Makes user part of given groups.
-     *
-     * @param array<string, string> $groups Either collection of ID or names
-     */
-    public function setUserGroups(int $userId, array $groups = []): bool
-    {
-        // remove user from all groups before resetting it in new groups
-        $this->groupModel->removeUserFromAllGroups($userId);
-
-        if ($groups === []) {
-            return true;
-        }
-
-        foreach ($groups as $group) {
-            $this->addUserToGroup($userId, $group);
-        }
-
-        return true;
-    }
-}
diff --git a/modules/Auth/Authorization/GroupModel.php b/modules/Auth/Authorization/GroupModel.php
deleted file mode 100644
index 746185420384fda9286e6edf12c1f01c6c3a2850..0000000000000000000000000000000000000000
--- a/modules/Auth/Authorization/GroupModel.php
+++ /dev/null
@@ -1,30 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Modules\Auth\Authorization;
-
-use Myth\Auth\Authorization\GroupModel as MythAuthGroupModel;
-
-class GroupModel extends MythAuthGroupModel
-{
-    /**
-     * @return mixed[]
-     */
-    public function getContributorRoles(): array
-    {
-        return $this->select('auth_groups.*')
-            ->like('name', 'podcast_', 'after')
-            ->findAll();
-    }
-
-    /**
-     * @return mixed[]
-     */
-    public function getUserRoles(): array
-    {
-        return $this->select('auth_groups.*')
-            ->notLike('name', 'podcast_', 'after')
-            ->findAll();
-    }
-}
diff --git a/modules/Auth/Authorization/PermissionModel.php b/modules/Auth/Authorization/PermissionModel.php
deleted file mode 100644
index 01106c100481235f6887a987be80477cbadc256b..0000000000000000000000000000000000000000
--- a/modules/Auth/Authorization/PermissionModel.php
+++ /dev/null
@@ -1,53 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Modules\Auth\Authorization;
-
-use Myth\Auth\Authorization\PermissionModel as MythAuthPermissionModel;
-
-class PermissionModel extends MythAuthPermissionModel
-{
-    /**
-     * Checks to see if a user, or one of their groups, has a specific permission.
-     */
-    public function doesGroupHavePermission(int $groupId, int $permissionId): bool
-    {
-        // Check group permissions and take advantage of caching
-        $groupPerms = $this->getPermissionsForGroup($groupId);
-
-        return count($groupPerms) &&
-            array_key_exists($permissionId, $groupPerms);
-    }
-
-    /**
-     * Gets all permissions for a group in a way that can be easily used to check against:
-     *
-     * [ id => name, id => name ]
-     *
-     * @return array<int, string>
-     */
-    public function getPermissionsForGroup(int $groupId): array
-    {
-        $cacheName = "group{$groupId}_permissions";
-        if (! ($found = cache($cacheName))) {
-            $groupPermissions = $this->db
-                ->table('auth_groups_permissions')
-                ->select('id, auth_permissions.name')
-                ->join('auth_permissions', 'auth_permissions.id = permission_id', 'inner')
-                ->where('group_id', $groupId)
-                ->get()
-                ->getResultObject();
-
-            $found = [];
-            foreach ($groupPermissions as $row) {
-                $found[$row->id] = strtolower($row->name);
-            }
-
-            cache()
-                ->save($cacheName, $found, 300);
-        }
-
-        return $found;
-    }
-}
diff --git a/modules/Auth/Commands/RolesDoc.php b/modules/Auth/Commands/RolesDoc.php
new file mode 100644
index 0000000000000000000000000000000000000000..5326c2b127f9a53f2ffc178a0d00ca16e3270ca7
--- /dev/null
+++ b/modules/Auth/Commands/RolesDoc.php
@@ -0,0 +1,184 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Auth\Commands;
+
+use Closure;
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\View\Table;
+use Config\Services;
+use League\HTMLToMarkdown\Converter\TableConverter;
+use League\HTMLToMarkdown\HtmlConverter;
+use Modules\Auth\Config\AuthGroups;
+
+class RolesDoc extends BaseCommand
+{
+    /**
+     * @var array<string, string>
+     */
+    private const COMMENT_BLOCK_IDS = [
+        'instance_roles' => 'AUTH-INSTANCE-ROLES-LIST',
+        'instance_permissions' => 'AUTH-INSTANCE-PERMISSIONS-LIST',
+        'podcast_roles' => 'AUTH-PODCAST-ROLES-LIST',
+        'podcast_permissions' => 'AUTH-PODCAST-PERMISSIONS-LIST',
+    ];
+
+    /**
+     * @var string
+     */
+    protected $group = 'auth';
+
+    /**
+     * @var string
+     */
+    protected $name = 'auth:generate-doc';
+
+    /**
+     * @var string
+     */
+    protected $description = 'Generates the html table references for roles and permissions in the docs.';
+
+    public function run(array $params): void
+    {
+        // loop over all files in path
+        $defaultFile = glob(ROOTPATH . 'docs/src/getting-started/auth.md');
+        $localizedFiles = glob(ROOTPATH . 'docs/src/**/getting-started/auth.md') ?? [];
+        $files = array_merge($defaultFile, $localizedFiles);
+        CLI::write(implode(', ', $files));
+
+        if ($files === []) {
+            return;
+        }
+
+        foreach ($files as $file) {
+            $locale = $this->detectLocaleFromPath($file);
+            $language = Services::language();
+            $language->setLocale($locale);
+
+            $authGroups = new AuthGroups();
+
+            $fileContents = file_get_contents($file);
+
+            foreach (self::COMMENT_BLOCK_IDS as $key => $block_id) {
+                $pattern = '/(<!--\s' . $block_id . ':START.*-->)[\S\s]*(<!--\s' . $block_id . ':END.*-->)/';
+
+                $handleInjectMethod = 'handle' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key)));
+
+                $fileContents = $this->{$handleInjectMethod}($authGroups, $fileContents, $pattern);
+            }
+
+            // Write the contents back to the file
+            file_put_contents($file, $fileContents);
+        }
+    }
+
+    protected function handleInstanceRoles($authGroups, string $fileContents, string $pattern): string
+    {
+        $instanceMatrix = $authGroups->matrix;
+        return $this->renderCommentBlock(
+            $fileContents,
+            $pattern,
+            ['role', 'description', 'permissions'],
+            $authGroups->instanceGroups,
+            static function ($table, $key, $value) use ($instanceMatrix): void {
+                $table->addRow($value['title'], $value['description'], implode(', ', $instanceMatrix[$key]));
+            }
+        );
+    }
+
+    protected function handleInstancePermissions($authGroups, string $fileContents, string $pattern): string
+    {
+        return $this->renderCommentBlock(
+            $fileContents,
+            $pattern,
+            ['permission', 'description'],
+            $authGroups->instancePermissions,
+            static function ($table, $key, $value): void {
+                $table->addRow($key, $value);
+            }
+        );
+    }
+
+    protected function handlePodcastRoles($authGroups, string $fileContents, string $pattern): string
+    {
+        $podcastMatrix = $authGroups->podcastMatrix;
+        return $this->renderCommentBlock(
+            $fileContents,
+            $pattern,
+            ['role', 'description', 'permissions'],
+            $authGroups->podcastGroups,
+            static function ($table, $key, $value) use ($podcastMatrix): void {
+                $table->addRow($value['title'], $value['description'], implode(', ', $podcastMatrix[$key]));
+            }
+        );
+    }
+
+    protected function handlePodcastPermissions($authGroups, string $fileContents, string $pattern): string
+    {
+        return $this->renderCommentBlock(
+            $fileContents,
+            $pattern,
+            ['permission', 'description'],
+            $authGroups->podcastPermissions,
+            static function ($table, $key, $value): void {
+                $table->addRow($key, $value);
+            }
+        );
+    }
+
+    private function renderCommentBlock(
+        string $fileContents,
+        string $pattern,
+        array $tableHeading,
+        array $data,
+        Closure $callback
+    ): string {
+        // check if it has the start and end comments to insert roles table
+        // looking for <AUTH-INSTANCE-ROLES-LIST:START> and <AUTH-INSTANCE-ROLES-LIST:END>
+
+        $hasInstanceInsertComments = preg_match($pattern, $fileContents);
+
+        if (! $hasInstanceInsertComments) {
+            return $fileContents;
+        }
+
+        // prepare role table
+        $table = new Table();
+        $table->setHeading($tableHeading);
+
+        foreach ($data as $key => $value) {
+            $callback($table, $key, $value);
+        }
+
+        $converter = new HtmlConverter();
+        $converter->getEnvironment()
+            ->addConverter(new TableConverter());
+        $markdownTable = $converter->convert($table->generate());
+
+        // insert table between block comments
+        $newFileContents = preg_replace(
+            $pattern,
+            '${1}' . PHP_EOL . PHP_EOL . $markdownTable . PHP_EOL . PHP_EOL . '${2}',
+            $fileContents
+        );
+
+        if ($newFileContents === null) {
+            return $fileContents;
+        }
+
+        return $newFileContents;
+    }
+
+    private function detectLocaleFromPath($filePath): string
+    {
+        preg_match('~docs\/src\/(?:([a-z]{2}(?:-[A-Za-z]{2,})?)\/)getting-started\/auth\.md~', $filePath, $match);
+
+        if ($match === []) {
+            return 'en';
+        }
+
+        return $match[1];
+    }
+}
diff --git a/modules/Auth/Config/Auth.php b/modules/Auth/Config/Auth.php
index 11ffea8aa91600b6b3f99370df91555053224de5..e9017374296b69c0594e20a687b217111ea63b9f 100644
--- a/modules/Auth/Config/Auth.php
+++ b/modules/Auth/Config/Auth.php
@@ -4,46 +4,99 @@ declare(strict_types=1);
 
 namespace Modules\Auth\Config;
 
-use Myth\Auth\Config\Auth as MythAuthConfig;
+use CodeIgniter\Shield\Authentication\Actions\ActionInterface;
+use CodeIgniter\Shield\Config\Auth as ShieldAuth;
+use Modules\Auth\Models\UserModel;
 
-class Auth extends MythAuthConfig
+class Auth extends ShieldAuth
 {
     /**
-     * --------------------------------------------------------------------------
-     * Views used by Auth Controllers
-     * --------------------------------------------------------------------------
+     * ////////////////// AUTHENTICATION //////////////////
      *
      * @var array<string, string>
      */
-    public $views = [
+    public array $views = [
         'login' => 'login',
         'register' => 'register',
-        'forgot' => 'forgot',
-        'reset' => 'reset',
-        'emailForgot' => 'emails/forgot',
-        'emailActivation' => 'emails/activation',
+        'layout' => '_layout',
+        'action_email_2fa' => 'email_2fa_show',
+        'action_email_2fa_verify' => 'email_2fa_verify',
+        'action_email_2fa_email' => 'emails/email_2fa_email',
+        'action_email_activate_show' => 'email_activate_show',
+        'action_email_activate_email' => 'emails/email_activate_email',
+        'magic-link-login' => 'magic_link_form',
+        'magic-link-message' => 'magic_link_message',
+        'magic-link-email' => 'emails/magic_link_email',
+        'magic-link-set-password' => 'magic_link_set_password',
+        'welcome-email' => 'emails/welcome_email',
     ];
 
     /**
-     * --------------------------------------------------------------------------
-     * Layout for the views to extend
-     * --------------------------------------------------------------------------
+     * --------------------------------------------------------------------
+     * Redirect urLs
+     * --------------------------------------------------------------------
+     * The default URL that a user will be redirected to after
+     * various auth actions. If you need more flexibility you can
+     * override the `getUrl()` method to apply any logic you may need.
      *
-     * @var string
+     * @var array<string, string>
      */
-    public $viewLayout = '_layout';
+    public array $redirects = [
+        'register' => '/',
+        'login' => '/',
+        'logout' => 'login',
+    ];
 
     /**
-     * --------------------------------------------------------------------------
-     * 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.
+     * --------------------------------------------------------------------
+     * Authentication Actions
+     * --------------------------------------------------------------------
+     * Specifies the class that represents an action to take after
+     * the user logs in or registers a new account at the site.
+     *
+     * You must register actions in the order of the actions to be performed.
+     *
+     * Available actions with Shield:
+     * - register: 'CodeIgniter\Shield\Authentication\Actions\EmailActivator'
+     * - login:    'CodeIgniter\Shield\Authentication\Actions\Email2FA'
+     *
+     * @var array<string, class-string<ActionInterface>|null>
+     */
+    public array $actions = [
+        'register' => null,
+        'login' => null,
+    ];
+
+    /**
+     * --------------------------------------------------------------------
+     * Allow Registration
+     * --------------------------------------------------------------------
+     * Determines whether users can register for the site.
+     */
+    public bool $allowRegistration = true;
+
+    /**
+     * --------------------------------------------------------------------
+     * Welcome Link Lifetime
+     * --------------------------------------------------------------------
+     * Specifies the amount of time, in seconds, that a welcome link is valid.
+     * You can use Time Constants or any desired number.
+     */
+    public int $welcomeLinkLifetime = 48 * HOUR;
+
+    /**
+     * --------------------------------------------------------------------
+     * User Provider
+     * --------------------------------------------------------------------
+     * The name of the class that handles user persistence.
+     * By default, this is the included UserModel, which
+     * works with any of the database engines supported by CodeIgniter.
+     * You can change it as long as they adhere to the
+     * CodeIgniter\Shield\Models\UserModel.
      *
-     * @var bool
+     * @var class-string<UserModel>
      */
-    public $allowRegistration = false;
+    public string $userProvider = UserModel::class;
 
     /**
      * --------------------------------------------------------------------------
@@ -52,4 +105,28 @@ class Auth extends MythAuthConfig
      * Defines a base route for all authentication related pages
      */
     public string $gateway = 'cp-auth';
+
+    public function __construct()
+    {
+        $adminGateway = config('Admin')
+            ->gateway;
+
+        $this->redirects = [
+            'register' => $adminGateway,
+            'login' => $adminGateway,
+            'logout' => $adminGateway,
+        ];
+    }
+
+    /**
+     * Returns the URL that a user should be redirected to after a successful login.
+     *
+     * Redirects to the set-password form if magicLogin
+     */
+    public function loginRedirect(): string
+    {
+        $url = session('magicLogin') ? route_to('magic-link-set-password') : setting('Auth.redirects')['login'];
+
+        return $this->getUrl($url);
+    }
 }
diff --git a/modules/Auth/Config/AuthGroups.php b/modules/Auth/Config/AuthGroups.php
new file mode 100644
index 0000000000000000000000000000000000000000..cfbcbada5b83d0ec4668456dde1e2544c63e4e39
--- /dev/null
+++ b/modules/Auth/Config/AuthGroups.php
@@ -0,0 +1,285 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Auth\Config;
+
+use App\Models\PodcastModel;
+use CodeIgniter\Shield\Config\AuthGroups as ShieldAuthGroups;
+
+class AuthGroups extends ShieldAuthGroups
+{
+    /**
+     * --------------------------------------------------------------------
+     * Default Group
+     * --------------------------------------------------------------------
+     * The group that a newly registered user is added to.
+     */
+    public string $defaultGroup = 'podcaster';
+
+    /**
+     * --------------------------------------------------------------------
+     * Most powerful Group
+     * --------------------------------------------------------------------
+     * The group that a has the most permissions.
+     */
+    public string $mostPowerfulGroup = 'superadmin';
+
+    /**
+     * --------------------------------------------------------------------
+     * Default Podcast Group
+     * --------------------------------------------------------------------
+     * The group that a newly registered user is added to.
+     */
+    public string $defaultPodcastGroup = 'guest';
+
+    /**
+     * --------------------------------------------------------------------
+     * Most powerful Podcast Group
+     * --------------------------------------------------------------------
+     * The group that a has the most permissions on a podcast.
+     */
+    public string $mostPowerfulPodcastGroup = 'admin';
+
+    /**
+     * --------------------------------------------------------------------
+     * Groups
+     * --------------------------------------------------------------------
+     * The available authentication systems, listed
+     * with alias and class name. These can be referenced
+     * by alias in the auth helper:
+     *      auth('api')->attempt($credentials);
+     *
+     * @var array<string, array<string, string>>
+     */
+    public array $groups = [];
+
+    /**
+     * --------------------------------------------------------------------
+     * Permissions
+     * --------------------------------------------------------------------
+     * The available permissions in the system. Each system is defined
+     * where the key is the
+     *
+     * If a permission is not listed here it cannot be used.
+     *
+     * @var array<string, string>
+     */
+    public array $permissions = [];
+
+    /**
+     * --------------------------------------------------------------------
+     * Permissions Matrix
+     * --------------------------------------------------------------------
+     * Maps permissions to groups.
+     * @var array<string, array<string>>
+     */
+    public array $matrix = [];
+
+    /**
+     * @var array<string, array<string, string>>
+     */
+    public array $instanceGroups = [];
+
+    /**
+     * @var array<string, string>
+     */
+    public array $instancePermissions = [];
+
+    /**
+     * @var array<string, array<string, string>>
+     */
+    public array $podcastGroups = [];
+
+    /**
+     * @var array<string, string>
+     */
+    public array $podcastPermissions = [];
+
+    /**
+     * @var string[]
+     */
+    public array $instanceBaseGroups = ['superadmin', 'manager', 'podcaster'];
+
+    /**
+     * @var string[]
+     */
+    public array $instanceBasePermissions = [
+        'admin.access',
+        'admin.settings',
+        'users.manage',
+        'persons.manage',
+        'pages.manage',
+        'podcasts.view',
+        'podcasts.create',
+        'podcasts.import',
+        'fediverse.manage-blocks',
+    ];
+
+    /**
+     * @var array<string, array<string>>
+     */
+    public array $instanceMatrix = [
+        'superadmin' => [
+            'admin.*',
+            'podcasts.*',
+            'users.manage',
+            'persons.manage',
+            'pages.manage',
+            'fediverse.manage-blocks',
+        ],
+        'manager' => ['podcasts.create', 'podcasts.import', 'persons.manage', 'pages.manage'],
+        'podcaster' => ['admin.access'],
+    ];
+
+    /**
+     * @var string[]
+     */
+    public array $podcastBaseGroups = ['admin', 'editor', 'author', 'guest'];
+
+    /**
+     * @var string[]
+     */
+    public array $podcastBasePermissions = [
+        'view',
+        'edit',
+        'delete',
+        'manage-import',
+        'manage-persons',
+        'manage-subscriptions',
+        'manage-contributors',
+        'manage-platforms',
+        'manage-publications',
+        'interact-as',
+        'episodes.view',
+        'episodes.create',
+        'episodes.edit',
+        'episodes.delete',
+        'episodes.manage-persons',
+        'episodes.manage-clips',
+        'episodes.manage-publications',
+        'episodes.manage-comments',
+    ];
+
+    /**
+     * @var array<string, string[]>
+     */
+    public array $podcastMatrix = [
+        'admin' => ['*'],
+        'editor' => [
+            'view',
+            'edit',
+            'manage-import',
+            'manage-persons',
+            'manage-platforms',
+            'manage-publications',
+            'interact-as',
+            'episodes.view',
+            'episodes.create',
+            'episodes.edit',
+            'episodes.delete',
+            'episodes.manage-persons',
+            'episodes.manage-clips',
+            'episodes.manage-publications',
+            'episodes.manage-comments',
+        ],
+        'author' => [
+            'view',
+            'manage-persons',
+            'episodes.view',
+            'episodes.create',
+            'episodes.edit',
+            'episodes.manage-persons',
+            'episodes.manage-clips',
+        ],
+        'guest' => ['view', 'episodes.view'],
+    ];
+
+    /**
+     * Fill groups, permissions and matrix based on
+     */
+    public function __construct($locale = null)
+    {
+        parent::__construct();
+
+        foreach ($this->instanceBaseGroups as $group) {
+            $this->instanceGroups[$group] = [
+                'title' => lang("Auth.instance_groups.{$group}.title"),
+                'description' => lang("Auth.instance_groups.{$group}.description"),
+            ];
+        }
+
+        $this->groups = $this->instanceGroups;
+
+        foreach ($this->instanceBasePermissions as $permission) {
+            $this->instancePermissions[$permission] = lang("Auth.instance_permissions.{$permission}");
+            $this->permissions[$permission] = lang("Auth.instance_permissions.{$permission}");
+        }
+
+        $this->matrix = $this->instanceMatrix;
+
+        $this->generateBasePodcastAuthorizations();
+
+        /**
+         * For each podcast, include podcast groups, permissions, and matrix into $groups, $permissions, and $matrix
+         * attributes.
+         */
+        $podcasts = (new PodcastModel())->findAll();
+        foreach ($podcasts as $podcast) {
+            $this->generatePodcastAuthorizations($podcast->id, $locale);
+        }
+    }
+
+    public function generateBasePodcastAuthorizations(): void
+    {
+        foreach ($this->podcastBaseGroups as $group) {
+            $this->podcastGroups[$group] = [
+                'title' => lang("Auth.podcast_groups.{$group}.title", [
+                    'id' => '{id}',
+                ]),
+                'description' => lang("Auth.podcast_groups.{$group}.description", [
+                    'id' => '{id}',
+                ]),
+            ];
+        }
+
+        foreach ($this->podcastBasePermissions as $permission) {
+            $this->podcastPermissions[$permission] = lang("Auth.podcast_permissions.{$permission}", [
+                'id' => '{id}',
+            ]);
+            $this->permissions[$permission] = lang("Auth.podcast_permissions.{$permission}", [
+                'id' => '{id}',
+            ]);
+        }
+    }
+
+    public function generatePodcastAuthorizations(int $podcastId): void
+    {
+        foreach ($this->podcastBaseGroups as $group) {
+            $podcastGroup = 'podcast#' . $podcastId . '-' . $group;
+            $this->groups[$podcastGroup] = [
+                'title' => lang("Auth.podcast_groups.{$group}.title", [
+                    'id' => $podcastId,
+                ]),
+                'description' => lang("Auth.podcast_groups.{$group}.description", [
+                    'id' => $podcastId,
+                ]),
+            ];
+        }
+
+        foreach ($this->podcastBasePermissions as $permission) {
+            $podcastPermission = 'podcast#' . $podcastId . '.' . $permission;
+            $this->permissions[$podcastPermission] = lang("Auth.podcast_permissions.{$permission}", [
+                'id' => $podcastId,
+            ]);
+        }
+
+        foreach ($this->podcastMatrix as $group => $permissionWildcards) {
+            $podcastGroup = 'podcast#' . $podcastId . '-' . $group;
+            foreach ($permissionWildcards as $permissionWildcard) {
+                $podcastPermissionWildcard = 'podcast#' . $podcastId . '.' . $permissionWildcard;
+                $this->matrix[$podcastGroup][] = $podcastPermissionWildcard;
+            }
+        }
+    }
+}
diff --git a/modules/Auth/Config/AuthRoutes.php b/modules/Auth/Config/AuthRoutes.php
new file mode 100644
index 0000000000000000000000000000000000000000..314203fa628fede2e77fbe1476e2079286f8a425
--- /dev/null
+++ b/modules/Auth/Config/AuthRoutes.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Auth\Config;
+
+use CodeIgniter\Shield\Config\AuthRoutes as ShieldAuthRoutes;
+
+class AuthRoutes extends ShieldAuthRoutes
+{
+    public array $routes = [
+        'register' => [
+            ['get', 'register', 'RegisterController::registerView', 'register'],
+            ['post', 'register', 'RegisterController::registerAction'],
+        ],
+        'login' => [
+            ['get', 'login', 'LoginController::loginView', 'login'],
+            ['post', 'login', 'LoginController::loginAction'],
+        ],
+        'magic-link' => [
+            [
+                'get',
+                'login/magic-link',
+                'MagicLinkController::loginView',
+                'magic-link',        // Route name
+            ],
+            ['post', 'login/magic-link', 'MagicLinkController::loginAction'],
+            [
+                'get',
+                'login/verify-magic-link',
+                'MagicLinkController::verify',
+                'verify-magic-link', // Route name
+            ],
+        ],
+        'logout' => [['get', 'logout', 'LoginController::logoutAction', 'logout']],
+        'auth-actions' => [
+            ['get', 'auth/a/show', 'ActionController::show', 'auth-action-show'],
+            ['post', 'auth/a/handle', 'ActionController::handle', 'auth-action-handle'],
+            ['post', 'auth/a/verify', 'ActionController::verify', 'auth-action-verify'],
+        ],
+    ];
+}
diff --git a/modules/Auth/Config/Events.php b/modules/Auth/Config/Events.php
new file mode 100644
index 0000000000000000000000000000000000000000..36b0cf2ad9edda75f636469bf7d923972913ec67
--- /dev/null
+++ b/modules/Auth/Config/Events.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Auth\Config;
+
+use CodeIgniter\Events\Events;
+use CodeIgniter\Shield\Entities\User;
+
+Events::on('logout', static function (User $user): void {
+    helper('auth');
+    // remove user's interact_as_actor session
+    remove_interact_as_actor();
+});
diff --git a/modules/Auth/Config/Routes.php b/modules/Auth/Config/Routes.php
index 3a8ce6b2f10e40d446fb496337b87bff5a75d5e8..323ae34e79e233553fc8e6924b1cb4604146d6b4 100644
--- a/modules/Auth/Config/Routes.php
+++ b/modules/Auth/Config/Routes.php
@@ -6,52 +6,134 @@ namespace Modules\Auth\Config;
 
 $routes = service('routes');
 
-/**
- * Overwriting Myth:auth routes file
- */
+service('auth')
+    ->routes($routes);
+
+// Admin routes for users and podcast contributors
 $routes->group(
-    config('Auth')
+    config('Admin')
         ->gateway,
     [
         'namespace' => 'Modules\Auth\Controllers',
     ],
     static function ($routes): void {
-        // Login/out
-        $routes->get('login', 'AuthController::login', [
-            'as' => 'login',
-        ]);
-        $routes->post('login', 'AuthController::attemptLogin');
-        $routes->get('logout', 'AuthController::logout', [
-            'as' => 'logout',
-        ]);
-        // Registration
-        $routes->get('register', 'AuthController::register', [
-            'as' => 'register',
-        ]);
-        $routes->post('register', 'AuthController::attemptRegister');
-        // Activation
-        $routes->get('activate-account', 'AuthController::activateAccount', [
-            'as' => 'activate-account',
-        ]);
-        $routes->get(
-            'resend-activate-account',
-            'AuthController::resendActivateAccount',
-            [
-                'as' => 'resend-activate-account',
-            ],
-        );
-        // Forgot/Resets
-        $routes->get('forgot', 'AuthController::forgotPassword', [
-            'as' => 'forgot',
+        $routes->get('magic-link-set-password', 'MagicLinkController::setPasswordView', [
+            'as' => 'magic-link-set-password',
         ]);
-        $routes->post('forgot', 'AuthController::attemptForgot');
-        $routes->get('reset-password', 'AuthController::resetPassword', [
-            'as' => 'reset-password',
-        ]);
-        $routes->post('reset-password', 'AuthController::attemptReset');
-        // interacting as an actor
-        $routes->post('interact-as-actor', 'AuthController::attemptInteractAsActor', [
+        $routes->post('magic-link-set-password', 'MagicLinkController::setPasswordAction');
+
+        $routes->post('interact-as-actor', 'InteractController::attemptInteractAsActor', [
             'as' => 'interact-as-actor',
         ]);
+
+        // Users
+        $routes->group('users', static function ($routes): void {
+            $routes->get('/', 'UserController::list', [
+                'as' => 'user-list',
+                'filter' => 'permission:users.manage',
+            ]);
+            $routes->get('new', 'UserController::create', [
+                'as' => 'user-create',
+                'filter' => 'permission:users.manage',
+            ]);
+            $routes->post('new', 'UserController::attemptCreate', [
+                'filter' => 'permission:users.manage',
+            ]);
+            // User
+            $routes->group('(:num)', static function ($routes): void {
+                $routes->get('/', 'UserController::view/$1', [
+                    'as' => 'user-view',
+                    'filter' => 'permission:users.manage',
+                ]);
+                $routes->get('edit', 'UserController::edit/$1', [
+                    'as' => 'user-edit',
+                    'filter' => 'permission:users.manage',
+                ]);
+                $routes->post('edit', 'UserController::attemptEdit/$1', [
+                    'filter' => 'permission:users.manage',
+                ]);
+                $routes->get('delete', 'UserController::delete/$1', [
+                    'as' => 'user-delete',
+                    'filter' => 'permission:users.manage',
+                ]);
+                $routes->post('delete', 'UserController::attemptDelete/$1', [
+                    'as' => 'user-delete',
+                    'filter' => 'permission:users.manage',
+                ]);
+            });
+        });
+        // My account
+        $routes->group('my-account', static function ($routes): void {
+            $routes->get('/', 'MyAccountController', [
+                'as' => 'my-account',
+            ]);
+            $routes->get('change-password', 'MyAccountController::changePassword', [
+                'as' => 'change-password',
+            ],);
+            $routes->post('change-password', 'MyAccountController::attemptChange');
+        });
+
+        // Podcast contributors
+        $routes->group('podcasts/(:num)/contributors', static function ($routes): void {
+            $routes->get('/', 'ContributorController::list/$1', [
+                'as' => 'contributor-list',
+                'filter' =>
+                    'permission:podcast#.manage-contributors',
+            ]);
+            $routes->get('add', 'ContributorController::add/$1', [
+                'as' => 'contributor-add',
+                'filter' => 'permission:podcast#.manage-contributors',
+            ]);
+            $routes->post(
+                'add',
+                'ContributorController::attemptAdd/$1',
+                [
+                    'filter' =>
+                        'permission:podcast#.manage-contributors',
+                ],
+            );
+            // Contributor
+            $routes->group('(:num)', static function ($routes): void {
+                $routes->get('/', 'ContributorController::view/$1/$2', [
+                    'as' => 'contributor-view',
+                    'filter' =>
+                        'permission:podcast#.manage-contributors',
+                ]);
+                $routes->get(
+                    'edit',
+                    'ContributorController::edit/$1/$2',
+                    [
+                        'as' => 'contributor-edit',
+                        'filter' =>
+                            'permission:podcast#.manage-contributors',
+                    ],
+                );
+                $routes->post(
+                    'edit',
+                    'ContributorController::attemptEdit/$1/$2',
+                    [
+                        'filter' =>
+                            'permission:podcast#.manage-contributors',
+                    ],
+                );
+                $routes->get(
+                    'remove',
+                    'ContributorController::remove/$1/$2',
+                    [
+                        'as' => 'contributor-remove',
+                        'filter' =>
+                            'permission:podcast#.manage-contributors',
+                    ],
+                );
+                $routes->post(
+                    'remove',
+                    'ContributorController::attemptRemove/$1/$2',
+                    [
+                        'filter' =>
+                            'permission:podcast#.manage-contributors',
+                    ],
+                );
+            });
+        });
     }
 );
diff --git a/modules/Auth/Config/Services.php b/modules/Auth/Config/Services.php
index 71b8129c8cd851b791fea7409cb94528f7ce7fda..f3dd45f7942d8622cee29ee47d49a6642f9fa00d 100644
--- a/modules/Auth/Config/Services.php
+++ b/modules/Auth/Config/Services.php
@@ -4,87 +4,23 @@ declare(strict_types=1);
 
 namespace Modules\Auth\Config;
 
-use App\Models\UserModel;
-use CodeIgniter\Config\BaseService;
-use CodeIgniter\Model;
-use Modules\Auth\Authorization\FlatAuthorization;
-use Modules\Auth\Authorization\GroupModel;
-use Modules\Auth\Authorization\PermissionModel;
-use Myth\Auth\Models\LoginModel;
+use CodeIgniter\Shield\Authentication\Authentication;
+use Config\Services as BaseService;
+use Modules\Auth\Auth;
 
-/**
- * Services Configuration file.
- *
- * Services are simply other classes/libraries that the system uses to do its job. This is used by CodeIgniter to allow
- * the core of the framework to be swapped out easily without affecting the usage within the rest of your application.
- *
- * This file holds any application-specific services, or service overrides that you might need. An example has been
- * included with the general method format you should use for your service methods. For more examples, see the core
- * Services file at system/Config/Services.php.
- */
 class Services extends BaseService
 {
     /**
-     * @return mixed
+     * The base auth class
      */
-    public static function authentication(
-        string $lib = 'local',
-        Model $userModel = null,
-        Model $loginModel = null,
-        bool $getShared = true
-    ) {
+    public static function auth(bool $getShared = true): Auth
+    {
         if ($getShared) {
-            return self::getSharedInstance('authentication', $lib, $userModel, $loginModel);
+            return self::getSharedInstance('auth');
         }
 
-        // config() checks first in app/Config
         $config = config('Auth');
 
-        $class = $config->authenticationLibs[$lib];
-
-        $instance = new $class($config);
-
-        if ($userModel === null) {
-            $userModel = new UserModel();
-        }
-
-        if ($loginModel === null) {
-            $loginModel = new LoginModel();
-        }
-
-        return $instance->setUserModel($userModel)
-            ->setLoginModel($loginModel);
-    }
-
-    /**
-     * @return mixed|$this
-     */
-    public static function authorization(
-        Model $groupModel = null,
-        Model $permissionModel = null,
-        Model $userModel = null,
-        bool $getShared = true
-    ) {
-        if ($getShared) {
-            return self::getSharedInstance('authorization', $groupModel, $permissionModel, $userModel);
-        }
-
-        if ($groupModel === null) {
-            $groupModel = new GroupModel();
-        }
-
-        if ($permissionModel === null) {
-            $permissionModel = new PermissionModel();
-        }
-
-        /* @phpstan-ignore-next-line */
-        $instance = new FlatAuthorization($groupModel, $permissionModel);
-
-        if ($userModel === null) {
-            $userModel = new UserModel();
-        }
-
-        /* @phpstan-ignore-next-line */
-        return $instance->setUserModel($userModel);
+        return new Auth(new Authentication($config));
     }
 }
diff --git a/modules/Auth/Controllers/ActionController.php b/modules/Auth/Controllers/ActionController.php
new file mode 100644
index 0000000000000000000000000000000000000000..e9db4ab3ced93b54c9143b1d9322679f1e7dfac5
--- /dev/null
+++ b/modules/Auth/Controllers/ActionController.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Auth\Controllers;
+
+use CodeIgniter\HTTP\RequestInterface;
+use CodeIgniter\HTTP\ResponseInterface;
+use CodeIgniter\Shield\Controllers\ActionController as ShieldActionController;
+use Psr\Log\LoggerInterface;
+use ViewThemes\Theme;
+
+/**
+ * Class ActionController
+ *
+ * A generic controller to handle Authentication Actions.
+ */
+class ActionController extends ShieldActionController
+{
+    public function initController(
+        RequestInterface $request,
+        ResponseInterface $response,
+        LoggerInterface $logger
+    ): void {
+        parent::initController($request, $response, $logger);
+
+        Theme::setTheme('auth');
+    }
+}
diff --git a/modules/Auth/Controllers/AuthController.php b/modules/Auth/Controllers/AuthController.php
deleted file mode 100644
index b2b60aaa0e8d29271942f343492340ff6d66034e..0000000000000000000000000000000000000000
--- a/modules/Auth/Controllers/AuthController.php
+++ /dev/null
@@ -1,204 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-namespace Modules\Auth\Controllers;
-
-use CodeIgniter\HTTP\RedirectResponse;
-use Modules\Auth\Entities\User;
-use Myth\Auth\Controllers\AuthController as MythAuthController;
-use ViewThemes\Theme;
-
-class AuthController extends MythAuthController
-{
-    /**
-     * An array of helpers to be automatically loaded upon class instantiation.
-     *
-     * @var string[]
-     */
-    protected $helpers = ['components'];
-
-    public function __construct()
-    {
-        parent::__construct();
-
-        Theme::setTheme('auth');
-    }
-
-    /**
-     * Attempt to register a new user.
-     */
-    public function attemptRegister(): RedirectResponse
-    {
-        // Check if registration is allowed
-        if (! $this->config->allowRegistration) {
-            return redirect()
-                ->back()
-                ->withInput()
-                ->with('error', lang('Auth.registerDisabled'));
-        }
-
-        $users = model('UserModel');
-
-        // Validate here first, since some things,
-        // like the password, can only be validated properly here.
-        $rules = [
-            'username' =>
-                'required|alpha_numeric_space|min_length[3]|is_unique[users.username]',
-            'email' => 'required|valid_email|is_unique[users.email]',
-            'password' => 'required|strong_password',
-        ];
-
-        if (! $this->validate($rules)) {
-            return redirect()
-                ->back()
-                ->withInput()
-                ->with('errors', service('validation')->getErrors());
-        }
-
-        // Save the user
-        $allowedPostFields = array_merge(['password'], $this->config->validFields, $this->config->personalFields);
-        $user = new User($this->request->getPost($allowedPostFields));
-
-        $this->config->requireActivation === null
-            ? $user->activate()
-            : $user->generateActivateHash();
-
-        // Ensure default group gets assigned if set
-        if ($this->config->defaultUserGroup !== null) {
-            $users = $users->withGroup($this->config->defaultUserGroup);
-        }
-
-        if (! $users->save($user)) {
-            return redirect()
-                ->back()
-                ->withInput()
-                ->with('errors', $users->errors());
-        }
-
-        if ($this->config->requireActivation !== null) {
-            $activator = service('activator');
-            $sent = $activator->send($user);
-
-            if (! $sent) {
-                return redirect()
-                    ->back()
-                    ->withInput()
-                    ->with('error', $activator->error() ?? lang('Auth.unknownError'));
-            }
-
-            // Success!
-            return redirect()
-                ->route('login')
-                ->with('message', lang('Auth.activationSuccess'));
-        }
-
-        // Success!
-        return redirect()
-            ->route('login')
-            ->with('message', lang('Auth.registerSuccess'));
-    }
-
-    /**
-     * Verifies the code with the email and saves the new password, if they all pass validation.
-     */
-    public function attemptReset(): RedirectResponse
-    {
-        if ($this->config->activeResetter === null) {
-            return redirect()
-                ->route('login')
-                ->with('error', lang('Auth.forgotDisabled'));
-        }
-
-        $users = model('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',
-        ];
-
-        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 ($user === null) {
-            return redirect()
-                ->back()
-                ->with('error', lang('Auth.forgotNoUser'));
-        }
-
-        // Reset token still valid?
-        if (
-            $user->reset_expires !== null &&
-            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);
-
-        helper('auth');
-
-        // set interact_as_actor_id value
-        $userPodcasts = $user->podcasts;
-        if ($userPodcasts = $user->podcasts) {
-            set_interact_as_actor($userPodcasts[0]->actor_id);
-        }
-
-        return redirect()
-            ->route('login')
-            ->with('message', lang('Auth.resetSuccess'));
-    }
-
-    public function attemptInteractAsActor(): RedirectResponse
-    {
-        $rules = [
-            'actor_id' => 'required|numeric',
-        ];
-
-        if (! $this->validate($rules)) {
-            return redirect()
-                ->back()
-                ->withInput()
-                ->with('errors', service('validation')->getErrors());
-        }
-
-        helper('auth');
-
-        set_interact_as_actor((int) $this->request->getPost('actor_id'));
-
-        return redirect()->back();
-    }
-}
diff --git a/modules/Auth/Controllers/ContributorController.php b/modules/Auth/Controllers/ContributorController.php
new file mode 100644
index 0000000000000000000000000000000000000000..d40f05813ec854780a009e9898ec8918eaabb7ab
--- /dev/null
+++ b/modules/Auth/Controllers/ContributorController.php
@@ -0,0 +1,243 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\Auth\Controllers;
+
+use App\Entities\Podcast;
+use App\Models\PodcastModel;
+use CodeIgniter\Exceptions\PageNotFoundException;
+use CodeIgniter\HTTP\RedirectResponse;
+use CodeIgniter\Shield\Entities\User;
+use Modules\Admin\Controllers\BaseController;
+use Modules\Auth\Models\UserModel;
+
+class ContributorController extends BaseController
+{
+    protected Podcast $podcast;
+
+    protected ?User $contributor;
+
+    public function _remap(string $method, string ...$params): mixed
+    {
+        if ($params === []) {
+            throw PageNotFoundException::forPageNotFound();
+        }
+
+        if (($podcast = (new PodcastModel())->getPodcastById((int) $params[0])) === null) {
+            throw PageNotFoundException::forPageNotFound();
+        }
+
+        $this->podcast = $podcast;
+
+        if (count($params) <= 1) {
+            return $this->{$method}();
+        }
+
+        if (($this->contributor = (new UserModel())->getPodcastContributor(
+            (int) $params[1],
+            (int) $params[0]
+        )) !== null) {
+            return $this->{$method}();
+        }
+
+        throw PageNotFoundException::forPageNotFound();
+    }
+
+    public function list(): string
+    {
+        $data = [
+            'podcast' => $this->podcast,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->at_handle,
+        ]);
+        return view('contributor/list', $data);
+    }
+
+    public function view(): string
+    {
+        $data = [
+            'podcast' => $this->podcast,
+            'contributor' => (new UserModel())->getPodcastContributor($this->contributor->id, $this->podcast->id),
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->at_handle,
+            1 => $this->contributor->username,
+        ]);
+        return view('contributor/view', $data);
+    }
+
+    public function add(): string
+    {
+        helper('form');
+
+        $users = (new UserModel())->findAll();
+        $contributorOptions = array_reduce(
+            $users,
+            static function ($result, $user) {
+                $result[$user->id] = $user->username;
+                return $result;
+            },
+            [],
+        );
+
+        $roles = setting('AuthGroups.podcastBaseGroups');
+        $roleOptions = [];
+        array_walk(
+            $roles,
+            static function ($role, $key) use (&$roleOptions): array {
+                $roleOptions[$role] = lang('Auth.podcast_groups.' . $role . '.title');
+                return $roleOptions;
+            },
+            [],
+        );
+
+        $data = [
+            'podcast' => $this->podcast,
+            'contributorOptions' => $contributorOptions,
+            'roleOptions' => $roleOptions,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->at_handle,
+        ]);
+        return view('contributor/add', $data);
+    }
+
+    public function attemptAdd(): RedirectResponse
+    {
+        $user = (new UserModel())->find((int) $this->request->getPost('user'));
+
+        if (get_podcast_group($user, $this->podcast->id)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', [lang('Contributor.messages.alreadyAddedError')]);
+        }
+
+        add_podcast_group($user, $this->podcast->id, $this->request->getPost('role'));
+
+        return redirect()->route('contributor-list', [$this->podcast->id]);
+    }
+
+    public function edit(): string|RedirectResponse
+    {
+        helper('form');
+
+        $roles = setting('AuthGroups.podcastBaseGroups');
+        $roleOptions = [];
+        array_walk(
+            $roles,
+            static function ($role) use (&$roleOptions): array {
+                $roleOptions[$role] = lang('Auth.podcast_groups.' . $role . '.title');
+                return $roleOptions;
+            },
+            [],
+        );
+
+        $contributorGroup = get_podcast_group($this->contributor, $this->podcast->id);
+
+        if ($contributorGroup === null) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', [lang('Contributor.messages.notAddedError')]);
+        }
+
+        $data = [
+            'podcast' => $this->podcast,
+            'contributor' => $this->contributor,
+            'contributorGroup' => $contributorGroup,
+            'roleOptions' => $roleOptions,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->at_handle,
+            1 => $this->contributor->username,
+        ]);
+        return view('contributor/edit', $data);
+    }
+
+    public function attemptEdit(): RedirectResponse
+    {
+        // forbid updating a podcast owner
+        if ($this->podcast->created_by === $this->contributor->id) {
+            return redirect()
+                ->back()
+                ->with('errors', [lang('Contributor.messages.editOwnerError')]);
+        }
+
+        $group = $this->request->getPost('role');
+
+        set_podcast_group($this->contributor, $this->podcast->id, $group);
+
+        cache()
+            ->delete("podcast#{$this->podcast->id}_contributors");
+
+        return redirect()->route('contributor-list', [$this->podcast->id])->with(
+            'message',
+            lang('Contributor.messages.editSuccess')
+        );
+    }
+
+    public function remove(): string
+    {
+        helper('form');
+
+        $data = [
+            'podcast' => $this->podcast,
+            'contributor' => $this->contributor,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->at_handle,
+            1 => $this->contributor->username,
+        ]);
+        return view('contributor/delete', $data);
+    }
+
+    public function attemptRemove(): RedirectResponse
+    {
+        if ($this->podcast->created_by === $this->contributor->id) {
+            return redirect()
+                ->back()
+                ->with('errors', [lang('Contributor.messages.removeOwnerError')]);
+        }
+
+        $rules = [
+            'understand' => 'required',
+        ];
+
+        if (! $this->validate($rules)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $this->validator->getErrors());
+        }
+
+        cache()
+            ->delete("podcast#{$this->podcast->id}_contributors");
+
+        // remove contributor from podcast group
+        $this->contributor->removeGroup(get_podcast_group($this->contributor, $this->podcast->id));
+
+        return redirect()
+            ->route('contributor-list', [$this->podcast->id])
+            ->with(
+                'message',
+                lang('Contributor.messages.removeSuccess', [
+                    'username' => $this->contributor->username,
+                    'podcastTitle' => $this->podcast->title,
+                ]),
+            );
+    }
+}
diff --git a/modules/Auth/Controllers/InteractController.php b/modules/Auth/Controllers/InteractController.php
new file mode 100644
index 0000000000000000000000000000000000000000..b917149b04efae4ba5fcfcec80ac38b4741c8b07
--- /dev/null
+++ b/modules/Auth/Controllers/InteractController.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Auth\Controllers;
+
+use CodeIgniter\Controller;
+use CodeIgniter\HTTP\RedirectResponse;
+
+/**
+ * Class ActionController
+ *
+ * A generic controller to handle Authentication Actions.
+ */
+class InteractController extends Controller
+{
+    public function attemptInteractAsActor(): RedirectResponse
+    {
+        $rules = [
+            'actor_id' => 'required|numeric',
+        ];
+
+        if (! $this->validate($rules)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', service('validation')->getErrors());
+        }
+
+        helper('auth');
+
+        set_interact_as_actor((int) $this->request->getPost('actor_id'));
+
+        return redirect()->back();
+    }
+}
diff --git a/modules/Auth/Controllers/LoginController.php b/modules/Auth/Controllers/LoginController.php
new file mode 100644
index 0000000000000000000000000000000000000000..75163cd6dc17c85cc7b61a46f65b2029166ebd06
--- /dev/null
+++ b/modules/Auth/Controllers/LoginController.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Auth\Controllers;
+
+use CodeIgniter\HTTP\RequestInterface;
+use CodeIgniter\HTTP\ResponseInterface;
+use CodeIgniter\Shield\Controllers\LoginController as ShieldLoginController;
+use Psr\Log\LoggerInterface;
+use ViewThemes\Theme;
+
+class LoginController extends ShieldLoginController
+{
+    public function initController(
+        RequestInterface $request,
+        ResponseInterface $response,
+        LoggerInterface $logger
+    ): void {
+        parent::initController($request, $response, $logger);
+
+        Theme::setTheme('auth');
+    }
+}
diff --git a/modules/Auth/Controllers/MagicLinkController.php b/modules/Auth/Controllers/MagicLinkController.php
new file mode 100644
index 0000000000000000000000000000000000000000..6033a44897f6cdba70ba5bcb471a09bbc6edc05f
--- /dev/null
+++ b/modules/Auth/Controllers/MagicLinkController.php
@@ -0,0 +1,76 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Auth\Controllers;
+
+use CodeIgniter\HTTP\RedirectResponse;
+use CodeIgniter\HTTP\RequestInterface;
+use CodeIgniter\HTTP\ResponseInterface;
+use CodeIgniter\Shield\Controllers\MagicLinkController as ShieldMagicLinkController;
+use Modules\Auth\Models\UserModel;
+use Psr\Log\LoggerInterface;
+use ViewThemes\Theme;
+
+/**
+ * Handles "Magic Link" logins - an email-based no-password login protocol. This works much like password reset would,
+ * but Shield provides this in place of password reset. It can also be used on it's own without an email/password login
+ * strategy.
+ */
+class MagicLinkController extends ShieldMagicLinkController
+{
+    public function initController(
+        RequestInterface $request,
+        ResponseInterface $response,
+        LoggerInterface $logger
+    ): void {
+        parent::initController($request, $response, $logger);
+
+        Theme::setTheme('auth');
+    }
+
+    public function setPasswordView(): string | RedirectResponse
+    {
+        if (! session('magicLogin')) {
+            return redirect()->to(config('Auth')->loginRedirect());
+        }
+
+        return view(setting('Auth.views')['magic-link-set-password']);
+    }
+
+    public function setPasswordAction(): RedirectResponse
+    {
+        $rules = [
+            'new_password' => 'required|strong_password',
+        ];
+
+        $userModel = new UserModel();
+        if (! $this->validate($rules)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $userModel->errors());
+        }
+
+        // set new password to user
+        auth()
+            ->user()
+            ->password = $this->request->getPost('new_password');
+
+        if (! $userModel->update(auth()->user()->id, auth()->user())) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $userModel->errors());
+        }
+
+        // remove magic login session to reinstate normal check
+        if (session('magicLogin')) {
+            session()->removeTempdata('magicLogin');
+        }
+
+        // Success!
+        return redirect()->to(config('Auth')->loginRedirect())
+            ->with('message', lang('MyAccount.messages.passwordChangeSuccess'));
+    }
+}
diff --git a/modules/Admin/Controllers/MyAccountController.php b/modules/Auth/Controllers/MyAccountController.php
similarity index 73%
rename from modules/Admin/Controllers/MyAccountController.php
rename to modules/Auth/Controllers/MyAccountController.php
index 8ffa0247fca93ee1dc721456399e9c213707bf2c..afa368caf9e28242b5c77e3f6b910bf6e44726b3 100644
--- a/modules/Admin/Controllers/MyAccountController.php
+++ b/modules/Auth/Controllers/MyAccountController.php
@@ -8,11 +8,11 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-namespace Modules\Admin\Controllers;
+namespace Modules\Auth\Controllers;
 
-use App\Models\UserModel;
 use CodeIgniter\HTTP\RedirectResponse;
-use Config\Services;
+use Modules\Admin\Controllers\BaseController;
+use Modules\Auth\Models\UserModel;
 
 class MyAccountController extends BaseController
 {
@@ -30,16 +30,12 @@ class MyAccountController extends BaseController
 
     public function attemptChange(): RedirectResponse
     {
-        $auth = Services::authentication();
-        $userModel = new UserModel();
-
-        // Validate here first, since some things,
-        // like the password, can only be validated properly here.
         $rules = [
             'password' => 'required',
             'new_password' => 'required|strong_password|differs[password]',
         ];
 
+        $userModel = new UserModel();
         if (! $this->validate($rules)) {
             return redirect()
                 ->back()
@@ -47,23 +43,28 @@ class MyAccountController extends BaseController
                 ->with('errors', $userModel->errors());
         }
 
+        // check credentials with the old password if logged in without magic link
         $credentials = [
-            'email' => user()
+            'email' => auth()
+                ->user()
                 ->email,
             'password' => $this->request->getPost('password'),
         ];
 
-        if (! $auth->validate($credentials)) {
-            return redirect()
-                ->back()
-                ->withInput()
+        $validCreds = auth()
+            ->check($credentials);
+
+        if (! $validCreds->isOK()) {
+            return redirect()->back()
                 ->with('error', lang('MyAccount.messages.wrongPasswordError'));
         }
 
-        user()
+        // set new password to user
+        auth()
+            ->user()
             ->password = $this->request->getPost('new_password');
 
-        if (! $userModel->update(user_id(), user())) {
+        if (! $userModel->update(auth()->user()->id, auth()->user())) {
             return redirect()
                 ->back()
                 ->withInput()
diff --git a/modules/Auth/Controllers/RegisterController.php b/modules/Auth/Controllers/RegisterController.php
new file mode 100644
index 0000000000000000000000000000000000000000..b54c0d9ceff9a40345d2b3e6692635fbff4e4740
--- /dev/null
+++ b/modules/Auth/Controllers/RegisterController.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Modules\Auth\Controllers;
+
+use CodeIgniter\HTTP\RequestInterface;
+use CodeIgniter\HTTP\ResponseInterface;
+use CodeIgniter\Shield\Controllers\RegisterController as ShieldRegisterController;
+use Psr\Log\LoggerInterface;
+use ViewThemes\Theme;
+
+/**
+ * Class RegisterController
+ *
+ * Handles displaying registration form, and handling actual registration flow.
+ */
+class RegisterController extends ShieldRegisterController
+{
+    public function initController(
+        RequestInterface $request,
+        ResponseInterface $response,
+        LoggerInterface $logger
+    ): void {
+        parent::initController($request, $response, $logger);
+
+        Theme::setTheme('auth');
+    }
+}
diff --git a/modules/Auth/Controllers/UserController.php b/modules/Auth/Controllers/UserController.php
new file mode 100644
index 0000000000000000000000000000000000000000..78a03c73ae190b118fa848c682baec76c097ea23
--- /dev/null
+++ b/modules/Auth/Controllers/UserController.php
@@ -0,0 +1,276 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\Auth\Controllers;
+
+use CodeIgniter\Exceptions\PageNotFoundException;
+use CodeIgniter\HTTP\RedirectResponse;
+use CodeIgniter\I18n\Time;
+use CodeIgniter\Shield\Authentication\Authenticators\Session;
+use CodeIgniter\Shield\Entities\User;
+use CodeIgniter\Shield\Exceptions\ValidationException;
+use CodeIgniter\Shield\Models\UserIdentityModel;
+use Modules\Admin\Controllers\BaseController;
+use Modules\Auth\Models\UserModel;
+
+class UserController extends BaseController
+{
+    protected ?User $user;
+
+    public function _remap(string $method, string ...$params): mixed
+    {
+        if ($params === []) {
+            return $this->{$method}();
+        }
+
+        if ($this->user = (new UserModel())->find($params[0])) {
+            return $this->{$method}();
+        }
+
+        throw PageNotFoundException::forPageNotFound();
+    }
+
+    public function list(): string
+    {
+        $data = [
+            'users' => (new UserModel())->findAll(),
+        ];
+
+        return view('user/list', $data);
+    }
+
+    public function view(): string
+    {
+        $data = [
+            'user' => $this->user,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->user->username,
+        ]);
+        return view('user/view', $data);
+    }
+
+    public function create(): string
+    {
+        helper('form');
+
+        $roles = setting('AuthGroups.instanceGroups');
+        $roleOptions = [];
+        array_walk(
+            $roles,
+            static function ($role, $key) use (&$roleOptions): array {
+                $roleOptions[$key] = $role['title'];
+                return $roleOptions;
+            },
+            [],
+        );
+
+        $data = [
+            'roleOptions' => $roleOptions,
+        ];
+
+        return view('user/create', $data);
+    }
+
+    /**
+     * Create the user with the provided username and email. The password is set as a random string and a magic link is
+     * sent to the user to allow them setting their password.
+     */
+    public function attemptCreate(): RedirectResponse
+    {
+        helper('text');
+
+        $db = db_connect();
+        $db->transStart();
+
+        $userModel = new UserModel();
+
+        // Save the user
+        $email = $this->request->getPost('email');
+        $user = new User([
+            'username' => $this->request->getPost('username'),
+            'email' => $email,
+            // set a random password
+            // user will be prompted to change it on first magic link login.
+            'password' => random_string('alnum', 32),
+        ]);
+        try {
+            $userModel->save($user);
+        } catch (ValidationException) {
+            return redirect()->back()
+                ->withInput()
+                ->with('errors', $userModel->errors());
+        }
+
+        $user = $userModel->findById($userModel->getInsertID());
+        $user->addGroup($this->request->getPost('role'));
+
+        // **** SEND WELCOME LINK FOR FIRST LOGIN ****
+
+        /** @var UserIdentityModel $identityModel */
+        $identityModel = model(UserIdentityModel::class);
+
+        // Delete any previous magic-link identities
+        $identityModel->deleteIdentitiesByType($user, Session::ID_TYPE_MAGIC_LINK);
+
+        // Generate the code and save it as an identity
+        $token = random_string('crypto', 20);
+
+        $identityModel->insert([
+            'user_id' => $user->id,
+            'type' => Session::ID_TYPE_MAGIC_LINK,
+            'secret' => $token,
+            'expires' => Time::now()->addSeconds(setting('Auth.welcomeLinkLifetime'))->format('Y-m-d H:i:s'),
+        ]);
+
+        // Send the user an email with the code
+        $email = emailer()
+            ->setFrom(setting('Email.fromEmail'), setting('Email.fromName') ?? '');
+        $email->setTo($user->email);
+        $email->setSubject(lang('Auth.welcomeSubject', [
+            'siteName' => setting('App.siteName'),
+        ], null, false));
+        $email->setMessage(view(setting('Auth.views')['welcome-email'], [
+            'token' => $token,
+        ], [
+            'theme' => 'auth',
+        ]));
+
+        if (! $email->send(false)) {
+            log_message('error', $email->printDebugger(['headers']));
+
+            return redirect()->back()
+                ->with('error', lang('Auth.unableSendEmailToUser', [$user->email]));
+        }
+
+        // Clear the email
+        $email->clear();
+
+        $db->transComplete();
+
+        // Success!
+        return redirect()
+            ->route('user-list')
+            ->with('message', lang('User.messages.createSuccess', [
+                'username' => $user->username,
+            ]));
+    }
+
+    public function edit(): string
+    {
+        helper('form');
+
+        $roles = setting('AuthGroups.instanceGroups');
+        $roleOptions = [];
+        array_walk(
+            $roles,
+            static function ($role, $key) use (&$roleOptions): array {
+                $roleOptions[$key] = $role['title'];
+                return $roleOptions;
+            },
+            [],
+        );
+
+        $data = [
+            'user' => $this->user,
+            'roleOptions' => $roleOptions,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->user->username,
+        ]);
+        return view('user/edit', $data);
+    }
+
+    public function attemptEdit(): RedirectResponse
+    {
+        // The instance owner is a superadmin and the only user that cannot be demoted.
+        if ((bool) $this->user->is_owner) {
+            return redirect()
+                ->back()
+                ->with('errors', [
+                    lang('User.messages.editOwnerError', [
+                        'username' => $this->user->username,
+                    ]),
+                ]);
+        }
+
+        $group = $this->request->getPost('role');
+
+        set_instance_group($this->user, $group);
+
+        // Success!
+        return redirect()
+            ->route('user-list')
+            ->with('message', lang('User.messages.roleEditSuccess', [
+                'username' => $this->user->username,
+            ]));
+    }
+
+    public function delete(): string
+    {
+        helper(['form']);
+
+        $data = [
+            'user' => $this->user,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->user->username,
+        ]);
+        return view('user/delete', $data);
+    }
+
+    public function attemptDelete(): RedirectResponse
+    {
+        // You cannot delete the instance owner.
+        if ((bool) $this->user->is_owner) {
+            return redirect()
+                ->back()
+                ->with('errors', [
+                    lang('User.messages.deleteOwnerError', [
+                        'username' => $this->user->username,
+                    ]),
+                ]);
+        }
+
+        // You cannot delete a superadmin
+        // superadmin has to be demoted before being deleted
+        if ($this->user->inGroup(setting('AuthGroups.mostPowerfulPodcastGroup'))) {
+            return redirect()
+                ->back()
+                ->with('errors', [
+                    lang('User.messages.deleteSuperAdminError', [
+                        'username' => $this->user->username,
+                    ]),
+                ]);
+        }
+
+        $rules = [
+            'understand' => 'required',
+        ];
+
+        if (! $this->validate($rules)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $this->validator->getErrors());
+        }
+
+        (new UserModel())->delete($this->user->id, true);
+
+        return redirect()
+            ->route('user-list')
+            ->with('message', lang('User.messages.deleteSuccess', [
+                'username' => $this->user->username,
+            ]));
+    }
+}
diff --git a/modules/Auth/Database/Migrations/2020-07-03-191500_add_podcasts_users.php b/modules/Auth/Database/Migrations/2020-07-03-191500_add_podcasts_users.php
deleted file mode 100644
index b6535c4adac24bed0e3f3eb3f21a6f4cf60dab4b..0000000000000000000000000000000000000000
--- a/modules/Auth/Database/Migrations/2020-07-03-191500_add_podcasts_users.php
+++ /dev/null
@@ -1,46 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * Class AddPodcastUsers Creates podcast_users table in database
- *
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-namespace Modules\Auth\Database\Migrations;
-
-use CodeIgniter\Database\Migration;
-
-class AddPodcastsUsers extends Migration
-{
-    public function up(): void
-    {
-        $this->forge->addField([
-            'podcast_id' => [
-                'type' => 'INT',
-                'unsigned' => true,
-            ],
-            'user_id' => [
-                'type' => 'INT',
-                'unsigned' => true,
-            ],
-            'group_id' => [
-                'type' => 'INT',
-                'unsigned' => true,
-            ],
-        ]);
-        $this->forge->addPrimaryKey(['user_id', 'podcast_id']);
-        $this->forge->addForeignKey('user_id', 'users', 'id', '', 'CASCADE');
-        $this->forge->addForeignKey('podcast_id', 'podcasts', 'id', '', 'CASCADE');
-        $this->forge->addForeignKey('group_id', 'auth_groups', 'id', '', 'CASCADE');
-        $this->forge->createTable('podcasts_users');
-    }
-
-    public function down(): void
-    {
-        $this->forge->dropTable('podcasts_users');
-    }
-}
diff --git a/modules/Auth/Database/Migrations/2020-12-29-100000_add_is_owner_to_users.php b/modules/Auth/Database/Migrations/2020-12-29-100000_add_is_owner_to_users.php
new file mode 100644
index 0000000000000000000000000000000000000000..aa587ba4225fe6e904afe80620d2555c427939e5
--- /dev/null
+++ b/modules/Auth/Database/Migrations/2020-12-29-100000_add_is_owner_to_users.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Database\Migrations;
+
+use CodeIgniter\Database\Migration;
+
+// Add custom column for shield
+class AddCustomColumnForUser extends Migration
+{
+    public function up(): void
+    {
+        $fields = [
+            'is_owner' => [
+                'type' => 'TINYINT',
+                'constraint' => 1,
+                'default' => 0,
+                'null' => false,
+            ],
+        ];
+
+        $this->forge->addColumn('users', $fields);
+    }
+
+    public function down(): void
+    {
+        $fields = ['is_owner'];
+        $this->forge->dropColumn('users', $fields);
+    }
+}
diff --git a/modules/Auth/Database/Seeds/.gitkeep b/modules/Auth/Database/Seeds/.gitkeep
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/modules/Auth/Database/Seeds/AuthSeeder.php b/modules/Auth/Database/Seeds/AuthSeeder.php
deleted file mode 100644
index 976c904b72433976d0452139658479f7f989768d..0000000000000000000000000000000000000000
--- a/modules/Auth/Database/Seeds/AuthSeeder.php
+++ /dev/null
@@ -1,302 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * Class PermissionSeeder Inserts permissions
- *
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-namespace Modules\Auth\Database\Seeds;
-
-use CodeIgniter\Database\Seeder;
-
-class AuthSeeder extends Seeder
-{
-    /**
-     * @var array<string, string>[]
-     */
-    protected array $groups = [
-        [
-            'name' => 'superadmin',
-            'description' =>
-                'Somebody who has access to all the castopod instance features',
-        ],
-        [
-            'name' => 'podcast_admin',
-            'description' =>
-                'Somebody who has access to all the features within a given podcast',
-        ],
-    ];
-
-    /**
-     * Build permissions array as a list of:
-     *
-     * ``` context => [ [action, description], [action, description], ... ] ```
-     *
-     * @var array<string, array<string, string|array>[]>
-     */
-    protected array $permissions = [
-        'users' => [
-            [
-                'name' => 'create',
-                'description' => 'Create a user',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'list',
-                'description' => 'List all users',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'view',
-                'description' => 'View any user info',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'manage_authorizations',
-                'description' => 'Add or remove roles/permissions to a user',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'manage_bans',
-                'description' => 'Ban / unban a user',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'force_pass_reset',
-                'description' =>
-                    'Force a user to update his password upon next login',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'delete',
-                'description' =>
-                    'Delete user without removing him from database',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'delete_permanently',
-                'description' =>
-                    'Delete all occurrences of a user from the database',
-                'has_permission' => ['superadmin'],
-            ],
-        ],
-        'pages' => [
-            [
-                'name' => 'manage',
-                'description' => 'List / create / edit / delete pages',
-                'has_permission' => ['superadmin'],
-            ],
-        ],
-        'podcasts' => [
-            [
-                'name' => 'create',
-                'description' => 'Add a new podcast',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'import',
-                'description' => 'Import a new podcast from an external feed',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'list',
-                'description' => 'List all podcasts and their episodes',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'view',
-                'description' => 'View any podcast and their contributors list',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'delete',
-                'description' => 'Delete any podcast from the database',
-                'has_permission' => ['superadmin'],
-            ],
-        ],
-        'episodes' => [
-            [
-                'name' => 'list',
-                'description' => 'List all episodes of any podcast',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'view',
-                'description' => 'View any episode of any podcast',
-                'has_permission' => ['superadmin'],
-            ],
-        ],
-        'podcast' => [
-            [
-                'name' => 'view',
-                'description' => 'View a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'edit',
-                'description' => 'Edit a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'manage_contributors',
-                'description' =>
-                    'Add / remove contributors to a podcast and edit their roles',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'manage_platforms',
-                'description' => 'Set / remove platform links of a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'manage_publications',
-                'description' =>
-                    'Publish / unpublish episodes & posts of a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'interact_as',
-                'description' =>
-                    'Interact as the podcast to favourite / share or reply to posts.',
-                'has_permission' => ['podcast_admin'],
-            ],
-        ],
-        'podcast_episodes' => [
-            [
-                'name' => 'list',
-                'description' => 'List all episodes of a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'view',
-                'description' => 'View any episode of a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'create',
-                'description' => 'Add new episodes for a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'edit',
-                'description' => 'Edit an episode of a podcast',
-                'has_permission' => ['podcast_admin'],
-            ],
-            [
-                'name' => 'delete',
-                'description' =>
-                    'Delete all occurrences of an episode of a podcast from the database',
-                'has_permission' => ['podcast_admin'],
-            ],
-        ],
-        'person' => [
-            [
-                'name' => 'create',
-                'description' => 'Add a new person',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'list',
-                'description' => 'List all persons',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'view',
-                'description' => 'View any person',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'edit',
-                'description' => 'Edit a person',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'delete',
-                'description' =>
-                    'Delete permanently any person from the database',
-                'has_permission' => ['superadmin'],
-            ],
-        ],
-        'fediverse' => [
-            [
-                'name' => 'block_actors',
-                'description' =>
-                    'Block fediverse actors from interacting with the instance.',
-                'has_permission' => ['superadmin'],
-            ],
-            [
-                'name' => 'block_domains',
-                'description' =>
-                    'Block fediverse domains from interacting with the instance.',
-                'has_permission' => ['superadmin'],
-            ],
-        ],
-    ];
-
-    public function run(): void
-    {
-        $groupId = 0;
-        $dataGroups = [];
-        foreach ($this->groups as $group) {
-            $dataGroups[] = [
-                'id' => ++$groupId,
-                'name' => $group['name'],
-                'description' => $group['description'],
-            ];
-        }
-
-        // Map permissions to a format the `auth_permissions` table expects
-        $dataPermissions = [];
-        $dataGroupsPermissions = [];
-        $permissionId = 0;
-        foreach ($this->permissions as $context => $actions) {
-            foreach ($actions as $action) {
-                $dataPermissions[] = [
-                    'id' => ++$permissionId,
-                    'name' => $context . '-' . $action['name'],
-                    'description' => $action['description'],
-                ];
-
-                foreach ($action['has_permission'] as $role) {
-                    // link permission to specified groups
-                    $dataGroupsPermissions[] = [
-                        'group_id' => $this->getGroupIdByName($role, $dataGroups),
-                        'permission_id' => $permissionId,
-                    ];
-                }
-            }
-        }
-
-        $this->db
-            ->table('auth_permissions')
-            ->ignore(true)
-            ->insertBatch($dataPermissions);
-        $this->db
-            ->table('auth_groups')
-            ->ignore(true)
-            ->insertBatch($dataGroups);
-        $this->db
-            ->table('auth_groups_permissions')
-            ->ignore(true)
-            ->insertBatch($dataGroupsPermissions);
-    }
-
-    /**
-     * @param array<string, string|int>[] $dataGroups
-     */
-    public static function getGroupIdByName(string $name, array $dataGroups): ?int
-    {
-        foreach ($dataGroups as $group) {
-            if ($group['name'] === $name) {
-                return $group['id'];
-            }
-        }
-
-        return null;
-    }
-}
diff --git a/modules/Auth/Entities/User.php b/modules/Auth/Entities/User.php
deleted file mode 100644
index 312bde685a6c50db1f31b03c00907a104ef93b61..0000000000000000000000000000000000000000
--- a/modules/Auth/Entities/User.php
+++ /dev/null
@@ -1,109 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright  2020 Ad Aures
- * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
- * @link       https://castopod.org/
- */
-
-namespace Modules\Auth\Entities;
-
-use App\Entities\Podcast;
-use App\Models\PodcastModel;
-use App\Models\UserModel;
-use Modules\Fediverse\Models\NotificationModel;
-use Myth\Auth\Entities\User as MythAuthUser;
-use RuntimeException;
-
-/**
- * @property int $id
- * @property string $username
- * @property string $email
- * @property string $password
- * @property bool $active
- * @property bool $force_pass_reset
- * @property int|null $podcast_id
- * @property string|null $podcast_role
- *
- * @property Podcast[] $podcasts All podcasts the user is contributing to
- * @property int[] $actorIdsWithUnreadNotifications Ids of the user's actors that have unread notifications
- */
-class User extends MythAuthUser
-{
-    public bool $is_owner;
-
-    /**
-     * @var Podcast[]|null
-     */
-    protected ?array $podcasts = null;
-
-    /**
-     * @var int[]|null
-     */
-    protected ?array $actorIdsWithUnreadNotifications = null;
-
-    /**
-     * Array of field names and the type of value to cast them as when they are accessed.
-     *
-     * @var array<string, string>
-     */
-    protected $casts = [
-        'id' => 'integer',
-        'active' => 'boolean',
-        'force_pass_reset' => 'boolean',
-        'podcast_id' => '?integer',
-        'podcast_role' => '?string',
-    ];
-
-    public function getIsOwner(): bool
-    {
-        $firstUser = (new UserModel())->first();
-
-        if (! $firstUser instanceof self) {
-            return false;
-        }
-
-        return $this->username === $firstUser->username;
-    }
-
-    /**
-     * Returns the podcasts the user is contributing to
-     *
-     * @return Podcast[]
-     */
-    public function getPodcasts(): array
-    {
-        if ($this->id === null) {
-            throw new RuntimeException('Users must be created before getting podcasts.');
-        }
-
-        if ($this->podcasts === null) {
-            $this->podcasts = (new PodcastModel())->getUserPodcasts($this->id);
-        }
-
-        return $this->podcasts;
-    }
-
-    /**
-     * Returns the ids of the user's actors that have unread notifications
-     *
-     * @return int[]
-     */
-    public function getActorIdsWithUnreadNotifications(): array
-    {
-        if ($this->getPodcasts() === []) {
-            return [];
-        }
-
-        $unreadNotifications = (new NotificationModel())->whereIn(
-            'target_actor_id',
-            array_column($this->getPodcasts(), 'actor_id')
-        )
-            ->where('read_at', null)
-            ->findAll();
-
-        return array_column($unreadNotifications, 'target_actor_id');
-    }
-}
diff --git a/modules/Auth/Filters/PermissionFilter.php b/modules/Auth/Filters/PermissionFilter.php
index 198247987a2b5c20e33dd4c1419b247281d64b77..44d7b7853f0236f34a4735549d302815010db350 100644
--- a/modules/Auth/Filters/PermissionFilter.php
+++ b/modules/Auth/Filters/PermissionFilter.php
@@ -8,8 +8,8 @@ use App\Models\PodcastModel;
 use CodeIgniter\Filters\FilterInterface;
 use CodeIgniter\HTTP\RequestInterface;
 use CodeIgniter\HTTP\ResponseInterface;
+use CodeIgniter\Shield\Exceptions\RuntimeException;
 use Config\Services;
-use Myth\Auth\Exceptions\PermissionException;
 
 class PermissionFilter implements FilterInterface
 {
@@ -24,62 +24,49 @@ class PermissionFilter implements FilterInterface
      */
     public function before(RequestInterface $request, $params = null)
     {
-        helper('auth');
-
-        if ($params === null) {
+        if (empty($params)) {
             return;
         }
 
-        $authenticate = Services::authentication();
+        if (! function_exists('auth')) {
+            helper('auth');
+        }
 
-        // if no user is logged in then send to the login form
-        if (! $authenticate->check()) {
-            session()->set('redirect_url', current_url());
-            return redirect('login');
+        if (! auth()->loggedIn()) {
+            return redirect()->to('login');
         }
 
-        helper('misc');
-        $authorize = Services::authorization();
-        $router = Services::router();
-        $routerParams = $router->params();
-        $result = false;
+        $result = true;
 
-        // Check if user has at least one of the permissions
         foreach ($params as $permission) {
-            // check if permission is for a specific podcast
-            if (
-                (str_starts_with($permission, 'podcast-') ||
-                    str_starts_with($permission, 'podcast_episodes-')) &&
-                $routerParams !== []
-            ) {
-                if (
-                    ($groupId = (new PodcastModel())->getContributorGroupId(
-                        $authenticate->id(),
-                        $routerParams[0],
-                    )) &&
-                    $authorize->groupHasPermission($permission, $groupId)
-                ) {
-                    $result = true;
-                    break;
+            // does permission is specific to a podcast?
+            if (str_contains($permission, '#')) {
+                $router = Services::router();
+                $routerParams = $router->params();
+
+                // get podcast id
+                $podcastId = null;
+                if (is_numeric($routerParams[0])) {
+                    $podcastId = (int) $routerParams[0];
+                } else {
+                    $podcast = (new PodcastModel())->getPodcastByHandle($routerParams[0]);
+                    if ($podcast !== null) {
+                        $podcastId = $podcast->id;
+                    }
+                }
+
+                if ($podcastId !== null) {
+                    $permission = str_replace('#', '#' . $podcastId, $permission);
                 }
-            } elseif (
-                $authorize->hasPermission($permission, $authenticate->id())
-            ) {
-                $result = true;
-                break;
             }
+
+            $result = $result && auth()
+                ->user()
+                ->can($permission);
         }
 
         if (! $result) {
-            if ($authenticate->silent()) {
-                $redirectURL = session('redirect_url') ?? '/';
-                unset($_SESSION['redirect_url']);
-                return redirect()
-                    ->to($redirectURL)
-                    ->with('error', lang('Auth.notEnoughPrivilege'));
-            }
-
-            throw new PermissionException(lang('Auth.notEnoughPrivilege'));
+            throw new RuntimeException(lang('Auth.notEnoughPrivilege'), 403);
         }
     }
 
diff --git a/modules/Auth/Helpers/auth_helper.php b/modules/Auth/Helpers/auth_helper.php
new file mode 100644
index 0000000000000000000000000000000000000000..f382e3f48db281231823c57e458801389aba513f
--- /dev/null
+++ b/modules/Auth/Helpers/auth_helper.php
@@ -0,0 +1,296 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+use App\Entities\Podcast;
+use App\Models\ActorModel;
+use App\Models\PodcastModel;
+use CodeIgniter\Shield\Entities\User;
+use Modules\Auth\Auth;
+use Modules\Fediverse\Entities\Actor;
+use Modules\Fediverse\Models\NotificationModel;
+
+if (! function_exists('auth')) {
+    /**
+     * Provides convenient access to the main Auth class for CodeIgniter Shield.
+     *
+     * @param string|null $alias Authenticator alias
+     */
+    function auth(?string $alias = null): Auth
+    {
+        /** @var Auth $auth */
+        $auth = service('auth');
+
+        return $auth->setAuthenticator($alias);
+    }
+}
+
+if (! function_exists('set_interact_as_actor')) {
+    /**
+     * Sets the actor id of which the user is acting as
+     */
+    function set_interact_as_actor(int $actorId): void
+    {
+        if (auth()->loggedIn()) {
+            session()
+                ->set('interact_as_actor_id', $actorId);
+        }
+    }
+}
+
+if (! function_exists('remove_interact_as_actor')) {
+    /**
+     * Removes the actor id of which the user is acting as
+     */
+    function remove_interact_as_actor(): void
+    {
+        session()->remove('interact_as_actor_id');
+    }
+}
+
+if (! function_exists('interact_as_actor_id')) {
+    /**
+     * Sets the podcast id of which the user is acting as
+     */
+    function interact_as_actor_id(): ?int
+    {
+        return session()->get('interact_as_actor_id');
+    }
+}
+
+if (! function_exists('interact_as_actor')) {
+    /**
+     * Get the actor the user is currently interacting as
+     */
+    function interact_as_actor(): Actor | false
+    {
+        if (! auth()->loggedIn()) {
+            return false;
+        }
+
+        $session = session();
+        if (! $session->has('interact_as_actor_id')) {
+            return false;
+        }
+
+        return model(ActorModel::class, false)->getActorById($session->get('interact_as_actor_id'));
+    }
+}
+
+if (! function_exists('can_user_interact')) {
+    function can_user_interact(): bool
+    {
+        return (bool) interact_as_actor();
+    }
+}
+
+if (! function_exists('add_podcast_group')) {
+    function add_podcast_group(User $user, int $podcastId, string $group): User
+    {
+        $podcastGroup = 'podcast#' . $podcastId . '-' . $group;
+
+        return $user->addGroup($podcastGroup);
+    }
+}
+
+if (! function_exists('get_instance_group')) {
+    function get_instance_group(User $user): ?string
+    {
+        $instanceGroups = array_filter($user->getGroups() ?? [], static function ($group): bool {
+            return ! str_starts_with($group, 'podcast#');
+        });
+
+        if ($instanceGroups === []) {
+            return null;
+        }
+
+        $instanceGroup = array_shift($instanceGroups);
+
+        // Verify that a user belongs to one group only!
+        if ($instanceGroups !== []) {
+            // remove any other group the user belongs to
+            $user->removeGroup(...$instanceGroups);
+        }
+
+        return $instanceGroup;
+    }
+}
+
+if (! function_exists('set_instance_group')) {
+    function set_instance_group(User $user, string $group): User
+    {
+        // remove old instance group
+        if (get_instance_group($user)) {
+            $user->removeGroup(get_instance_group($user));
+        }
+
+        // set new group
+        return $user->addGroup($group);
+    }
+}
+
+if (! function_exists('get_podcast_group')) {
+    function get_podcast_group(User $user, int $podcastId): ?string
+    {
+        $podcastGroups = array_filter($user->getGroups() ?? [], static function ($group) use ($podcastId): bool {
+            return str_starts_with($group, "podcast#{$podcastId}");
+        });
+
+        if ($podcastGroups === []) {
+            return null;
+        }
+
+        $podcastGroup = array_shift($podcastGroups);
+
+        // Verify that a user belongs to one group only!
+        if ($podcastGroups !== []) {
+            // remove any other group the user belongs to
+            $user->removeGroup(...$podcastGroups);
+        }
+
+        // strip the `podcast#{id}.` prefix when returning group
+        return substr($podcastGroup, strlen('podcast#' . $podcastId . '-'));
+    }
+}
+
+if (! function_exists('set_podcast_group')) {
+    function set_podcast_group(User $user, int $podcastId, string $group): User
+    {
+        // remove old instance group
+        $user->removeGroup("podcast#{$podcastId}-" . get_podcast_group($user, $podcastId));
+
+        // set new group
+        return add_podcast_group($user, $podcastId, $group);
+    }
+}
+
+if (! function_exists('get_podcast_groups')) {
+    /**
+     * @return string[]
+     */
+    function get_user_podcast_ids(User $user): array
+    {
+        $podcastGroups = array_filter($user->getGroups() ?? [], static function ($group): bool {
+            return str_starts_with($group, 'podcast#');
+        });
+
+        $userPodcastIds = [];
+        // extract all podcast ids from groups
+        foreach ($podcastGroups as $podcastGroup) {
+            $userPodcastIds[] = substr($podcastGroup, strpos($podcastGroup, '#') + 1, 1);
+        }
+
+        return $userPodcastIds;
+    }
+}
+
+if (! function_exists('can_podcast')) {
+    function can_podcast(User $user, int $podcastId, string $permission): bool
+    {
+        return $user->can('podcast#' . $podcastId . '.' . $permission);
+    }
+}
+
+if (! function_exists('get_user_podcasts')) {
+    /**
+     * Returns the podcasts the user is contributing to
+     *
+     * @return Podcast[]
+     */
+    function get_user_podcasts(User $user): array
+    {
+        return (new PodcastModel())->getUserPodcasts($user->id, get_user_podcast_ids($user));
+    }
+}
+
+if (! function_exists('get_podcasts_user_can_interact_with')) {
+    /**
+     * @return Podcast[]
+     */
+    function get_podcasts_user_can_interact_with(User $user): array
+    {
+        $userPodcasts = (new PodcastModel())->getUserPodcasts($user->id, get_user_podcast_ids($user));
+
+        $hasInteractAsPrivilege = interact_as_actor_id() === null;
+
+        if ($userPodcasts === []) {
+            if ($hasInteractAsPrivilege) {
+                remove_interact_as_actor();
+            }
+
+            return [];
+        }
+
+        $isInteractAsPrivilegeLost = true;
+        $podcastsUserCanInteractWith = [];
+        foreach ($userPodcasts as $userPodcast) {
+            if (can_podcast($user, $userPodcast->id, 'interact-as')) {
+                if (interact_as_actor_id() === $userPodcast->actor_id) {
+                    $isInteractAsPrivilegeLost = false;
+                }
+
+                $podcastsUserCanInteractWith[] = $userPodcast;
+            }
+        }
+
+        if ($podcastsUserCanInteractWith === []) {
+            if (interact_as_actor_id() !== null) {
+                remove_interact_as_actor();
+            }
+
+            return [];
+        }
+
+        // check if user has lost the interact as privilege for current podcast actor.
+        // --> Remove interact as if there's no podcast actor to interact as
+        // or set the first podcast actor the user can interact as
+        if ($isInteractAsPrivilegeLost) {
+            set_interact_as_actor($podcastsUserCanInteractWith[0]->actor_id);
+        }
+
+        return $podcastsUserCanInteractWith;
+    }
+}
+
+if (! function_exists('get_actor_ids_with_unread_notifications')) {
+    /**
+     * Returns the ids of the user's actors that have unread notifications
+     *
+     * @return int[]
+     */
+    function get_actor_ids_with_unread_notifications(User $user): array
+    {
+        if (($userPodcasts = get_user_podcasts($user)) === []) {
+            return [];
+        }
+
+        $unreadNotifications = (new NotificationModel())->whereIn(
+            'target_actor_id',
+            array_column($userPodcasts, 'actor_id')
+        )
+            ->where('read_at', null)
+            ->findAll();
+
+        return array_column($unreadNotifications, 'target_actor_id');
+    }
+}
+
+if (! function_exists('get_group_title')) {
+    /**
+     * @return array<'title'|'description', string>
+     */
+    function get_group_info(string $group, ?int $podcastId = null): array
+    {
+        if ($podcastId === null) {
+            return setting('AuthGroups.instanceGroups')[$group];
+        }
+
+        return setting('AuthGroups.podcastGroups')[$group];
+    }
+}
diff --git a/modules/Admin/Language/ar/Contributor.php b/modules/Auth/Language/ar/Contributor.php
similarity index 100%
rename from modules/Admin/Language/ar/Contributor.php
rename to modules/Auth/Language/ar/Contributor.php
diff --git a/modules/Admin/Language/ar/MyAccount.php b/modules/Auth/Language/ar/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/ar/MyAccount.php
rename to modules/Auth/Language/ar/MyAccount.php
diff --git a/modules/Admin/Language/ar/User.php b/modules/Auth/Language/ar/User.php
similarity index 90%
rename from modules/Admin/Language/ar/User.php
rename to modules/Auth/Language/ar/User.php
index f2a3340986d0dd2c6507b9dd91f2b1f3ab3c7d4e..7607ad977c0a273648094492c95bd314903e49ae 100644
--- a/modules/Admin/Language/ar/User.php
+++ b/modules/Auth/Language/ar/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Edit {username}'s roles",
-    'forcePassReset' => 'Force pass reset',
     'ban' => 'Ban',
     'unban' => 'Unban',
     'delete' => 'احذف',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
         'banSuccess' => '{username} has been banned.',
         'unbanSuccess' => '{username} has been unbanned.',
         'editOwnerError' =>
diff --git a/modules/Admin/Language/br/Contributor.php b/modules/Auth/Language/br/Contributor.php
similarity index 100%
rename from modules/Admin/Language/br/Contributor.php
rename to modules/Auth/Language/br/Contributor.php
diff --git a/modules/Admin/Language/br/MyAccount.php b/modules/Auth/Language/br/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/br/MyAccount.php
rename to modules/Auth/Language/br/MyAccount.php
diff --git a/modules/Admin/Language/br/User.php b/modules/Auth/Language/br/User.php
similarity index 90%
rename from modules/Admin/Language/br/User.php
rename to modules/Auth/Language/br/User.php
index 314e676a18d0a65029f9fe83a21ea269830b17a6..1fa37c2ae9b17bcea95fc8242599f1dd897fd66e 100644
--- a/modules/Admin/Language/br/User.php
+++ b/modules/Auth/Language/br/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Kemm rolloù {username}",
-    'forcePassReset' => 'Force pass reset',
     'ban' => 'Ban',
     'unban' => 'Unban',
     'delete' => 'Dilemel',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
         'banSuccess' => '{username} has been banned.',
         'unbanSuccess' => '{username} has been unbanned.',
         'editOwnerError' =>
diff --git a/modules/Admin/Language/ca/Contributor.php b/modules/Auth/Language/ca/Contributor.php
similarity index 100%
rename from modules/Admin/Language/ca/Contributor.php
rename to modules/Auth/Language/ca/Contributor.php
diff --git a/modules/Admin/Language/ca/MyAccount.php b/modules/Auth/Language/ca/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/ca/MyAccount.php
rename to modules/Auth/Language/ca/MyAccount.php
diff --git a/modules/Admin/Language/ca/User.php b/modules/Auth/Language/ca/User.php
similarity index 88%
rename from modules/Admin/Language/ca/User.php
rename to modules/Auth/Language/ca/User.php
index 9e2f172a1701d37195ab057aeb415fc91e7112e5..d0065c45ed437520eb7506e7328f0dc48544e1b4 100644
--- a/modules/Admin/Language/ca/User.php
+++ b/modules/Auth/Language/ca/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Editar els rols de {username}",
-    'forcePassReset' => 'Força el restabliment de la contrasenya',
     'ban' => 'Bandejar',
     'unban' => 'Re-admetre',
     'delete' => 'Eliminar',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             'S\'ha creat l\'usuari! Es demanarà a {username} un restabliment de la contrasenya durant la primera autenticació.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "S'han actualitzat correctament els rols de {username}.",
-        'forcePassResetSuccess' =>
-            'Es demanarà a {username} un restabliment de contrasenya durant la següent visita.',
         'banSuccess' => '{username} ha estat bandejat.',
         'unbanSuccess' => '{username} ha estat desbandejat.',
         'editOwnerError' =>
diff --git a/modules/Admin/Language/de/Contributor.php b/modules/Auth/Language/de/Contributor.php
similarity index 100%
rename from modules/Admin/Language/de/Contributor.php
rename to modules/Auth/Language/de/Contributor.php
diff --git a/modules/Admin/Language/de/MyAccount.php b/modules/Auth/Language/de/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/de/MyAccount.php
rename to modules/Auth/Language/de/MyAccount.php
diff --git a/modules/Admin/Language/de/User.php b/modules/Auth/Language/de/User.php
similarity index 88%
rename from modules/Admin/Language/de/User.php
rename to modules/Auth/Language/de/User.php
index a4d261bee4cbe4ef71d991617c5dd123c6dc8702..f6337ed47078bb4fb2b997d0f5968d772dbf4b3e 100644
--- a/modules/Admin/Language/de/User.php
+++ b/modules/Auth/Language/de/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Bearbeite {username}'s Rollen",
-    'forcePassReset' => 'Erzwinge Pass-Zurücksetzung',
     'ban' => 'Bannen',
     'unban' => 'Entbannen',
     'delete' => 'Löschen',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             'Benutzer wurde erfolgreich erstellt! {username} wird bei der ersten Authentifizierung zu einer Passwortzurücksetzung aufgefordert.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "{username}'s Rollen wurden erfolgreich aktualisiert.",
-        'forcePassResetSuccess' =>
-            '{username} wird beim nächsten Besuch zu einem Zurücksetzen des Passworts aufgefordert.',
         'banSuccess' => '{username} wurde gebannt.',
         'unbanSuccess' => '{username} wurde entbannt.',
         'editOwnerError' =>
diff --git a/modules/Admin/Language/el/Contributor.php b/modules/Auth/Language/el/Contributor.php
similarity index 100%
rename from modules/Admin/Language/el/Contributor.php
rename to modules/Auth/Language/el/Contributor.php
diff --git a/modules/Admin/Language/el/MyAccount.php b/modules/Auth/Language/el/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/el/MyAccount.php
rename to modules/Auth/Language/el/MyAccount.php
diff --git a/modules/Admin/Language/gd/User.php b/modules/Auth/Language/el/User.php
similarity index 89%
rename from modules/Admin/Language/gd/User.php
rename to modules/Auth/Language/el/User.php
index 585d6799fc98d3f7c485c26b9ed1439b28813a3b..fe3a9e1a65af49b66bdd092e694b5d0f05673aa6 100644
--- a/modules/Admin/Language/gd/User.php
+++ b/modules/Auth/Language/el/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Edit {username}'s roles",
-    'forcePassReset' => 'Force pass reset',
     'ban' => 'Ban',
     'unban' => 'Unban',
     'delete' => 'Delete',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
         'banSuccess' => '{username} has been banned.',
         'unbanSuccess' => '{username} has been unbanned.',
         'editOwnerError' =>
diff --git a/modules/Auth/Language/en/Auth.php b/modules/Auth/Language/en/Auth.php
new file mode 100644
index 0000000000000000000000000000000000000000..6928cf9b10fa8c3fd3ba766c1063fab806edcf4a
--- /dev/null
+++ b/modules/Auth/Language/en/Auth.php
@@ -0,0 +1,89 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2022 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'instance_groups' => [
+        'owner' => [
+            'title' => 'Instance Owner',
+            'description' => 'The Castopod owner.',
+        ],
+        'superadmin' => [
+            'title' => 'Super admin',
+            'description' => 'Has complete control over Castopod.',
+        ],
+        'manager' => [
+            'title' => 'Manager',
+            'description' => 'Manages Castopod\'s content.',
+        ],
+        'podcaster' => [
+            'title' => 'Podcaster',
+            'description' => 'General users of Castopod.',
+        ],
+    ],
+    'instance_permissions' => [
+        'admin.access' => 'Can access the Castopod admin area.',
+        'admin.settings' => 'Can access the Castopod settings.',
+        'users.manage' => 'Can manage Castopod users.',
+        'persons.manage' => 'Can manage persons.',
+        'pages.manage' => 'Can manage pages.',
+        'podcasts.view' => 'Can view all podcasts.',
+        'podcasts.create' => 'Can create new podcasts.',
+        'podcasts.import' => 'Can import podcasts.',
+        'fediverse.manage-blocks' => 'Can block fediverse actors/domains from interacting with Castopod.',
+    ],
+    'podcast_groups' => [
+        'owner' => [
+            'title' => 'Podcast Owner',
+            'description' => 'The podcast owner.',
+        ],
+        'admin' => [
+            'title' => 'Admin',
+            'description' => 'Has complete control of podcast #{id}.',
+        ],
+        'editor' => [
+            'title' => 'Editor',
+            'description' => 'Manages content and publications of podcast #{id}.',
+        ],
+        'author' => [
+            'title' => 'Author',
+            'description' => 'Manages content of podcast #{id} but cannot publish them.',
+        ],
+        'guest' => [
+            'title' => 'Guest',
+            'description' => 'General contributor of the podcast #{id}.',
+        ],
+    ],
+    'podcast_permissions' => [
+        'view' => 'Can view dashboard and analytics of podcast #{id}.',
+        'edit' => 'Can edit podcast #{id}.',
+        'delete' => 'Can delete podcast #{id}.',
+        'manage-import' => 'Can synchronize imported podcast #{id}.',
+        'manage-persons' => 'Can manage subscriptions of podcast #{id}.',
+        'manage-subscriptions' => 'Can manage subscriptions of podcast #{id}.',
+        'manage-contributors' => 'Can manage contributors of podcast #{id}.',
+        'manage-platforms' => 'Can set/remove platform links of podcast #{id}.',
+        'manage-publications' => 'Can publish podcast #{id}.',
+        'interact-as' => 'Can interact as the podcast #{id} to favourite, share or reply to posts.',
+        'episodes.view' => 'Can view dashboards and analytics of podcast #{id}\'s episodes.',
+        'episodes.create' => 'Can create episodes for podcast #{id}.',
+        'episodes.edit' => 'Can edit episodes of podcast #{id}.',
+        'episodes.delete' => 'Can delete episodes of podcast #{id}.',
+        'episodes.manage-persons' => 'Can manage episode persons of podcast #{id}.',
+        'episodes.manage-clips' => 'Can manage video clips or soundbites of podcast #{id}.',
+        'episodes.manage-publications' => 'Can publish/unpublish episodes and posts of podcast #{id}.',
+        'episodes.manage-comments' => 'Can create/remove episode comments of podcast #{id}.',
+    ],
+    'notEnoughPrivilege' => 'You do not have sufficient permissions to access that page.',
+    'set_password' => 'Set your password',
+
+    // Welcome email
+    'welcomeSubject' => 'You\'ve been invited to {siteName}',
+    'emailWelcomeMailBody' => 'An account was created for you on {domain}, click on the login link below to set your password. The link is valid for {numberOfHours} hours after this email was sent.',
+];
diff --git a/modules/Admin/Language/sv/Contributor.php b/modules/Auth/Language/en/Contributor.php
similarity index 70%
rename from modules/Admin/Language/sv/Contributor.php
rename to modules/Auth/Language/en/Contributor.php
index d0f3b93d9ff29bbf2086380e374c7e35b5c7539a..c70badc0a12e55a270b55c3450314d06e6c328c8 100644
--- a/modules/Admin/Language/sv/Contributor.php
+++ b/modules/Auth/Language/en/Contributor.php
@@ -28,10 +28,16 @@ return [
         'submit_add' => 'Add contributor',
         'submit_edit' => 'Update role',
     ],
-    'roles' => [
-        'podcast_admin' => 'Podcast admin',
+    'delete_form' => [
+        'title' => 'Remove {contributor}',
+        'disclaimer' =>
+            'You are about to remove {contributor} from contributors. They will not be able to access "{podcastTitle}" anymore.',
+        'understand' => 'I understand, I want to remove {contributor} from "{podcastTitle}"',
+        'submit' => 'Remove',
     ],
     'messages' => [
+        'editSuccess' => 'Role successfully changed!',
+        'editOwnerError' => "You can't edit the podcast owner!",
         'removeOwnerError' => "You can't remove the podcast owner!",
         'removeSuccess' =>
             'You have successfully removed {username} from {podcastTitle}',
diff --git a/modules/Admin/Language/en/MyAccount.php b/modules/Auth/Language/en/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/en/MyAccount.php
rename to modules/Auth/Language/en/MyAccount.php
diff --git a/modules/Auth/Language/en/User.php b/modules/Auth/Language/en/User.php
new file mode 100644
index 0000000000000000000000000000000000000000..32ec560cd59e47e5413f93f5e941d2708534db99
--- /dev/null
+++ b/modules/Auth/Language/en/User.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'edit_role' => "Edit {username}'s role",
+    'ban' => 'Ban',
+    'unban' => 'Unban',
+    'delete' => 'Delete',
+    'create' => 'New user',
+    'view' => "{username}'s info",
+    'all_users' => 'All users',
+    'list' => [
+        'user' => 'User',
+        'role' => 'Role',
+        'banned' => 'Banned?',
+    ],
+    'form' => [
+        'email' => 'Email',
+        'username' => 'Username',
+        'password' => 'Password',
+        'new_password' => 'New Password',
+        'role' => 'Role',
+        'roles' => 'Roles',
+        'permissions' => 'Permissions',
+        'submit_create' => 'Create user',
+        'submit_edit' => 'Save',
+        'submit_password_change' => 'Change!',
+    ],
+    'delete_form' => [
+        'title' => 'Delete {user}',
+        'disclaimer' =>
+            "You are about to delete {user} permanently. They will not be able to access the admin area anymore.",
+        'understand' => 'I understand, I want to delete {user} permanently',
+        'submit' => 'Delete',
+    ],
+    'messages' => [
+        'createSuccess' =>
+            'User created successfully! A welcome email was sent to {username} with a login link, they will be prompted with a password reset upon first authentication.',
+        'roleEditSuccess' =>
+            "{username}'s roles have been successfully updated.",
+        'banSuccess' => '{username} has been banned.',
+        'unbanSuccess' => '{username} has been unbanned.',
+        'editOwnerError' =>
+            '{username} is the instance owner, one does not simply touch the owner…',
+        'banSuperAdminError' =>
+            '{username} is a superadmin, one does not simply ban a superadmin…',
+        'deleteOwnerError' =>
+            '{username} is the instance owner, one does not simply delete the owner…',
+        'deleteSuperAdminError' =>
+            '{username} is a superadmin, one does not simply delete a superadmin…',
+        'deleteSuccess' => '{username} has been deleted.',
+    ],
+];
diff --git a/modules/Admin/Language/es/Contributor.php b/modules/Auth/Language/es/Contributor.php
similarity index 100%
rename from modules/Admin/Language/es/Contributor.php
rename to modules/Auth/Language/es/Contributor.php
diff --git a/modules/Admin/Language/es/MyAccount.php b/modules/Auth/Language/es/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/es/MyAccount.php
rename to modules/Auth/Language/es/MyAccount.php
diff --git a/modules/Admin/Language/es/User.php b/modules/Auth/Language/es/User.php
similarity index 88%
rename from modules/Admin/Language/es/User.php
rename to modules/Auth/Language/es/User.php
index 1b37eec25024e68f356389a5674bda74d2d60b6d..cf00d9481eb549b6b49a1d9bc155b68d3689e800 100644
--- a/modules/Admin/Language/es/User.php
+++ b/modules/Auth/Language/es/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Editar rol de {username}",
-    'forcePassReset' => 'Forzar el reseteo de la contraseña',
     'ban' => 'Banear',
     'unban' => 'Desbanear',
     'delete' => 'Borrar',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             '¡Usuario creado con éxito! Se le pedirá a {username} que restablezca la contraseña en la primera autenticación.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "Los roles de {username} se han actualizado correctamente.",
-        'forcePassResetSuccess' =>
-            'Se pedirá a {username} que restablezca su contraseña en la próxima visita.',
         'banSuccess' => '{username} ha sido baneado.',
         'unbanSuccess' => '{username} ha sido desbaneado.',
         'editOwnerError' =>
diff --git a/modules/Admin/Language/en/Contributor.php b/modules/Auth/Language/fa/Contributor.php
similarity index 100%
rename from modules/Admin/Language/en/Contributor.php
rename to modules/Auth/Language/fa/Contributor.php
diff --git a/modules/Admin/Language/fa/MyAccount.php b/modules/Auth/Language/fa/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/fa/MyAccount.php
rename to modules/Auth/Language/fa/MyAccount.php
diff --git a/modules/Admin/Language/el/User.php b/modules/Auth/Language/fa/User.php
similarity index 89%
rename from modules/Admin/Language/el/User.php
rename to modules/Auth/Language/fa/User.php
index 585d6799fc98d3f7c485c26b9ed1439b28813a3b..fe3a9e1a65af49b66bdd092e694b5d0f05673aa6 100644
--- a/modules/Admin/Language/el/User.php
+++ b/modules/Auth/Language/fa/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Edit {username}'s roles",
-    'forcePassReset' => 'Force pass reset',
     'ban' => 'Ban',
     'unban' => 'Unban',
     'delete' => 'Delete',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
         'banSuccess' => '{username} has been banned.',
         'unbanSuccess' => '{username} has been unbanned.',
         'editOwnerError' =>
diff --git a/modules/Admin/Language/fr/Contributor.php b/modules/Auth/Language/fr/Contributor.php
similarity index 100%
rename from modules/Admin/Language/fr/Contributor.php
rename to modules/Auth/Language/fr/Contributor.php
diff --git a/modules/Admin/Language/fr/MyAccount.php b/modules/Auth/Language/fr/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/fr/MyAccount.php
rename to modules/Auth/Language/fr/MyAccount.php
diff --git a/modules/Admin/Language/fr/User.php b/modules/Auth/Language/fr/User.php
similarity index 89%
rename from modules/Admin/Language/fr/User.php
rename to modules/Auth/Language/fr/User.php
index c5d33a1200f8212563c72d781165ffb3e4edb970..27283ed9c4454ab5515b3aa625f44c73eb28959a 100644
--- a/modules/Admin/Language/fr/User.php
+++ b/modules/Auth/Language/fr/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Modifier les rôles de {username}",
-    'forcePassReset' => 'Forcer la réinitialisation du mot de passe',
     'ban' => 'Bloquer',
     'unban' => 'Débloquer',
     'delete' => 'Supprimer',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             'Utilisateur créé avec succès ! {username} devra modifier son mot de passe à la première authentification.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "Les rôles de {username} ont été mis à jour avec succès.",
-        'forcePassResetSuccess' =>
-            '{username} devra modifier son mot de passe à la prochaine visite.',
         'banSuccess' => '{username} a été bloqué.',
         'unbanSuccess' => '{username} a été débloqué.',
         'editOwnerError' =>
diff --git a/modules/Admin/Language/fa/Contributor.php b/modules/Auth/Language/gd/Contributor.php
similarity index 100%
rename from modules/Admin/Language/fa/Contributor.php
rename to modules/Auth/Language/gd/Contributor.php
diff --git a/modules/Admin/Language/gd/MyAccount.php b/modules/Auth/Language/gd/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/gd/MyAccount.php
rename to modules/Auth/Language/gd/MyAccount.php
diff --git a/modules/Admin/Language/fa/User.php b/modules/Auth/Language/gd/User.php
similarity index 89%
rename from modules/Admin/Language/fa/User.php
rename to modules/Auth/Language/gd/User.php
index 585d6799fc98d3f7c485c26b9ed1439b28813a3b..fe3a9e1a65af49b66bdd092e694b5d0f05673aa6 100644
--- a/modules/Admin/Language/fa/User.php
+++ b/modules/Auth/Language/gd/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Edit {username}'s roles",
-    'forcePassReset' => 'Force pass reset',
     'ban' => 'Ban',
     'unban' => 'Unban',
     'delete' => 'Delete',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
         'banSuccess' => '{username} has been banned.',
         'unbanSuccess' => '{username} has been unbanned.',
         'editOwnerError' =>
diff --git a/modules/Admin/Language/gl/Contributor.php b/modules/Auth/Language/gl/Contributor.php
similarity index 100%
rename from modules/Admin/Language/gl/Contributor.php
rename to modules/Auth/Language/gl/Contributor.php
diff --git a/modules/Admin/Language/gl/MyAccount.php b/modules/Auth/Language/gl/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/gl/MyAccount.php
rename to modules/Auth/Language/gl/MyAccount.php
diff --git a/modules/Admin/Language/gl/User.php b/modules/Auth/Language/gl/User.php
similarity index 88%
rename from modules/Admin/Language/gl/User.php
rename to modules/Auth/Language/gl/User.php
index 4bdfcab9448405f994f23b973b3dc0e99b40f5c1..dd84c53d1b8b3cf19717331072622dfc8c33579b 100644
--- a/modules/Admin/Language/gl/User.php
+++ b/modules/Auth/Language/gl/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Editar os roles de {username}",
-    'forcePassReset' => 'Forzar restablecemento do contrasinal',
     'ban' => 'Vetar',
     'unban' => 'Retirar veto',
     'delete' => 'Eliminar',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             'Usuaria creada correctamente! Váiselle pedir a {username} que cambie o seu contrasinal após o primeiro acceso.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "Os roles de {username} actualizáronse correctamente.",
-        'forcePassResetSuccess' =>
-            'Solicitarase que {username} restableza o contrasinal na seguinte visita.',
         'banSuccess' => '{username} foi vetada.',
         'unbanSuccess' => 'Retirouse o veto sobre {username}.',
         'editOwnerError' =>
diff --git a/modules/Admin/Language/gd/Contributor.php b/modules/Auth/Language/id/Contributor.php
similarity index 100%
rename from modules/Admin/Language/gd/Contributor.php
rename to modules/Auth/Language/id/Contributor.php
diff --git a/modules/Admin/Language/id/MyAccount.php b/modules/Auth/Language/id/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/id/MyAccount.php
rename to modules/Auth/Language/id/MyAccount.php
diff --git a/modules/Admin/Language/en/User.php b/modules/Auth/Language/id/User.php
similarity index 89%
rename from modules/Admin/Language/en/User.php
rename to modules/Auth/Language/id/User.php
index 585d6799fc98d3f7c485c26b9ed1439b28813a3b..fe3a9e1a65af49b66bdd092e694b5d0f05673aa6 100644
--- a/modules/Admin/Language/en/User.php
+++ b/modules/Auth/Language/id/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Edit {username}'s roles",
-    'forcePassReset' => 'Force pass reset',
     'ban' => 'Ban',
     'unban' => 'Unban',
     'delete' => 'Delete',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             'User created successfully! {username} will be prompted with a password reset upon first authentication.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "{username}'s roles have been successfully updated.",
-        'forcePassResetSuccess' =>
-            '{username} will be prompted with a password reset upon next visit.',
         'banSuccess' => '{username} has been banned.',
         'unbanSuccess' => '{username} has been unbanned.',
         'editOwnerError' =>
diff --git a/modules/Admin/Language/it/Contributor.php b/modules/Auth/Language/it/Contributor.php
similarity index 100%
rename from modules/Admin/Language/it/Contributor.php
rename to modules/Auth/Language/it/Contributor.php
diff --git a/modules/Admin/Language/it/MyAccount.php b/modules/Auth/Language/it/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/it/MyAccount.php
rename to modules/Auth/Language/it/MyAccount.php
diff --git a/modules/Auth/Language/it/User.php b/modules/Auth/Language/it/User.php
new file mode 100644
index 0000000000000000000000000000000000000000..fe3a9e1a65af49b66bdd092e694b5d0f05673aa6
--- /dev/null
+++ b/modules/Auth/Language/it/User.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'edit_roles' => "Edit {username}'s roles",
+    'ban' => 'Ban',
+    'unban' => 'Unban',
+    'delete' => 'Delete',
+    'create' => 'New user',
+    'view' => "{username}'s info",
+    'all_users' => 'All users',
+    'list' => [
+        'user' => 'User',
+        'roles' => 'Roles',
+        'banned' => 'Banned?',
+    ],
+    'form' => [
+        'email' => 'Email',
+        'username' => 'Username',
+        'password' => 'Password',
+        'new_password' => 'New Password',
+        'roles' => 'Roles',
+        'permissions' => 'Permissions',
+        'submit_create' => 'Create user',
+        'submit_edit' => 'Save',
+        'submit_password_change' => 'Change!',
+    ],
+    'roles' => [
+        'superadmin' => 'Super admin',
+    ],
+    'messages' => [
+        'createSuccess' =>
+            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
+        'roleEditSuccess' =>
+            "{username}'s roles have been successfully updated.",
+        'banSuccess' => '{username} has been banned.',
+        'unbanSuccess' => '{username} has been unbanned.',
+        'editOwnerError' =>
+            '{username} is the instance owner, you cannot edit its roles.',
+        'banSuperAdminError' =>
+            '{username} is a superadmin, one does not simply ban a superadmin…',
+        'deleteSuperAdminError' =>
+            '{username} is a superadmin, one does not simply delete a superadmin…',
+        'deleteSuccess' => '{username} has been deleted.',
+    ],
+];
diff --git a/modules/Admin/Language/nl/Contributor.php b/modules/Auth/Language/nl/Contributor.php
similarity index 100%
rename from modules/Admin/Language/nl/Contributor.php
rename to modules/Auth/Language/nl/Contributor.php
diff --git a/modules/Admin/Language/nl/MyAccount.php b/modules/Auth/Language/nl/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/nl/MyAccount.php
rename to modules/Auth/Language/nl/MyAccount.php
diff --git a/modules/Auth/Language/nl/User.php b/modules/Auth/Language/nl/User.php
new file mode 100644
index 0000000000000000000000000000000000000000..fe3a9e1a65af49b66bdd092e694b5d0f05673aa6
--- /dev/null
+++ b/modules/Auth/Language/nl/User.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'edit_roles' => "Edit {username}'s roles",
+    'ban' => 'Ban',
+    'unban' => 'Unban',
+    'delete' => 'Delete',
+    'create' => 'New user',
+    'view' => "{username}'s info",
+    'all_users' => 'All users',
+    'list' => [
+        'user' => 'User',
+        'roles' => 'Roles',
+        'banned' => 'Banned?',
+    ],
+    'form' => [
+        'email' => 'Email',
+        'username' => 'Username',
+        'password' => 'Password',
+        'new_password' => 'New Password',
+        'roles' => 'Roles',
+        'permissions' => 'Permissions',
+        'submit_create' => 'Create user',
+        'submit_edit' => 'Save',
+        'submit_password_change' => 'Change!',
+    ],
+    'roles' => [
+        'superadmin' => 'Super admin',
+    ],
+    'messages' => [
+        'createSuccess' =>
+            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
+        'roleEditSuccess' =>
+            "{username}'s roles have been successfully updated.",
+        'banSuccess' => '{username} has been banned.',
+        'unbanSuccess' => '{username} has been unbanned.',
+        'editOwnerError' =>
+            '{username} is the instance owner, you cannot edit its roles.',
+        'banSuperAdminError' =>
+            '{username} is a superadmin, one does not simply ban a superadmin…',
+        'deleteSuperAdminError' =>
+            '{username} is a superadmin, one does not simply delete a superadmin…',
+        'deleteSuccess' => '{username} has been deleted.',
+    ],
+];
diff --git a/modules/Admin/Language/nn-NO/Contributor.php b/modules/Auth/Language/nn-NO/Contributor.php
similarity index 100%
rename from modules/Admin/Language/nn-NO/Contributor.php
rename to modules/Auth/Language/nn-NO/Contributor.php
diff --git a/modules/Admin/Language/nn-NO/MyAccount.php b/modules/Auth/Language/nn-NO/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/nn-NO/MyAccount.php
rename to modules/Auth/Language/nn-NO/MyAccount.php
diff --git a/modules/Admin/Language/nn-NO/User.php b/modules/Auth/Language/nn-NO/User.php
similarity index 89%
rename from modules/Admin/Language/nn-NO/User.php
rename to modules/Auth/Language/nn-NO/User.php
index 0a51b30d96ac73818179f7112e8d19bac718154d..a3ec4bfe823a3f8282e8c294297b48d68b5b3c59 100644
--- a/modules/Admin/Language/nn-NO/User.php
+++ b/modules/Auth/Language/nn-NO/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Endre rollene til {username}",
-    'forcePassReset' => 'Tving passordnullstilling',
     'ban' => 'Steng ute',
     'unban' => 'Slepp inn att',
     'delete' => 'Slett',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             'Brukaren er oppretta! {username} vil få spørsmål om å endra passord fyrste gong hen loggar inn.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "Rollene til {username} er oppdaterte.",
-        'forcePassResetSuccess' =>
-            '{username} vil bli beden om å endra passord neste gong hen loggar inn.',
         'banSuccess' => '{username} er utestengd.',
         'unbanSuccess' => '{username} fekk sleppa inn att.',
         'editOwnerError' =>
diff --git a/modules/Admin/Language/id/Contributor.php b/modules/Auth/Language/oc/Contributor.php
similarity index 100%
rename from modules/Admin/Language/id/Contributor.php
rename to modules/Auth/Language/oc/Contributor.php
diff --git a/modules/Admin/Language/oc/MyAccount.php b/modules/Auth/Language/oc/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/oc/MyAccount.php
rename to modules/Auth/Language/oc/MyAccount.php
diff --git a/modules/Auth/Language/oc/User.php b/modules/Auth/Language/oc/User.php
new file mode 100644
index 0000000000000000000000000000000000000000..fe3a9e1a65af49b66bdd092e694b5d0f05673aa6
--- /dev/null
+++ b/modules/Auth/Language/oc/User.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'edit_roles' => "Edit {username}'s roles",
+    'ban' => 'Ban',
+    'unban' => 'Unban',
+    'delete' => 'Delete',
+    'create' => 'New user',
+    'view' => "{username}'s info",
+    'all_users' => 'All users',
+    'list' => [
+        'user' => 'User',
+        'roles' => 'Roles',
+        'banned' => 'Banned?',
+    ],
+    'form' => [
+        'email' => 'Email',
+        'username' => 'Username',
+        'password' => 'Password',
+        'new_password' => 'New Password',
+        'roles' => 'Roles',
+        'permissions' => 'Permissions',
+        'submit_create' => 'Create user',
+        'submit_edit' => 'Save',
+        'submit_password_change' => 'Change!',
+    ],
+    'roles' => [
+        'superadmin' => 'Super admin',
+    ],
+    'messages' => [
+        'createSuccess' =>
+            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
+        'roleEditSuccess' =>
+            "{username}'s roles have been successfully updated.",
+        'banSuccess' => '{username} has been banned.',
+        'unbanSuccess' => '{username} has been unbanned.',
+        'editOwnerError' =>
+            '{username} is the instance owner, you cannot edit its roles.',
+        'banSuperAdminError' =>
+            '{username} is a superadmin, one does not simply ban a superadmin…',
+        'deleteSuperAdminError' =>
+            '{username} is a superadmin, one does not simply delete a superadmin…',
+        'deleteSuccess' => '{username} has been deleted.',
+    ],
+];
diff --git a/modules/Admin/Language/pl/Contributor.php b/modules/Auth/Language/pl/Contributor.php
similarity index 100%
rename from modules/Admin/Language/pl/Contributor.php
rename to modules/Auth/Language/pl/Contributor.php
diff --git a/modules/Admin/Language/pl/MyAccount.php b/modules/Auth/Language/pl/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/pl/MyAccount.php
rename to modules/Auth/Language/pl/MyAccount.php
diff --git a/modules/Admin/Language/pl/User.php b/modules/Auth/Language/pl/User.php
similarity index 89%
rename from modules/Admin/Language/pl/User.php
rename to modules/Auth/Language/pl/User.php
index 7db87b4416be16f496ad790795522275e310d5f1..7c2d0789fed3ec19b3ab2651d890807d78fb1250 100644
--- a/modules/Admin/Language/pl/User.php
+++ b/modules/Auth/Language/pl/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Edytuj role użytkownika {username}",
-    'forcePassReset' => 'Wymuś resetowanie hasła',
     'ban' => 'Zablokuj',
     'unban' => 'Odblokuj',
     'delete' => 'Usuń',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             'Pomyślnie utworzono użytkownika! {username} zostanie poproszony o zresetowanie hasła przy pierwszym uwierzytelnieniu.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "Role {username} zostały pomyślnie zaktualizowane.",
-        'forcePassResetSuccess' =>
-            '{username} zostanie poproszony o zresetowanie hasła przy następnej wizycie.',
         'banSuccess' => '{username} został zablokowany.',
         'unbanSuccess' => '{username} został odblokowany.',
         'editOwnerError' =>
diff --git a/modules/Admin/Language/pt-BR/Contributor.php b/modules/Auth/Language/pt-BR/Contributor.php
similarity index 100%
rename from modules/Admin/Language/pt-BR/Contributor.php
rename to modules/Auth/Language/pt-BR/Contributor.php
diff --git a/modules/Admin/Language/pt-BR/MyAccount.php b/modules/Auth/Language/pt-BR/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/pt-BR/MyAccount.php
rename to modules/Auth/Language/pt-BR/MyAccount.php
diff --git a/modules/Admin/Language/pt-BR/User.php b/modules/Auth/Language/pt-BR/User.php
similarity index 89%
rename from modules/Admin/Language/pt-BR/User.php
rename to modules/Auth/Language/pt-BR/User.php
index d42b9fc6e5105ad087629b20b87b318aa8762fae..aff24883a7748088030b41915ed81c2b43a01fbc 100644
--- a/modules/Admin/Language/pt-BR/User.php
+++ b/modules/Auth/Language/pt-BR/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "Editar cargos de {username}",
-    'forcePassReset' => 'Forçar redefinição da senha',
     'ban' => 'Banir',
     'unban' => 'Desbanir',
     'delete' => 'Excluir',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             'Usuário criado com sucesso! {username} terá que alterar sua senha na primeira autenticação.',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "Cargos de {username} foram atualizados com sucesso.",
-        'forcePassResetSuccess' =>
-            '{username} precisará alterar sua senha na próxima visita.',
         'banSuccess' => '{username} foi banido.',
         'unbanSuccess' => '{username} foi desbanido.',
         'editOwnerError' =>
diff --git a/modules/Admin/Language/oc/Contributor.php b/modules/Auth/Language/pt/Contributor.php
similarity index 100%
rename from modules/Admin/Language/oc/Contributor.php
rename to modules/Auth/Language/pt/Contributor.php
diff --git a/modules/Admin/Language/pt/MyAccount.php b/modules/Auth/Language/pt/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/pt/MyAccount.php
rename to modules/Auth/Language/pt/MyAccount.php
diff --git a/modules/Auth/Language/pt/User.php b/modules/Auth/Language/pt/User.php
new file mode 100644
index 0000000000000000000000000000000000000000..fe3a9e1a65af49b66bdd092e694b5d0f05673aa6
--- /dev/null
+++ b/modules/Auth/Language/pt/User.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'edit_roles' => "Edit {username}'s roles",
+    'ban' => 'Ban',
+    'unban' => 'Unban',
+    'delete' => 'Delete',
+    'create' => 'New user',
+    'view' => "{username}'s info",
+    'all_users' => 'All users',
+    'list' => [
+        'user' => 'User',
+        'roles' => 'Roles',
+        'banned' => 'Banned?',
+    ],
+    'form' => [
+        'email' => 'Email',
+        'username' => 'Username',
+        'password' => 'Password',
+        'new_password' => 'New Password',
+        'roles' => 'Roles',
+        'permissions' => 'Permissions',
+        'submit_create' => 'Create user',
+        'submit_edit' => 'Save',
+        'submit_password_change' => 'Change!',
+    ],
+    'roles' => [
+        'superadmin' => 'Super admin',
+    ],
+    'messages' => [
+        'createSuccess' =>
+            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
+        'roleEditSuccess' =>
+            "{username}'s roles have been successfully updated.",
+        'banSuccess' => '{username} has been banned.',
+        'unbanSuccess' => '{username} has been unbanned.',
+        'editOwnerError' =>
+            '{username} is the instance owner, you cannot edit its roles.',
+        'banSuperAdminError' =>
+            '{username} is a superadmin, one does not simply ban a superadmin…',
+        'deleteSuperAdminError' =>
+            '{username} is a superadmin, one does not simply delete a superadmin…',
+        'deleteSuccess' => '{username} has been deleted.',
+    ],
+];
diff --git a/modules/Admin/Language/pt/Contributor.php b/modules/Auth/Language/ru/Contributor.php
similarity index 100%
rename from modules/Admin/Language/pt/Contributor.php
rename to modules/Auth/Language/ru/Contributor.php
diff --git a/modules/Admin/Language/ru/MyAccount.php b/modules/Auth/Language/ru/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/ru/MyAccount.php
rename to modules/Auth/Language/ru/MyAccount.php
diff --git a/modules/Auth/Language/ru/User.php b/modules/Auth/Language/ru/User.php
new file mode 100644
index 0000000000000000000000000000000000000000..fe3a9e1a65af49b66bdd092e694b5d0f05673aa6
--- /dev/null
+++ b/modules/Auth/Language/ru/User.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'edit_roles' => "Edit {username}'s roles",
+    'ban' => 'Ban',
+    'unban' => 'Unban',
+    'delete' => 'Delete',
+    'create' => 'New user',
+    'view' => "{username}'s info",
+    'all_users' => 'All users',
+    'list' => [
+        'user' => 'User',
+        'roles' => 'Roles',
+        'banned' => 'Banned?',
+    ],
+    'form' => [
+        'email' => 'Email',
+        'username' => 'Username',
+        'password' => 'Password',
+        'new_password' => 'New Password',
+        'roles' => 'Roles',
+        'permissions' => 'Permissions',
+        'submit_create' => 'Create user',
+        'submit_edit' => 'Save',
+        'submit_password_change' => 'Change!',
+    ],
+    'roles' => [
+        'superadmin' => 'Super admin',
+    ],
+    'messages' => [
+        'createSuccess' =>
+            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
+        'roleEditSuccess' =>
+            "{username}'s roles have been successfully updated.",
+        'banSuccess' => '{username} has been banned.',
+        'unbanSuccess' => '{username} has been unbanned.',
+        'editOwnerError' =>
+            '{username} is the instance owner, you cannot edit its roles.',
+        'banSuperAdminError' =>
+            '{username} is a superadmin, one does not simply ban a superadmin…',
+        'deleteSuperAdminError' =>
+            '{username} is a superadmin, one does not simply delete a superadmin…',
+        'deleteSuccess' => '{username} has been deleted.',
+    ],
+];
diff --git a/modules/Admin/Language/sk/Contributor.php b/modules/Auth/Language/sk/Contributor.php
similarity index 100%
rename from modules/Admin/Language/sk/Contributor.php
rename to modules/Auth/Language/sk/Contributor.php
diff --git a/modules/Admin/Language/sk/MyAccount.php b/modules/Auth/Language/sk/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/sk/MyAccount.php
rename to modules/Auth/Language/sk/MyAccount.php
diff --git a/modules/Auth/Language/sk/User.php b/modules/Auth/Language/sk/User.php
new file mode 100644
index 0000000000000000000000000000000000000000..fe3a9e1a65af49b66bdd092e694b5d0f05673aa6
--- /dev/null
+++ b/modules/Auth/Language/sk/User.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'edit_roles' => "Edit {username}'s roles",
+    'ban' => 'Ban',
+    'unban' => 'Unban',
+    'delete' => 'Delete',
+    'create' => 'New user',
+    'view' => "{username}'s info",
+    'all_users' => 'All users',
+    'list' => [
+        'user' => 'User',
+        'roles' => 'Roles',
+        'banned' => 'Banned?',
+    ],
+    'form' => [
+        'email' => 'Email',
+        'username' => 'Username',
+        'password' => 'Password',
+        'new_password' => 'New Password',
+        'roles' => 'Roles',
+        'permissions' => 'Permissions',
+        'submit_create' => 'Create user',
+        'submit_edit' => 'Save',
+        'submit_password_change' => 'Change!',
+    ],
+    'roles' => [
+        'superadmin' => 'Super admin',
+    ],
+    'messages' => [
+        'createSuccess' =>
+            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
+        'roleEditSuccess' =>
+            "{username}'s roles have been successfully updated.",
+        'banSuccess' => '{username} has been banned.',
+        'unbanSuccess' => '{username} has been unbanned.',
+        'editOwnerError' =>
+            '{username} is the instance owner, you cannot edit its roles.',
+        'banSuperAdminError' =>
+            '{username} is a superadmin, one does not simply ban a superadmin…',
+        'deleteSuperAdminError' =>
+            '{username} is a superadmin, one does not simply delete a superadmin…',
+        'deleteSuccess' => '{username} has been deleted.',
+    ],
+];
diff --git a/modules/Admin/Language/ru/Contributor.php b/modules/Auth/Language/sv/Contributor.php
similarity index 100%
rename from modules/Admin/Language/ru/Contributor.php
rename to modules/Auth/Language/sv/Contributor.php
diff --git a/modules/Admin/Language/sv/MyAccount.php b/modules/Auth/Language/sv/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/sv/MyAccount.php
rename to modules/Auth/Language/sv/MyAccount.php
diff --git a/modules/Auth/Language/sv/User.php b/modules/Auth/Language/sv/User.php
new file mode 100644
index 0000000000000000000000000000000000000000..fe3a9e1a65af49b66bdd092e694b5d0f05673aa6
--- /dev/null
+++ b/modules/Auth/Language/sv/User.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2020 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'edit_roles' => "Edit {username}'s roles",
+    'ban' => 'Ban',
+    'unban' => 'Unban',
+    'delete' => 'Delete',
+    'create' => 'New user',
+    'view' => "{username}'s info",
+    'all_users' => 'All users',
+    'list' => [
+        'user' => 'User',
+        'roles' => 'Roles',
+        'banned' => 'Banned?',
+    ],
+    'form' => [
+        'email' => 'Email',
+        'username' => 'Username',
+        'password' => 'Password',
+        'new_password' => 'New Password',
+        'roles' => 'Roles',
+        'permissions' => 'Permissions',
+        'submit_create' => 'Create user',
+        'submit_edit' => 'Save',
+        'submit_password_change' => 'Change!',
+    ],
+    'roles' => [
+        'superadmin' => 'Super admin',
+    ],
+    'messages' => [
+        'createSuccess' =>
+            'User created successfully! {username} will be prompted with a password reset upon first authentication.',
+        'roleEditSuccess' =>
+            "{username}'s roles have been successfully updated.",
+        'banSuccess' => '{username} has been banned.',
+        'unbanSuccess' => '{username} has been unbanned.',
+        'editOwnerError' =>
+            '{username} is the instance owner, you cannot edit its roles.',
+        'banSuperAdminError' =>
+            '{username} is a superadmin, one does not simply ban a superadmin…',
+        'deleteSuperAdminError' =>
+            '{username} is a superadmin, one does not simply delete a superadmin…',
+        'deleteSuccess' => '{username} has been deleted.',
+    ],
+];
diff --git a/modules/Admin/Language/zh-Hans/Contributor.php b/modules/Auth/Language/zh-Hans/Contributor.php
similarity index 100%
rename from modules/Admin/Language/zh-Hans/Contributor.php
rename to modules/Auth/Language/zh-Hans/Contributor.php
diff --git a/modules/Admin/Language/zh-Hans/MyAccount.php b/modules/Auth/Language/zh-Hans/MyAccount.php
similarity index 100%
rename from modules/Admin/Language/zh-Hans/MyAccount.php
rename to modules/Auth/Language/zh-Hans/MyAccount.php
diff --git a/modules/Admin/Language/zh-Hans/User.php b/modules/Auth/Language/zh-Hans/User.php
similarity index 90%
rename from modules/Admin/Language/zh-Hans/User.php
rename to modules/Auth/Language/zh-Hans/User.php
index cb3e0ed6b6f8b5e67c4433878a58cfabf48a1cde..f76f603d56fcfac16be611d0df49263211f0377e 100644
--- a/modules/Admin/Language/zh-Hans/User.php
+++ b/modules/Auth/Language/zh-Hans/User.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 return [
     'edit_roles' => "编辑 {username} 的角色",
-    'forcePassReset' => '强制重置',
     'ban' => '封禁',
     'unban' => '取消封禁',
     'delete' => '删除',
@@ -39,10 +38,8 @@ return [
     'messages' => [
         'createSuccess' =>
             '用户创建成功!{username} 将在首次验证时提醒重置密码。',
-        'rolesEditSuccess' =>
+        'roleEditSuccess' =>
             "{username} 的角色已更新。",
-        'forcePassResetSuccess' =>
-            '下次访问时 {username} 将被提醒重置密码。',
         'banSuccess' => '{username} 已被封禁。',
         'unbanSuccess' => '{username} 已解除封禁。',
         'editOwnerError' =>
diff --git a/modules/Auth/Models/UserModel.php b/modules/Auth/Models/UserModel.php
new file mode 100644
index 0000000000000000000000000000000000000000..82ba51776b51e95affd40c939f4cbf6bbf17dedd
--- /dev/null
+++ b/modules/Auth/Models/UserModel.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2021 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace Modules\Auth\Models;
+
+use CodeIgniter\Shield\Entities\User;
+use CodeIgniter\Shield\Models\UserModel as ShieldUserModel;
+
+class UserModel extends ShieldUserModel
+{
+    /**
+     * @var string[]
+     */
+    protected $allowedFields = [
+        'username',
+        'status',
+        'status_message',
+        'active',
+        'is_owner',
+        'last_active',
+        'deleted_at',
+    ];
+
+    /**
+     * @return User[]
+     */
+    public function getPodcastContributors(int $podcastId): array
+    {
+        return $this->select('users.*')
+            ->join('auth_groups_users', 'users.id = auth_groups_users.user_id')
+            ->like('auth_groups_users.group', "podcast#{$podcastId}")
+            ->findAll();
+    }
+
+    public function getPodcastContributor(int $contributorId, int $podcastId): ?User
+    {
+        return $this->select('users.*')
+            ->join('auth_groups_users', 'users.id = auth_groups_users.user_id')
+            ->where('users.id', $contributorId)
+            ->like('auth_groups_users.group', "podcast#{$podcastId}")
+            ->first();
+    }
+}
diff --git a/modules/Install/Controllers/InstallController.php b/modules/Install/Controllers/InstallController.php
index 0f80f081a88df33c8c169b789119549524d8347e..c5705c9fc3ce9cc31ab7fd3155c004b40d28e996 100644
--- a/modules/Install/Controllers/InstallController.php
+++ b/modules/Install/Controllers/InstallController.php
@@ -10,18 +10,18 @@ declare(strict_types=1);
 
 namespace Modules\Install\Controllers;
 
-use App\Models\UserModel;
 use CodeIgniter\Controller;
 use CodeIgniter\Database\Exceptions\DatabaseException;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
 use CodeIgniter\HTTP\RequestInterface;
 use CodeIgniter\HTTP\ResponseInterface;
+use CodeIgniter\Shield\Entities\User;
 use Config\Database;
 use Config\Services;
 use Dotenv\Dotenv;
 use Dotenv\Exception\ValidationException;
-use Modules\Auth\Entities\User;
+use Modules\Auth\Models\UserModel;
 use Psr\Log\LoggerInterface;
 use Throwable;
 use ViewThemes\Theme;
@@ -31,7 +31,7 @@ class InstallController extends Controller
     /**
      * @var string[]
      */
-    protected $helpers = ['form', 'components', 'svg', 'misc'];
+    protected $helpers = ['form', 'components', 'svg', 'misc', 'setting'];
 
     /**
      * Constructor.
@@ -117,10 +117,11 @@ class InstallController extends Controller
         try {
             $db = db_connect();
 
-            // Check if superadmin has been created, meaning migrations and seeds have passed
+            // Check if instance owner has been created, meaning install was completed
             if (
                 $db->tableExists('users') &&
-                (new UserModel())->countAll() > 0
+                (new UserModel())->where('is_owner', true)
+                    ->first() !== null
                 ) {
                 // if so, show a 404 page
                 throw PageNotFoundException::forPageNotFound();
@@ -249,7 +250,7 @@ class InstallController extends Controller
 
         $migrations->setNamespace('CodeIgniter\Settings')
             ->latest();
-        $migrations->setNamespace('Myth\Auth')
+        $migrations->setNamespace('CodeIgniter\Shield')
             ->latest();
         $migrations->setNamespace('Modules\Fediverse')
             ->latest();
@@ -293,48 +294,25 @@ class InstallController extends Controller
     {
         $userModel = new UserModel();
 
-        // Validate here first, since some things,
-        // like the password, can only be validated properly here.
-        $rules = array_merge(
-            $userModel->getValidationRules([
-                'only' => ['username'],
-            ]),
-            [
-                'email' => 'required|valid_email|is_unique[users.email]',
-                'password' => 'required|strong_password',
-            ],
-        );
-
-        if (! $this->validate($rules)) {
-            return redirect()
-                ->back()
-                ->withInput()
-                ->with('errors', $this->validator->getErrors());
-        }
-
         // Save the user
-        $user = new User($this->request->getPost());
-
-        // Activate user
-        $user->activate();
-
-        $db = db_connect();
-
-        $db->transStart();
-        if (! ($userId = $userModel->insert($user, true))) {
-            $db->transRollback();
-
-            return redirect()
-                ->back()
+        $user = new User([
+            'username' => $this->request->getPost('username'),
+            'email' => $this->request->getPost('email'),
+            'password' => $this->request->getPost('password'),
+            'is_owner' => true,
+        ]);
+        try {
+            $userModel->save($user);
+        } catch (ValidationException) {
+            return redirect()->back()
                 ->withInput()
                 ->with('errors', $userModel->errors());
         }
 
-        // add newly created user to superadmin group
-        $authorization = Services::authorization();
-        $authorization->addUserToGroup($userId, 'superadmin');
+        $user = $userModel->findById($userModel->getInsertID());
 
-        $db->transComplete();
+        // set newly created user as most powerful instance group (superadmin)
+        $user->addGroup(setting('AuthGroups.mostPowerfulGroup'));
 
         // Success!
         // set redirect_url session as admin area to go to after login
@@ -342,7 +320,7 @@ class InstallController extends Controller
             ->set('redirect_url', route_to('admin'));
 
         return redirect()
-            ->route('login')
+            ->route('admin')
             ->with('message', lang('Install.messages.createSuperAdminSuccess'));
     }
 
diff --git a/modules/Install/Language/en/Install.php b/modules/Install/Language/en/Install.php
index 1f66ef118fd188c73e37e5009c5cdd22c7fdd3f2..45d2608580b2c13f6c29ab1a664b6136c37acf64 100644
--- a/modules/Install/Language/en/Install.php
+++ b/modules/Install/Language/en/Install.php
@@ -46,7 +46,7 @@ return [
         ],
         'next' => 'Next',
         'submit' => 'Finish install',
-        'create_superadmin' => 'Create your superadmin account',
+        'create_superadmin' => 'Create your Super Admin account',
         'email' => 'Email',
         'username' => 'Username',
         'password' => 'Password',
diff --git a/modules/PremiumPodcasts/Config/Routes.php b/modules/PremiumPodcasts/Config/Routes.php
index 2859b596f352068024f26228af75331bc9db4c71..a962079419ae990c333098adbc55501f764b0c20 100644
--- a/modules/PremiumPodcasts/Config/Routes.php
+++ b/modules/PremiumPodcasts/Config/Routes.php
@@ -20,30 +20,30 @@ $routes->group(
             $routes->get('/', 'SubscriptionController::list/$1', [
                 'as' => 'subscription-list',
                 'filter' =>
-                    'permission:podcasts-view,podcast-manage_subscriptions',
+                    'permission:podcast#.manage-subscriptions',
             ]);
             $routes->get('add', 'SubscriptionController::add/$1', [
                 'as' => 'subscription-add',
-                'filter' => 'permission:podcast-manage_subscriptions',
+                'filter' => 'permission:podcast#.manage-subscriptions',
             ]);
             $routes->post(
                 'add',
                 'SubscriptionController::attemptAdd/$1',
                 [
                     'filter' =>
-                        'permission:podcast-manage_subscriptions',
+                        'permission:podcast#.manage-subscriptions',
                 ],
             );
             $routes->post('save-link', 'SubscriptionController::attemptLinkSave/$1', [
                 'as' => 'subscription-link-save',
-                'filter' => 'permission:podcast-manage_subscriptions',
+                'filter' => 'permission:podcast#.manage-subscriptions',
             ]);
             // Subscription
             $routes->group('(:num)', static function ($routes): void {
                 $routes->get('/', 'SubscriptionController::view/$1/$2', [
                     'as' => 'subscription-view',
                     'filter' =>
-                        'permission:podcast-manage_subscriptions',
+                        'permission:podcast#.manage-subscriptions',
                 ]);
                 $routes->get(
                     'edit',
@@ -51,7 +51,7 @@ $routes->group(
                     [
                         'as' => 'subscription-edit',
                         'filter' =>
-                            'permission:podcast-manage_subscriptions',
+                            'permission:podcast#.manage-subscriptions',
                     ],
                 );
                 $routes->post(
@@ -60,7 +60,7 @@ $routes->group(
                     [
                         'as' => 'subscription-edit',
                         'filter' =>
-                            'permission:podcast-manage_subscriptions',
+                            'permission:podcast#.manage-subscriptions',
                     ],
                 );
                 $routes->get(
@@ -69,7 +69,7 @@ $routes->group(
                     [
                         'as' => 'subscription-regenerate-token',
                         'filter' =>
-                            'permission:podcast-manage_subscriptions',
+                            'permission:podcast#.manage-subscriptions',
                     ]
                 );
                 $routes->get(
@@ -78,7 +78,7 @@ $routes->group(
                     [
                         'as' => 'subscription-suspend',
                         'filter' =>
-                            'permission:podcast-manage_subscriptions',
+                            'permission:podcast#.manage-subscriptions',
                     ],
                 );
                 $routes->post(
@@ -86,7 +86,7 @@ $routes->group(
                     'SubscriptionController::attemptSuspend/$1/$2',
                     [
                         'filter' =>
-                        'permission:podcast-manage_subscriptions',
+                        'permission:podcast#.manage-subscriptions',
                     ],
                 );
                 $routes->get(
@@ -95,7 +95,7 @@ $routes->group(
                     [
                         'as' => 'subscription-resume',
                         'filter' =>
-                            'permission:podcast-manage_subscriptions',
+                            'permission:podcast#.manage-subscriptions',
                     ],
                 );
                 $routes->get(
@@ -104,7 +104,7 @@ $routes->group(
                     [
                         'as' => 'subscription-delete',
                         'filter' =>
-                            'permission:podcast-manage_subscriptions',
+                            'permission:podcast#.manage-subscriptions',
                     ],
                 );
                 $routes->post(
@@ -112,7 +112,7 @@ $routes->group(
                     'SubscriptionController::attemptDelete/$1/$2',
                     [
                         'filter' =>
-                            'permission:podcast-manage_subscriptions',
+                            'permission:podcast#.manage-subscriptions',
                     ],
                 );
             });
diff --git a/modules/PremiumPodcasts/Controllers/SubscriptionController.php b/modules/PremiumPodcasts/Controllers/SubscriptionController.php
index 055de5b83311e806d494235c664dcc2f5374b07c..c29bac8c3f80ad020412a6fca464955801a83755 100644
--- a/modules/PremiumPodcasts/Controllers/SubscriptionController.php
+++ b/modules/PremiumPodcasts/Controllers/SubscriptionController.php
@@ -58,7 +58,7 @@ class SubscriptionController extends BaseController
         helper('form');
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('subscription/list', $data);
     }
@@ -106,7 +106,7 @@ class SubscriptionController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => '#' . $this->subscription->id,
         ]);
         return view('subscription/view', $data);
@@ -121,7 +121,7 @@ class SubscriptionController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
         ]);
         return view('subscription/add', $data);
     }
@@ -247,7 +247,7 @@ class SubscriptionController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => '#' . $this->subscription->id,
         ]);
         return view('subscription/edit', $data);
@@ -315,7 +315,7 @@ class SubscriptionController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => '#' . $this->subscription->id,
         ]);
         return view('subscription/suspend', $data);
@@ -410,7 +410,7 @@ class SubscriptionController extends BaseController
         ];
 
         replace_breadcrumb_params([
-            0 => $this->podcast->title,
+            0 => $this->podcast->at_handle,
             1 => '#' . $this->subscription->id,
         ]);
         return view('subscription/delete', $data);
diff --git a/modules/PremiumPodcasts/Filters/PodcastUnlockFilter.php b/modules/PremiumPodcasts/Filters/PodcastUnlockFilter.php
index 74468fa2e5959a730df0fb2022ad427fa1b491e2..a4a1d3f8fffc20a4ee33ef306295a3967d32df3a 100644
--- a/modules/PremiumPodcasts/Filters/PodcastUnlockFilter.php
+++ b/modules/PremiumPodcasts/Filters/PodcastUnlockFilter.php
@@ -11,7 +11,6 @@ use CodeIgniter\HTTP\ResponseInterface;
 use CodeIgniter\Router\Router;
 use Config\App;
 use Modules\PremiumPodcasts\PremiumPodcasts;
-use Myth\Auth\Authentication\AuthenticationBase;
 
 class PodcastUnlockFilter implements FilterInterface
 {
@@ -48,9 +47,7 @@ class PodcastUnlockFilter implements FilterInterface
         }
 
         // no need to go through the unlock form if user is connected
-        /** @var AuthenticationBase $auth */
-        $auth = service('authentication');
-        if ($auth->isLoggedIn()) {
+        if (auth()->loggedIn()) {
             return;
         }
 
diff --git a/phpstan.neon b/phpstan.neon
index cbedb485d8b1dce91a3520ee5f0636e435127f2d..8e79ac0600b4c0a7aafd10195b4de8b89f517e3d 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -9,10 +9,12 @@ parameters:
     scanDirectories:
         - app/Helpers
         - modules/Analytics/Helpers
+        - modules/Auth/Helpers
         - modules/Fediverse/Helpers
         - modules/PremiumPodcasts/Helpers
         - vendor/codeigniter4/framework/system/Helpers
-        - vendor/myth/auth/src/Helpers
+        - vendor/codeigniter4/settings/src/Helpers
+        - vendor/codeigniter4/shield/src/Helpers
     excludePaths:
         - app/Libraries/Router.php
         - app/Views/*
diff --git a/themes/cp_admin/_partials/_nav_header.php b/themes/cp_admin/_partials/_nav_header.php
index b3711aa956ab4f8b2dc778a5d70d9ef1227e730d..d3ec1f3c137ea5dd33cce25cb671f35bf337aecc 100644
--- a/themes/cp_admin/_partials/_nav_header.php
+++ b/themes/cp_admin/_partials/_nav_header.php
@@ -1,3 +1,7 @@
+<?php declare(strict_types=1);
+
+$userPodcasts = get_podcasts_user_can_interact_with(auth()->user()); ?>
+
 <header class="sticky top-0 z-[60] flex items-center h-10 text-white border-b col-span-full bg-navigation border-navigation">
     <button type="button"
         data-sidebar-toggler="toggler"
@@ -18,7 +22,7 @@
     <div class="inline-flex items-center h-full ml-auto">
         <button type="button" class="relative h-full px-2 focus:ring-accent focus:ring-inset" id="notifications-dropdown" data-dropdown="button" data-dropdown-target="notifications-dropdown-menu" aria-haspopup="true" aria-expanded="false">
             <?= icon('notification-bell', 'text-2xl opacity-80') ?>
-            <?php if (user()->actorIdsWithUnreadNotifications !== []): ?>
+            <?php if (($actorIdsWithUnreadNotifications = get_actor_ids_with_unread_notifications(auth()->user())) !== []): ?>
                 <span class="absolute top-2 right-2 w-2.5 h-2.5 bg-red-500 rounded-full border border-navigation-bg"></span>
             <?php endif ?>
         </button>
@@ -34,11 +38,11 @@
                 ],
             ];
 
-            if (user()->podcasts !== []) {
-                foreach (user()->podcasts as $userPodcast) {
+            if ($userPodcasts !== []) {
+                foreach ($userPodcasts as $userPodcast) {
                     $userPodcastTitle = esc($userPodcast->title);
 
-                    $unreadNotificationDotDisplayClass = in_array($userPodcast->actor_id, user()->actorIdsWithUnreadNotifications, true) ? '' : 'hidden';
+                    $unreadNotificationDotDisplayClass = in_array($userPodcast->actor_id, $actorIdsWithUnreadNotifications, true) ? '' : 'hidden';
 
                     $items[] = [
                         'type' => 'link',
@@ -66,7 +70,7 @@
             }
         ?>
         <DropdownMenu id="notifications-dropdown-menu" labelledby="notifications-dropdown" items="<?= esc(json_encode($items)) ?>" placement="bottom"/>
-    
+
         <button
             type="button"
             class="inline-flex items-center h-full px-3 text-sm font-semibold focus:ring-inset focus:ring-accent gap-x-2"
@@ -76,15 +80,14 @@
             aria-haspopup="true"
             aria-expanded="false"><div class="relative mr-1">
                 <?= icon('account-circle', 'text-3xl opacity-60') ?>
-                <?= user()
-                    ->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation-bg" loading="lazy" />' ?>
+                <?= $userPodcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation-bg" loading="lazy" />' ?>
             </div>
-            <?= esc(user()->username) ?>
+            <?= esc(auth()->user()->username) ?>
             <?= icon('caret-down', 'ml-auto text-2xl') ?></button>
     </div>
     <?php
         $interactButtons = '';
-        foreach (user()->podcasts as $userPodcast) {
+        foreach ($userPodcasts as $userPodcast) {
             $checkMark = interact_as_actor_id() === $userPodcast->actor_id ? icon('check', 'ml-2 bg-accent-base text-accent-contrast rounded-full') : '';
             $userPodcastTitle = esc($userPodcast->title);
 
@@ -96,7 +99,7 @@
         }
 
         $interactAsText = lang('Common.choose_interact');
-        $route = route_to('interact-as-actor');
+        $interactAsRoute = route_to('interact-as-actor');
         $csrfField = csrf_field();
 
         $menuItems = [
@@ -120,14 +123,14 @@
             ],
         ];
 
-        if (user()->podcasts !== []) {
+        if ($userPodcasts !== []) {
             $menuItems = array_merge([
                 [
                     'type' => 'html',
                     'content' => esc(<<<CODE_SAMPLE
                         <nav class="flex flex-col py-2 whitespace-nowrap">
                             <span class="px-4 mb-2 text-xs font-semibold tracking-wider uppercase text-skin-muted">{$interactAsText}</span>
-                            <form action="{$route}" method="POST" class="flex flex-col">
+                            <form action="{$interactAsRoute}" method="POST" class="flex flex-col">
                                 {$csrfField}
                                 {$interactButtons}
                             </form>
diff --git a/themes/cp_admin/_partials/_user_info.php b/themes/cp_admin/_partials/_user_info.php
index 61871f6a9e1147486fd81b458590186f00292a10..6bb3aebbb0265d199f5ea4caffb9f4057eda4e81 100644
--- a/themes/cp_admin/_partials/_user_info.php
+++ b/themes/cp_admin/_partials/_user_info.php
@@ -1,17 +1,17 @@
 <div class="px-4 py-5">
     <dt class="text-sm font-medium leading-5 text-skin-muted">
-    <?= lang('User.form.email') ?>
+    <?= lang('User.form.username') ?>
     </dt>
     <dd class="mt-1 text-sm leading-5">
-    <?= $user->email ?>
+    <?= esc($user->username) ?>
     </dd>
 </div>
 <div class="px-4 py-5">
     <dt class="text-sm font-medium leading-5 text-skin-muted">
-    <?= lang('User.form.username') ?>
+    <?= lang('User.form.email') ?>
     </dt>
     <dd class="mt-1 text-sm leading-5">
-    <?= esc($user->username) ?>
+    <?= $user->email ?>
     </dd>
 </div>
 <div class="px-4 py-5">
@@ -19,7 +19,7 @@
     <?= lang('User.form.roles') ?>
     </dt>
     <dd class="mt-1 text-sm leading-5">
-    <?= implode(', ', $user->roles) ?>
+    <?= implode(', ', $user->getGroups()) ?>
     </dd>
 </div>
 <div class="px-4 py-5">
@@ -27,6 +27,6 @@
     <?= lang('User.form.permissions') ?>
     </dt>
     <dd class="w-full max-w-xl mt-1 text-sm leading-5">
-    <?= implode(', ', $user->permissions) ?>
+    <?= implode(', ', $user->getPermissions()) ?>
     </dd>
 </div>
diff --git a/themes/cp_admin/contributor/add.php b/themes/cp_admin/contributor/add.php
index ef2325bb4f7cac9b77c7160900003c80ffe5df51..91b3a94a8c62247922e4da5c7d095bf1bbd51acf 100644
--- a/themes/cp_admin/contributor/add.php
+++ b/themes/cp_admin/contributor/add.php
@@ -18,7 +18,7 @@
     as="Select"
     name="user"
     label="<?= lang('Contributor.form.user') ?>"
-    options="<?= esc(json_encode($userOptions)) ?>"
+    options="<?= esc(json_encode($contributorOptions)) ?>"
     placeholder="<?= lang('Contributor.form.user_placeholder') ?>"
     required="true" />
 
@@ -28,6 +28,7 @@
     label="<?= lang('Contributor.form.role') ?>"
     options="<?= esc(json_encode($roleOptions)) ?>"
     placeholder="<?= lang('Contributor.form.role_placeholder') ?>"
+    selected="<?= setting('AuthGroups.defaultPodcastGroup') ?>"
     required="true" />
 
 <Button type="submit" class="self-end" variant="primary"><?= lang('Contributor.form.submit_add') ?></Button>
diff --git a/themes/cp_admin/contributor/delete.php b/themes/cp_admin/contributor/delete.php
new file mode 100644
index 0000000000000000000000000000000000000000..d1f733790720fd0a16e28ae6a5988b702ed38232
--- /dev/null
+++ b/themes/cp_admin/contributor/delete.php
@@ -0,0 +1,37 @@
+<?= $this->extend('_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('Contributor.delete_form.title', [
+    'contributor' => $contributor->username,
+]) ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('pageTitle') ?>
+<?= lang('Contributor.delete_form.title', [
+    'contributor' => $contributor->username,
+]) ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<form action="<?= route_to('contributor-delete', $podcast->id, $contributor->id) ?>" method="POST" class="flex flex-col w-full max-w-xl mx-auto">
+<?= csrf_field() ?>
+
+<Alert variant="danger" glyph="alert" class="font-semibold"><?= lang('Contributor.delete_form.disclaimer', [
+    'contributor' => $contributor->username,
+    'podcastTitle' => $podcast->title,
+]) ?></Alert>
+
+<Forms.Checkbox class="mt-2" name="understand" required="true" isChecked="false"><?= lang('Contributor.delete_form.understand', [
+    'contributor' => $contributor->username,
+    'podcastTitle' => $podcast->title,
+]) ?></Forms.Checkbox>
+
+<div class="self-end mt-4">
+    <Button uri="<?= route_to('contributor-view', $podcast->id, $contributor->id) ?>"><?= lang('Common.cancel') ?></Button>
+    <Button type="submit" variant="danger"><?= lang('Contributor.delete_form.submit') ?></Button>
+</div>
+
+</form>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_admin/contributor/edit.php b/themes/cp_admin/contributor/edit.php
index edfed8412f0a86ab27ea176acd536758b26b31f1..ca854d963828fdf8221f500a9c8d9585911c4e4b 100644
--- a/themes/cp_admin/contributor/edit.php
+++ b/themes/cp_admin/contributor/edit.php
@@ -1,25 +1,25 @@
 <?= $this->extend('_layout') ?>
 
 <?= $this->section('title') ?>
-<?= lang('Contributor.edit_role', [esc($user->username)]) ?>
+<?= lang('Contributor.edit_role', [esc($contributor->username)]) ?>
 <?= $this->endSection() ?>
 
 <?= $this->section('pageTitle') ?>
-<?= lang('Contributor.edit_role', [esc($user->username)]) ?>
+<?= lang('Contributor.edit_role', [esc($contributor->username)]) ?>
 <?= $this->endSection() ?>
 
 
 <?= $this->section('content') ?>
 
-<form method="POST" action="<?= route_to('contributor-edit', $podcast->id, $user->id) ?>" class="flex flex-col max-w-sm gap-y-4">
+<form method="POST" action="<?= route_to('contributor-edit', $podcast->id, $contributor->id) ?>" class="flex flex-col max-w-sm gap-y-4">
 <?= csrf_field() ?>
 
 <Forms.Field
     as="Select"
     name="role"
     label="<?= lang('Contributor.form.role') ?>"
-    selected="<?= $contributorGroupId ?>"
     options="<?= esc(json_encode($roleOptions)) ?>"
+    selected="<?= $contributorGroup ?>"
     placeholder="<?= lang('Contributor.form.role_placeholder') ?>"
     required="true" />
 
diff --git a/themes/cp_admin/contributor/list.php b/themes/cp_admin/contributor/list.php
index 6ad44137e2e48c93a6bfe4d8095f1935b8e89f16..9da3de4d7ec82185cd497a4f0e70eb6db183547e 100644
--- a/themes/cp_admin/contributor/list.php
+++ b/themes/cp_admin/contributor/list.php
@@ -25,8 +25,14 @@
         ],
         [
             'header' => lang('Contributor.list.role'),
-            'cell' => function ($contributor): string {
-                return lang('Contributor.roles.' . $contributor->podcast_role);
+            'cell' => function ($contributor, $podcast): string {
+                $role = get_group_info(get_podcast_group($contributor, $podcast->id), $podcast->id)['title'];
+
+                if ($podcast->created_by === $contributor->id) {
+                    $role = '<div class="inline-flex items-center"><span class="mr-2 focus:ring-accent" tabindex="0" data-tooltip="bottom" title="' . lang('Auth.podcast_groups.owner.title') . '">' . icon('shield-user') . '</span>' . $role . '</div>';
+                }
+
+                return $role;
             },
         ],
         [
diff --git a/themes/cp_admin/my_account/view.php b/themes/cp_admin/my_account/view.php
index c329ea717fadaf8919c77053745d4ce47cd120c9..5d5c11019d2268f8c5ff0d16e70841fa6b0b9985 100644
--- a/themes/cp_admin/my_account/view.php
+++ b/themes/cp_admin/my_account/view.php
@@ -12,7 +12,8 @@
 <?= $this->section('content') ?>
 
 <?= view('_partials/_user_info.php', [
-    'user' => user(),
+    'user' => auth()
+        ->user(),
 ]) ?>
 
 <?= $this->endSection() ?>
diff --git a/themes/cp_admin/podcast/list.php b/themes/cp_admin/podcast/list.php
index 6736877ef3a8eec33239cf31157dabee390476ee..28cbd9161d414555aec5a0214193b0d961b3704b 100644
--- a/themes/cp_admin/podcast/list.php
+++ b/themes/cp_admin/podcast/list.php
@@ -17,7 +17,7 @@
 <?= $this->section('content') ?>
 
 <div class="grid gap-4 grid-cols-cards">
-    <?php if ($podcasts !== null): ?>
+    <?php if ($podcasts !== []): ?>
         <?php foreach ($podcasts as $podcast): ?>
             <?= view('podcast/_card', [
                 'podcast' => $podcast,
diff --git a/themes/cp_admin/user/create.php b/themes/cp_admin/user/create.php
index d81377df4ebf2dbad2d3fa6e06ad5c12a30fce16..eb6b769b5e079207e65c936f5ed0ac7ef09e59b7 100644
--- a/themes/cp_admin/user/create.php
+++ b/themes/cp_admin/user/create.php
@@ -14,6 +14,11 @@
 <form action="<?= route_to('user-create') ?>" method="POST" class="flex flex-col max-w-sm gap-y-4">
 <?= csrf_field() ?>
 
+<Forms.Field
+    name="username"
+    label="<?= lang('User.form.username') ?>"
+    required="true" />
+
 <Forms.Field
     name="email"
     type="email"
@@ -21,17 +26,13 @@
     required="true" />
 
 <Forms.Field
-    name="username"
-    label="<?= lang('User.form.username') ?>"
+    as="Select"
+    name="role"
+    label="<?= lang('User.form.role') ?>"
+    options="<?= esc(json_encode($roleOptions)) ?>"
+    selected="<?= setting('AuthGroups.defaultGroup') ?>"
     required="true" />
 
-<Forms.Field
-    name="password"
-    type="password"
-    label="<?= lang('User.form.password') ?>"
-    required="true"
-    autocomplete="new-password" />
-
 <Button variant="primary" type="submit" class="self-end"><?= lang('User.form.submit_create') ?></Button>
 
 </form>
diff --git a/themes/cp_admin/user/delete.php b/themes/cp_admin/user/delete.php
new file mode 100644
index 0000000000000000000000000000000000000000..70c915de17661bcbc2fe6e8dc35addb96e6ac237
--- /dev/null
+++ b/themes/cp_admin/user/delete.php
@@ -0,0 +1,35 @@
+<?= $this->extend('_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('User.delete_form.title', [
+    'user' => $user->username,
+]) ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('pageTitle') ?>
+<?= lang('User.delete_form.title', [
+    'user' => $user->username,
+]) ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<form action="<?= route_to('user-delete', $user->id) ?>" method="POST" class="flex flex-col w-full max-w-xl mx-auto">
+<?= csrf_field() ?>
+
+<Alert variant="danger" glyph="alert" class="font-semibold"><?= lang('User.delete_form.disclaimer', [
+    'user' => $user->username,
+]) ?></Alert>
+
+<Forms.Checkbox class="mt-2" name="understand" required="true" isChecked="false"><?= lang('User.delete_form.understand', [
+    'user' => $user->username,
+]) ?></Forms.Checkbox>
+
+<div class="self-end mt-4">
+    <Button uri="<?= route_to('user-view', $user->id) ?>"><?= lang('Common.cancel') ?></Button>
+    <Button type="submit" variant="danger"><?= lang('User.delete_form.submit') ?></Button>
+</div>
+
+</form>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_admin/user/edit.php b/themes/cp_admin/user/edit.php
index 2981b9f38f8e3fe06c68fce18930f7807c24ec03..de91027dea914e8b2337dbc7f6e2dda8e917fd57 100644
--- a/themes/cp_admin/user/edit.php
+++ b/themes/cp_admin/user/edit.php
@@ -1,13 +1,13 @@
 <?= $this->extend('_layout') ?>
 
 <?= $this->section('title') ?>
-<?= lang('User.edit_roles', [
+<?= lang('User.edit_role', [
     'username' => esc($user->username),
 ]) ?>
 <?= $this->endSection() ?>
 
 <?= $this->section('pageTitle') ?>
-<?= lang('User.edit_roles', [
+<?= lang('User.edit_role', [
     'username' => esc($user->username),
 ]) ?>
 <?= $this->endSection() ?>
@@ -19,12 +19,12 @@
 <?= csrf_field() ?>
 
 <Forms.Field
-    as="MultiSelect"
-    id="roles"
-    name="roles[]"
-    label="<?= lang('User.form.roles') ?>"
+    as="Select"
+    name="role"
+    label="<?= lang('User.form.role') ?>"
     options="<?= esc(json_encode($roleOptions)) ?>"
-    selected="<?= esc(json_encode($user->roles)) ?>" />
+    selected="<?= esc(get_instance_group($user)) ?>"
+    required="true" />
 
 <Button variant="primary" type="submit" class="self-end mt-4"><?= lang('User.form.submit_edit') ?></Button>
 
diff --git a/themes/cp_admin/user/list.php b/themes/cp_admin/user/list.php
index ce9777924c29c5c2765133514ac0460ca78df127..9e87eed75c1364e179124201d29588bc4ecd7ad2 100644
--- a/themes/cp_admin/user/list.php
+++ b/themes/cp_admin/user/list.php
@@ -28,50 +28,30 @@
             },
         ],
         [
-            'header' => lang('User.list.roles'),
+            'header' => lang('User.list.role'),
             'cell' => function ($user) {
-                if ($user->isOwner) {
-                    return 'owner, ' . implode(',', $user->roles);
+                $role = get_group_info(get_instance_group($user))['title'];
+
+                if ((bool) $user->is_owner) {
+                    $role = '<div class="inline-flex items-center"><span class="mr-2 focus:ring-accent" tabindex="0" data-tooltip="bottom" title="' . lang('Auth.instance_groups.owner.title') . '">' . icon('shield-user') . '</span>' . $role . '</div>';
                 }
 
-                return implode(',', $user->roles) . '<IconButton uri="' . route_to('user-edit', $user->id) . '" glyph="edit" variant="info">' . lang('User.edit_roles', [
+                return $role . '<IconButton uri="' . route_to('user-edit', $user->id) . '" glyph="edit" variant="info">' . lang('User.edit_role', [
                     'username' => esc($user->username),
                 ]) . '</IconButton>';
             },
         ],
-        [
-            'header' => lang('User.list.banned'),
-            'cell' => function ($user) {
-                return $user->isBanned()
-                    ? lang('Common.yes')
-                    : lang('Common.no');
-            },
-        ],
         [
             'header' => lang('Common.actions'),
             'cell' => function ($user) {
                 return '<button id="more-dropdown-' . $user->id . '" type="button" class="inline-flex items-center p-1 focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $user->id . '-menu" aria-haspopup="true" aria-expanded="false">' . icon('more') . '</button>' .
                 '<DropdownMenu id="more-dropdown-' . $user->id . '-menu" labelledby="more-dropdown-' . $user->id . '" items="' . esc(json_encode([
-                    [
-                        'type' => 'link',
-                        'title' => lang('User.forcePassReset'),
-                        'uri' => route_to('user-force_pass_reset', $user->id),
-                    ],
-                    [
-                        'type' => 'link',
-                        'title' => lang('User.' . ($user->isBanned() ? 'unban' : 'ban')),
-                        'uri' => route_to($user->isBanned() ? 'user-unban' : 'user-ban', $user->id),
-                    ],
-                    [
-                        'type' => 'separator',
-                    ],
                     [
                         'type' => 'link',
                         'title' => lang('User.delete'),
                         'uri' => route_to('user-delete', $user->id),
                         'class' => 'font-semibold text-red-600',
                     ],
-
                 ])) . '" />';
             },
         ],
diff --git a/themes/cp_admin/user/view.php b/themes/cp_admin/user/view.php
index 5213a16a7eaf3aac8b912925d1a2c163056b272f..3ce71ba16d7ecb594381058788437ec6aa863509 100644
--- a/themes/cp_admin/user/view.php
+++ b/themes/cp_admin/user/view.php
@@ -6,6 +6,12 @@
 ]) ?>
 <?= $this->endSection() ?>
 
+<?= $this->section('pageTitle') ?>
+<?= lang('User.view', [
+    'username' => esc($user->username),
+]) ?>
+<?= $this->endSection() ?>
+
 
 <?= $this->section('content') ?>
 
diff --git a/themes/cp_app/_admin_navbar.php b/themes/cp_app/_admin_navbar.php
index 15f542f4456fa93260a55a79c97c47386f2e4597..f17ea0d8e2768754c8d6bf56ea0c4044c9464362 100644
--- a/themes/cp_app/_admin_navbar.php
+++ b/themes/cp_app/_admin_navbar.php
@@ -1,3 +1,7 @@
+<?php declare(strict_types=1);
+
+$userPodcasts = get_podcasts_user_can_interact_with(auth()->user()); ?>
+
 <div class="sticky top-0 left-0 z-50 flex items-center justify-between w-full h-10 text-white border-b bg-navigation border-navigation">
         <div class="inline-flex items-center h-full">
             <a href="<?= route_to('home') ?>" class="inline-flex items-center h-full px-2 border-r border-navigation focus:ring-inset focus:ring-accent">
@@ -12,7 +16,7 @@
         <div class="inline-flex items-center h-full">
             <button type="button" class="relative h-full px-2 focus:ring-accent focus:ring-inset" id="notifications-dropdown" data-dropdown="button" data-dropdown-target="notifications-dropdown-menu" aria-haspopup="true" aria-expanded="false">
                 <?= icon('notification-bell', 'text-2xl opacity-80') ?>
-                <?php if (user()->actorIdsWithUnreadNotifications !== []): ?>
+                <?php if (($actorIdsWithUnreadNotifications = get_actor_ids_with_unread_notifications(auth()->user())) !== []): ?>
                     <span class="absolute top-2 right-2 w-2.5 h-2.5 bg-red-500 rounded-full border border-navigation-bg"></span>
                 <?php endif ?>
             </button>
@@ -28,11 +32,11 @@
                     ],
                 ];
 
-                if (user()->podcasts !== []) {
-                    foreach (user()->podcasts as $userPodcast) {
+                if ($userPodcasts !== []) {
+                    foreach ($userPodcasts as $userPodcast) {
                         $userPodcastTitle = esc($userPodcast->title);
 
-                        $unreadNotificationDotDisplayClass = in_array($userPodcast->actor_id, user()->actorIdsWithUnreadNotifications, true) ? '' : 'hidden';
+                        $unreadNotificationDotDisplayClass = in_array($userPodcast->actor_id, $actorIdsWithUnreadNotifications, true) ? '' : 'hidden';
 
                         $items[] = [
                             'type' => 'link',
@@ -60,7 +64,7 @@
                 }
             ?>
             <DropdownMenu id="notifications-dropdown-menu" labelledby="notifications-dropdown" items="<?= esc(json_encode($items)) ?>" placement="bottom"/>
-            
+
             <button
                 type="button"
                 class="inline-flex items-center h-full px-3 text-sm font-semibold focus:ring-inset focus:ring-accent gap-x-2"
@@ -68,28 +72,34 @@
                 data-dropdown="button"
                 data-dropdown-target="my-account-dropdown-menu"
                 aria-haspopup="true"
-                aria-expanded="false"><div class="relative mr-1">
+                aria-expanded="false">
+                <div class="relative mr-1">
                     <?= icon('account-circle', 'text-3xl opacity-60') ?>
-                    <?= user()
+                    <?php if (can_user_interact()): ?>
+                        <?= auth()
+                        ->user()
                         ->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation-bg" loading="lazy" />' ?>
+                    <?php endif; ?>
                 </div>
-                <?= esc(user()->username) ?>
+                <?= esc(auth()->user()->username) ?>
                 <?= icon('caret-down', 'ml-auto text-2xl') ?></button>
         <?php
             $interactButtons = '';
-            foreach (user()->podcasts as $userPodcast) {
-                $checkMark = interact_as_actor_id() === $userPodcast->actor_id ? icon('check', 'ml-2 bg-accent-base text-accent-contrast rounded-full') : '';
-                $userPodcastTitle = esc($userPodcast->title);
+            foreach ($userPodcasts as $userPodcast) {
+                if (can_podcast(auth()->user(), $userPodcast->id, 'interact-as')) {
+                    $checkMark = interact_as_actor_id() === $userPodcast->actor_id ? icon('check', 'ml-2 bg-accent-base text-accent-contrast rounded-full') : '';
+                    $userPodcastTitle = esc($userPodcast->title);
 
-                $interactButtons .= <<<CODE_SAMPLE
-                    <button class="inline-flex items-center w-full px-4 py-1 hover:bg-highlight" id="interact-as-actor-{$userPodcast->id}" name="actor_id" value="{$userPodcast->actor_id}">
-                        <div class="inline-flex items-center flex-1 text-sm"><img src="{$userPodcast->cover->tiny_url}" class="w-6 h-6 mr-2 rounded-full" loading="lazy" /><span class="max-w-xs truncate">{$userPodcastTitle}</span>{$checkMark}</div>
-                    </button>
-                CODE_SAMPLE;
+                    $interactButtons .= <<<CODE_SAMPLE
+                       <button class="inline-flex items-center w-full px-4 py-1 hover:bg-highlight" id="interact-as-actor-{$userPodcast->id}" name="actor_id" value="{$userPodcast->actor_id}">
+                            <div class="inline-flex items-center flex-1 text-sm"><img src="{$userPodcast->cover->tiny_url}" class="w-6 h-6 mr-2 rounded-full" loading="lazy" /><span class="max-w-xs truncate">{$userPodcastTitle}</span>{$checkMark}</div>
+                       </button>
+                    CODE_SAMPLE;
+                }
             }
 
             $interactAsText = lang('Common.choose_interact');
-            $route = route_to('interact-as-actor');
+            $interactAsRoute = route_to('interact-as-actor');
             $csrfField = csrf_field();
 
             $menuItems = [
@@ -113,14 +123,14 @@
                 ],
             ];
 
-            if (user()->podcasts !== []) {
+            if ($userPodcasts !== []) {
                 $menuItems = array_merge([
                     [
                         'type' => 'html',
                         'content' => esc(<<<CODE_SAMPLE
                             <nav class="flex flex-col py-2 whitespace-nowrap">
                                 <span class="px-4 mb-2 text-xs font-semibold tracking-wider uppercase text-skin-muted">{$interactAsText}</span>
-                                <form action="{$route}" method="POST" class="flex flex-col">
+                                <form action="{$interactAsRoute}" method="POST" class="flex flex-col">
                                     {$csrfField}
                                     {$interactButtons}
                                 </form>
diff --git a/themes/cp_app/embed.php b/themes/cp_app/embed.php
index 26621ea0485f5c219804f3ebdea8d5f9397d1c91..072160b96814e9c17c6b6c048fac0334ae13452e 100644
--- a/themes/cp_app/embed.php
+++ b/themes/cp_app/embed.php
@@ -45,7 +45,7 @@
                 style="--vm-player-box-shadow:0; --vm-player-theme: hsl(var(--color-accent-base)); --vm-control-focus-color: hsl(var(--color-accent-contrast)); --vm-control-spacing: 4px; --vm-menu-item-focus-bg: hsl(var(--color-background-highlight)); --vm-control-icon-size: 24px; <?= str_ends_with($theme, 'transparent') ? '--vm-controls-bg: transparent;' : '' ?>"
             >
             <vm-audio preload="none">
-                <?php $source = logged_in() ? $episode->audio->file_url : $episode->audio_analytics_url .
+                <?php $source = auth()->loggedIn() ? $episode->audio->file_url : $episode->audio_analytics_url .
                     (isset($_SERVER['HTTP_REFERER'])
                         ? '?_from=' .
                             parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST)
diff --git a/themes/cp_app/home.php b/themes/cp_app/home.php
index c6f62d4cff21c985457082785693ae3de8dc051c..b04f29942ced10f04c3d4ffcef65943ca572a9e1 100644
--- a/themes/cp_app/home.php
+++ b/themes/cp_app/home.php
@@ -32,7 +32,7 @@
 
 <body class="flex flex-col min-h-screen mx-auto bg-base theme-<?= service('settings')
         ->get('App.theme') ?>">
-    <?php if (service('authentication')->check()): ?>
+    <?php if (auth()->loggedIn()): ?>
         <?= $this->include('_admin_navbar') ?>
     <?php endif; ?>
 
diff --git a/themes/cp_app/pages/_layout.php b/themes/cp_app/pages/_layout.php
index f0eb353f38bfa00e8367b61e25fcf4475178bbcb..0e3134fedaa84e1d55aeba4768e76ae8c0151461 100644
--- a/themes/cp_app/pages/_layout.php
+++ b/themes/cp_app/pages/_layout.php
@@ -34,7 +34,7 @@
 
 <body class="flex flex-col min-h-screen mx-auto bg-base theme-<?= service('settings')
         ->get('App.theme') ?>">
-    <?php if (service('authentication')->check()): ?>
+    <?php if (auth()->loggedIn()): ?>
         <?= $this->include('_admin_navbar') ?>
     <?php endif; ?>
 
diff --git a/themes/cp_app/pages/map.php b/themes/cp_app/pages/map.php
index 8f61b83ab80e3820ae94a513c380dd16e9e170d7..228f1488c44688f662769f32e56ed99037c08c62 100644
--- a/themes/cp_app/pages/map.php
+++ b/themes/cp_app/pages/map.php
@@ -37,7 +37,7 @@
 
 <body class="flex flex-col h-full min-h-screen mx-auto bg-base theme-<?= service('settings')
         ->get('App.theme') ?>">
-    <?php if (service('authentication')->check()): ?>
+    <?php if (auth()->loggedIn()): ?>
         <?= $this->include('_admin_navbar') ?>
     <?php endif; ?>
 
diff --git a/themes/cp_auth/_layout.php b/themes/cp_auth/_layout.php
index 7cbee61671f7c051aea3426d56536e7668c97505..166dff031517cd35da56de4b92b93a0026f9595e 100644
--- a/themes/cp_auth/_layout.php
+++ b/themes/cp_auth/_layout.php
@@ -1,6 +1,7 @@
 <?= helper('svg') ?>
 <!DOCTYPE html>
-<html lang="en">
+<html lang="<?= service('request')
+    ->getLocale() ?>">
 
 <head>
 	<meta charset="UTF-8"/>
diff --git a/themes/cp_auth/email_2fa_show.php b/themes/cp_auth/email_2fa_show.php
new file mode 100644
index 0000000000000000000000000000000000000000..ddedc530642f1e57f7483e5ee6faf136cafe3a38
--- /dev/null
+++ b/themes/cp_auth/email_2fa_show.php
@@ -0,0 +1,38 @@
+<?= $this->extend(config('Auth')->views['layout']) ?>
+
+<?= $this->section('title') ?><?= lang('Auth.email2FATitle') ?> <?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<div class="container p-5 d-flex justify-content-center">
+    <div class="shadow-sm card col-12 col-md-5">
+        <div class="card-body">
+            <h5 class="mb-5 card-title"><?= lang('Auth.email2FATitle') ?></h5>
+
+            <p><?= lang('Auth.confirmEmailAddress') ?></p>
+
+            <?php if (session('error')) : ?>
+                <div class="alert alert-danger"><?= session('error') ?></div>
+            <?php endif ?>
+
+            <form action="<?= url_to('auth-action-handle') ?>" method="post">
+                <?= csrf_field() ?>
+
+                <!-- Email -->
+                <div class="mb-2">
+                    <input type="email" class="form-control" name="email"
+                        inputmode="email" autocomplete="email" placeholder="<?= lang('Auth.email') ?>"
+                        <?php /** @var \CodeIgniter\Shield\Entities\User $user */ ?>
+                        value="<?= old('email', $user->email) ?>" required />
+                </div>
+
+                <div class="m-3 mx-auto d-grid col-8">
+                    <button type="submit" class="btn btn-primary btn-block"><?= lang('Auth.send') ?></button>
+                </div>
+
+            </form>
+        </div>
+    </div>
+</div>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_auth/email_2fa_verify.php b/themes/cp_auth/email_2fa_verify.php
new file mode 100644
index 0000000000000000000000000000000000000000..e9dad7f40cfa36c846df27e75ba9695317c45d7d
--- /dev/null
+++ b/themes/cp_auth/email_2fa_verify.php
@@ -0,0 +1,36 @@
+<?= $this->extend(config('Auth')->views['layout']) ?>
+
+<?= $this->section('title') ?><?= lang('Auth.email2FATitle') ?> <?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<div class="container p-5 d-flex justify-content-center">
+    <div class="shadow-sm card col-12 col-md-5">
+        <div class="card-body">
+            <h5 class="mb-5 card-title"><?= lang('Auth.emailEnterCode') ?></h5>
+
+            <p><?= lang('Auth.emailConfirmCode') ?></p>
+
+            <?php if (session('error') !== null) : ?>
+            <div class="alert alert-danger"><?= session('error') ?></div>
+            <?php endif ?>
+
+            <form action="<?= url_to('auth-action-verify') ?>" method="post">
+                <?= csrf_field() ?>
+
+                <!-- Code -->
+                <div class="mb-2">
+                    <input type="number" class="form-control" name="token" placeholder="000000"
+                        inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" required />
+                </div>
+
+                <div class="m-3 mx-auto d-grid col-8">
+                    <button type="submit" class="btn btn-primary btn-block"><?= lang('Auth.confirm') ?></button>
+                </div>
+
+            </form>
+        </div>
+    </div>
+</div>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_auth/email_activate_show.php b/themes/cp_auth/email_activate_show.php
new file mode 100644
index 0000000000000000000000000000000000000000..d0d93d73f3fdbd51e9fc8931d2da7d443a355d36
--- /dev/null
+++ b/themes/cp_auth/email_activate_show.php
@@ -0,0 +1,28 @@
+<?= helper('form') ?>
+<?= $this->extend(config('Auth')->views['layout']) ?>
+
+<?= $this->section('title') ?><?= lang('Auth.emailActivateTitle') ?><?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<p><?= lang('Auth.emailActivateBody') ?></p>
+
+<form action="<?= site_url('auth/a/verify') ?>" method="POST" class="flex flex-col w-full gap-y-4">
+    <?= csrf_field() ?>
+
+    <!-- Code -->
+    <Forms.Field
+        name="token"
+        label="<?= lang('Auth.token') ?>"
+        required="true"
+        inputmode="numeric"
+        pattern="[0-9]*"
+        autocomplete="one-time-code"
+        autofocus="autofocus"
+        placeholder="000000"
+    />
+
+    <Button variant="primary" type="submit" class="self-end"><?= lang('Auth.send') ?></Button>
+</form>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_auth/emails/activation.php b/themes/cp_auth/emails/activation.php
deleted file mode 100644
index 4dc506c60ba255ce5daf98fecdd0be1b3b014d83..0000000000000000000000000000000000000000
--- a/themes/cp_auth/emails/activation.php
+++ /dev/null
@@ -1,11 +0,0 @@
-<p>This is activation email for your account on <?= base_url() ?>.</p>
-
-<p>To activate your account use this URL.</p>
-
-<p><a href="<?= url_to('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>
diff --git a/themes/cp_auth/emails/email_2fa_email.php b/themes/cp_auth/emails/email_2fa_email.php
new file mode 100644
index 0000000000000000000000000000000000000000..bbc010c5750acf207e0abe7b78e3ca967393a72b
--- /dev/null
+++ b/themes/cp_auth/emails/email_2fa_email.php
@@ -0,0 +1 @@
+<p><?= lang('Auth.email2FAMailBody') ?> <b><?= $code ?></b></p>
diff --git a/themes/cp_auth/emails/email_activate_email.php b/themes/cp_auth/emails/email_activate_email.php
new file mode 100644
index 0000000000000000000000000000000000000000..9686df46cdb5bbf6f92e4cf52f1fb1b441f46d4a
--- /dev/null
+++ b/themes/cp_auth/emails/email_activate_email.php
@@ -0,0 +1,3 @@
+<p><?= lang('Auth.emailActivateMailBody') ?></p>
+
+<p><?= $code ?></p>
diff --git a/themes/cp_auth/emails/forgot.php b/themes/cp_auth/emails/forgot.php
deleted file mode 100644
index e6f199aa01a1c075797a334f3963dca95c72f605..0000000000000000000000000000000000000000
--- a/themes/cp_auth/emails/forgot.php
+++ /dev/null
@@ -1,13 +0,0 @@
-<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="<?= url_to('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/themes/cp_auth/emails/magic_link_email.php b/themes/cp_auth/emails/magic_link_email.php
new file mode 100644
index 0000000000000000000000000000000000000000..d6f6a9432662fc9a6cb8ea4fd31ce82641934dc5
--- /dev/null
+++ b/themes/cp_auth/emails/magic_link_email.php
@@ -0,0 +1,5 @@
+<p>
+    <a href="<?= url_to('verify-magic-link') ?>?token=<?= $token ?>">
+        <?= lang('Auth.login') ?>
+    </a>
+</p>
diff --git a/themes/cp_auth/emails/welcome_email.php b/themes/cp_auth/emails/welcome_email.php
new file mode 100644
index 0000000000000000000000000000000000000000..13da1138442a4a6fe631023fad926533726a20d2
--- /dev/null
+++ b/themes/cp_auth/emails/welcome_email.php
@@ -0,0 +1,10 @@
+<p>
+    <?= lang('Auth.emailWelcomeMailBody', [
+    'domain' => current_domain(),
+        'numberOfHours' => setting('Auth.welcomeLinkLifetime') / 3600,
+]) ?><br /><br />
+
+    <a href="<?= url_to('verify-magic-link') ?>?token=<?= $token ?>">
+        <?= lang('Auth.login') ?>
+    </a>
+</p>
diff --git a/themes/cp_auth/forgot.php b/themes/cp_auth/forgot.php
deleted file mode 100644
index 57756758b8050a8de587dc6b89dd58faaf26a740..0000000000000000000000000000000000000000
--- a/themes/cp_auth/forgot.php
+++ /dev/null
@@ -1,24 +0,0 @@
-<?= helper('form') ?>
-<?= $this->extend($config->viewLayout) ?>
-
-<?= $this->section('title') ?>
-	<?= lang('Auth.forgotPassword') ?>
-<?= $this->endSection() ?>
-
-
-<?= $this->section('content') ?>
-
-<p class="mb-4 text-skin-muted"><?= lang('Auth.enterEmailForInstructions') ?></p>
-
-<form action="<?= route_to('forgot') ?>" method="POST" class="flex flex-col w-full gap-y-4">
-    <?= csrf_field() ?>
-
-    <Forms.Field
-        name="email"
-        label="<?= lang('Auth.emailAddress') ?>"
-        type="email"
-        required="true" />
-    <Button variant="primary" type="submit" class="self-end"><?= lang('Auth.sendInstructions') ?></Button>
-</form>
-
-<?= $this->endSection() ?>
diff --git a/themes/cp_auth/login.php b/themes/cp_auth/login.php
index ba6fd06e28ad7759a468bc72e6cea005700da25e..34d2849e13de99c33bc394a97a0f80978c84081d 100644
--- a/themes/cp_auth/login.php
+++ b/themes/cp_auth/login.php
@@ -1,29 +1,38 @@
 <?= helper('form') ?>
-<?= $this->extend($config->viewLayout) ?>
+<?= $this->extend(config('Auth')->views['layout']) ?>
 
-<?= $this->section('title') ?>
-    <?= lang('Auth.loginTitle') ?>
-<?= $this->endSection() ?>
+<?= $this->section('title') ?><?= lang('Auth.login') ?><?= $this->endSection() ?>
 
 
 <?= $this->section('content') ?>
 
-<form actions="<?= route_to('login') ?>" method="POST" class="flex flex-col w-full gap-y-4">
+<form actions="<?= url_to('login') ?>" method="POST" class="flex flex-col w-full gap-y-4">
     <?= csrf_field() ?>
 
     <Forms.Field
-        name="login"
-        label="<?= lang('Auth.emailOrUsername') ?>"
+        name="email"
+        label="<?= lang('Auth.email') ?>"
         required="true"
-        autofocus="autofocus" />
+        type="email"
+        inputmode="email"
+        autocomplete="email"
+        autofocus="autofocus"
+    />
 
     <Forms.Field
         name="password"
         label="<?= lang('Auth.password') ?>"
         type="password"
+        inputmode="text"
+        autocomplete="current-password"
         required="true" />
 
-    <Button variant="primary" type="submit" class="self-end"><?= lang('Auth.loginAction') ?></Button>
+    <!-- Remember me -->
+    <?php if (setting('Auth.sessionConfig')['allowRemembering']): ?>
+        <Forms.Toggler name="remember" value="yes" checked="<?= old('remember') ?>" size="small"><?= lang('Auth.rememberMe') ?></Forms.Toggler>
+    <?php endif; ?>
+
+    <Button variant="primary" type="submit" class="self-end"><?= lang('Auth.login') ?></Button>
 </form>
 
 <?= $this->endSection() ?>
@@ -32,14 +41,12 @@
 <?= $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>
+    <?php if (setting('Auth.allowMagicLinkLogins')) : ?>
+            <p class="text-center"><?= lang('Auth.forgotPassword') ?> <a class="underline hover:no-underline" href="<?= url_to('magic-link') ?>"><?= lang('Auth.useMagicLink') ?></a></p>
+    <?php endif ?>
+    <?php if (setting('Auth.allowRegistration')) : ?>
+        <p class="text-center"><?= lang('Auth.needAccount') ?> <a class="underline hover:no-underline" href="<?= url_to('register') ?>"><?= lang('Auth.register') ?></a></p>
+    <?php endif ?>
 </div>
 
 <?= $this->endSection() ?>
diff --git a/themes/cp_auth/magic_link_form.php b/themes/cp_auth/magic_link_form.php
new file mode 100644
index 0000000000000000000000000000000000000000..b4f9c9785c4dd168795a6735a079823dd4c5fa90
--- /dev/null
+++ b/themes/cp_auth/magic_link_form.php
@@ -0,0 +1,24 @@
+<?= helper('form') ?>
+<?= $this->extend(config('Auth')->views['layout']) ?>
+
+<?= $this->section('title') ?><?= lang('Auth.useMagicLink') ?> <?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<form actions="<?= url_to('magic-link') ?>" method="POST" class="flex flex-col w-full gap-y-4">
+    <?= csrf_field() ?>
+
+    <Forms.Field
+        name="email"
+        label="<?= lang('Auth.email') ?>"
+        required="true"
+        inputmode="email"
+        autocomplete="email"
+        autofocus="autofocus"
+        value="<?= old('email', auth()->user()->email ?? null) ?>"
+    />
+
+    <Button variant="primary" type="submit" class="self-end"><?= lang('Auth.send') ?></Button>
+</form>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_auth/magic_link_message.php b/themes/cp_auth/magic_link_message.php
new file mode 100644
index 0000000000000000000000000000000000000000..ade1b651b34e30cdb43eb62481b951f087cac0a2
--- /dev/null
+++ b/themes/cp_auth/magic_link_message.php
@@ -0,0 +1,13 @@
+<?= $this->extend(config('Auth')->views['layout']) ?>
+
+<?= $this->section('title') ?><?= lang('Auth.useMagicLink') ?> <?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<p class="text-lg font-semibold"><?= lang('Auth.checkYourEmail') ?></p>
+
+<p><?= lang('Auth.magicLinkDetails', [setting('Auth.magicLinkLifetime') / 60]) ?></p>
+
+</form>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_auth/magic_link_set_password.php b/themes/cp_auth/magic_link_set_password.php
new file mode 100644
index 0000000000000000000000000000000000000000..64d85614b687d3afad806efe788e622d5f975a29
--- /dev/null
+++ b/themes/cp_auth/magic_link_set_password.php
@@ -0,0 +1,26 @@
+<?= helper('form') ?>
+<?= $this->extend(config('Auth')->views['layout']) ?>
+
+<?= $this->section('title') ?>
+	<?= lang('Auth.set_password') ?>
+<?= $this->endSection() ?>
+
+
+<?= $this->section('content') ?>
+
+<form action="<?= url_to('magic-link-set-password') ?>" method="POST" class="flex flex-col w-full gap-y-4">
+<?= csrf_field() ?>
+
+<Forms.Field
+    name="new_password"
+    label="<?= lang('Auth.password') ?>"
+    type="password"
+    required="true"
+    inputmode="text"
+    autocomplete="new-password" />
+
+<Button variant="primary" type="submit" class="self-end"><?= lang('Auth.set_password') ?></Button>
+
+</form>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_auth/register.php b/themes/cp_auth/register.php
index 292f25ba3e7b5d89496ffb8505c843812793e51e..c8eeccc2cace3bcf072d47e1d3ef6bb45d630a5b 100644
--- a/themes/cp_auth/register.php
+++ b/themes/cp_auth/register.php
@@ -1,5 +1,5 @@
 <?= helper('form') ?>
-<?= $this->extend($config->viewLayout) ?>
+<?= $this->extend(config('Auth')->views['layout']) ?>
 
 <?= $this->section('title') ?>
 	<?= lang('Auth.register') ?>
@@ -8,19 +8,22 @@
 
 <?= $this->section('content') ?>
 
-<form action="<?= route_to('register') ?>" method="POST" class="flex flex-col w-full gap-y-4">
+<form action="<?= url_to('register') ?>" method="POST" class="flex flex-col w-full gap-y-4">
 <?= csrf_field() ?>
 
 <Forms.Field
-    name="email"
-    label="<?= lang('Auth.email') ?>"
-    helper="<?= lang('Auth.weNeverShare') ?>"
-    type="email"
+    name="username"
+    label="<?= lang('Auth.username') ?>"
+    autocomplete="username"
+    inputmode="text"
     required="true" />
 
 <Forms.Field
-    name="username"
-    label="<?= lang('Auth.username') ?>"
+    name="email"
+    label="<?= lang('Auth.email') ?>"
+    type="email"
+    inputmode="email"
+    autocomplete="email"
     required="true" />
 
 <Forms.Field
@@ -28,6 +31,15 @@
     label="<?= lang('Auth.password') ?>"
     type="password"
     required="true"
+    inputmode="text"
+    autocomplete="new-password" />
+
+<Forms.Field
+    name="password_confirm"
+    label="<?= lang('Auth.passwordConfirm') ?>"
+    type="password"
+    required="true"
+    inputmode="text"
     autocomplete="new-password" />
 
 <Button variant="primary" type="submit" class="self-end"><?= lang('Auth.register') ?></Button>
@@ -41,10 +53,10 @@
 
 <p class="py-4 text-sm text-center">
     <?= lang(
-    'Auth.alreadyRegistered',
+    'Auth.haveAccount',
 ) ?> <a class="underline hover:no-underline" href="<?= route_to(
     'login',
-) ?>"><?= lang('Auth.signIn') ?></a>
+) ?>"><?= lang('Auth.login') ?></a>
 </p>
 
 <?= $this->endSection() ?>
diff --git a/themes/cp_auth/reset.php b/themes/cp_auth/reset.php
deleted file mode 100644
index 2b9199a98b909a74bebc65b5f84cff0be27f88c3..0000000000000000000000000000000000000000
--- a/themes/cp_auth/reset.php
+++ /dev/null
@@ -1,38 +0,0 @@
-<?= helper('form') ?>
-<?= $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 w-full">
-<?= csrf_field() ?>
-
-<Forms.Field
-    name="token"
-    label="<?= lang('Auth.token') ?>"
-    value="<?= esc($token) ?? '' ?>"
-    required="true" />
-    
-<Forms.Field
-    name="email"
-    label="<?= lang('Auth.email') ?>"
-    type="email"
-    required="true" />
-
-<Forms.Field
-    name="password"
-    label="<?= lang('Auth.newPassword') ?>"
-    type="password"
-    required="true"
-    autocomplete="new-password" />
-
-<Button variant="primary" type="submit" class="self-end"><?= lang('Auth.resetPassword') ?></Button>
-
-</form>
-
-<?= $this->endSection() ?>
diff --git a/themes/cp_install/_layout.php b/themes/cp_install/_layout.php
index 0e72bbdb757a174cba5619ead5079863e6085875..6d35f502cc964caab1424a0810b139cd15c1e78d 100644
--- a/themes/cp_install/_layout.php
+++ b/themes/cp_install/_layout.php
@@ -1,5 +1,6 @@
 <!DOCTYPE html>
-<html lang="en">
+<html lang="<?= service('request')
+    ->getLocale() ?>">
 
 <head>
     <meta charset="UTF-8"/>
diff --git a/themes/cp_install/create_superadmin.php b/themes/cp_install/create_superadmin.php
index 25aeb06bf2ebd3259d156e33de343f265a5fefa8..9fe28fa704dc078c0937c8439e75f50fa38c0143 100644
--- a/themes/cp_install/create_superadmin.php
+++ b/themes/cp_install/create_superadmin.php
@@ -11,14 +11,14 @@
 </div>
 
 <Forms.Field
-    name="email"
-    label="<?= lang('Install.form.email') ?>"
-    type="email"
+    name="username"
+    label="<?= lang('Install.form.username') ?>"
     required="true" />
 
 <Forms.Field
-    name="username"
-    label="<?= lang('Install.form.username') ?>"
+    name="email"
+    label="<?= lang('Install.form.email') ?>"
+    type="email"
     required="true" />
 
 <Forms.Field