Repository: hpi-schul-cloud/nuxt-client
Pull request #3603: BC-8630 - Displaying the Open Source Licenses
Author: login: muratmerdoglu-dp
Created: 2025-03-13T16:34:06Z | Merged: 2025-03-31T09:19:19Z

# Short Description

<!-- Write a short explanation of what this Pull Request does -->

<!--
  This Pull-Request template helps the reviewers as well as servers as a checklist for you. Please feel out all possible sections.

  Quality guidelines:
    - Code should be self-explanatory; Document code that is not self-explanatory
    - Code respects SOLID principles, DRY, YAGNI, KISS
    - Think about potential bugs this Pull Request might introduce
    - Keep security in mind
    - Write tests (Unit and Integration), including for error cases. Don't decrease coverage
    - Business logic should be implemented in the API; never trust the client
    - UI changes should be discussed & agreed with the UX-Team before staring
    - Boyscout rule: leave the code in a better state than you found it. Remove unnecessary lines. Listen to the linter.
-->

## Links to Ticket and related Pull-Requests
- https://ticketsystem.dbildungscloud.de/browse/BC-8630
- https://github.com/hpi-schul-cloud/schulcloud-client/pull/3610
- https://github.com/hpi-schul-cloud/dof_app_deploy/pull/1130
<!--
Base links to copy
- https://ticketsystem.dbildungscloud.de/browse/BC-????
- https://github.com/hpi-schul-cloud/schulcloud-server/pull/????
- https://github.com/hpi-schul-cloud/end-to-end-tests/pull/????
- https://github.com/hpi-schul-cloud/schulcloud-client/pull/????
-->

## Changes

<!--
- What will the PR change?
- Why are the changes required?
- Links to documentation / tickets if exists, or provide more details here.
-->

## Data-security

<!--
Please note here about:
- any data model changes
- any changes about logging of user data
- any changes about permissions
- user input, authentication and other user data related things
If you are not sure if it is relevant, take a look at confluence or ask the data-security team.
-->

## Deployment

<!--
Keep in mind to changes to seed data, if changes are done by migration scripts.
Changes to the infrastructure have to discussed with the devops.
This information should be also in corresponding ticket, and collected in release deployment ticket.

This point should includes following information:
- What else is required for its deployment?
- Environment variables like FEATURE_XY=true
- Are there any migration scripts to be run?
-->

## New Repos, NPM packages or vendor scripts

<!--
- Keep in mind the stability, performance, activity and author.
- Describe why it is needed.
-->

## Screenshots of UI changes

<!--
- For UI changes, insert screenshots here.
- Has it been reviewed by a UX colleague?
-->

## Checklist before merging

- [x] QA: In addition to review, the code has been manually tested (if manual testing is possible)
- [x] PO: Any deviation from requirements was agreed with Product-Owner / ticket author / support-team
- [x] DEV: Every new component is implemented having accessibility in mind (e.g. aria-label, role property)

> Notice: Please keep this Pull-Request as a Draft (or add WIP label), until it is ready to be reviewed



Diff:
diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml
index 6a2839dc320..e3478a8a5d4 100644
--- a/.github/workflows/tag.yml
+++ b/.github/workflows/tag.yml
@@ -4,7 +4,7 @@ name: Build and push Docker Image on Tag
 on:
   push:
     tags:
-      - '[0-9]*'
+      - "[0-9]*"
 
 jobs:
   build_and_push_nuxt:
@@ -69,4 +69,4 @@ jobs:
       - name: create release
         uses: softprops/action-gh-release@v2
         with:
-          files: dependencies.sbom.json
\ No newline at end of file
+          files: dependencies.sbom.json
diff --git a/ansible/roles/nuxt-client-core/templates/configmap-files.yml.j2 b/ansible/roles/nuxt-client-core/templates/configmap-files.yml.j2
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/ansible/roles/nuxt-client-core/templates/configmap.yml.j2 b/ansible/roles/nuxt-client-core/templates/configmap.yml.j2
index 665610fb61b..df5a9605b6d 100644
--- a/ansible/roles/nuxt-client-core/templates/configmap.yml.j2
+++ b/ansible/roles/nuxt-client-core/templates/configmap.yml.j2
@@ -15,3 +15,4 @@ data:
   H5P_FRAME_SRC_URLS: "{{ H5P_FRAME_SRC_URLS }}"
   H5P_IMG_SRC_URLS: "{{ H5P_PROXY_PREFIX }}{{ DOMAIN }}"
   H5P_SCRIPT_SRC_URLS: "https://{{ DOMAIN }}/api/v3/h5p-editor/h5pstatics/editor/scripts/"
+  LICENSE_SUMMARY_URL_FOR_CSP: "{{ LICENSE_SUMMARY_URL }}"
diff --git a/config/docker/nginx.conf.template b/config/docker/nginx.conf.template
index 4f6994f11a4..9a6eac8a698 100644
--- a/config/docker/nginx.conf.template
+++ b/config/docker/nginx.conf.template
@@ -2,7 +2,7 @@ server {
     listen 4000;
     server_name  localhost;
 
-    set $csp "default-src 'self'; base-uri 'self'; script-src 'nonce-$request_id' 'strict-dynamic' 'unsafe-inline' https:; object-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' ${H5P_FRAME_SRC_URLS} https://docs.dbildungscloud.de/";
+    set $csp "default-src 'self'; base-uri 'self'; script-src 'nonce-$request_id' 'strict-dynamic' 'unsafe-inline' https:; object-src 'none'; connect-src 'self' ${LICENSE_SUMMARY_URL_FOR_CSP}; font-src 'self' data:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' ${H5P_FRAME_SRC_URLS} https://docs.dbildungscloud.de/";
 
     set $h5pcsp "default-src 'self'; base-uri 'self'; script-src ${H5P_SCRIPT_SRC_URLS} 'unsafe-inline' https:; object-src 'none'; font-src 'self' data:; img-src 'self' ${H5P_IMG_SRC_URLS} data:; style-src 'self' 'unsafe-inline'; frame-src 'self' ${H5P_FRAME_SRC_URLS}";
 
diff --git a/src/locales/de.ts b/src/locales/de.ts
index 837a7bcc330..ca0b2e67377 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -965,6 +965,7 @@ export default {
 	"global.sidebar.item.teacher": "Lehrkräfte",
 	"global.sidebar.item.teams": "Teams",
 	"global.sidebar.item.training": "Fortbildungen",
+	"global.sidebar.item.licenses": "Lizenzen",
 	"global.skipLink.mainContent": "Zum Hauptinhalt wechseln",
 	"global.topbar.actions.alerts": "Status-Warnung",
 	"global.topbar.actions.qrCode": "Seitenlink per QR-Code teilen",
@@ -1682,6 +1683,11 @@ export default {
 	"pages.files.overview.teamFiles": "Team-Dateien",
 	"pages.h5p.api.success.save": "Inhalt wurde erfolgreich gespeichert.",
 	"pages.impressum.title": "Impressum",
+	"pages.licenseList.title": "Open-Source-Lizenzliste",
+	"pages.licenseList.introduction":
+		"Im Folgenden sind die Softwarelizenzen der verwendeten Pakete aufgeführt.",
+	"pages.licenseList.packageIntroduction":
+		"Unter dieser Lizenz verwendete Pakete:",
 	"pages.news.edit.title.default": "Artikel bearbeiten",
 	"pages.news.edit.title": "{title} bearbeiten",
 	"pages.news.index.new": "Neuigkeit hinzufügen",
diff --git a/src/locales/en.ts b/src/locales/en.ts
index e625f5925ec..2c373b3ac0c 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -958,6 +958,7 @@ export default {
 	"global.sidebar.item.teacher": "Teachers",
 	"global.sidebar.item.teams": "Teams",
 	"global.sidebar.item.training": "Advanced trainings",
+	"global.sidebar.item.licenses": "Licenses",
 	"global.skipLink.mainContent": "Skip to main content",
 	"global.topbar.actions.alerts": "Status alert",
 	"global.topbar.actions.qrCode": "Share page link via QR-Code",
@@ -1657,6 +1658,10 @@ export default {
 	"pages.files.overview.teamFiles": "Team files",
 	"pages.h5p.api.success.save": "Content was successfully saved.",
 	"pages.impressum.title": "Imprint",
+	"pages.licenseList.title": "Open Source License List",
+	"pages.licenseList.introduction":
+		"The software licences for the packages used are listed below.",
+	"pages.licenseList.packageIntroduction": "Packages used under this licence:",
 	"pages.news.edit.title.default": "Edit article",
 	"pages.news.edit.title": "Edit {title}",
 	"pages.news.index.new": "Add news",
diff --git a/src/locales/es.ts b/src/locales/es.ts
index 01f8164920c..96ca876ebe7 100644
--- a/src/locales/es.ts
+++ b/src/locales/es.ts
@@ -986,6 +986,7 @@ export default {
 	"global.sidebar.item.teacher": "Profesores",
 	"global.sidebar.item.teams": "Equipos",
 	"global.sidebar.item.training": "Formaciones avanzadas",
+	"global.sidebar.item.licenses": "Licencias",
 	"global.skipLink.mainContent": "Saltar al contenido principal",
 	"global.topbar.actions.alerts": "Alerta de estado",
 	"global.topbar.actions.qrCode": "Compartir el código QR de la página",
@@ -1703,6 +1704,11 @@ export default {
 	"pages.files.overview.teamFiles": "Archivos de equipo",
 	"pages.h5p.api.success.save": "El contenido se ha guardado correctamente.",
 	"pages.impressum.title": "Impresión",
+	"pages.licenseList.title": "Lista de licencias de código abierto",
+	"pages.licenseList.introduction":
+		"A continuación se indican las licencias de software de los paquetes utilizados.",
+	"pages.licenseList.packageIntroduction":
+		"Paquetes utilizados bajo esta licencia:",
 	"pages.news.edit.title.default": "Editar artículo",
 	"pages.news.edit.title": "Editar {title}",
 	"pages.news.index.new": "Añadir noticias",
diff --git a/src/locales/uk.ts b/src/locales/uk.ts
index 5a7e65cd4f3..bf026778778 100644
--- a/src/locales/uk.ts
+++ b/src/locales/uk.ts
@@ -979,6 +979,7 @@ export default {
 	"global.sidebar.item.teacher": "Викладачі",
 	"global.sidebar.item.teams": "Команди",
 	"global.sidebar.item.training": "Поглиблене навчання",
+	"global.sidebar.item.licenses": "Ліцензії",
 	"global.skipLink.mainContent": "Перейти до основного вмісту",
 	"global.topbar.actions.alerts": "Сповіщення про стан",
 	"global.topbar.actions.qrCode": "Поділіться QR-кодом веб-сторінки",
@@ -1687,6 +1688,11 @@ export default {
 	"pages.files.overview.teamFiles": "Командні файли",
 	"pages.h5p.api.success.save": "Вміст успішно збережено.",
 	"pages.impressum.title": "Вихідні дані",
+	"pages.licenseList.title": "Список ліцензій на відкритий код",
+	"pages.licenseList.introduction":
+		"Ліцензії на програмне забезпечення для використаних пакетів наведені нижче.",
+	"pages.licenseList.packageIntroduction":
+		"Пакунки, що використовуються за цією ліцензією:",
 	"pages.news.edit.title.default": "Редагувати статтю",
 	"pages.news.edit.title": "Редагувати {title}",
 	"pages.news.index.new": "Додати новини",
diff --git a/src/modules/ui/layout/sidebar/SidebarItems.composable.ts b/src/modules/ui/layout/sidebar/SidebarItems.composable.ts
index 0a13344d73d..a078b2b9f7a 100644
--- a/src/modules/ui/layout/sidebar/SidebarItems.composable.ts
+++ b/src/modules/ui/layout/sidebar/SidebarItems.composable.ts
@@ -161,6 +161,13 @@ export const useSidebarItems = () => {
 			rel: "noopener",
 			testId: "sidebar-privacypolicy",
 		},
+		{
+			to: "/licenses",
+			title: "global.sidebar.item.licenses",
+			feature: "LICENSE_SUMMARY_URL",
+			featureValue: `${envConfigModule.getEnv.LICENSE_SUMMARY_URL}`,
+			testId: "sidebar-licenses",
+		},
 	]);
 
 	const systemLinks: SidebarSingleItem[] = [
diff --git a/src/modules/ui/layout/sidebar/SidebarItems.composable.unit.ts b/src/modules/ui/layout/sidebar/SidebarItems.composable.unit.ts
index 18cb06dcc64..08fb4d8562f 100644
--- a/src/modules/ui/layout/sidebar/SidebarItems.composable.unit.ts
+++ b/src/modules/ui/layout/sidebar/SidebarItems.composable.unit.ts
@@ -53,7 +53,7 @@ describe("SidebarItems Composable", () => {
 	it("should have correct amount of legal links", () => {
 		const { legalLinks } = setup();
 
-		expect(legalLinks.value).toHaveLength(4);
+		expect(legalLinks.value).toHaveLength(5);
 	});
 
 	it("should have correct amount of meta links", () => {
diff --git a/src/pages/LicenseList.page.unit.ts b/src/pages/LicenseList.page.unit.ts
new file mode 100644
index 00000000000..d00ea9670d0
--- /dev/null
+++ b/src/pages/LicenseList.page.unit.ts
@@ -0,0 +1,105 @@
+import EnvConfigModule from "@/store/env-config";
+import NotifierModule from "@/store/notifier";
+import { ENV_CONFIG_MODULE_KEY, NOTIFIER_MODULE_KEY } from "@/utils/inject";
+import { envsFactory } from "@@/tests/test-utils";
+import { createModuleMocks } from "@@/tests/test-utils/mock-store-module";
+import {
+	createTestingI18n,
+	createTestingVuetify,
+} from "@@/tests/test-utils/setup";
+import { flushPromises } from "@vue/test-utils";
+import axios from "axios";
+import { nextTick } from "vue";
+import {
+	VExpansionPanelText,
+	VExpansionPanelTitle,
+} from "vuetify/lib/components/index.mjs";
+import LicenseListPage from "./LicenseList.page.vue";
+
+jest.mock("axios");
+const mockAxios = jest.mocked(axios);
+mockAxios.get.mockResolvedValue({
+	data: {
+		"MIT-License": {
+			components: ["ComponentA@2.0.0", "ComponentB@1.2.3"],
+			licenseText: "MIT License Text",
+		},
+		"Apache-2.0": {
+			components: ["ComponentC@1.0.0"],
+			licenseText: "Apache License Text",
+		},
+	},
+});
+
+describe("LicenseList Page", () => {
+	const envs = envsFactory.build({
+		LICENSE_SUMMARY_URL: "https://license-summary-url",
+	});
+	const notifierModule = createModuleMocks(NotifierModule);
+
+	const setup = () => {
+		const envConfigModuleMock = createModuleMocks(EnvConfigModule, {
+			getEnv: { ...envs },
+		});
+
+		const wrapper = mount(LicenseListPage, {
+			global: {
+				plugins: [createTestingI18n(), createTestingVuetify()],
+				provide: {
+					[ENV_CONFIG_MODULE_KEY.valueOf()]: envConfigModuleMock,
+					[NOTIFIER_MODULE_KEY.valueOf()]: notifierModule,
+				},
+			},
+		});
+
+		return { wrapper };
+	};
+
+	it("should render the component", async () => {
+		const { wrapper } = setup();
+		await flushPromises();
+
+		expect(wrapper.exists()).toBe(true);
+		["MIT-License", "Apache-2.0", "pages.licenseList.title"].forEach(
+			(license) => {
+				expect(wrapper.text()).toContain(license);
+			}
+		);
+	});
+
+	it("should call the license summary url", () => {
+		setup();
+		expect(mockAxios.get).toHaveBeenCalledWith(envs.LICENSE_SUMMARY_URL);
+	});
+
+	it("should display error notification on error", async () => {
+		mockAxios.get.mockRejectedValueOnce(new Error("Error"));
+		setup();
+		await flushPromises();
+
+		expect(notifierModule.show).toHaveBeenCalled();
+	});
+
+	describe("when the license item is clicked", () => {
+		it("should display the component list", async () => {
+			const { wrapper } = setup();
+			await flushPromises();
+
+			const panelTitles = wrapper.findAllComponents(VExpansionPanelTitle);
+			const panelTexts = wrapper.findAllComponents(VExpansionPanelText);
+			expect(panelTitles).toHaveLength(2);
+			const [panelTitle1, panelTitle2] = panelTitles;
+			const [panelText1, panelText2] = panelTexts;
+
+			await panelTitle1.trigger("click");
+			await nextTick();
+			expect(panelText1.text()).toContain("MIT License Text");
+			expect(panelText1.text()).not.toContain("Apache License Text");
+
+			await panelTitle2.trigger("click");
+			await nextTick();
+			expect(panelText2.text()).not.toContain("MIT License Text");
+			expect(panelText2.text()).toContain("Apache License Text");
+		});
+	});
+});
diff --git a/src/pages/LicenseList.page.vue b/src/pages/LicenseList.page.vue
new file mode 100644
index 00000000000..6e97e0137e4
--- /dev/null
+++ b/src/pages/LicenseList.page.vue
@@ -0,0 +1,105 @@
+<template>
+	<v-container class="align-center d-flex justify-center w-100">
+		<div class="license-list w-100">
+			<h1 class="text-h3 d-flex justify-center">
+				{{ t("pages.licenseList.title") }}
+			</h1>
+			<p>{{ t("pages.licenseList.introduction") }}</p>
+
+			<v-expansion-panels multiple class="w-100 pb-9">
+				<v-expansion-panel
+					v-for="[name, item] in Object.entries(licenseData)"
+					:key="name"
+				>
+					<v-expansion-panel-title>
+						<div class="text-h4">{{ name }}</div>
+						<template v-slot:actions="{ expanded }">
+							<v-icon :icon="expanded ? mdiMinus : mdiPlus" />
+						</template>
+					</v-expansion-panel-title>
+					<v-expansion-panel-text>
+						<p style="white-space: pre-line">{{ item.licenseText }}</p>
+						<p class="font-weight-bold">
+							{{ t("pages.licenseList.packageIntroduction") }}
+						</p>
+						<div class="ga-2">
+							<v-chip
+								label
+								v-for="componentName in item.components"
+								:key="componentName"
+								class="ma-1"
+							>
+								{{ componentName }}
+							</v-chip>
+						</div>
+					</v-expansion-panel-text>
+				</v-expansion-panel>
+			</v-expansion-panels>
+		</div>
+	</v-container>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from "vue";
+import axios from "axios";
+import { useI18n } from "vue-i18n";
+import {
+	ENV_CONFIG_MODULE_KEY,
+	NOTIFIER_MODULE_KEY,
+	injectStrict,
+} from "@/utils/inject";
+import { mdiMinus, mdiPlus } from "@icons/material";
+
+const envConfigModule = injectStrict(ENV_CONFIG_MODULE_KEY);
+const notifierModule = injectStrict(NOTIFIER_MODULE_KEY);
+const { t } = useI18n();
+
+type LicenseDetails = {
+	components: string[];
+	licenseText: string;
+};
+type LicenseData = Record<string, LicenseDetails>;
+
+const licenseData = ref<LicenseData>({});
+
+const removeVersionNumbers = (data: LicenseData): LicenseData => {
+	const result: LicenseData = {};
+	for (const [licenseName, licenseDetails] of Object.entries(data)) {
+		const components = licenseDetails.components.map((component) => {
+			return component.replace(/@[\d.]+$/, "");
+		});
+
+		result[licenseName] = {
+			components: [...new Set(components)],
+			licenseText: licenseDetails.licenseText,
+		};
+	}
+	return result;
+};
+
+const fetchLicenseData = async () => {
+	try {
+		const licensesUrl = envConfigModule.getEnv.LICENSE_SUMMARY_URL;
+		if (!licensesUrl) throw new Error("License summary URL is not defined");
+
+		licenseData.value = removeVersionNumbers(
+			(await axios.get(licensesUrl as string)).data
+		);
+	} catch {
+		notifierModule.show({
+			text: t("error.load"),
+			status: "error",
+		});
+	}
+};
+
+onMounted(async () => {
+	fetchLicenseData();
+});
+</script>
+
+<style lang="scss" scoped>
+.license-list {
+	max-width: 720px;
+}
+</style>
diff --git a/src/router/routes.ts b/src/router/routes.ts
index 76820e98f58..f8016da1d76 100644
--- a/src/router/routes.ts
+++ b/src/router/routes.ts
@@ -353,6 +353,14 @@ export const routes: Readonly<RouteRecordRaw>[] = [
 		component: async () => (await import("@page-media-shelf")).MediaShelfPage,
 		name: "media-shelf",
 	},
+	{
+		path: "/licenses",
+		component: async () => await import("@/pages/LicenseList.page.vue"),
+		name: "licenses",
+		meta: {
+			isPublic: true,
+		},
+	},
 	{
 		path: "/",
 		component: () => import("@/pages/Home.page.vue"),
diff --git a/src/router/vue-client-route.js b/src/router/vue-client-route.js
index e9c74a12c82..7c9df3bd9ca 100644
--- a/src/router/vue-client-route.js
+++ b/src/router/vue-client-route.js
@@ -36,6 +36,7 @@ const vueRoutes = [
 	`^/h5p/editor/?$`,
 	`^/h5p/editor/${h5pId}/?$`,
 	`^/imprint/?$`,
+	`^/licenses/?$`,
 	`^/migration/?$`,
 	`^/migration/success/?$`,
 	`^/migration/error/?$`,
diff --git a/src/store/env-config-defaults.ts b/src/store/env-config-defaults.ts
index ab7d128183c..d331ec3f8ba 100644
--- a/src/store/env-config-defaults.ts
+++ b/src/store/env-config-defaults.ts
@@ -75,4 +75,5 @@ export const defaultConfigEnvs: ConfigResponse = {
 	FEATURE_PREFERRED_CTL_TOOLS_ENABLED: false,
 	FEATURE_EXTERNAL_SYSTEM_LOGOUT_ENABLED: false,
 	FEATURE_VIDIS_MEDIA_ACTIVATIONS_ENABLED: false,
+	LICENSE_SUMMARY_URL: "",
 };


Reviews:
user: login: hoeppner-dataport
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T08:31:54Z
user: login: hoeppner-dataport
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T15:18:54Z
user: login: hoeppner-dataport
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T15:23:49Z
user: login: hoeppner-dataport
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T15:44:52Z

Review comments:
diff_hunk: @@ -0,0 +1,118 @@
+<template>
+	<div class="d-flex justify-center">
+		<div class="license-list">
+			<h1 class="text-h3 d-flex justify-center">
+				{{ t("pages.licenseList.title") }}
+			</h1>
+
+			<div v-if="licenseNames.length > 0" class="d-flex px-4 pl-7">
+				<div>{{ t("pages.licenseList.name") }}</div>
+				<v-spacer />
+				<div>{{ t("pages.licenseList.componentCount") }}</div>
+			</div>
+
+			<VTreeview
+				:items="licenseList"
+				:open-on-click="true"
+				:load-children="onExpand"
+			>
+				<template #title="{ title }">
+					<span data-testid="license-title">{{ title }}</span>
+				</template>
+
+				<template #append="{ item }">
+					<span class="ml-4">{{ item.count }}</span>
+				</template>
+			</VTreeview>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from "vue";
+import axios from "axios";
+import { VTreeview } from "vuetify/labs/VTreeview";
+import { useI18n } from "vue-i18n";
+import {
+	ENV_CONFIG_MODULE_KEY,
+	NOTIFIER_MODULE_KEY,
+	injectStrict,
+} from "@/utils/inject";
+
+const envConfigModule = injectStrict(ENV_CONFIG_MODULE_KEY);
+const notifierModule = injectStrict(NOTIFIER_MODULE_KEY);
+const { t } = useI18n();
+
+type LicenseData = {
+	[key: string]: {
+		components: string[];
+		licenseText: string;
+	};
+};
+
+type TreeViewItem = {
+	id: number;
+	title: string;
+	children?: { id: number; title: string }[];
+	count?: number;
+};
+
+const response = ref<LicenseData>({});
+const licenseNames = ref<string[]>([]);
+const licenseList = ref<TreeViewItem[]>([]);
+
+const onExpand = async (args: unknown) => {
+	const payload = args as TreeViewItem;
+
+	const item = licenseList.value.find(
+		(license) => license.title === payload.title
+	);
+	if (item?.title !== payload.title) return;
+	if (item.children && item.children.length > 0) return;
+
+	let i = 0;
+	item.children = response.value[payload.title].components?.map((component) => {
+		return {
+			id: i++,
+			title: component,
+		};
+	});
+};
+
+const fetchLicenseData = async () => {
+	try {
+		const licensesUrl = envConfigModule.getEnv.LICENSE_SUMMARY_URL;
+		if (!licensesUrl) throw new Error("License summary URL is not	defined");
path: src/pages/LicenseList.page.vue
user: login: hoeppner-dataport
body: some additional spaces in the error message, that should be removed ;)
created_at: 2025-03-20T08:31:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,118 @@
+<template>
+	<div class="d-flex justify-center">
+		<div class="license-list">
+			<h1 class="text-h3 d-flex justify-center">
+				{{ t("pages.licenseList.title") }}
+			</h1>
+
+			<div v-if="licenseNames.length > 0" class="d-flex px-4 pl-7">
+				<div>{{ t("pages.licenseList.name") }}</div>
+				<v-spacer />
+				<div>{{ t("pages.licenseList.componentCount") }}</div>
+			</div>
+
+			<VTreeview
+				:items="licenseList"
+				:open-on-click="true"
+				:load-children="onExpand"
+			>
+				<template #title="{ title }">
+					<span data-testid="license-title">{{ title }}</span>
+				</template>
+
+				<template #append="{ item }">
+					<span class="ml-4">{{ item.count }}</span>
+				</template>
+			</VTreeview>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from "vue";
+import axios from "axios";
+import { VTreeview } from "vuetify/labs/VTreeview";
+import { useI18n } from "vue-i18n";
+import {
+	ENV_CONFIG_MODULE_KEY,
+	NOTIFIER_MODULE_KEY,
+	injectStrict,
+} from "@/utils/inject";
+
+const envConfigModule = injectStrict(ENV_CONFIG_MODULE_KEY);
+const notifierModule = injectStrict(NOTIFIER_MODULE_KEY);
+const { t } = useI18n();
+
+type LicenseData = {
+	[key: string]: {
+		components: string[];
+		licenseText: string;
+	};
+};
+
+type TreeViewItem = {
+	id: number;
+	title: string;
+	children?: { id: number; title: string }[];
+	count?: number;
+};
+
+const response = ref<LicenseData>({});
+const licenseNames = ref<string[]>([]);
+const licenseList = ref<TreeViewItem[]>([]);
+
+const onExpand = async (args: unknown) => {
+	const payload = args as TreeViewItem;
+
+	const item = licenseList.value.find(
+		(license) => license.title === payload.title
+	);
+	if (item?.title !== payload.title) return;
+	if (item.children && item.children.length > 0) return;
+
+	let i = 0;
+	item.children = response.value[payload.title].components?.map((component) => {
+		return {
+			id: i++,
+			title: component,
+		};
+	});
+};
+
+const fetchLicenseData = async () => {
+	try {
+		const licensesUrl = envConfigModule.getEnv.LICENSE_SUMMARY_URL;
+		if (!licensesUrl) throw new Error("License summary URL is not	defined");
+
+		response.value = (await axios.get(licensesUrl as string)).data;
+		licenseNames.value = Object.keys(response.value);
+	} catch {
+		notifierModule.show({
+			text: t("error.load"),
+			status: "error",
+		});
+	}
+
+	if (response.value && licenseNames.value.length > 0) {
+		let i = 0;
+		licenseNames.value.forEach((license) => {
+			licenseList.value.push({
+				id: i++,
+				title: license,
+				count: response.value[license].components.length,
+				children: [],
+			});
+		});
path: src/pages/LicenseList.page.vue
user: login: hoeppner-dataport
body: you could avoid introducing i by using the index-parameter
```suggestion
		licenseNames.value.forEach((license, index) => {
			licenseList.value.push({
				id: index,
				title: license,
				count: response.value[license].components.length,
				children: [],
			});
		});
```
created_at: 2025-03-20T15:18:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,118 @@
+<template>
+	<div class="d-flex justify-center">
+		<div class="license-list">
+			<h1 class="text-h3 d-flex justify-center">
+				{{ t("pages.licenseList.title") }}
+			</h1>
+
+			<div v-if="licenseNames.length > 0" class="d-flex px-4 pl-7">
+				<div>{{ t("pages.licenseList.name") }}</div>
+				<v-spacer />
+				<div>{{ t("pages.licenseList.componentCount") }}</div>
+			</div>
+
+			<VTreeview
+				:items="licenseList"
+				:open-on-click="true"
+				:load-children="onExpand"
+			>
+				<template #title="{ title }">
+					<span data-testid="license-title">{{ title }}</span>
+				</template>
+
+				<template #append="{ item }">
+					<span class="ml-4">{{ item.count }}</span>
+				</template>
+			</VTreeview>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from "vue";
+import axios from "axios";
+import { VTreeview } from "vuetify/labs/VTreeview";
+import { useI18n } from "vue-i18n";
+import {
+	ENV_CONFIG_MODULE_KEY,
+	NOTIFIER_MODULE_KEY,
+	injectStrict,
+} from "@/utils/inject";
+
+const envConfigModule = injectStrict(ENV_CONFIG_MODULE_KEY);
+const notifierModule = injectStrict(NOTIFIER_MODULE_KEY);
+const { t } = useI18n();
+
+type LicenseData = {
+	[key: string]: {
+		components: string[];
+		licenseText: string;
+	};
+};
+
+type TreeViewItem = {
+	id: number;
+	title: string;
+	children?: { id: number; title: string }[];
+	count?: number;
+};
+
+const response = ref<LicenseData>({});
+const licenseNames = ref<string[]>([]);
+const licenseList = ref<TreeViewItem[]>([]);
+
+const onExpand = async (args: unknown) => {
+	const payload = args as TreeViewItem;
+
+	const item = licenseList.value.find(
+		(license) => license.title === payload.title
+	);
+	if (item?.title !== payload.title) return;
+	if (item.children && item.children.length > 0) return;
+
+	let i = 0;
+	item.children = response.value[payload.title].components?.map((component) => {
+		return {
+			id: i++,
+			title: component,
+		};
+	});
path: src/pages/LicenseList.page.vue
user: login: hoeppner-dataport
body: you could avoid introducing i by using the index-parameter
```suggestion
	item.children = response.value[payload.title].components?.map((component, index) => ({
		id: index,
		title: component,
	}));
```
created_at: 2025-03-20T15:23:48Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-03-28T15:49:02Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=hpi-schul-cloud_nuxt-client&pullRequest=3603) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=hpi-schul-cloud_nuxt-client&pullRequest=3603&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=hpi-schul-cloud_nuxt-client&pullRequest=3603&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=hpi-schul-cloud_nuxt-client&pullRequest=3603&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [98.4% Coverage on New Code](https://sonarcloud.io/component_measures?id=hpi-schul-cloud_nuxt-client&pullRequest=3603&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=hpi-schul-cloud_nuxt-client&pullRequest=3603&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=hpi-schul-cloud_nuxt-client&pullRequest=3603)


author_association: NONE