From 9a720a69d9052a865163054c133b916782a8c529 Mon Sep 17 00:00:00 2001
From: Dennis Benz <dennis.benz@uni-osnabrueck.de>
Date: Thu, 29 Jun 2023 08:37:50 +0000
Subject: [PATCH] New Courseware Block: LTI, refs #2326

Merge request studip/studip!1545
---
 app/controllers/courseware/lti.php            |  99 +++++++
 lib/classes/JsonApi/RouteMap.php              |   7 +
 lib/classes/JsonApi/Routes/Lti/Authority.php  |  25 ++
 .../JsonApi/Routes/Lti/LtiToolsIndex.php      |  27 ++
 .../JsonApi/Routes/Lti/LtiToolsShow.php       |  32 ++
 lib/classes/JsonApi/SchemaMap.php             |   1 +
 lib/classes/JsonApi/Schemas/LtiTool.php       |  30 ++
 .../Courseware/BlockTypes/BlockType.php       |   1 +
 lib/models/Courseware/BlockTypes/Lti.json     |  37 +++
 lib/models/Courseware/BlockTypes/Lti.php      |  86 ++++++
 .../assets/stylesheets/scss/courseware.scss   |  25 ++
 .../courseware/CoursewareLtiBlock.vue         | 279 ++++++++++++++++++
 .../courseware/container-components.js        |   2 +
 resources/vue/courseware-index-app.js         |   1 +
 14 files changed, 652 insertions(+)
 create mode 100644 app/controllers/courseware/lti.php
 create mode 100644 lib/classes/JsonApi/Routes/Lti/Authority.php
 create mode 100644 lib/classes/JsonApi/Routes/Lti/LtiToolsIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/Lti/LtiToolsShow.php
 create mode 100644 lib/classes/JsonApi/Schemas/LtiTool.php
 create mode 100644 lib/models/Courseware/BlockTypes/Lti.json
 create mode 100644 lib/models/Courseware/BlockTypes/Lti.php
 create mode 100644 resources/vue/components/courseware/CoursewareLtiBlock.vue

diff --git a/app/controllers/courseware/lti.php b/app/controllers/courseware/lti.php
new file mode 100644
index 00000000000..7792051d953
--- /dev/null
+++ b/app/controllers/courseware/lti.php
@@ -0,0 +1,99 @@
+<?php
+
+class Courseware_LtiController extends AuthenticatedController
+{
+
+    /**
+     * Display the launch form for a tool as an iframe in a courseware LTI block.
+     *
+     * @param   int $block_id    courseware block id
+     */
+    public function iframe_action($block_id)
+    {
+        $cw_block = \Courseware\Block::find($block_id);
+        if (!$cw_block->container->structural_element->canRead($GLOBALS['user']->id)) {
+            throw new AccessDeniedException();
+        }
+
+        $cw_block = \Courseware\Block::find($block_id);
+
+        $lti_link = $this->getLtiLink($cw_block);
+
+        $this->launch_url  = $lti_link->getLaunchURL();
+        $this->launch_data = $lti_link->getBasicLaunchData();
+        $this->signature   = $lti_link->getLaunchSignature($this->launch_data);
+
+        $this->set_layout(null);
+        $this->render_template('course/lti/iframe');
+    }
+
+    /**
+     * Return an LtiLink object for the passed courseware LTI block.
+     *
+     * @param   \Courseware\Block $cw_block courseware LTI block
+     *
+     * @return  LtiLink  LTI link representation
+     */
+    public function getLtiLink($cw_block)
+    {
+        $block_payload = json_decode($cw_block->payload, true);
+
+        // Collect LTI Data from courseware block payload
+        $id = $cw_block->id;
+        $context_id = Context::getId();
+        $range_id = $cw_block->getStructuralElement()->range_id;
+        $title = trim($block_payload['title']);
+        $tool_id = $block_payload['tool_id'];
+        $launch_url = trim($block_payload['launch_url']);
+        $custom_parameters = trim($block_payload['custom_parameters']);
+        $document_target = 'iframe';
+
+        if ($tool_id) {
+            $tool = LtiTool::find($tool_id);
+
+            // Prefer custom url
+            if (!$tool->allow_custom_url && !$tool->deep_linking || !$launch_url) {
+                $launch_url = $tool->launch_url;
+            }
+
+            $consumer_key = $tool->consumer_key;
+            $consumer_secret = $tool->consumer_secret;
+            $send_lis_person = $tool->send_lis_person;
+            $oauth_signature_method = $tool->oauth_signature_method;
+            $custom_parameters = $tool->custom_parameters . "\n" . $custom_parameters;
+        } else {
+            $consumer_key = trim($block_payload['consumer_key']);
+            $consumer_secret = trim($block_payload['consumer_secret']);
+            $send_lis_person = $block_payload['send_lis_person'];
+            $oauth_signature_method = $block_payload['oauth_signature_method'] ?? 'sha1';
+        }
+
+        if ($context_id) {
+            // Role in course
+            $roles = $GLOBALS['perm']->have_studip_perm('tutor', $context_id) ? 'Instructor' : 'Learner';
+        } else {
+            // Role in workspace
+            $roles = $range_id === $GLOBALS['user']->id ? 'Instructor' : 'Learner';
+        }
+
+        // Create LTI Link for setting up launch request
+        $lti_link = new LtiLink($launch_url, $consumer_key, $consumer_secret, $oauth_signature_method);
+        $lti_link->setResource($id, $title);
+        $lti_link->setUser($GLOBALS['user']->id, $roles, $send_lis_person);
+        $lti_link->setCourse($range_id);
+        $lti_link->addLaunchParameters([
+            'launch_presentation_locale' => str_replace('_', '-', $_SESSION['_language']),
+            'launch_presentation_document_target' => $document_target,
+        ]);
+
+        $custom_parameters = explode("\n", $custom_parameters);
+        foreach ($custom_parameters as $param) {
+            if (strpos($param, '=') !== false) {
+                list($key, $value) = explode('=', $param, 2);
+                $lti_link->addCustomParameter(trim($key), trim($value));
+            }
+        }
+
+        return $lti_link;
+    }
+}
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 52d4402e54b..f01a0965170 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -129,6 +129,7 @@ class RouteMap
         $this->addAuthenticatedFilesRoutes($group);
         $this->addAuthenticatedForumRoutes($group);
         $this->addAuthenticatedInstitutesRoutes($group);
+        $this->addAuthenticatedLtiRoutes($group);
         $this->addAuthenticatedMessagesRoutes($group);
         $this->addAuthenticatedNewsRoutes($group);
         $this->addAuthenticatedStudyAreasRoutes($group);
@@ -251,6 +252,12 @@ class RouteMap
         $group->get('/institutes/{id}/status-groups', Routes\Institutes\StatusGroupsOfInstitutes::class);
     }
 
+    private function addAuthenticatedLtiRoutes(RouteCollectorProxy $group): void
+    {
+        $group->get('/lti-tools/{id}', Routes\Lti\LtiToolsShow::class);
+        $group->get('/lti-tools', Routes\Lti\LtiToolsIndex::class);
+    }
+
     private function addAuthenticatedNewsRoutes(RouteCollectorProxy $group): void
     {
         $group->post('/courses/{id}/news', Routes\News\CourseNewsCreate::class);
diff --git a/lib/classes/JsonApi/Routes/Lti/Authority.php b/lib/classes/JsonApi/Routes/Lti/Authority.php
new file mode 100644
index 00000000000..99d438b0ec3
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Lti/Authority.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace JsonApi\Routes\Lti;
+
+use LtiTool;
+use User;
+
+class Authority
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public static function canShowLtiTool(User $user, LtiTool $tool): bool
+    {
+        return true;
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public static function canIndexLtiTools(User $user): bool
+    {
+        return true;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Lti/LtiToolsIndex.php b/lib/classes/JsonApi/Routes/Lti/LtiToolsIndex.php
new file mode 100644
index 00000000000..d701f16fade
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Lti/LtiToolsIndex.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace JsonApi\Routes\Lti;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use JsonApi\JsonApiController;
+
+class LtiToolsIndex extends JsonApiController
+{
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        if (!Authority::canIndexLtiTools($this->getUser($request))) {
+            throw new AuthorizationFailedException();
+        }
+
+        list($offset, $limit) = $this->getOffsetAndLimit();
+
+        $total = \LtiTool::countBySql('1');
+        $tools = \LtiTool::findBySQL("1 ORDER BY `name` LIMIT ?, ?", [$offset, $limit]);
+
+        return $this->getPaginatedContentResponse($tools, $total);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Lti/LtiToolsShow.php b/lib/classes/JsonApi/Routes/Lti/LtiToolsShow.php
new file mode 100644
index 00000000000..40924f91b10
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Lti/LtiToolsShow.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace JsonApi\Routes\Lti;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+/**
+ * Displays a certain lti tool.
+ */
+class LtiToolsShow extends JsonApiController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        if (!$resource = \LtiTool::find($args['id'])) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!Authority::canShowLtiTool($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+
+        return $this->getContentResponse($resource);
+    }
+}
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index f4ac20796ed..e5a2f678f5f 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -33,6 +33,7 @@ class SchemaMap
             \JsonApi\Models\ForumEntry::class => Schemas\ForumEntry::class,
             \Institute::class => Schemas\Institute::class,
             \InstituteMember::class => Schemas\InstituteMember::class,
+            \LtiTool::class => Schemas\LtiTool::class,
             \Message::class => Schemas\Message::class,
             \SemClass::class => Schemas\SemClass::class,
             \Semester::class => Schemas\Semester::class,
diff --git a/lib/classes/JsonApi/Schemas/LtiTool.php b/lib/classes/JsonApi/Schemas/LtiTool.php
new file mode 100644
index 00000000000..58aa24cd164
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/LtiTool.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace JsonApi\Schemas;
+
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+
+class LtiTool extends SchemaProvider
+{
+    const TYPE = 'lti-tools';
+
+    public function getId($resource): ?string
+    {
+        return $resource->id;
+    }
+
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        return [
+            'name' => $resource->name,
+            'launch-url' => $resource->launch_url,
+            'allow-custom-url' => (bool) $resource->allow_custom_url,
+            'deep-linking' => (bool) $resource->deep_linking,
+        ];
+    }
+
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        return [];
+    }
+}
diff --git a/lib/models/Courseware/BlockTypes/BlockType.php b/lib/models/Courseware/BlockTypes/BlockType.php
index 8c03c880832..e5526f5866a 100644
--- a/lib/models/Courseware/BlockTypes/BlockType.php
+++ b/lib/models/Courseware/BlockTypes/BlockType.php
@@ -118,6 +118,7 @@ abstract class BlockType
             ImageMap::class,
             KeyPoint::class,
             Link::class,
+            Lti::class,
             TableOfContents::class,
             Text::class,
             Timeline::class,
diff --git a/lib/models/Courseware/BlockTypes/Lti.json b/lib/models/Courseware/BlockTypes/Lti.json
new file mode 100644
index 00000000000..596eb002e84
--- /dev/null
+++ b/lib/models/Courseware/BlockTypes/Lti.json
@@ -0,0 +1,37 @@
+{
+    "title": "Payload schema of Courseware\\BlockType\\LTI",
+    "type": "object",
+    "properties": {
+        "title": {
+            "type": "string"
+        },
+        "height": {
+            "type": "string"
+        },
+        "tool_id": {
+            "type": "string"
+        },
+        "launch_url": {
+            "type": "string"
+        },
+        "consumer_key": {
+            "type": "string"
+        },
+        "consumer_secret": {
+            "type": "string"
+        },
+        "oauth_signature_method": {
+            "type": "string"
+        },
+        "send_lis_person": {
+            "type": "boolean"
+        },
+        "custom_parameters": {
+            "type": "string"
+        }
+    },
+    "required": [
+        "tool_id"
+    ],
+    "additionalProperties": true
+}
diff --git a/lib/models/Courseware/BlockTypes/Lti.php b/lib/models/Courseware/BlockTypes/Lti.php
new file mode 100644
index 00000000000..8b527c193df
--- /dev/null
+++ b/lib/models/Courseware/BlockTypes/Lti.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Courseware\BlockTypes;
+
+use Opis\JsonSchema\Schema;
+
+/**
+ * This class represents the content of a Courseware LTI block.
+ *
+ * @author  Dennis Benz <debenz@uos.de>
+ * @license GPL2 or any later version
+ *
+ * @since   Stud.IP 5.3
+ */
+class Lti extends BlockType
+{
+    public static function getType(): string
+    {
+        return 'lti';
+    }
+
+    public static function getTitle(): string
+    {
+        return _('LTI');
+    }
+
+    public static function getDescription(): string
+    {
+        return _('Einbinden eines externen Tools.');
+    }
+
+    public function initialPayload(): array
+    {
+        return [
+            'title' => '',
+            'height' => '640',
+            'tool_id' => '',
+            'launch_url' => '',
+            'consumer_key' => '',
+            'consumer_secret' => '',
+            'oauth_signature_method' => 'sha1',
+            'send_lis_person' => false,
+            'custom_parameters' => '',
+        ];
+    }
+
+    public static function getJsonSchema(): Schema
+    {
+        $schemaFile = __DIR__.'/Lti.json';
+
+        return Schema::fromJsonString(file_get_contents($schemaFile));
+    }
+
+    public function getPayload()
+    {
+        $payload = $this->decodePayloadString($this->block['payload']);
+        $user = \User::findCurrent();
+
+        // Remove sensitive lti parameters if user has no edit permission
+        if (!$this->block->getStructuralElement()->canEdit($user)) {
+            unset($payload['launch_url']);
+            unset($payload['consumer_key']);
+            unset($payload['consumer_secret']);
+            unset($payload['oauth_signature_method']);
+            unset($payload['send_lis_person']);
+            unset($payload['custom_parameters']);
+        }
+
+        return $payload;
+    }
+
+    public static function getCategories(): array
+    {
+        return ['external'];
+    }
+
+    public static function getContentTypes(): array
+    {
+        return ['rich'];
+    }
+
+    public static function getFileTypes(): array
+    {
+        return [];
+    }
+}
diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss
index 08e8811d604..525f7b4a000 100644
--- a/resources/assets/stylesheets/scss/courseware.scss
+++ b/resources/assets/stylesheets/scss/courseware.scss
@@ -85,6 +85,7 @@ $blockadder-items: (
     folder: folder-full,
     headline: block-eyecatcher,
     iframe: door-enter,
+    lti: plugin,
     key-point: exclaim-circle,
     link: link-extern,
     table-of-contents: table-of-contents,
@@ -3679,6 +3680,30 @@ i f r a m e  b l o c k
 i f r a m e  b l o c k  e n d
 * * * * * * * * * * * * * */
 
+/* * * * * * * * +
+l t i  b l o c k
+* * * * * * * * */
+.cw-block-lti {
+    .cw-block-content {
+        .cw-block-lti-content {
+            border: solid thin $content-color-40;
+            box-sizing: border-box;
+        }
+        .cw-block-lti-icon-tool {
+            @include background-icon(plugin, info, 24);
+            background-repeat: no-repeat;
+
+            display: block;
+            padding: 16px 16px 16px 40px;
+            background-position: 10px center;
+            overflow: hidden;
+            text-overflow: ellipsis;
+        }
+    }
+}
+/* * * * * * * * * * * *
+l t i  b l o c k  e n d
+* * * * * * * * * * * */
 
 /* * * * * * * * * * * *
 f o l d e r  b l o c k
diff --git a/resources/vue/components/courseware/CoursewareLtiBlock.vue b/resources/vue/components/courseware/CoursewareLtiBlock.vue
new file mode 100644
index 00000000000..e9679625050
--- /dev/null
+++ b/resources/vue/components/courseware/CoursewareLtiBlock.vue
@@ -0,0 +1,279 @@
+<template>
+    <div class="cw-block cw-block-lti">
+        <courseware-default-block
+            :block="block"
+            :canEdit="canEdit"
+            :isTeacher="isTeacher"
+            :preview="false"
+            @showEdit="initCurrentData"
+            @storeEdit="storeBlock"
+            @closeEdit="initCurrentData"
+        >
+            <template #content>
+                <div v-if="currentTitle !== ''" class="cw-block-title">{{ currentTitle }}</div>
+                <iframe
+                    v-if="toolId !== ''"
+                    class="cw-block-lti-content"
+                    :src="iframeUrl"
+                    :height="currentHeight"
+                    width="100%"
+                    allowfullscreen
+                    sandbox="allow-downloads allow-forms allow-popups allow-pointer-lock allow-same-origin allow-scripts"
+                />
+                <div v-else class="cw-block-lti-content">
+                    <span class="cw-block-lti-icon-tool">
+                        {{ $gettext('Kein LTI-Tool konfiguriert') }}
+                    </span>
+                </div>
+            </template>
+            <template v-if="canEdit" #edit>
+                <courseware-tabs>
+                    <courseware-tab
+                        :index="0"
+                        :name="$gettext('Grunddaten')"
+                        :selected="true"
+                    >
+                        <form class="default" @submit.prevent="">
+                            <label>
+                                {{ $gettext('Titel') }}
+                                <input type="text" v-model="currentTitle" />
+                            </label>
+                            <label>
+                                {{ $gettext('Auswahl des externen Tools') }}
+                                <select v-model="currentToolId">
+                                    <option v-for="tool in tools" :key="tool.id" :value="tool.id">
+                                        {{ tool.name }}
+                                    </option>
+                                    <option value="0">{{ $gettext('Zugangsdaten selbst eingeben...') }}</option>
+                                </select>
+                            </label>
+                            <label v-show="allowCustomUrl">
+                                {{ $gettext('URL der Anwendung (optional)') }}
+                                <studip-tooltip-icon :text="$gettext('Sie können direkt auf eine URL in der Anwendung verlinken.')"/>
+                                <input type="text" v-model="currentLaunchUrl" :placeholder="currentTool?.launch_url" />
+                            </label>
+
+                            <div v-show="customToolSelected">
+                                <label class="studiprequired">
+                                    {{ $gettext('URL der Anwendung') }}
+                                    <span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span>
+                                    <studip-tooltip-icon :text="$gettext('Die Betreiber dieses Tools müssen Ihnen eine URL und Zugangsdaten (Consumer-Key und Consumer-Secret) mitteilen.')"/>
+                                    <input type="text" v-model="currentLaunchUrl" required>
+                                </label>
+                                <label class="studiprequired">
+                                    {{ $gettext('Consumer-Key des LTI-Tools') }}
+                                    <span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span>
+                                    <input type="text" v-model="currentConsumerKey" required>
+                                </label>
+                                <label class="studiprequired">
+                                    {{ $gettext('Consumer-Secret des LTI-Tools') }}
+                                    <span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span>
+                                    <input type="text" v-model="currentConsumerSecret" required>
+                                </label>
+                                <label>
+                                    {{ $gettext('OAuth Signatur Methode des LTI-Tools') }}
+                                    <select v-model="currentOauthSignatureMethod">
+                                        <option value="sha1">HMAC-SHA1</option>
+                                        <option value="sha256">HMAC-SHA256</option>
+                                    </select>
+                                </label>
+                                <label>
+                                    <input type="checkbox" v-model="currentSendLisPerson" />
+                                    {{ $gettext('Nutzerdaten an LTI-Tool senden') }}
+                                    <studip-tooltip-icon :text="$gettext('Nutzerdaten dürfen nur an das externe Tool gesendet werden, wenn es keine Datenschutzbedenken gibt. Mit Setzen des Hakens bestätigen Sie, dass die Übermittlung der Daten zulässig ist.')"/>
+                                </label>
+                            </div>
+                        </form>
+                    </courseware-tab>
+                    <courseware-tab
+                        :index="1"
+                        :name="$gettext('Zusätzliche Einstellungen')"
+                    >
+                        <form class="default" @submit.prevent="">
+                            <label>
+                                {{ $gettext('Höhe') }}
+                                <input type="number" v-model="currentHeight" min="0" />
+                            </label>
+                            <label>
+                                {{ $gettext('Zusätzliche LTI-Parameter') }}
+                                <studip-tooltip-icon :text="$gettext('Ein Wert pro Zeile, Beispiel: Review:Chapter=1.2.56')"/>
+                                <textarea v-model="currentCustomParameters" />
+                            </label>
+                        </form>
+                    </courseware-tab>
+                </courseware-tabs>
+            </template>
+            <template #info>
+                <p>{{ $gettext('Informationen zum LTI-Block') }}</p>
+            </template>
+        </courseware-default-block>
+    </div>
+</template>
+
+<script>
+import CoursewareDefaultBlock from "./CoursewareDefaultBlock.vue";
+import {blockMixin} from "./block-mixin";
+import {mapActions, mapGetters} from "vuex";
+import CoursewareTabs from "./CoursewareTabs.vue";
+import CoursewareTab from "./CoursewareTab.vue";
+
+export default {
+    name: 'courseware-lti-block',
+    mixins: [blockMixin],
+    components: {
+        CoursewareTab,
+        CoursewareTabs,
+        CoursewareDefaultBlock
+    },
+    props: {
+        block: Object,
+        canEdit: Boolean,
+        isTeacher: Boolean,
+    },
+    data() {
+        return {
+            currentTitle: '',
+            currentHeight: '',
+            currentToolId: '',
+            currentLaunchUrl: '',
+            currentConsumerKey: '',
+            currentConsumerSecret: '',
+            currentOauthSignatureMethod: '',
+            currentSendLisPerson: false,
+            currentCustomParameters: '',
+        }
+    },
+    computed: {
+        ...mapGetters({
+            urlHelper: 'urlHelper',
+            ltiTools: 'lti-tools/all',
+        }),
+        title() {
+            return this.block?.attributes?.payload?.title;
+        },
+        height() {
+            return this.block?.attributes?.payload?.height;
+        },
+        tools() {
+            return this.ltiTools.map(tool => ({
+                id: tool.id,
+                name: tool.attributes.name,
+                launch_url: tool.attributes['launch-url'],
+                allow_custom_url: tool.attributes['allow-custom-url'],
+            }));
+        },
+        toolId() {
+            return this.block?.attributes?.payload?.tool_id;
+        },
+        currentTool() {
+            return this.tools.find(tool => tool.id === this.currentToolId);
+        },
+        allowCustomUrl() {
+            return this.currentTool?.allow_custom_url;
+        },
+        customToolSelected() {
+            return this.currentToolId === '0';
+        },
+        launchUrl() {
+            return this.block?.attributes?.payload?.launch_url;
+        },
+        consumerKey() {
+            return this.block?.attributes?.payload?.consumer_key;
+        },
+        consumerSecret() {
+            return this.block?.attributes?.payload?.consumer_secret;
+        },
+        oauthSignatureMethod() {
+            return this.block?.attributes?.payload?.oauth_signature_method ?? 'sha1';
+        },
+        sendLisPerson() {
+            return this.block?.attributes?.payload?.send_lis_person;
+        },
+        customParameters() {
+            return this.block?.attributes?.payload?.custom_parameters;
+        },
+        iframeUrl() {
+            return this.urlHelper.getURL('dispatch.php/courseware/lti/iframe/' + this.block.id);
+        },
+    },
+    async mounted() {
+        await this.loadLtiTools();
+        this.initCurrentData();
+    },
+    methods: {
+        ...mapActions({
+            updateBlock: 'updateBlockInContainer',
+            loadLtiTools: 'lti-tools/loadAll',
+            companionWarning: 'companionWarning',
+        }),
+        initCurrentData() {
+            this.currentTitle = this.title;
+            this.currentHeight = this.height;
+            this.currentToolId = this.toolId !== '' ? this.toolId : this.currentToolId; // keep preselected tool
+            this.currentLaunchUrl = this.launchUrl;
+            this.currentConsumerKey = this.consumerKey;
+            this.currentConsumerSecret = this.consumerSecret;
+            this.currentOauthSignatureMethod = this.oauthSignatureMethod;
+            this.currentSendLisPerson = Boolean(this.sendLisPerson);  // prevent undefined value
+            this.currentCustomParameters = this.customParameters;
+        },
+        storeBlock() {
+            // require url, key and secret if custom tool is selected
+            if (this.currentToolId === '0') {
+                if (!this.currentLaunchUrl) {
+                    this.companionWarning({
+                        info: this.$gettext('Bitte geben Sie eine URL der Anwendung an.')
+                    });
+                    return false;
+                }
+                if (!this.currentConsumerKey) {
+                    this.companionWarning({
+                        info: this.$gettext('Bitte geben Sie den Consumer-Key des LTI-Tools an.')
+                    });
+                    return false;
+                }
+                if (!this.currentConsumerSecret) {
+                    this.companionWarning({
+                        info: this.$gettext('Bitte geben Sie den Consumer-Secret des LTI-Tools an.')
+                    });
+                    return false;
+                }
+            }
+
+            let attributes = {};
+            attributes.payload = {};
+            attributes.payload.title = this.currentTitle;
+            attributes.payload.height = this.currentHeight;
+            attributes.payload.tool_id = this.currentToolId;
+            attributes.payload.launch_url = this.currentLaunchUrl;
+            if (this.currentToolId === '0') {
+                attributes.payload.consumer_key = this.currentConsumerKey;
+                attributes.payload.consumer_secret = this.currentConsumerSecret;
+                attributes.payload.oauth_signature_method = this.currentOauthSignatureMethod;
+                attributes.payload.send_lis_person = this.currentSendLisPerson;
+            }
+            attributes.payload.custom_parameters = this.currentCustomParameters;
+
+            this.updateBlock({
+                attributes: attributes,
+                blockId: this.block.id,
+                containerId: this.block.relationships.container.data.id,
+            });
+        },
+    },
+    watch: {
+        tools(value) {
+            // Preselect tool
+            if (this.currentToolId === '') {
+                if (value.length > 0) {
+                    // Preselect first tool
+                    this.currentToolId = value[0].id;
+                } else {
+                    // Preselect custom tool
+                    this.currentToolId = '0';
+                }
+            }
+        }
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/container-components.js b/resources/vue/components/courseware/container-components.js
index 2926a186e25..63e665887bd 100644
--- a/resources/vue/components/courseware/container-components.js
+++ b/resources/vue/components/courseware/container-components.js
@@ -21,6 +21,7 @@ import CoursewareIframeBlock from './CoursewareIframeBlock.vue';
 import CoursewareImageMapBlock from './CoursewareImageMapBlock.vue';
 import CoursewareKeyPointBlock from './CoursewareKeyPointBlock.vue';
 import CoursewareLinkBlock from './CoursewareLinkBlock.vue';
+import CoursewareLtiBlock from "./CoursewareLtiBlock.vue";
 import CoursewareTableOfContentsBlock from './CoursewareTableOfContentsBlock.vue';
 import CoursewareTextBlock from './CoursewareTextBlock.vue';
 import CoursewareTimelineBlock from './CoursewareTimelineBlock.vue';
@@ -56,6 +57,7 @@ const ContainerComponents = {
     CoursewareImageMapBlock,
     CoursewareKeyPointBlock,
     CoursewareLinkBlock,
+    CoursewareLtiBlock,
     CoursewareTableOfContentsBlock,
     CoursewareTextBlock,
     CoursewareTimelineBlock,
diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js
index 4741cd46974..073948565fe 100644
--- a/resources/vue/courseware-index-app.js
+++ b/resources/vue/courseware-index-app.js
@@ -109,6 +109,7 @@ const mountApp = async (STUDIP, createApp, element) => {
                     'files',
                     'file-refs',
                     'folders',
+                    'lti-tools',
                     'status-groups',
                     'users',
                     'institutes',
-- 
GitLab