diff --git a/app/controllers/contents/courseware.php b/app/controllers/contents/courseware.php
index f618d7965986c892439d64a47ab010ed16a64e75..c1ca32137bd1de4ed6293f4faa28a5c9b836fa2d 100755
--- a/app/controllers/contents/courseware.php
+++ b/app/controllers/contents/courseware.php
@@ -185,6 +185,20 @@ class Contents_CoursewareController extends AuthenticatedController
         $this->setBookmarkSidebar();
     }
 
+    /**
+     * Show users releases
+     *
+     * @SuppressWarnings(PHPMD.CamelCaseMethodName)
+     * @SuppressWarnings(PHPMD.Superglobals)
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+
+    public function releases_action()
+    {
+        Navigation::activateItem('/contents/courseware/releases');
+        $this->user_id = $GLOBALS['user']->id;
+    }
+
     private function setBookmarkSidebar()
     {
         $sidebar = Sidebar::Get();
diff --git a/app/controllers/courseware/public.php b/app/controllers/courseware/public.php
new file mode 100644
index 0000000000000000000000000000000000000000..aa5a3099b32fd8fd42653cbd280f8f347d55c50e
--- /dev/null
+++ b/app/controllers/courseware/public.php
@@ -0,0 +1,28 @@
+<?php
+
+use Courseware\PublicLink;
+
+class Courseware_PublicController extends StudipController
+{
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+        PageLayout::setTitle(_('Courseware'));
+        PageLayout::setHelpKeyword('Basis.Courseware');
+    }
+
+    public function index_action()
+    {
+        $this->invalid = true;
+        $this->link_id = Request::option('link');
+        if ($this->link_id) {
+            $publicLink = PublicLink::find($this->link_id);
+            $this->invalid = $publicLink === null;
+            if (!$this->invalid) {
+                $this->expired = $publicLink->isExpired();
+                $this->link_pass = $publicLink->password;
+                $this->entry_element_id = $publicLink->structural_element_id;
+            }
+        }
+    }
+}
diff --git a/app/views/contents/courseware/releases.php b/app/views/contents/courseware/releases.php
new file mode 100644
index 0000000000000000000000000000000000000000..972526f5a09b9f320bddcd64118e126a14a67020
--- /dev/null
+++ b/app/views/contents/courseware/releases.php
@@ -0,0 +1,6 @@
+<div
+    id="courseware-content-releases-app"
+    entry-type="users"
+    entry-id="<?= htmlReady($user_id) ?>"
+>
+</div>
diff --git a/app/views/courseware/public/index.php b/app/views/courseware/public/index.php
new file mode 100644
index 0000000000000000000000000000000000000000..3696d1d104791caabdc44ea22f1b56eccc02fd9f
--- /dev/null
+++ b/app/views/courseware/public/index.php
@@ -0,0 +1,16 @@
+<? if (!$expired && !$invalid): ?>
+<div
+    id="courseware-public-app"
+    link-id="<?= htmlReady($link_id) ?>"
+    link-pass="<?= htmlReady($link_pass) ?>"
+    entry-type="public"
+    entry-element-id="<?= htmlReady($entry_element_id) ?>"
+>
+</div>
+<? endif; ?>
+<? if ($expired): ?>
+    <?= MessageBox::warning(_('Der Link zu dieser Seite ist abgelaufen.'))->hideClose() ?>
+<? endif; ?>
+<? if ($invalid): ?>
+    <?= MessageBox::error(_('Es wurde kein gültiger Link aufgerufen.'))->hideClose() ?>
+<? endif; ?>
diff --git a/db/migrations/5.2.13_add_courseware_public_links.php b/db/migrations/5.2.13_add_courseware_public_links.php
new file mode 100644
index 0000000000000000000000000000000000000000..1a9275cd692192987f500270b005a914098379d5
--- /dev/null
+++ b/db/migrations/5.2.13_add_courseware_public_links.php
@@ -0,0 +1,31 @@
+<?php
+final class AddCoursewarePublicLinks extends Migration
+{
+    public function description()
+    {
+        return 'Create Courseware public links database table';
+    }
+
+    public function up()
+    {
+        \DBManager::get()->exec("CREATE TABLE `cw_public_links` (
+            `id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+            `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+            `structural_element_id` int(11) NOT NULL,
+            `password` varbinary(64) NOT NULL,
+            `expire_date` int(11) NOT NULL,
+            `mkdate` int(11) NOT NULL,
+            `chdate` int(11) NOT NULL,
+
+            PRIMARY KEY (`id`),
+            INDEX index_user_id (`user_id`),
+            INDEX index_structural_element_id (`structural_element_id`)
+            )
+        ");
+    }
+
+    public function down()
+    {
+        \DBManager::get()->exec("DROP TABLE IF EXISTS `cw_public_links`");
+    }
+}
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 0ebea65337913851d4730859473f16d3930aad80..baccc2625696efdee4857cd1fef5b7a41fda2fa2 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -143,6 +143,9 @@ class RouteMap
         $group->get('/semesters/{id}', Routes\SemestersShow::class)->setName('get-semester');
 
         $group->get('/studip/properties', Routes\Studip\PropertiesIndex::class);
+
+        $group->get('/public/courseware/{link_id}/courseware-structural-elements/{id}', Routes\Courseware\PublicStructuralElementsShow::class);
+        $group->get('/public/courseware/{link_id}/courseware-structural-elements', Routes\Courseware\PublicStructuralElementsIndex::class);
     }
 
     private function getAuthenticator(): callable
@@ -449,6 +452,12 @@ class RouteMap
         $group->post('/courseware-templates', Routes\Courseware\TemplatesCreate::class);
         $group->patch('/courseware-templates/{id}', Routes\Courseware\TemplatesUpdate::class);
         $group->delete('/courseware-templates/{id}', Routes\Courseware\TemplatesDelete::class);
+
+        $group->get('/courseware-public-links/{id}', Routes\Courseware\PublicLinksShow::class);
+        $group->get('/courseware-public-links', Routes\Courseware\PublicLinksIndex::class);
+        $group->post('/courseware-public-links', Routes\Courseware\PublicLinksCreate::class);
+        $group->patch('/courseware-public-links/{id}', Routes\Courseware\PublicLinksUpdate::class);
+        $group->delete('/courseware-public-links/{id}', Routes\Courseware\PublicLinksDelete::class);
     }
 
     private function addAuthenticatedFilesRoutes(RouteCollectorProxy $group): void
diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php
index fa84c0a0cd62894f772af6591c4764323ddc5c75..9c5a37b4fbc318b943e2173e731380a2658cc12d 100755
--- a/lib/classes/JsonApi/Routes/Courseware/Authority.php
+++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php
@@ -14,6 +14,7 @@ use Courseware\TaskGroup;
 use Courseware\Template;
 use Courseware\UserDataField;
 use Courseware\UserProgress;
+use Courseware\PublicLink;
 use User;
 
 /**
@@ -427,4 +428,36 @@ class Authority
         return self::canCreateTemplate($user);
     }
 
+    public static function canIndexPublicLinks(User $user): bool
+    {
+        return self::canCreatePublicLink($user);
+    }
+
+    public static function canShowPublicLink(User $user, PublicLink $resource): bool
+    {
+        return self::canUpdatePublicLink($user, $resource);
+    }
+
+    public static function canCreatePublicLink(User $user): bool
+    {
+        return true;
+    }
+
+    public static function canUpdatePublicLink(User $user, PublicLink $resource): bool
+    {
+        return $resource->user_id === $user->id;
+    }
+
+    public static function canDeletePublicLink(User $user, PublicLink $resource): bool
+    {
+        return self::canUpdatePublicLink($user, $resource);
+    }
+
+    public static function canShowPublicStructuralElement(StructuralElement $resource): bool
+    {
+        $publicLink = PublicLink::findOneBySQL('structural_element_id = ?', [$resource->id]);
+
+        return (bool) $publicLink;
+    }
+
 }
diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicLinksCreate.php b/lib/classes/JsonApi/Routes/Courseware/PublicLinksCreate.php
new file mode 100644
index 0000000000000000000000000000000000000000..8d5a6e6b692079ad4353875956e1f462136e127e
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PublicLinksCreate.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use Courseware\PublicLink;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Create a Template.
+ */
+class PublicLinksCreate extends JsonApiController
+{
+    use TimestampTrait;
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $json = $this->validate($request);
+        if (!Authority::canCreatePublicLink($user = $this->getUser($request))) {
+            throw new AuthorizationFailedException();
+        }
+
+        $publicLink = $this->createPublicLink($json, $user);
+
+        return $this->getCreatedResponse($publicLink);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+
+        if (!self::arrayHas($json, 'data.relationships.structural-element.data.id')) {
+            return 'Missing `structural-element-id` value.';
+        }
+
+    }
+
+    private function createPublicLink(array $json, $user): PublicLink
+    {
+        $get = function ($key, $default = '') use ($json) {
+            return self::arrayGet($json, $key, $default);
+        };
+
+        $publicLink = new PublicLink();
+
+        $publicLink->setId($publicLink->getNewId());
+        $publicLink->user_id = $user->id;
+
+        $publicLink->structural_element_id = $get('data.relationships.structural-element.data.id');
+        $publicLink->password = str_replace(' ', '', $get('data.attributes.password'));
+        $expire_date = $get('data.attributes.expire-date');
+        $expireDate = self::fromISO8601($expire_date);
+        $publicLink->expire_date =  $expireDate->getTimestamp();
+
+        $publicLink->store();
+
+        return $publicLink;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicLinksDelete.php b/lib/classes/JsonApi/Routes/Courseware/PublicLinksDelete.php
new file mode 100644
index 0000000000000000000000000000000000000000..4e06b0ea7c8b0b04de8916dedf9099e8173f6c31
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PublicLinksDelete.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use Courseware\PublicLink;
+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 PublicLink.
+ */
+class PublicLinksDelete extends JsonApiController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $resource = PublicLink::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        if (!Authority::canDeletePublicLink($user = $this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $resource->delete();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicLinksIndex.php b/lib/classes/JsonApi/Routes/Courseware/PublicLinksIndex.php
new file mode 100644
index 0000000000000000000000000000000000000000..f76361086fb33c49a0cbcbb712139c0e74b2a844
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PublicLinksIndex.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use Courseware\PublicLink;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Displays all PublicLinks
+ */
+class PublicLinksIndex extends JsonApiController
+{
+
+    protected $allowedIncludePaths = ['structural-element'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $user = $this->getUser($request);
+        if (!Authority::canIndexPublicLinks($user)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $resources = PublicLink::findBySQL('user_id = ? ORDER BY mkdate', [$user->id]);
+
+        return $this->getContentResponse($resources);
+    }
+}
\ No newline at end of file
diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicLinksShow.php b/lib/classes/JsonApi/Routes/Courseware/PublicLinksShow.php
new file mode 100644
index 0000000000000000000000000000000000000000..4649512591869edc7a7f06dc2e688f99f14293ca
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PublicLinksShow.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use Courseware\PublicLink;
+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;
+
+/**
+ * Displays one PublicLink.
+ */
+class PublicLinksShow extends JsonApiController
+{
+    protected $allowedIncludePaths = ['structural-element'];
+
+        /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $resource = PublicLink::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!Authority::canShowPublicLink($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        return $this->getContentResponse($resource);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicLinksUpdate.php b/lib/classes/JsonApi/Routes/Courseware/PublicLinksUpdate.php
new file mode 100644
index 0000000000000000000000000000000000000000..9c43f84cfe74816202c44dc1fd264d6a2602004b
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PublicLinksUpdate.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use Courseware\PublicLink;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Errors\UnprocessableEntityException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Update one PublicLink.
+ */
+class PublicLinksUpdate extends JsonApiController
+{
+    use TimestampTrait;
+    use ValidationTrait;
+        /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $resource = PublicLink::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        $json = $this->validate($request, $resource);
+        if (!Authority::canUpdatePublicLink($user = $this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+        $resource = $this->updatePublicLink($resource, $json);
+
+        return $this->getContentResponse($resource);
+    }
+
+        /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+
+        if (!self::arrayHas($json, 'data.id')) {
+            return 'Document must have an `id`.';
+        }
+
+        if (self::arrayHas($json, 'data.attributes.expire-date')) {
+            $expire_date = self::arrayGet($json, 'data.attributes.expire-date');
+            if (!self::isValidTimestamp($expire_date)) {
+                return '`expire-date` is not an ISO 8601 timestamp.';
+            }
+        }
+    }
+
+    private function updatePublicLink(PublicLink $resource, array $json): PublicLink
+    {
+        $get = function ($key, $default = '') use ($json) {
+            return self::arrayGet($json, $key, $default);
+        };
+
+        $resource->password = $get('data.attributes.password');
+
+        $expire_date = $get('data.attributes.expire-date');
+        $expireDate = self::fromISO8601($expire_date);
+        $resource->expire_date =  $expireDate->getTimestamp();
+
+        $resource->store();
+
+        return $resource;
+    }
+
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsIndex.php b/lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsIndex.php
new file mode 100644
index 0000000000000000000000000000000000000000..75211ee327f0565ee191e4269952827ede956c69
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsIndex.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use Courseware\StructuralElement;
+use Courseware\PublicLink;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Neomerx\JsonApi\Contracts\Http\ResponsesInterface;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Displays one StructuralElement.
+ */
+class PublicStructuralElementsIndex extends JsonApiController
+{
+
+    protected $allowedIncludePaths = [];
+
+        /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        
+        $publicLink = PublicLink::find($args['link_id']);
+
+        if (!$publicLink) {
+            throw new AuthorizationFailedException();
+        }
+
+        $root = StructuralElement::find($publicLink->structural_element_id);
+        if (!$root) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!$publicLink->canVisitElement($root)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $resources = array_merge([$root], $root->findDescendants());
+
+        return $this->getContentResponse($resources);
+    }
+}
\ No newline at end of file
diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsShow.php b/lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsShow.php
new file mode 100644
index 0000000000000000000000000000000000000000..eeb43fc31d184eb300c1f4c4db16ecc96622a78e
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsShow.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use Courseware\StructuralElement;
+use Courseware\PublicLink;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Neomerx\JsonApi\Contracts\Http\ResponsesInterface;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Displays one StructuralElement.
+ */
+class PublicStructuralElementsShow extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        'children',
+        'containers',
+        'containers.blocks',
+        'course',
+        'parent',
+    ];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $resource = StructuralElement::find($args['id']);
+        $publicLink = PublicLink::find($args['link_id']);
+
+        if (!$publicLink) {
+            throw new AuthorizationFailedException();
+        }
+
+        /** @var ?StructuralElement $resource*/
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!$publicLink->canVisitElement($resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $meta = [];
+
+        return $this->getContentResponse($resource, ResponsesInterface::HTTP_OK, [], $meta);
+    }
+}
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index 1af7a90b38092635403f3a03f0369561cf0a9d4e..e7168cd998900a09b7c811cd7a3441e59f08c45f 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -65,6 +65,7 @@ class SchemaMap
             \Courseware\TaskGroup::class => Schemas\Courseware\TaskGroup::class,
             \Courseware\TaskFeedback::class => Schemas\Courseware\TaskFeedback::class,
             \Courseware\Template::class => Schemas\Courseware\Template::class,
+            \Courseware\PublicLink::class => Schemas\Courseware\PublicLink::class,
         ];
     }
 }
diff --git a/lib/classes/JsonApi/Schemas/Courseware/Block.php b/lib/classes/JsonApi/Schemas/Courseware/Block.php
index 63fd96e47c6892c1d9b96b0074cdbe3f9262b947..0bbe578a2deeba0e2c7c14125eba97c26d89e243 100755
--- a/lib/classes/JsonApi/Schemas/Courseware/Block.php
+++ b/lib/classes/JsonApi/Schemas/Courseware/Block.php
@@ -101,21 +101,23 @@ class Block extends SchemaProvider
         ];
 
         $user = $this->currentUser;
-        $userDataField = UserDataField::getUserDataField($user, $resource);
-        $relationships[self::REL_USERDATAFIELD] = [
-            self::RELATIONSHIP_LINKS => [
-                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_USERDATAFIELD),
-            ],
-            self::RELATIONSHIP_DATA => $userDataField,
-        ];
-
-        $userProgress = UserProgress::getUserProgress($user, $resource);
-        $relationships[self::REL_USERPROGRESS] = [
-            self::RELATIONSHIP_LINKS => [
-                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_USERPROGRESS),
-            ],
-            self::RELATIONSHIP_DATA => $userProgress,
-        ];
+        if ($user) {
+            $userDataField = UserDataField::getUserDataField($user, $resource);
+            $relationships[self::REL_USERDATAFIELD] = [
+                self::RELATIONSHIP_LINKS => [
+                    Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_USERDATAFIELD),
+                ],
+                self::RELATIONSHIP_DATA => $userDataField,
+            ];
+    
+            $userProgress = UserProgress::getUserProgress($user, $resource);
+            $relationships[self::REL_USERPROGRESS] = [
+                self::RELATIONSHIP_LINKS => [
+                    Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_USERPROGRESS),
+                ],
+                self::RELATIONSHIP_DATA => $userProgress,
+            ];
+        }
 
         if ($resource->files) {
             $filesLink = $this->getRelationshipRelatedLink($resource, self::REL_FILES);
diff --git a/lib/classes/JsonApi/Schemas/Courseware/PublicLink.php b/lib/classes/JsonApi/Schemas/Courseware/PublicLink.php
new file mode 100644
index 0000000000000000000000000000000000000000..13d14e06999135e097d2f70272781355d0410166
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Courseware/PublicLink.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace JsonApi\Schemas\Courseware;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class PublicLink extends SchemaProvider
+{
+    const TYPE = 'courseware-public-links';
+
+    const REL_STRUCTURAL_ELEMENT = 'structural-element';
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getId($resource): ?string
+    {
+        return $resource->id;
+    }
+
+        /**
+     * {@inheritdoc}
+     */
+    public function getAttributes($resource, ContextInterface $context): array
+    {
+        return [
+            'password' => $resource['password'],
+            'expire-date' => $resource['expire_date'] ? date('Y-m-d', $resource['expire_date']) : null,
+            'mkdate' => date('c', $resource['mkdate']),
+            'chdate' => date('c', $resource['chdate']),
+        ];
+    }
+
+        /**
+     * {@inheritdoc}
+     */
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $relationships[self::REL_STRUCTURAL_ELEMENT] = $resource['structural_element_id']
+        ? [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($resource['structural_element']),
+            ],
+            self::RELATIONSHIP_DATA => $resource['structural_element'],
+        ]
+        : [self::RELATIONSHIP_DATA => null];
+
+        return $relationships;
+    }
+}
diff --git a/lib/models/Courseware/PublicLink.php b/lib/models/Courseware/PublicLink.php
new file mode 100644
index 0000000000000000000000000000000000000000..d02e077b1a0a800ec538fd5b636ba953a7e84ce4
--- /dev/null
+++ b/lib/models/Courseware/PublicLink.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Courseware;
+
+/**
+* Courseware's template.
+*
+* @author  Ron Lucke <lucke@elan-ev.de>
+* @license GPL2 or any later version
+*
+* @since   Stud.IP 5.2
+*
+* @property string                         $id                     database column
+* @property int                            $structural_element_id  database column
+* @property string                         $password               database column
+* @property int                            $expire_date            database column
+* @property int                            $mkdate                 database column
+* @property int                            $chdate                 database column
+*/
+class PublicLink extends \SimpleORMap
+{
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'cw_public_links';
+
+        $config['belongs_to']['structural_element'] = [
+            'class_name' => StructuralElement::class,
+            'foreign_key' => 'structural_element_id',
+        ];
+
+        parent::configure($config);
+    }
+
+    public function canVisitElement(StructuralElement $structuralElement): bool
+    {
+        if (!$structuralElement) {
+            return false;
+        }
+
+        if ($structuralElement->isRootNode()) {
+            return $this->structural_element_id === $structuralElement->id;
+        }
+
+        if ($this->structural_element_id === $structuralElement->id) {
+            return true;
+        }
+
+        return $this->canVisitElement($structuralElement->parent);
+    }
+
+    public function isExpired(): bool
+    {
+        if (!$this->expire_date) {
+            return false;
+        }
+
+        return time() > $this->expire_date;
+    }
+}
diff --git a/lib/navigation/ContentsNavigation.php b/lib/navigation/ContentsNavigation.php
index 735d1d0d04e81fd3a9f0bd2d9315611f5302d08c..4e01acaadfd43926fcb1d9556dd290a075bae711 100755
--- a/lib/navigation/ContentsNavigation.php
+++ b/lib/navigation/ContentsNavigation.php
@@ -59,6 +59,10 @@ class ContentsNavigation extends Navigation
             'courseware_manager',
             new Navigation(_('Verwaltung persönlicher Lernmaterialien'), 'dispatch.php/contents/courseware/courseware_manager')
         );
+        $courseware->addSubNavigation(
+            'releases',
+            new Navigation(_('Freigaben'), 'dispatch.php/contents/courseware/releases')
+        );
         $courseware->addSubNavigation(
             'bookmarks',
             new Navigation(_('Lesezeichen'), 'dispatch.php/contents/courseware/bookmarks')
diff --git a/resources/assets/javascripts/bootstrap/courseware.js b/resources/assets/javascripts/bootstrap/courseware.js
index 2c2938d17d6704483119bd826bc0e0873fb79dfe..87101506622c9ac184629a3b04d11387e4c0a8c6 100755
--- a/resources/assets/javascripts/bootstrap/courseware.js
+++ b/resources/assets/javascripts/bootstrap/courseware.js
@@ -64,4 +64,26 @@ STUDIP.domReady(() => {
             });
         });
     }
+
+    if (document.getElementById('courseware-public-app')) {
+        STUDIP.Vue.load().then(({ createApp }) => {
+            import(
+                /* webpackChunkName: "courseware-public-app" */
+                '@/vue/courseware-public-app.js'
+            ).then(({ default: mountApp }) => {
+                return mountApp(STUDIP, createApp, '#courseware-public-app');
+            });
+        });
+    }
+
+    if (document.getElementById('courseware-content-releases-app')) {
+        STUDIP.Vue.load().then(({ createApp }) => {
+            import(
+                /* webpackChunkName: "courseware-content-links-app" */
+                '@/vue/courseware-content-releases-app.js'
+            ).then(({ default: mountApp }) => {
+                return mountApp(STUDIP, createApp, '#courseware-content-releases-app');
+            });
+        });
+    }
 });
diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss
index 175cbd70741d53246ec5f67a8500723f6efb738b..52431eb2a93d1450567fe97a75943df07a6f4034 100755
--- a/resources/assets/stylesheets/scss/courseware.scss
+++ b/resources/assets/stylesheets/scss/courseware.scss
@@ -510,6 +510,20 @@ $consum_ribbon_width: calc(100% - 58px);
     }
 }
 
+#courseware-public-index {
+    .cw-ribbon-tools {
+        top: 127px;
+
+        &.cw-ribbon-tools-consume {
+            top: 14px;
+        }
+
+        &.cw-ribbon-tools-sticky {
+            top: 56px;
+        }
+    }
+}
+
 .cw-structural-element-consumemode {
     .cw-ribbon-tools {
         top: 14px;
@@ -1933,6 +1947,9 @@ v i e w  w i d g e t
     .cw-action-widget-oer{
         @include background-icon(oer-campus, clickable);
     }
+    .cw-action-widget-link {
+        @include background-icon(group, clickable);
+    }
 }
 .cw-export-widget {
     .cw-export-widget-export{
@@ -4775,3 +4792,21 @@ cw tiles end
  /* * * * * * * * * * * * *
  i n p u t  f i l e  e n d
 * * * * * * * * * * * * * */
+
+/* * * * * * * * * * * *
+p u b l i c  l i n k s 
+* * * * * * * * * * * */
+.cw-public-link-clipboard-button {
+    width: 16px;
+    height: 16px;
+    margin-left: 0.5em;
+    background-color: transparent;
+    border: none;
+    vertical-align: text-bottom;
+    @include background-icon(clipboard, clickable, 16);
+    cursor: pointer;
+    
+}
+/* * * * * * * * * * * * * * *
+e n d  p u b l i c  l i n k s 
+* * * * * * * * * * * * * * */
\ No newline at end of file
diff --git a/resources/vue/components/courseware/ContentReleasesApp.vue b/resources/vue/components/courseware/ContentReleasesApp.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7dd550079ebcef0cfde80a75962389b284ead9e5
--- /dev/null
+++ b/resources/vue/components/courseware/ContentReleasesApp.vue
@@ -0,0 +1,18 @@
+<template>
+  <div class="cw-content-releases">
+        <courseware-content-links />
+        <courseware-companion-overlay />
+  </div>
+</template>
+
+<script>
+import CoursewareContentLinks from './CoursewareContentLinks.vue';
+import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue';
+
+export default {
+    components: {
+        CoursewareContentLinks,
+        CoursewareCompanionOverlay
+    }
+}
+</script>
diff --git a/resources/vue/components/courseware/CoursewareActionWidget.vue b/resources/vue/components/courseware/CoursewareActionWidget.vue
index c15174349e932eb01af2887e5a4089556c92159f..20df52240ec5f84318c4fe8d9dffbebed85a1562 100644
--- a/resources/vue/components/courseware/CoursewareActionWidget.vue
+++ b/resources/vue/components/courseware/CoursewareActionWidget.vue
@@ -35,6 +35,11 @@
                 <translate>Lesezeichen setzen</translate>
             </button>
         </li>
+        <li v-if="context.type === 'users'" class="cw-action-widget-link">
+            <button @click="linkElement">
+                <translate>Öffentlichen Link erzeugen</translate>
+            </button>
+        </li>
         <li v-if="!isOwner" class="cw-action-widget-oer">
             <button @click="suggestOER">
                 <translate>Material für %{oerTitle} vorschlagen</translate>
@@ -65,6 +70,7 @@ export default {
             userId: 'userId',
             consumeMode: 'consumeMode',
             showToolbar: 'showToolbar',
+            context: 'context',
         }),
         isRoot() {
             if (!this.structuralElement) {
@@ -110,6 +116,7 @@ export default {
             showElementAddDialog: 'showElementAddDialog',
             showElementDeleteDialog: 'showElementDeleteDialog',
             showElementInfoDialog: 'showElementInfoDialog',
+            showElementLinkDialog: 'showElementLinkDialog',
             updateShowSuggestOerDialog: 'updateShowSuggestOerDialog',
             setStructuralElementSortMode: 'setStructuralElementSortMode',
             companionInfo: 'companionInfo',
@@ -167,6 +174,9 @@ export default {
         suggestOER() {
             this.updateShowSuggestOerDialog(true);
         },
+        linkElement() {
+            this.showElementLinkDialog(true);
+        }
     },
 };
 </script>
diff --git a/resources/vue/components/courseware/CoursewareContentLinks.vue b/resources/vue/components/courseware/CoursewareContentLinks.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7260fd6401014ca4bfab05e345de8d6c8012a50d
--- /dev/null
+++ b/resources/vue/components/courseware/CoursewareContentLinks.vue
@@ -0,0 +1,173 @@
+<template>
+    <div>
+        <table class="default">
+            <caption>
+                <translate>Öffentlich verlinkte Seiten</translate>
+            </caption>
+            <thead>
+                <tr>
+                    <th><translate>Seite</translate></th>
+                    <th><translate>Link</translate></th>
+                    <th><translate>Passwort</translate></th>
+                    <th><translate>Ablaufdatum</translate></th>
+                    <th class="actions"><translate>Aktionen</translate></th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr v-for="link in links" :key="link.id">
+                    <td><a :href="getElementUrl(link)">{{ getPage(link) }}</a></td>
+                    <td>
+                        <a :href="getLinkUrl(link)" target="_blank" :title="getLinkUrl(link)">
+                            <studip-icon shape="link-extern" role="clickable" />
+                        </a>
+                    </td>
+                    <td>{{ link.attributes.password || '-' }}</td>
+                    <td>{{ getReadableDate(link.attributes['expire-date']) }}</td>
+                    <td class="actions">
+                        <studip-action-menu
+                            :items="menuItems"
+                            @editLink="editLink(link)"
+                            @deleteLink="displayDeleteLink(link)"
+                            @copyLinkToClipboard="copyLinkToClipboard(link)"
+                        />
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+        <studip-dialog
+            v-if="showDeleteDialog"
+            :title="$gettext('Link löschen')"
+            :question="$gettext('Möchten Sie diesen Link löschen')"
+            height="180"
+            width="360"
+            @confirm="executeDelete"
+            @close="closeDeleteDialog"
+        ></studip-dialog>
+        <studip-dialog
+            v-if="showEditDialog"
+            :title="$gettext('Link bearbeiten')"
+            :confirmText="$gettext('Speichern')"
+            confirmClass="accept"
+            :closeText="$gettext('Schließen')"
+            closeClass="cancel"
+            @close="closeEditDialog"
+            @confirm="storeLink"
+        >
+            <template v-slot:dialogContent>
+                <form class="default" @submit.prevent="">
+                    <label>
+                        <translate>Passwort</translate>
+                        <input type="text" v-model="currentLink.attributes.password" />
+                    </label>
+                    <label>
+                        <translate>Ablaufdatum</translate>
+                        <input v-model="currentLink.attributes['expire-date']" type="date" class="size-l" />
+                    </label>
+                </form>
+            </template>
+        </studip-dialog>
+    </div>
+</template>
+
+<script>
+import StudipActionMenu from './../StudipActionMenu.vue';
+import { mapActions, mapGetters } from 'vuex';
+import StudipIcon from '../StudipIcon.vue';
+
+export default {
+    name: 'courseware-content-links',
+    components: {
+        StudipActionMenu,
+        StudipIcon,
+    },
+    data() {
+        return {
+            menuItems: [
+                { id: 1, label: this.$gettext('Link in Zwischenablage kopieren'), icon: 'clipboard', emit: 'copyLinkToClipboard'}, 
+                { id: 2, label: this.$gettext('Link bearbeiten'), icon: 'edit', emit: 'editLink' },
+                { id: 3, label: this.$gettext('Link löschen'), icon: 'trash', emit: 'deleteLink' }
+            ],
+            showEditDialog: false,
+            showDeleteDialog: false,
+            currentLink: null
+        }
+    },
+     computed: {
+        ...mapGetters({
+            context: 'context',
+            links: 'courseware-public-links/all',
+            getElementById: 'courseware-structural-elements/byId',
+        }),
+    },
+    methods: {
+        ...mapActions({
+            companionSuccess: 'companionSuccess',
+            deleteLink: 'deleteLink',
+            updateLink: 'updateLink',
+            loadAllLinks: 'courseware-public-links/loadAll'
+        }),
+        getPage(link) {
+            let element = this.getElementById({ id: link.relationships['structural-element'].data.id });
+            return element.attributes.title;
+        },
+        getLinkUrl(link) {
+            return STUDIP.URLHelper.getURL('dispatch.php/courseware/public/', { link: link.id });
+        },
+        getElementUrl(link) {
+            return STUDIP.URLHelper.getURL('dispatch.php/contents/courseware/courseware#/structural_element/' + link.relationships['structural-element'].data.id);
+        },
+        displayDeleteLink(link) {
+            this.showDeleteDialog = true;
+            this.currentLink = link;
+        },
+        executeDelete() {
+            this.deleteLink({linkId: this.currentLink.id});
+            this.closeDeleteDialog();
+        },
+        closeDeleteDialog() {
+            this.showDeleteDialog = false;
+            this.currentLink = null;
+        },
+        editLink(link) {
+            this.showEditDialog = true;
+            this.currentLink = link;
+        },
+        closeEditDialog() {
+            this.showEditDialog = false;
+            this.currentLink = null;
+        },
+        async storeLink() {
+            const date = this.currentLink.attributes['expire-date'];
+            let attributes = {
+                password: this.currentLink.attributes.password,
+                'expire-date': date === '' ? new Date(0).toISOString() : new Date(date).toISOString()
+            };
+
+            await this.updateLink({
+                attributes: attributes,
+                linkId: this.currentLink.id
+            });
+            this.closeEditDialog();
+            this.companionSuccess({
+                info: this.$gettext('Änderungen wurden gespeichert.'),
+            });
+        },
+        copyLinkToClipboard(link) {
+            navigator.clipboard.writeText(this.getLinkUrl(link));
+            this.companionSuccess({
+                info: this.$gettext('Link wurde in die Zwischenablage kopiert.'),
+            });
+        },
+        getReadableDate(date) {
+            if (!date) {
+                return '-';
+            }
+            return new Date(date).toLocaleDateString(navigator.language, { 
+                year: 'numeric',
+                month: '2-digit',
+                day: '2-digit'
+            });
+        },
+    }
+}
+</script>
diff --git a/resources/vue/components/courseware/CoursewareDefaultBlock.vue b/resources/vue/components/courseware/CoursewareDefaultBlock.vue
index 123643aea14bd68faa9210ee9138fc487cf7f7c6..85e97b5e862a65c2c742c40db3f202b8335bf5c6 100755
--- a/resources/vue/components/courseware/CoursewareDefaultBlock.vue
+++ b/resources/vue/components/courseware/CoursewareDefaultBlock.vue
@@ -119,7 +119,7 @@ export default {
         defaultGrade: {
             type: Boolean,
             default: true,
-        }
+        },
     },
     data() {
         return {
@@ -141,10 +141,11 @@ export default {
     computed: {
         ...mapGetters({
             blockTypes: 'blockTypes',
+            containerById: 'courseware-containers/byId',
+            context: 'context',
             userId: 'userId',
             userById: 'users/byId',
             viewMode: 'viewMode',
-            containerById: 'courseware-containers/byId',
         }),
         showEditMode() {
             let show = this.viewMode === 'edit' || this.blockedByThisUser;
@@ -187,6 +188,9 @@ export default {
 
             return this.blockTypes.find((blockType) => blockType.type === type)?.title || this.$gettext('Fehler');
         },
+        public() {
+            return this.context.type === 'public';
+        }
     },
     mounted() {
         if (this.blocked) {
@@ -197,7 +201,7 @@ export default {
                 this.loadUserById({ id: this.blockerId });
             }
         }
-        if (this.userProgress && this.userProgress.attributes.grade === 0 && this.defaultGrade) {
+        if (!this.public && this.userProgress && this.userProgress.attributes.grade === 0 && this.defaultGrade) {
             this.userProgress = 1;
         }
     },
diff --git a/resources/vue/components/courseware/CoursewareRibbonToolbar.vue b/resources/vue/components/courseware/CoursewareRibbonToolbar.vue
index b65fafdd209f9063f71d9a4ff909d06de3591795..6a523bf8fb6e7dffef5f72492a652f9caba6bc96 100755
--- a/resources/vue/components/courseware/CoursewareRibbonToolbar.vue
+++ b/resources/vue/components/courseware/CoursewareRibbonToolbar.vue
@@ -24,7 +24,7 @@
                             />
                         </courseware-tab>
                         <courseware-tab
-                            v-if="!consumeMode && showEditMode && canEdit"
+                            v-if="displayAdder"
                             :name="$gettext('Elemente hinzufügen')"
                             :selected="showBlockAdder"
                             alias="blockadder"
@@ -36,7 +36,7 @@
                             />
                         </courseware-tab>
                         <courseware-tab
-                            v-if="!consumeMode && displaySettings"
+                            v-if="displaySettings"
                             :name="$gettext('Einstellungen')"
                             :selected="showAdmin"
                             alias="admin"
@@ -80,6 +80,14 @@ export default {
     props: {
         toolsActive: Boolean,
         canEdit: Boolean,
+        disableSettings: {
+            type: Boolean,
+            default: false,
+        },
+        disableAdder: {
+            type: Boolean,
+            default: false,
+        },
     },
     data() {
         return {
@@ -105,9 +113,21 @@ export default {
         showEditMode() {
             return this.viewMode === 'edit';
         },
+        displayAdder() {
+            if (this.disableAdder) {
+                return false;
+            } else {
+                return !this.consumeMode && this.showEditMode && this.canEdit;
+            }
+        },
         displaySettings() {
-            let user = this.userById({ id: this.userId });
-            return this.context.type === 'courses' && (this.isTeacher || ['root', 'admin'].includes(user.attributes.permission));
+            if (this.disableSettings) {
+                return false;
+            } else {
+                let user = this.userById({ id: this.userId });
+                return !this.consumeMode && this.context.type === 'courses' && (this.isTeacher || ['root', 'admin'].includes(user.attributes.permission));
+            }
+            
         },
         isTeacher() {
             return this.userIsTeacher;
@@ -150,7 +170,9 @@ export default {
             setTimeout(() => {
                 let contents = this.$refs.contents.$el; 
                 let current = contents.querySelector('.cw-tree-item-link-current');
-                contents.scroll({ top: current.offsetTop - 4, behavior: 'smooth' });
+                if (current) {
+                    contents.scroll({ top: current.offsetTop - 4, behavior: 'smooth' });
+                }
             }, 360);
         },
     },
diff --git a/resources/vue/components/courseware/CoursewareStructuralElement.vue b/resources/vue/components/courseware/CoursewareStructuralElement.vue
index a1786b178c625a848b0f2aa74e83c6579d4e738a..0c016e3cd9d6584332ea1a39d638504dd377b4e8 100755
--- a/resources/vue/components/courseware/CoursewareStructuralElement.vue
+++ b/resources/vue/components/courseware/CoursewareStructuralElement.vue
@@ -61,6 +61,7 @@
                                 @sortContainers="menuAction('sortContainers')"
                                 @pdfExport="menuAction('pdfExport')"
                                 @showSuggest="menuAction('showSuggest')"
+                                @linkElement="menuAction('linkElement')"
                             />
                         </template>
                     </courseware-ribbon>
@@ -558,6 +559,30 @@
                     @confirm="deleteCurrentElement"
                     @close="closeDeleteDialog"
                 ></studip-dialog>
+                <studip-dialog
+                    v-if="showLinkDialog"
+                    :title="$gettext('Öffentlichen Link für Seite erzeugen')"
+                    :confirmText="$gettext('Erstellen')"
+                    confirmClass="accept"
+                    :closeText="$gettext('Schließen')"
+                    closeClass="cancel"
+                    class="cw-structural-element-dialog"
+                    @close="closeLinkDialog"
+                    @confirm="createElementLink"
+                >
+                    <template v-slot:dialogContent>
+                        <form class="default" @submit.prevent="">
+                            <label>
+                                <translate>Passwort</translate>
+                                <input type="password" v-model="publicLink.password" />
+                            </label>
+                            <label>
+                                <translate>Ablaufdatum</translate>
+                                <input v-model="publicLink['expire-date']" type="date" class="size-l" />
+                            </label>
+                        </form>
+                    </template>
+                </studip-dialog>
             </div>
             <div v-else>
                 <courseware-companion-box
@@ -586,6 +611,7 @@ import CoursewareTabs from './CoursewareTabs.vue';
 import CoursewareTab from './CoursewareTab.vue';
 import CoursewareExport from '@/vue/mixins/courseware/export.js';
 import CoursewareOerMessage from '@/vue/mixins/courseware/oermessage.js';
+import CoursewareDateInput from './CoursewareDateInput.vue';
 import { FocusTrap } from 'focus-trap-vue';
 import IsoDate from './IsoDate.vue';
 import StudipDialog from '../StudipDialog.vue';
@@ -606,6 +632,7 @@ export default {
         CoursewareEmptyElementBox,
         CoursewareTabs,
         CoursewareTab,
+        CoursewareDateInput,
         FocusTrap,
         IsoDate,
         StudipDialog,
@@ -665,6 +692,11 @@ export default {
             errorEmptyChapterName: false,
             consumModeTrap: false,
             additionalText: '',
+
+            publicLink: {
+                passsword: '',
+                'expire-date': ''
+            }
         };
     },
 
@@ -689,6 +721,7 @@ export default {
             showDeleteDialog: 'showStructuralElementDeleteDialog',
             showOerDialog: 'showStructuralElementOerDialog',
             showSuggestOerDialog: 'showSuggestOerDialog',
+            showLinkDialog: 'showStructuralElementLinkDialog',
             oerEnabled: 'oerEnabled',
             oerTitle: 'oerTitle',
             licenses: 'licenses',
@@ -758,6 +791,10 @@ export default {
                 }
             }
 
+            if (context.type === 'public') {
+                valid = true;
+            }
+
             return valid;
         },
 
@@ -897,6 +934,9 @@ export default {
 
                 menu.push({ id: 3, label: this.$gettext('Seite hinzufügen'), icon: 'add', emit: 'addElement' });
             }
+            if (this.context.type === 'users') {
+                menu.push({ id: 6, label: this.$gettext('Öffentlichen Link erzeugen'), icon: 'group', emit: 'linkElement' });
+            }
             if (!this.isRoot && this.canEdit && !this.isTask) {
                 menu.push({
                     id: 9,
@@ -1219,12 +1259,14 @@ export default {
             showElementInfoDialog: 'showElementInfoDialog',
             showElementDeleteDialog: 'showElementDeleteDialog',
             showElementOerDialog: 'showElementOerDialog',
+            showElementLinkDialog: 'showElementLinkDialog',
             updateShowSuggestOerDialog: 'updateShowSuggestOerDialog',
             updateContainer: 'updateContainer',
             setStructuralElementSortMode: 'setStructuralElementSortMode',
             sortContainersInStructualElements: 'sortContainersInStructualElements',
             loadTask: 'loadTask',
             loadStructuralElement: 'loadStructuralElement',
+            createLink: 'createLink',
         }),
 
         initCurrent() {
@@ -1295,6 +1337,10 @@ export default {
                         return false;
                     }
                     this.enableSortContainers();
+                    break;
+                case 'linkElement':
+                    this.showElementLinkDialog(true);
+                    break;
             }
         },
         async closeEditDialog() {
@@ -1477,6 +1523,36 @@ export default {
         sendOerSuggestion() {
             this.suggestViaAction(this.currentElement, this.additionalText);
             this.updateShowSuggestOerDialog(false);
+        },
+        async createElementLink() {
+            const date = this.publicLink['expire-date'];
+            const publicLink = {
+                attributes: {
+                    password: this.publicLink.password,
+                    'expire-date': date === '' ? new Date(0).toISOString() : new Date(date).toISOString()
+                },
+                relationships: {
+                    'structural-element': {
+                        data: {
+                            id: this.currentElement.id,
+                            type: 'courseware-structural-elements'
+                        }
+                    }
+                }
+            }
+
+            await this.createLink({ publicLink });
+            this.companionSuccess({
+                info: this.$gettext('Öffentlicher Link wurde angelegt. Unter Freigaben finden Sie alle Ihre öffentlichen Links.'),
+            });
+            this.closeLinkDialog();
+        },
+        closeLinkDialog() {
+            this.publicLink = { 
+                passsword: '',
+                'expire-date': ''
+            };
+            this.showElementLinkDialog(false);
         }
     },
     created() {
diff --git a/resources/vue/components/courseware/CoursewareTree.vue b/resources/vue/components/courseware/CoursewareTree.vue
index 881c1a48df724642f61e3ce25bed91e20c3bdc89..eba9f775bc4b7c83dbfbcb43e01d20f0eb532b96 100755
--- a/resources/vue/components/courseware/CoursewareTree.vue
+++ b/resources/vue/components/courseware/CoursewareTree.vue
@@ -19,6 +19,7 @@ export default {
     name: 'courseware-tree',
     computed: {
         ...mapGetters({
+            context: 'context',
             courseware: 'courseware',
             relatedStructuralElement: 'courseware-structural-elements/related',
             structuralElementById: 'courseware-structural-elements/byId',
@@ -33,12 +34,17 @@ export default {
         },
 
         rootElement() {
-            const root = this.relatedStructuralElement({
-                parent: { id: this.courseware.id, type: this.courseware.type },
-                relationship: 'root',
-            });
+            if (this.context.type !== 'public') {
+                    const root = this.relatedStructuralElement({
+                    parent: { id: this.courseware.id, type: this.courseware.type },
+                    relationship: 'root',
+                });
+
+                return root;
+            } else {
+                return this.structuralElementById({ id: this.context.rootId });
+            }
 
-            return root;
         },
     },
 };
diff --git a/resources/vue/components/courseware/PublicApp.vue b/resources/vue/components/courseware/PublicApp.vue
new file mode 100644
index 0000000000000000000000000000000000000000..616db7fa69f7fcc1e8359f349ff5213c1a12c2af
--- /dev/null
+++ b/resources/vue/components/courseware/PublicApp.vue
@@ -0,0 +1,141 @@
+<template>
+    <div>
+        <div v-if="structureLoadingState === 'done'">
+            <public-courseware-structural-element
+                :canVisit="true"
+                :structural-element="selected"
+                :ordered-structural-elements="orderedStructuralElements"
+                @select="selectStructuralElement"
+            ></public-courseware-structural-element>
+        </div>
+        <studip-progress-indicator
+            v-if="structureLoadingState === 'loading'"
+            class="cw-loading-indicator-content"
+            :description="$gettext('Lade Lernmaterial...')"
+        />
+        <courseware-companion-box
+            v-if="structureLoadingState === 'error'"
+            mood="sad"
+            :msgCompanion="loadingErrorMessage"
+        />
+        <courseware-companion-box
+            v-if="wrongPassword"
+            mood="sad"
+            :msgCompanion="passwordMessage"
+        />
+        <form v-if="!isAuthenticated" class="default" @submit.prevent="">
+            <label>
+                <translate>Passwort</translate>
+                <input type="password" v-model="password">
+            </label>
+            <button class="button" @click="submitPassword">
+                <translate>Absenden</translate>
+            </button>
+        </form>
+     </div>
+</template>
+
+<script>
+import PublicCoursewareStructuralElement from './PublicCoursewareStructuralElement.vue';
+import CoursewareCompanionBox from './CoursewareCompanionBox.vue';
+import StudipProgressIndicator from '../StudipProgressIndicator.vue';
+import { mapActions, mapGetters } from 'vuex';
+
+export default {
+     components: {
+        PublicCoursewareStructuralElement,
+        CoursewareCompanionBox,
+        StudipProgressIndicator,
+     },
+    data() {
+        return {
+            selected: null,
+            structureLoadingState: 'idle',
+            loadingErrorStatus: null,
+            wrongPassword: false,
+            password: '',
+            passwordMessage: this.$gettext('Das eingegebene Passwort ist leider falsch.'),
+            userIsTeacher: false,
+        }
+    },
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            courseware: 'courseware',
+            isAuthenticated: 'isAuthenticated',
+            relatedStructuralElement: 'courseware-structural-elements/related',
+            structuralElements: 'courseware-structural-elements/all',
+            structuralElementById: 'courseware-structural-elements/byId',
+            userId: 'userId',
+
+            orderedStructuralElements: 'courseware-structure/ordered',
+            childrenById: 'courseware-structure/children',
+        }),
+        loadingErrorMessage() {
+            switch (this.loadingErrorStatus) {
+                case 404:
+                    return this.$gettext('Die Seite konnte nicht gefunden werden.');
+                case 403:
+                    return this.$gettext('Diese Seite steht Ihnen leider nicht zur Verfügung.');
+                default:
+                    return this.$gettext('Beim Laden der Seite ist ein Fehler aufgetreten.');
+            }
+        },
+        selectedId() {
+            return this.$route.params?.id;
+        }
+     },
+        methods: {
+        ...mapActions({
+            loadElements: 'courseware-structural-elements/loadAll',
+            buildStructure: 'courseware-structure/build',
+            loadStructuralElement: 'loadStructuralElement',
+            validatePassword: 'validatePassword',
+        }),
+        async selectStructuralElement(id) {
+            if (!id) {
+                return;
+            }
+
+            this.loadingErrorStatus = null;
+            this.structureLoadingState = 'loading';
+            try {
+                await this.loadStructuralElement(id);
+            } catch(error) {
+                this.loadingErrorStatus = error.status;
+                this.structureLoadingState = 'error';
+                return;
+            }
+            this.structureLoadingState = 'done';
+            this.selected = this.structuralElementById({ id });
+        },
+
+        submitPassword() {
+            this.validatePassword(this.password);
+            if (this.isAuthenticated) {
+                this.$router.push({ path: '/', replace: true});
+            } else {
+                this.wrongPassword = true;
+            }
+        }
+    },
+    async mounted() {
+        await this.loadElements();
+        await this.buildStructure();
+        const selectedId = this.$route.params?.id;
+        await this.selectStructuralElement(selectedId);
+    },
+
+    watch: {
+        $route(to) {
+            const selectedId = to.params?.id;
+            this.selectStructuralElement(selectedId);
+            window.scrollTo({ top: 0 });
+        },
+        password() {
+            this.wrongPassword = false;
+        },
+    },
+
+}
+</script>
diff --git a/resources/vue/components/courseware/PublicCoursewareStructuralElement.vue b/resources/vue/components/courseware/PublicCoursewareStructuralElement.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f6956d3cead1a31057b13e363d5c00dd500948f8
--- /dev/null
+++ b/resources/vue/components/courseware/PublicCoursewareStructuralElement.vue
@@ -0,0 +1,256 @@
+<template>
+    <focus-trap v-model="consumModeTrap">
+        <div
+            :class="{ 'cw-structural-element-consumemode': consumeMode }"
+            class="cw-structural-element"
+        >
+            <div v-if="structuralElement" class="cw-structural-element-content">
+                <courseware-ribbon :canEdit="false" :disableSettings="true" :disableAdder="true">
+                    <template #buttons>
+                        <router-link v-if="prevElement" :to="'/structural_element/' + prevElement.id">
+                            <div class="cw-ribbon-button cw-ribbon-button-prev" :title="textRibbon.perv" />
+                        </router-link>
+                        <div v-else class="cw-ribbon-button cw-ribbon-button-prev-disabled" :title="$gettext('keine vorherige Seite')"/>
+                        <router-link v-if="nextElement" :to="'/structural_element/' + nextElement.id">
+                            <div class="cw-ribbon-button cw-ribbon-button-next" :title="textRibbon.next" />
+                        </router-link>
+                        <div v-else class="cw-ribbon-button cw-ribbon-button-next-disabled" :title="$gettext('keine nächste Seite')"/>
+                    </template>
+                    <template #breadcrumbList>
+                        <li
+                            v-for="ancestor in ancestors"
+                            :key="ancestor.id"
+                            :title="ancestor.attributes.title"
+                            class="cw-ribbon-breadcrumb-item"
+                        >
+                            <span>
+                                <router-link :to="'/structural_element/' + ancestor.id">{{ ancestor.attributes.title || "–" }}</router-link>
+                            </span>
+                        </li>
+                        <li
+                            class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current"
+                            :title="structuralElement.attributes.title"
+                        >
+                            <span>{{ structuralElement.attributes.title || "–" }}</span>
+                        </li>
+                    </template>
+                    <template #breadcrumbFallback>
+                        <li
+                            class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current"
+                            :title="structuralElement.attributes.title"
+                        >
+                            <span>{{ structuralElement.attributes.title }}</span>
+                        </li>
+                    </template>
+                </courseware-ribbon>
+
+                <div
+                    class="cw-container-wrapper"
+                    :class="{
+                        'cw-container-wrapper-consume': consumeMode,
+                    }"
+                >
+                    <div v-if="structuralElementLoaded" class="cw-companion-box-wrapper">
+                        <courseware-empty-element-box
+                            v-if="noContainers"
+                            :noContainers="noContainers"
+                        />
+                    </div>
+                    <component
+                        v-for="container in containers"
+                        :key="container.id"
+                        :is="containerComponent(container)"
+                        :container="container"
+                        :canEdit="false"
+                        :canAddElements="false"
+                        :isTeacher="false"
+                        class="cw-container-item"
+                    />
+                </div>
+            </div>
+        </div>
+    </focus-trap>
+</template>
+
+<script>
+import ContainerComponents from './container-components.js';
+import CoursewarePluginComponents from './plugin-components.js';
+import CoursewareAccordionContainer from './CoursewareAccordionContainer.vue';
+import CoursewareListContainer from './CoursewareListContainer.vue';
+import CoursewareCompanionBox from './CoursewareCompanionBox.vue';
+import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue';
+import CoursewareEmptyElementBox from './CoursewareEmptyElementBox.vue';
+import CoursewareTabsContainer from './CoursewareTabsContainer.vue';
+import CoursewareRibbon from './CoursewareRibbon.vue';
+import CoursewareTabs from './CoursewareTabs.vue';
+import CoursewareTab from './CoursewareTab.vue';
+
+import IsoDate from './IsoDate.vue';
+import { FocusTrap } from 'focus-trap-vue';
+import { mapActions, mapGetters } from 'vuex';
+
+export default {
+    name: 'public-courseware-structural-element',
+
+    components: {
+        ContainerComponents,
+        CoursewareRibbon,
+        CoursewareListContainer,
+        CoursewareAccordionContainer,
+        CoursewareTabsContainer,
+        CoursewareCompanionBox,
+        CoursewareCompanionOverlay,
+        CoursewareEmptyElementBox,
+        CoursewareTabs,
+        CoursewareTab,
+        FocusTrap,
+        IsoDate,
+    },
+
+    props: ['orderedStructuralElements', 'structuralElement'],
+
+    data() {
+        return {
+            consumModeTrap: false,
+            textRibbon: {
+                perv: this.$gettext('zurück'),
+                next: this.$gettext('weiter'),
+            },
+        }
+    },
+
+    computed: {
+        ...mapGetters({
+            courseware: 'courseware',
+            context: 'context',
+            consumeMode: 'consumeMode',
+            containerById: 'courseware-containers/byId',
+            pluginManager: 'pluginManager',
+            relatedContainers: 'courseware-containers/related',
+            relatedStructuralElements: 'courseware-structural-elements/related',
+            structuralElementById: 'courseware-structural-elements/byId',
+        }),
+
+        currentId() {
+            return this.structuralElement?.id;
+        },
+
+        image() {
+            return this.structuralElement.relationships?.image?.meta?.['download-url'] ?? null;
+        },
+
+        structuralElementLoaded() {
+            return this.structuralElement !== null && this.structuralElement !== {};
+        },
+
+        ancestors() {
+            if (!this.structuralElement) {
+                return [];
+            }
+
+            const finder = (parent) => {
+                const parentId = parent.relationships?.parent?.data?.id;
+                if (!parentId) {
+                    return null;
+                }
+                const element = this.structuralElementById({ id: parentId });
+
+                return element;
+            };
+
+            const visitAncestors = function* (node) {
+                const parent = finder(node);
+                if (parent) {
+                    yield parent;
+                    yield* visitAncestors(parent);
+                }
+            };
+
+            return [...visitAncestors(this.structuralElement)].reverse();
+        },
+
+        prevElement() {
+            const currentIndex = this.orderedStructuralElements.indexOf(this.structuralElement.id);
+            if (currentIndex <= 0) {
+                return null;
+            }
+            const previousId = this.orderedStructuralElements[currentIndex - 1];
+            const previous = this.structuralElementById({ id: previousId });
+
+            return previous;
+        },
+
+        nextElement() {
+            const currentIndex = this.orderedStructuralElements.indexOf(this.structuralElement.id);
+            const lastIndex = this.orderedStructuralElements.length - 1;
+            if (currentIndex === -1 || currentIndex === lastIndex) {
+                return null;
+            }
+            const nextId = this.orderedStructuralElements[currentIndex + 1];
+            const next = this.structuralElementById({ id: nextId });
+
+            return next;
+        },
+
+        empty() {
+            if (this.containers === null) {
+                return true;
+            } else {
+                return !this.containers.some((container) => container.relationships.blocks.data.length > 0);
+            }
+        },
+
+        containers() {
+            let containers = [];
+            let relatedContainers = this.structuralElement?.relationships?.containers?.data;
+
+            if (relatedContainers) {
+                for (const container of relatedContainers) {
+                    containers.push(this.containerById({ id: container.id}));
+                }
+            }
+
+            return containers;
+        },
+
+        noContainers() {
+            if (this.containers === null) {
+                return true;
+            } else {
+                return this.containers.length === 0;
+            }
+        },
+
+        isRoot() {
+            return this.structuralElement.id === this.context.rootId;
+        },
+    },
+
+    methods: {
+        ...mapActions({
+            companionError: 'companionError',
+            companionInfo: 'companionInfo',
+            companionSuccess: 'companionSuccess',
+        }),
+        containerComponent(container) {
+            return 'courseware-' + container.attributes['container-type'] + '-container';
+        },
+    },
+
+    created() {
+        this.pluginManager.registerComponentsLocally(this);
+    },
+
+    watch: {
+        consumeMode(newState) {
+            this.consumModeTrap = newState;
+        },
+    },
+
+    // this line provides all the components to courseware plugins
+    provide: () => ({
+        containerComponents: ContainerComponents,
+        coursewarePluginComponents: CoursewarePluginComponents,
+    }),
+};
+</script>
diff --git a/resources/vue/courseware-content-releases-app.js b/resources/vue/courseware-content-releases-app.js
new file mode 100644
index 0000000000000000000000000000000000000000..f99744b34a87296f0fc55083c76c88727ea94970
--- /dev/null
+++ b/resources/vue/courseware-content-releases-app.js
@@ -0,0 +1,70 @@
+import ContentReleasesApp from './components/courseware/ContentReleasesApp.vue';
+import CoursewareModule from './store/courseware/courseware.module';
+import { mapResourceModules } from '@elan-ev/reststate-vuex';
+import Vuex from 'vuex';
+import axios from 'axios';
+
+const mountApp = (STUDIP, createApp, element) => {
+    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: {
+            courseware: CoursewareModule,
+            ...mapResourceModules({
+                names: [
+                    'courseware-containers',
+                    'courseware-public-links',
+                    'courseware-structural-elements',
+                    'file-refs',
+                    'users',
+                ],
+                httpClient,
+            }),
+        },
+    });
+    let entry_id = null;
+    let entry_type = null;
+    let elem = document.getElementById(element.substring(1));
+
+    if (elem !== 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;
+            }
+        }
+    }
+
+    store.dispatch('coursewareContext', {
+        id: entry_id,
+        type: entry_type,
+    });
+
+    store.dispatch('courseware-public-links/loadAll', {
+        options: {
+            include: 'structural-element',
+        },
+    });
+
+    const app = createApp({
+        render: (h) => h(ContentReleasesApp),
+        store
+    });
+
+    app.$mount(element);
+
+    return app;
+}
+
+export default mountApp;
\ No newline at end of file
diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js
index acc8457c7479f23205503fee2ef5a623a4cc867f..c2e4a1e52d82402a2dbff0c1ff0f97006597b7ef 100755
--- a/resources/vue/courseware-index-app.js
+++ b/resources/vue/courseware-index-app.js
@@ -95,6 +95,7 @@ const mountApp = (STUDIP, createApp, element) => {
                     'courseware-block-feedback',
                     'courseware-containers',
                     'courseware-instances',
+                    'courseware-public-links',
                     'courseware-structural-elements',
                     'courseware-structural-element-comments',
                     'courseware-structural-element-feedback',
diff --git a/resources/vue/courseware-public-app.js b/resources/vue/courseware-public-app.js
new file mode 100644
index 0000000000000000000000000000000000000000..ae706055b89790206ba67c052f7481e25bc71a4a
--- /dev/null
+++ b/resources/vue/courseware-public-app.js
@@ -0,0 +1,127 @@
+import PublicApp from './components/courseware/PublicApp.vue';
+import CoursewarePublicModule from './store/courseware/courseware-public.module';
+import PublicCoursewareStructuralElement from './components/courseware/PublicCoursewareStructuralElement.vue';
+import CoursewarePublicStructureModule from './store/courseware/public-structure.module';
+import PluginManager from './components/courseware/plugin-manager.js';
+import VueRouter from 'vue-router';
+import Vuex from 'vuex';
+import axios from 'axios';
+import { mapResourceModules } from '@elan-ev/reststate-vuex';
+import _ from 'lodash';
+
+const mountApp = (STUDIP, createApp, element) => {
+
+    let elem_id = null;
+    let link_id = null;
+    let link_pass = null;
+    let entry_type = null;
+    let elem = document.getElementById(element.substring(1));
+
+    if (elem !== undefined) {
+        if (elem.attributes !== undefined) {
+            if (elem.attributes['entry-element-id'] !== undefined) {
+                elem_id = elem.attributes['entry-element-id'].value;
+            }
+
+            if (elem.attributes['entry-type'] !== undefined) {
+                entry_type = elem.attributes['entry-type'].value;
+            }
+
+            if (elem.attributes['link-id'] !== undefined) {
+                link_id = elem.attributes['link-id'].value;
+            }
+
+            if (elem.attributes['link-pass'] !== undefined) {
+                link_pass = elem.attributes['link-pass'].value;
+            }
+        }
+    }
+
+    const getHttpClient = () =>
+    axios.create({
+        baseURL: STUDIP.URLHelper.getURL('jsonapi.php/v1/public/courseware/'  + link_id, {}, true),
+        headers: {
+            'Content-Type': 'application/vnd.api+json',
+        },
+    });
+
+    let base = new URL(
+        STUDIP.URLHelper.getURL('dispatch.php/courseware/public', { link: link_id }, true)
+    );
+
+    const httpClient = getHttpClient();
+
+    const store = new Vuex.Store({
+        modules: {
+            // courseware: CoursewareModule,
+            'courseware-public': CoursewarePublicModule,
+            'courseware-structure': CoursewarePublicStructureModule,
+            ...mapResourceModules({
+                names: [
+                    'courseware-blocks',
+                    'courseware-containers',
+                    'courseware-instances',
+                    'courseware-structural-elements',
+                    'courseware-user-data-fields',
+                    'courseware-user-progresses',
+                    'files',
+                    'file-refs',
+                    'folders',
+                    'users',
+                ],
+                httpClient,
+            }),
+        },
+    });
+
+    store.dispatch('setContext', {
+        id: link_id,
+        type: entry_type,
+        rootId: elem_id
+    });
+
+    if (link_pass) {
+        store.dispatch('setPassword', link_pass);
+    } else {
+        store.dispatch('setIsAuthenticated', true);
+    }
+
+    const pluginManager = new PluginManager();
+    store.dispatch('setPluginManager', pluginManager);
+    STUDIP.eventBus.emit('courseware:init-plugin-manager', pluginManager);
+
+    const routes = [
+        {
+            path: '/',
+            redirect: '/structural_element/' + elem_id,
+        },
+        {
+            path: '/structural_element/:id',
+            name: 'PublicCoursewareStructuralElement',
+            component: PublicCoursewareStructuralElement,
+            beforeEnter: (to, from, next) => {
+                if (!store.getters.isAuthenticated) {
+                    return false;
+                }
+                next();
+            },
+        },
+    ];
+
+    const router = new VueRouter({
+        base: `${base.pathname}${base.search}`,
+        routes,
+    });
+
+    const app = createApp({
+        render: (h) => h(PublicApp),
+        router,
+        store
+    });
+
+    app.$mount(element);
+
+    return app;
+}
+
+export default mountApp;
diff --git a/resources/vue/store/courseware/courseware-links.module.js b/resources/vue/store/courseware/courseware-links.module.js
new file mode 100644
index 0000000000000000000000000000000000000000..ed198d079aaa6553602245d6e63fe5b0f845f94c
--- /dev/null
+++ b/resources/vue/store/courseware/courseware-links.module.js
@@ -0,0 +1,29 @@
+const getDefaultState = () => {
+    return {
+    };
+};
+
+const initialState = getDefaultState();
+
+const getters = {
+
+};
+
+export const state = { ...initialState };
+
+export const actions = {
+    // setters
+
+    // other actions
+};
+
+export const mutations = {
+
+};
+
+export default {
+    state,
+    actions,
+    mutations,
+    getters,
+};
diff --git a/resources/vue/store/courseware/courseware-public.module.js b/resources/vue/store/courseware/courseware-public.module.js
new file mode 100644
index 0000000000000000000000000000000000000000..1982fe28fcf9e6ce7318145344e87f8941663da7
--- /dev/null
+++ b/resources/vue/store/courseware/courseware-public.module.js
@@ -0,0 +1,175 @@
+const getDefaultState = () => {
+    return {
+        blockAdder: {},
+        consumeMode: true,
+        containerAdder: false,
+        context: null,
+        courseware: {},
+        isAuthenticated: false,
+        password: null,
+        pluginManager: null,
+        selectedToolbarItem: 'contents',
+        showToolbar: false,
+        userId: null,
+        viewMode: 'read',
+    };
+};
+
+const initialState = getDefaultState();
+
+const getters = {
+    blockAdder(state) {
+        return state.blockAdder;
+    },
+
+    consumeMode(state) {
+        return state.consumeMode;
+    },
+
+    containerAdder(state) {
+        return state.containerAdder;
+    },
+
+    context(state) {
+        return state.context;
+    },
+
+    courseware(state) {
+        return state.courseware;
+    },
+
+    isAuthenticated(state) {
+        return state.isAuthenticated;
+    },
+
+    password(state) {
+        return state.password;
+    },
+
+    pluginManager(state) {
+        return state.pluginManager;
+    },
+
+    selectedToolbarItem(state) {
+        return state.selectedToolbarItem;
+    },
+
+    showToolbar(state) {
+        return state.showToolbar;
+    },
+
+    userId(state) {
+        return state.userId;
+    },
+
+    viewMode(state) {
+        return state.viewMode;
+    },
+};
+
+export const state = { ...initialState };
+
+export const actions = {
+    // setters
+    coursewareConsumeMode({ commit }, consumeMode) {
+        commit('setConsumeMode', consumeMode);
+    },
+
+    coursewareContainerAdder(context, adder) {
+        context.commit('setContainerAdder', adder);
+    },
+
+    coursewareShowToolbar(context, toolbar) {
+        context.commit('setShowToolbar', toolbar);
+    },
+
+    coursewareViewMode(context, view) {
+        context.commit('setViewMode', view);
+    },
+
+    setContext({ commit }, context) {
+        commit('setContext', context);
+    },
+
+    setPluginManager({ commit }, pluginManager) {
+        commit('setPluginManager', pluginManager);
+    },
+
+    setIsAuthenticated({ commit }, isAuthenticated) {
+        commit('setIsAuthenticated', isAuthenticated);
+    },
+
+    setPassword({ commit }, password) {
+        commit('setPassword', password);
+    },
+
+    // other actions
+    loadStructuralElement({ dispatch }, structuralElementId) {
+        const options = {
+            include:
+                'containers,containers.blocks',
+        };
+
+        return dispatch(
+            'courseware-structural-elements/loadById',
+            { id: structuralElementId, options },
+            { root: true }
+        );
+    },
+
+    validatePassword({ getters, dispatch }, password) {
+        if (password === getters.password) {
+            dispatch('setIsAuthenticated', true);
+
+            return true;
+        }
+
+        return false;
+    }
+};
+
+export const mutations = {
+
+    coursewareSet(state, data) {
+        state.courseware = data;
+    },
+
+    setConsumeMode(state, consumeMode) {
+        state.consumeMode = consumeMode;
+    },
+
+    setContainerAdder(state, containerAdder) {
+        state.containerAdder = containerAdder;
+    },
+
+    setContext(state, context) {
+        state.context = context;
+    },
+
+    setIsAuthenticated(state, isAuthenticated) {
+        state.isAuthenticated = isAuthenticated;
+    },
+
+    setPassword(state, password) {
+        state.password = password;
+    },
+
+    setPluginManager(state, pluginManager) {
+        state.pluginManager = pluginManager;
+    },
+
+    setShowToolbar(state, showToolbar) {
+        state.showToolbar = showToolbar;
+    },
+
+    setViewMode(state, data) {
+        state.viewMode = data;
+    },
+};
+
+export default {
+    state,
+    actions,
+    mutations,
+    getters,
+};
diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js
index dd4cfac60d932b16f0ced8853cf8a825d057676f..9a511100f9d1a88938b6a5f9f064213703427ea1 100755
--- a/resources/vue/store/courseware/courseware.module.js
+++ b/resources/vue/store/courseware/courseware.module.js
@@ -36,6 +36,7 @@ const getDefaultState = () => {
         showStructuralElementInfoDialog: false,
         showStructuralElementDeleteDialog: false,
         showStructuralElementOerDialog: false,
+        showStructuralElementLinkDialog: false,
 
         showSuggestOerDialog: false,
 
@@ -171,6 +172,9 @@ const getters = {
     showStructuralElementDeleteDialog(state) {
         return state.showStructuralElementDeleteDialog;
     },
+    showStructuralElementLinkDialog(state) {
+        return state.showStructuralElementLinkDialog;
+    },
     showOverviewElementAddDialog(state) {
         return state.showOverviewElementAddDialog;
     },
@@ -822,6 +826,10 @@ export const actions = {
         context.commit('setShowStructuralElementDeleteDialog', bool);
     },
 
+    showElementLinkDialog(context, bool) {
+        context.commit('setShowStructuralElementLinkDialog', bool);
+    },
+
     setShowOverviewElementAddDialog(context, bool) {
         context.commit('setShowOverviewElementAddDialog', bool);
     },
@@ -1194,6 +1202,28 @@ export const actions = {
     setBookmarkFilter({ commit }, course) {
         commit('setBookmarkFilter', course);
     },
+
+    createLink({ dispatch, rootGetters }, { publicLink }) {
+        dispatch('courseware-public-links/create', publicLink, { root: true });
+    },
+
+    deleteLink({ dispatch }, { linkId }) {
+        const data = {
+            id: linkId,
+        };
+        dispatch('courseware-public-links/delete', data, { root: true });
+    },
+
+    async updateLink({ dispatch }, { attributes, linkId }) {
+        const link = {
+            type: 'courseware-public-links',
+            attributes: attributes,
+            id: linkId,
+        };
+        await dispatch('courseware-public-links/update', link, { root: true });
+
+        return dispatch('courseware-public-links/loadById', { id: link.id }, { root: true });
+    }
 };
 
 /* eslint no-param-reassign: ["error", { "props": false }] */
@@ -1324,6 +1354,10 @@ export const mutations = {
         state.showOverviewElementAddDialog = showAdd;
     },
 
+    setShowStructuralElementLinkDialog(state, showLink) {
+        state.showStructuralElementLinkDialog = showLink;
+    },
+
     setStructuralElementSortMode(state, mode) {
         state.structuralElementSortMode = mode;
     },
diff --git a/resources/vue/store/courseware/public-structure.module.js b/resources/vue/store/courseware/public-structure.module.js
new file mode 100644
index 0000000000000000000000000000000000000000..134a65e359654e590c3cd79d73e984316b35b0b1
--- /dev/null
+++ b/resources/vue/store/courseware/public-structure.module.js
@@ -0,0 +1,78 @@
+const getDefaultState = () => {
+    return {
+        children: [],
+        ordered: [],
+    };
+};
+
+const initialState = getDefaultState();
+const state = { ...initialState };
+
+const getters = {
+    children(state) {
+        return (id) => state.children[id] ?? [];
+    },
+    ordered(state) {
+        return state.ordered;
+    },
+};
+
+export const mutations = {
+    reset(state) {
+        state = getDefaultState();
+    },
+    setChildren(state, children) {
+        state.children = children;
+    },
+    setOrdered(state, ordered) {
+        state.ordered = ordered;
+    },
+};
+
+const actions = {
+    build({commit, rootGetters }) {
+        const context = rootGetters['context'];
+        const structuralElements = rootGetters['courseware-structural-elements/all'];
+        const children = structuralElements.reduce((memo, element) => {
+            const parent = element.relationships.parent?.data?.id ?? null;
+            if (parent) {
+                if (!memo[parent]) {
+                    memo[parent] = [];
+                }
+                memo[parent].push([element.id, element.attributes.position]);
+            }
+
+            return memo;
+        }, {});
+        for (const key of Object.keys(children)) {
+            children[key].sort((childA, childB) => childA[1] - childB[1]);
+            children[key] = children[key].map(([id]) => id);
+        }
+        
+        commit('setChildren', children);
+
+        const ordered = [...visitTree(children, context.rootId)];
+        commit('setOrdered', ordered);
+    },
+};
+
+function* visitTree(tree, current) {
+    if (current) {
+        yield current;
+
+        const children = tree[current];
+        if (children) {
+            for (let index = 0; index < children.length; index++) {
+                yield* visitTree(tree, children[index]);
+            }
+        }
+    }
+}
+
+export default {
+    namespaced: true,
+    actions,
+    getters,
+    mutations,
+    state,
+};