From 1ddfe70c68edc75c1826a8feb78d0bcbc499e689 Mon Sep 17 00:00:00 2001
From: Ron Lucke <lucke@elan-ev.de>
Date: Tue, 25 Jun 2024 14:56:16 +0000
Subject: [PATCH] TIC Avatar Modernisierung

Closes #4055

Merge request studip/studip!2877
---
 app/controllers/course/avatar.php             |  17 +
 app/controllers/course/basicdata.php          |   5 +-
 app/controllers/course/studygroup.php         |  16 +-
 app/controllers/institute/avatar.php          |  44 ++
 app/controllers/institute/basicdata.php       |   2 -
 app/controllers/settings/avatar.php           |  23 +
 app/views/avatar/update.php                   |  75 ---
 app/views/course/avatar/index.php             |   7 +
 app/views/course/studygroup/avatar.php        |   7 +
 app/views/institute/avatar/index.php          |   7 +
 app/views/institute/basicdata/index.php       |  20 -
 app/views/profile/widget-avatar.php           |  10 +-
 app/views/settings/avatar/index.php           |   7 +
 lib/classes/Avatar.php                        |   5 +
 lib/classes/JsonApi/RouteMap.php              |   9 +
 .../JsonApi/Routes/Avatar/Authority.php       |  29 ++
 .../JsonApi/Routes/Avatar/AvatarHelpers.php   |  40 ++
 .../Routes/Avatar/AvatarOfRangeShow.php       |  34 ++
 .../JsonApi/Routes/Avatar/AvatarUpload.php    |  66 +++
 .../Routes/Avatar/AvatarofRangeDelete.php     |  36 ++
 lib/classes/JsonApi/SchemaMap.php             |   2 +
 lib/classes/JsonApi/Schemas/Avatar.php        |  69 +++
 lib/modules/CoreAdmin.php                     |   3 +-
 lib/modules/CoreStudygroupAdmin.php           |   2 +-
 lib/navigation/AdminNavigation.php            |   1 +
 lib/navigation/ProfileNavigation.php          |   1 +
 public/assets/images/icons/blue/flip.svg      |  44 ++
 .../assets/javascripts/bootstrap/avatar.js    |  61 +--
 resources/assets/javascripts/chunk-loader.js  |  28 +-
 resources/assets/javascripts/chunks/avatar.js |   0
 resources/vue/avatar-app.js                   |  76 ++++
 resources/vue/components/avatar/AvatarApp.vue | 430 ++++++++++++++++++
 resources/vue/store/avatar.module.js          | 102 +++++
 33 files changed, 1100 insertions(+), 178 deletions(-)
 create mode 100644 app/controllers/course/avatar.php
 create mode 100644 app/controllers/institute/avatar.php
 create mode 100644 app/controllers/settings/avatar.php
 delete mode 100644 app/views/avatar/update.php
 create mode 100644 app/views/course/avatar/index.php
 create mode 100644 app/views/course/studygroup/avatar.php
 create mode 100644 app/views/institute/avatar/index.php
 create mode 100644 app/views/settings/avatar/index.php
 create mode 100644 lib/classes/JsonApi/Routes/Avatar/Authority.php
 create mode 100644 lib/classes/JsonApi/Routes/Avatar/AvatarHelpers.php
 create mode 100644 lib/classes/JsonApi/Routes/Avatar/AvatarOfRangeShow.php
 create mode 100644 lib/classes/JsonApi/Routes/Avatar/AvatarUpload.php
 create mode 100644 lib/classes/JsonApi/Routes/Avatar/AvatarofRangeDelete.php
 create mode 100644 lib/classes/JsonApi/Schemas/Avatar.php
 create mode 100644 public/assets/images/icons/blue/flip.svg
 create mode 100644 resources/assets/javascripts/chunks/avatar.js
 create mode 100644 resources/vue/avatar-app.js
 create mode 100644 resources/vue/components/avatar/AvatarApp.vue
 create mode 100644 resources/vue/store/avatar.module.js

diff --git a/app/controllers/course/avatar.php b/app/controllers/course/avatar.php
new file mode 100644
index 00000000000..b5b34d2c1dd
--- /dev/null
+++ b/app/controllers/course/avatar.php
@@ -0,0 +1,17 @@
+<?php
+
+class Course_AvatarController extends AuthenticatedController
+{
+    public function index_action()
+    {
+        $this->course_id = Context::getId();
+        if (!$GLOBALS['perm']->have_studip_perm('tutor', $this->course_id)) {
+            throw new AccessDeniedException(_("Sie haben keine Berechtigung diese " .
+                "Veranstaltung zu verändern."));
+        }
+        PageLayout::setTitle(Context::getHeaderLine() . ' - ' . _('Veranstaltungsbild ändern'));
+        Navigation::activateItem('/course/admin/avatar');
+        $avatar = CourseAvatar::getAvatar($this->course_id);
+        $this->avatar_url = $avatar->getURL(Avatar::NORMAL);
+    }
+}
\ No newline at end of file
diff --git a/app/controllers/course/basicdata.php b/app/controllers/course/basicdata.php
index 97ec0531cbb..329554f244f 100644
--- a/app/controllers/course/basicdata.php
+++ b/app/controllers/course/basicdata.php
@@ -379,10 +379,7 @@ class Course_BasicdataController extends AuthenticatedController
                 );
             }
         }
-        $widget->addLink(_('Bild ändern'),
-            $this->url_for('avatar/update/course', $this->course_id),
-            Icon::create('edit')
-        );
+
         if ($GLOBALS['perm']->have_perm('admin')) {
             $is_locked = $course->lock_rule;
             $widget->addLink(
diff --git a/app/controllers/course/studygroup.php b/app/controllers/course/studygroup.php
index cd08ba3ce3d..1f9c4a48405 100644
--- a/app/controllers/course/studygroup.php
+++ b/app/controllers/course/studygroup.php
@@ -383,13 +383,7 @@ class Course_StudygroupController extends AuthenticatedController
                 $this->url_for('course/wizard?studygroup=1'),
                 Icon::create('add')
             );
-            if ($GLOBALS['perm']->have_studip_perm('tutor', $id)) {
-                $actions->addLink(
-                    _('Bild ändern'),
-                    $this->url_for('avatar/update/course/' . $id),
-                    Icon::create('edit')
-                );
-            }
+
             $actions->addLink(
                 _('Diese Studiengruppe löschen'),
                 $this->deleteURL(),
@@ -977,6 +971,12 @@ class Course_StudygroupController extends AuthenticatedController
 
         $this->redirect($this->url_for('messages/write', ['course_id' => $id, 'default_subject' => $subject, 'filter' => 'all', 'emailrequest' => 1]));
     }
-
+    public function avatar_action()
+    {
+        Navigation::activateItem('/course/admin/avatar');
+        $this->studygroup_id = Context::getId();
+        $avatar = StudygroupAvatar::getAvatar($this->studygroup_id);
+        $this->avatar_url = $avatar->getURL(Avatar::NORMAL);
+    }
 
 }
diff --git a/app/controllers/institute/avatar.php b/app/controllers/institute/avatar.php
new file mode 100644
index 00000000000..24b840059e3
--- /dev/null
+++ b/app/controllers/institute/avatar.php
@@ -0,0 +1,44 @@
+<?php
+
+class Institute_AvatarController extends AuthenticatedController
+{
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        // Ensure only admins gain access to this page
+        if (!$GLOBALS['perm']->have_perm("admin")) {
+            throw new AccessDeniedException();
+        }
+    }
+    public function index_action($i_id = false)
+    {
+        //get ID from an open Institut
+        $i_view = $i_id ?: Request::option('i_view', Context::getId());
+
+        if (!$i_view) {
+            Navigation::activateItem('/admin/institute/avatar');
+            require_once 'lib/admin_search.inc.php';
+
+            // This search just died a little inside, so it should be safe to
+            // continue here but we nevertheless return just to be sure
+            return;
+        } elseif ($i_view === 'new') {
+            closeObject();
+            Navigation::activateItem('/admin/institute/create');
+        } else {
+            Navigation::activateItem('/admin/institute/avatar');
+        }
+
+        //  allow only inst-admin and root to view / edit
+        if ($i_view && !$GLOBALS['perm']->have_studip_perm('admin', $i_view) && $i_view !== 'new') {
+            throw new AccessDeniedException();
+        }
+
+        PageLayout::setTitle(Context::getHeaderLine() . ' - ' . _('Einrichtungsbild ändern'));
+        $this->institute_id = Context::getId();
+        $avatar = InstituteAvatar::getAvatar($this->institute_id);
+        $this->avatar_url = $avatar->getURL(Avatar::NORMAL);
+        
+    }
+}
\ No newline at end of file
diff --git a/app/controllers/institute/basicdata.php b/app/controllers/institute/basicdata.php
index 9c800fdbbaa..0b7c2960201 100644
--- a/app/controllers/institute/basicdata.php
+++ b/app/controllers/institute/basicdata.php
@@ -35,8 +35,6 @@ class Institute_BasicdataController extends AuthenticatedController
     {
         PageLayout::setTitle(_('Verwaltung der Grunddaten'));
 
-        PageLayout::addSqueezePackage('avatar');
-
         //get ID from an open Institut
         $i_view = $i_id ?: Request::option('i_view', Context::getId());
 
diff --git a/app/controllers/settings/avatar.php b/app/controllers/settings/avatar.php
new file mode 100644
index 00000000000..31bd7050233
--- /dev/null
+++ b/app/controllers/settings/avatar.php
@@ -0,0 +1,23 @@
+<?php
+
+class Settings_AvatarController extends AuthenticatedController
+{
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+        // Ensure user is logged in
+        $GLOBALS['auth']->login_if($action !== 'logout' && $GLOBALS['auth']->auth['uid'] === 'nobody');
+
+        if (!$GLOBALS['perm']->have_profile_perm('user', User::findCurrent()->id)) {
+            throw new AccessDeniedException(_('Sie dürfen dieses Profil nicht bearbeiten'));
+        }
+    }
+    public function index_action()
+    {
+        PageLayout::setTitle(_('Profilbild anpassen'));
+        Navigation::activateItem('/profile/edit/avatar');
+        $this->user_id = User::findCurrent()->id;
+        $avatar = Avatar::getAvatar($this->user_id);
+        $this->avatar_url = $avatar->getURL(Avatar::NORMAL);
+    }
+}
\ No newline at end of file
diff --git a/app/views/avatar/update.php b/app/views/avatar/update.php
deleted file mode 100644
index f995702536e..00000000000
--- a/app/views/avatar/update.php
+++ /dev/null
@@ -1,75 +0,0 @@
-<?php
-/**
- * @var AvatarController $controller
- * @var string $type
- * @var string $id
- * @var string $avatar
- * @var bool $customized
- * @var string $cancel_link
- */
-?>
-<form class="default settings-avatar" enctype="multipart/form-data"
-        action="<?= $controller->link_for('avatar/upload', $type, $id) ?>" method="post">
-    <fieldset>
-        <legend>
-            <?= $type == 'user' ? _('Profilbild bearbeiten und zuschneiden') :
-                ($type == 'course' ? _('Veranstaltungsbild bearbeiten und zuschneiden') :
-                    _('Einrichtungsbild bearbeiten und zuschneiden')) ?>
-        </legend>
-        <div class="form-group">
-            <div id="avatar-preview">
-                <img class="avatar-normal" id="new-avatar" src="<?= htmlReady($avatar) ?>"
-                     data-message-too-small="<?= _('Das Bild ist kleiner als 250 x 250 Pixel. Wollen Sie wirklich fortfahren?') ?>">
-            </div>
-
-            <label class="file-upload">
-                <?= _('Wählen Sie ein Bild von Ihrer Festplatte aus.') ?>
-                <input type="file" id="avatar-upload" accept="image/gif,image/png,image/jpeg,image/webp"
-                       capture="camera"
-                       data-max-size="<?= Avatar::MAX_FILE_SIZE ?>"
-                       data-message-too-large="<?= _('Die hochgeladene Datei ist zu groß. Bitte wählen Sie ein anderes Bild.') ?>">
-
-                <p class="form-text">
-                    <?= sprintf(
-                        _('Die Bilddatei darf max. %s groß sein, es sind nur Dateien mit den Endungen .jpg, .png, .gif und .webp erlaubt!'),
-                        relsize(Avatar::MAX_FILE_SIZE)
-                    ) ?>
-                </p>
-
-                <a class="button" tabindex="0"><?= _('Auswählen') ?></a>
-            </label>
-
-            <input type="hidden" name="cropped-image" id="cropped-image" value="">
-
-            <div id="avatar-buttons" class="hidden-js">
-                <a href="" id="avatar-zoom-in" title="<?= _('Vergrößern') ?>">
-                    <?= Icon::create('zoom-in')->asImg(24) ?>
-                    <?= _('Vergrößern') ?>
-                </a>
-                <a href="" id="avatar-zoom-out" title="<?= _('Verkleinern') ?>">
-                    <?= Icon::create('zoom-out')->asImg(24) ?>
-                    <?= _('Verkleinern') ?>
-                </a>
-                <a href="" id="avatar-rotate-clockwise" title="<?= _('Nach rechts drehen') ?>">
-                    <?= Icon::create('rotate-right')->asImg(24) ?>
-                    <?= _('Nach rechts drehen') ?>
-                </a>
-                <a href="" id="avatar-rotate-counter-clockwise" title="<?= _('Nach links drehen') ?>">
-                    <?= Icon::create('rotate-left')->asImg(24) ?>
-                    <?= _('Nach links drehen') ?>
-                </a>
-            </div>
-        </div>
-        <?= CSRFProtection::tokenTag() ?>
-    </fieldset>
-    <footer data-dialog-button>
-        <?= Studip\Button::createAccept(_('Absenden'), 'upload', ['id' => 'submit-avatar']) ?>
-        <? if ($customized): ?>
-            <?= Studip\LinkButton::create(
-                _('Aktuelles Bild löschen'),
-                $controller->url_for('avatar/delete', $type, $id)
-            ) ?>
-        <? endif ?>
-        <?= Studip\LinkButton::createCancel(_('Abbrechen'), $cancel_link) ?>
-    </footer>
-</form>
diff --git a/app/views/course/avatar/index.php b/app/views/course/avatar/index.php
new file mode 100644
index 00000000000..4899aad5353
--- /dev/null
+++ b/app/views/course/avatar/index.php
@@ -0,0 +1,7 @@
+<div
+    id="avatar-courses-app"
+    entry-type="courses"
+    entry-id="<?= $course_id ?>"
+    avatar-url="<?= $avatar_url ?>"
+>
+</div>
\ No newline at end of file
diff --git a/app/views/course/studygroup/avatar.php b/app/views/course/studygroup/avatar.php
new file mode 100644
index 00000000000..ed3eeec2bed
--- /dev/null
+++ b/app/views/course/studygroup/avatar.php
@@ -0,0 +1,7 @@
+<div
+    id="avatar-studygroups-app"
+    entry-type="courses"
+    entry-id="<?= $studygroup_id ?>"
+    avatar-url="<?= $avatar_url ?>"
+>
+</div>
\ No newline at end of file
diff --git a/app/views/institute/avatar/index.php b/app/views/institute/avatar/index.php
new file mode 100644
index 00000000000..fee3c00718e
--- /dev/null
+++ b/app/views/institute/avatar/index.php
@@ -0,0 +1,7 @@
+<div
+    id="avatar-institutes-app"
+    entry-type="institutes"
+    entry-id="<?= $institute_id ?>"
+    avatar-url="<?= $avatar_url ?>"
+>
+</div>
\ No newline at end of file
diff --git a/app/views/institute/basicdata/index.php b/app/views/institute/basicdata/index.php
index abb048786ef..5c364918d7e 100644
--- a/app/views/institute/basicdata/index.php
+++ b/app/views/institute/basicdata/index.php
@@ -142,23 +142,3 @@
        <input type="hidden" name="i_view" value="<?= $i_view ?>">
     </footer>
 </form>
-
-<?php
-$sidebar = Sidebar::get();
-
-if (!$institute->isNew()) {
-    $widget = new ActionsWidget();
-    $widget->addLink(
-        _('Infobild ändern'),
-        URLHelper::getURL('dispatch.php/avatar/update/institute/' . $institute->id),
-        Icon::create('edit')
-    )->asDialog();
-    if (InstituteAvatar::getAvatar($institute->id)->is_customized()) {
-        $widget->addLink(
-            _('Infobild löschen'),
-            URLHelper::getURL('dispatch.php/avatar/delete/institute/' . $institute->id),
-            Icon::create('trash')
-        );
-    }
-    $sidebar->addWidget($widget);
-}
diff --git a/app/views/profile/widget-avatar.php b/app/views/profile/widget-avatar.php
index 157b74a1725..74b142c6a19 100644
--- a/app/views/profile/widget-avatar.php
+++ b/app/views/profile/widget-avatar.php
@@ -1,17 +1,11 @@
 <div class="avatar-widget">
     <? if ($GLOBALS['perm']->have_profile_perm('user', $current_user)) : ?>
         <a class="profile-avatar"
-           accept="image/gif,image/png,image/jpeg" capture="camera"
-           data-max-size="<?= Avatar::MAX_FILE_SIZE ?>"
-           data-message-too-large="<?= _('Die hochgeladene Datei ist zu groß. Bitte wählen Sie ein anderes Bild.') ?>"
-           data-message-unaccepted="<?= _('Die hochgeladene Datei hat falsche Typ. Bitte wählen Sie ein anderes Bild.') ?>"
-           href="<?= URLHelper::getURL('dispatch.php/avatar/update/user/' . $current_user) ?>" data-dialog>
+           href="<?= URLHelper::getURL('dispatch.php/settings/avatar/') ?>">
             <?= $avatar->getImageTag(Avatar::NORMAL) ?>
             <div id="avatar-overlay" class="avatar-overlay">
                 <div class="text">
-                    <?= _('Bild hochladen oder löschen.') ?>
-                    <br>
-                    <?= _('Drag & Drop oder Klicken') ?>
+                    <?= _('Profilbild ändern') ?>
                 </div>
             </div>
         </a>
diff --git a/app/views/settings/avatar/index.php b/app/views/settings/avatar/index.php
new file mode 100644
index 00000000000..cee9ba495c5
--- /dev/null
+++ b/app/views/settings/avatar/index.php
@@ -0,0 +1,7 @@
+<div
+    id="avatar-users-app"
+    entry-type="users"
+    entry-id="<?= $user_id ?>"
+    avatar-url="<?= $avatar_url ?>"
+>
+</div>
\ No newline at end of file
diff --git a/lib/classes/Avatar.php b/lib/classes/Avatar.php
index 959523f2ea1..d2dcd9d65c7 100644
--- a/lib/classes/Avatar.php
+++ b/lib/classes/Avatar.php
@@ -632,4 +632,9 @@ class Avatar
             imagedestroy($img);
         }
     }
+
+    public function getId()
+    {
+        return $this->user_id;
+    }
 }
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 63c69e63059..e9a0a011a88 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -125,6 +125,7 @@ class RouteMap
             $this->addAuthenticatedCoursewareRoutes($group);
         }
 
+        $this->addAuthenticatedAvatarRoutes($group);
         $this->addAuthenticatedEventsRoutes($group);
         $this->addAuthenticatedFeedbackRoutes($group);
         $this->addAuthenticatedFilesRoutes($group);
@@ -650,6 +651,14 @@ class RouteMap
         $group->post('/stock-images/{id}/blob', Routes\StockImages\StockImagesUpload::class);
     }
 
+    private function addAuthenticatedAvatarRoutes(RouteCollectorProxy $group): void
+    {
+        $group->get('/{type:courses|institutes|users}/{id}/avatar', Routes\Avatar\AvatarOfRangeShow::class);
+        $group->delete('/{type:courses|institutes|users}/{id}/avatar', Routes\Avatar\AvatarofRangeDelete::class);
+
+        $group->post('/{type:courses|institutes|users}/{id}/avatar', Routes\Avatar\AvatarUpload::class);
+    }
+
     private function addRelationship(RouteCollectorProxy $group, string $url, string $handler): void
     {
         $group->map(['GET', 'PATCH', 'POST', 'DELETE'], $url, $handler);
diff --git a/lib/classes/JsonApi/Routes/Avatar/Authority.php b/lib/classes/JsonApi/Routes/Avatar/Authority.php
new file mode 100644
index 00000000000..20166e702b4
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Avatar/Authority.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace JsonApi\Routes\Avatar;
+
+use Avatar;
+use User;
+use Course;
+use Institute;
+
+class Authority
+{
+    public static function canShowAvatarOfRange(User $user, Avatar $resource): bool
+    {
+        return true;
+    }
+
+    public static function canUpdateAvatarOfUser(User $user): bool
+    {
+        return $user->hasPermissionLevel('user', $user);
+    }
+    public static function canUpdateAvatarOfInstitute(User $user, Institute $institute): bool
+    {
+        return $user->hasPermissionLevel('admin', $institute);
+    }
+    public static function canUpdateAvatarOfSeminar(User $user, Course $course): bool
+    {
+        return $user->hasPermissionLevel('tutor', $course);
+    }
+}
\ No newline at end of file
diff --git a/lib/classes/JsonApi/Routes/Avatar/AvatarHelpers.php b/lib/classes/JsonApi/Routes/Avatar/AvatarHelpers.php
new file mode 100644
index 00000000000..b288d08cb54
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Avatar/AvatarHelpers.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace JsonApi\Routes\Avatar;
+
+use JsonApi\Errors\RecordNotFoundException;
+
+trait AvatarHelpers
+{
+
+    protected static function getAvatarClass(String $range_id, String $range_type, \User $user): Array
+    {
+        $has_perm = false;
+        $class = null;
+
+        if ($range_type === 'users') {
+            $has_perm = Authority::canUpdateAvatarOfUser($user);
+            $class = \Avatar::class;
+        } else if ($range_type === 'institutes') {
+            $inst = \Institute::find($range_id);
+            if ($inst) {
+                $has_perm = Authority::canUpdateAvatarOfInstitute($user, $inst);
+                $class = \InstituteAvatar::class;
+            }
+        } else if ($range_type === 'courses') {
+            $course = \Course::find($range_id);
+            if ($course) {
+                $has_perm = Authority::canUpdateAvatarOfSeminar($user, $course);
+                if ($course->isStudygroup()) {
+                    $class = \StudygroupAvatar::class;
+                } else {
+                    $class = \CourseAvatar::class;
+                }
+            }
+        } else {
+            throw new RecordNotFoundException();
+        }
+
+        return ['class' => $class, 'has_perm' => $has_perm];
+    }
+}
\ No newline at end of file
diff --git a/lib/classes/JsonApi/Routes/Avatar/AvatarOfRangeShow.php b/lib/classes/JsonApi/Routes/Avatar/AvatarOfRangeShow.php
new file mode 100644
index 00000000000..285729594eb
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Avatar/AvatarOfRangeShow.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace JsonApi\Routes\Avatar;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+class AvatarOfRangeShow extends JsonApiController
+{
+    use AvatarHelpers;
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        $range_id = $args['id'];
+        $range_type = $args['type'];
+        $user = $this->getUser($request);
+
+        ['class' => $class] = self::getAvatarClass($range_id, $range_type, $user);
+
+        $resource = $class::getAvatar($range_id);
+
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!Authority::canShowAvatarOfRange($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        return $this->getContentResponse($resource);
+    }
+}
\ No newline at end of file
diff --git a/lib/classes/JsonApi/Routes/Avatar/AvatarUpload.php b/lib/classes/JsonApi/Routes/Avatar/AvatarUpload.php
new file mode 100644
index 00000000000..777d65c8bff
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Avatar/AvatarUpload.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace JsonApi\Routes\Avatar;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\NonJsonApiController;
+use JsonApi\Routes\Files\RoutesHelperTrait as FilesRoutesHelper;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Slim\Psr7\UploadedFile;
+
+/**
+ * Create an Avatar.
+ */
+class AvatarUpload extends NonJsonApiController
+{
+    use ValidationTrait;
+    use AvatarHelpers;
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        $user = $this->getUser($request);
+        $json = $this->validate($request);
+        $range_id = self::arrayGet($json, 'data.range-id');
+        $range_type = self::arrayGet($json, 'data.range-type');
+
+        ['class' => $class, 'has_perm' => $has_perm] = self::getAvatarClass($range_id, $range_type, $user);
+
+        if (!$has_perm) {
+            throw new AuthorizationFailedException();
+        }
+
+        $avatar = $class::getAvatar($range_id);
+        $imgdata_string = self::arrayGet($json, 'data.image');
+        [$type, $imgdata_part] = explode(';', $imgdata_string);
+        [$base, $imgdata_base64] = explode(',', $imgdata_part);
+        $imgdata = base64_decode($imgdata_base64);
+        // Write data to file.
+        $filename = $GLOBALS['TMP_PATH'] . '/avatar-' . $range_id . '.webp';
+        file_put_contents($filename, $imgdata);
+
+        // Use new image file for avatar creation.
+        $avatar->createFrom($filename);
+        
+        
+        return $response->withStatus(201);
+    }
+
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (!self::arrayHas($json, 'data.range-id')) {
+            return 'New avatar must have an `range-id`.';
+        }
+        if (!self::arrayHas($json, 'data.range-type')) {
+            return 'New avatar must have a `range-type`.';
+        }
+        if (!self::arrayHas($json, 'data.image')) {
+            return 'New avatar must have a `image`.';
+        }
+    }
+}
\ No newline at end of file
diff --git a/lib/classes/JsonApi/Routes/Avatar/AvatarofRangeDelete.php b/lib/classes/JsonApi/Routes/Avatar/AvatarofRangeDelete.php
new file mode 100644
index 00000000000..05353cbbbb2
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Avatar/AvatarofRangeDelete.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace JsonApi\Routes\Avatar;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Delete one Avatar.
+ */
+class AvatarofRangeDelete extends JsonApiController
+{
+    use AvatarHelpers;
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $range_id = $args['id'];
+        $range_type = $args['type'];
+        $user = $this->getUser($request);
+
+        ['class' => $class, 'has_perm' => $has_perm] = self::getAvatarClass($range_id, $range_type, $user);
+
+        if (!$has_perm) {
+            throw new AuthorizationFailedException();
+        }
+
+        $class::getAvatar($range_id)->reset();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index 1498daf6925..ff5040dc57f 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -16,6 +16,8 @@ class SchemaMap
 
             \JsonApi\Models\ScheduleEntry::class => Schemas\ScheduleEntry::class,
 
+            \Avatar::class => Schemas\Avatar::class,
+
             \BlubberComment::class => Schemas\BlubberComment::class,
             \BlubberStatusgruppeThread::class => Schemas\BlubberStatusgruppeThread::class,
             \BlubberThread::class => Schemas\BlubberThread::class,
diff --git a/lib/classes/JsonApi/Schemas/Avatar.php b/lib/classes/JsonApi/Schemas/Avatar.php
new file mode 100644
index 00000000000..5926f591a03
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Avatar.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace JsonApi\Schemas;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class Avatar extends SchemaProvider
+{
+    public const TYPE = 'avatar';
+    const REL_RANGE = 'range';
+
+    public function getId($resource): ?string
+    {
+        return $resource->getId();
+    }
+
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        return [
+            'type' => $resource::AVATAR_TYPE,
+            'customized' => $resource->is_customized(),
+            'is-nobody' => $resource->isNobody(),
+        ];
+    }
+    public function hasResourceMeta($resource): bool
+    {
+        return true;
+    }
+
+    public function getResourceMeta($resource)
+    {
+        return [
+            'url' => [
+                'normal' => $resource->getURL(\Avatar::NORMAL),
+                'medium' => $resource->getURL(\Avatar::MEDIUM),
+                'small' => $resource->getURL(\Avatar::SMALL),
+            ]
+        ];
+    }
+
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+        $range = self::getRange($resource->getId(),  $resource::AVATAR_TYPE);
+        $relationships[self::REL_RANGE] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($range),
+            ],
+            self::RELATIONSHIP_DATA => $range,
+        ];
+        return $relationships;
+    }
+
+    private function getRange(String $range_id, String $range_type)
+    {
+        switch ($range_type) {
+            case 'course':
+            case 'studygroup':
+                return \Course::build(['id' => $range_id], false);
+            case 'user':
+                return \User::build(['id' => $range_id], false);
+            case 'institute':
+                return \Institute::build(['id' => $range_id], false);
+        }
+        return null;
+    }
+}
\ No newline at end of file
diff --git a/lib/modules/CoreAdmin.php b/lib/modules/CoreAdmin.php
index 4bf1880561d..81849768a7a 100644
--- a/lib/modules/CoreAdmin.php
+++ b/lib/modules/CoreAdmin.php
@@ -38,8 +38,7 @@ class CoreAdmin extends CorePlugin implements StudipModule
                 $item->setDescription(_('Bearbeiten der Grundeinstellungen dieser Veranstaltung.'));
                 $navigation->addSubNavigation('details', $item);
 
-                $item = new Navigation(_('Infobild'), 'dispatch.php/avatar/update/course/' . $course_id);
-                $item->setImage(Icon::create('file-pic'));
+                $item = new Navigation(_('Veranstaltungsbild'), 'dispatch.php/course/avatar');
                 $item->setDescription(_('Infobild dieser Veranstaltung bearbeiten oder löschen.'));
                 $navigation->addSubNavigation('avatar', $item);
 
diff --git a/lib/modules/CoreStudygroupAdmin.php b/lib/modules/CoreStudygroupAdmin.php
index d299adf1483..60819c9f02a 100644
--- a/lib/modules/CoreStudygroupAdmin.php
+++ b/lib/modules/CoreStudygroupAdmin.php
@@ -37,7 +37,7 @@ class CoreStudygroupAdmin extends CorePlugin implements StudipModule
 
         $navigation->addSubNavigation('contentmodules', new Navigation(_('Werkzeuge'), "dispatch.php/course/contentmodules?cid={$course_id}"));
         $navigation->addSubNavigation('main', new Navigation(_('Verwaltung'), "dispatch.php/course/studygroup/edit/?cid={$course_id}"));
-        $navigation->addSubNavigation('avatar', new Navigation(_('Infobild'), "dispatch.php/avatar/update/course/{$course_id}?cid={$course_id}"));
+        $navigation->addSubNavigation('avatar', new Navigation(_(' Studiengruppenbild'), "dispatch.php/course/studygroup/avatar?cid={$course_id}"));
 
         if (!$GLOBALS['perm']->have_perm('admin') && Config::get()->VOTE_ENABLE) {
             $item = new Navigation(_('Fragebögen'), 'dispatch.php/questionnaire/courseoverview');
diff --git a/lib/navigation/AdminNavigation.php b/lib/navigation/AdminNavigation.php
index 3e638766619..1dccf0efd13 100644
--- a/lib/navigation/AdminNavigation.php
+++ b/lib/navigation/AdminNavigation.php
@@ -72,6 +72,7 @@ class AdminNavigation extends Navigation
 
         $navigation->setURL('dispatch.php/institute/basicdata/index?cid=');
         $navigation->addSubNavigation('details', new Navigation(_('Grunddaten'), 'dispatch.php/institute/basicdata/index'));
+        $navigation->addSubNavigation('avatar', new Navigation(_('Einrichtungsbild'), 'dispatch.php/institute/avatar/index'));
         $navigation->addSubNavigation('faculty', new Navigation(_('Mitarbeiter'), 'dispatch.php/institute/members?admin_view=1'));
         $navigation->addSubNavigation('groups', new Navigation(_('Funktionen / Gruppen'), 'dispatch.php/admin/statusgroups?type=inst'));
 
diff --git a/lib/navigation/ProfileNavigation.php b/lib/navigation/ProfileNavigation.php
index 307cd986e54..2cf457437a9 100644
--- a/lib/navigation/ProfileNavigation.php
+++ b/lib/navigation/ProfileNavigation.php
@@ -62,6 +62,7 @@ class ProfileNavigation extends Navigation
             // profile data
             $navigation = new Navigation(_('Persönliche Angaben'));
             $navigation->addSubNavigation('profile', new Navigation(_('Grunddaten'), 'dispatch.php/settings/account'));
+            $navigation->addSubNavigation('avatar', new Navigation(_('Profilbild'), 'dispatch.php/settings/avatar'));
             if (
                 !StudipAuthAbstract::CheckField('auth_user_md5.password', $current_user->auth_plugin)
                 && (
diff --git a/public/assets/images/icons/blue/flip.svg b/public/assets/images/icons/blue/flip.svg
new file mode 100644
index 00000000000..3e3bd454a05
--- /dev/null
+++ b/public/assets/images/icons/blue/flip.svg
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   width="16"
+   height="16"
+   viewBox="0 0 54 54"
+   fill="#28497c"
+   version="1.1"
+   id="svg1"
+   sodipodi:docname="flip.svg"
+   inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs1" />
+  <sodipodi:namedview
+     id="namedview1"
+     pagecolor="#ffffff"
+     bordercolor="#000000"
+     borderopacity="0.25"
+     inkscape:showpageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#d1d1d1"
+     inkscape:zoom="22.734375"
+     inkscape:cx="-8.4673539"
+     inkscape:cy="9.0831615"
+     inkscape:window-width="4300"
+     inkscape:window-height="1711"
+     inkscape:window-x="3591"
+     inkscape:window-y="-9"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg1" />
+  <path
+     d="m 5.5246542,50.625 c -0.7581122,0 -1.4601614,-0.506298 -1.847644,-1.332805 -0.3874829,-0.826146 -0.4031039,-1.85001 -0.041102,-2.694691 L 21.549628,4.7995235 c 0.469484,-1.0955038 1.465493,-1.6474176 2.418507,-1.3402576 0.952728,0.30716 1.619981,1.3951039 1.619981,2.6419528 V 47.899052 c 0,1.50545 -0.962473,2.725948 -2.149656,2.725948 z"
+     fill="#212121"
+     id="path1"
+     style="fill:#28497c;fill-opacity:1;stroke-width:3.22762" />
+  <path
+     style="fill:#28497c;fill-opacity:1;stroke-width:0.053239"
+     d="m 30.099579,50.504015 c -0.76301,-0.260211 -1.349993,-0.988992 -1.539376,-1.911246 -0.06565,-0.319689 -0.07338,-2.596896 -0.07338,-21.622388 0,-20.6602267 0.0025,-21.2753879 0.08907,-21.6279742 0.12269,-0.4998832 0.282609,-0.8248327 0.583559,-1.1857624 0.456209,-0.5471316 0.951945,-0.7744407 1.580519,-0.7247116 0.619496,0.04901 1.24274,0.4786448 1.570219,1.0824358 0.229538,0.4232141 18.145117,42.2636284 18.2324,42.5803254 0.115579,0.419371 0.108551,1.214107 -0.01461,1.651927 -0.248621,0.883811 -0.797929,1.528143 -1.500662,1.76025 -0.245569,0.08111 -1.358206,0.09144 -9.475636,0.08796 -7.875707,-0.0034 -9.233994,-0.01643 -9.452109,-0.09081 z M 44.886923,45.068188 C 44.687305,44.586157 32.943681,17.188 32.854222,16.995607 l -0.111509,-0.239809 -4.33e-4,14.223642 -4.33e-4,14.223642 h 6.100484 6.100482 z"
+     id="path3" />
+</svg>
diff --git a/resources/assets/javascripts/bootstrap/avatar.js b/resources/assets/javascripts/bootstrap/avatar.js
index cbda58851fe..31d724dec53 100644
--- a/resources/assets/javascripts/bootstrap/avatar.js
+++ b/resources/assets/javascripts/bootstrap/avatar.js
@@ -1,54 +1,17 @@
 STUDIP.domReady(() => {
-    STUDIP.Avatar.init('#avatar-upload');
-
-    // Get file data on drop
-    var dropZone = document.getElementById('avatar-overlay');
-
-    if (dropZone) {
-        dropZone.addEventListener('dragover', function(e) {
-            e.stopPropagation();
-            e.preventDefault();
-            e.target.parentNode.classList.add("dragging");
-        });
-
-        dropZone.addEventListener('dragleave', function(e) {
-            e.stopPropagation();
-            e.preventDefault();
-            e.target.parentNode.classList.remove("dragging");
-        });
-
-        dropZone.addEventListener('drop', function(e) {
-            e.stopPropagation();
-            e.preventDefault();
-            e.target.parentNode.classList.remove("dragging");
-            var files = e.dataTransfer.files;
-            var div = e.target.parentNode;
-            var avatar_dialog = div.getElementsByTagName('a')[0];
-
-            if (!div.getAttribute('accept') || !div.getAttribute('accept').includes(files[0].type)) {
-                alert(div.getAttribute('data-message-unaccepted'));
-                return false;
-            }
-
-            if (!div.getAttribute('data-max-size') || files[0].size > div.getAttribute('data-max-size')) {
-                alert(div.getAttribute('data-message-too-large'));
-                return false;
-            }
-
-            avatar_dialog.click();
-            div.files = files;
-            STUDIP.dialogReady(() => {
-                STUDIP.Avatar.readFile(div);
+    const avatarTypes = ['courses', 'institutes', 'studygroups', 'users'];
+
+    avatarTypes.forEach((type) => {
+        if (document.getElementById(`avatar-${type}-app`)) {
+            Promise.all([
+                STUDIP.loadChunk('avatar'),
+                import(
+                    /* webpackChunkName: "avatar-app" */
+                    '@/vue/avatar-app.js'
+                ),
+            ]).then(([{ createApp }, { default: mountApp }]) => {
+                return mountApp(STUDIP, createApp, `#avatar-${type}-app`);
             });
-        });
-    }
-
-    //"Redirecting" the event is necessary so that the avatar image upload
-    //is accessible by pressing the enter key when its focused.
-    jQuery(document).on('keydown', 'form.settings-avatar label.file-upload a.button', function(event) {
-        if (event.code == "Enter") {
-            //The enter key has been pressed.
-            jQuery(this).parent('.file-upload').trigger('click');
         }
     });
 });
diff --git a/resources/assets/javascripts/chunk-loader.js b/resources/assets/javascripts/chunk-loader.js
index f3722866a86..995b35f4d8f 100644
--- a/resources/assets/javascripts/chunk-loader.js
+++ b/resources/assets/javascripts/chunk-loader.js
@@ -21,15 +21,6 @@ let mathjax_promise = null;
 export const loadChunk = function (chunk, { silent = false } = {}) {
     let promise = null;
     switch (chunk) {
-        case 'code-highlight':
-            promise = import(
-                /* webpackChunkName: "code-highlight" */
-                './chunks/code-highlight'
-            ).then(({ default: hljs }) => {
-                return hljs;
-            });
-            break;
-
         case 'courseware':
             promise = Promise.all([
                 STUDIP.loadChunk('vue'),
@@ -40,6 +31,25 @@ export const loadChunk = function (chunk, { silent = false } = {}) {
             ]).then(([Vue]) => Vue);
             break;
 
+        case 'avatar':
+            promise = Promise.all([
+                STUDIP.loadChunk('vue'),
+                import(
+                    /* webpackChunkName: "avatar" */
+                    './chunks/avatar'
+                ),
+            ]).then(([Vue]) => Vue);
+            break;
+
+        case 'code-highlight':
+            promise = import(
+                /* webpackChunkName: "code-highlight" */
+                './chunks/code-highlight'
+            ).then(({default: hljs}) => {
+                return hljs;
+            });
+            break;
+
         case 'chartist':
             promise = import(
                 /* webpackChunkName: "chartist" */
diff --git a/resources/assets/javascripts/chunks/avatar.js b/resources/assets/javascripts/chunks/avatar.js
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/resources/vue/avatar-app.js b/resources/vue/avatar-app.js
new file mode 100644
index 00000000000..0092ebe5f83
--- /dev/null
+++ b/resources/vue/avatar-app.js
@@ -0,0 +1,76 @@
+import AvatarApp from './components/avatar/AvatarApp.vue';
+import AvatarModule from './store/avatar.module';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { mapResourceModules } from '@elan-ev/reststate-vuex';
+import axios from 'axios';
+
+const mountApp = async (STUDIP, createApp, element) => {
+
+    let entry_id = null;
+    let entry_type = null;
+    let avatar_url = null;
+    let elem;
+
+    if ((elem = document.getElementById(element.substring(1))) !== undefined) {
+        if (elem.attributes !== undefined) {
+            if (elem.attributes['entry-type'] !== undefined) {
+                entry_type = elem.attributes['entry-type'].value;
+            }
+
+            if (elem.attributes['entry-id'] !== undefined) {
+                entry_id = elem.attributes['entry-id'].value;
+            }
+
+            if (elem.attributes['avatar-url'] !== undefined) {
+                avatar_url = elem.attributes['avatar-url'].value;
+            }
+        }
+    }
+
+    const getHttpClient = () =>
+    axios.create({
+        baseURL: STUDIP.URLHelper.getURL(`jsonapi.php/v1`, {}, true),
+        headers: {
+            'Content-Type': 'application/vnd.api+json',
+        },
+    });
+    const httpClient = getHttpClient();
+
+    const store = new Vuex.Store({
+        modules: {
+            'avatar-module': AvatarModule,
+            ...mapResourceModules({
+                names: [
+                    'avatar',
+                    'courses',
+                    'institutes',
+                    'stock-images',
+                    'studygroups',
+                    'users',
+                ],
+                httpClient,
+            }),
+        }
+    });
+
+    const context = {
+        type: entry_type,
+        id: entry_id
+    }
+    store.dispatch('setUserId', STUDIP.USER_ID);
+    await store.dispatch('users/loadById', { id: STUDIP.USER_ID });
+    store.dispatch('setHttpClient', httpClient);
+    store.dispatch('setContext', context);
+    const avatar = await store.dispatch('loadAvatar');
+
+    const app =  createApp({
+        render: (h) => h(AvatarApp),
+        store,
+    });
+    app.$mount(element);
+
+    return app;
+}
+
+export default mountApp;
\ No newline at end of file
diff --git a/resources/vue/components/avatar/AvatarApp.vue b/resources/vue/components/avatar/AvatarApp.vue
new file mode 100644
index 00000000000..0e89b5e3c4b
--- /dev/null
+++ b/resources/vue/components/avatar/AvatarApp.vue
@@ -0,0 +1,430 @@
+<template>
+    <div class="avatar">
+        <section class="contentbox">
+            <header>
+                <h1>{{ avatarAltText }}</h1>
+            </header>
+            <section v-if="!editingImage" class="avatar-display">
+                <img class="avatar-original" :src="avatarUrl" :alt="avatarAltText" />
+                <div class="button-wrapper">
+                    <button class="button edit" @click="changeImage">
+                        <span v-if="isCustomized">{{ $gettext('Ändern') }}</span
+                        ><span v-else>{{ $gettext('Bild wählen') }}</span>
+                    </button>
+                    <button v-if="isCustomized" class="button trash" @click="showRemoveDialog = true">
+                        {{ $gettext('Löschen') }}
+                    </button>
+                </div>
+            </section>
+            <section v-show="editingImage" class="avatar-edit">
+                <div class="cropper-container">
+                    <img ref="newImage" src="" @zoom="checkImageSize" />
+                </div>
+                <studip-message-box v-if="invalid" type="warning" :hideClose="false">{{
+                    $gettext(
+                        'Bildauswahl zu klein. Bitte wählen Sie einen größeren Ausschnitt oder ein anderes Bild aus.'
+                    )
+                }}</studip-message-box>
+                <div class="cropper-actions-wrapper">
+                    <div class="cropper-actions-group">
+                        <div class="labeled-range-input">
+                            <label for="cropper-rotate" class="sr-only">{{ $gettext('Neigungswähler') }}</label>
+                            <input
+                                id="cropper-rotate"
+                                type="range"
+                                min="-45"
+                                max="45"
+                                steps="1"
+                                v-model="cropperRotate"
+                                @input="updateCropperRotate"
+                            />
+                            <div class="labeled-range-input-labels">
+                                <span>-45°</span>
+                                <span>0°</span>
+                                <span>+45°</span>
+                            </div>
+                        </div>
+                    </div>
+                    <div class="cropper-actions-group">
+                        <button
+                            class="cropper-actions-button"
+                            @click="updateCropperBaseRotation(90)"
+                            :title="$gettext('nach rechts drehen')"
+                        >
+                            <StudipIcon shape="rotate-right" :size="24" />
+                        </button>
+                        <button
+                            class="cropper-actions-button"
+                            @click="updateCropperBaseRotation(-90)"
+                            :title="$gettext('nach links drehen')"
+                        >
+                            <StudipIcon shape="rotate-left" :size="24" />
+                        </button>
+                        <button
+                            class="cropper-actions-button"
+                            @click="zoomCropper(+0.1)"
+                            :title="$gettext('vergrößern')"
+                        >
+                            <StudipIcon shape="zoom-in2" :size="24" />
+                        </button>
+                        <button
+                            class="cropper-actions-button"
+                            @click="zoomCropper(-0.1)"
+                            :title="$gettext('verkleinern')"
+                        >
+                            <StudipIcon shape="zoom-out2" :size="24" />
+                        </button>
+                        <button class="cropper-actions-button" @click="flip()" :title="$gettext('horizontal spiegeln')">
+                            <StudipIcon shape="flip" :size="24" />
+                        </button>
+                        <button class="cropper-actions-button" @click="resetCropper" :title="$gettext('zurücksetzen')">
+                            <StudipIcon shape="refresh" :size="24" />
+                        </button>
+                        <button class="cropper-actions-button" @click="changeImage" :title="$gettext('Bild auswählen')">
+                            <StudipIcon shape="upload" :size="24" />
+                        </button>
+                    </div>
+                    <div class="cropper-actions-group">
+                        <button class="button accept" @click="storeAvatar" :disabled="invalid">
+                            {{ $gettext('Speichern') }}
+                        </button>
+                        <button class="button cancel" @click="abortEditing">{{ $gettext('Abbrechen') }}</button>
+                    </div>
+                </div>
+            </section>
+        </section>
+        <input
+            id="avatar-upload"
+            ref="uploadFile"
+            type="file"
+            accept="image/gif,image/png,image/jpeg,image/webp;capture=camera"
+            @change="updateUploadImage"
+        />
+        <studip-dialog
+            v-if="showRemoveDialog"
+            :title="$gettext('Bild löschen')"
+            :question="$gettext('Möchten Sie dieses Bild wirklich löschen')"
+            height="180"
+            width="360"
+            @confirm="removeAvatar"
+            @close="showRemoveDialog = false"
+        ></studip-dialog>
+        <studip-dialog
+            v-if="showChangeImageDialog"
+            :title="$gettext('Bildquelle auswählen')"
+            :closeText="$gettext('Abbrechen')"
+            closeClass="cancel"
+            height="310"
+            @close="showChangeImageDialog = false"
+        >
+            <template v-slot:dialogContent>
+                <div class="square-button-panel">
+                    <studip-square-button
+                        icon="upload"
+                        :title="$gettext('Bild hochladen')"
+                        @click="selectUploadImage"
+                    ></studip-square-button>
+                    <studip-square-button
+                        icon="block-gallery"
+                        :title="$gettext('Aus Bilderpool auswählen')"
+                        @click="selectStockImage"
+                    ></studip-square-button>
+                </div>
+            </template>
+        </studip-dialog>
+        <StockImageSelector
+            v-if="showStockImageSelector"
+            @close="showStockImageSelector = false"
+            @select="onSelectStockImage"
+        />
+    </div>
+</template>
+
+<script>
+import axios from 'axios';
+import StudipDialog from '../StudipDialog.vue';
+import StudipMessageBox from '../StudipMessageBox.vue';
+import StudipSquareButton from '../StudipSquareButton.vue';
+import StockImageSelector from '../stock-images/SelectorDialog.vue';
+import { mapGetters, mapActions } from 'vuex';
+
+import Cropper from 'cropperjs';
+
+export default {
+    components: {
+        StudipDialog,
+        StudipMessageBox,
+        StudipSquareButton,
+        StockImageSelector,
+    },
+    data() {
+        return {
+            showRemoveDialog: false,
+            showChangeImageDialog: false,
+            showStockImageSelector: false,
+            selectedStockImage: false,
+            uploadImage: null,
+            editingImage: false,
+            base64Image: null,
+            cropper: null,
+            invalid: false,
+            cropperRotate: 0,
+            cropperBaseRotation: 0,
+            fliped: false,
+        };
+    },
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            currentAvatar: 'currentAvatar',
+            isCourseAvatar: 'isCourseAvatar',
+            isInstituteAvatar: 'isInstituteAvatar',
+            isStudygroupAvatar: 'isStudygroupAvatar',
+            isUserAvatar: 'isUserAvatar',
+            isCustomized: 'isCustomized',
+        }),
+        avatarUrl() {
+            return this.currentAvatar.meta.url.normal;
+        },
+        avatarAltText() {
+            if (this.isUserAvatar) {
+                return this.$gettext('Mein Profilbild');
+            }
+            if (this.isCourseAvatar) {
+                return this.$gettext('Veranstaltungsbild');
+            }
+            if (this.isStudygroupAvatar) {
+                return this.$gettext('Studiengruppenbild');
+            }
+            if (this.isInstituteAvatar) {
+                return this.$gettext('Einrichtungsbild');
+            }
+            return '';
+        },
+    },
+    methods: {
+        changeImage() {
+            if (this.isUserAvatar) {
+                this.selectUploadImage();
+            } else {
+                this.showChangeImageDialog = true;
+            }
+        },
+        selectUploadImage() {
+            this.showChangeImageDialog = false;
+            this.$refs.uploadFile.click();
+        },
+        selectStockImage() {
+            this.showChangeImageDialog = false;
+            this.showStockImageSelector = true;
+        },
+        onSelectStockImage(stockImage) {
+            if (this.cropper) {
+                this.resetCropper();
+                this.cropper.destroy();
+            }
+            this.base64Image = null;
+            this.uploadImage = null;
+            this.selectedStockImage = stockImage;
+            this.showStockImageSelector = false;
+            this.loadStockImage();
+        },
+        updateUploadImage() {
+            if (this.cropper) {
+                this.resetCropper();
+                this.cropper.destroy();
+            }
+            this.base64Image = null;
+            this.createBase64Image(this.$refs.uploadFile.files[0]);
+        },
+        createBase64Image(FileObject) {
+            const reader = new FileReader();
+            reader.onload = (event) => {
+                this.base64Image = event.target.result;
+                this.enableCropper();
+            };
+            reader.readAsDataURL(FileObject);
+        },
+        enableCropper() {
+            let image = this.$refs.newImage;
+            image.src = this.base64Image;
+            this.cropper = new Cropper(image, {
+                aspectRatio: 1,
+                viewMode: 1,
+                autoCropArea: 0.9,
+                dragMode: 'move',
+                cropBoxMovable: false,
+                cropBoxResizable: false,
+                toggleDragModeOnDblclick: false,
+            });
+            this.editingImage = true;
+        },
+        checkImageSize() {
+            const data = this.cropper.getData();
+            if (data.width < 250 || data.height < 250) {
+                this.invalid = true;
+            } else {
+                this.invalid = false;
+            }
+        },
+        updateCropperBaseRotation(val) {
+            this.cropperBaseRotation += val;
+            if (this.cropperBaseRotation > 270) {
+                this.cropperBaseRotation = 0;
+            }
+            this.cropperRotate = 0;
+            this.cropper.rotateTo(this.cropperBaseRotation);
+        },
+        updateCropperRotate() {
+            this.cropper.rotateTo(parseInt(this.cropperRotate) + this.cropperBaseRotation);
+        },
+        resetCropper() {
+            this.cropper.reset();
+            this.fliped = false;
+            this.cropperBaseRotation = 0;
+            this.cropperRotate = 0;
+        },
+        zoomCropper(val) {
+            this.cropper.zoom(val);
+        },
+        flip() {
+            this.fliped ? this.cropper.scale(1, 1) : this.cropper.scale(-1, 1);
+            this.fliped = !this.fliped;
+        },
+        abortEditing() {
+            this.resetCropper();
+            this.cropper.destroy();
+            this.selectedStockImage = null;
+            this.$refs.uploadFile.value = '';
+            this.base64Image = null;
+            this.editingImage = false;
+            this.fliped = false;
+        },
+
+        loadStockImage() {
+            const url = this.selectedStockImage.attributes['download-urls'].original;
+            fetch(url)
+                .then((response) => response.blob())
+                .then(
+                    (blob) =>
+                        new Promise((resolve, reject) => {
+                            const reader = new FileReader();
+                            reader.onloadend = () => resolve(reader.result);
+                            reader.onerror = reject;
+                            reader.readAsDataURL(blob);
+                        })
+                )
+                .then((base64) => {
+                    this.base64Image = base64;
+                    this.enableCropper();
+                });
+        },
+
+        storeAvatar() {
+            if (this.invalid) {
+                return false;
+            }
+            const croppedImage = this.cropper.getCroppedCanvas().toDataURL('image/webp');
+            const data = {
+                data: {
+                    'range-id': this.context.id,
+                    'range-type': this.context.type,
+                    image: croppedImage,
+                },
+            };
+
+            axios.post(STUDIP.URLHelper.getURL(`jsonapi.php/v1/${this.context.type}/${this.context.id}/avatar`, {}, true), data).then((response) => {
+                location.reload();
+            });
+        },
+
+        removeAvatar() {
+            axios
+                .delete(
+                    STUDIP.URLHelper.getURL(`jsonapi.php/v1/${this.context.type}/${this.context.id}/avatar`, {}, true)
+                )
+                .then((response) => {
+                    location.reload();
+                });
+        },
+    },
+};
+</script>
+<style scoped lang="scss">
+.avatar {
+    max-width: 520px;
+}
+
+.avatar-original {
+    max-width: 500px;
+}
+#avatar-upload {
+    display: none;
+}
+.cropper-container {
+    width: 500px;
+    height: 500px;
+    margin-bottom: 1em;
+}
+.square-button-panel {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    width: 100%;
+    justify-content: center;
+}
+
+.cropper-actions-wrapper {
+    max-width: 500px;
+
+    .cropper-actions-group {
+        display: flex;
+        justify-content: space-between;
+        margin: 1em 0;
+
+        .cropper-actions-button {
+            background-color: transparent;
+            border: none;
+            cursor: pointer;
+        }
+
+        .labeled-range-input {
+            width: 100%;
+            input[type='range'] {
+                width: 100%;
+                -webkit-appearance: none;
+                appearance: none;
+                cursor: pointer;
+                height: 2px;
+                background: var(--content-color-40);
+
+                &::-webkit-slider-thumb {
+                    -webkit-appearance: none;
+                    appearance: none;
+                    height: 16px;
+                    width: 16px;
+                    background-color: var(--base-color);
+                    border-radius: 50%;
+                    border: none;
+                }
+
+                &::-moz-range-thumb {
+                    height: 16px;
+                    width: 16px;
+                    background-color: var(--base-color);
+                    border-radius: 50%;
+                    border: none;
+                }
+            }
+
+            .labeled-range-input-labels {
+                display: flex;
+                justify-content: space-between;
+                span {
+                    margin-left: 0.5em;
+                }
+            }
+        }
+    }
+}
+</style>
diff --git a/resources/vue/store/avatar.module.js b/resources/vue/store/avatar.module.js
new file mode 100644
index 00000000000..7a45576b283
--- /dev/null
+++ b/resources/vue/store/avatar.module.js
@@ -0,0 +1,102 @@
+const getDefaultState = () => {
+    return {
+        context: null,
+        httpClient: null,
+        userId: null,
+    };
+};
+
+const initialState = getDefaultState();
+
+const getters = {
+    context(state) {
+        return state.context;
+    },
+    httpClient(state) {
+        return state.httpClient;
+    },
+    userId(state) {
+        return state.userId;
+    },
+
+    currentAvatar(state, getters, rootState, rootGetters) {
+        if (getters.context === null) {
+            return null;
+        }
+        const parent = {
+            type: getters.context.type,
+            id: getters.context.id,
+        };
+
+        const relationship = 'avatar';
+
+        return rootGetters['avatar/related']({ parent, relationship });
+    },
+    currentUser(state, getters, rootState, rootGetters) {
+        const id = getters.userId;
+        return rootGetters['users/byId']({ id });
+    },
+    isCourseAvatar(state, getters) {
+        return getters.context?.type === 'courses';
+    },
+    isInstituteAvatar(state, getters) {
+        return getters.context?.type === 'institutes';
+    },
+    isStudygroupAvatar(state, getters) {
+        return getters.context?.type === 'studygroups';
+    },
+    isUserAvatar(state, getters) {
+        return getters.context?.type === 'users';
+    },
+    isCustomized(state, getters) {
+        return getters.currentAvatar.attributes.customized;
+    }
+};
+
+export const state = { ...initialState };
+
+export const actions = {
+    // setters
+    setContext({ commit }, context) {
+        commit('setContext', context);
+    },
+    setHttpClient({ commit }, httpClient) {
+        commit('setHttpClient', httpClient);
+    },
+    setUserId({ commit }, userId) {
+        commit('setUserId', userId);
+    },
+
+    // other actions
+    loadAvatar({ dispatch, getters, rootGetters }) {
+        const parent = {
+            type: getters.context.type,
+            id: getters.context.id,
+        };
+
+        const relationship = 'avatar';
+
+        return dispatch('avatar/loadRelated', { parent, relationship }, { root: true }).then(() => {
+            rootGetters['avatar/related']({ parent, relationship });
+        });
+    },
+};
+
+export const mutations = {
+    setContext(state, context) {
+        state.context = context;
+    },
+    setHttpClient(state, httpClient) {
+        state.httpClient = httpClient;
+    },
+    setUserId(state, data) {
+        state.userId = data;
+    },
+};
+
+export default {
+    state,
+    actions,
+    mutations,
+    getters,
+};
-- 
GitLab