From da9c47848d1ec21d3c30496d8405c041ff11c1fa Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Tue, 24 Feb 2026 12:25:37 +0100 Subject: [PATCH 01/39] fix postgres --- docker/docker-compose.dev.yaml | 2 +- docker/docker-compose.prod.yaml | 2 +- docker/docker-compose.staging.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.dev.yaml b/docker/docker-compose.dev.yaml index d1ced2f6..1572cd21 100644 --- a/docker/docker-compose.dev.yaml +++ b/docker/docker-compose.dev.yaml @@ -9,7 +9,7 @@ services: ports: - "${DB_PORT}:5432" volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql networks: - module-management-network restart: unless-stopped diff --git a/docker/docker-compose.prod.yaml b/docker/docker-compose.prod.yaml index 294f7cb9..d2712ffe 100644 --- a/docker/docker-compose.prod.yaml +++ b/docker/docker-compose.prod.yaml @@ -26,7 +26,7 @@ services: ports: - "${DB_PORT}:5432" volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql networks: - module-management-network restart: unless-stopped diff --git a/docker/docker-compose.staging.yaml b/docker/docker-compose.staging.yaml index 13ceced0..c62d682f 100644 --- a/docker/docker-compose.staging.yaml +++ b/docker/docker-compose.staging.yaml @@ -27,7 +27,7 @@ services: ports: - "${DB_PORT}:5432" volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql networks: - module-management-network restart: unless-stopped From ea0ecb156d645615e31415cddfad93086109ad4d Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Sun, 1 Mar 2026 17:15:12 +0100 Subject: [PATCH 02/39] managing degree programs and specializations --- Client/environments/environment.ts | 2 +- Client/src/app/app.routes.ts | 11 +- .../breadcrumb/breadcrumb-labels.service.ts | 14 + .../breadcrumb/breadcrumb.component.ts | 49 +- .../create-edit-base.component.html | 2 +- .../side-bar/side-bar.component.html | 8 + .../users-select/users-select.component.html | 26 + .../users-select/users-select.component.ts | 112 ++++ .../modules/openapi/.openapi-generator/FILES | 12 + .../src/app/core/modules/openapi/api/api.ts | 8 +- ...gram-specializations-controller.service.ts | 371 +++++++++++ ...ializations-controller.serviceInterface.ts | 55 ++ .../api/degree-programs-controller.service.ts | 580 ++++++++++++++++++ ...ee-programs-controller.serviceInterface.ts | 79 +++ ...d-specializations-to-degree-program-dto.ts | 15 + .../model/create-degree-program-dto.ts | 16 + ...reate-degree-program-specialization-dto.ts | 16 + .../openapi/model/degree-program-dto.ts | 20 + .../degree-program-specialization-dto.ts | 18 + .../app/core/modules/openapi/model/models.ts | 8 + .../openapi/model/responsible-user-dto.ts | 18 + .../model/update-degree-program-dto.ts | 16 + ...pdate-degree-program-specialization-dto.ts | 16 + .../pages/admin/admin-layout.component.html | 29 - .../app/pages/admin/admin-layout.component.ts | 18 - .../all-specializations-page.component.html | 63 ++ .../all-specializations-page.component.ts | 109 ++++ .../all-degree-programs-page.component.html | 63 ++ .../all-degree-programs-page.component.ts | 88 +++ ...degree-program-details-page.component.html | 109 ++++ .../degree-program-details-page.component.ts | 192 ++++++ ...mponent.html => users-page.component.html} | 5 +- ...e.component.ts => users-page.component.ts} | 22 +- .../feedback-view/feedback-view.component.ts | 15 +- .../src/app/pages/index/index.component.html | 4 + .../module-version-edit.component.ts | 14 +- .../module-version-view.component.ts | 18 +- .../proposal-view.component.html | 2 +- .../proposal-view/proposal-view.component.ts | 16 +- ...egreeProgramSpecializationsController.java | 45 ++ .../controllers/DegreeProgramsController.java | 64 ++ .../AddSpecializationsToDegreeProgramDTO.java | 19 + .../ls1/dtos/CreateDegreeProgramDTO.java | 15 + .../CreateDegreeProgramSpecializationDTO.java | 15 + .../ls1/dtos/DegreeProgramDTO.java | 34 + .../dtos/DegreeProgramSpecializationDTO.java | 25 + .../ls1/dtos/ResponsibleUserDTO.java | 23 + .../ls1/dtos/UpdateDegreeProgramDTO.java | 11 + .../UpdateDegreeProgramSpecializationDTO.java | 11 + .../ls1/models/DegreeProgram.java | 32 + .../models/DegreeProgramSpecialization.java | 30 + .../repositories/DegreeProgramRepository.java | 22 + ...DegreeProgramSpecializationRepository.java | 23 + .../ls1/services/AdminUserService.java | 1 - .../ls1/services/AuthenticationService.java | 2 - .../ls1/services/DegreeProgramService.java | 106 ++++ .../DegreeProgramSpecializationService.java | 60 ++ .../0008_degree_program_and_area_tables.yaml | 83 +++ .../main/resources/db/changelog/master.yaml | 3 + 59 files changed, 2776 insertions(+), 87 deletions(-) create mode 100644 Client/src/app/components/breadcrumb/breadcrumb-labels.service.ts create mode 100644 Client/src/app/components/users-select/users-select.component.html create mode 100644 Client/src/app/components/users-select/users-select.component.ts create mode 100644 Client/src/app/core/modules/openapi/api/degree-program-specializations-controller.service.ts create mode 100644 Client/src/app/core/modules/openapi/api/degree-program-specializations-controller.serviceInterface.ts create mode 100644 Client/src/app/core/modules/openapi/api/degree-programs-controller.service.ts create mode 100644 Client/src/app/core/modules/openapi/api/degree-programs-controller.serviceInterface.ts create mode 100644 Client/src/app/core/modules/openapi/model/add-specializations-to-degree-program-dto.ts create mode 100644 Client/src/app/core/modules/openapi/model/create-degree-program-dto.ts create mode 100644 Client/src/app/core/modules/openapi/model/create-degree-program-specialization-dto.ts create mode 100644 Client/src/app/core/modules/openapi/model/degree-program-dto.ts create mode 100644 Client/src/app/core/modules/openapi/model/degree-program-specialization-dto.ts create mode 100644 Client/src/app/core/modules/openapi/model/responsible-user-dto.ts create mode 100644 Client/src/app/core/modules/openapi/model/update-degree-program-dto.ts create mode 100644 Client/src/app/core/modules/openapi/model/update-degree-program-specialization-dto.ts delete mode 100644 Client/src/app/pages/admin/admin-layout.component.html delete mode 100644 Client/src/app/pages/admin/admin-layout.component.ts create mode 100644 Client/src/app/pages/admin/degree-program-specializations/all-specializations-page.component.html create mode 100644 Client/src/app/pages/admin/degree-program-specializations/all-specializations-page.component.ts create mode 100644 Client/src/app/pages/admin/degree-programs/all-degree-programs-page.component.html create mode 100644 Client/src/app/pages/admin/degree-programs/all-degree-programs-page.component.ts create mode 100644 Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.html create mode 100644 Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.ts rename Client/src/app/pages/admin/users/{admin-users-page.component.html => users-page.component.html} (93%) rename Client/src/app/pages/admin/users/{admin-users-page.component.ts => users-page.component.ts} (83%) create mode 100644 Server/src/main/java/modulemanagement/ls1/controllers/DegreeProgramSpecializationsController.java create mode 100644 Server/src/main/java/modulemanagement/ls1/controllers/DegreeProgramsController.java create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/AddSpecializationsToDegreeProgramDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/CreateDegreeProgramDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/CreateDegreeProgramSpecializationDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/DegreeProgramDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/DegreeProgramSpecializationDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/ResponsibleUserDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/UpdateDegreeProgramDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/UpdateDegreeProgramSpecializationDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/models/DegreeProgram.java create mode 100644 Server/src/main/java/modulemanagement/ls1/models/DegreeProgramSpecialization.java create mode 100644 Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java create mode 100644 Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramSpecializationRepository.java create mode 100644 Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java create mode 100644 Server/src/main/java/modulemanagement/ls1/services/DegreeProgramSpecializationService.java create mode 100644 Server/src/main/resources/db/changelog/changes/0008_degree_program_and_area_tables.yaml diff --git a/Client/environments/environment.ts b/Client/environments/environment.ts index 1001a4a4..d066a11b 100644 --- a/Client/environments/environment.ts +++ b/Client/environments/environment.ts @@ -3,7 +3,7 @@ export const environment = { redirect: 'https://module.aet.cit.tum.de', serverUrl: 'https://module.aet.cit.tum.de', keycloak: { - url: ' https://keycloak.ase.in.tum.de/', + url: 'https://keycloak.ase.in.tum.de', realm: 'tum', clientId: 'module-management' } diff --git a/Client/src/app/app.routes.ts b/Client/src/app/app.routes.ts index 64f2117e..fac7c6c4 100644 --- a/Client/src/app/app.routes.ts +++ b/Client/src/app/app.routes.ts @@ -13,8 +13,10 @@ import { SimilarModulesPage } from './pages/similar-modules/similar-modules.comp import { AccountLayoutComponent } from './pages/account-management/account-layout/account-layout.component'; import { AccountInformationComponent } from './pages/account-management/account-information/account-information.component'; import { AccountPasskeysComponent } from './pages/account-management/passkeys/account-passkeys.component'; -import { AdminUsersPageComponent } from './pages/admin/users/admin-users-page.component'; - +import { UsersPageComponent } from './pages/admin/users/users-page.component'; +import { AllDegreeProgramsPageComponent } from './pages/admin/degree-programs/all-degree-programs-page.component'; +import { DegreeProgramDetailsPageComponent } from './pages/admin/degree-programs/degree-program-details-page.component'; +import { AllSpecializationsPageComponent } from './pages/admin/degree-program-specializations/all-specializations-page.component'; export const routes: Routes = [ { path: '', component: IndexComponent }, { @@ -52,7 +54,10 @@ export const routes: Routes = [ path: 'admin', canActivate: [AuthGuard, AdminGuard], children: [ - { path: 'users', component: AdminUsersPageComponent }, + { path: 'users', component: UsersPageComponent }, + { path: 'degree-programs/specializations', component: AllSpecializationsPageComponent }, + { path: 'degree-programs/:id', component: DegreeProgramDetailsPageComponent }, + { path: 'degree-programs', component: AllDegreeProgramsPageComponent }, { path: '', redirectTo: 'users', pathMatch: 'full' } ] } diff --git a/Client/src/app/components/breadcrumb/breadcrumb-labels.service.ts b/Client/src/app/components/breadcrumb/breadcrumb-labels.service.ts new file mode 100644 index 00000000..071fb279 --- /dev/null +++ b/Client/src/app/components/breadcrumb/breadcrumb-labels.service.ts @@ -0,0 +1,14 @@ +import { Injectable, signal } from '@angular/core'; + +/** Segment-specific breadcrumb labels. Pages set the relevant signal when they load data and set to null on destroy. */ +@Injectable({ providedIn: 'root' }) +export class BreadcrumbLabelsService { + /** Degree program details page: program name. */ + readonly degreeProgramName = signal(null); + /** Proposal/view segment: module title (e.g. from latestModuleVersion.titleEng). */ + readonly proposalTitle = signal(null); + /** Version segment: e.g. "Version 2" from moduleVersion.version. */ + readonly versionLabel = signal(null); + /** Feedback view segment: e.g. module title from the feedback's module version. */ + readonly feedbackLabel = signal(null); +} diff --git a/Client/src/app/components/breadcrumb/breadcrumb.component.ts b/Client/src/app/components/breadcrumb/breadcrumb.component.ts index bfe558dc..50e4b2f2 100644 --- a/Client/src/app/components/breadcrumb/breadcrumb.component.ts +++ b/Client/src/app/components/breadcrumb/breadcrumb.component.ts @@ -3,7 +3,7 @@ import { Router, RouterModule, NavigationEnd } from '@angular/router'; import { filter } from 'rxjs/operators'; import { BreadcrumbModule } from 'primeng/breadcrumb'; import type { MenuItem } from 'primeng/api'; -import { SecurityStore } from '../../core/security/security-store.service'; +import { BreadcrumbLabelsService } from './breadcrumb-labels.service'; @Component({ selector: 'app-breadcrumb', @@ -13,7 +13,7 @@ import { SecurityStore } from '../../core/security/security-store.service'; }) export class BreadcrumbComponent { private router = inject(Router); - private securityStore = inject(SecurityStore); + private breadcrumbLabels = inject(BreadcrumbLabelsService); private url = signal(this.router.url); @@ -27,15 +27,49 @@ export class BreadcrumbComponent { showBreadcrumb = computed(() => { const u = this.url(); - return u.startsWith('/proposals') || u.startsWith('/feedbacks'); + return u.startsWith('/proposals') || u.startsWith('/feedbacks') || u.startsWith('/admin'); }); private buildItems(url: string): MenuItem[] { if (url.startsWith('/proposals')) return this.buildProposalItems(url); if (url.startsWith('/feedbacks')) return this.buildFeedbackItems(url); + if (url.startsWith('/admin')) return this.buildAdminItems(url); return []; } + private buildAdminItems(url: string): MenuItem[] { + const segments = url.split('/').filter(Boolean); // ['admin', 'degree-programs', ...] + const items: MenuItem[] = []; + + if (segments.length < 2) return items; + + if (segments[1] === 'users') { + items.push({ label: 'Users', routerLink: ['/admin/users'] }); + return items; + } + + if (segments[1] === 'degree-programs') { + items.push({ label: 'Degree Programs', routerLink: ['/admin/degree-programs'] }); + if (segments.length <= 2) return items; + + if (segments[2] === 'specializations') { + items.push({ label: 'All areas of specializations', routerLink: ['/admin/degree-programs/specializations'] }); + return items; + } + + // degree-programs/:id (program details page) – label from details page when it loads the program + const programId = segments[2]; + const name = (this.breadcrumbLabels.degreeProgramName() ?? '').trim() || `Program ${programId}`; + items.push({ + label: name, + routerLink: ['/admin/degree-programs', programId] + }); + return items; + } + + return items; + } + private buildProposalItems(url: string): MenuItem[] { const segments = url.split('/').filter(Boolean); // ['proposals', ...] const items: MenuItem[] = []; @@ -53,8 +87,9 @@ export class BreadcrumbComponent { if (segments[1] === 'view' && segments[2]) { const proposalId = segments[2]; + const proposalLabel = (this.breadcrumbLabels.proposalTitle() ?? '').trim() || `Proposal ${proposalId}`; items.push({ - label: 'Proposal ' + proposalId, + label: proposalLabel, routerLink: ['/proposals/view', proposalId] }); @@ -62,8 +97,9 @@ export class BreadcrumbComponent { if (segments[3] === 'version' && segments[4]) { const versionId = segments[4]; + const versionSegmentLabel = (this.breadcrumbLabels.versionLabel() ?? '').trim() || `Version ${versionId}`; items.push({ - label: 'Version ' + versionId, + label: versionSegmentLabel, routerLink: ['/proposals/view', proposalId, 'version', versionId] }); @@ -97,7 +133,8 @@ export class BreadcrumbComponent { } if (segments[1] === 'view' && segments[2]) { - items.push({ label: 'Feedback ' + segments[2], routerLink: ['/feedbacks/view', segments[2]] }); + const label = (this.breadcrumbLabels.feedbackLabel() ?? '').trim() || `Feedback ${segments[2]}`; + items.push({ label, routerLink: ['/feedbacks/view', segments[2]] }); } if (segments[3] === 'overlap' && segments[4]) { diff --git a/Client/src/app/components/create-edit-base/create-edit-base.component.html b/Client/src/app/components/create-edit-base/create-edit-base.component.html index dc4fcac5..d89c2ef4 100644 --- a/Client/src/app/components/create-edit-base/create-edit-base.component.html +++ b/Client/src/app/components/create-edit-base/create-edit-base.component.html @@ -511,7 +511,7 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for '
Cancel - + {{ loading() ? 'Saving...' : moduleVersionDto() ? 'Update Proposal' : 'Create Proposal' }}
diff --git a/Client/src/app/components/side-bar/side-bar.component.html b/Client/src/app/components/side-bar/side-bar.component.html index e6fb7ffc..f7007f4f 100644 --- a/Client/src/app/components/side-bar/side-bar.component.html +++ b/Client/src/app/components/side-bar/side-bar.component.html @@ -9,6 +9,14 @@ [style]="{ width: '100%' }" styleClass="sidebar-btn justify-start" /> + } @if (isProfessor()) { + + @if (loading()) { +
+ +
+ } +
+ diff --git a/Client/src/app/components/users-select/users-select.component.ts b/Client/src/app/components/users-select/users-select.component.ts new file mode 100644 index 00000000..3630113f --- /dev/null +++ b/Client/src/app/components/users-select/users-select.component.ts @@ -0,0 +1,112 @@ +import { Component, inject, signal, computed, input } from '@angular/core'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; +import { SelectModule, SelectLazyLoadEvent } from 'primeng/select'; +import { Subject, debounceTime, distinctUntilChanged, firstValueFrom } from 'rxjs'; +import { AdminUserControllerService } from '../../core/modules/openapi'; +import type { UserDTO } from '../../core/modules/openapi/model/user-dto'; +import type { ResponsibleUserDTO } from '../../core/modules/openapi/model/responsible-user-dto'; + +const USER_PAGE_SIZE = 20; +const FILTER_DEBOUNCE_MS = 500; + +@Component({ + selector: 'app-users-select', + standalone: true, + imports: [FormsModule, ProgressSpinnerModule, SelectModule], + templateUrl: './users-select.component.html', + providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: UsersSelectComponent, multi: true }] +}) +export class UsersSelectComponent implements ControlValueAccessor { + private readonly adminUserControllerService = inject(AdminUserControllerService); + + private readonly filter$ = new Subject(); + + styleClass = input(''); + /** When the selected user is not yet in the loaded list, pass this so they are displayed like other options. */ + selectedUser = input(null); + + constructor() { + this.loadUsers(); + this.filter$.pipe(debounceTime(FILTER_DEBOUNCE_MS), distinctUntilChanged()).subscribe((term) => { + this.searchTerm.set(term); + this.loadUsers(0); + }); + } + + value = signal(null); + users = signal([]); + totalRecords = signal(0); + loading = signal(false); + searchTerm = signal(''); + + private onTouched = () => {}; + private onChange: (v: string | null) => void = () => {}; + + formatUserLabel(u: { firstName?: string; lastName?: string; email?: string; userId?: string }): string { + return `${u.firstName ?? ''} ${u.lastName ?? ''} (${u.email ?? u.userId ?? ''})`.trim() || String(u.userId ?? ''); + } + + userOptions = computed(() => { + const list = this.users().map((u) => ({ + label: this.formatUserLabel(u), + value: u.userId + })); + const current = this.value(); + if (current && !list.some((o) => o.value === current)) { + const u = this.selectedUser(); + const label = u ? this.formatUserLabel(u) : current; + return [{ label, value: current }, ...list]; + } + return list; + }); + + writeValue(v: string | null): void { + this.value.set(v); + } + + registerOnChange(fn: (v: string | null) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + onValueChange(v: string | null): void { + this.value.set(v); + this.onChange(v); + this.onTouched(); + } + + async loadUsers(page = 0) { + if (this.loading()) return; + this.loading.set(true); + try { + const res = await firstValueFrom(this.adminUserControllerService.getUsers(page, USER_PAGE_SIZE, this.searchTerm().trim() || undefined)); + this.totalRecords.set(res.totalElements ?? 0); + if (page === 0) { + this.users.set(res.content ?? []); + } else { + this.users.update((prev) => [...prev, ...(res.content ?? [])]); + } + } catch (e) { + this.users.set([]); + this.totalRecords.set(0); + } finally { + this.loading.set(false); + } + } + + async onLazyLoad(event: SelectLazyLoadEvent): Promise { + if (this.totalRecords() > 0 && this.users().length >= this.totalRecords()) return; + if (event.last < this.users().length) return; + + const page = Math.floor(this.users().length / USER_PAGE_SIZE); + await this.loadUsers(page); + } + + onFilter(event: { filter: string }): void { + this.filter$.next(event.filter ?? ''); + } +} diff --git a/Client/src/app/core/modules/openapi/.openapi-generator/FILES b/Client/src/app/core/modules/openapi/.openapi-generator/FILES index b45a5429..da40739c 100644 --- a/Client/src/app/core/modules/openapi/.openapi-generator/FILES +++ b/Client/src/app/core/modules/openapi/.openapi-generator/FILES @@ -5,6 +5,10 @@ api.module.ts api/admin-user-controller.service.ts api/admin-user-controller.serviceInterface.ts api/api.ts +api/degree-program-specializations-controller.service.ts +api/degree-program-specializations-controller.serviceInterface.ts +api/degree-programs-controller.service.ts +api/degree-programs-controller.serviceInterface.ts api/feedback-controller.service.ts api/feedback-controller.serviceInterface.ts api/module-version-controller.service.ts @@ -18,8 +22,13 @@ encoder.ts git_push.sh index.ts model/add-module-version-dto.ts +model/add-specializations-to-degree-program-dto.ts model/completion-service-request-dto.ts model/completion-service-response-dto.ts +model/create-degree-program-dto.ts +model/create-degree-program-specialization-dto.ts +model/degree-program-dto.ts +model/degree-program-specialization-dto.ts model/feedback-dto.ts model/feedback-list-item-dto.ts model/feedback.ts @@ -36,7 +45,10 @@ model/proposal-request-dto.ts model/proposal-view-dto.ts model/proposal.ts model/proposals-compact-dto.ts +model/responsible-user-dto.ts model/similar-module-dto.ts +model/update-degree-program-dto.ts +model/update-degree-program-specialization-dto.ts model/update-user-role-dto.ts model/user-dto.ts model/user.ts diff --git a/Client/src/app/core/modules/openapi/api/api.ts b/Client/src/app/core/modules/openapi/api/api.ts index e3480da6..80159f7c 100644 --- a/Client/src/app/core/modules/openapi/api/api.ts +++ b/Client/src/app/core/modules/openapi/api/api.ts @@ -1,6 +1,12 @@ export * from './admin-user-controller.service'; import { AdminUserControllerService } from './admin-user-controller.service'; export * from './admin-user-controller.serviceInterface'; +export * from './degree-program-specializations-controller.service'; +import { DegreeProgramSpecializationsControllerService } from './degree-program-specializations-controller.service'; +export * from './degree-program-specializations-controller.serviceInterface'; +export * from './degree-programs-controller.service'; +import { DegreeProgramsControllerService } from './degree-programs-controller.service'; +export * from './degree-programs-controller.serviceInterface'; export * from './feedback-controller.service'; import { FeedbackControllerService } from './feedback-controller.service'; export * from './feedback-controller.serviceInterface'; @@ -13,4 +19,4 @@ export * from './proposal-controller.serviceInterface'; export * from './user-controller.service'; import { UserControllerService } from './user-controller.service'; export * from './user-controller.serviceInterface'; -export const APIS = [AdminUserControllerService, FeedbackControllerService, ModuleVersionControllerService, ProposalControllerService, UserControllerService]; +export const APIS = [AdminUserControllerService, DegreeProgramSpecializationsControllerService, DegreeProgramsControllerService, FeedbackControllerService, ModuleVersionControllerService, ProposalControllerService, UserControllerService]; diff --git a/Client/src/app/core/modules/openapi/api/degree-program-specializations-controller.service.ts b/Client/src/app/core/modules/openapi/api/degree-program-specializations-controller.service.ts new file mode 100644 index 00000000..babd8939 --- /dev/null +++ b/Client/src/app/core/modules/openapi/api/degree-program-specializations-controller.service.ts @@ -0,0 +1,371 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +/* tslint:disable:no-unused-variable member-ordering */ + +import { Inject, Injectable, Optional } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams, + HttpResponse, HttpEvent, HttpParameterCodec, HttpContext + } from '@angular/common/http'; +import { CustomHttpParameterCodec } from '../encoder'; +import { Observable } from 'rxjs'; + +// @ts-ignore +import { CreateDegreeProgramSpecializationDTO } from '../model/create-degree-program-specialization-dto'; +// @ts-ignore +import { DegreeProgramSpecializationDTO } from '../model/degree-program-specialization-dto'; +// @ts-ignore +import { UpdateDegreeProgramSpecializationDTO } from '../model/update-degree-program-specialization-dto'; + +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; +import { Configuration } from '../configuration'; +import { + DegreeProgramSpecializationsControllerServiceInterface +} from './degree-program-specializations-controller.serviceInterface'; + + + +@Injectable({ + providedIn: 'root' +}) +export class DegreeProgramSpecializationsControllerService implements DegreeProgramSpecializationsControllerServiceInterface { + + protected basePath = 'http://localhost:8080'; + public defaultHeaders = new HttpHeaders(); + public configuration = new Configuration(); + public encoder: HttpParameterCodec; + + constructor(protected httpClient: HttpClient, @Optional()@Inject(BASE_PATH) basePath: string|string[], @Optional() configuration: Configuration) { + if (configuration) { + this.configuration = configuration; + } + if (typeof this.configuration.basePath !== 'string') { + const firstBasePath = Array.isArray(basePath) ? basePath[0] : undefined; + if (firstBasePath != undefined) { + basePath = firstBasePath; + } + + if (typeof basePath !== 'string') { + basePath = this.basePath; + } + this.configuration.basePath = basePath; + } + this.encoder = this.configuration.encoder || new CustomHttpParameterCodec(); + } + + + // @ts-ignore + private addToHttpParams(httpParams: HttpParams, value: any, key?: string): HttpParams { + if (typeof value === "object" && value instanceof Date === false) { + httpParams = this.addToHttpParamsRecursive(httpParams, value); + } else { + httpParams = this.addToHttpParamsRecursive(httpParams, value, key); + } + return httpParams; + } + + private addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string): HttpParams { + if (value == null) { + return httpParams; + } + + if (typeof value === "object") { + if (Array.isArray(value)) { + (value as any[]).forEach( elem => httpParams = this.addToHttpParamsRecursive(httpParams, elem, key)); + } else if (value instanceof Date) { + if (key != null) { + httpParams = httpParams.append(key, (value as Date).toISOString().substring(0, 10)); + } else { + throw Error("key may not be null if value is Date"); + } + } else { + Object.keys(value).forEach( k => httpParams = this.addToHttpParamsRecursive( + httpParams, value[k], key != null ? `${key}.${k}` : k)); + } + } else if (key != null) { + httpParams = httpParams.append(key, value); + } else { + throw Error("key may not be null if value is not object or array"); + } + return httpParams; + } + + /** + * @param createDegreeProgramSpecializationDTO + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public createDegreeProgramSpecialization(createDegreeProgramSpecializationDTO: CreateDegreeProgramSpecializationDTO, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public createDegreeProgramSpecialization(createDegreeProgramSpecializationDTO: CreateDegreeProgramSpecializationDTO, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createDegreeProgramSpecialization(createDegreeProgramSpecializationDTO: CreateDegreeProgramSpecializationDTO, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createDegreeProgramSpecialization(createDegreeProgramSpecializationDTO: CreateDegreeProgramSpecializationDTO, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (createDegreeProgramSpecializationDTO === null || createDegreeProgramSpecializationDTO === undefined) { + throw new Error('Required parameter createDegreeProgramSpecializationDTO was null or undefined when calling createDegreeProgramSpecialization.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/degree-program-specializations`; + return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: createDegreeProgramSpecializationDTO, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param id + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public deleteDegreeProgramSpecialization(id: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable; + public deleteDegreeProgramSpecialization(id: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable>; + public deleteDegreeProgramSpecialization(id: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable>; + public deleteDegreeProgramSpecialization(id: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable { + if (id === null || id === undefined) { + throw new Error('Required parameter id was null or undefined when calling deleteDegreeProgramSpecialization.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/degree-program-specializations/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + return this.httpClient.request('delete', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getAllDegreeProgramSpecializations(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getAllDegreeProgramSpecializations(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getAllDegreeProgramSpecializations(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getAllDegreeProgramSpecializations(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/degree-program-specializations`; + return this.httpClient.request>('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param id + * @param updateDegreeProgramSpecializationDTO + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public updateDegreeProgramSpecialization(id: number, updateDegreeProgramSpecializationDTO: UpdateDegreeProgramSpecializationDTO, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public updateDegreeProgramSpecialization(id: number, updateDegreeProgramSpecializationDTO: UpdateDegreeProgramSpecializationDTO, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public updateDegreeProgramSpecialization(id: number, updateDegreeProgramSpecializationDTO: UpdateDegreeProgramSpecializationDTO, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public updateDegreeProgramSpecialization(id: number, updateDegreeProgramSpecializationDTO: UpdateDegreeProgramSpecializationDTO, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (id === null || id === undefined) { + throw new Error('Required parameter id was null or undefined when calling updateDegreeProgramSpecialization.'); + } + if (updateDegreeProgramSpecializationDTO === null || updateDegreeProgramSpecializationDTO === undefined) { + throw new Error('Required parameter updateDegreeProgramSpecializationDTO was null or undefined when calling updateDegreeProgramSpecialization.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/degree-program-specializations/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + return this.httpClient.request('patch', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: updateDegreeProgramSpecializationDTO, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + +} diff --git a/Client/src/app/core/modules/openapi/api/degree-program-specializations-controller.serviceInterface.ts b/Client/src/app/core/modules/openapi/api/degree-program-specializations-controller.serviceInterface.ts new file mode 100644 index 00000000..3411aa50 --- /dev/null +++ b/Client/src/app/core/modules/openapi/api/degree-program-specializations-controller.serviceInterface.ts @@ -0,0 +1,55 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { HttpHeaders } from '@angular/common/http'; + +import { Observable } from 'rxjs'; + +import { CreateDegreeProgramSpecializationDTO } from '../model/models'; +import { DegreeProgramSpecializationDTO } from '../model/models'; +import { UpdateDegreeProgramSpecializationDTO } from '../model/models'; + + +import { Configuration } from '../configuration'; + + + +export interface DegreeProgramSpecializationsControllerServiceInterface { + defaultHeaders: HttpHeaders; + configuration: Configuration; + + /** + * + * + * @param createDegreeProgramSpecializationDTO + */ + createDegreeProgramSpecialization(createDegreeProgramSpecializationDTO: CreateDegreeProgramSpecializationDTO, extraHttpRequestParams?: any): Observable; + + /** + * + * + * @param id + */ + deleteDegreeProgramSpecialization(id: number, extraHttpRequestParams?: any): Observable<{}>; + + /** + * + * + */ + getAllDegreeProgramSpecializations(extraHttpRequestParams?: any): Observable>; + + /** + * + * + * @param id + * @param updateDegreeProgramSpecializationDTO + */ + updateDegreeProgramSpecialization(id: number, updateDegreeProgramSpecializationDTO: UpdateDegreeProgramSpecializationDTO, extraHttpRequestParams?: any): Observable; + +} diff --git a/Client/src/app/core/modules/openapi/api/degree-programs-controller.service.ts b/Client/src/app/core/modules/openapi/api/degree-programs-controller.service.ts new file mode 100644 index 00000000..01150472 --- /dev/null +++ b/Client/src/app/core/modules/openapi/api/degree-programs-controller.service.ts @@ -0,0 +1,580 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +/* tslint:disable:no-unused-variable member-ordering */ + +import { Inject, Injectable, Optional } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams, + HttpResponse, HttpEvent, HttpParameterCodec, HttpContext + } from '@angular/common/http'; +import { CustomHttpParameterCodec } from '../encoder'; +import { Observable } from 'rxjs'; + +// @ts-ignore +import { AddSpecializationsToDegreeProgramDTO } from '../model/add-specializations-to-degree-program-dto'; +// @ts-ignore +import { CreateDegreeProgramDTO } from '../model/create-degree-program-dto'; +// @ts-ignore +import { DegreeProgramDTO } from '../model/degree-program-dto'; +// @ts-ignore +import { UpdateDegreeProgramDTO } from '../model/update-degree-program-dto'; + +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; +import { Configuration } from '../configuration'; +import { + DegreeProgramsControllerServiceInterface +} from './degree-programs-controller.serviceInterface'; + + + +@Injectable({ + providedIn: 'root' +}) +export class DegreeProgramsControllerService implements DegreeProgramsControllerServiceInterface { + + protected basePath = 'http://localhost:8080'; + public defaultHeaders = new HttpHeaders(); + public configuration = new Configuration(); + public encoder: HttpParameterCodec; + + constructor(protected httpClient: HttpClient, @Optional()@Inject(BASE_PATH) basePath: string|string[], @Optional() configuration: Configuration) { + if (configuration) { + this.configuration = configuration; + } + if (typeof this.configuration.basePath !== 'string') { + const firstBasePath = Array.isArray(basePath) ? basePath[0] : undefined; + if (firstBasePath != undefined) { + basePath = firstBasePath; + } + + if (typeof basePath !== 'string') { + basePath = this.basePath; + } + this.configuration.basePath = basePath; + } + this.encoder = this.configuration.encoder || new CustomHttpParameterCodec(); + } + + + // @ts-ignore + private addToHttpParams(httpParams: HttpParams, value: any, key?: string): HttpParams { + if (typeof value === "object" && value instanceof Date === false) { + httpParams = this.addToHttpParamsRecursive(httpParams, value); + } else { + httpParams = this.addToHttpParamsRecursive(httpParams, value, key); + } + return httpParams; + } + + private addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string): HttpParams { + if (value == null) { + return httpParams; + } + + if (typeof value === "object") { + if (Array.isArray(value)) { + (value as any[]).forEach( elem => httpParams = this.addToHttpParamsRecursive(httpParams, elem, key)); + } else if (value instanceof Date) { + if (key != null) { + httpParams = httpParams.append(key, (value as Date).toISOString().substring(0, 10)); + } else { + throw Error("key may not be null if value is Date"); + } + } else { + Object.keys(value).forEach( k => httpParams = this.addToHttpParamsRecursive( + httpParams, value[k], key != null ? `${key}.${k}` : k)); + } + } else if (key != null) { + httpParams = httpParams.append(key, value); + } else { + throw Error("key may not be null if value is not object or array"); + } + return httpParams; + } + + /** + * @param degreeProgramId + * @param addSpecializationsToDegreeProgramDTO + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public addSpecializationsToDegreeProgram(degreeProgramId: number, addSpecializationsToDegreeProgramDTO: AddSpecializationsToDegreeProgramDTO, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public addSpecializationsToDegreeProgram(degreeProgramId: number, addSpecializationsToDegreeProgramDTO: AddSpecializationsToDegreeProgramDTO, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public addSpecializationsToDegreeProgram(degreeProgramId: number, addSpecializationsToDegreeProgramDTO: AddSpecializationsToDegreeProgramDTO, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public addSpecializationsToDegreeProgram(degreeProgramId: number, addSpecializationsToDegreeProgramDTO: AddSpecializationsToDegreeProgramDTO, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (degreeProgramId === null || degreeProgramId === undefined) { + throw new Error('Required parameter degreeProgramId was null or undefined when calling addSpecializationsToDegreeProgram.'); + } + if (addSpecializationsToDegreeProgramDTO === null || addSpecializationsToDegreeProgramDTO === undefined) { + throw new Error('Required parameter addSpecializationsToDegreeProgramDTO was null or undefined when calling addSpecializationsToDegreeProgram.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/degree-programs/${this.configuration.encodeParam({name: "degreeProgramId", value: degreeProgramId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}/degree-program-specializations/batch`; + return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: addSpecializationsToDegreeProgramDTO, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param createDegreeProgramDTO + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public createDegreeProgram(createDegreeProgramDTO: CreateDegreeProgramDTO, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public createDegreeProgram(createDegreeProgramDTO: CreateDegreeProgramDTO, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createDegreeProgram(createDegreeProgramDTO: CreateDegreeProgramDTO, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createDegreeProgram(createDegreeProgramDTO: CreateDegreeProgramDTO, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (createDegreeProgramDTO === null || createDegreeProgramDTO === undefined) { + throw new Error('Required parameter createDegreeProgramDTO was null or undefined when calling createDegreeProgram.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/degree-programs`; + return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: createDegreeProgramDTO, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param id + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public deleteDegreeProgram(id: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable; + public deleteDegreeProgram(id: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable>; + public deleteDegreeProgram(id: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable>; + public deleteDegreeProgram(id: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable { + if (id === null || id === undefined) { + throw new Error('Required parameter id was null or undefined when calling deleteDegreeProgram.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/degree-programs/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + return this.httpClient.request('delete', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getAllDegreePrograms(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getAllDegreePrograms(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getAllDegreePrograms(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getAllDegreePrograms(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/degree-programs`; + return this.httpClient.request>('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param id + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getDegreeProgram(id: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getDegreeProgram(id: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getDegreeProgram(id: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getDegreeProgram(id: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (id === null || id === undefined) { + throw new Error('Required parameter id was null or undefined when calling getDegreeProgram.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/degree-programs/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param degreeProgramId + * @param degreeProgramSpecializationId + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public removeSpecializationFromDegreeProgram(degreeProgramId: number, degreeProgramSpecializationId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public removeSpecializationFromDegreeProgram(degreeProgramId: number, degreeProgramSpecializationId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public removeSpecializationFromDegreeProgram(degreeProgramId: number, degreeProgramSpecializationId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public removeSpecializationFromDegreeProgram(degreeProgramId: number, degreeProgramSpecializationId: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (degreeProgramId === null || degreeProgramId === undefined) { + throw new Error('Required parameter degreeProgramId was null or undefined when calling removeSpecializationFromDegreeProgram.'); + } + if (degreeProgramSpecializationId === null || degreeProgramSpecializationId === undefined) { + throw new Error('Required parameter degreeProgramSpecializationId was null or undefined when calling removeSpecializationFromDegreeProgram.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/degree-programs/${this.configuration.encodeParam({name: "degreeProgramId", value: degreeProgramId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}/degree-program-specializations/${this.configuration.encodeParam({name: "degreeProgramSpecializationId", value: degreeProgramSpecializationId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + return this.httpClient.request('delete', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * @param id + * @param updateDegreeProgramDTO + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public updateDegreeProgram(id: number, updateDegreeProgramDTO: UpdateDegreeProgramDTO, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public updateDegreeProgram(id: number, updateDegreeProgramDTO: UpdateDegreeProgramDTO, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public updateDegreeProgram(id: number, updateDegreeProgramDTO: UpdateDegreeProgramDTO, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public updateDegreeProgram(id: number, updateDegreeProgramDTO: UpdateDegreeProgramDTO, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (id === null || id === undefined) { + throw new Error('Required parameter id was null or undefined when calling updateDegreeProgram.'); + } + if (updateDegreeProgramDTO === null || updateDegreeProgramDTO === undefined) { + throw new Error('Required parameter updateDegreeProgramDTO was null or undefined when calling updateDegreeProgram.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/degree-programs/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + return this.httpClient.request('patch', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: updateDegreeProgramDTO, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + +} diff --git a/Client/src/app/core/modules/openapi/api/degree-programs-controller.serviceInterface.ts b/Client/src/app/core/modules/openapi/api/degree-programs-controller.serviceInterface.ts new file mode 100644 index 00000000..70fe8197 --- /dev/null +++ b/Client/src/app/core/modules/openapi/api/degree-programs-controller.serviceInterface.ts @@ -0,0 +1,79 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { HttpHeaders } from '@angular/common/http'; + +import { Observable } from 'rxjs'; + +import { AddSpecializationsToDegreeProgramDTO } from '../model/models'; +import { CreateDegreeProgramDTO } from '../model/models'; +import { DegreeProgramDTO } from '../model/models'; +import { UpdateDegreeProgramDTO } from '../model/models'; + + +import { Configuration } from '../configuration'; + + + +export interface DegreeProgramsControllerServiceInterface { + defaultHeaders: HttpHeaders; + configuration: Configuration; + + /** + * + * + * @param degreeProgramId + * @param addSpecializationsToDegreeProgramDTO + */ + addSpecializationsToDegreeProgram(degreeProgramId: number, addSpecializationsToDegreeProgramDTO: AddSpecializationsToDegreeProgramDTO, extraHttpRequestParams?: any): Observable; + + /** + * + * + * @param createDegreeProgramDTO + */ + createDegreeProgram(createDegreeProgramDTO: CreateDegreeProgramDTO, extraHttpRequestParams?: any): Observable; + + /** + * + * + * @param id + */ + deleteDegreeProgram(id: number, extraHttpRequestParams?: any): Observable<{}>; + + /** + * + * + */ + getAllDegreePrograms(extraHttpRequestParams?: any): Observable>; + + /** + * + * + * @param id + */ + getDegreeProgram(id: number, extraHttpRequestParams?: any): Observable; + + /** + * + * + * @param degreeProgramId + * @param degreeProgramSpecializationId + */ + removeSpecializationFromDegreeProgram(degreeProgramId: number, degreeProgramSpecializationId: number, extraHttpRequestParams?: any): Observable; + + /** + * + * + * @param id + * @param updateDegreeProgramDTO + */ + updateDegreeProgram(id: number, updateDegreeProgramDTO: UpdateDegreeProgramDTO, extraHttpRequestParams?: any): Observable; + +} diff --git a/Client/src/app/core/modules/openapi/model/add-specializations-to-degree-program-dto.ts b/Client/src/app/core/modules/openapi/model/add-specializations-to-degree-program-dto.ts new file mode 100644 index 00000000..b0535b59 --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/add-specializations-to-degree-program-dto.ts @@ -0,0 +1,15 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface AddSpecializationsToDegreeProgramDTO { + degreeProgramSpecializationIds: Array; +} + diff --git a/Client/src/app/core/modules/openapi/model/create-degree-program-dto.ts b/Client/src/app/core/modules/openapi/model/create-degree-program-dto.ts new file mode 100644 index 00000000..da13df1f --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/create-degree-program-dto.ts @@ -0,0 +1,16 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface CreateDegreeProgramDTO { + name: string; + responsibleUserId: string; +} + diff --git a/Client/src/app/core/modules/openapi/model/create-degree-program-specialization-dto.ts b/Client/src/app/core/modules/openapi/model/create-degree-program-specialization-dto.ts new file mode 100644 index 00000000..ac2b92bd --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/create-degree-program-specialization-dto.ts @@ -0,0 +1,16 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface CreateDegreeProgramSpecializationDTO { + name: string; + responsibleUserId: string; +} + diff --git a/Client/src/app/core/modules/openapi/model/degree-program-dto.ts b/Client/src/app/core/modules/openapi/model/degree-program-dto.ts new file mode 100644 index 00000000..e05d448f --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/degree-program-dto.ts @@ -0,0 +1,20 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { DegreeProgramSpecializationDTO } from './degree-program-specialization-dto'; +import { ResponsibleUserDTO } from './responsible-user-dto'; + + +export interface DegreeProgramDTO { + degreeProgramId: number; + name: string; + responsibleUser: ResponsibleUserDTO; + degreeProgramSpecializations?: Array; +} + diff --git a/Client/src/app/core/modules/openapi/model/degree-program-specialization-dto.ts b/Client/src/app/core/modules/openapi/model/degree-program-specialization-dto.ts new file mode 100644 index 00000000..01fd09af --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/degree-program-specialization-dto.ts @@ -0,0 +1,18 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { ResponsibleUserDTO } from './responsible-user-dto'; + + +export interface DegreeProgramSpecializationDTO { + degreeProgramSpecializationId: number; + name: string; + responsibleUser: ResponsibleUserDTO; +} + diff --git a/Client/src/app/core/modules/openapi/model/models.ts b/Client/src/app/core/modules/openapi/model/models.ts index 45fb6363..0ca448a1 100644 --- a/Client/src/app/core/modules/openapi/model/models.ts +++ b/Client/src/app/core/modules/openapi/model/models.ts @@ -1,6 +1,11 @@ export * from './add-module-version-dto'; +export * from './add-specializations-to-degree-program-dto'; export * from './completion-service-request-dto'; export * from './completion-service-response-dto'; +export * from './create-degree-program-dto'; +export * from './create-degree-program-specialization-dto'; +export * from './degree-program-dto'; +export * from './degree-program-specialization-dto'; export * from './feedback'; export * from './feedback-dto'; export * from './feedback-list-item-dto'; @@ -16,7 +21,10 @@ export * from './proposal'; export * from './proposal-request-dto'; export * from './proposal-view-dto'; export * from './proposals-compact-dto'; +export * from './responsible-user-dto'; export * from './similar-module-dto'; +export * from './update-degree-program-dto'; +export * from './update-degree-program-specialization-dto'; export * from './update-user-role-dto'; export * from './user'; export * from './user-dto'; diff --git a/Client/src/app/core/modules/openapi/model/responsible-user-dto.ts b/Client/src/app/core/modules/openapi/model/responsible-user-dto.ts new file mode 100644 index 00000000..155263f6 --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/responsible-user-dto.ts @@ -0,0 +1,18 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface ResponsibleUserDTO { + userId?: string; + firstName?: string; + lastName?: string; + email?: string; +} + diff --git a/Client/src/app/core/modules/openapi/model/update-degree-program-dto.ts b/Client/src/app/core/modules/openapi/model/update-degree-program-dto.ts new file mode 100644 index 00000000..34094e90 --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/update-degree-program-dto.ts @@ -0,0 +1,16 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface UpdateDegreeProgramDTO { + name?: string; + responsibleUserId?: string; +} + diff --git a/Client/src/app/core/modules/openapi/model/update-degree-program-specialization-dto.ts b/Client/src/app/core/modules/openapi/model/update-degree-program-specialization-dto.ts new file mode 100644 index 00000000..74f859bb --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/update-degree-program-specialization-dto.ts @@ -0,0 +1,16 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface UpdateDegreeProgramSpecializationDTO { + name?: string; + responsibleUserId?: string; +} + diff --git a/Client/src/app/pages/admin/admin-layout.component.html b/Client/src/app/pages/admin/admin-layout.component.html deleted file mode 100644 index 98358abe..00000000 --- a/Client/src/app/pages/admin/admin-layout.component.html +++ /dev/null @@ -1,29 +0,0 @@ -
-

Admin Panel

- -
- -
- -
- - - -
-
-
- - -
- -
-
-
diff --git a/Client/src/app/pages/admin/admin-layout.component.ts b/Client/src/app/pages/admin/admin-layout.component.ts deleted file mode 100644 index e00e40d6..00000000 --- a/Client/src/app/pages/admin/admin-layout.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { Router, RouterModule } from '@angular/router'; -import { PanelModule } from 'primeng/panel'; -import { ButtonModule } from 'primeng/button'; - -@Component({ - selector: 'app-admin-layout', - standalone: true, - imports: [PanelModule, ButtonModule, RouterModule], - templateUrl: './admin-layout.component.html' -}) -export class AdminLayoutComponent { - private router = inject(Router); - - isActive(path: string): boolean { - return this.router.url.startsWith(path); - } -} diff --git a/Client/src/app/pages/admin/degree-program-specializations/all-specializations-page.component.html b/Client/src/app/pages/admin/degree-program-specializations/all-specializations-page.component.html new file mode 100644 index 00000000..6fe0ae13 --- /dev/null +++ b/Client/src/app/pages/admin/degree-program-specializations/all-specializations-page.component.html @@ -0,0 +1,63 @@ + + +

All specializations

+

Create and manage the full list of areas of specializations for all degree programs. They can be assigned to any degree program in each program's page.

+ +
+ +
+ + + + + Name + Responsible user + Actions + + + + + {{ spec.name }} + {{ userLabel(spec) }} + + + + + + + + + No specializations yet. Add one to get started. + + + + + +
+
+ + +
+
+ + +
+
+ + + + +
diff --git a/Client/src/app/pages/admin/degree-program-specializations/all-specializations-page.component.ts b/Client/src/app/pages/admin/degree-program-specializations/all-specializations-page.component.ts new file mode 100644 index 00000000..a6922f9d --- /dev/null +++ b/Client/src/app/pages/admin/degree-program-specializations/all-specializations-page.component.ts @@ -0,0 +1,109 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { TableModule } from 'primeng/table'; +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { DialogModule } from 'primeng/dialog'; +import { MessageService } from 'primeng/api'; +import { ToastModule } from 'primeng/toast'; +import { firstValueFrom } from 'rxjs'; +import { + DegreeProgramSpecializationsControllerService, + type CreateDegreeProgramSpecializationDTO, + type DegreeProgramSpecializationDTO, + type UpdateDegreeProgramSpecializationDTO +} from '../../../core/modules/openapi'; +import { UsersSelectComponent } from '../../../components/users-select/users-select.component'; + +@Component({ + selector: 'app-all-specializations-page', + standalone: true, + imports: [FormsModule, TableModule, ButtonModule, InputTextModule, DialogModule, ToastModule, UsersSelectComponent], + templateUrl: './all-specializations-page.component.html' +}) +export class AllSpecializationsPageComponent { + private readonly specializationService = inject(DegreeProgramSpecializationsControllerService); + private readonly messageService = inject(MessageService); + + specializations = signal([]); + loading = signal(false); + dialogVisible = signal(false); + selectedSpecialization = signal(null); + editMode = signal(false); + + name = signal(''); + responsibleUserId = signal(null); + + constructor() { + this.loadSpecializations(); + } + + async loadSpecializations() { + this.loading.set(true); + try { + const list = await firstValueFrom(this.specializationService.getAllDegreeProgramSpecializations()); + this.specializations.set(list ?? []); + } catch (e) { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to load degree program specializations.' }); + this.specializations.set([]); + } finally { + this.loading.set(false); + } + } + + userLabel(spec: DegreeProgramSpecializationDTO): string { + const u = spec?.responsibleUser; + return ([u.firstName, u.lastName].filter(Boolean).join(' ').trim() || u.email) ?? '—'; + } + + openCreate() { + this.editMode.set(false); + this.selectedSpecialization.set(null); + this.name.set(''); + this.responsibleUserId.set(null); + this.dialogVisible.set(true); + } + + openEdit(spec: DegreeProgramSpecializationDTO) { + this.editMode.set(true); + this.selectedSpecialization.set(spec); + this.name.set(spec.name); + this.responsibleUserId.set(spec.responsibleUser?.userId ?? null); + this.dialogVisible.set(true); + } + + async saveSpecialization() { + const id = this.selectedSpecialization()?.degreeProgramSpecializationId; + const nameVal = this.name().trim(); + const userIdVal = this.responsibleUserId(); + if (!nameVal || !userIdVal) { + this.messageService.add({ severity: 'warn', summary: 'Validation', detail: 'Name and responsible user are required.' }); + return; + } + const dto: CreateDegreeProgramSpecializationDTO & UpdateDegreeProgramSpecializationDTO = { name: nameVal, responsibleUserId: userIdVal }; + try { + if (this.editMode() && id != null) { + await firstValueFrom(this.specializationService.updateDegreeProgramSpecialization(id, dto)); + this.messageService.add({ severity: 'success', summary: 'Updated', detail: 'Degree program specialization updated.' }); + } else { + await firstValueFrom(this.specializationService.createDegreeProgramSpecialization(dto)); + this.messageService.add({ severity: 'success', summary: 'Created', detail: 'Degree program specialization created.' }); + } + this.dialogVisible.set(false); + await this.loadSpecializations(); + } catch (e) { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to save degree program specialization.' }); + } + } + + async deleteSpecialization(spec: DegreeProgramSpecializationDTO) { + if (!confirm(`Delete "${spec.name}"? It will be removed from all degree programs that use it.`)) return; + try { + await firstValueFrom(this.specializationService.deleteDegreeProgramSpecialization(spec.degreeProgramSpecializationId)); + this.messageService.add({ severity: 'success', summary: 'Deleted', detail: 'Degree program specialization deleted.' }); + await this.loadSpecializations(); + } catch (e) { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to delete degree program specialization.' }); + } + } +} diff --git a/Client/src/app/pages/admin/degree-programs/all-degree-programs-page.component.html b/Client/src/app/pages/admin/degree-programs/all-degree-programs-page.component.html new file mode 100644 index 00000000..0de707a2 --- /dev/null +++ b/Client/src/app/pages/admin/degree-programs/all-degree-programs-page.component.html @@ -0,0 +1,63 @@ + + +

Degree Programs

+

Create and manage degree programs. Assign areas of specializations to each program from the program's page.

+ +
+ + + + +
+ + + + + Name + Responsible user + Actions + + + + + {{ program.name }} + {{ userLabel(program) }} + + + + + + + + + + + No degree programs yet. Add one to get started. + + + + + +
+
+ + +
+
+ + +
+
+ + + + +
diff --git a/Client/src/app/pages/admin/degree-programs/all-degree-programs-page.component.ts b/Client/src/app/pages/admin/degree-programs/all-degree-programs-page.component.ts new file mode 100644 index 00000000..623e5763 --- /dev/null +++ b/Client/src/app/pages/admin/degree-programs/all-degree-programs-page.component.ts @@ -0,0 +1,88 @@ +import { Component, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { TableModule } from 'primeng/table'; +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { DialogModule } from 'primeng/dialog'; +import { TooltipModule } from 'primeng/tooltip'; +import { MessageService } from 'primeng/api'; +import { ToastModule } from 'primeng/toast'; +import { firstValueFrom } from 'rxjs'; +import { DegreeProgramsControllerService, type DegreeProgramDTO } from '../../../core/modules/openapi'; +import { UsersSelectComponent } from '../../../components/users-select/users-select.component'; + +@Component({ + selector: 'app-all-degree-programs-page', + standalone: true, + imports: [RouterLink, FormsModule, TableModule, ButtonModule, InputTextModule, DialogModule, TooltipModule, ToastModule, UsersSelectComponent], + templateUrl: './all-degree-programs-page.component.html' +}) +export class AllDegreeProgramsPageComponent { + private readonly degreeProgramService = inject(DegreeProgramsControllerService); + private readonly messageService = inject(MessageService); + + programs = signal([]); + loading = signal(false); + dialogVisible = signal(false); + + name = signal(''); + responsibleUserId = signal(null); + + constructor() { + this.loadPrograms(); + } + + async loadPrograms() { + this.loading.set(true); + try { + const list = await firstValueFrom(this.degreeProgramService.getAllDegreePrograms()); + this.programs.set(list ?? []); + } catch (e) { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to load degree programs.' }); + this.programs.set([]); + } finally { + this.loading.set(false); + } + } + + userLabel(program: { responsibleUserId?: string | null; responsibleUser?: { firstName?: string; lastName?: string; email?: string } }): string { + const u = program?.responsibleUser; + if (!u) return program?.responsibleUserId ?? '—'; + return ([u.firstName, u.lastName].filter(Boolean).join(' ').trim() || u.email) ?? program?.responsibleUserId ?? '—'; + } + + openCreate() { + this.name.set(''); + this.responsibleUserId.set(null); + this.dialogVisible.set(true); + } + + async saveProgram() { + const nameVal = this.name().trim(); + const userIdVal = this.responsibleUserId(); + if (!nameVal || !userIdVal) { + this.messageService.add({ severity: 'warn', summary: 'Validation', detail: 'Name and responsible user are required.' }); + return; + } + try { + await firstValueFrom(this.degreeProgramService.createDegreeProgram({ name: nameVal, responsibleUserId: userIdVal })); + this.messageService.add({ severity: 'success', summary: 'Created', detail: 'Degree program created.' }); + this.dialogVisible.set(false); + await this.loadPrograms(); + } catch (e) { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to save degree program.' }); + } + } + + async deleteProgram(program: DegreeProgramDTO) { + if (!confirm(`Delete degree program "${program.name}"?`)) return; + try { + await firstValueFrom(this.degreeProgramService.deleteDegreeProgram(program.degreeProgramId)); + this.messageService.add({ severity: 'success', summary: 'Deleted', detail: 'Degree program deleted.' }); + await this.loadPrograms(); + } catch (e) { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to delete degree program.' }); + } + } +} diff --git a/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.html b/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.html new file mode 100644 index 00000000..dd096001 --- /dev/null +++ b/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.html @@ -0,0 +1,109 @@ + + +@if (program(); as prog) { +

Program details – {{ prog.name }}

+ +
+

Program details

+
+
+ + +
+
+ + +
+
+ +
+ +

Manage which specializations are assigned to this degree program. Add from the list below or create and assign a new one.

+ +
+
+ +
+ + +
+
+ +
+ +

Currently assigned to this program

+ @if (prog.degreeProgramSpecializations && prog.degreeProgramSpecializations.length > 0) { + + + + Name + Responsible user + Actions + + + + + {{ spec.name }} + {{ userLabel(spec) }} + + + + + + + } @else { +

No specializations assigned to this program yet. Add from the list above or create and assign a new one.

+ } +} @else if (!loading()) { +

Degree program not found.

+ +} @else { +

Loading…

+} + + +
+
+ + +
+
+ + +
+
+ + + + +
diff --git a/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.ts b/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.ts new file mode 100644 index 00000000..a5340925 --- /dev/null +++ b/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.ts @@ -0,0 +1,192 @@ +import { Component, inject, signal, computed, OnDestroy } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { TableModule } from 'primeng/table'; +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { DialogModule } from 'primeng/dialog'; +import { MultiSelectModule } from 'primeng/multiselect'; +import { TooltipModule } from 'primeng/tooltip'; +import { MessageService } from 'primeng/api'; +import { ToastModule } from 'primeng/toast'; +import { firstValueFrom } from 'rxjs'; +import { + DegreeProgramSpecializationsControllerService, + DegreeProgramsControllerService, + type CreateDegreeProgramSpecializationDTO, + type DegreeProgramDTO, + type DegreeProgramSpecializationDTO +} from '../../../core/modules/openapi'; +import { UsersSelectComponent } from '../../../components/users-select/users-select.component'; +import { BreadcrumbLabelsService } from '../../../components/breadcrumb/breadcrumb-labels.service'; + +@Component({ + selector: 'app-degree-program-details-page', + standalone: true, + imports: [RouterLink, FormsModule, TableModule, ButtonModule, InputTextModule, DialogModule, MultiSelectModule, TooltipModule, ToastModule, UsersSelectComponent], + templateUrl: './degree-program-details-page.component.html' +}) +export class DegreeProgramDetailsPageComponent implements OnDestroy { + private readonly route = inject(ActivatedRoute); + private readonly degreeProgramsService = inject(DegreeProgramsControllerService); + private readonly specializationsService = inject(DegreeProgramSpecializationsControllerService); + private readonly messageService = inject(MessageService); + private readonly breadcrumbLabels = inject(BreadcrumbLabelsService); + + program = signal(null); + allSpecializations = signal([]); + loading = signal(true); + savingProgram = signal(false); + createDialogVisible = signal(false); + specializationsToAdd: number[] = []; + + programName = signal(''); + programResponsibleUserId = signal(null); + + newSpecName = signal(''); + newSpecResponsibleUserId = signal(null); + + specializationOptions = computed(() => { + const all = this.allSpecializations(); + const programSpecs = this.program()?.degreeProgramSpecializations ?? []; + const assignedIds = new Set(programSpecs.map((s) => s.degreeProgramSpecializationId)); + return all.filter((s) => !assignedIds.has(s.degreeProgramSpecializationId)).map((s) => ({ label: s.name, value: s.degreeProgramSpecializationId })); + }); + + constructor() { + this.route.params.subscribe((params) => { + const id = params['id']; + if (id != null) { + this.loadProgram(Number(id)); + } + }); + } + + ngOnDestroy(): void { + this.breadcrumbLabels.degreeProgramName.set(null); + } + + async loadProgram(id: number) { + this.loading.set(true); + try { + const program = await firstValueFrom(this.degreeProgramsService.getDegreeProgram(id)); + this.program.set(program); + this.programName.set(program.name ?? ''); + this.programResponsibleUserId.set(program.responsibleUser.userId ?? null); + this.breadcrumbLabels.degreeProgramName.set(program.name ?? null); + await this.loadAllSpecializations(); + } catch (e) { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Degree program not found.' }); + this.program.set(null); + this.breadcrumbLabels.degreeProgramName.set(null); + } finally { + this.loading.set(false); + } + } + + async saveProgramDetails() { + const prog = this.program(); + const nameVal = this.programName().trim(); + const userIdVal = this.programResponsibleUserId(); + if (!prog || !nameVal || !userIdVal) { + this.messageService.add({ severity: 'warn', summary: 'Validation', detail: 'Name and responsible user are required.' }); + return; + } + this.savingProgram.set(true); + try { + await firstValueFrom(this.degreeProgramsService.updateDegreeProgram(prog.degreeProgramId, { name: nameVal, responsibleUserId: userIdVal })); + this.messageService.add({ severity: 'success', summary: 'Updated', detail: 'Program details saved.' }); + await this.loadProgram(prog.degreeProgramId); + } catch (e) { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to save program details.' }); + } finally { + this.savingProgram.set(false); + } + } + + async loadAllSpecializations() { + try { + const list = await firstValueFrom(this.specializationsService.getAllDegreeProgramSpecializations()); + this.allSpecializations.set(list ?? []); + } catch (e) { + this.allSpecializations.set([]); + } + } + + userLabel(spec: { responsibleUserId?: string | null; responsibleUser?: { firstName?: string; lastName?: string; email?: string } }): string { + const u = spec?.responsibleUser; + if (!u) return spec?.responsibleUserId ?? '—'; + return ([u.firstName, u.lastName].filter(Boolean).join(' ').trim() || u.email) ?? spec?.responsibleUserId ?? '—'; + } + + async addSpecializations() { + const prog = this.program(); + if (!prog || this.specializationsToAdd.length === 0) return; + try { + await firstValueFrom( + this.degreeProgramsService.addSpecializationsToDegreeProgram(prog.degreeProgramId, { + degreeProgramSpecializationIds: this.specializationsToAdd + }) + ); + this.messageService.add({ + severity: 'success', + summary: 'Specializations added', + detail: `${this.specializationsToAdd.length} specialization(s) assigned.` + }); + this.specializationsToAdd = []; + await this.loadProgram(prog.degreeProgramId); + } catch (e) { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to add specializations.' }); + } + } + + async removeSpecialization(degreeProgramSpecializationId: number) { + const prog = this.program(); + if (!prog) return; + try { + await firstValueFrom(this.degreeProgramsService.removeSpecializationFromDegreeProgram(prog.degreeProgramId, degreeProgramSpecializationId)); + this.messageService.add({ + severity: 'success', + summary: 'Specialization removed', + detail: 'Specialization unassigned from program.' + }); + await this.loadProgram(prog.degreeProgramId); + } catch (e) { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to remove specialization.' }); + } + } + + openCreateSpec() { + this.newSpecName.set(''); + this.newSpecResponsibleUserId.set(null); + this.createDialogVisible.set(true); + } + + async createSpecAndAssign() { + const nameVal = this.newSpecName().trim(); + const userIdVal = this.newSpecResponsibleUserId(); + const prog = this.program(); + if (!nameVal || !userIdVal || !prog) { + this.messageService.add({ severity: 'warn', summary: 'Validation', detail: 'Name and responsible user are required.' }); + return; + } + const dto: CreateDegreeProgramSpecializationDTO = { name: nameVal, responsibleUserId: userIdVal }; + try { + const newSpec = await firstValueFrom(this.specializationsService.createDegreeProgramSpecialization(dto)); + await firstValueFrom( + this.degreeProgramsService.addSpecializationsToDegreeProgram(prog.degreeProgramId, { + degreeProgramSpecializationIds: [newSpec.degreeProgramSpecializationId] + }) + ); + this.messageService.add({ + severity: 'success', + summary: 'Specialization created', + detail: 'New specialization created and assigned to this program.' + }); + this.createDialogVisible.set(false); + await this.loadProgram(prog.degreeProgramId); + } catch (e) { + this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to create specialization.' }); + } + } +} diff --git a/Client/src/app/pages/admin/users/admin-users-page.component.html b/Client/src/app/pages/admin/users/users-page.component.html similarity index 93% rename from Client/src/app/pages/admin/users/admin-users-page.component.html rename to Client/src/app/pages/admin/users/users-page.component.html index 1d207d49..b7ac2750 100644 --- a/Client/src/app/pages/admin/users/admin-users-page.component.html +++ b/Client/src/app/pages/admin/users/users-page.component.html @@ -1,9 +1,10 @@ -

Users & Roles

+ +

Users & Roles

View all users and assign or change their roles.

- +
diff --git a/Client/src/app/pages/admin/users/admin-users-page.component.ts b/Client/src/app/pages/admin/users/users-page.component.ts similarity index 83% rename from Client/src/app/pages/admin/users/admin-users-page.component.ts rename to Client/src/app/pages/admin/users/users-page.component.ts index 9acdf75c..041eb43e 100644 --- a/Client/src/app/pages/admin/users/admin-users-page.component.ts +++ b/Client/src/app/pages/admin/users/users-page.component.ts @@ -11,12 +11,12 @@ import { ToastModule } from 'primeng/toast'; import { firstValueFrom } from 'rxjs'; @Component({ - selector: 'app-admin-users-page', + selector: 'app-users-page', standalone: true, imports: [FormsModule, TableModule, MultiSelectModule, InputTextModule, ButtonModule, ToastModule], - templateUrl: './admin-users-page.component.html' + templateUrl: './users-page.component.html' }) -export class AdminUsersPageComponent { +export class UsersPageComponent { private readonly adminUserControllerService = inject(AdminUserControllerService); private readonly messageService = inject(MessageService); @@ -24,6 +24,7 @@ export class AdminUsersPageComponent { totalRecords = signal(0); loading = signal(false); savingUserId = signal(null); + searchField = ''; searchQuery = ''; currentPageSize = signal(10); firstRowIndex = signal(0); @@ -33,27 +34,26 @@ export class AdminUsersPageComponent { })); constructor() { - this.loadUsers(0, this.currentPageSize(), this.searchQuery); + this.loadUsers(); } async pageChange(event: TablePageEvent) { const first = event.first ?? 0; - const rows = event.rows ?? this.currentPageSize(); - this.currentPageSize.set(rows); this.firstRowIndex.set(first); - const page = rows > 0 ? Math.floor(first / rows) : 0; - await this.loadUsers(page, rows, this.searchQuery); + const page = this.currentPageSize() > 0 ? Math.floor(first / this.currentPageSize()) : 0; + await this.loadUsers(page); } runSearch() { this.firstRowIndex.set(0); - this.loadUsers(0, this.currentPageSize(), this.searchQuery); + this.searchQuery = this.searchField; + this.loadUsers(0); } - async loadUsers(page = 0, size = 10, search?: string) { + async loadUsers(page = 0) { this.loading.set(true); try { - const res = await firstValueFrom(this.adminUserControllerService.getUsers(page, size, search?.trim() || undefined)); + const res = await firstValueFrom(this.adminUserControllerService.getUsers(page, this.currentPageSize(), this.searchQuery?.trim() || undefined)); this.users.set(res.content ?? []); this.totalRecords.set(res.totalElements ?? 0); } catch (e) { diff --git a/Client/src/app/pages/feedback-view/feedback-view.component.ts b/Client/src/app/pages/feedback-view/feedback-view.component.ts index a8302b41..ad5dc590 100644 --- a/Client/src/app/pages/feedback-view/feedback-view.component.ts +++ b/Client/src/app/pages/feedback-view/feedback-view.component.ts @@ -1,7 +1,8 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { Component, inject, signal } from '@angular/core'; +import { Component, inject, signal, OnDestroy } from '@angular/core'; import { FeedbackControllerService, ModuleVersionUpdateRequestDTO, FeedbackDTO, GiveFeedbackDTO, ModuleVersionControllerService } from '../../core/modules/openapi'; +import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; import { FormBuilder, FormGroup, FormsModule } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; import { MessageService } from 'primeng/api'; @@ -19,11 +20,12 @@ import { MessageModule } from 'primeng/message'; imports: [FormsModule, RouterModule, ButtonModule, TextareaModule, DialogModule, ToastModule, ProgressSpinnerModule, TooltipModule, MessageModule], templateUrl: './feedback-view.component.html' }) -export class FeedbackViewComponent { +export class FeedbackViewComponent implements OnDestroy { router = inject(Router); feedbackService = inject(FeedbackControllerService); messageService = inject(MessageService); moduleVersionService = inject(ModuleVersionControllerService); + private breadcrumbLabels = inject(BreadcrumbLabelsService); feedbackForm: FormGroup; feedbackId: number | null = null; moduleVersion = signal(null); @@ -121,7 +123,10 @@ export class FeedbackViewComponent { this.loading.set(true); if (feedbackId) { this.feedbackService.getModuleVersionOfFeedback(feedbackId).subscribe({ - next: (response: ModuleVersionUpdateRequestDTO) => this.moduleVersion.set(response), + next: (response: ModuleVersionUpdateRequestDTO) => { + this.moduleVersion.set(response); + this.breadcrumbLabels.feedbackLabel.set(response?.titleEng ?? null); + }, error: (err: HttpErrorResponse) => this.error.set(err.error), complete: () => this.loading.set(false) }); @@ -224,6 +229,10 @@ export class FeedbackViewComponent { } } + ngOnDestroy(): void { + this.breadcrumbLabels.feedbackLabel.set(null); + } + giveFeedback() { if (this.feedbackId) { const feedbackDTO: FeedbackDTO = { diff --git a/Client/src/app/pages/index/index.component.html b/Client/src/app/pages/index/index.component.html index 42d854f1..24a3f946 100644 --- a/Client/src/app/pages/index/index.component.html +++ b/Client/src/app/pages/index/index.component.html @@ -34,6 +34,10 @@

Welcome, {{ user()?.firstName }} {{ user

Manage users and assign roles.

+ +

Manage degree programs and their areas of specialization.

+ +
} @if (isProfessor()) { diff --git a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts index 4c32d6c3..89434af8 100644 --- a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts +++ b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, inject, OnDestroy } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, RouterModule } from '@angular/router'; @@ -6,6 +6,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { ProposalBaseComponent } from '../../components/create-edit-base/create-edit-base.component'; import { FeedbackDepartmentPipe } from '../../pipes/feedbackDepartment.pipe'; import { ModuleVersionUpdateRequestDTO, ModuleVersionUpdateResponseDTO, ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; +import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; import { ToggleButtonGroupComponent } from '../../components/toggle-button-group/toggle-button-group.component'; import { ButtonModule } from 'primeng/button'; import { InputTextModule } from 'primeng/inputtext'; @@ -30,10 +31,11 @@ import { MessageModule } from 'primeng/message'; ], templateUrl: '../../components/create-edit-base/create-edit-base.component.html' }) -export class ModuleVersionEditComponent extends ProposalBaseComponent { +export class ModuleVersionEditComponent extends ProposalBaseComponent implements OnDestroy { override moduleVersionId: number; moduleLoading = false; feedbackLoading = false; + private breadcrumbLabels = inject(BreadcrumbLabelsService); constructor(route: ActivatedRoute) { super(); @@ -42,12 +44,20 @@ export class ModuleVersionEditComponent extends ProposalBaseComponent { this.fetchPreviousModuleVersionFeedback(this.moduleVersionId); } + ngOnDestroy(): void { + this.breadcrumbLabels.proposalTitle.set(null); + this.breadcrumbLabels.versionLabel.set(null); + } + fetchModuleVersion(moduleVersionId: number) { this.moduleLoading = true; this.moduleVersionService.getModuleVersionUpdateDtoFromId(moduleVersionId).subscribe({ next: (response: ModuleVersionUpdateRequestDTO) => { this.proposalForm.patchValue(response); this.moduleVersionDto.set(response); + const version = response?.version; + this.breadcrumbLabels.proposalTitle.set(response?.titleEng ?? null); + this.breadcrumbLabels.versionLabel.set(version != null ? `Version ${version}` : null); }, error: (err: HttpErrorResponse) => this.error.set(err.error), complete: () => { diff --git a/Client/src/app/pages/module-version-view/module-version-view.component.ts b/Client/src/app/pages/module-version-view/module-version-view.component.ts index 4457e118..0fa5aca6 100644 --- a/Client/src/app/pages/module-version-view/module-version-view.component.ts +++ b/Client/src/app/pages/module-version-view/module-version-view.component.ts @@ -1,8 +1,9 @@ import { CommonModule } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; -import { Component, inject, signal } from '@angular/core'; +import { Component, inject, signal, OnDestroy } from '@angular/core'; import { RouterModule, ActivatedRoute } from '@angular/router'; import { ModuleVersionControllerService, ModuleVersionViewDTO, ModuleVersion, ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; +import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; import { FeedbackDepartmentPipe } from '../../pipes/feedbackDepartment.pipe'; import { FeedbackStatusPipe } from '../../pipes/feedbackStatus.pipe'; import { ModuleVersionStatusPipe } from '../../pipes/moduleVersionStatus.pipe'; @@ -40,9 +41,10 @@ export interface ModuleField { ], templateUrl: './module-version-view.component.html' }) -export class ModuleVersionViewComponent { +export class ModuleVersionViewComponent implements OnDestroy { route = inject(ActivatedRoute); moduleVersionService = inject(ModuleVersionControllerService); + private breadcrumbLabels = inject(BreadcrumbLabelsService); proposalId: string | null = null; moduleVersionId: number | null = null; loading = signal(true); @@ -98,12 +100,22 @@ export class ModuleVersionViewComponent { private fetchModuleVersionViewDto(moduleVersionId: number) { this.loading.set(true); this.moduleVersionService.getModuleVersionViewDto(moduleVersionId).subscribe({ - next: (data: ModuleVersionViewDTO) => this.moduleVersionDto.set(data), + next: (data: ModuleVersionViewDTO) => { + this.moduleVersionDto.set(data); + const version = data?.version; + this.breadcrumbLabels.proposalTitle.set(data?.titleEng ?? null); + this.breadcrumbLabels.versionLabel.set(version != null ? `Version ${version}` : null); + }, error: (err: HttpErrorResponse) => this.error.set(err.error), complete: () => this.loading.set(false) }); } + ngOnDestroy(): void { + this.breadcrumbLabels.proposalTitle.set(null); + this.breadcrumbLabels.versionLabel.set(null); + } + pdfExport() { const mvid = this.moduleVersionId; if (!mvid) { diff --git a/Client/src/app/pages/proposal-view/proposal-view.component.html b/Client/src/app/pages/proposal-view/proposal-view.component.html index 0c9c9332..a3acfd93 100644 --- a/Client/src/app/pages/proposal-view/proposal-view.component.html +++ b/Client/src/app/pages/proposal-view/proposal-view.component.html @@ -22,7 +22,7 @@

Module Proposal '{{ proposal.latestModuleVersion?.titl

{{ proposal.proposalId }}

-

Latest Version

+

Total versions

{{ proposal.latestVersion }}

diff --git a/Client/src/app/pages/proposal-view/proposal-view.component.ts b/Client/src/app/pages/proposal-view/proposal-view.component.ts index 5afeac86..47a1baf8 100644 --- a/Client/src/app/pages/proposal-view/proposal-view.component.ts +++ b/Client/src/app/pages/proposal-view/proposal-view.component.ts @@ -1,5 +1,6 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, inject, signal, OnDestroy } from '@angular/core'; import { AddModuleVersionDTO, ModuleVersion, Proposal, ProposalControllerService, ProposalViewDTO } from '../../core/modules/openapi'; +import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { CommonModule } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; @@ -39,10 +40,11 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner'; }, templateUrl: './proposal-view.component.html' }) -export class ProposalViewComponent { +export class ProposalViewComponent implements OnDestroy { router = inject(Router); route = inject(ActivatedRoute); proposalService = inject(ProposalControllerService); + private breadcrumbLabels = inject(BreadcrumbLabelsService); loading = signal(true); error = signal(null); proposal = signal(null); @@ -59,7 +61,11 @@ export class ProposalViewComponent { if (proposalId) { this.loading.set(true); this.proposalService.getProposalView(proposalId).subscribe({ - next: (data: ProposalViewDTO) => this.proposal.set(data), + next: (data: ProposalViewDTO) => { + this.proposal.set(data); + const title = data?.latestModuleVersion?.titleEng ?? null; + this.breadcrumbLabels.proposalTitle.set(title); + }, error: (err: HttpErrorResponse) => this.error.set(err.error), complete: () => this.loading.set(false) }); @@ -103,4 +109,8 @@ export class ProposalViewComponent { }); } } + + ngOnDestroy(): void { + this.breadcrumbLabels.proposalTitle.set(null); + } } diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/DegreeProgramSpecializationsController.java b/Server/src/main/java/modulemanagement/ls1/controllers/DegreeProgramSpecializationsController.java new file mode 100644 index 00000000..d3948993 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/controllers/DegreeProgramSpecializationsController.java @@ -0,0 +1,45 @@ +package modulemanagement.ls1.controllers; + +import jakarta.validation.Valid; +import modulemanagement.ls1.dtos.*; +import modulemanagement.ls1.services.DegreeProgramSpecializationService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/degree-program-specializations") +@PreAuthorize("hasRole('ADMIN')") +public class DegreeProgramSpecializationsController { + + private final DegreeProgramSpecializationService degreeProgramSpecializationService; + + public DegreeProgramSpecializationsController(DegreeProgramSpecializationService degreeProgramSpecializationService) { + this.degreeProgramSpecializationService = degreeProgramSpecializationService; + } + + @GetMapping + public ResponseEntity> getAllDegreeProgramSpecializations() { + return ResponseEntity.ok(degreeProgramSpecializationService.getAllDegreeProgramSpecializations()); + } + + @PostMapping + public ResponseEntity createDegreeProgramSpecialization(@Valid @RequestBody CreateDegreeProgramSpecializationDTO dto) { + return ResponseEntity.ok(degreeProgramSpecializationService.createDegreeProgramSpecialization(dto)); + } + + @PatchMapping("/{id}") + public ResponseEntity updateDegreeProgramSpecialization( + @PathVariable Long id, + @Valid @RequestBody UpdateDegreeProgramSpecializationDTO dto) { + return ResponseEntity.ok(degreeProgramSpecializationService.updateDegreeProgramSpecialization(id, dto)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteDegreeProgramSpecialization(@PathVariable Long id) { + degreeProgramSpecializationService.deleteDegreeProgramSpecialization(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/DegreeProgramsController.java b/Server/src/main/java/modulemanagement/ls1/controllers/DegreeProgramsController.java new file mode 100644 index 00000000..44d8bcbf --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/controllers/DegreeProgramsController.java @@ -0,0 +1,64 @@ +package modulemanagement.ls1.controllers; + +import jakarta.validation.Valid; +import modulemanagement.ls1.dtos.*; +import modulemanagement.ls1.services.DegreeProgramService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/degree-programs") +@PreAuthorize("hasRole('ADMIN')") +public class DegreeProgramsController { + + private final DegreeProgramService degreeProgramService; + + public DegreeProgramsController(DegreeProgramService degreeProgramService) { + this.degreeProgramService = degreeProgramService; + } + + @GetMapping + public ResponseEntity> getAllDegreePrograms() { + return ResponseEntity.ok(degreeProgramService.getAllDegreePrograms()); + } + + @GetMapping("/{id}") + public ResponseEntity getDegreeProgram(@PathVariable Long id) { + return ResponseEntity.ok(degreeProgramService.getDegreeProgram(id)); + } + + @PostMapping + public ResponseEntity createDegreeProgram(@Valid @RequestBody CreateDegreeProgramDTO dto) { + return ResponseEntity.ok(degreeProgramService.createDegreeProgram(dto)); + } + + @PatchMapping("/{id}") + public ResponseEntity updateDegreeProgram( + @PathVariable Long id, + @Valid @RequestBody UpdateDegreeProgramDTO dto) { + return ResponseEntity.ok(degreeProgramService.updateDegreeProgram(id, dto)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteDegreeProgram(@PathVariable Long id) { + degreeProgramService.deleteDegreeProgram(id); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{degreeProgramId}/degree-program-specializations/batch") + public ResponseEntity addSpecializationsToDegreeProgram( + @PathVariable Long degreeProgramId, + @Valid @RequestBody AddSpecializationsToDegreeProgramDTO dto) { + return ResponseEntity.ok(degreeProgramService.addSpecializationsToDegreeProgram(degreeProgramId, dto.getDegreeProgramSpecializationIds())); + } + + @DeleteMapping("/{degreeProgramId}/degree-program-specializations/{degreeProgramSpecializationId}") + public ResponseEntity removeSpecializationFromDegreeProgram( + @PathVariable Long degreeProgramId, + @PathVariable Long degreeProgramSpecializationId) { + return ResponseEntity.ok(degreeProgramService.removeSpecializationFromDegreeProgram(degreeProgramId, degreeProgramSpecializationId)); + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/AddSpecializationsToDegreeProgramDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/AddSpecializationsToDegreeProgramDTO.java new file mode 100644 index 00000000..c17719c2 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/AddSpecializationsToDegreeProgramDTO.java @@ -0,0 +1,19 @@ +package modulemanagement.ls1.dtos; + +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public class AddSpecializationsToDegreeProgramDTO { + + @NotNull(message = "degreeProgramSpecializationIds must not be null") + private List degreeProgramSpecializationIds; + + public List getDegreeProgramSpecializationIds() { + return degreeProgramSpecializationIds; + } + + public void setDegreeProgramSpecializationIds(List degreeProgramSpecializationIds) { + this.degreeProgramSpecializationIds = degreeProgramSpecializationIds; + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/CreateDegreeProgramDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/CreateDegreeProgramDTO.java new file mode 100644 index 00000000..ca6197e7 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/CreateDegreeProgramDTO.java @@ -0,0 +1,15 @@ +package modulemanagement.ls1.dtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.UUID; + +@Data +public class CreateDegreeProgramDTO { + @NotBlank + private String name; + @NotNull + private UUID responsibleUserId; +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/CreateDegreeProgramSpecializationDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/CreateDegreeProgramSpecializationDTO.java new file mode 100644 index 00000000..428acd8a --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/CreateDegreeProgramSpecializationDTO.java @@ -0,0 +1,15 @@ +package modulemanagement.ls1.dtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.UUID; + +@Data +public class CreateDegreeProgramSpecializationDTO { + @NotBlank + private String name; + @NotNull + private UUID responsibleUserId; +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/DegreeProgramDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/DegreeProgramDTO.java new file mode 100644 index 00000000..d05a4005 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/DegreeProgramDTO.java @@ -0,0 +1,34 @@ +package modulemanagement.ls1.dtos; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import modulemanagement.ls1.models.DegreeProgram; + +import java.util.List; +import java.util.stream.Collectors; + +@Data +public class DegreeProgramDTO { + @NotNull + private Long degreeProgramId; + @NotNull + private String name; + @NotNull + private ResponsibleUserDTO responsibleUser; + + private List degreeProgramSpecializations; + + public static DegreeProgramDTO fromDegreeProgram(DegreeProgram program) { + DegreeProgramDTO dto = new DegreeProgramDTO(); + dto.setDegreeProgramId(program.getDegreeProgramId()); + dto.setName(program.getName()); + if (program.getResponsibleUser() != null) { + dto.setResponsibleUser(ResponsibleUserDTO.fromUser(program.getResponsibleUser())); + } + if (program.getDegreeProgramSpecializations() != null) { + dto.setDegreeProgramSpecializations(program.getDegreeProgramSpecializations().stream() + .map(DegreeProgramSpecializationDTO::fromEntity).collect(Collectors.toList())); + } + return dto; + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/DegreeProgramSpecializationDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/DegreeProgramSpecializationDTO.java new file mode 100644 index 00000000..11cf9617 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/DegreeProgramSpecializationDTO.java @@ -0,0 +1,25 @@ +package modulemanagement.ls1.dtos; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import modulemanagement.ls1.models.DegreeProgramSpecialization; + +@Data +public class DegreeProgramSpecializationDTO { + @NotNull + private Long degreeProgramSpecializationId; + @NotNull + private String name; + @NotNull + private ResponsibleUserDTO responsibleUser; + + public static DegreeProgramSpecializationDTO fromEntity(DegreeProgramSpecialization entity) { + DegreeProgramSpecializationDTO dto = new DegreeProgramSpecializationDTO(); + dto.setDegreeProgramSpecializationId(entity.getDegreeProgramSpecializationId()); + dto.setName(entity.getName()); + if (entity.getResponsibleUser() != null) { + dto.setResponsibleUser(ResponsibleUserDTO.fromUser(entity.getResponsibleUser())); + } + return dto; + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ResponsibleUserDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ResponsibleUserDTO.java new file mode 100644 index 00000000..9899d23e --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ResponsibleUserDTO.java @@ -0,0 +1,23 @@ +package modulemanagement.ls1.dtos; + +import lombok.Data; + +import java.util.UUID; + +@Data +public class ResponsibleUserDTO { + private UUID userId; + private String firstName; + private String lastName; + private String email; + + public static ResponsibleUserDTO fromUser(modulemanagement.ls1.models.User user) { + if (user == null) return null; + ResponsibleUserDTO dto = new ResponsibleUserDTO(); + dto.setUserId(user.getUserId()); + dto.setFirstName(user.getFirstName()); + dto.setLastName(user.getLastName()); + dto.setEmail(user.getEmail()); + return dto; + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/UpdateDegreeProgramDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/UpdateDegreeProgramDTO.java new file mode 100644 index 00000000..dfd14230 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/UpdateDegreeProgramDTO.java @@ -0,0 +1,11 @@ +package modulemanagement.ls1.dtos; + +import lombok.Data; + +import java.util.UUID; + +@Data +public class UpdateDegreeProgramDTO { + private String name; + private UUID responsibleUserId; +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/UpdateDegreeProgramSpecializationDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/UpdateDegreeProgramSpecializationDTO.java new file mode 100644 index 00000000..aa83b9ae --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/UpdateDegreeProgramSpecializationDTO.java @@ -0,0 +1,11 @@ +package modulemanagement.ls1.dtos; + +import lombok.Data; + +import java.util.UUID; + +@Data +public class UpdateDegreeProgramSpecializationDTO { + private String name; + private UUID responsibleUserId; +} diff --git a/Server/src/main/java/modulemanagement/ls1/models/DegreeProgram.java b/Server/src/main/java/modulemanagement/ls1/models/DegreeProgram.java new file mode 100644 index 00000000..6f83a7e6 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/models/DegreeProgram.java @@ -0,0 +1,32 @@ +package modulemanagement.ls1.models; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@NoArgsConstructor +@Entity +@Table(name = "degree_program") +public class DegreeProgram { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "degree_program_id") + private Long degreeProgramId; + + @Column(name = "name", nullable = false) + private String name; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "responsible_user_id", nullable = false) + private User responsibleUser; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "degree_program_specialization_assignment", joinColumns = @JoinColumn(name = "degree_program_id"), inverseJoinColumns = @JoinColumn(name = "degree_program_specialization_id")) + private List degreeProgramSpecializations = new ArrayList<>(); + +} diff --git a/Server/src/main/java/modulemanagement/ls1/models/DegreeProgramSpecialization.java b/Server/src/main/java/modulemanagement/ls1/models/DegreeProgramSpecialization.java new file mode 100644 index 00000000..646e9aed --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/models/DegreeProgramSpecialization.java @@ -0,0 +1,30 @@ +package modulemanagement.ls1.models; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@NoArgsConstructor +@Entity +@Table(name = "degree_program_specialization") +public class DegreeProgramSpecialization { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "degree_program_specialization_id") + private Long degreeProgramSpecializationId; + + @Column(name = "name", nullable = false) + private String name; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "responsible_user_id", nullable = false) + private User responsibleUser; + + @ManyToMany(mappedBy = "degreeProgramSpecializations", fetch = FetchType.LAZY) + private List degreePrograms = new ArrayList<>(); +} diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java new file mode 100644 index 00000000..1c54da04 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java @@ -0,0 +1,22 @@ +package modulemanagement.ls1.repositories; + +import modulemanagement.ls1.models.DegreeProgram; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DegreeProgramRepository extends JpaRepository { + + @EntityGraph(attributePaths = { "responsibleUser" }) + @Query("SELECT p FROM DegreeProgram p") + List findAllWithResponsibleUser(); + + @EntityGraph(attributePaths = { "responsibleUser", "degreeProgramSpecializations", + "degreeProgramSpecializations.responsibleUser" }) + Optional findWithSpecializationsByDegreeProgramId(Long degreeProgramId); +} diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramSpecializationRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramSpecializationRepository.java new file mode 100644 index 00000000..92d3170d --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramSpecializationRepository.java @@ -0,0 +1,23 @@ +package modulemanagement.ls1.repositories; + +import modulemanagement.ls1.models.DegreeProgramSpecialization; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DegreeProgramSpecializationRepository extends JpaRepository { + + @EntityGraph(attributePaths = { "responsibleUser" }) + @Query("SELECT s FROM DegreeProgramSpecialization s") + List findAllWithResponsibleUser(); + + @EntityGraph(attributePaths = { "responsibleUser" }) + @Query("SELECT s FROM DegreeProgramSpecialization s WHERE s.degreeProgramSpecializationId = :id") + Optional findByIdWithResponsibleUser(@Param("id") Long id); +} diff --git a/Server/src/main/java/modulemanagement/ls1/services/AdminUserService.java b/Server/src/main/java/modulemanagement/ls1/services/AdminUserService.java index eec90e77..1388666f 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/AdminUserService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/AdminUserService.java @@ -10,7 +10,6 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import java.util.ArrayList; import java.util.UUID; @Service diff --git a/Server/src/main/java/modulemanagement/ls1/services/AuthenticationService.java b/Server/src/main/java/modulemanagement/ls1/services/AuthenticationService.java index 2b5850e0..2e435759 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/AuthenticationService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/AuthenticationService.java @@ -1,12 +1,10 @@ package modulemanagement.ls1.services; -import modulemanagement.ls1.enums.UserRole; import modulemanagement.ls1.models.User; import modulemanagement.ls1.repositories.UserRepository; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.stereotype.Service; -import java.util.Collections; import java.util.Map; import java.util.Optional; import java.util.UUID; diff --git a/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java new file mode 100644 index 00000000..bfc6edaf --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java @@ -0,0 +1,106 @@ +package modulemanagement.ls1.services; + +import modulemanagement.ls1.dtos.*; +import modulemanagement.ls1.models.DegreeProgramSpecialization; +import modulemanagement.ls1.models.DegreeProgram; +import modulemanagement.ls1.repositories.DegreeProgramRepository; +import modulemanagement.ls1.repositories.DegreeProgramSpecializationRepository; +import modulemanagement.ls1.repositories.UserRepository; +import modulemanagement.ls1.shared.ResourceNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class DegreeProgramService { + + private final DegreeProgramRepository degreeProgramRepository; + private final DegreeProgramSpecializationRepository degreeProgramSpecializationRepository; + private final UserRepository userRepository; + + public DegreeProgramService(DegreeProgramRepository degreeProgramRepository, + DegreeProgramSpecializationRepository degreeProgramSpecializationRepository, + UserRepository userRepository) { + this.degreeProgramRepository = degreeProgramRepository; + this.degreeProgramSpecializationRepository = degreeProgramSpecializationRepository; + this.userRepository = userRepository; + } + + public List getAllDegreePrograms() { + return degreeProgramRepository.findAllWithResponsibleUser().stream() + .map(DegreeProgramDTO::fromDegreeProgram) + .collect(Collectors.toList()); + } + + public DegreeProgramDTO getDegreeProgram(Long id) { + DegreeProgram program = degreeProgramRepository.findWithSpecializationsByDegreeProgramId(id) + .orElseThrow(() -> new ResourceNotFoundException("Degree program not found: " + id)); + return DegreeProgramDTO.fromDegreeProgram(program); + } + + public DegreeProgramDTO createDegreeProgram(CreateDegreeProgramDTO dto) { + DegreeProgram program = new DegreeProgram(); + program.setName(dto.getName()); + program.setResponsibleUser(userRepository.getReferenceById(dto.getResponsibleUserId())); + program = degreeProgramRepository.save(program); + program = degreeProgramRepository.findWithSpecializationsByDegreeProgramId(program.getDegreeProgramId()) + .orElse(program); + return DegreeProgramDTO.fromDegreeProgram(program); + } + + public DegreeProgramDTO updateDegreeProgram(Long id, UpdateDegreeProgramDTO dto) { + DegreeProgram program = degreeProgramRepository.findWithSpecializationsByDegreeProgramId(id) + .orElseThrow(() -> new ResourceNotFoundException("Degree program not found: " + id)); + if (dto.getName() != null) + program.setName(dto.getName()); + if (dto.getResponsibleUserId() != null) + program.setResponsibleUser(userRepository.getReferenceById(dto.getResponsibleUserId())); + program = degreeProgramRepository.save(program); + program = degreeProgramRepository.findWithSpecializationsByDegreeProgramId(id).orElse(program); + return DegreeProgramDTO.fromDegreeProgram(program); + } + + public void deleteDegreeProgram(Long id) { + if (!degreeProgramRepository.existsById(id)) { + throw new ResourceNotFoundException("Degree program not found: " + id); + } + degreeProgramRepository.deleteById(id); + } + + public DegreeProgramDTO addSpecializationsToDegreeProgram(Long degreeProgramId, + List degreeProgramSpecializationIds) { + + DegreeProgram program = degreeProgramRepository.findWithSpecializationsByDegreeProgramId(degreeProgramId) + .orElseThrow(() -> new ResourceNotFoundException("Degree program not found: " + degreeProgramId)); + + if (degreeProgramSpecializationIds == null || degreeProgramSpecializationIds.isEmpty()) { + return DegreeProgramDTO.fromDegreeProgram(program); + } + + var existingIds = program.getDegreeProgramSpecializations().stream() + .map(DegreeProgramSpecialization::getDegreeProgramSpecializationId) + .collect(Collectors.toSet()); + + for (Long specId : degreeProgramSpecializationIds) { + if (existingIds.contains(specId)) + continue; + program.getDegreeProgramSpecializations() + .add(degreeProgramSpecializationRepository.getReferenceById(specId)); + existingIds.add(specId); + } + program = degreeProgramRepository.save(program); + return DegreeProgramDTO.fromDegreeProgram( + degreeProgramRepository.findWithSpecializationsByDegreeProgramId(degreeProgramId).orElse(program)); + } + + public DegreeProgramDTO removeSpecializationFromDegreeProgram(Long degreeProgramId, + Long degreeProgramSpecializationId) { + DegreeProgram program = degreeProgramRepository.findWithSpecializationsByDegreeProgramId(degreeProgramId) + .orElseThrow(() -> new ResourceNotFoundException("Degree program not found: " + degreeProgramId)); + program.getDegreeProgramSpecializations() + .removeIf(s -> s.getDegreeProgramSpecializationId().equals(degreeProgramSpecializationId)); + program = degreeProgramRepository.save(program); + return DegreeProgramDTO.fromDegreeProgram(program); + } +} diff --git a/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramSpecializationService.java b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramSpecializationService.java new file mode 100644 index 00000000..97a6a3a4 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramSpecializationService.java @@ -0,0 +1,60 @@ +package modulemanagement.ls1.services; + +import modulemanagement.ls1.dtos.*; +import modulemanagement.ls1.models.DegreeProgramSpecialization; +import modulemanagement.ls1.repositories.DegreeProgramSpecializationRepository; +import modulemanagement.ls1.repositories.UserRepository; +import modulemanagement.ls1.shared.ResourceNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class DegreeProgramSpecializationService { + + private final DegreeProgramSpecializationRepository degreeProgramSpecializationRepository; + private final UserRepository userRepository; + + public DegreeProgramSpecializationService( + DegreeProgramSpecializationRepository degreeProgramSpecializationRepository, + UserRepository userRepository) { + this.degreeProgramSpecializationRepository = degreeProgramSpecializationRepository; + this.userRepository = userRepository; + } + + public List getAllDegreeProgramSpecializations() { + return degreeProgramSpecializationRepository.findAllWithResponsibleUser().stream() + .map(DegreeProgramSpecializationDTO::fromEntity) + .collect(Collectors.toList()); + } + + public DegreeProgramSpecializationDTO createDegreeProgramSpecialization(CreateDegreeProgramSpecializationDTO dto) { + DegreeProgramSpecialization entity = new DegreeProgramSpecialization(); + entity.setName(dto.getName()); + entity.setResponsibleUser(userRepository.getReferenceById(dto.getResponsibleUserId())); + entity = degreeProgramSpecializationRepository.save(entity); + return DegreeProgramSpecializationDTO.fromEntity( + degreeProgramSpecializationRepository + .findByIdWithResponsibleUser(entity.getDegreeProgramSpecializationId()).orElse(entity)); + } + + public DegreeProgramSpecializationDTO updateDegreeProgramSpecialization(Long id, + UpdateDegreeProgramSpecializationDTO dto) { + DegreeProgramSpecialization entity = degreeProgramSpecializationRepository.findByIdWithResponsibleUser(id) + .orElseThrow(() -> new ResourceNotFoundException("Degree program specialization not found: " + id)); + if (dto.getName() != null) + entity.setName(dto.getName()); + if (dto.getResponsibleUserId() != null) + entity.setResponsibleUser(userRepository.getReferenceById(dto.getResponsibleUserId())); + entity = degreeProgramSpecializationRepository.save(entity); + return DegreeProgramSpecializationDTO.fromEntity(entity); + } + + public void deleteDegreeProgramSpecialization(Long id) { + if (!degreeProgramSpecializationRepository.existsById(id)) { + throw new ResourceNotFoundException("Degree program specialization not found: " + id); + } + degreeProgramSpecializationRepository.deleteById(id); + } +} diff --git a/Server/src/main/resources/db/changelog/changes/0008_degree_program_and_area_tables.yaml b/Server/src/main/resources/db/changelog/changes/0008_degree_program_and_area_tables.yaml new file mode 100644 index 00000000..d37e43dc --- /dev/null +++ b/Server/src/main/resources/db/changelog/changes/0008_degree_program_and_area_tables.yaml @@ -0,0 +1,83 @@ +databaseChangeLog: + - changeSet: + id: 0008-degree-program-and-area + author: system + changes: + - createTable: + tableName: degree_program + columns: + - column: + name: degree_program_id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + - column: + name: name + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: responsible_user_id + type: UUID + constraints: + nullable: false + - addForeignKeyConstraint: + baseTableName: degree_program + baseColumnNames: responsible_user_id + referencedTableName: app_user + referencedColumnNames: user_id + constraintName: degree_program_responsible_user_fk + - createTable: + tableName: degree_program_specialization + columns: + - column: + name: degree_program_specialization_id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + - column: + name: name + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: responsible_user_id + type: UUID + constraints: + nullable: false + - addForeignKeyConstraint: + baseTableName: degree_program_specialization + baseColumnNames: responsible_user_id + referencedTableName: app_user + referencedColumnNames: user_id + constraintName: degree_program_specialization_responsible_user_fk + - createTable: + tableName: degree_program_specialization_assignment + columns: + - column: + name: degree_program_id + type: BIGINT + constraints: + primaryKey: true + nullable: false + - column: + name: degree_program_specialization_id + type: BIGINT + constraints: + primaryKey: true + nullable: false + - addForeignKeyConstraint: + baseTableName: degree_program_specialization_assignment + baseColumnNames: degree_program_id + referencedTableName: degree_program + referencedColumnNames: degree_program_id + constraintName: deg_prog_spec_assign_program_fk + - addForeignKeyConstraint: + baseTableName: degree_program_specialization_assignment + baseColumnNames: degree_program_specialization_id + referencedTableName: degree_program_specialization + referencedColumnNames: degree_program_specialization_id + constraintName: deg_prog_spec_assign_spec_fk + onDelete: CASCADE diff --git a/Server/src/main/resources/db/changelog/master.yaml b/Server/src/main/resources/db/changelog/master.yaml index d770a6d3..ce435a17 100644 --- a/Server/src/main/resources/db/changelog/master.yaml +++ b/Server/src/main/resources/db/changelog/master.yaml @@ -20,3 +20,6 @@ databaseChangeLog: - include: relativeToChangelogFile: true file: changes/0007_user_roles_many.yaml + - include: + relativeToChangelogFile: true + file: changes/0008_degree_program_and_area_tables.yaml From ad4c35fc3fe1f3642989f00eb817257fa34190fc Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Sun, 1 Mar 2026 17:57:58 +0100 Subject: [PATCH 03/39] fix --- .../degree-program-details-page.component.ts | 8 ++------ .../app/pages/feedback-view/feedback-view.component.ts | 8 ++------ .../module-version-edit/module-version-edit.component.ts | 9 ++------- .../module-version-view/module-version-view.component.ts | 9 ++------- .../app/pages/proposal-view/proposal-view.component.ts | 8 ++------ 5 files changed, 10 insertions(+), 32 deletions(-) diff --git a/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.ts b/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.ts index a5340925..afe422a9 100644 --- a/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.ts +++ b/Client/src/app/pages/admin/degree-programs/degree-program-details-page.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, signal, computed, OnDestroy } from '@angular/core'; +import { Component, inject, signal, computed } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { TableModule } from 'primeng/table'; @@ -26,7 +26,7 @@ import { BreadcrumbLabelsService } from '../../../components/breadcrumb/breadcru imports: [RouterLink, FormsModule, TableModule, ButtonModule, InputTextModule, DialogModule, MultiSelectModule, TooltipModule, ToastModule, UsersSelectComponent], templateUrl: './degree-program-details-page.component.html' }) -export class DegreeProgramDetailsPageComponent implements OnDestroy { +export class DegreeProgramDetailsPageComponent { private readonly route = inject(ActivatedRoute); private readonly degreeProgramsService = inject(DegreeProgramsControllerService); private readonly specializationsService = inject(DegreeProgramSpecializationsControllerService); @@ -62,10 +62,6 @@ export class DegreeProgramDetailsPageComponent implements OnDestroy { }); } - ngOnDestroy(): void { - this.breadcrumbLabels.degreeProgramName.set(null); - } - async loadProgram(id: number) { this.loading.set(true); try { diff --git a/Client/src/app/pages/feedback-view/feedback-view.component.ts b/Client/src/app/pages/feedback-view/feedback-view.component.ts index ad5dc590..1b355839 100644 --- a/Client/src/app/pages/feedback-view/feedback-view.component.ts +++ b/Client/src/app/pages/feedback-view/feedback-view.component.ts @@ -1,6 +1,6 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { Component, inject, signal, OnDestroy } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { FeedbackControllerService, ModuleVersionUpdateRequestDTO, FeedbackDTO, GiveFeedbackDTO, ModuleVersionControllerService } from '../../core/modules/openapi'; import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; import { FormBuilder, FormGroup, FormsModule } from '@angular/forms'; @@ -20,7 +20,7 @@ import { MessageModule } from 'primeng/message'; imports: [FormsModule, RouterModule, ButtonModule, TextareaModule, DialogModule, ToastModule, ProgressSpinnerModule, TooltipModule, MessageModule], templateUrl: './feedback-view.component.html' }) -export class FeedbackViewComponent implements OnDestroy { +export class FeedbackViewComponent { router = inject(Router); feedbackService = inject(FeedbackControllerService); messageService = inject(MessageService); @@ -229,10 +229,6 @@ export class FeedbackViewComponent implements OnDestroy { } } - ngOnDestroy(): void { - this.breadcrumbLabels.feedbackLabel.set(null); - } - giveFeedback() { if (this.feedbackId) { const feedbackDTO: FeedbackDTO = { diff --git a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts index 89434af8..b8076bdd 100644 --- a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts +++ b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, OnDestroy } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, RouterModule } from '@angular/router'; @@ -31,7 +31,7 @@ import { MessageModule } from 'primeng/message'; ], templateUrl: '../../components/create-edit-base/create-edit-base.component.html' }) -export class ModuleVersionEditComponent extends ProposalBaseComponent implements OnDestroy { +export class ModuleVersionEditComponent extends ProposalBaseComponent { override moduleVersionId: number; moduleLoading = false; feedbackLoading = false; @@ -44,11 +44,6 @@ export class ModuleVersionEditComponent extends ProposalBaseComponent implements this.fetchPreviousModuleVersionFeedback(this.moduleVersionId); } - ngOnDestroy(): void { - this.breadcrumbLabels.proposalTitle.set(null); - this.breadcrumbLabels.versionLabel.set(null); - } - fetchModuleVersion(moduleVersionId: number) { this.moduleLoading = true; this.moduleVersionService.getModuleVersionUpdateDtoFromId(moduleVersionId).subscribe({ diff --git a/Client/src/app/pages/module-version-view/module-version-view.component.ts b/Client/src/app/pages/module-version-view/module-version-view.component.ts index 0fa5aca6..d25d11f6 100644 --- a/Client/src/app/pages/module-version-view/module-version-view.component.ts +++ b/Client/src/app/pages/module-version-view/module-version-view.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; -import { Component, inject, signal, OnDestroy } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { RouterModule, ActivatedRoute } from '@angular/router'; import { ModuleVersionControllerService, ModuleVersionViewDTO, ModuleVersion, ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; @@ -41,7 +41,7 @@ export interface ModuleField { ], templateUrl: './module-version-view.component.html' }) -export class ModuleVersionViewComponent implements OnDestroy { +export class ModuleVersionViewComponent { route = inject(ActivatedRoute); moduleVersionService = inject(ModuleVersionControllerService); private breadcrumbLabels = inject(BreadcrumbLabelsService); @@ -111,11 +111,6 @@ export class ModuleVersionViewComponent implements OnDestroy { }); } - ngOnDestroy(): void { - this.breadcrumbLabels.proposalTitle.set(null); - this.breadcrumbLabels.versionLabel.set(null); - } - pdfExport() { const mvid = this.moduleVersionId; if (!mvid) { diff --git a/Client/src/app/pages/proposal-view/proposal-view.component.ts b/Client/src/app/pages/proposal-view/proposal-view.component.ts index 47a1baf8..add3a87a 100644 --- a/Client/src/app/pages/proposal-view/proposal-view.component.ts +++ b/Client/src/app/pages/proposal-view/proposal-view.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, signal, OnDestroy } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { AddModuleVersionDTO, ModuleVersion, Proposal, ProposalControllerService, ProposalViewDTO } from '../../core/modules/openapi'; import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; @@ -40,7 +40,7 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner'; }, templateUrl: './proposal-view.component.html' }) -export class ProposalViewComponent implements OnDestroy { +export class ProposalViewComponent { router = inject(Router); route = inject(ActivatedRoute); proposalService = inject(ProposalControllerService); @@ -109,8 +109,4 @@ export class ProposalViewComponent implements OnDestroy { }); } } - - ngOnDestroy(): void { - this.breadcrumbLabels.proposalTitle.set(null); - } } From 7d79b14eed6ae278a391a159d5e56549bb92b474 Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Sun, 8 Mar 2026 22:00:58 +0100 Subject: [PATCH 04/39] module creation steps --- .../create-edit-base-layout.css | 22 + .../create-edit-base.component.html | 1090 +++++++++-------- .../create-edit-base.component.ts | 129 +- .../module-edit-stepper.component.css | 81 ++ .../module-edit-stepper.component.html | 14 + .../module-edit-stepper.component.ts | 27 + .../module-edit-steps.config.ts | 56 + .../proposal-list-table.component.ts | 4 +- .../modules/openapi/.openapi-generator/FILES | 4 +- .../api/degree-programs-controller.service.ts | 59 + ...ee-programs-controller.serviceInterface.ts | 6 + .../api/feedback-controller.service.ts | 10 +- .../feedback-controller.serviceInterface.ts | 4 +- .../api/module-version-controller.service.ts | 18 +- ...ule-version-controller.serviceInterface.ts | 5 +- .../api/proposal-controller.service.ts | 10 +- .../proposal-controller.serviceInterface.ts | 3 +- .../core/modules/openapi/model/feedback.ts | 2 +- .../app/core/modules/openapi/model/models.ts | 4 +- .../module-degree-program-assignment-dto.ts | 16 + .../module-version-update-request-dto.ts | 9 + .../module-version-update-response-dto.ts | 62 - .../openapi/model/module-version-view-dto.ts | 9 + .../modules/openapi/model/module-version.ts | 64 - .../openapi/model/proposal-request-dto.ts | 9 + .../core/modules/openapi/model/proposal.ts | 34 - .../openapi/model/similar-module-dto.ts | 2 +- .../feedback-view/feedback-view.component.ts | 9 +- .../module-version-edit.component.ts | 68 +- .../module-version-view.component.ts | 4 +- .../proposal-create.component.ts | 74 +- .../proposal-view/proposal-view.component.ts | 6 +- Client/src/app/pipes/proposalStatus.pipe.ts | 6 +- .../controllers/DegreeProgramsController.java | 6 + .../ls1/controllers/FeedbackController.java | 6 +- .../controllers/ModuleVersionController.java | 9 +- .../ls1/controllers/ProposalController.java | 7 +- .../ModuleDegreeProgramAssignmentDTO.java | 12 + .../dtos/ModuleVersionUpdateRequestDTO.java | 53 +- .../dtos/ModuleVersionUpdateResponseDTO.java | 21 - .../ls1/dtos/ModuleVersionViewDTO.java | 27 + .../ls1/dtos/ProposalRequestDTO.java | 18 +- .../ls1/models/ModuleVersion.java | 28 +- .../ModuleVersionDegreeProgramAssignment.java | 29 + .../modulemanagement/ls1/models/Proposal.java | 20 +- .../repositories/DegreeProgramRepository.java | 4 + ...sionDegreeProgramAssignmentRepository.java | 17 + .../ls1/services/DegreeProgramService.java | 6 + .../ls1/services/FeedbackService.java | 6 +- .../ls1/services/ModuleVersionService.java | 73 +- .../ls1/services/ProposalService.java | 92 +- ...odule_version_initial_and_assignments.yaml | 76 ++ .../initialize/0010_seed_degree_programs.yaml | 61 + ...1_seed_degree_program_specializations.yaml | 84 ++ .../main/resources/db/changelog/master.yaml | 9 + 55 files changed, 1720 insertions(+), 864 deletions(-) create mode 100644 Client/src/app/components/create-edit-base/create-edit-base-layout.css create mode 100644 Client/src/app/components/module-edit-stepper/module-edit-stepper.component.css create mode 100644 Client/src/app/components/module-edit-stepper/module-edit-stepper.component.html create mode 100644 Client/src/app/components/module-edit-stepper/module-edit-stepper.component.ts create mode 100644 Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts create mode 100644 Client/src/app/core/modules/openapi/model/module-degree-program-assignment-dto.ts delete mode 100644 Client/src/app/core/modules/openapi/model/module-version-update-response-dto.ts delete mode 100644 Client/src/app/core/modules/openapi/model/module-version.ts delete mode 100644 Client/src/app/core/modules/openapi/model/proposal.ts create mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/ModuleDegreeProgramAssignmentDTO.java delete mode 100644 Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionUpdateResponseDTO.java create mode 100644 Server/src/main/java/modulemanagement/ls1/models/ModuleVersionDegreeProgramAssignment.java create mode 100644 Server/src/main/java/modulemanagement/ls1/repositories/ModuleVersionDegreeProgramAssignmentRepository.java create mode 100644 Server/src/main/resources/db/changelog/changes/0009_module_version_initial_and_assignments.yaml create mode 100644 Server/src/main/resources/db/changelog/initialize/0010_seed_degree_programs.yaml create mode 100644 Server/src/main/resources/db/changelog/initialize/0011_seed_degree_program_specializations.yaml diff --git a/Client/src/app/components/create-edit-base/create-edit-base-layout.css b/Client/src/app/components/create-edit-base/create-edit-base-layout.css new file mode 100644 index 00000000..fbbb0231 --- /dev/null +++ b/Client/src/app/components/create-edit-base/create-edit-base-layout.css @@ -0,0 +1,22 @@ +.module-edit-layout { + display: grid; + grid-template-columns: 280px 1fr; + gap: 2rem; + margin: 0 auto; +} + +@media (max-width: 768px) { + .module-edit-layout { + grid-template-columns: 1fr; + } +} + +.module-edit-stepper-column { + position: sticky; + top: 1rem; + align-self: start; +} + +.module-edit-form-column { + min-width: 0; +} diff --git a/Client/src/app/components/create-edit-base/create-edit-base.component.html b/Client/src/app/components/create-edit-base/create-edit-base.component.html index d89c2ef4..8aa31cbf 100644 --- a/Client/src/app/components/create-edit-base/create-edit-base.component.html +++ b/Client/src/app/components/create-edit-base/create-edit-base.component.html @@ -1,523 +1,639 @@ -
-

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' + moduleVersionDto()?.titleEng : 'Create New Module Proposal' }}

- -
- -
-

Information

-

- Please fill out all information fields required for a new module. -
- After giving the module a title you can save your progress at any time and come back at another time. -

+
+ +
+

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' + moduleVersionDto()?.titleEng : 'Create New Module Proposal' }}

+ +
+ +
+

Information

+

Complete the steps on the left. You can save progress at any time and submit for feedback when ready.

+
-
- + -
-
- - @if (moduleVersionId && hasFeedback('titleFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.titleFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.titleFeedback }} -

+ + @if (currentStepIndex() === 0) { +
+
+ + @if (moduleVersionId && hasFeedback('titleFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.titleFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.titleFeedback }} +

+ } + } +
} - } - - } -
- - @if (proposalForm.get('titleEng')?.invalid && proposalForm.get('titleEng')?.touched) { - A preliminary title is required - } -
-
- -
- -
- -
-
- -
- - @if (moduleVersionId && hasFeedback('levelFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.levelFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.levelFeedback }} -

+
+ + @if (proposalForm.get('titleEng')?.invalid && proposalForm.get('titleEng')?.touched) { + A preliminary title is required + } +
+
+
+ +
+ +
+
+ +
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+ -
- - @if (moduleVersionId && hasFeedback('languageFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.languageFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.languageFeedback }} -

+ +
+ +

Assign this module to degree programs and specializations.

+ @if (loadingPrograms()) { + + } @else { +
+ @for (row of assignments(); track $index; let i = $index) { +
+ + + +
+ } + +
} - } - +
+
} -
- -
-
- -
- - @if (moduleVersionId && hasFeedback('repetitionFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.repetitionFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.repetitionFeedback }} -

+ @if (currentStepIndex() === 1) { +
+
+ + @if (moduleVersionId && hasFeedback('durationFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.durationFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.durationFeedback }} +

+ } + } +
} - } - - } -
- -
-
- -
- - @if (moduleVersionId && hasFeedback('frequencyFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.frequencyFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.frequencyFeedback }} -

+
+ +
+
+
+ + @if (moduleVersionId && hasFeedback('repetitionFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.repetitionFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.repetitionFeedback }} +

+ } + } +
} - } - - } -
- -
-
- -
-
- - @if (moduleVersionId && hasFeedback('creditsFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.creditsFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.creditsFeedback }} -

+
+ +
+
+
+
+ + @if (moduleVersionId && hasFeedback('creditsFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.creditsFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.creditsFeedback }} +

+ } + } +
} - } - - } -
- -
-
- -
- - @if (moduleVersionId && hasFeedback('hoursTotalFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.hoursTotalFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.hoursTotalFeedback }} -

+
+ +
+
+
+ + @if (moduleVersionId && hasFeedback('hoursTotalFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.hoursTotalFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.hoursTotalFeedback }} +

+ } + } +
} - } - - } -
- -
-
- -
- - @if (moduleVersionId && hasFeedback('hoursSelfStudyFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.hoursSelfStudyFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.hoursSelfStudyFeedback }} -

+
+ +
+
+
+ + @if (moduleVersionId && hasFeedback('hoursSelfStudyFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.hoursSelfStudyFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.hoursSelfStudyFeedback }} +

+ } + } +
} - } - - } -
- -
-
- -
- - @if (moduleVersionId && hasFeedback('hoursPresenceFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.hoursPresenceFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.hoursPresenceFeedback }} -

+
+ +
+
+
+ + @if (moduleVersionId && hasFeedback('hoursPresenceFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.hoursPresenceFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.hoursPresenceFeedback }} +

+ } + } +
} - } - - } -
- +
+ +
+
+
-
-
- -
- - @if (moduleVersionId && hasFeedback('examinationAchievementsFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.examinationAchievementsFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.examinationAchievementsFeedback }} -

- } - } -
} -
- -
-
- - {{ showPrompt['examination'] ? 'Hide Prompt' : 'Customize Prompt' }} - - - Generate Examination Achievements - -
- @if (showPrompt['examination']) { -
- -
- } -
- -
- - @if (moduleVersionId && hasFeedback('recommendedPrerequisitesFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.recommendedPrerequisitesFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.recommendedPrerequisitesFeedback }} -

+ @if (currentStepIndex() === 2) { +
+
+ + @if (moduleVersionId && hasFeedback('examinationAchievementsFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.examinationAchievementsFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.examinationAchievementsFeedback }} +

+ } + } +
} - } - - } -
- -
-
- -
- - @if (moduleVersionId && hasFeedback('contentFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.contentFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.contentFeedback }} -

+
+ +
+
+ + {{ showPrompt['examination'] ? 'Hide Prompt' : 'Customize Prompt' }} + + + Generate Examination Achievements + +
+ @if (showPrompt['examination']) { +
+ +
} - } -
- } -
- -
-
- - {{ showPrompt['content'] ? 'Hide Prompt' : 'Customize Prompt' }} - - Generate Content -
- @if (showPrompt['content']) { -
- -
- } -
- -
- - @if (moduleVersionId && hasFeedback('learningOutcomesFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.learningOutcomesFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.learningOutcomesFeedback }} -

+
+
+ + @if (moduleVersionId && hasFeedback('recommendedPrerequisitesFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.recommendedPrerequisitesFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.recommendedPrerequisitesFeedback }} +

+ } + } +
} - } - - } -
- -
-
- - {{ showPrompt['learning'] ? 'Hide Prompt' : 'Customize Prompt' }} - - Generate Learning Outcomes -
- @if (showPrompt['learning']) { -
- +
+ +
+
} -
- -
- - @if (moduleVersionId && hasFeedback('teachingMethodsFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.teachingMethodsFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.teachingMethodsFeedback }} -

+ @if (currentStepIndex() === 3) { +
+
+ + @if (moduleVersionId && hasFeedback('contentFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.contentFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.contentFeedback }} +

+ } + } +
} - } - - } -
- -
-
- - {{ showPrompt['teaching'] ? 'Hide Prompt' : 'Customize Prompt' }} - - Generate Teaching Methods -
- @if (showPrompt['teaching']) { -
- +
+ +
+
+ + {{ showPrompt['content'] ? 'Hide Prompt' : 'Customize Prompt' }} + + Generate Content +
+ @if (showPrompt['content']) { +
+ +
+ } +
+
+ + @if (moduleVersionId && hasFeedback('learningOutcomesFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.learningOutcomesFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.learningOutcomesFeedback }} +

+ } + } +
+ } +
+ +
+
+ + {{ showPrompt['learning'] ? 'Hide Prompt' : 'Customize Prompt' }} + + Generate Learning Outcomes +
+ @if (showPrompt['learning']) { +
+ +
+ } +
+
+ + @if (moduleVersionId && hasFeedback('teachingMethodsFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.teachingMethodsFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.teachingMethodsFeedback }} +

+ } + } +
+ } +
+ +
+
+ + {{ showPrompt['teaching'] ? 'Hide Prompt' : 'Customize Prompt' }} + + Generate Teaching Methods +
+ @if (showPrompt['teaching']) { +
+ +
+ } +
} -
+ @if (currentStepIndex() === 4) { +
+
+ + @if (moduleVersionId && hasFeedback('mediaFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.mediaFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.mediaFeedback }} +

+ } + } +
+ } +
+ +
+
-
- - @if (moduleVersionId && hasFeedback('mediaFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.mediaFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.mediaFeedback }} -

+
+ + @if (moduleVersionId && hasFeedback('literatureFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.literatureFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.literatureFeedback }} +

+ } + } +
} - } - - } -
- -
-
+
+ +
+
-
- - @if (moduleVersionId && hasFeedback('literatureFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.literatureFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.literatureFeedback }} -

+
+ + @if (moduleVersionId && hasFeedback('responsiblesFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.responsiblesFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.responsiblesFeedback }} +

+ } + } +
} - } - - } -
- -
-
+
+ +
+
-
- - @if (moduleVersionId && hasFeedback('responsiblesFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.responsiblesFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.responsiblesFeedback }} -

+
+ + @if (moduleVersionId && hasFeedback('lvSwsLecturerFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackRole) { + @if (feedback.lvSwsLecturerFeedback) { +

+ {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.lvSwsLecturerFeedback }} +

+ } + } +
} - } - +
+ +
+
+
} -
- -
-
-
- - @if (moduleVersionId && hasFeedback('lvSwsLecturerFeedback')) { - - @for (feedback of feedbacks(); track feedback.feedbackRole) { - @if (feedback.lvSwsLecturerFeedback) { -

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: - {{ feedback.lvSwsLecturerFeedback }} -

- } +
+ Cancel +
+ @if (currentStepIndex() > 0) { + Previous } - - } -
- + @if (isCreateMode()) { + + } + @if (!isCreateMode() && currentStepIndex() < MODULE_EDIT_STEPS.length - 1) { + Next + } + @if (!isCreateMode() && currentStepIndex() === MODULE_EDIT_STEPS.length - 1 && canSubmitForFeedback()) { + Submit for feedback + } + @if (!isCreateMode()) { + + {{ loading() ? 'Saving...' : 'Update proposal' }} + + } +
-
-
- Cancel - - {{ loading() ? 'Saving...' : moduleVersionDto() ? 'Update Proposal' : 'Create Proposal' }} - -
- - @if (error()) { - {{ error() }} - } - + @if (error()) { + {{ error() }} + } + +
diff --git a/Client/src/app/components/create-edit-base/create-edit-base.component.ts b/Client/src/app/components/create-edit-base/create-edit-base.component.ts index 212bd8d2..4bcfefb7 100644 --- a/Client/src/app/components/create-edit-base/create-edit-base.component.ts +++ b/Client/src/app/components/create-edit-base/create-edit-base.component.ts @@ -1,16 +1,19 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, effect, inject, signal } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { CompletionServiceRequestDTO, + DegreeProgramDTO, ModuleVersionControllerService, ProposalControllerService, - ModuleVersionUpdateRequestDTO, + ModuleVersionViewDTO, CompletionServiceResponseDTO, ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; +import { DegreeProgramsControllerService } from '../../core/modules/openapi/api/degree-programs-controller.service'; import { Location } from '@angular/common'; import { Router } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; +import { MODULE_EDIT_STEPS } from '../module-edit-stepper/module-edit-steps.config'; @Component({ template: '' @@ -21,14 +24,62 @@ export abstract class ProposalBaseComponent { protected location = inject(Location); protected moduleVersionService = inject(ModuleVersionControllerService); protected proposalService = inject(ProposalControllerService); + protected degreeProgramsService = inject(DegreeProgramsControllerService); + + readonly MODULE_EDIT_STEPS = MODULE_EDIT_STEPS; proposalForm: FormGroup; loading = signal(false); + loadingPrograms = signal(true); error = signal(null); - moduleVersionDto = signal(null); + moduleVersionDto = signal(null); moduleVersionId: number | null = null; feedbacks = signal([]); + degreePrograms = signal([]); + assignments = signal<{ degreeProgramId: number | null; degreeProgramSpecializationId: number | null }[]>([]); + + isCreateMode = computed(() => this.moduleVersionId == null); + currentStepIndex = signal(0); + + stepCompleted = computed(() => { + const form = this.proposalForm; + return MODULE_EDIT_STEPS.map((step) => { + const required = step.requiredControlNames ?? []; + if (required.length === 0) return false; + return required.every((name) => { + const c = form.get(name); + return c && c.valid && c.value != null && c.value !== ''; + }); + }); + }); + + moduleVersionStatus = computed(() => { + const dto = this.moduleVersionDto(); + return dto && 'status' in dto ? (dto as ModuleVersionViewDTO).status : undefined; + }); + + currentVersionFeedbacks = computed(() => { + const dto = this.moduleVersionDto(); + return dto && 'feedbacks' in dto ? (dto as ModuleVersionViewDTO).feedbacks ?? [] : []; + }); + + canSubmitForFeedback = computed(() => { + const dto = this.moduleVersionDto(); + const status = dto && 'status' in dto ? (dto as ModuleVersionViewDTO).status : null; + return status === 'PENDING_SUBMISSION' && this.proposalForm.valid; + }); + + private _syncAssignmentsFromDto = effect(() => { + const dto = this.moduleVersionDto(); + if (dto && 'degreeProgramAssignments' in dto && Array.isArray((dto as ModuleVersionViewDTO).degreeProgramAssignments)) { + const list = (dto as ModuleVersionViewDTO).degreeProgramAssignments ?? []; + this.assignments.set( + list.map((a) => ({ degreeProgramId: a.degreeProgramId ?? null, degreeProgramSpecializationId: a.degreeProgramSpecializationId ?? null })) + ); + } + }); + showPrompt: { [key: string]: boolean } = { examination: false, content: false, @@ -55,11 +106,18 @@ export abstract class ProposalBaseComponent { this.proposalForm = this.formBuilder.group({ bulletPoints: [''], titleEng: ['', Validators.required], + titleDe: [''], levelEng: [''], languageEng: ['English'], repetitionEng: [''], frequencyEng: [''], credits: [null], + hoursLecture: [null], + hoursExercise: [null], + hoursPractical: [null], + hoursSeminar: [null], + firstSemesterAvailable: [''], + successorModuleName: [''], duration: [''], hoursTotal: [null], hoursSelfStudy: [null], @@ -80,6 +138,71 @@ export abstract class ProposalBaseComponent { }); } + goToStep(index: number) { + this.currentStepIndex.set(index); + } + + loadDegreePrograms() { + this.loadingPrograms.set(true); + this.degreeProgramsService.getDegreeProgramsWithSpecializations().subscribe({ + next: (list) => this.degreePrograms.set(list ?? []), + error: () => this.degreePrograms.set([]), + complete: () => this.loadingPrograms.set(false) + }); + } + + addAssignment() { + this.assignments.update((a) => [...a, { degreeProgramId: null, degreeProgramSpecializationId: null }]); + } + + removeAssignment(index: number) { + this.assignments.update((a) => a.filter((_, i) => i !== index)); + } + + setAssignmentProgram(index: number, degreeProgramId: number | null) { + this.assignments.update((a) => { + const next = [...a]; + next[index] = { ...next[index], degreeProgramId, degreeProgramSpecializationId: null }; + return next; + }); + } + + setAssignmentSpecialization(index: number, degreeProgramSpecializationId: number | null) { + this.assignments.update((a) => { + const next = [...a]; + next[index] = { ...next[index], degreeProgramSpecializationId }; + return next; + }); + } + + degreeProgramsAvailableForRow(rowIndex: number): DegreeProgramDTO[] { + const selected = this.assignments().map((a) => a.degreeProgramId).filter(Boolean) as number[]; + return this.degreePrograms().filter((p) => { + const current = this.assignments()[rowIndex]?.degreeProgramId; + return !selected.includes(p.degreeProgramId) || p.degreeProgramId === current; + }); + } + + specializationsForProgram(degreeProgramId: number | null) { + if (!degreeProgramId) return []; + const program = this.degreePrograms().find((p) => p.degreeProgramId === degreeProgramId); + return program?.degreeProgramSpecializations ?? []; + } + + onProgramChange(rowIndex: number) { + this.setAssignmentSpecialization(rowIndex, null); + } + + submitForFeedback(): void { + const dto = this.moduleVersionDto(); + const proposalId = dto && 'proposalId' in dto ? (dto as ModuleVersionViewDTO).proposalId : null; + if (proposalId == null) return; + this.proposalService.submitProposal(proposalId).subscribe({ + next: () => this.router.navigate(['/proposals/view', proposalId]), + error: (err: HttpErrorResponse) => this.error.set(err.error?.message ?? err.error ?? 'Failed to submit') + }); + } + private getCompletionServiceRequestDTO(promptFieldName: string): CompletionServiceRequestDTO { const data: CompletionServiceRequestDTO = { bulletPoints: this.proposalForm.get('bulletPoints')?.value || 'New Module', diff --git a/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.css b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.css new file mode 100644 index 00000000..36cb4894 --- /dev/null +++ b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.css @@ -0,0 +1,81 @@ +.stepper-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.step-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border: 1px solid var(--p-content-border-color, #dee2e6); + border-radius: var(--p-border-radius, 6px); + background: var(--p-content-background, #fff); + cursor: pointer; + text-align: left; + width: 100%; + transition: background 0.15s, border-color 0.15s; +} + +.step-item:hover { + background: var(--p-content-hover-background, #f8f9fa); +} + +.step-item--active { + border-color: var(--p-primary-color, #60a5fa); + background: var(--p-content-hover-background, #f8f9fa); +} + +.step-item--completed .step-number { + background: var(--p-success-color, #22c55e); + color: white; +} + +.step-number { + flex-shrink: 0; + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + background: var(--p-surface-200, #e9ecef); + color: var(--p-text-muted-color, #6c757d); + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + font-weight: 600; +} + +.step-title { + flex: 1; + font-size: 1rem; +} + +.step-feedbacks { + display: flex; + gap: 0.25rem; +} + +.feedback-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--p-surface-400, #adb5bd); +} + +.feedback-dot--PENDING_SUBMISSION { + background: #94a3b8; +} + +.feedback-dot--PENDING_FEEDBACK { + background: #eab308; +} + +.feedback-dot--APPROVED, +.feedback-dot--FEEDBACK_GIVEN { + background: #22c55e; +} + +.feedback-dot--REJECTED { + background: #ef4444; +} diff --git a/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.html b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.html new file mode 100644 index 00000000..d2c15a78 --- /dev/null +++ b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.html @@ -0,0 +1,14 @@ + diff --git a/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.ts b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.ts new file mode 100644 index 00000000..82f4f120 --- /dev/null +++ b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.ts @@ -0,0 +1,27 @@ +import { Component, input, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ButtonModule } from 'primeng/button'; +import type { ModuleEditStepConfig } from './module-edit-steps.config'; +import type { ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; +import { FeedbackStatusPipe } from '../../pipes/feedbackStatus.pipe'; + +@Component({ + selector: 'app-module-edit-stepper', + standalone: true, + imports: [CommonModule, ButtonModule, FeedbackStatusPipe], + templateUrl: './module-edit-stepper.component.html', + styleUrl: './module-edit-stepper.component.css' +}) +export class ModuleEditStepperComponent { + steps = input.required(); + currentIndex = input.required(); + stepCompleted = input.required(); + feedbacks = input([]); + status = input(); + + stepChange = output(); + + goToStep(index: number) { + this.stepChange.emit(index); + } +} diff --git a/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts b/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts new file mode 100644 index 00000000..060032a6 --- /dev/null +++ b/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts @@ -0,0 +1,56 @@ +export interface ModuleEditStepConfig { + id: string; + title: string; + controlNames: string[]; + requiredControlNames?: string[]; +} + +export const MODULE_EDIT_STEPS: ModuleEditStepConfig[] = [ + { + id: 'basic', + title: 'Basic information & assignment', + controlNames: [ + 'titleEng', + 'titleDe', + 'bulletPoints', + 'credits', + 'frequencyEng', + 'hoursLecture', + 'hoursExercise', + 'hoursPractical', + 'hoursSeminar', + 'firstSemesterAvailable', + 'successorModuleName', + 'levelEng', + 'languageEng' + ], + requiredControlNames: ['titleEng'] + }, + { + id: 'schedule-workload', + title: 'Schedule & workload', + controlNames: ['duration', 'repetitionEng', 'hoursTotal', 'hoursSelfStudy', 'hoursPresence', 'credits'] + }, + { + id: 'examination-prereqs', + title: 'Examination & prerequisites', + controlNames: ['examinationAchievementsEng', 'examinationAchievementsPromptEng', 'recommendedPrerequisitesEng'] + }, + { + id: 'content-learning-teaching', + title: 'Content, learning & teaching', + controlNames: [ + 'contentEng', + 'contentPromptEng', + 'learningOutcomesEng', + 'learningOutcomesPromptEng', + 'teachingMethodsEng', + 'teachingMethodsPromptEng' + ] + }, + { + id: 'media-literature', + title: 'Media, literature & responsibles', + controlNames: ['mediaEng', 'literatureEng', 'responsiblesEng', 'lvSwsLecturerEng'] + } +]; diff --git a/Client/src/app/components/proposal-list-table/proposal-list-table.component.ts b/Client/src/app/components/proposal-list-table/proposal-list-table.component.ts index e23e1480..985100fb 100644 --- a/Client/src/app/components/proposal-list-table/proposal-list-table.component.ts +++ b/Client/src/app/components/proposal-list-table/proposal-list-table.component.ts @@ -2,7 +2,7 @@ import { Component, inject, signal } from '@angular/core'; import { TableModule } from 'primeng/table'; import { ButtonModule } from 'primeng/button'; import { TagModule } from 'primeng/tag'; -import { Proposal, ProposalControllerService, ProposalsCompactDTO } from '../../core/modules/openapi'; +import { ProposalControllerService, ProposalsCompactDTO } from '../../core/modules/openapi'; import { HttpErrorResponse } from '@angular/common/http'; import { Router, RouterModule } from '@angular/router'; @@ -25,7 +25,7 @@ export class ProposalListTableComponent { loading = signal(true); error = signal(null); proposals = signal([]); - proposalEnum = Proposal.StatusEnum; + proposalEnum = ProposalsCompactDTO.StatusEnum; user = this.securityStore.user; constructor() { diff --git a/Client/src/app/core/modules/openapi/.openapi-generator/FILES b/Client/src/app/core/modules/openapi/.openapi-generator/FILES index da40739c..4a76023e 100644 --- a/Client/src/app/core/modules/openapi/.openapi-generator/FILES +++ b/Client/src/app/core/modules/openapi/.openapi-generator/FILES @@ -34,16 +34,14 @@ model/feedback-list-item-dto.ts model/feedback.ts model/give-feedback-dto.ts model/models.ts +model/module-degree-program-assignment-dto.ts model/module-version-compact-dto.ts model/module-version-update-request-dto.ts -model/module-version-update-response-dto.ts model/module-version-view-dto.ts model/module-version-view-feedback-dto.ts -model/module-version.ts model/page-response-dto-user-dto.ts model/proposal-request-dto.ts model/proposal-view-dto.ts -model/proposal.ts model/proposals-compact-dto.ts model/responsible-user-dto.ts model/similar-module-dto.ts diff --git a/Client/src/app/core/modules/openapi/api/degree-programs-controller.service.ts b/Client/src/app/core/modules/openapi/api/degree-programs-controller.service.ts index 01150472..e319f745 100644 --- a/Client/src/app/core/modules/openapi/api/degree-programs-controller.service.ts +++ b/Client/src/app/core/modules/openapi/api/degree-programs-controller.service.ts @@ -433,6 +433,65 @@ export class DegreeProgramsControllerService implements DegreeProgramsController ); } + /** + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getDegreeProgramsWithSpecializations(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getDegreeProgramsWithSpecializations(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getDegreeProgramsWithSpecializations(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getDegreeProgramsWithSpecializations(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/admin/degree-programs/with-specializations`; + return this.httpClient.request>('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + /** * @param degreeProgramId * @param degreeProgramSpecializationId diff --git a/Client/src/app/core/modules/openapi/api/degree-programs-controller.serviceInterface.ts b/Client/src/app/core/modules/openapi/api/degree-programs-controller.serviceInterface.ts index 70fe8197..4418d811 100644 --- a/Client/src/app/core/modules/openapi/api/degree-programs-controller.serviceInterface.ts +++ b/Client/src/app/core/modules/openapi/api/degree-programs-controller.serviceInterface.ts @@ -60,6 +60,12 @@ export interface DegreeProgramsControllerServiceInterface { */ getDegreeProgram(id: number, extraHttpRequestParams?: any): Observable; + /** + * + * + */ + getDegreeProgramsWithSpecializations(extraHttpRequestParams?: any): Observable>; + /** * * diff --git a/Client/src/app/core/modules/openapi/api/feedback-controller.service.ts b/Client/src/app/core/modules/openapi/api/feedback-controller.service.ts index 528ee86b..b60bad14 100644 --- a/Client/src/app/core/modules/openapi/api/feedback-controller.service.ts +++ b/Client/src/app/core/modules/openapi/api/feedback-controller.service.ts @@ -25,7 +25,7 @@ import { FeedbackListItemDto } from '../model/feedback-list-item-dto'; // @ts-ignore import { GiveFeedbackDTO } from '../model/give-feedback-dto'; // @ts-ignore -import { ModuleVersionUpdateRequestDTO } from '../model/module-version-update-request-dto'; +import { ModuleVersionViewDTO } from '../model/module-version-view-dto'; // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; @@ -280,9 +280,9 @@ export class FeedbackControllerService implements FeedbackControllerServiceInter * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public getModuleVersionOfFeedback(feedbackId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public getModuleVersionOfFeedback(feedbackId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public getModuleVersionOfFeedback(feedbackId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getModuleVersionOfFeedback(feedbackId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getModuleVersionOfFeedback(feedbackId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getModuleVersionOfFeedback(feedbackId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; public getModuleVersionOfFeedback(feedbackId: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { if (feedbackId === null || feedbackId === undefined) { throw new Error('Required parameter feedbackId was null or undefined when calling getModuleVersionOfFeedback.'); @@ -325,7 +325,7 @@ export class FeedbackControllerService implements FeedbackControllerServiceInter } let localVarPath = `/api/feedbacks/module-version-of-feedback/${this.configuration.encodeParam({name: "feedbackId", value: feedbackId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; - return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, responseType: responseType_, diff --git a/Client/src/app/core/modules/openapi/api/feedback-controller.serviceInterface.ts b/Client/src/app/core/modules/openapi/api/feedback-controller.serviceInterface.ts index bffb04fa..e764692b 100644 --- a/Client/src/app/core/modules/openapi/api/feedback-controller.serviceInterface.ts +++ b/Client/src/app/core/modules/openapi/api/feedback-controller.serviceInterface.ts @@ -15,7 +15,7 @@ import { Feedback } from '../model/models'; import { FeedbackDTO } from '../model/models'; import { FeedbackListItemDto } from '../model/models'; import { GiveFeedbackDTO } from '../model/models'; -import { ModuleVersionUpdateRequestDTO } from '../model/models'; +import { ModuleVersionViewDTO } from '../model/models'; import { Configuration } from '../configuration'; @@ -51,7 +51,7 @@ export interface FeedbackControllerServiceInterface { * * @param feedbackId */ - getModuleVersionOfFeedback(feedbackId: number, extraHttpRequestParams?: any): Observable; + getModuleVersionOfFeedback(feedbackId: number, extraHttpRequestParams?: any): Observable; /** * diff --git a/Client/src/app/core/modules/openapi/api/module-version-controller.service.ts b/Client/src/app/core/modules/openapi/api/module-version-controller.service.ts index 03f4f3b0..8eec0648 100644 --- a/Client/src/app/core/modules/openapi/api/module-version-controller.service.ts +++ b/Client/src/app/core/modules/openapi/api/module-version-controller.service.ts @@ -23,8 +23,6 @@ import { CompletionServiceResponseDTO } from '../model/completion-service-respon // @ts-ignore import { ModuleVersionUpdateRequestDTO } from '../model/module-version-update-request-dto'; // @ts-ignore -import { ModuleVersionUpdateResponseDTO } from '../model/module-version-update-response-dto'; -// @ts-ignore import { ModuleVersionViewDTO } from '../model/module-version-view-dto'; // @ts-ignore import { ModuleVersionViewFeedbackDTO } from '../model/module-version-view-feedback-dto'; @@ -517,9 +515,9 @@ export class ModuleVersionControllerService implements ModuleVersionControllerSe * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public getModuleVersionUpdateDtoFromId(moduleVersionId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public getModuleVersionUpdateDtoFromId(moduleVersionId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public getModuleVersionUpdateDtoFromId(moduleVersionId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getModuleVersionUpdateDtoFromId(moduleVersionId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getModuleVersionUpdateDtoFromId(moduleVersionId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getModuleVersionUpdateDtoFromId(moduleVersionId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; public getModuleVersionUpdateDtoFromId(moduleVersionId: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { if (moduleVersionId === null || moduleVersionId === undefined) { throw new Error('Required parameter moduleVersionId was null or undefined when calling getModuleVersionUpdateDtoFromId.'); @@ -562,7 +560,7 @@ export class ModuleVersionControllerService implements ModuleVersionControllerSe } let localVarPath = `/api/module-versions/${this.configuration.encodeParam({name: "moduleVersionId", value: moduleVersionId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; - return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, responseType: responseType_, @@ -707,9 +705,9 @@ export class ModuleVersionControllerService implements ModuleVersionControllerSe * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public updateModuleVersion(moduleVersionId: number, moduleVersionUpdateRequestDTO: ModuleVersionUpdateRequestDTO, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public updateModuleVersion(moduleVersionId: number, moduleVersionUpdateRequestDTO: ModuleVersionUpdateRequestDTO, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public updateModuleVersion(moduleVersionId: number, moduleVersionUpdateRequestDTO: ModuleVersionUpdateRequestDTO, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public updateModuleVersion(moduleVersionId: number, moduleVersionUpdateRequestDTO: ModuleVersionUpdateRequestDTO, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public updateModuleVersion(moduleVersionId: number, moduleVersionUpdateRequestDTO: ModuleVersionUpdateRequestDTO, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public updateModuleVersion(moduleVersionId: number, moduleVersionUpdateRequestDTO: ModuleVersionUpdateRequestDTO, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; public updateModuleVersion(moduleVersionId: number, moduleVersionUpdateRequestDTO: ModuleVersionUpdateRequestDTO, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { if (moduleVersionId === null || moduleVersionId === undefined) { throw new Error('Required parameter moduleVersionId was null or undefined when calling updateModuleVersion.'); @@ -764,7 +762,7 @@ export class ModuleVersionControllerService implements ModuleVersionControllerSe } let localVarPath = `/api/module-versions/${this.configuration.encodeParam({name: "moduleVersionId", value: moduleVersionId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; - return this.httpClient.request('put', `${this.configuration.basePath}${localVarPath}`, + return this.httpClient.request('put', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, body: moduleVersionUpdateRequestDTO, diff --git a/Client/src/app/core/modules/openapi/api/module-version-controller.serviceInterface.ts b/Client/src/app/core/modules/openapi/api/module-version-controller.serviceInterface.ts index 879faf38..c1ae9d93 100644 --- a/Client/src/app/core/modules/openapi/api/module-version-controller.serviceInterface.ts +++ b/Client/src/app/core/modules/openapi/api/module-version-controller.serviceInterface.ts @@ -14,7 +14,6 @@ import { Observable } from 'rxjs'; import { CompletionServiceRequestDTO } from '../model/models'; import { CompletionServiceResponseDTO } from '../model/models'; import { ModuleVersionUpdateRequestDTO } from '../model/models'; -import { ModuleVersionUpdateResponseDTO } from '../model/models'; import { ModuleVersionViewDTO } from '../model/models'; import { ModuleVersionViewFeedbackDTO } from '../model/models'; import { SimilarModuleDTO } from '../model/models'; @@ -75,7 +74,7 @@ export interface ModuleVersionControllerServiceInterface { * * @param moduleVersionId */ - getModuleVersionUpdateDtoFromId(moduleVersionId: number, extraHttpRequestParams?: any): Observable; + getModuleVersionUpdateDtoFromId(moduleVersionId: number, extraHttpRequestParams?: any): Observable; /** * @@ -97,6 +96,6 @@ export interface ModuleVersionControllerServiceInterface { * @param moduleVersionId * @param moduleVersionUpdateRequestDTO */ - updateModuleVersion(moduleVersionId: number, moduleVersionUpdateRequestDTO: ModuleVersionUpdateRequestDTO, extraHttpRequestParams?: any): Observable; + updateModuleVersion(moduleVersionId: number, moduleVersionUpdateRequestDTO: ModuleVersionUpdateRequestDTO, extraHttpRequestParams?: any): Observable; } diff --git a/Client/src/app/core/modules/openapi/api/proposal-controller.service.ts b/Client/src/app/core/modules/openapi/api/proposal-controller.service.ts index baa121bd..306e7933 100644 --- a/Client/src/app/core/modules/openapi/api/proposal-controller.service.ts +++ b/Client/src/app/core/modules/openapi/api/proposal-controller.service.ts @@ -19,8 +19,6 @@ import { Observable } from 'rxjs'; // @ts-ignore import { AddModuleVersionDTO } from '../model/add-module-version-dto'; // @ts-ignore -import { Proposal } from '../model/proposal'; -// @ts-ignore import { ProposalRequestDTO } from '../model/proposal-request-dto'; // @ts-ignore import { ProposalViewDTO } from '../model/proposal-view-dto'; @@ -242,9 +240,9 @@ export class ProposalControllerService implements ProposalControllerServiceInter * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public createProposal(proposalRequestDTO: ProposalRequestDTO, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public createProposal(proposalRequestDTO: ProposalRequestDTO, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public createProposal(proposalRequestDTO: ProposalRequestDTO, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createProposal(proposalRequestDTO: ProposalRequestDTO, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public createProposal(proposalRequestDTO: ProposalRequestDTO, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createProposal(proposalRequestDTO: ProposalRequestDTO, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; public createProposal(proposalRequestDTO: ProposalRequestDTO, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { if (proposalRequestDTO === null || proposalRequestDTO === undefined) { throw new Error('Required parameter proposalRequestDTO was null or undefined when calling createProposal.'); @@ -296,7 +294,7 @@ export class ProposalControllerService implements ProposalControllerServiceInter } let localVarPath = `/api/proposals`; - return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, + return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, body: proposalRequestDTO, diff --git a/Client/src/app/core/modules/openapi/api/proposal-controller.serviceInterface.ts b/Client/src/app/core/modules/openapi/api/proposal-controller.serviceInterface.ts index ff92ddb5..112f9a3d 100644 --- a/Client/src/app/core/modules/openapi/api/proposal-controller.serviceInterface.ts +++ b/Client/src/app/core/modules/openapi/api/proposal-controller.serviceInterface.ts @@ -12,7 +12,6 @@ import { HttpHeaders } from '@angular/comm import { Observable } from 'rxjs'; import { AddModuleVersionDTO } from '../model/models'; -import { Proposal } from '../model/models'; import { ProposalRequestDTO } from '../model/models'; import { ProposalViewDTO } from '../model/models'; import { ProposalsCompactDTO } from '../model/models'; @@ -45,7 +44,7 @@ export interface ProposalControllerServiceInterface { * * @param proposalRequestDTO */ - createProposal(proposalRequestDTO: ProposalRequestDTO, extraHttpRequestParams?: any): Observable; + createProposal(proposalRequestDTO: ProposalRequestDTO, extraHttpRequestParams?: any): Observable; /** * diff --git a/Client/src/app/core/modules/openapi/model/feedback.ts b/Client/src/app/core/modules/openapi/model/feedback.ts index 89c91b77..1415112b 100644 --- a/Client/src/app/core/modules/openapi/model/feedback.ts +++ b/Client/src/app/core/modules/openapi/model/feedback.ts @@ -54,8 +54,8 @@ export interface Feedback { responsiblesAccepted?: boolean; lvSwsLecturerFeedback?: string; lvSwsLecturerAccepted?: boolean; - allFeedbackPositive?: boolean; feedbackGiven?: boolean; + allFeedbackPositive?: boolean; comment?: string; } export namespace Feedback { diff --git a/Client/src/app/core/modules/openapi/model/models.ts b/Client/src/app/core/modules/openapi/model/models.ts index 0ca448a1..701186b8 100644 --- a/Client/src/app/core/modules/openapi/model/models.ts +++ b/Client/src/app/core/modules/openapi/model/models.ts @@ -10,14 +10,12 @@ export * from './feedback'; export * from './feedback-dto'; export * from './feedback-list-item-dto'; export * from './give-feedback-dto'; -export * from './module-version'; +export * from './module-degree-program-assignment-dto'; export * from './module-version-compact-dto'; export * from './module-version-update-request-dto'; -export * from './module-version-update-response-dto'; export * from './module-version-view-dto'; export * from './module-version-view-feedback-dto'; export * from './page-response-dto-user-dto'; -export * from './proposal'; export * from './proposal-request-dto'; export * from './proposal-view-dto'; export * from './proposals-compact-dto'; diff --git a/Client/src/app/core/modules/openapi/model/module-degree-program-assignment-dto.ts b/Client/src/app/core/modules/openapi/model/module-degree-program-assignment-dto.ts new file mode 100644 index 00000000..7ce6a604 --- /dev/null +++ b/Client/src/app/core/modules/openapi/model/module-degree-program-assignment-dto.ts @@ -0,0 +1,16 @@ +/** + * OpenAPI definition + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface ModuleDegreeProgramAssignmentDTO { + degreeProgramId: number; + degreeProgramSpecializationId: number; +} + diff --git a/Client/src/app/core/modules/openapi/model/module-version-update-request-dto.ts b/Client/src/app/core/modules/openapi/model/module-version-update-request-dto.ts index 30be8097..72bc204e 100644 --- a/Client/src/app/core/modules/openapi/model/module-version-update-request-dto.ts +++ b/Client/src/app/core/modules/openapi/model/module-version-update-request-dto.ts @@ -7,6 +7,7 @@ * https://openapi-generator.tech * Do not edit the class manually. */ +import { ModuleDegreeProgramAssignmentDTO } from './module-degree-program-assignment-dto'; export interface ModuleVersionUpdateRequestDTO { @@ -17,10 +18,18 @@ export interface ModuleVersionUpdateRequestDTO { isComplete?: boolean; bulletPoints?: string; titleEng?: string; + titleDe?: string; levelEng?: string; languageEng?: ModuleVersionUpdateRequestDTO.LanguageEngEnum; frequencyEng?: string; credits?: number; + hoursLecture?: number; + hoursExercise?: number; + hoursPractical?: number; + hoursSeminar?: number; + firstSemesterAvailable?: string; + successorModuleName?: string; + degreeProgramAssignments?: Array; duration?: string; hoursTotal?: number; hoursSelfStudy?: number; diff --git a/Client/src/app/core/modules/openapi/model/module-version-update-response-dto.ts b/Client/src/app/core/modules/openapi/model/module-version-update-response-dto.ts deleted file mode 100644 index e801e25d..00000000 --- a/Client/src/app/core/modules/openapi/model/module-version-update-response-dto.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * OpenAPI definition - * - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -export interface ModuleVersionUpdateResponseDTO { - moduleVersionId?: number; - version?: number; - moduleId?: string; - status?: ModuleVersionUpdateResponseDTO.StatusEnum; - isComplete?: boolean; - bulletPoints?: string; - titleEng?: string; - levelEng?: string; - languageEng?: ModuleVersionUpdateResponseDTO.LanguageEngEnum; - frequencyEng?: string; - credits?: number; - duration?: string; - hoursTotal?: number; - hoursSelfStudy?: number; - hoursPresence?: number; - examinationAchievementsEng?: string; - examinationAchievementsPromptEng?: string; - repetitionEng?: string; - recommendedPrerequisitesEng?: string; - contentEng?: string; - contentPromptEng?: string; - learningOutcomesEng?: string; - learningOutcomesPromptEng?: string; - teachingMethodsEng?: string; - teachingMethodsPromptEng?: string; - mediaEng?: string; - literatureEng?: string; - responsiblesEng?: string; - lvSwsLecturerEng?: string; - proposalId: number; -} -export namespace ModuleVersionUpdateResponseDTO { - export type StatusEnum = 'PENDING_SUBMISSION' | 'PENDING_FEEDBACK' | 'ACCEPTED' | 'FEEDBACK_GIVEN' | 'REJECTED' | 'OBSOLETE' | 'CANCELLED'; - export const StatusEnum = { - PendingSubmission: 'PENDING_SUBMISSION' as StatusEnum, - PendingFeedback: 'PENDING_FEEDBACK' as StatusEnum, - Accepted: 'ACCEPTED' as StatusEnum, - FeedbackGiven: 'FEEDBACK_GIVEN' as StatusEnum, - Rejected: 'REJECTED' as StatusEnum, - Obsolete: 'OBSOLETE' as StatusEnum, - Cancelled: 'CANCELLED' as StatusEnum - }; - export type LanguageEngEnum = 'English' | 'German'; - export const LanguageEngEnum = { - English: 'English' as LanguageEngEnum, - German: 'German' as LanguageEngEnum - }; -} - - diff --git a/Client/src/app/core/modules/openapi/model/module-version-view-dto.ts b/Client/src/app/core/modules/openapi/model/module-version-view-dto.ts index 1c0a1955..1c49dcd0 100644 --- a/Client/src/app/core/modules/openapi/model/module-version-view-dto.ts +++ b/Client/src/app/core/modules/openapi/model/module-version-view-dto.ts @@ -8,6 +8,7 @@ * Do not edit the class manually. */ import { ModuleVersionViewFeedbackDTO } from './module-version-view-feedback-dto'; +import { ModuleDegreeProgramAssignmentDTO } from './module-degree-program-assignment-dto'; export interface ModuleVersionViewDTO { @@ -19,10 +20,18 @@ export interface ModuleVersionViewDTO { status?: ModuleVersionViewDTO.StatusEnum; bulletPoints?: string; titleEng?: string; + titleDe?: string; levelEng?: string; languageEng?: ModuleVersionViewDTO.LanguageEngEnum; frequencyEng?: string; credits?: number; + hoursLecture?: number; + hoursExercise?: number; + hoursPractical?: number; + hoursSeminar?: number; + firstSemesterAvailable?: string; + successorModuleName?: string; + degreeProgramAssignments?: Array; duration?: string; hoursTotal?: number; hoursSelfStudy?: number; diff --git a/Client/src/app/core/modules/openapi/model/module-version.ts b/Client/src/app/core/modules/openapi/model/module-version.ts deleted file mode 100644 index c2671141..00000000 --- a/Client/src/app/core/modules/openapi/model/module-version.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * OpenAPI definition - * - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ -import { Feedback } from './feedback'; - - -export interface ModuleVersion { - moduleVersionId?: number; - version?: number; - moduleId?: string; - creationDate?: string; - status: ModuleVersion.StatusEnum; - bulletPoints?: string; - titleEng?: string; - levelEng?: string; - languageEng?: ModuleVersion.LanguageEngEnum; - frequencyEng?: string; - credits?: number; - duration?: string; - hoursTotal?: number; - hoursSelfStudy?: number; - hoursPresence?: number; - examinationAchievementsEng?: string; - examinationAchievementsPromptEng?: string; - repetitionEng?: string; - recommendedPrerequisitesEng?: string; - contentEng?: string; - contentPromptEng?: string; - learningOutcomesEng?: string; - learningOutcomesPromptEng?: string; - teachingMethodsEng?: string; - teachingMethodsPromptEng?: string; - mediaEng?: string; - literatureEng?: string; - responsiblesEng?: string; - lvSwsLecturerEng?: string; - requiredFeedbacks?: Array; - feedbackGiven?: boolean; -} -export namespace ModuleVersion { - export type StatusEnum = 'PENDING_SUBMISSION' | 'PENDING_FEEDBACK' | 'ACCEPTED' | 'FEEDBACK_GIVEN' | 'REJECTED' | 'OBSOLETE' | 'CANCELLED'; - export const StatusEnum = { - PendingSubmission: 'PENDING_SUBMISSION' as StatusEnum, - PendingFeedback: 'PENDING_FEEDBACK' as StatusEnum, - Accepted: 'ACCEPTED' as StatusEnum, - FeedbackGiven: 'FEEDBACK_GIVEN' as StatusEnum, - Rejected: 'REJECTED' as StatusEnum, - Obsolete: 'OBSOLETE' as StatusEnum, - Cancelled: 'CANCELLED' as StatusEnum - }; - export type LanguageEngEnum = 'English' | 'German'; - export const LanguageEngEnum = { - English: 'English' as LanguageEngEnum, - German: 'German' as LanguageEngEnum - }; -} - - diff --git a/Client/src/app/core/modules/openapi/model/proposal-request-dto.ts b/Client/src/app/core/modules/openapi/model/proposal-request-dto.ts index 788a15e9..14704f7b 100644 --- a/Client/src/app/core/modules/openapi/model/proposal-request-dto.ts +++ b/Client/src/app/core/modules/openapi/model/proposal-request-dto.ts @@ -7,15 +7,24 @@ * https://openapi-generator.tech * Do not edit the class manually. */ +import { ModuleDegreeProgramAssignmentDTO } from './module-degree-program-assignment-dto'; export interface ProposalRequestDTO { bulletPoints?: string; titleEng: string; + titleDe?: string; levelEng?: string; languageEng?: ProposalRequestDTO.LanguageEngEnum; frequencyEng?: string; credits?: number; + hoursLecture?: number; + hoursExercise?: number; + hoursPractical?: number; + hoursSeminar?: number; + firstSemesterAvailable?: string; + successorModuleName?: string; + degreeProgramAssignments?: Array; duration?: string; hoursTotal?: number; hoursSelfStudy?: number; diff --git a/Client/src/app/core/modules/openapi/model/proposal.ts b/Client/src/app/core/modules/openapi/model/proposal.ts deleted file mode 100644 index b0c8dd93..00000000 --- a/Client/src/app/core/modules/openapi/model/proposal.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * OpenAPI definition - * - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ -import { ModuleVersionViewFeedbackDTO } from './module-version-view-feedback-dto'; -import { User } from './user'; -import { ModuleVersion } from './module-version'; - - -export interface Proposal { - proposalId?: number; - createdBy?: User; - creationDate?: string; - status: Proposal.StatusEnum; - moduleVersions?: Array; - previousModuleVersionFeedback?: Array; -} -export namespace Proposal { - export type StatusEnum = 'PENDING_SUBMISSION' | 'PENDING_FEEDBACK' | 'ACCEPTED' | 'REQUIRES_REVIEW' | 'REJECTED'; - export const StatusEnum = { - PendingSubmission: 'PENDING_SUBMISSION' as StatusEnum, - PendingFeedback: 'PENDING_FEEDBACK' as StatusEnum, - Accepted: 'ACCEPTED' as StatusEnum, - RequiresReview: 'REQUIRES_REVIEW' as StatusEnum, - Rejected: 'REJECTED' as StatusEnum - }; -} - - diff --git a/Client/src/app/core/modules/openapi/model/similar-module-dto.ts b/Client/src/app/core/modules/openapi/model/similar-module-dto.ts index ad23d307..5d41a7b1 100644 --- a/Client/src/app/core/modules/openapi/model/similar-module-dto.ts +++ b/Client/src/app/core/modules/openapi/model/similar-module-dto.ts @@ -14,7 +14,7 @@ export interface SimilarModuleDTO { titleEng?: string; levelEng?: string; languageEng?: string; - frequencyEng?: string; + semesterAvailability?: string; credits?: number; duration?: string; hoursTotal?: number; diff --git a/Client/src/app/pages/feedback-view/feedback-view.component.ts b/Client/src/app/pages/feedback-view/feedback-view.component.ts index 1b355839..9b18779f 100644 --- a/Client/src/app/pages/feedback-view/feedback-view.component.ts +++ b/Client/src/app/pages/feedback-view/feedback-view.component.ts @@ -1,7 +1,6 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; - import { Component, inject, signal } from '@angular/core'; -import { FeedbackControllerService, ModuleVersionUpdateRequestDTO, FeedbackDTO, GiveFeedbackDTO, ModuleVersionControllerService } from '../../core/modules/openapi'; +import { FeedbackControllerService, ModuleVersionViewDTO, FeedbackDTO, GiveFeedbackDTO, ModuleVersionControllerService } from '../../core/modules/openapi'; import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; import { FormBuilder, FormGroup, FormsModule } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; @@ -28,7 +27,7 @@ export class FeedbackViewComponent { private breadcrumbLabels = inject(BreadcrumbLabelsService); feedbackForm: FormGroup; feedbackId: number | null = null; - moduleVersion = signal(null); + moduleVersion = signal(null); loading = signal(true); error = signal(null); rejectionReason: string = ''; @@ -39,7 +38,7 @@ export class FeedbackViewComponent { fieldStates: Record = {}; fieldFeedback: Record = {}; - getModuleVersionProperty(key: keyof ModuleVersionUpdateRequestDTO): string | undefined { + getModuleVersionProperty(key: keyof ModuleVersionViewDTO): string | undefined { const mv = this.moduleVersion(); return mv ? mv[key]?.toString() : undefined; } @@ -123,7 +122,7 @@ export class FeedbackViewComponent { this.loading.set(true); if (feedbackId) { this.feedbackService.getModuleVersionOfFeedback(feedbackId).subscribe({ - next: (response: ModuleVersionUpdateRequestDTO) => { + next: (response: ModuleVersionViewDTO) => { this.moduleVersion.set(response); this.breadcrumbLabels.feedbackLabel.set(response?.titleEng ?? null); }, diff --git a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts index b8076bdd..939b7820 100644 --- a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts +++ b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts @@ -1,11 +1,17 @@ import { Component, inject } from '@angular/core'; -import { ReactiveFormsModule } from '@angular/forms'; +import { ReactiveFormsModule, FormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, RouterModule } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { ProposalBaseComponent } from '../../components/create-edit-base/create-edit-base.component'; import { FeedbackDepartmentPipe } from '../../pipes/feedbackDepartment.pipe'; -import { ModuleVersionUpdateRequestDTO, ModuleVersionUpdateResponseDTO, ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; +import { ModuleEditStepperComponent } from '../../components/module-edit-stepper/module-edit-stepper.component'; +import { + ModuleDegreeProgramAssignmentDTO, + ModuleVersionUpdateRequestDTO, + ModuleVersionViewDTO, + ModuleVersionViewFeedbackDTO +} from '../../core/modules/openapi'; import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; import { ToggleButtonGroupComponent } from '../../components/toggle-button-group/toggle-button-group.component'; import { ButtonModule } from 'primeng/button'; @@ -13,23 +19,30 @@ import { InputTextModule } from 'primeng/inputtext'; import { TextareaModule } from 'primeng/textarea'; import { InputNumberModule } from 'primeng/inputnumber'; import { MessageModule } from 'primeng/message'; +import { SelectModule } from 'primeng/select'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; @Component({ selector: 'app-module-version-edit', standalone: true, imports: [ ReactiveFormsModule, + FormsModule, CommonModule, RouterModule, FeedbackDepartmentPipe, ToggleButtonGroupComponent, + ModuleEditStepperComponent, ButtonModule, InputTextModule, TextareaModule, InputNumberModule, - MessageModule + MessageModule, + SelectModule, + ProgressSpinnerModule ], - templateUrl: '../../components/create-edit-base/create-edit-base.component.html' + templateUrl: '../../components/create-edit-base/create-edit-base.component.html', + styleUrl: '../../components/create-edit-base/create-edit-base-layout.css' }) export class ModuleVersionEditComponent extends ProposalBaseComponent { override moduleVersionId: number; @@ -40,6 +53,7 @@ export class ModuleVersionEditComponent extends ProposalBaseComponent { constructor(route: ActivatedRoute) { super(); this.moduleVersionId = Number(route.snapshot.paramMap.get('versionId')); + this.loadDegreePrograms(); this.fetchModuleVersion(this.moduleVersionId); this.fetchPreviousModuleVersionFeedback(this.moduleVersionId); } @@ -47,7 +61,7 @@ export class ModuleVersionEditComponent extends ProposalBaseComponent { fetchModuleVersion(moduleVersionId: number) { this.moduleLoading = true; this.moduleVersionService.getModuleVersionUpdateDtoFromId(moduleVersionId).subscribe({ - next: (response: ModuleVersionUpdateRequestDTO) => { + next: (response: ModuleVersionViewDTO) => { this.proposalForm.patchValue(response); this.moduleVersionDto.set(response); const version = response?.version; @@ -74,30 +88,24 @@ export class ModuleVersionEditComponent extends ProposalBaseComponent { }); } - override async onSubmit() { - if (this.proposalForm.valid && this.moduleVersionId) { - this.loading.set(true); - this.error.set(null); - - const proposalData: ModuleVersionUpdateRequestDTO = { - ...this.proposalForm.value, - moduleVersionId: this.moduleVersionId - }; - - this.moduleVersionService.updateModuleVersion(this.moduleVersionId, proposalData).subscribe({ - next: (response: ModuleVersionUpdateResponseDTO) => { - this.proposalForm.reset(); - this.router.navigate(['/proposals/view', response.proposalId], { - queryParams: { created: true } - }); - }, - error: (err: HttpErrorResponse) => { - console.error(err); - this.error.set(err.error); - this.loading.set(false); - }, - complete: () => this.loading.set(false) - }); - } + override onSubmit(): void { + if (this.moduleVersionId == null) return; + this.loading.set(true); + this.error.set(null); + const rawAssignments = this.assignments().filter((a) => a.degreeProgramId != null && a.degreeProgramSpecializationId != null); + const degreeProgramAssignments: ModuleDegreeProgramAssignmentDTO[] = rawAssignments.map((a) => ({ + degreeProgramId: a.degreeProgramId!, + degreeProgramSpecializationId: a.degreeProgramSpecializationId! + })); + const payload: ModuleVersionUpdateRequestDTO = { + ...this.proposalForm.value, + moduleVersionId: this.moduleVersionId, + degreeProgramAssignments: degreeProgramAssignments.length > 0 ? degreeProgramAssignments : undefined + }; + this.moduleVersionService.updateModuleVersion(this.moduleVersionId, payload).subscribe({ + next: (response: ModuleVersionViewDTO) => this.moduleVersionDto.set(response), + error: (err: HttpErrorResponse) => this.error.set(err.error), + complete: () => this.loading.set(false) + }); } } diff --git a/Client/src/app/pages/module-version-view/module-version-view.component.ts b/Client/src/app/pages/module-version-view/module-version-view.component.ts index d25d11f6..cc12cf97 100644 --- a/Client/src/app/pages/module-version-view/module-version-view.component.ts +++ b/Client/src/app/pages/module-version-view/module-version-view.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; import { Component, inject, signal } from '@angular/core'; import { RouterModule, ActivatedRoute } from '@angular/router'; -import { ModuleVersionControllerService, ModuleVersionViewDTO, ModuleVersion, ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; +import { ModuleVersionControllerService, ModuleVersionViewDTO, ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; import { FeedbackDepartmentPipe } from '../../pipes/feedbackDepartment.pipe'; import { FeedbackStatusPipe } from '../../pipes/feedbackStatus.pipe'; @@ -49,7 +49,7 @@ export class ModuleVersionViewComponent { moduleVersionId: number | null = null; loading = signal(true); moduleVersionDto = signal(null); - moduleVersionStatus = ModuleVersion.StatusEnum; + moduleVersionStatus = ModuleVersionViewDTO.StatusEnum; error = signal(null); moduleFields: ModuleField[] = [ diff --git a/Client/src/app/pages/proposal-create/proposal-create.component.ts b/Client/src/app/pages/proposal-create/proposal-create.component.ts index 64f9c502..20743332 100644 --- a/Client/src/app/pages/proposal-create/proposal-create.component.ts +++ b/Client/src/app/pages/proposal-create/proposal-create.component.ts @@ -1,53 +1,81 @@ import { Component } from '@angular/core'; -import { ReactiveFormsModule } from '@angular/forms'; - +import { ReactiveFormsModule, FormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; import { ProposalBaseComponent } from '../../components/create-edit-base/create-edit-base.component'; import { FeedbackDepartmentPipe } from '../../pipes/feedbackDepartment.pipe'; import { ToggleButtonGroupComponent } from '../../components/toggle-button-group/toggle-button-group.component'; +import { ModuleEditStepperComponent } from '../../components/module-edit-stepper/module-edit-stepper.component'; +import { ModuleDegreeProgramAssignmentDTO, ProposalRequestDTO } from '../../core/modules/openapi'; import { ButtonModule } from 'primeng/button'; import { InputTextModule } from 'primeng/inputtext'; import { TextareaModule } from 'primeng/textarea'; import { InputNumberModule } from 'primeng/inputnumber'; import { MessageModule } from 'primeng/message'; +import { SelectModule } from 'primeng/select'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; @Component({ selector: 'app-proposal-create', standalone: true, imports: [ ReactiveFormsModule, + FormsModule, RouterModule, FeedbackDepartmentPipe, ToggleButtonGroupComponent, + ModuleEditStepperComponent, ButtonModule, InputTextModule, TextareaModule, InputNumberModule, - MessageModule -], - templateUrl: '../../components/create-edit-base/create-edit-base.component.html' + MessageModule, + SelectModule, + ProgressSpinnerModule + ], + templateUrl: '../../components/create-edit-base/create-edit-base.component.html', + styleUrl: '../../components/create-edit-base/create-edit-base-layout.css' }) export class ProposalCreateComponent extends ProposalBaseComponent { - override async onSubmit() { - if (this.proposalForm.valid) { - this.loading.set(true); - this.error.set(null); + constructor() { + super(); + this.loadDegreePrograms(); + } - const proposalData = this.proposalForm.value; - this.proposalService.createProposal(proposalData).subscribe({ - next: (response) => { - this.proposalForm.reset(); - this.router.navigate(['/proposals/view', response.proposalId], { - queryParams: { created: true } - }); - }, - error: (err: HttpErrorResponse) => { - this.error.set(err.error); - this.loading.set(false); - }, - complete: () => this.loading.set(false) - }); + override async onSubmit(): Promise { + if (!this.proposalForm.valid) return; + + const rawAssignments = this.assignments().filter((a) => a.degreeProgramId != null && a.degreeProgramSpecializationId != null); + const programIds = rawAssignments.map((a) => a.degreeProgramId!); + if (new Set(programIds).size !== programIds.length) { + this.error.set('Each degree program can only be assigned once.'); + return; + } + const degreeProgramAssignments: ModuleDegreeProgramAssignmentDTO[] = rawAssignments.map((a) => ({ + degreeProgramId: a.degreeProgramId!, + degreeProgramSpecializationId: a.degreeProgramSpecializationId! + })); + const body: ProposalRequestDTO = { + ...this.proposalForm.value, + titleEng: this.proposalForm.value.titleEng?.trim() ?? '', + degreeProgramAssignments: degreeProgramAssignments.length > 0 ? degreeProgramAssignments : undefined + }; + this.loading.set(true); + this.error.set(null); + try { + const res = await firstValueFrom(this.proposalService.createProposal(body)); + const proposalId = res.proposalId; + const moduleVersionId = res.latestModuleVersion?.moduleVersionId; + if (proposalId != null && moduleVersionId != null) { + await this.router.navigate(['/proposals/view', proposalId, 'version', moduleVersionId, 'edit'], { queryParams: { created: true } }); + } else { + this.error.set('Unexpected response from server.'); + } + } catch (err: unknown) { + this.error.set(err instanceof HttpErrorResponse ? (err.error ?? err.message) : 'Failed to create proposal.'); + } finally { + this.loading.set(false); } } } diff --git a/Client/src/app/pages/proposal-view/proposal-view.component.ts b/Client/src/app/pages/proposal-view/proposal-view.component.ts index add3a87a..164335f4 100644 --- a/Client/src/app/pages/proposal-view/proposal-view.component.ts +++ b/Client/src/app/pages/proposal-view/proposal-view.component.ts @@ -1,5 +1,5 @@ import { Component, inject, signal } from '@angular/core'; -import { AddModuleVersionDTO, ModuleVersion, Proposal, ProposalControllerService, ProposalViewDTO } from '../../core/modules/openapi'; +import { AddModuleVersionDTO, ModuleVersionCompactDTO, ProposalControllerService, ProposalViewDTO } from '../../core/modules/openapi'; import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { CommonModule } from '@angular/common'; @@ -48,8 +48,8 @@ export class ProposalViewComponent { loading = signal(true); error = signal(null); proposal = signal(null); - proposalStatusEnum = Proposal.StatusEnum; - moduleStatusEnum = ModuleVersion.StatusEnum; + proposalStatusEnum = ProposalViewDTO.StatusEnum; + moduleStatusEnum = ModuleVersionCompactDTO.StatusEnum; constructor() { this.fetchProposal(); diff --git a/Client/src/app/pipes/proposalStatus.pipe.ts b/Client/src/app/pipes/proposalStatus.pipe.ts index 71b9de43..3bbef4cb 100644 --- a/Client/src/app/pipes/proposalStatus.pipe.ts +++ b/Client/src/app/pipes/proposalStatus.pipe.ts @@ -1,9 +1,9 @@ import { Pipe, PipeTransform } from '@angular/core'; -import { Proposal } from '../core/modules/openapi'; +import { ProposalViewDTO } from '../core/modules/openapi'; import { Tag } from 'primeng/tag'; @Pipe({ name: 'statusDisplay', standalone: true }) export class StatusDisplayPipe implements PipeTransform { - transform(status: Proposal.StatusEnum): { text: string; severity: Tag['severity'] } { + transform(status: ProposalViewDTO.StatusEnum): { text: string; severity: Tag['severity'] } { switch (status) { case 'PENDING_SUBMISSION': return { text: 'Pending Submission', severity: 'secondary' }; @@ -23,7 +23,7 @@ export class StatusDisplayPipe implements PipeTransform { @Pipe({ name: 'statusInfo', standalone: true }) export class StatusInfoPipeline implements PipeTransform { - transform(status: Proposal.StatusEnum): string { + transform(status: ProposalViewDTO.StatusEnum): string { switch (status) { case 'PENDING_SUBMISSION': return 'This module proposal is pending submission. Please fill all module information fields and submit the module proposal. After submission the necessary staff will be notified to review your proposal.'; diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/DegreeProgramsController.java b/Server/src/main/java/modulemanagement/ls1/controllers/DegreeProgramsController.java index 44d8bcbf..7e84e4bd 100644 --- a/Server/src/main/java/modulemanagement/ls1/controllers/DegreeProgramsController.java +++ b/Server/src/main/java/modulemanagement/ls1/controllers/DegreeProgramsController.java @@ -25,6 +25,12 @@ public ResponseEntity> getAllDegreePrograms() { return ResponseEntity.ok(degreeProgramService.getAllDegreePrograms()); } + @GetMapping("/with-specializations") + @PreAuthorize("hasAnyRole('ADMIN', 'PROFESSOR')") + public ResponseEntity> getDegreeProgramsWithSpecializations() { + return ResponseEntity.ok(degreeProgramService.getAllDegreeProgramsWithSpecializations()); + } + @GetMapping("/{id}") public ResponseEntity getDegreeProgram(@PathVariable Long id) { return ResponseEntity.ok(degreeProgramService.getDegreeProgram(id)); diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/FeedbackController.java b/Server/src/main/java/modulemanagement/ls1/controllers/FeedbackController.java index deb9d4f9..489c0511 100644 --- a/Server/src/main/java/modulemanagement/ls1/controllers/FeedbackController.java +++ b/Server/src/main/java/modulemanagement/ls1/controllers/FeedbackController.java @@ -2,7 +2,7 @@ import modulemanagement.ls1.dtos.FeedbackDTO; import modulemanagement.ls1.dtos.FeedbackListItemDto; -import modulemanagement.ls1.dtos.ModuleVersionUpdateRequestDTO; +import modulemanagement.ls1.dtos.ModuleVersionViewDTO; import modulemanagement.ls1.dtos.GiveFeedbackDTO; import modulemanagement.ls1.models.Feedback; import modulemanagement.ls1.models.User; @@ -39,8 +39,8 @@ public ResponseEntity> getFeedbacksForAuthenticatedUse @GetMapping("/module-version-of-feedback/{feedbackId}") @PreAuthorize("hasAnyRole('QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD')") - public ResponseEntity getModuleVersionOfFeedback(@PathVariable Long feedbackId) { - ModuleVersionUpdateRequestDTO dto = feedbackService.getModuleVersionOfFeedback(feedbackId); + public ResponseEntity getModuleVersionOfFeedback(@PathVariable Long feedbackId) { + ModuleVersionViewDTO dto = feedbackService.getModuleVersionOfFeedback(feedbackId); return ResponseEntity.ok(dto); } diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java b/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java index 58acec71..875c51fd 100644 --- a/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java +++ b/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java @@ -1,7 +1,6 @@ package modulemanagement.ls1.controllers; import modulemanagement.ls1.dtos.ModuleVersionUpdateRequestDTO; -import modulemanagement.ls1.dtos.ModuleVersionUpdateResponseDTO; import modulemanagement.ls1.dtos.ModuleVersionViewDTO; import modulemanagement.ls1.dtos.CompletionServiceResponseDTO; import modulemanagement.ls1.dtos.CompletionServiceRequestDTO; @@ -42,18 +41,18 @@ public ModuleVersionController(ModuleVersionService moduleVersionService, @GetMapping("/{moduleVersionId}") @PreAuthorize("hasAnyRole('PROFESSOR')") - public ResponseEntity getModuleVersionUpdateDtoFromId( + public ResponseEntity getModuleVersionUpdateDtoFromId( @CurrentUser User user, @PathVariable Long moduleVersionId) { - ModuleVersionUpdateResponseDTO dto = moduleVersionService.getModuleVersionUpdateDtoFromId(moduleVersionId, + ModuleVersionViewDTO dto = moduleVersionService.getModuleVersionUpdateDtoFromId(moduleVersionId, user.getUserId()); return ResponseEntity.ok(dto); } @PutMapping("/{moduleVersionId}") @PreAuthorize("hasAnyRole('PROFESSOR')") - public ResponseEntity updateModuleVersion(@CurrentUser User user, + public ResponseEntity updateModuleVersion(@CurrentUser User user, @PathVariable Long moduleVersionId, @Valid @RequestBody ModuleVersionUpdateRequestDTO moduleVersion) { - ModuleVersionUpdateResponseDTO updatedModuleVersion = moduleVersionService + ModuleVersionViewDTO updatedModuleVersion = moduleVersionService .updateModuleVersionFromRequest(user.getUserId(), moduleVersionId, moduleVersion); return ResponseEntity.ok(updatedModuleVersion); } diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/ProposalController.java b/Server/src/main/java/modulemanagement/ls1/controllers/ProposalController.java index 0e86423d..cb085f4a 100644 --- a/Server/src/main/java/modulemanagement/ls1/controllers/ProposalController.java +++ b/Server/src/main/java/modulemanagement/ls1/controllers/ProposalController.java @@ -1,7 +1,6 @@ package modulemanagement.ls1.controllers; import modulemanagement.ls1.dtos.*; -import modulemanagement.ls1.models.Proposal; import modulemanagement.ls1.models.User; import modulemanagement.ls1.services.ProposalService; import modulemanagement.ls1.shared.CurrentUser; @@ -30,11 +29,11 @@ public ProposalController(ProposalService proposalService) { @PostMapping @PreAuthorize("hasAnyRole('PROFESSOR')") - public ResponseEntity createProposal(@CurrentUser User user, + public ResponseEntity createProposal(@CurrentUser User user, @Valid @RequestBody ProposalRequestDTO request) { log.info("createProposal invoked"); - Proposal proposal = proposalService.createProposalFromRequest(user, request); - return ResponseEntity.ok(proposal); + ProposalViewDTO proposalView = proposalService.createProposalFromRequest(user, request); + return ResponseEntity.ok(proposalView); } @PostMapping(value = "/submit/{proposalId}") diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleDegreeProgramAssignmentDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleDegreeProgramAssignmentDTO.java new file mode 100644 index 00000000..36c0682e --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleDegreeProgramAssignmentDTO.java @@ -0,0 +1,12 @@ +package modulemanagement.ls1.dtos; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class ModuleDegreeProgramAssignmentDTO { + @NotNull + private Long degreeProgramId; + @NotNull + private Long degreeProgramSpecializationId; +} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionUpdateRequestDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionUpdateRequestDTO.java index ebc12ed1..0321f5a1 100644 --- a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionUpdateRequestDTO.java +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionUpdateRequestDTO.java @@ -1,10 +1,11 @@ package modulemanagement.ls1.dtos; -import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.Valid; import modulemanagement.ls1.enums.Language; import modulemanagement.ls1.enums.ModuleVersionStatus; import lombok.Data; -import modulemanagement.ls1.models.ModuleVersion; +import java.util.ArrayList; +import java.util.List; @Data public class ModuleVersionUpdateRequestDTO { @@ -15,10 +16,19 @@ public class ModuleVersionUpdateRequestDTO { private Boolean isComplete; private String bulletPoints; private String titleEng; + private String titleDe; private String levelEng; private Language languageEng; private String frequencyEng; private Integer credits; + private Integer hoursLecture; + private Integer hoursExercise; + private Integer hoursPractical; + private Integer hoursSeminar; + private String firstSemesterAvailable; + private String successorModuleName; + @Valid + private List degreeProgramAssignments = new ArrayList<>(); private String duration; private Integer hoursTotal; private Integer hoursSelfStudy; @@ -38,43 +48,4 @@ public class ModuleVersionUpdateRequestDTO { private String responsiblesEng; private String lvSwsLecturerEng; - - @JsonIgnore - public static ModuleVersionUpdateRequestDTO fromModuleVersion(ModuleVersion mv) { - ModuleVersionUpdateRequestDTO mdto = new ModuleVersionUpdateRequestDTO(); - ModuleVersionToRequestDTO(mv, mdto); - return mdto; - } - - static void ModuleVersionToRequestDTO(ModuleVersion mv, ModuleVersionUpdateRequestDTO mdto) { - mdto.setModuleVersionId(mv.getModuleVersionId()); - mdto.setVersion(mv.getVersion()); - mdto.setModuleId(mv.getModuleId()); - mdto.setStatus(mv.getStatus()); - mdto.setIsComplete(mv.isCompleted()); - mdto.setBulletPoints(mv.getBulletPoints()); - mdto.setTitleEng(mv.getTitleEng()); - mdto.setLevelEng(mv.getLevelEng()); - mdto.setLanguageEng(mv.getLanguageEng()); - mdto.setFrequencyEng(mv.getFrequencyEng()); - mdto.setCredits(mv.getCredits()); - mdto.setDuration(mv.getDuration()); - mdto.setHoursTotal(mv.getHoursTotal()); - mdto.setHoursSelfStudy(mv.getHoursSelfStudy()); - mdto.setHoursPresence(mv.getHoursPresence()); - mdto.setExaminationAchievementsEng(mv.getExaminationAchievementsEng()); - mdto.setExaminationAchievementsPromptEng(mv.getExaminationAchievementsPromptEng()); - mdto.setRepetitionEng(mv.getRepetitionEng()); - mdto.setRecommendedPrerequisitesEng(mv.getRecommendedPrerequisitesEng()); - mdto.setContentEng(mv.getContentEng()); - mdto.setContentPromptEng(mv.getContentPromptEng()); - mdto.setLearningOutcomesEng(mv.getLearningOutcomesEng()); - mdto.setLearningOutcomesPromptEng(mv.getLearningOutcomesPromptEng()); - mdto.setTeachingMethodsEng(mv.getTeachingMethodsEng()); - mdto.setTeachingMethodsPromptEng(mv.getTeachingMethodsPromptEng()); - mdto.setMediaEng(mv.getMediaEng()); - mdto.setLiteratureEng(mv.getLiteratureEng()); - mdto.setResponsiblesEng(mv.getResponsiblesEng()); - mdto.setLvSwsLecturerEng(mv.getLvSwsLecturerEng()); - } } \ No newline at end of file diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionUpdateResponseDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionUpdateResponseDTO.java deleted file mode 100644 index 4cc25827..00000000 --- a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionUpdateResponseDTO.java +++ /dev/null @@ -1,21 +0,0 @@ -package modulemanagement.ls1.dtos; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import jakarta.validation.constraints.NotNull; -import lombok.Getter; -import lombok.Setter; -import modulemanagement.ls1.models.ModuleVersion; - -@Setter -@Getter -public class ModuleVersionUpdateResponseDTO extends ModuleVersionUpdateRequestDTO { - @NotNull private Long proposalId; - - @JsonIgnore - public static ModuleVersionUpdateResponseDTO fromModuleVersion(ModuleVersion mv) { - ModuleVersionUpdateResponseDTO mdto = new ModuleVersionUpdateResponseDTO(); - ModuleVersionUpdateRequestDTO.ModuleVersionToRequestDTO(mv, mdto); - mdto.setProposalId(mv.getProposal().getProposalId()); - return mdto; - } -} diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewDTO.java index c8c6f264..2a97beca 100644 --- a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewDTO.java +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewDTO.java @@ -9,6 +9,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; @Data public class ModuleVersionViewDTO { @@ -21,10 +22,18 @@ public class ModuleVersionViewDTO { private String bulletPoints; private String titleEng; + private String titleDe; private String levelEng; private Language languageEng; private String frequencyEng; private Integer credits; + private Integer hoursLecture; + private Integer hoursExercise; + private Integer hoursPractical; + private Integer hoursSeminar; + private String firstSemesterAvailable; + private String successorModuleName; + private List degreeProgramAssignments = new ArrayList<>(); private String duration; private Integer hoursTotal; private Integer hoursSelfStudy; @@ -55,10 +64,28 @@ public static ModuleVersionViewDTO from(ModuleVersion moduleVersion) { dto.status = moduleVersion.getStatus(); dto.bulletPoints = moduleVersion.getBulletPoints(); dto.titleEng = moduleVersion.getTitleEng(); + dto.titleDe = moduleVersion.getTitleDe(); dto.levelEng = moduleVersion.getLevelEng(); dto.languageEng = moduleVersion.getLanguageEng(); dto.frequencyEng = moduleVersion.getFrequencyEng(); dto.credits = moduleVersion.getCredits(); + dto.hoursLecture = moduleVersion.getHoursLecture(); + dto.hoursExercise = moduleVersion.getHoursExercise(); + dto.hoursPractical = moduleVersion.getHoursPractical(); + dto.hoursSeminar = moduleVersion.getHoursSeminar(); + dto.firstSemesterAvailable = moduleVersion.getFirstSemesterAvailable(); + dto.successorModuleName = moduleVersion.getSuccessorModuleName(); + if (moduleVersion.getDegreeProgramAssignments() != null) { + dto.degreeProgramAssignments = moduleVersion.getDegreeProgramAssignments().stream() + .map(a -> { + ModuleDegreeProgramAssignmentDTO item = new ModuleDegreeProgramAssignmentDTO(); + item.setDegreeProgramId(a.getDegreeProgram().getDegreeProgramId()); + item.setDegreeProgramSpecializationId( + a.getDegreeProgramSpecialization().getDegreeProgramSpecializationId()); + return item; + }) + .collect(Collectors.toList()); + } dto.duration = moduleVersion.getDuration(); dto.hoursTotal = moduleVersion.getHoursTotal(); dto.hoursSelfStudy = moduleVersion.getHoursSelfStudy(); diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ProposalRequestDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ProposalRequestDTO.java index 3ad82552..ebb18315 100644 --- a/Server/src/main/java/modulemanagement/ls1/dtos/ProposalRequestDTO.java +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ProposalRequestDTO.java @@ -1,17 +1,31 @@ package modulemanagement.ls1.dtos; +import jakarta.validation.Valid; import modulemanagement.ls1.enums.Language; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; import lombok.Data; +import java.util.ArrayList; +import java.util.List; + @Data public class ProposalRequestDTO { private String bulletPoints; - @NotNull private String titleEng; + @NotBlank + private String titleEng; + private String titleDe; private String levelEng; private Language languageEng; private String frequencyEng; private Integer credits; + private Integer hoursLecture; + private Integer hoursExercise; + private Integer hoursPractical; + private Integer hoursSeminar; + private String firstSemesterAvailable; + private String successorModuleName; + @Valid + private List degreeProgramAssignments = new ArrayList<>(); private String duration; private Integer hoursTotal; private Integer hoursSelfStudy; diff --git a/Server/src/main/java/modulemanagement/ls1/models/ModuleVersion.java b/Server/src/main/java/modulemanagement/ls1/models/ModuleVersion.java index 834e51ca..9e1a7f3b 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/ModuleVersion.java +++ b/Server/src/main/java/modulemanagement/ls1/models/ModuleVersion.java @@ -10,6 +10,7 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Data @@ -31,7 +32,8 @@ public class ModuleVersion { private LocalDateTime creationDate; @Column(name = "status") - @NotNull private ModuleVersionStatus status; + @NotNull + private ModuleVersionStatus status; @Column(name = "bullet_points", columnDefinition = "CLOB") private String bulletPoints; @@ -40,6 +42,27 @@ public class ModuleVersion { @Column(name = "title_eng") private String titleEng; + @Column(name = "title_de") + private String titleDe; + + @Column(name = "hours_lecture") + private Integer hoursLecture; + + @Column(name = "hours_exercise") + private Integer hoursExercise; + + @Column(name = "hours_practical") + private Integer hoursPractical; + + @Column(name = "hours_seminar") + private Integer hoursSeminar; + + @Column(name = "first_semester_available") + private String firstSemesterAvailable; + + @Column(name = "successor_module_name") + private String successorModuleName; + @Column(name = "level_eng") private String levelEng; @@ -117,6 +140,9 @@ public class ModuleVersion { @OneToMany(mappedBy = "moduleVersion", cascade = CascadeType.ALL) private List requiredFeedbacks; + @OneToMany(mappedBy = "moduleVersion", cascade = CascadeType.ALL, orphanRemoval = true) + private List degreeProgramAssignments = new ArrayList<>(); + @JsonIgnore public boolean isCompleted() { return !StringUtils.isEmpty(titleEng) diff --git a/Server/src/main/java/modulemanagement/ls1/models/ModuleVersionDegreeProgramAssignment.java b/Server/src/main/java/modulemanagement/ls1/models/ModuleVersionDegreeProgramAssignment.java new file mode 100644 index 00000000..01ef533e --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/models/ModuleVersionDegreeProgramAssignment.java @@ -0,0 +1,29 @@ +package modulemanagement.ls1.models; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Entity +@Table(name = "module_version_degree_program_assignment", + uniqueConstraints = @UniqueConstraint(columnNames = { "module_version_id", "degree_program_id" })) +public class ModuleVersionDegreeProgramAssignment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "module_version_id", nullable = false) + private ModuleVersion moduleVersion; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "degree_program_id", nullable = false) + private DegreeProgram degreeProgram; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "degree_program_specialization_id", nullable = false) + private DegreeProgramSpecialization degreeProgramSpecialization; +} diff --git a/Server/src/main/java/modulemanagement/ls1/models/Proposal.java b/Server/src/main/java/modulemanagement/ls1/models/Proposal.java index 9d8bbc55..a45f3156 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/Proposal.java +++ b/Server/src/main/java/modulemanagement/ls1/models/Proposal.java @@ -34,7 +34,8 @@ public class Proposal { private LocalDateTime creationDate; @Column(name = "status") - @NotNull private ProposalStatus status; + @NotNull + private ProposalStatus status; @OneToMany(mappedBy = "proposal", cascade = CascadeType.ALL, orphanRemoval = true) private List moduleVersions = new ArrayList<>(); @@ -54,7 +55,6 @@ public Integer getLatestModuleVersion() { return getLatestModuleVersionWithContent().getVersion(); } - @JsonIgnore public void addNewModuleVersion() { ModuleVersion latestMv = getLatestModuleVersionWithContent(); @@ -65,6 +65,13 @@ public void addNewModuleVersion() { newMv.setStatus(ModuleVersionStatus.PENDING_SUBMISSION); newMv.setBulletPoints(latestMv.getBulletPoints()); newMv.setTitleEng(latestMv.getTitleEng()); + newMv.setTitleDe(latestMv.getTitleDe()); + newMv.setHoursLecture(latestMv.getHoursLecture()); + newMv.setHoursExercise(latestMv.getHoursExercise()); + newMv.setHoursPractical(latestMv.getHoursPractical()); + newMv.setHoursSeminar(latestMv.getHoursSeminar()); + newMv.setFirstSemesterAvailable(latestMv.getFirstSemesterAvailable()); + newMv.setSuccessorModuleName(latestMv.getSuccessorModuleName()); newMv.setLevelEng(latestMv.getLevelEng()); newMv.setLanguageEng(latestMv.getLanguageEng()); newMv.setFrequencyEng(latestMv.getFrequencyEng()); @@ -84,6 +91,15 @@ public void addNewModuleVersion() { newMv.setResponsiblesEng(latestMv.getResponsiblesEng()); newMv.setLvSwsLecturerEng(latestMv.getLvSwsLecturerEng()); + if (latestMv.getDegreeProgramAssignments() != null) { + for (var a : latestMv.getDegreeProgramAssignments()) { + ModuleVersionDegreeProgramAssignment newAssig = new ModuleVersionDegreeProgramAssignment(); + newAssig.setModuleVersion(newMv); + newAssig.setDegreeProgram(a.getDegreeProgram()); + newAssig.setDegreeProgramSpecialization(a.getDegreeProgramSpecialization()); + newMv.getDegreeProgramAssignments().add(newAssig); + } + } List requiredFeedbacks = new ArrayList<>(); ProposalService.createNewFeedbacks(newMv, requiredFeedbacks); newMv.setRequiredFeedbacks(requiredFeedbacks); diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java index 1c54da04..6f16a27d 100644 --- a/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java +++ b/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java @@ -19,4 +19,8 @@ public interface DegreeProgramRepository extends JpaRepository findWithSpecializationsByDegreeProgramId(Long degreeProgramId); + + @EntityGraph(attributePaths = { "degreeProgramSpecializations" }) + @Query("SELECT p FROM DegreeProgram p ORDER BY p.name") + List findAllWithSpecializations(); } diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/ModuleVersionDegreeProgramAssignmentRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/ModuleVersionDegreeProgramAssignmentRepository.java new file mode 100644 index 00000000..4bae49d5 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/repositories/ModuleVersionDegreeProgramAssignmentRepository.java @@ -0,0 +1,17 @@ +package modulemanagement.ls1.repositories; + +import modulemanagement.ls1.models.ModuleVersionDegreeProgramAssignment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface ModuleVersionDegreeProgramAssignmentRepository + extends JpaRepository { + + @Modifying + @Query("DELETE FROM ModuleVersionDegreeProgramAssignment a WHERE a.moduleVersion.moduleVersionId = :moduleVersionId") + void deleteByModuleVersion_ModuleVersionId(@Param("moduleVersionId") Long moduleVersionId); +} diff --git a/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java index bfc6edaf..ad5098b3 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java @@ -33,6 +33,12 @@ public List getAllDegreePrograms() { .collect(Collectors.toList()); } + public List getAllDegreeProgramsWithSpecializations() { + return degreeProgramRepository.findAllWithSpecializations().stream() + .map(DegreeProgramDTO::fromDegreeProgram) + .collect(Collectors.toList()); + } + public DegreeProgramDTO getDegreeProgram(Long id) { DegreeProgram program = degreeProgramRepository.findWithSpecializationsByDegreeProgramId(id) .orElseThrow(() -> new ResourceNotFoundException("Degree program not found: " + id)); diff --git a/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java b/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java index fd320a24..4bc2fa95 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java @@ -3,7 +3,7 @@ import jakarta.validation.constraints.NotBlank; import modulemanagement.ls1.dtos.FeedbackDTO; import modulemanagement.ls1.dtos.FeedbackListItemDto; -import modulemanagement.ls1.dtos.ModuleVersionUpdateRequestDTO; +import modulemanagement.ls1.dtos.ModuleVersionViewDTO; import modulemanagement.ls1.enums.FeedbackStatus; import modulemanagement.ls1.models.Feedback; import modulemanagement.ls1.models.User; @@ -81,10 +81,10 @@ private Feedback getPendingFeedback(Long feedbackId) { return feedback; } - public ModuleVersionUpdateRequestDTO getModuleVersionOfFeedback(Long feedbackId) { + public ModuleVersionViewDTO getModuleVersionOfFeedback(Long feedbackId) { Feedback feedback = feedbackRepository.findById(feedbackId) .orElseThrow(() -> new ResourceNotFoundException("Feedback not found")); - return ModuleVersionUpdateRequestDTO.fromModuleVersion(feedback.getModuleVersion()); + return ModuleVersionViewDTO.from(feedback.getModuleVersion()); } } diff --git a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java index 6276d540..020688ab 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java @@ -1,7 +1,7 @@ package modulemanagement.ls1.services; +import modulemanagement.ls1.dtos.ModuleDegreeProgramAssignmentDTO; import modulemanagement.ls1.dtos.ModuleVersionUpdateRequestDTO; -import modulemanagement.ls1.dtos.ModuleVersionUpdateResponseDTO; import modulemanagement.ls1.dtos.ModuleVersionViewDTO; import modulemanagement.ls1.dtos.ModuleVersionViewFeedbackDTO; import modulemanagement.ls1.dtos.SimilarModuleDTO; @@ -9,10 +9,15 @@ import modulemanagement.ls1.enums.ModuleVersionStatus; import modulemanagement.ls1.enums.ProposalStatus; import modulemanagement.ls1.enums.UserRole; +import modulemanagement.ls1.models.DegreeProgram; +import modulemanagement.ls1.models.DegreeProgramSpecialization; import modulemanagement.ls1.models.Feedback; import modulemanagement.ls1.models.ModuleVersion; +import modulemanagement.ls1.models.ModuleVersionDegreeProgramAssignment; import modulemanagement.ls1.models.Proposal; import modulemanagement.ls1.models.User; +import modulemanagement.ls1.repositories.DegreeProgramRepository; +import modulemanagement.ls1.repositories.ModuleVersionDegreeProgramAssignmentRepository; import modulemanagement.ls1.repositories.ModuleVersionRepository; import modulemanagement.ls1.repositories.ProposalRepository; import modulemanagement.ls1.shared.PdfCreator; @@ -21,24 +26,36 @@ import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; +import jakarta.persistence.EntityManager; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Service public class ModuleVersionService { private final ModuleVersionRepository moduleVersionRepository; + private final ModuleVersionDegreeProgramAssignmentRepository assignmentRepository; + private final DegreeProgramRepository degreeProgramRepository; private final ProposalRepository proposalRepository; private final OverlapDetectionService overlapDetectionService; private final PdfCreator pdfCreator; + private final EntityManager entityManager; - public ModuleVersionService(ModuleVersionRepository moduleVersionRepository, ProposalRepository proposalRepository, - OverlapDetectionService overlapDetectionService, PdfCreator pdfCreator) { + public ModuleVersionService(ModuleVersionRepository moduleVersionRepository, + ModuleVersionDegreeProgramAssignmentRepository assignmentRepository, + DegreeProgramRepository degreeProgramRepository, ProposalRepository proposalRepository, + OverlapDetectionService overlapDetectionService, PdfCreator pdfCreator, + EntityManager entityManager) { this.moduleVersionRepository = moduleVersionRepository; + this.assignmentRepository = assignmentRepository; + this.degreeProgramRepository = degreeProgramRepository; this.proposalRepository = proposalRepository; this.overlapDetectionService = overlapDetectionService; this.pdfCreator = pdfCreator; + this.entityManager = entityManager; } private boolean hasAccessPermission(Proposal proposal, User user) { @@ -46,13 +63,13 @@ private boolean hasAccessPermission(Proposal proposal, User user) { return true; } - return user.getRoles() != null && ( - user.getRoles().contains(UserRole.QUALITY_MANAGEMENT) || + return user.getRoles() != null && (user.getRoles().contains(UserRole.QUALITY_MANAGEMENT) || user.getRoles().contains(UserRole.EXAMINATION_BOARD) || user.getRoles().contains(UserRole.ACADEMIC_PROGRAM_ADVISOR)); } - public ModuleVersionUpdateResponseDTO updateModuleVersionFromRequest(UUID userId, Long moduleVersionId, + @Transactional + public ModuleVersionViewDTO updateModuleVersionFromRequest(UUID userId, Long moduleVersionId, ModuleVersionUpdateRequestDTO request) { ModuleVersion mv = moduleVersionRepository.findById(moduleVersionId) .orElseThrow(() -> new ResourceNotFoundException("ModuleVersion not found")); @@ -69,10 +86,17 @@ public ModuleVersionUpdateResponseDTO updateModuleVersionFromRequest(UUID userId mv.setBulletPoints(request.getBulletPoints()); mv.setTitleEng(request.getTitleEng()); + mv.setTitleDe(request.getTitleDe()); mv.setLevelEng(request.getLevelEng()); mv.setLanguageEng(request.getLanguageEng()); mv.setFrequencyEng(request.getFrequencyEng()); mv.setCredits(request.getCredits()); + mv.setHoursLecture(request.getHoursLecture()); + mv.setHoursExercise(request.getHoursExercise()); + mv.setHoursPractical(request.getHoursPractical()); + mv.setHoursSeminar(request.getHoursSeminar()); + mv.setFirstSemesterAvailable(request.getFirstSemesterAvailable()); + mv.setSuccessorModuleName(request.getSuccessorModuleName()); mv.setDuration(request.getDuration()); mv.setHoursTotal(request.getHoursTotal()); mv.setHoursSelfStudy(request.getHoursSelfStudy()); @@ -92,8 +116,39 @@ public ModuleVersionUpdateResponseDTO updateModuleVersionFromRequest(UUID userId mv.setResponsiblesEng(request.getResponsiblesEng()); mv.setLvSwsLecturerEng(request.getLvSwsLecturerEng()); + if (request.getDegreeProgramAssignments() != null && !request.getDegreeProgramAssignments().isEmpty()) { + List programIds = request.getDegreeProgramAssignments().stream() + .map(ModuleDegreeProgramAssignmentDTO::getDegreeProgramId) + .collect(Collectors.toList()); + if (programIds.size() != programIds.stream().distinct().count()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "A module cannot be assigned to the same degree program more than once."); + } + assignmentRepository.deleteByModuleVersion_ModuleVersionId(mv.getModuleVersionId()); + entityManager.flush(); + mv.getDegreeProgramAssignments().clear(); + for (ModuleDegreeProgramAssignmentDTO item : request.getDegreeProgramAssignments()) { + DegreeProgram program = degreeProgramRepository + .findWithSpecializationsByDegreeProgramId(item.getDegreeProgramId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Degree program not found: " + item.getDegreeProgramId())); + DegreeProgramSpecialization spec = program.getDegreeProgramSpecializations().stream() + .filter(s -> s.getDegreeProgramSpecializationId() + .equals(item.getDegreeProgramSpecializationId())) + .findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Specialization " + item.getDegreeProgramSpecializationId() + + " does not belong to degree program " + item.getDegreeProgramId())); + ModuleVersionDegreeProgramAssignment assignment = new ModuleVersionDegreeProgramAssignment(); + assignment.setModuleVersion(mv); + assignment.setDegreeProgram(program); + assignment.setDegreeProgramSpecialization(spec); + mv.getDegreeProgramAssignments().add(assignment); + } + } + mv = moduleVersionRepository.save(mv); - return ModuleVersionUpdateResponseDTO.fromModuleVersion(mv); + return ModuleVersionViewDTO.from(mv); } public void updateStatus(Long moduleVersionId) { @@ -139,7 +194,7 @@ public void updateStatus(Long moduleVersionId) { moduleVersionRepository.save(mv); } - public ModuleVersionUpdateResponseDTO getModuleVersionUpdateDtoFromId(Long moduleVersionId, UUID userId) { + public ModuleVersionViewDTO getModuleVersionUpdateDtoFromId(Long moduleVersionId, UUID userId) { var mv = moduleVersionRepository.findById(moduleVersionId) .orElseThrow(() -> new ResourceNotFoundException("Module Version not found")); Proposal p = mv.getProposal(); @@ -151,7 +206,7 @@ public ModuleVersionUpdateResponseDTO getModuleVersionUpdateDtoFromId(Long modul throw new IllegalStateException("Proposal must have at least one ModuleVersion."); } - return ModuleVersionUpdateResponseDTO.fromModuleVersion(mv); + return ModuleVersionViewDTO.from(mv); } public ModuleVersionViewDTO getModuleVersionViewDto(Long moduleVersionId, UUID userId) { diff --git a/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java b/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java index 8f876a25..810fc519 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java @@ -3,16 +3,21 @@ import jakarta.validation.Valid; import modulemanagement.ls1.dtos.*; import modulemanagement.ls1.enums.*; +import modulemanagement.ls1.models.DegreeProgram; +import modulemanagement.ls1.models.DegreeProgramSpecialization; import modulemanagement.ls1.models.Feedback; import modulemanagement.ls1.models.ModuleVersion; +import modulemanagement.ls1.models.ModuleVersionDegreeProgramAssignment; import modulemanagement.ls1.models.Proposal; import modulemanagement.ls1.models.User; +import modulemanagement.ls1.repositories.DegreeProgramRepository; import modulemanagement.ls1.repositories.FeedbackRepository; import modulemanagement.ls1.repositories.ModuleVersionRepository; import modulemanagement.ls1.repositories.ProposalRepository; import modulemanagement.ls1.shared.ResourceNotFoundException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; @@ -28,14 +33,18 @@ public class ProposalService { private final ProposalRepository proposalRepository; private final ModuleVersionRepository moduleVersionRepository; private final FeedbackRepository feedbackRepository; + private final DegreeProgramRepository degreeProgramRepository; - public ProposalService(ProposalRepository proposalRepository, ModuleVersionRepository moduleVersionRepository, FeedbackRepository feedbackRepository) { + public ProposalService(ProposalRepository proposalRepository, ModuleVersionRepository moduleVersionRepository, + FeedbackRepository feedbackRepository, DegreeProgramRepository degreeProgramRepository) { this.proposalRepository = proposalRepository; this.moduleVersionRepository = moduleVersionRepository; this.feedbackRepository = feedbackRepository; + this.degreeProgramRepository = degreeProgramRepository; } - public Proposal createProposalFromRequest(User user, ProposalRequestDTO request) { + @Transactional + public ProposalViewDTO createProposalFromRequest(User user, ProposalRequestDTO request) { Proposal p = new Proposal(); p.setCreatedBy(user); p.setCreationDate(LocalDateTime.now()); @@ -50,10 +59,17 @@ public Proposal createProposalFromRequest(User user, ProposalRequestDTO request) mv.setStatus(ModuleVersionStatus.PENDING_SUBMISSION); mv.setBulletPoints(request.getBulletPoints()); mv.setTitleEng(request.getTitleEng()); + mv.setTitleDe(request.getTitleDe()); mv.setLevelEng(request.getLevelEng()); mv.setLanguageEng(request.getLanguageEng()); mv.setFrequencyEng(request.getFrequencyEng()); mv.setCredits(request.getCredits()); + mv.setHoursLecture(request.getHoursLecture()); + mv.setHoursExercise(request.getHoursExercise()); + mv.setHoursPractical(request.getHoursPractical()); + mv.setHoursSeminar(request.getHoursSeminar()); + mv.setFirstSemesterAvailable(request.getFirstSemesterAvailable()); + mv.setSuccessorModuleName(request.getSuccessorModuleName()); mv.setDuration(request.getDuration()); mv.setHoursTotal(request.getHoursTotal()); mv.setHoursSelfStudy(request.getHoursSelfStudy()); @@ -74,6 +90,35 @@ public Proposal createProposalFromRequest(User user, ProposalRequestDTO request) mv.setLvSwsLecturerEng(request.getLvSwsLecturerEng()); mv = moduleVersionRepository.save(mv); + if (request.getDegreeProgramAssignments() != null && !request.getDegreeProgramAssignments().isEmpty()) { + List programIds = request.getDegreeProgramAssignments().stream() + .map(ModuleDegreeProgramAssignmentDTO::getDegreeProgramId) + .collect(Collectors.toList()); + if (programIds.size() != programIds.stream().distinct().count()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "A module cannot be assigned to the same degree program more than once."); + } + for (ModuleDegreeProgramAssignmentDTO item : request.getDegreeProgramAssignments()) { + DegreeProgram program = degreeProgramRepository + .findWithSpecializationsByDegreeProgramId(item.getDegreeProgramId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Degree program not found: " + item.getDegreeProgramId())); + DegreeProgramSpecialization spec = program.getDegreeProgramSpecializations().stream() + .filter(s -> s.getDegreeProgramSpecializationId() + .equals(item.getDegreeProgramSpecializationId())) + .findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Specialization " + item.getDegreeProgramSpecializationId() + + " does not belong to degree program " + item.getDegreeProgramId())); + ModuleVersionDegreeProgramAssignment assignment = new ModuleVersionDegreeProgramAssignment(); + assignment.setModuleVersion(mv); + assignment.setDegreeProgram(program); + assignment.setDegreeProgramSpecialization(spec); + mv.getDegreeProgramAssignments().add(assignment); + } + moduleVersionRepository.save(mv); + } + List feedbacks = new ArrayList<>(); createNewFeedbacks(mv, feedbacks); feedbacks = feedbackRepository.saveAll(feedbacks); @@ -81,7 +126,7 @@ public Proposal createProposalFromRequest(User user, ProposalRequestDTO request) moduleVersionRepository.save(mv); p.addModuleVersion(mv); proposalRepository.save(p); - return p; + return ProposalViewDTO.from(p); } public static void createNewFeedbacks(ModuleVersion mv, List feedbacks) { @@ -97,11 +142,14 @@ public static void createNewFeedbacks(ModuleVersion mv, List feedbacks } public ProposalViewDTO addModuleVersion(UUID userId, @Valid AddModuleVersionDTO request) { - Proposal p = proposalRepository.findById(request.getProposalId()).orElseThrow(() -> new ResourceNotFoundException("Proposal not found")); + Proposal p = proposalRepository.findById(request.getProposalId()) + .orElseThrow(() -> new ResourceNotFoundException("Proposal not found")); if (!userId.equals(p.getCreatedBy().getUserId())) - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "You cannot add a module version to a module you did not create."); + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "You cannot add a module version to a module you did not create."); if (!p.getStatus().equals(ProposalStatus.REQUIRES_REVIEW)) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "You can only add a new module version, if the proposal requires a review."); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "You can only add a new module version, if the proposal requires a review."); for (Feedback f : p.getLatestModuleVersionWithContent().getRequiredFeedbacks()) { if (f.getStatus().equals(FeedbackStatus.PENDING_FEEDBACK)) { @@ -122,9 +170,12 @@ public List getCompactProposalsOfUser(UUID userId) { p.getProposalId(), p.getCreatedBy().getFirstName(), p.getStatus(), - p.getLatestModuleVersionWithContent() != null ? p.getLatestModuleVersionWithContent().getModuleVersionId() : null, - p.getLatestModuleVersionWithContent() != null ? p.getLatestModuleVersionWithContent().getTitleEng() : null - )) + p.getLatestModuleVersionWithContent() != null + ? p.getLatestModuleVersionWithContent().getModuleVersionId() + : null, + p.getLatestModuleVersionWithContent() != null + ? p.getLatestModuleVersionWithContent().getTitleEng() + : null)) .sorted(Comparator.comparing(ProposalsCompactDTO::getProposalId)) .collect(Collectors.toList()); @@ -141,7 +192,7 @@ public ProposalViewDTO getProposalViewDtoById(UUID userId, long id) { public ProposalViewDTO submitProposal(Long proposalId, UUID userId) { Proposal proposal = proposalRepository.findById(proposalId) - .orElseThrow(() -> new IllegalArgumentException("No proposal with id " + proposalId +" found")); + .orElseThrow(() -> new IllegalArgumentException("No proposal with id " + proposalId + " found")); if (!proposal.getCreatedBy().getUserId().equals(userId)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized access"); } @@ -173,7 +224,7 @@ public ProposalViewDTO submitProposal(Long proposalId, UUID userId) { public ProposalViewDTO cancelSubmission(Long proposalId, UUID userId) { Proposal proposal = proposalRepository.findById(proposalId) - .orElseThrow(() -> new IllegalArgumentException("No proposal with id " + proposalId +" found")); + .orElseThrow(() -> new IllegalArgumentException("No proposal with id " + proposalId + " found")); if (!proposal.getCreatedBy().getUserId().equals(userId)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized access"); @@ -181,11 +232,12 @@ public ProposalViewDTO cancelSubmission(Long proposalId, UUID userId) { ModuleVersion mv = proposal.getLatestModuleVersionWithContent(); if (!mv.getStatus().equals(ModuleVersionStatus.PENDING_FEEDBACK)) { - throw new IllegalStateException("Only submitted proposals can cancel their submission. This proposal is " + mv.getStatus() + "."); + throw new IllegalStateException( + "Only submitted proposals can cancel their submission. This proposal is " + mv.getStatus() + "."); } boolean oneFeedbackNotPending = false; - for (Feedback f: mv.getRequiredFeedbacks()) { + for (Feedback f : mv.getRequiredFeedbacks()) { if (!f.getStatus().equals(FeedbackStatus.PENDING_FEEDBACK)) { oneFeedbackNotPending = true; break; @@ -193,16 +245,15 @@ public ProposalViewDTO cancelSubmission(Long proposalId, UUID userId) { } if (oneFeedbackNotPending) { - for (Feedback f: mv.getRequiredFeedbacks()) { + for (Feedback f : mv.getRequiredFeedbacks()) { if (f.getStatus().equals(FeedbackStatus.PENDING_FEEDBACK)) { f.setStatus(FeedbackStatus.CANCELLED); } } mv.setStatus(ModuleVersionStatus.CANCELLED); proposal.setStatus(ProposalStatus.REQUIRES_REVIEW); - } - else { - for (Feedback f: mv.getRequiredFeedbacks()) { + } else { + for (Feedback f : mv.getRequiredFeedbacks()) { f.setStatus(FeedbackStatus.PENDING_SUBMISSION); } mv.setStatus(ModuleVersionStatus.PENDING_SUBMISSION); @@ -213,12 +264,15 @@ public ProposalViewDTO cancelSubmission(Long proposalId, UUID userId) { } public void deleteProposalById(long proposalId, UUID userId) { - Proposal p = proposalRepository.findById(proposalId).orElseThrow(() -> new ResourceNotFoundException("Proposal with id " + proposalId + " not found.")); + Proposal p = proposalRepository.findById(proposalId) + .orElseThrow(() -> new ResourceNotFoundException("Proposal with id " + proposalId + " not found.")); if (!p.getCreatedBy().getUserId().equals(userId)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized access"); } if (p.getStatus() != ProposalStatus.PENDING_SUBMISSION) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "You can only delete a proposal that is not already submit. This module proposal is " + p.getStatus() + "."); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "You can only delete a proposal that is not already submit. This module proposal is " + + p.getStatus() + "."); } proposalRepository.delete(p); } diff --git a/Server/src/main/resources/db/changelog/changes/0009_module_version_initial_and_assignments.yaml b/Server/src/main/resources/db/changelog/changes/0009_module_version_initial_and_assignments.yaml new file mode 100644 index 00000000..56ef8012 --- /dev/null +++ b/Server/src/main/resources/db/changelog/changes/0009_module_version_initial_and_assignments.yaml @@ -0,0 +1,76 @@ +databaseChangeLog: + - changeSet: + id: 0009-module-version-initial-and-assignments + author: system + changes: + - addColumn: + tableName: module_version + columns: + - column: + name: title_de + type: VARCHAR(500) + - column: + name: hours_lecture + type: INT + - column: + name: hours_exercise + type: INT + - column: + name: hours_practical + type: INT + - column: + name: hours_seminar + type: INT + - column: + name: first_semester_available + type: VARCHAR(100) + - column: + name: successor_module_name + type: VARCHAR(500) + - createTable: + tableName: module_version_degree_program_assignment + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + - column: + name: module_version_id + type: BIGINT + constraints: + nullable: false + - column: + name: degree_program_id + type: BIGINT + constraints: + nullable: false + - column: + name: degree_program_specialization_id + type: BIGINT + constraints: + nullable: false + - addUniqueConstraint: + tableName: module_version_degree_program_assignment + columnNames: module_version_id, degree_program_id + constraintName: mv_dp_assign_unique_mv_program + - addForeignKeyConstraint: + baseTableName: module_version_degree_program_assignment + baseColumnNames: module_version_id + referencedTableName: module_version + referencedColumnNames: module_version_id + constraintName: mv_dp_assign_mv_fk + onDelete: CASCADE + - addForeignKeyConstraint: + baseTableName: module_version_degree_program_assignment + baseColumnNames: degree_program_id + referencedTableName: degree_program + referencedColumnNames: degree_program_id + constraintName: mv_dp_assign_program_fk + - addForeignKeyConstraint: + baseTableName: module_version_degree_program_assignment + baseColumnNames: degree_program_specialization_id + referencedTableName: degree_program_specialization + referencedColumnNames: degree_program_specialization_id + constraintName: mv_dp_assign_spec_fk diff --git a/Server/src/main/resources/db/changelog/initialize/0010_seed_degree_programs.yaml b/Server/src/main/resources/db/changelog/initialize/0010_seed_degree_programs.yaml new file mode 100644 index 00000000..5d693af1 --- /dev/null +++ b/Server/src/main/resources/db/changelog/initialize/0010_seed_degree_programs.yaml @@ -0,0 +1,61 @@ +databaseChangeLog: + # Seed degree programs (responsible_user_id = admin user from 0006) + - changeSet: + id: 0010-seed-degree-programs + author: system + changes: + - insert: + tableName: degree_program + columns: + - column: { name: name, value: "B.Sc. Informatics" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program + columns: + - column: { name: name, value: "B.Sc. Informatics: Games Engineering" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program + columns: + - column: { name: name, value: "B.Sc. Information Systems" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program + columns: + - column: { name: name, value: "M.Sc. Informatics" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program + columns: + - column: { name: name, value: "M.Sc. Informatics: Games Engineering" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program + columns: + - column: { name: name, value: "M.Sc. Information Systems" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program + columns: + - column: { name: name, value: "M.Sc. Data Engineering and Analytics" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program + columns: + - column: { name: name, value: "B.Sc. and M.Sc. Information Engineering (Campus Heilbronn)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program + columns: + - column: { name: name, value: "B.Sc. and M.Sc. Bioinformatics" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program + columns: + - column: { name: name, value: "M.Sc. Robotics, Cognition, Intelligence" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program + columns: + - column: { name: name, value: "M.Sc. Computational Science and Engineering" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } diff --git a/Server/src/main/resources/db/changelog/initialize/0011_seed_degree_program_specializations.yaml b/Server/src/main/resources/db/changelog/initialize/0011_seed_degree_program_specializations.yaml new file mode 100644 index 00000000..76cefa36 --- /dev/null +++ b/Server/src/main/resources/db/changelog/initialize/0011_seed_degree_program_specializations.yaml @@ -0,0 +1,84 @@ +databaseChangeLog: + # Seed areas of specialization (responsible_user_id = admin user from 0006) + - changeSet: + id: 0011-seed-specializations + author: system + changes: + - insert: + tableName: degree_program_specialization + columns: + - column: { name: name, value: "Engineering Software-intensive Systems (SE)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program_specialization + columns: + - column: { name: name, value: "Databases and Information Systems (DBI)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program_specialization + columns: + - column: { name: name, value: "Robotics (ROB)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program_specialization + columns: + - column: { name: name, value: "Machine Learning and Analytics (MLA)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program_specialization + columns: + - column: { name: name, value: "Computer Graphics and Vision (CGV)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program_specialization + columns: + - column: { name: name, value: "Computer Architecture, Computer Networks and Distributed Systems (RRV)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program_specialization + columns: + - column: { name: name, value: "Security and Privacy (SP)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program_specialization + columns: + - column: { name: name, value: "Algorithms (ALG)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program_specialization + columns: + - column: { name: name, value: "Scientific Computing and High Performance Computing (HPC)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program_specialization + columns: + - column: { name: name, value: "Formal Methods and their Applications (FMA)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program_specialization + columns: + - column: { name: name, value: "Human Centered Engineering (HCE)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program_specialization + columns: + - column: { name: name, value: "Digital Biology and Digital Medicine (DBM)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + - insert: + tableName: degree_program_specialization + columns: + - column: { name: name, value: "Support Electives (UFG)" } + - column: { name: responsible_user_id, value: "f47ac10b-58cc-4372-a567-0e02b2c3d479" } + + # Assign all 13 specializations to all 11 degree programs (from 0010) + - changeSet: + id: 0011-assign-specializations-to-programs + author: system + changes: + - sql: + - INSERT INTO degree_program_specialization_assignment (degree_program_id, degree_program_specialization_id) + SELECT dp.degree_program_id, ds.degree_program_specialization_id + FROM degree_program dp + CROSS JOIN degree_program_specialization ds + WHERE dp.degree_program_id BETWEEN 1 AND 11 + AND ds.degree_program_specialization_id BETWEEN 1 AND 13 diff --git a/Server/src/main/resources/db/changelog/master.yaml b/Server/src/main/resources/db/changelog/master.yaml index ce435a17..d2170be5 100644 --- a/Server/src/main/resources/db/changelog/master.yaml +++ b/Server/src/main/resources/db/changelog/master.yaml @@ -23,3 +23,12 @@ databaseChangeLog: - include: relativeToChangelogFile: true file: changes/0008_degree_program_and_area_tables.yaml + - include: + relativeToChangelogFile: true + file: changes/0009_module_version_initial_and_assignments.yaml + - include: + relativeToChangelogFile: true + file: initialize/0010_seed_degree_programs.yaml + - include: + relativeToChangelogFile: true + file: initialize/0011_seed_degree_program_specializations.yaml From fc0d56a8c48ff3ece20dd3bd4d619bbac7622361 Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Sun, 8 Mar 2026 22:29:40 +0100 Subject: [PATCH 05/39] fixes --- .../create-edit-base.component.ts | 46 +++++++++++++------ .../module-edit-stepper.component.ts | 3 +- .../module-edit-steps.config.ts | 15 ++---- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/Client/src/app/components/create-edit-base/create-edit-base.component.ts b/Client/src/app/components/create-edit-base/create-edit-base.component.ts index 4bcfefb7..c43b1880 100644 --- a/Client/src/app/components/create-edit-base/create-edit-base.component.ts +++ b/Client/src/app/components/create-edit-base/create-edit-base.component.ts @@ -1,4 +1,5 @@ -import { Component, computed, effect, inject, signal } from '@angular/core'; +import { Component, computed, effect, inject, signal, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { CompletionServiceRequestDTO, @@ -42,18 +43,34 @@ export abstract class ProposalBaseComponent { isCreateMode = computed(() => this.moduleVersionId == null); currentStepIndex = signal(0); + /** Updated on form valueChanges so stepCompleted computed re-runs when user types. */ + private formValueVersion = signal(0); + stepCompleted = computed(() => { + this.formValueVersion(); const form = this.proposalForm; - return MODULE_EDIT_STEPS.map((step) => { - const required = step.requiredControlNames ?? []; - if (required.length === 0) return false; - return required.every((name) => { - const c = form.get(name); - return c && c.valid && c.value != null && c.value !== ''; - }); + const assignmentsList = this.assignments(); + return MODULE_EDIT_STEPS.map((step, index) => { + const allFieldsFilled = step.controlNames.every((name) => this.controlHasValue(form.get(name))); + if (step.id === 'basic') { + const hasCompleteAssignment = assignmentsList.some( + (a) => a.degreeProgramId != null && a.degreeProgramSpecializationId != null + ); + return allFieldsFilled && hasCompleteAssignment; + } + return allFieldsFilled; }); }); + private controlHasValue(c: ReturnType): boolean { + if (!c) return false; + const v = c.value; + if (v === undefined || v === null) return false; + if (typeof v === 'string') return v.trim() !== ''; + if (typeof v === 'number') return true; + return true; + } + moduleVersionStatus = computed(() => { const dto = this.moduleVersionDto(); return dto && 'status' in dto ? (dto as ModuleVersionViewDTO).status : undefined; @@ -61,7 +78,7 @@ export abstract class ProposalBaseComponent { currentVersionFeedbacks = computed(() => { const dto = this.moduleVersionDto(); - return dto && 'feedbacks' in dto ? (dto as ModuleVersionViewDTO).feedbacks ?? [] : []; + return dto && 'feedbacks' in dto ? ((dto as ModuleVersionViewDTO).feedbacks ?? []) : []; }); canSubmitForFeedback = computed(() => { @@ -74,9 +91,7 @@ export abstract class ProposalBaseComponent { const dto = this.moduleVersionDto(); if (dto && 'degreeProgramAssignments' in dto && Array.isArray((dto as ModuleVersionViewDTO).degreeProgramAssignments)) { const list = (dto as ModuleVersionViewDTO).degreeProgramAssignments ?? []; - this.assignments.set( - list.map((a) => ({ degreeProgramId: a.degreeProgramId ?? null, degreeProgramSpecializationId: a.degreeProgramSpecializationId ?? null })) - ); + this.assignments.set(list.map((a) => ({ degreeProgramId: a.degreeProgramId ?? null, degreeProgramSpecializationId: a.degreeProgramSpecializationId ?? null }))); } }); @@ -102,6 +117,8 @@ export abstract class ProposalBaseComponent { this.location.back(); } + private destroyRef = inject(DestroyRef); + constructor() { this.proposalForm = this.formBuilder.group({ bulletPoints: [''], @@ -136,6 +153,7 @@ export abstract class ProposalBaseComponent { responsiblesEng: [''], lvSwsLecturerEng: [''] }); + this.proposalForm.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.formValueVersion.update((n) => n + 1)); } goToStep(index: number) { @@ -176,7 +194,9 @@ export abstract class ProposalBaseComponent { } degreeProgramsAvailableForRow(rowIndex: number): DegreeProgramDTO[] { - const selected = this.assignments().map((a) => a.degreeProgramId).filter(Boolean) as number[]; + const selected = this.assignments() + .map((a) => a.degreeProgramId) + .filter(Boolean) as number[]; return this.degreePrograms().filter((p) => { const current = this.assignments()[rowIndex]?.degreeProgramId; return !selected.includes(p.degreeProgramId) || p.degreeProgramId === current; diff --git a/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.ts b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.ts index 82f4f120..aec20852 100644 --- a/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.ts +++ b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.ts @@ -3,12 +3,11 @@ import { CommonModule } from '@angular/common'; import { ButtonModule } from 'primeng/button'; import type { ModuleEditStepConfig } from './module-edit-steps.config'; import type { ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; -import { FeedbackStatusPipe } from '../../pipes/feedbackStatus.pipe'; @Component({ selector: 'app-module-edit-stepper', standalone: true, - imports: [CommonModule, ButtonModule, FeedbackStatusPipe], + imports: [CommonModule, ButtonModule], templateUrl: './module-edit-stepper.component.html', styleUrl: './module-edit-stepper.component.css' }) diff --git a/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts b/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts index 060032a6..94f646df 100644 --- a/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts +++ b/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts @@ -12,7 +12,7 @@ export const MODULE_EDIT_STEPS: ModuleEditStepConfig[] = [ controlNames: [ 'titleEng', 'titleDe', - 'bulletPoints', + // 'bulletPoints', 'credits', 'frequencyEng', 'hoursLecture', @@ -21,7 +21,7 @@ export const MODULE_EDIT_STEPS: ModuleEditStepConfig[] = [ 'hoursSeminar', 'firstSemesterAvailable', 'successorModuleName', - 'levelEng', + // 'levelEng', 'languageEng' ], requiredControlNames: ['titleEng'] @@ -34,19 +34,12 @@ export const MODULE_EDIT_STEPS: ModuleEditStepConfig[] = [ { id: 'examination-prereqs', title: 'Examination & prerequisites', - controlNames: ['examinationAchievementsEng', 'examinationAchievementsPromptEng', 'recommendedPrerequisitesEng'] + controlNames: ['examinationAchievementsEng', 'recommendedPrerequisitesEng'] }, { id: 'content-learning-teaching', title: 'Content, learning & teaching', - controlNames: [ - 'contentEng', - 'contentPromptEng', - 'learningOutcomesEng', - 'learningOutcomesPromptEng', - 'teachingMethodsEng', - 'teachingMethodsPromptEng' - ] + controlNames: ['contentEng', 'learningOutcomesEng', 'teachingMethodsEng'] }, { id: 'media-literature', From 90237af3137305d2c087c1effe775a08a2db17da Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Sun, 8 Mar 2026 22:33:12 +0100 Subject: [PATCH 06/39] fix --- .../module-version-edit.component.ts | 2 +- .../proposal-create.component.ts | 2 +- .../ls1/services/ModuleVersionService.java | 17 ++++++++++------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts index 939b7820..9484f8f9 100644 --- a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts +++ b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts @@ -100,7 +100,7 @@ export class ModuleVersionEditComponent extends ProposalBaseComponent { const payload: ModuleVersionUpdateRequestDTO = { ...this.proposalForm.value, moduleVersionId: this.moduleVersionId, - degreeProgramAssignments: degreeProgramAssignments.length > 0 ? degreeProgramAssignments : undefined + degreeProgramAssignments }; this.moduleVersionService.updateModuleVersion(this.moduleVersionId, payload).subscribe({ next: (response: ModuleVersionViewDTO) => this.moduleVersionDto.set(response), diff --git a/Client/src/app/pages/proposal-create/proposal-create.component.ts b/Client/src/app/pages/proposal-create/proposal-create.component.ts index 20743332..7d8faaa5 100644 --- a/Client/src/app/pages/proposal-create/proposal-create.component.ts +++ b/Client/src/app/pages/proposal-create/proposal-create.component.ts @@ -59,7 +59,7 @@ export class ProposalCreateComponent extends ProposalBaseComponent { const body: ProposalRequestDTO = { ...this.proposalForm.value, titleEng: this.proposalForm.value.titleEng?.trim() ?? '', - degreeProgramAssignments: degreeProgramAssignments.length > 0 ? degreeProgramAssignments : undefined + degreeProgramAssignments }; this.loading.set(true); this.error.set(null); diff --git a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java index 020688ab..b6ea2b4a 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java @@ -116,13 +116,16 @@ public ModuleVersionViewDTO updateModuleVersionFromRequest(UUID userId, Long mod mv.setResponsiblesEng(request.getResponsiblesEng()); mv.setLvSwsLecturerEng(request.getLvSwsLecturerEng()); - if (request.getDegreeProgramAssignments() != null && !request.getDegreeProgramAssignments().isEmpty()) { - List programIds = request.getDegreeProgramAssignments().stream() - .map(ModuleDegreeProgramAssignmentDTO::getDegreeProgramId) - .collect(Collectors.toList()); - if (programIds.size() != programIds.stream().distinct().count()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "A module cannot be assigned to the same degree program more than once."); + if (request.getDegreeProgramAssignments() != null) { + List assignments = request.getDegreeProgramAssignments(); + if (!assignments.isEmpty()) { + List programIds = assignments.stream() + .map(ModuleDegreeProgramAssignmentDTO::getDegreeProgramId) + .collect(Collectors.toList()); + if (programIds.size() != programIds.stream().distinct().count()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "A module cannot be assigned to the same degree program more than once."); + } } assignmentRepository.deleteByModuleVersion_ModuleVersionId(mv.getModuleVersionId()); entityManager.flush(); From 0541fbef5c8ca95d88893527c0510b0b4b7e4df1 Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Tue, 17 Mar 2026 21:44:25 +0100 Subject: [PATCH 07/39] first iteration without showing feedbacks --- .../create-edit-base.component.html | 185 ++++++--- .../create-edit-base.component.ts | 120 ++++-- .../module-edit-stepper.component.css | 6 + .../module-edit-stepper.component.html | 11 +- .../module-edit-stepper.component.ts | 8 +- .../module-edit-steps.config.ts | 25 ++ .../modules/openapi/.openapi-generator/FILES | 1 - .../api/module-version-controller.service.ts | 73 +--- ...ule-version-controller.serviceInterface.ts | 9 +- .../api/proposal-controller.service.ts | 167 +++----- .../proposal-controller.serviceInterface.ts | 24 +- .../openapi/model/add-module-version-dto.ts | 15 - .../openapi/model/feedback-list-item-dto.ts | 7 +- .../core/modules/openapi/model/feedback.ts | 16 +- .../app/core/modules/openapi/model/models.ts | 1 - .../model/module-version-compact-dto.ts | 11 +- .../module-version-update-request-dto.ts | 11 +- .../openapi/model/module-version-view-dto.ts | 11 +- .../model/module-version-view-feedback-dto.ts | 15 +- .../openapi/model/proposal-view-dto.ts | 11 +- .../openapi/model/proposals-compact-dto.ts | 11 +- .../openapi/model/similar-module-dto.ts | 2 +- .../openapi/model/update-user-role-dto.ts | 6 +- .../core/modules/openapi/model/user-dto.ts | 6 +- .../app/core/modules/openapi/model/user.ts | 6 +- Client/src/app/core/shared/user-role.utils.ts | 10 +- .../module-version-edit.component.ts | 22 +- .../module-version-view.component.css | 23 ++ .../module-version-view.component.html | 370 ++++++++++++------ .../module-version-view.component.ts | 54 ++- .../proposal-create.component.ts | 6 +- .../proposal-view.component.html | 34 +- .../proposal-view/proposal-view.component.ts | 30 +- .../src/app/pipes/feedbackDepartment.pipe.ts | 11 +- Client/src/app/pipes/feedbackStatus.pipe.ts | 22 +- .../src/app/pipes/moduleVersionStatus.pipe.ts | 29 +- Client/src/app/pipes/proposalStatus.pipe.ts | 32 +- .../ls1/controllers/FeedbackController.java | 16 +- .../controllers/ModuleVersionController.java | 15 +- .../ls1/controllers/ProposalController.java | 33 +- .../dtos/ModuleVersionViewFeedbackDTO.java | 14 +- .../ls1/enums/FeedbackStatus.java | 9 +- .../ls1/enums/ModuleVersionStatus.java | 24 +- .../ls1/enums/ProposalStatus.java | 18 +- .../modulemanagement/ls1/enums/UserRole.java | 6 +- .../ls1/models/DegreeProgram.java | 2 +- .../models/DegreeProgramSpecialization.java | 2 +- .../modulemanagement/ls1/models/Feedback.java | 51 ++- .../ls1/models/ModuleVersion.java | 23 +- .../modulemanagement/ls1/models/Proposal.java | 31 +- .../repositories/DegreeProgramRepository.java | 5 + ...DegreeProgramSpecializationRepository.java | 5 + .../ls1/repositories/FeedbackRepository.java | 4 + .../ls1/repositories/ProposalRepository.java | 5 + .../ls1/services/AdminUserService.java | 16 +- .../ls1/services/DegreeProgramService.java | 24 +- .../DegreeProgramSpecializationService.java | 24 +- .../ls1/services/FeedbackService.java | 39 +- .../ls1/services/ModuleVersionService.java | 160 ++++++-- .../ls1/services/ProposalService.java | 165 ++++---- .../services/ResponsibleUserRoleService.java | 96 +++++ .../0008_degree_program_and_area_tables.yaml | 4 +- .../0012_feedback_requested_from_user.yaml | 26 ++ ...3_grant_program_area_responsible_role.yaml | 33 ++ .../main/resources/db/changelog/master.yaml | 6 + 65 files changed, 1401 insertions(+), 856 deletions(-) delete mode 100644 Client/src/app/core/modules/openapi/model/add-module-version-dto.ts create mode 100644 Client/src/app/pages/module-version-view/module-version-view.component.css create mode 100644 Server/src/main/java/modulemanagement/ls1/services/ResponsibleUserRoleService.java create mode 100644 Server/src/main/resources/db/changelog/changes/0012_feedback_requested_from_user.yaml create mode 100644 Server/src/main/resources/db/changelog/changes/0013_grant_program_area_responsible_role.yaml diff --git a/Client/src/app/components/create-edit-base/create-edit-base.component.html b/Client/src/app/components/create-edit-base/create-edit-base.component.html index 8aa31cbf..a30ab661 100644 --- a/Client/src/app/components/create-edit-base/create-edit-base.component.html +++ b/Client/src/app/components/create-edit-base/create-edit-base.component.html @@ -1,13 +1,6 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' + moduleVersionDto()?.titleEng : 'Create New Module Proposal' }}

@@ -31,10 +24,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('titleFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.titleFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.titleFeedback }}

} @@ -123,10 +116,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('levelFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.levelFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.levelFeedback }}

} @@ -150,10 +143,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('languageFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.languageFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.languageFeedback }}

} @@ -211,15 +204,63 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for '

} @if (currentStepIndex() === 1) { +
+ @if (isCreateMode()) { + + Complete the first step and create the proposal. After saving, you can use this step to submit for feedback from program coordinators and area responsibles. + + } @else { +

+ Submit to request feedback from program coordinators and area responsibles (first submission). One feedback is required per chosen degree program and area. Complete + step 1 first, then use the button below. You can cancel and resubmit later if needed. +

+ @if (!isFirstStepComplete()) { + + Complete step 1: fill basic information and add at least one degree program assignment. Then you can submit for coordinator feedback. + + } + +
+ @if (canRequestCoordinatorsFeedback()) { + + } +
+ } +
+ } + @if (currentStepIndex() === 2) {
@if (moduleVersionId && hasFeedback('durationFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.durationFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.durationFeedback }}

} @@ -243,10 +284,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('repetitionFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.repetitionFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.repetitionFeedback }}

} @@ -270,10 +311,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('creditsFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.creditsFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.creditsFeedback }}

} @@ -288,10 +329,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('hoursTotalFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.hoursTotalFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.hoursTotalFeedback }}

} @@ -306,10 +347,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('hoursSelfStudyFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.hoursSelfStudyFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.hoursSelfStudyFeedback }}

} @@ -324,10 +365,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('hoursPresenceFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.hoursPresenceFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.hoursPresenceFeedback }}

} @@ -341,16 +382,16 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for '

} - @if (currentStepIndex() === 2) { + @if (currentStepIndex() === 3) {
@if (moduleVersionId && hasFeedback('examinationAchievementsFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.examinationAchievementsFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.examinationAchievementsFeedback }}

} @@ -392,10 +433,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('recommendedPrerequisitesFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.recommendedPrerequisitesFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.recommendedPrerequisitesFeedback }}

} @@ -408,16 +449,16 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for '

} - @if (currentStepIndex() === 3) { + @if (currentStepIndex() === 4) {
@if (moduleVersionId && hasFeedback('contentFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.contentFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.contentFeedback }}

} @@ -450,10 +491,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('learningOutcomesFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.learningOutcomesFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.learningOutcomesFeedback }}

} @@ -486,10 +527,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('teachingMethodsFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.teachingMethodsFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.teachingMethodsFeedback }}

} @@ -520,16 +561,16 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for '

} - @if (currentStepIndex() === 4) { + @if (currentStepIndex() === 5) {
@if (moduleVersionId && hasFeedback('mediaFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.mediaFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.mediaFeedback }}

} @@ -545,10 +586,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('literatureFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.literatureFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.literatureFeedback }}

} @@ -564,10 +605,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('responsiblesFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.responsiblesFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.responsiblesFeedback }}

} @@ -583,10 +624,10 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (moduleVersionId && hasFeedback('lvSwsLecturerFeedback')) { - @for (feedback of feedbacks(); track feedback.feedbackRole) { + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.lvSwsLecturerFeedback) {

- {{ (feedback.feedbackRole! | feedbackDepartment).text }}: + {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: {{ feedback.lvSwsLecturerFeedback }}

} @@ -600,6 +641,53 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for '

} + @if (currentStepIndex() === 6) { +
+ @if (isCreateMode()) { + + Complete all steps and get coordinator feedback accepted first. Then you can submit for full feedback (quality management, program advisor, examination board). + + } @else { +

+ Submit for full feedback from quality management, academic program advisor, and examination board. This requires all steps to be completed and all program/area + coordinator feedback to be accepted. +

+ + @if (canRequestFullFeedback()) { + + } + } +
+ } +
Cancel
@@ -620,9 +708,6 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' @if (!isCreateMode() && currentStepIndex() < MODULE_EDIT_STEPS.length - 1) { Next } - @if (!isCreateMode() && currentStepIndex() === MODULE_EDIT_STEPS.length - 1 && canSubmitForFeedback()) { - Submit for feedback - } @if (!isCreateMode()) { {{ loading() ? 'Saving...' : 'Update proposal' }} diff --git a/Client/src/app/components/create-edit-base/create-edit-base.component.ts b/Client/src/app/components/create-edit-base/create-edit-base.component.ts index c43b1880..4f1e6bf1 100644 --- a/Client/src/app/components/create-edit-base/create-edit-base.component.ts +++ b/Client/src/app/components/create-edit-base/create-edit-base.component.ts @@ -8,13 +8,15 @@ import { ProposalControllerService, ModuleVersionViewDTO, CompletionServiceResponseDTO, - ModuleVersionViewFeedbackDTO + ModuleVersionViewFeedbackDTO, + ProposalViewDTO } from '../../core/modules/openapi'; import { DegreeProgramsControllerService } from '../../core/modules/openapi/api/degree-programs-controller.service'; import { Location } from '@angular/common'; import { Router } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; -import { MODULE_EDIT_STEPS } from '../module-edit-stepper/module-edit-steps.config'; +import { MODULE_EDIT_STEPS, StepperStatus } from '../module-edit-stepper/module-edit-steps.config'; +import { BreadcrumbLabelsService } from '../breadcrumb/breadcrumb-labels.service'; @Component({ template: '' @@ -26,6 +28,7 @@ export abstract class ProposalBaseComponent { protected moduleVersionService = inject(ModuleVersionControllerService); protected proposalService = inject(ProposalControllerService); protected degreeProgramsService = inject(DegreeProgramsControllerService); + protected breadcrumbLabels = inject(BreadcrumbLabelsService); readonly MODULE_EDIT_STEPS = MODULE_EDIT_STEPS; @@ -46,19 +49,27 @@ export abstract class ProposalBaseComponent { /** Updated on form valueChanges so stepCompleted computed re-runs when user types. */ private formValueVersion = signal(0); - stepCompleted = computed(() => { + stepsStatuses = computed(() => { this.formValueVersion(); const form = this.proposalForm; const assignmentsList = this.assignments(); - return MODULE_EDIT_STEPS.map((step, index) => { - const allFieldsFilled = step.controlNames.every((name) => this.controlHasValue(form.get(name))); + return MODULE_EDIT_STEPS.map((step) => { if (step.id === 'basic') { - const hasCompleteAssignment = assignmentsList.some( - (a) => a.degreeProgramId != null && a.degreeProgramSpecializationId != null - ); - return allFieldsFilled && hasCompleteAssignment; + const allFieldsFilled = step.controlNames.every((name) => this.controlHasValue(form.get(name))); + const hasCompleteAssignment = assignmentsList.some((a) => a.degreeProgramId != null && a.degreeProgramSpecializationId != null); + if (allFieldsFilled && hasCompleteAssignment) { + return StepperStatus.Completed; + } else { + return StepperStatus.Default; + } } - return allFieldsFilled; + if (step.id === 'submit-coordinator-feedback') { + return StepperStatus.Default; + } + if (step.id === 'submit-full-feedback') { + return StepperStatus.Default; + } + return step.controlNames.every((name) => this.controlHasValue(form.get(name))) ? StepperStatus.Completed : StepperStatus.Default; }); }); @@ -76,23 +87,31 @@ export abstract class ProposalBaseComponent { return dto && 'status' in dto ? (dto as ModuleVersionViewDTO).status : undefined; }); - currentVersionFeedbacks = computed(() => { - const dto = this.moduleVersionDto(); - return dto && 'feedbacks' in dto ? ((dto as ModuleVersionViewDTO).feedbacks ?? []) : []; + /** First step (basic + assignments) is complete. */ + isFirstStepComplete = computed(() => this.stepsStatuses()[0] === StepperStatus.Completed); + + /** Can submit for feedback (never submitted yet). */ + canRequestCoordinatorsFeedback = computed(() => { + const status = this.moduleVersionStatus(); + return status === 'PENDING_FIRST_SUBMISSION' && this.isFirstStepComplete(); }); - canSubmitForFeedback = computed(() => { - const dto = this.moduleVersionDto(); - const status = dto && 'status' in dto ? (dto as ModuleVersionViewDTO).status : null; - return status === 'PENDING_SUBMISSION' && this.proposalForm.valid; + /** All form steps (basic, schedule, exam, content, media) are complete – not the two submission steps. */ + allFormStepsComplete = computed(() => { + const completed = this.stepsStatuses(); + return ( + completed[0] === StepperStatus.Completed && + completed[2] === StepperStatus.Completed && + completed[3] === StepperStatus.Completed && + completed[4] === StepperStatus.Completed && + completed[5] === StepperStatus.Completed + ); }); - private _syncAssignmentsFromDto = effect(() => { - const dto = this.moduleVersionDto(); - if (dto && 'degreeProgramAssignments' in dto && Array.isArray((dto as ModuleVersionViewDTO).degreeProgramAssignments)) { - const list = (dto as ModuleVersionViewDTO).degreeProgramAssignments ?? []; - this.assignments.set(list.map((a) => ({ degreeProgramId: a.degreeProgramId ?? null, degreeProgramSpecializationId: a.degreeProgramSpecializationId ?? null }))); - } + /** Can submit for full feedback (second submission): PENDING_FULL_SUBMISSION, all steps done, all coordinator feedback accepted. */ + canRequestFullFeedback = computed(() => { + const status = this.moduleVersionStatus(); + return status === 'PENDING_FULL_SUBMISSION' && this.allFormStepsComplete(); }); showPrompt: { [key: string]: boolean } = { @@ -102,13 +121,6 @@ export abstract class ProposalBaseComponent { teaching: false }; - fieldMapping: { [key: string]: string } = { - content: 'contentEng', - examination: 'examinationAchievementsEng', - learning: 'learningOutcomesEng', - teaching: 'teachingMethodsEng' - }; - togglePromptField(field: string) { this.showPrompt[field] = !this.showPrompt[field]; } @@ -213,13 +225,53 @@ export abstract class ProposalBaseComponent { this.setAssignmentSpecialization(rowIndex, null); } - submitForFeedback(): void { + requestCoordinatorsFeedback(): void { + const dto = this.moduleVersionDto(); + const proposalId = dto && 'proposalId' in dto ? (dto as ModuleVersionViewDTO).proposalId : null; + if (proposalId == null) return; + this.loading.set(true); + this.error.set(null); + this.proposalService.requestCoordinatorsFeedback(proposalId).subscribe({ + next: (response: ProposalViewDTO) => { + this.moduleVersionDto.set(response); + // When backend created a new version (immutable versioning), switch to editing the new version + const newId = response?.latestModuleVersion?.moduleVersionId; + if (newId != null && newId !== this.moduleVersionId) { + this.moduleVersionId = newId; + this.breadcrumbLabels.versionLabel.set(response?.latestVersion != null ? `Version ${response.latestVersion}` : null); + this.router.navigate(['/proposals/view', proposalId, 'version', newId, 'edit'], { replaceUrl: true }); + } + }, + error: (err: HttpErrorResponse) => { + this.error.set(err.error?.message ?? err.error ?? 'Failed to submit'); + this.loading.set(false); + }, + complete: () => this.loading.set(false) + }); + } + + requestFullFeedback(): void { const dto = this.moduleVersionDto(); const proposalId = dto && 'proposalId' in dto ? (dto as ModuleVersionViewDTO).proposalId : null; if (proposalId == null) return; - this.proposalService.submitProposal(proposalId).subscribe({ - next: () => this.router.navigate(['/proposals/view', proposalId]), - error: (err: HttpErrorResponse) => this.error.set(err.error?.message ?? err.error ?? 'Failed to submit') + this.loading.set(true); + this.error.set(null); + this.proposalService.requestFullFeedback(proposalId).subscribe({ + next: (response: ProposalViewDTO) => { + this.moduleVersionDto.set(response); + // When backend created a new version (immutable versioning), switch to editing the new version + const newId = response?.latestModuleVersion?.moduleVersionId; + if (newId != null && newId !== this.moduleVersionId) { + this.moduleVersionId = newId; + this.breadcrumbLabels.versionLabel.set(response?.latestVersion != null ? `Version ${response.latestVersion}` : null); + this.router.navigate(['/proposals/view', proposalId, 'version', newId, 'edit'], { replaceUrl: true }); + } + }, + error: (err: HttpErrorResponse) => { + this.error.set(err.error?.message ?? err.error ?? 'Failed to submit for full feedback'); + this.loading.set(false); + }, + complete: () => this.loading.set(false) }); } diff --git a/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.css b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.css index 36cb4894..59540382 100644 --- a/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.css +++ b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.css @@ -32,6 +32,12 @@ color: white; } +/* Feedback step(s): yellow/orange when at least one feedback is pending */ +.step-item--pending .step-number { + background: var(--p-warn-color, #eab308); + color: var(--p-primary-contrast-color, #1a1a1a); +} + .step-number { flex-shrink: 0; width: 1.75rem; diff --git a/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.html b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.html index d2c15a78..3b49daf3 100644 --- a/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.html +++ b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.html @@ -1,8 +1,15 @@

} @else if (moduleVersionDto(); as moduleVersionDto) { -
-

Overview of '{{ moduleVersionDto.titleEng }}' - Version {{ moduleVersionDto.version }}

-
- -
- @if (proposalId) { - - } - @if (isLatestVersion() && moduleVersionDto.status == moduleVersionStatus.PendingSubmission && proposalId) { - - } +
+ +
+

Overview of '{{ moduleVersionDto.titleEng }}' - Version {{ moduleVersionDto.version }}

+
+ +
+ @if (proposalId) { + + } + @if (isLatestVersion() && proposalId) { + + } +
-
-
-
-

Module Version ID

-

{{ moduleVersionDto.moduleVersionId }}

-
-
-

Version

-

{{ moduleVersionDto.version }} / {{ moduleVersionDto.latestVersion }}

-
-
-

Status

- -
-
-

Created On

-

{{ moduleVersionDto.creationDate | date: 'yyyy-MM-dd' }}

+
+
+

Module Version ID

+

{{ moduleVersionDto.moduleVersionId }}

+
+
+

Version

+

{{ moduleVersionDto.version }} / {{ moduleVersionDto.latestVersion }}

+
+
+

Status

+ +
+
+

Created On

+

{{ moduleVersionDto.creationDate | date: 'yyyy-MM-dd' }}

+
-
- - @if (moduleVersionDto.feedbacks?.length) { -
- @for (feedback of moduleVersionDto.feedbacks; track feedback.feedbackId) { - - -
-

{{ feedback.feedbackFromFirstName }} {{ feedback.feedbackFromLastName }}

-

- {{ feedback.submissionDate | date: 'yyyy-MM-dd' }} -

-
- - @if (feedback.rejectionComment) { - -

Rejection Comment:

-

{{ feedback.rejectionComment }}

-
+ @if (currentStepIndex() === 0) { +
+ @for (field of getFieldsForViewStep(0); track field.key) { + +

+ @if (getModuleVersionProperty(field.key)) { + {{ getModuleVersionProperty(field.key) }} + } @else { + Not specified + } +

+ @if (field.feedbackKey && getFieldFeedbacks(field.key).length > 0) { +
+ @for (feedback of getFieldFeedbacks(field.key); track feedback.feedbackId) { + +

{{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}

+

{{ getFeedbackContent(feedback, field.key) }}

+
+ } +
} +
+ } +
+ @if (moduleVersionDto.degreeProgramAssignments?.length) { + +
    + @for (a of moduleVersionDto.degreeProgramAssignments; track a.degreeProgramId + '-' + a.degreeProgramSpecializationId) { +
  • Program {{ a.degreeProgramId }} – Specialization {{ a.degreeProgramSpecializationId }}
  • + } +
+
+ } + } + + @if (currentStepIndex() === 1) { + + } -
- @for (field of getFeedbackFields(feedback); track field.key) { - -

{{ field.label }}

-

{{ field.value }}

-
+ @if (currentStepIndex() === 2) { +
+ @for (field of getFieldsForViewStep(2); track field.key) { + +

+ @if (getModuleVersionProperty(field.key)) { + {{ getModuleVersionProperty(field.key) }} + } @else { + Not specified } -

- +

+ @if (field.feedbackKey && getFieldFeedbacks(field.key).length > 0) { +
+ @for (feedback of getFieldFeedbacks(field.key); track feedback.feedbackId) { + +

{{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}

+

{{ getFeedbackContent(feedback, field.key) }}

+
+ } +
+ } + }
- } @else { -

No feedback provided yet

} - -
-

Module Version Data

-
- @for (field of getFieldsBySection('basic'); track field.key) { - -

+ @if (currentStepIndex() === 3) { +

+ @for (field of getFieldsForViewStep(3); track field.key) { + @if (getModuleVersionProperty(field.key)) { - {{ getModuleVersionProperty(field.key) }} +

{{ getModuleVersionProperty(field.key) }}

+ @if (field.hasPrompt && getModuleVersionProperty(field.hasPrompt)) { + +

{{ getModuleVersionProperty(field.hasPrompt) }}

+
+ } + @if (field.feedbackKey && getFieldFeedbacks(field.key).length > 0) { +
+ @for (feedback of getFieldFeedbacks(field.key); track feedback.feedbackId) { + +

{{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}

+

{{ getFeedbackContent(feedback, field.key) }}

+
+ } +
+ } } @else { - Not specified +

Not specified

} -

- @if (getFieldFeedbacks(field.key).length > 0) { -
- @for (feedback of getFieldFeedbacks(field.key); track feedback.feedbackId) { - -

{{ (feedback.feedbackRole! | feedbackDepartment).text }}

-

{{ getFeedbackContent(feedback, field.key) }}

-
- } -
- } -
- } -
-
+ + } +
+ } -
-
- @for (field of getFieldsBySection('hours'); track field.key) { - -

+ @if (currentStepIndex() === 4) { +

+ @for (field of getFieldsForViewStep(4); track field.key) { + @if (getModuleVersionProperty(field.key)) { - {{ getModuleVersionProperty(field.key) }} +

{{ getModuleVersionProperty(field.key) }}

+ @if (field.hasPrompt && getModuleVersionProperty(field.hasPrompt)) { + +

{{ getModuleVersionProperty(field.hasPrompt) }}

+
+ } + @if (field.feedbackKey && getFieldFeedbacks(field.key).length > 0) { +
+ @for (feedback of getFieldFeedbacks(field.key); track feedback.feedbackId) { + +

{{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}

+

{{ getFeedbackContent(feedback, field.key) }}

+
+ } +
+ } } @else { - Not specified +

Not specified

} -

- @if (getFieldFeedbacks(field.key).length > 0) { -
- @for (feedback of getFieldFeedbacks(field.key); track feedback.feedbackId) { - -

{{ (feedback.feedbackRole! | feedbackDepartment).text }}

-

{{ getFeedbackContent(feedback, field.key) }}

-
+ + } +
+ } + + @if (currentStepIndex() === 5) { +
+ @for (field of getFieldsForViewStep(5); track field.key) { + + @if (getModuleVersionProperty(field.key)) { +

{{ getModuleVersionProperty(field.key) }}

+ @if (field.feedbackKey && getFieldFeedbacks(field.key).length > 0) { +
+ @for (feedback of getFieldFeedbacks(field.key); track feedback.feedbackId) { + +

{{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}

+

{{ getFeedbackContent(feedback, field.key) }}

+
+ } +
} -
- } -
- } -
-
+ } @else { +

Not specified

+ } +
+ } +
+ } - @for (field of getFieldsBySection('content'); track field.key) { - - @if (getModuleVersionProperty(field.key)) { -

{{ getModuleVersionProperty(field.key) }}

- @if (field.hasPrompt && getModuleVersionProperty(field.hasPrompt)) { - -

{{ getModuleVersionProperty(field.hasPrompt) }}

-
+ @if (currentStepIndex() === 6) { + +

+ Second submission: after all steps are complete and coordinator feedback is accepted, you can submit for feedback from quality management, academic program advisor, and + examination board. +

+ @if (moduleVersionDto.feedbacks?.length) { +
+

Feedback status

+
    + @for (fb of moduleVersionDto.feedbacks; track fb.feedbackId) { +
  • + {{ fb.requestedFromUserName ?? (fb.feedbackRole | feedbackDepartment).text }}: + +
  • + } +
+
} - @if (getFieldFeedbacks(field.key).length > 0) { -
- @for (feedback of getFieldFeedbacks(field.key); track feedback.feedbackId) { - -

{{ (feedback.feedbackRole! | feedbackDepartment).text }}

-

{{ getFeedbackContent(feedback, field.key) }}

-
+ + } + + @if (currentStepIndex() === 7) { + + @if (moduleVersionDto.feedbacks?.length) { +
+ @for (feedback of moduleVersionDto.feedbacks; track feedback.feedbackId) { + + +
+

{{ feedback.feedbackFromFirstName }} {{ feedback.feedbackFromLastName }}

+

+ {{ feedback.submissionDate | date: 'yyyy-MM-dd' }} +

+
+ + @if (feedback.rejectionComment) { + +

Rejection Comment:

+

{{ feedback.rejectionComment }}

+
+ } + +
+ @for (field of getFeedbackFields(feedback); track field.key) { + +

{{ field.label }}

+

{{ field.value }}

+
+ } +
+
}
+ } @else { +

No feedback provided yet

} - } @else { -

No content provided

- } -
- } + + } +
} @else {
No module version found
diff --git a/Client/src/app/pages/module-version-view/module-version-view.component.ts b/Client/src/app/pages/module-version-view/module-version-view.component.ts index cc12cf97..2c6925e7 100644 --- a/Client/src/app/pages/module-version-view/module-version-view.component.ts +++ b/Client/src/app/pages/module-version-view/module-version-view.component.ts @@ -1,9 +1,11 @@ import { CommonModule } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { RouterModule, ActivatedRoute } from '@angular/router'; import { ModuleVersionControllerService, ModuleVersionViewDTO, ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; +import { ModuleEditStepperComponent } from '../../components/module-edit-stepper/module-edit-stepper.component'; +import { MODULE_VIEW_STEPS, StepperStatus } from '../../components/module-edit-stepper/module-edit-steps.config'; import { FeedbackDepartmentPipe } from '../../pipes/feedbackDepartment.pipe'; import { FeedbackStatusPipe } from '../../pipes/feedbackStatus.pipe'; import { ModuleVersionStatusPipe } from '../../pipes/moduleVersionStatus.pipe'; @@ -29,6 +31,7 @@ export interface ModuleField { imports: [ CommonModule, RouterModule, + ModuleEditStepperComponent, ModuleVersionStatusPipe, FeedbackStatusPipe, FeedbackDepartmentPipe, @@ -39,7 +42,8 @@ export interface ModuleField { CardModule, PanelModule ], - templateUrl: './module-version-view.component.html' + templateUrl: './module-version-view.component.html', + styleUrl: './module-version-view.component.css' }) export class ModuleVersionViewComponent { route = inject(ActivatedRoute); @@ -52,14 +56,24 @@ export class ModuleVersionViewComponent { moduleVersionStatus = ModuleVersionViewDTO.StatusEnum; error = signal(null); + readonly MODULE_VIEW_STEPS = MODULE_VIEW_STEPS; + currentStepIndex = signal(0); + moduleFields: ModuleField[] = [ { key: 'titleEng', label: 'Title', section: 'basic', feedbackKey: 'titleFeedback' }, + { key: 'titleDe', label: 'Title (German)', section: 'basic' }, { key: 'levelEng', label: 'Level', section: 'basic', feedbackKey: 'levelFeedback' }, { key: 'languageEng', label: 'Language', section: 'basic', feedbackKey: 'languageFeedback' }, { key: 'frequencyEng', label: 'Frequency', section: 'basic', feedbackKey: 'frequencyFeedback' }, + { key: 'credits', label: 'Credits', section: 'hours', feedbackKey: 'creditsFeedback' }, + { key: 'hoursLecture', label: 'Hours (Lecture)', section: 'basic' }, + { key: 'hoursExercise', label: 'Hours (Exercise)', section: 'basic' }, + { key: 'hoursPractical', label: 'Hours (Practical)', section: 'basic' }, + { key: 'hoursSeminar', label: 'Hours (Seminar)', section: 'basic' }, + { key: 'firstSemesterAvailable', label: 'First semester available', section: 'basic' }, + { key: 'successorModuleName', label: 'Successor module', section: 'basic' }, { key: 'duration', label: 'Duration', section: 'basic', feedbackKey: 'durationFeedback' }, { key: 'repetitionEng', label: 'Repetition', section: 'basic', feedbackKey: 'repetitionFeedback' }, - { key: 'credits', label: 'Credits', section: 'hours', feedbackKey: 'creditsFeedback' }, { key: 'hoursTotal', label: 'Total Hours', section: 'hours', feedbackKey: 'hoursTotalFeedback' }, { key: 'hoursSelfStudy', label: 'Self-Study Hours', section: 'hours', feedbackKey: 'hoursSelfStudyFeedback' }, { key: 'hoursPresence', label: 'Presence Hours', section: 'hours', feedbackKey: 'hoursPresenceFeedback' }, @@ -89,6 +103,28 @@ export class ModuleVersionViewComponent { { key: 'lvSwsLecturerEng', label: 'Lecturer', section: 'content', isLongText: true, feedbackKey: 'lvSwsLecturerFeedback' } ]; + stepStatuses = computed(() => { + const dto = this.moduleVersionDto(); + if (!dto) return MODULE_VIEW_STEPS.map(() => StepperStatus.Default); + + return MODULE_VIEW_STEPS.map((step, index) => { + if (step.id === 'feedbacks') return StepperStatus.Default; + if (step.id === 'submit-coordinator-feedback') return StepperStatus.Default; + if (step.id === 'submit-full-feedback') return StepperStatus.Default; + + const keys = MODULE_VIEW_STEPS[index].controlNames as (keyof ModuleVersionViewDTO)[]; + if (!keys?.length) return StepperStatus.Default; + const allFilled = keys.every((k) => { + const v = dto[k]; + if (v === undefined || v === null) return false; + if (typeof v === 'string') return v.trim() !== ''; + if (typeof v === 'number') return true; + return true; + }); + return allFilled ? StepperStatus.Completed : StepperStatus.Default; + }); + }); + constructor() { const params = this.route.snapshot.paramMap; this.proposalId = params.get('id'); @@ -97,9 +133,19 @@ export class ModuleVersionViewComponent { this.fetchModuleVersionViewDto(this.moduleVersionId); } + goToStep(index: number) { + this.currentStepIndex.set(index); + } + + getFieldsForViewStep(stepIndex: number): ModuleField[] { + const keys = MODULE_VIEW_STEPS[stepIndex].controlNames as (keyof ModuleVersionViewDTO)[]; + if (!keys?.length) return []; + return this.moduleFields.filter((f) => keys.includes(f.key)); + } + private fetchModuleVersionViewDto(moduleVersionId: number) { this.loading.set(true); - this.moduleVersionService.getModuleVersionViewDto(moduleVersionId).subscribe({ + this.moduleVersionService.getModuleVersion(moduleVersionId).subscribe({ next: (data: ModuleVersionViewDTO) => { this.moduleVersionDto.set(data); const version = data?.version; diff --git a/Client/src/app/pages/proposal-create/proposal-create.component.ts b/Client/src/app/pages/proposal-create/proposal-create.component.ts index 7d8faaa5..e9ee0d85 100644 --- a/Client/src/app/pages/proposal-create/proposal-create.component.ts +++ b/Client/src/app/pages/proposal-create/proposal-create.component.ts @@ -5,6 +5,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { ProposalBaseComponent } from '../../components/create-edit-base/create-edit-base.component'; import { FeedbackDepartmentPipe } from '../../pipes/feedbackDepartment.pipe'; +import { FeedbackStatusPipe } from '../../pipes/feedbackStatus.pipe'; import { ToggleButtonGroupComponent } from '../../components/toggle-button-group/toggle-button-group.component'; import { ModuleEditStepperComponent } from '../../components/module-edit-stepper/module-edit-stepper.component'; import { ModuleDegreeProgramAssignmentDTO, ProposalRequestDTO } from '../../core/modules/openapi'; @@ -15,6 +16,7 @@ import { InputNumberModule } from 'primeng/inputnumber'; import { MessageModule } from 'primeng/message'; import { SelectModule } from 'primeng/select'; import { ProgressSpinnerModule } from 'primeng/progressspinner'; +import { TagModule } from 'primeng/tag'; @Component({ selector: 'app-proposal-create', @@ -24,6 +26,7 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner'; FormsModule, RouterModule, FeedbackDepartmentPipe, + FeedbackStatusPipe, ToggleButtonGroupComponent, ModuleEditStepperComponent, ButtonModule, @@ -32,7 +35,8 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner'; InputNumberModule, MessageModule, SelectModule, - ProgressSpinnerModule + ProgressSpinnerModule, + TagModule ], templateUrl: '../../components/create-edit-base/create-edit-base.component.html', styleUrl: '../../components/create-edit-base/create-edit-base-layout.css' diff --git a/Client/src/app/pages/proposal-view/proposal-view.component.html b/Client/src/app/pages/proposal-view/proposal-view.component.html index a3acfd93..1b1c0391 100644 --- a/Client/src/app/pages/proposal-view/proposal-view.component.html +++ b/Client/src/app/pages/proposal-view/proposal-view.component.html @@ -83,33 +83,13 @@

Current Module Versions

- @if (proposal.latestModuleVersion!.isComplete && proposal.latestModuleVersion!.status == moduleStatusEnum.PendingSubmission) { - - } - @if (proposal.latestModuleVersion!.status === moduleStatusEnum.FeedbackGiven || proposal.latestModuleVersion!.status === moduleStatusEnum.Rejected) { - - } - @if (proposal.latestModuleVersion!.status === moduleStatusEnum.PendingSubmission) { - - } - @if (proposal.latestModuleVersion!.status === moduleStatusEnum.PendingFeedback) { - - } - @if (proposal.status === proposalStatusEnum.RequiresReview) { - - } + this.proposal.set(response), - error: (err: HttpErrorResponse) => this.error.set(err.error) - }); - } - } - - cancelProposal() { - if (this.proposal()) { - this.proposalService.cancelSubmission(this.proposal()!.proposalId!).subscribe({ - next: (response: ProposalViewDTO) => this.proposal.set(response), - error: (err: HttpErrorResponse) => this.error.set(err.error) - }); - } - } - deleteProposal() { if (this.proposal()) { this.proposalService.deleteProposal(this.proposal()!.proposalId!).subscribe({ @@ -99,14 +81,4 @@ export class ProposalViewComponent { }); } } - - addNewModuleVersion() { - if (this.proposal()) { - const addModuleVersionDto: AddModuleVersionDTO = { proposalId: this.proposal()!.proposalId! }; - this.proposalService.addModuleVersion(addModuleVersionDto).subscribe({ - next: (response: ProposalViewDTO) => this.proposal.set(response), - error: (err: HttpErrorResponse) => this.error.set(err.error) - }); - } - } } diff --git a/Client/src/app/pipes/feedbackDepartment.pipe.ts b/Client/src/app/pipes/feedbackDepartment.pipe.ts index a1ef5a94..21140f90 100644 --- a/Client/src/app/pipes/feedbackDepartment.pipe.ts +++ b/Client/src/app/pipes/feedbackDepartment.pipe.ts @@ -3,16 +3,21 @@ import { Feedback } from '../core/modules/openapi'; @Pipe({ name: 'feedbackDepartment', standalone: true }) export class FeedbackDepartmentPipe implements PipeTransform { - transform(status: Feedback.RequiredRoleEnum): { text: string } { - switch (status) { + transform(role: Feedback.RequiredRoleEnum | null | undefined): { text: string } { + if (role == null) return { text: 'Specialization area responsible' }; + switch (role) { case 'QUALITY_MANAGEMENT': return { text: 'Quality Management' }; case 'EXAMINATION_BOARD': return { text: 'Examination Board' }; case 'ACADEMIC_PROGRAM_ADVISOR': return { text: 'Academic Program Advisor' }; + case 'PROGRAM_COORDINATOR': + return { text: 'Program coordinator' }; + case 'SPECIALIZATION_AREA_RESPONSIBLE': + return { text: 'Specialization area responsible' }; default: - return { text: status }; + return { text: role }; } } } diff --git a/Client/src/app/pipes/feedbackStatus.pipe.ts b/Client/src/app/pipes/feedbackStatus.pipe.ts index 1f64907f..3b78d102 100644 --- a/Client/src/app/pipes/feedbackStatus.pipe.ts +++ b/Client/src/app/pipes/feedbackStatus.pipe.ts @@ -11,13 +11,6 @@ export class FeedbackStatusPipe implements PipeTransform { severity: Tag['severity']; } { switch (status) { - case 'PENDING_SUBMISSION': - return { - text: 'Pending Submission', - normalColor: 'bg-gray-500 text-white', - fadedColor: 'bg-gray-300 text-white', - severity: 'secondary' - }; case 'PENDING_FEEDBACK': return { text: 'Pending Feedback', @@ -46,20 +39,7 @@ export class FeedbackStatusPipe implements PipeTransform { fadedColor: 'bg-red-300 text-white', severity: 'danger' }; - case 'OBSOLETE': - return { - text: 'Obsolete', - normalColor: 'bg-gray-300 text-gray-600', - fadedColor: 'bg-gray-200 text-gray-600', - severity: 'secondary' - }; - case 'CANCELLED': - return { - text: 'Cancelled', - normalColor: 'bg-gray-400 text-white', - fadedColor: 'bg-gray-300 text-white', - severity: 'secondary' - }; + default: return { text: status, diff --git a/Client/src/app/pipes/moduleVersionStatus.pipe.ts b/Client/src/app/pipes/moduleVersionStatus.pipe.ts index f12e9aae..a6569985 100644 --- a/Client/src/app/pipes/moduleVersionStatus.pipe.ts +++ b/Client/src/app/pipes/moduleVersionStatus.pipe.ts @@ -14,16 +14,30 @@ export class ModuleVersionStatusPipe implements PipeTransform { severity: Tag['severity']; } { switch (status) { - case ModuleVersionCompactDTO.StatusEnum.PendingSubmission: + case ModuleVersionCompactDTO.StatusEnum.PendingFirstSubmission: return { - text: 'Pending Submission', + text: 'Pending first submission', normalColor: 'bg-gray-500', fadedColor: 'bg-gray-300', severity: 'secondary' }; - case ModuleVersionCompactDTO.StatusEnum.PendingFeedback: + case ModuleVersionCompactDTO.StatusEnum.PendingCoordinatorFeedback: return { - text: 'Pending Feedback', + text: 'Pending coordinator feedback', + normalColor: 'bg-yellow-500', + fadedColor: 'bg-yellow-300', + severity: 'warn' + }; + case ModuleVersionCompactDTO.StatusEnum.PendingFullSubmission: + return { + text: 'Pending full submission', + normalColor: 'bg-gray-500', + fadedColor: 'bg-gray-300', + severity: 'secondary' + }; + case ModuleVersionCompactDTO.StatusEnum.PendingFullFeedback: + return { + text: 'Pending full feedback', normalColor: 'bg-yellow-500', fadedColor: 'bg-yellow-300', severity: 'warn' @@ -35,13 +49,6 @@ export class ModuleVersionStatusPipe implements PipeTransform { fadedColor: 'bg-green-300', severity: 'success' }; - case ModuleVersionCompactDTO.StatusEnum.FeedbackGiven: - return { - text: 'Feedback given', - normalColor: 'bg-blue-500', - fadedColor: 'bg-blue-300', - severity: 'info' - }; case ModuleVersionCompactDTO.StatusEnum.Rejected: return { text: 'Rejected', diff --git a/Client/src/app/pipes/proposalStatus.pipe.ts b/Client/src/app/pipes/proposalStatus.pipe.ts index 3bbef4cb..09a379c2 100644 --- a/Client/src/app/pipes/proposalStatus.pipe.ts +++ b/Client/src/app/pipes/proposalStatus.pipe.ts @@ -5,10 +5,14 @@ import { Tag } from 'primeng/tag'; export class StatusDisplayPipe implements PipeTransform { transform(status: ProposalViewDTO.StatusEnum): { text: string; severity: Tag['severity'] } { switch (status) { - case 'PENDING_SUBMISSION': - return { text: 'Pending Submission', severity: 'secondary' }; - case 'PENDING_FEEDBACK': - return { text: 'Pending Feedback', severity: 'warn' }; + case 'PENDING_FIRST_SUBMISSION': + return { text: 'Pending first submission', severity: 'secondary' }; + case 'PENDING_COORDINATOR_FEEDBACK': + return { text: 'Pending coordinator feedback', severity: 'warn' }; + case 'PENDING_FULL_SUBMISSION': + return { text: 'Pending full submission', severity: 'secondary' }; + case 'PENDING_FULL_FEEDBACK': + return { text: 'Pending full feedback', severity: 'warn' }; case 'ACCEPTED': return { text: 'Accepted', severity: 'success' }; case 'REQUIRES_REVIEW': @@ -16,7 +20,7 @@ export class StatusDisplayPipe implements PipeTransform { case 'REJECTED': return { text: 'Rejected', severity: 'danger' }; default: - return { text: status, severity: 'secondary' }; + return { text: status ?? '', severity: 'secondary' }; } } } @@ -25,18 +29,22 @@ export class StatusDisplayPipe implements PipeTransform { export class StatusInfoPipeline implements PipeTransform { transform(status: ProposalViewDTO.StatusEnum): string { switch (status) { - case 'PENDING_SUBMISSION': - return 'This module proposal is pending submission. Please fill all module information fields and submit the module proposal. After submission the necessary staff will be notified to review your proposal.'; - case 'PENDING_FEEDBACK': - return 'This module proposal is submitted and waiting for review by the necessary staff. Please wait for their feedback. If you made a mistake you can cancel the submission. If a staff member already gave feedback, you will need to create a new module version for consistency. New module versions copy the content of the previously submitted module version.'; + case 'PENDING_FIRST_SUBMISSION': + return 'This module proposal is pending submission. Please complete step 1 (basic information and degree program assignments) and submit for coordinator feedback.'; + case 'PENDING_COORDINATOR_FEEDBACK': + return 'Submitted for coordinator feedback. Program and area responsibles are reviewing. You can cancel and resubmit if needed.'; + case 'PENDING_FULL_SUBMISSION': + return 'Coordinator feedback was accepted. Complete all steps and submit for full feedback (quality management, program advisor, examination board) when ready.'; + case 'PENDING_FULL_FEEDBACK': + return 'This module proposal is submitted and waiting for review. You can cancel the submission if needed.'; case 'ACCEPTED': return 'This module is approved.'; case 'REQUIRES_REVIEW': - return 'This module proposal requires your review. It was either rejected by a staff member, or you canceled it after a staff member already gave feedback. Please create a new module version and update your proposal by the rejection feedback.'; + return 'This module proposal requires your review. Create a new module version and update by the rejection feedback.'; case 'REJECTED': - return 'This proposal was rejected. You cannot modify this module proposal anymore.'; + return 'This proposal was rejected. Create a new module version to resubmit.'; default: - return status; + return status ?? ''; } } } diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/FeedbackController.java b/Server/src/main/java/modulemanagement/ls1/controllers/FeedbackController.java index 489c0511..543936c7 100644 --- a/Server/src/main/java/modulemanagement/ls1/controllers/FeedbackController.java +++ b/Server/src/main/java/modulemanagement/ls1/controllers/FeedbackController.java @@ -31,21 +31,21 @@ public FeedbackController(FeedbackService feedbackService, ModuleVersionService } @GetMapping("/for-authenticated-user") - @PreAuthorize("hasAnyRole('QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD')") + @PreAuthorize("hasAnyRole('QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD', 'PROGRAM_COORDINATOR', 'SPECIALIZATION_AREA_RESPONSIBLE')") public ResponseEntity> getFeedbacksForAuthenticatedUser(@CurrentUser User user) { List feedbacks = feedbackService.getAllFeedbacksForUser(user); return ResponseEntity.ok(feedbacks); } @GetMapping("/module-version-of-feedback/{feedbackId}") - @PreAuthorize("hasAnyRole('QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD')") - public ResponseEntity getModuleVersionOfFeedback(@PathVariable Long feedbackId) { - ModuleVersionViewDTO dto = feedbackService.getModuleVersionOfFeedback(feedbackId); + @PreAuthorize("hasAnyRole('QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD', 'PROGRAM_COORDINATOR', 'SPECIALIZATION_AREA_RESPONSIBLE')") + public ResponseEntity getModuleVersionOfFeedback(@CurrentUser User user, @PathVariable Long feedbackId) { + ModuleVersionViewDTO dto = feedbackService.getModuleVersionOfFeedback(feedbackId, user); return ResponseEntity.ok(dto); } @PutMapping("/{feedbackId}/accept") - @PreAuthorize("hasAnyRole('QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD')") + @PreAuthorize("hasAnyRole('QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD', 'PROGRAM_COORDINATOR', 'SPECIALIZATION_AREA_RESPONSIBLE')") public ResponseEntity approveFeedback(@CurrentUser User user, @PathVariable Long feedbackId) { Feedback updatedFeedback = feedbackService.Accept(feedbackId, user); moduleVersionService.updateStatus(updatedFeedback.getModuleVersion().getModuleVersionId()); @@ -53,7 +53,7 @@ public ResponseEntity approveFeedback(@CurrentUser User user, @PathVar } @PutMapping("/{feedbackId}/give-feedback") - @PreAuthorize("hasAnyRole('QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD')") + @PreAuthorize("hasAnyRole('QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD', 'PROGRAM_COORDINATOR', 'SPECIALIZATION_AREA_RESPONSIBLE')") public ResponseEntity giveFeedback(@CurrentUser User user, @PathVariable Long feedbackId, @Valid @RequestBody FeedbackDTO givenFeedback) { Feedback updatedFeedback = feedbackService.GiveFeedback(feedbackId, user, givenFeedback); @@ -62,7 +62,7 @@ public ResponseEntity giveFeedback(@CurrentUser User user, @PathVariab } @PutMapping("/{feedbackId}/reject") - @PreAuthorize("hasAnyRole('QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD')") + @PreAuthorize("hasAnyRole('QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD', 'PROGRAM_COORDINATOR', 'SPECIALIZATION_AREA_RESPONSIBLE')") public ResponseEntity rejectFeedback(@CurrentUser User user, @PathVariable Long feedbackId, @Valid @RequestBody GiveFeedbackDTO request) { Feedback updatedFeedback = feedbackService.RejectFeedback(feedbackId, user, request.getComment()); @@ -72,7 +72,7 @@ public ResponseEntity rejectFeedback(@CurrentUser User user, @PathVari } @GetMapping(value = "/{moduleVersionId}/export-pdf", produces = MediaType.APPLICATION_PDF_VALUE) - @PreAuthorize("hasAnyRole('QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD')") + @PreAuthorize("hasAnyRole('QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD', 'PROGRAM_COORDINATOR', 'SPECIALIZATION_AREA_RESPONSIBLE')") public ResponseEntity exportModuleVersionPdf(@CurrentUser User user, @PathVariable Long moduleVersionId) { diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java b/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java index 875c51fd..6695ab3d 100644 --- a/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java +++ b/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java @@ -39,15 +39,6 @@ public ModuleVersionController(ModuleVersionService moduleVersionService, this.llmGenerationService = llmGenerationService; } - @GetMapping("/{moduleVersionId}") - @PreAuthorize("hasAnyRole('PROFESSOR')") - public ResponseEntity getModuleVersionUpdateDtoFromId( - @CurrentUser User user, @PathVariable Long moduleVersionId) { - ModuleVersionViewDTO dto = moduleVersionService.getModuleVersionUpdateDtoFromId(moduleVersionId, - user.getUserId()); - return ResponseEntity.ok(dto); - } - @PutMapping("/{moduleVersionId}") @PreAuthorize("hasAnyRole('PROFESSOR')") public ResponseEntity updateModuleVersion(@CurrentUser User user, @@ -57,11 +48,11 @@ public ResponseEntity updateModuleVersion(@CurrentUser Use return ResponseEntity.ok(updatedModuleVersion); } - @GetMapping("/view/{moduleVersionId}") + @GetMapping("/{moduleVersionId}") @PreAuthorize("hasAnyRole('PROFESSOR')") - public ResponseEntity getModuleVersionViewDto(@CurrentUser User user, + public ResponseEntity getModuleVersion(@CurrentUser User user, @PathVariable Long moduleVersionId) { - ModuleVersionViewDTO dto = moduleVersionService.getModuleVersionViewDto(moduleVersionId, user.getUserId()); + ModuleVersionViewDTO dto = moduleVersionService.getModuleVersion(moduleVersionId, user.getUserId()); return ResponseEntity.ok(dto); } diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/ProposalController.java b/Server/src/main/java/modulemanagement/ls1/controllers/ProposalController.java index cb085f4a..57c06a95 100644 --- a/Server/src/main/java/modulemanagement/ls1/controllers/ProposalController.java +++ b/Server/src/main/java/modulemanagement/ls1/controllers/ProposalController.java @@ -7,11 +7,9 @@ import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; import java.util.List; @@ -36,29 +34,22 @@ public ResponseEntity createProposal(@CurrentUser User user, return ResponseEntity.ok(proposalView); } - @PostMapping(value = "/submit/{proposalId}") + @PostMapping(value = "/request-coordinators-feedback/{proposalId}") @PreAuthorize("hasAnyRole('PROFESSOR')") - public ResponseEntity submitProposal(@CurrentUser User user, + public ResponseEntity requestCoordinatorsFeedback(@CurrentUser User user, @PathVariable Long proposalId) { - var proposalDto = proposalService.submitProposal(proposalId, user.getUserId()); + var proposalDto = proposalService.requestCoordinatorsFeedback(proposalId, user.getUserId()); return ResponseEntity.ok(proposalDto); } - @PostMapping(value = "/cancel-submission/{proposalId}") - public ResponseEntity cancelSubmission(@CurrentUser User user, + @PostMapping(value = "/request-full-feedback/{proposalId}") + @PreAuthorize("hasAnyRole('PROFESSOR')") + public ResponseEntity requestFullFeedback(@CurrentUser User user, @PathVariable Long proposalId) { - var proposalDto = proposalService.cancelSubmission(proposalId, user.getUserId()); + var proposalDto = proposalService.requestFullFeedback(proposalId, user.getUserId()); return ResponseEntity.ok(proposalDto); } - @PostMapping("/add-module-version") - @PreAuthorize("hasAnyRole('PROFESSOR')") - public ResponseEntity addModuleVersion(@CurrentUser User user, - @Valid @RequestBody AddModuleVersionDTO request) { - ProposalViewDTO proposal = proposalService.addModuleVersion(user.getUserId(), request); - return ResponseEntity.ok(proposal); - } - @GetMapping("/{id}/view") @PreAuthorize("hasAnyRole('PROFESSOR')") public ResponseEntity getProposalView(@CurrentUser User user, @PathVariable Long id) { @@ -73,14 +64,10 @@ public ResponseEntity> getCompactProposalsFromUser(@Cu return ResponseEntity.ok(proposals); } - @DeleteMapping(value = "/{proposalId}", produces = MediaType.TEXT_PLAIN_VALUE) + @DeleteMapping(value = "/{proposalId}") @PreAuthorize("hasAnyRole('PROFESSOR')") public ResponseEntity deleteProposal(@CurrentUser User user, @PathVariable Long proposalId) { - try { - proposalService.deleteProposalById(proposalId, user.getUserId()); - return ResponseEntity.ok("Proposal deleted successfully."); - } catch (ResponseStatusException e) { - return ResponseEntity.badRequest().body(e.getReason()); - } + proposalService.deleteProposalById(proposalId, user.getUserId()); + return ResponseEntity.ok("Proposal deleted successfully."); } } diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java index 9a9f081c..f25df9af 100644 --- a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java @@ -10,13 +10,16 @@ @Data public class ModuleVersionViewFeedbackDTO { - @NotNull private Long feedbackId; + @NotNull + private Long feedbackId; private String feedbackFromFirstName; private String feedbackFromLastName; private String rejectionComment; private UserRole feedbackRole; + private String requestedFromUserName; private FeedbackStatus feedbackStatus; private LocalDateTime submissionDate; + private Long degreeProgramSpecializationId; private String titleFeedback; private String levelFeedback; @@ -47,8 +50,17 @@ public static ModuleVersionViewFeedbackDTO from(Feedback f) { } dto.setRejectionComment(f.getComment()); dto.setFeedbackRole(f.getRequiredRole()); + if (f.getDegreeProgramSpecialization() != null + && f.getDegreeProgramSpecialization().getResponsibleUser() != null) { + var resp = f.getDegreeProgramSpecialization().getResponsibleUser(); + dto.setRequestedFromUserName(resp.getFirstName() + " " + resp.getLastName() + " (" + + f.getDegreeProgramSpecialization().getName() + ")"); + } dto.setFeedbackStatus(f.getStatus()); dto.setSubmissionDate(f.getSubmissionDate()); + if (f.getDegreeProgramSpecialization() != null) { + dto.setDegreeProgramSpecializationId(f.getDegreeProgramSpecialization().getDegreeProgramSpecializationId()); + } dto.setTitleFeedback(f.getTitleFeedback()); dto.setLevelFeedback(f.getLevelFeedback()); dto.setLanguageFeedback(f.getLanguageFeedback()); diff --git a/Server/src/main/java/modulemanagement/ls1/enums/FeedbackStatus.java b/Server/src/main/java/modulemanagement/ls1/enums/FeedbackStatus.java index 866d67e8..4f36710c 100644 --- a/Server/src/main/java/modulemanagement/ls1/enums/FeedbackStatus.java +++ b/Server/src/main/java/modulemanagement/ls1/enums/FeedbackStatus.java @@ -1,11 +1,8 @@ package modulemanagement.ls1.enums; public enum FeedbackStatus { - PENDING_SUBMISSION, // Corresponding ModuleVersion only created, but nothing to review yet. - PENDING_FEEDBACK, // Corresponding ModuleVersion is submitted, waiting for this feedback. - APPROVED, // Reviewer accepts this ModuleVersion. + PENDING_FEEDBACK, // Corresponding ModuleVersion is submitted, waiting for this feedback. + APPROVED, // Reviewer accepts this ModuleVersion. FEEDBACK_GIVEN, - REJECTED, // Reviewer rejects this ModuleVersion. - OBSOLETE, // A new ModuleVersion for this proposal was submitted. - CANCELLED // Review of corresponding ModuleVersion was cancelled. + REJECTED, // Reviewer rejects this ModuleVersion. } diff --git a/Server/src/main/java/modulemanagement/ls1/enums/ModuleVersionStatus.java b/Server/src/main/java/modulemanagement/ls1/enums/ModuleVersionStatus.java index e58013b2..b9504087 100644 --- a/Server/src/main/java/modulemanagement/ls1/enums/ModuleVersionStatus.java +++ b/Server/src/main/java/modulemanagement/ls1/enums/ModuleVersionStatus.java @@ -1,11 +1,21 @@ package modulemanagement.ls1.enums; public enum ModuleVersionStatus { - PENDING_SUBMISSION, // Not submitted yet. - PENDING_FEEDBACK, // Submitted, and review is requested. - ACCEPTED, // All feedbacks accept. - FEEDBACK_GIVEN, - REJECTED, // One feedback rejects. - OBSOLETE, // New ModuleVersion was proposed. - CANCELLED, // Proposal of this version was cancelled. + /** Not yet submitted for coordinator feedback (first submission). */ + PENDING_FIRST_SUBMISSION, + /** + * Submitted for coordinator feedback; waiting for program/area coordinators. + */ + PENDING_COORDINATOR_FEEDBACK, + /** + * Coordinator feedback accepted; professor has not yet submitted for full + * feedback. + */ + PENDING_FULL_SUBMISSION, + /** Submitted for full feedback; waiting for QM, advisor, examination board. */ + PENDING_FULL_FEEDBACK, + ACCEPTED, + REQUIRES_REVIEW, + REJECTED, + CANCELLED, } diff --git a/Server/src/main/java/modulemanagement/ls1/enums/ProposalStatus.java b/Server/src/main/java/modulemanagement/ls1/enums/ProposalStatus.java index 7eb93e88..fb5ac52a 100644 --- a/Server/src/main/java/modulemanagement/ls1/enums/ProposalStatus.java +++ b/Server/src/main/java/modulemanagement/ls1/enums/ProposalStatus.java @@ -1,9 +1,23 @@ package modulemanagement.ls1.enums; public enum ProposalStatus { - PENDING_SUBMISSION, - PENDING_FEEDBACK, + /** + * Professor has not yet submitted for coordinator feedback (first submission). + */ + PENDING_FIRST_SUBMISSION, + /** + * Submitted for coordinator feedback; waiting for program/area coordinators. + */ + PENDING_COORDINATOR_FEEDBACK, + /** + * Coordinator feedback accepted; professor has not yet submitted for full + * feedback. + */ + PENDING_FULL_SUBMISSION, + /** Submitted for full feedback; waiting for QM, advisor, examination board. */ + PENDING_FULL_FEEDBACK, ACCEPTED, REQUIRES_REVIEW, REJECTED, + CANCELLED, } diff --git a/Server/src/main/java/modulemanagement/ls1/enums/UserRole.java b/Server/src/main/java/modulemanagement/ls1/enums/UserRole.java index 81dca201..7b878e7b 100644 --- a/Server/src/main/java/modulemanagement/ls1/enums/UserRole.java +++ b/Server/src/main/java/modulemanagement/ls1/enums/UserRole.java @@ -5,5 +5,9 @@ public enum UserRole { QUALITY_MANAGEMENT, ACADEMIC_PROGRAM_ADVISOR, EXAMINATION_BOARD, - PROFESSOR + PROFESSOR, + /** User is responsible for at least one degree program; can receive and respond to feedback requests for those. */ + PROGRAM_COORDINATOR, + /** User is responsible for at least one area of specialization; can receive and respond to feedback requests for those. */ + SPECIALIZATION_AREA_RESPONSIBLE } diff --git a/Server/src/main/java/modulemanagement/ls1/models/DegreeProgram.java b/Server/src/main/java/modulemanagement/ls1/models/DegreeProgram.java index 6f83a7e6..41adbdad 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/DegreeProgram.java +++ b/Server/src/main/java/modulemanagement/ls1/models/DegreeProgram.java @@ -22,7 +22,7 @@ public class DegreeProgram { private String name; @ManyToOne(fetch = FetchType.EAGER) - @JoinColumn(name = "responsible_user_id", nullable = false) + @JoinColumn(name = "responsible_user_id") private User responsibleUser; @ManyToMany(fetch = FetchType.LAZY) diff --git a/Server/src/main/java/modulemanagement/ls1/models/DegreeProgramSpecialization.java b/Server/src/main/java/modulemanagement/ls1/models/DegreeProgramSpecialization.java index 646e9aed..6b5a6d0b 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/DegreeProgramSpecialization.java +++ b/Server/src/main/java/modulemanagement/ls1/models/DegreeProgramSpecialization.java @@ -22,7 +22,7 @@ public class DegreeProgramSpecialization { private String name; @ManyToOne(fetch = FetchType.EAGER) - @JoinColumn(name = "responsible_user_id", nullable = false) + @JoinColumn(name = "responsible_user_id") private User responsibleUser; @ManyToMany(mappedBy = "degreeProgramSpecializations", fetch = FetchType.LAZY) diff --git a/Server/src/main/java/modulemanagement/ls1/models/Feedback.java b/Server/src/main/java/modulemanagement/ls1/models/Feedback.java index b3aa0191..3d01d696 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/Feedback.java +++ b/Server/src/main/java/modulemanagement/ls1/models/Feedback.java @@ -24,6 +24,18 @@ public class Feedback { @JoinColumn(name = "feedback_from") private User feedbackFrom; + @Column(name = "invalidated") + private boolean invalidated; + + /** + * When set, this feedback is for whoever is currently responsible for this + * specialization (position-based). + */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "degree_program_specialization_id") + @JsonIgnore + private DegreeProgramSpecialization degreeProgramSpecialization; + @Column(name = "comment") private String Comment; @@ -33,7 +45,8 @@ public class Feedback { @Enumerated(EnumType.STRING) @Column(name = "feedback_status") - @NotNull private FeedbackStatus status; + @NotNull + private FeedbackStatus status; @Column(name = "submission_date") private LocalDateTime submissionDate; @@ -206,23 +219,23 @@ public void insert(FeedbackDTO dto) { public boolean isAllFeedbackPositive() { return this.titleAccepted - && this.levelAccepted - && this.languageAccepted - && this.frequencyAccepted - && this.creditsAccepted - && this.durationAccepted - && this.hoursTotalAccepted - && this.hoursSelfStudyAccepted - && this.hoursPresenceAccepted - && this.examinationAchievementsAccepted - && this.repetitionAccepted - && this.recommendedPrerequisitesAccepted - && this.contentAccepted - && this.learningOutcomesAccepted - && this.teachingMethodsAccepted - && this.mediaAccepted - && this.literatureAccepted - && this.responsiblesAccepted - && this.lvSwsLecturerAccepted; + && this.levelAccepted + && this.languageAccepted + && this.frequencyAccepted + && this.creditsAccepted + && this.durationAccepted + && this.hoursTotalAccepted + && this.hoursSelfStudyAccepted + && this.hoursPresenceAccepted + && this.examinationAchievementsAccepted + && this.repetitionAccepted + && this.recommendedPrerequisitesAccepted + && this.contentAccepted + && this.learningOutcomesAccepted + && this.teachingMethodsAccepted + && this.mediaAccepted + && this.literatureAccepted + && this.responsiblesAccepted + && this.lvSwsLecturerAccepted; } } diff --git a/Server/src/main/java/modulemanagement/ls1/models/ModuleVersion.java b/Server/src/main/java/modulemanagement/ls1/models/ModuleVersion.java index 9e1a7f3b..07bb03bb 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/ModuleVersion.java +++ b/Server/src/main/java/modulemanagement/ls1/models/ModuleVersion.java @@ -143,6 +143,26 @@ public class ModuleVersion { @OneToMany(mappedBy = "moduleVersion", cascade = CascadeType.ALL, orphanRemoval = true) private List degreeProgramAssignments = new ArrayList<>(); + @JsonIgnore + public boolean isFirstStepComplete() { + if (StringUtils.isEmpty(titleEng) || StringUtils.isEmpty(titleDe)) + return false; + if (credits == null) + return false; + if (StringUtils.isEmpty(frequencyEng)) + return false; + if (hoursLecture == null || hoursExercise == null || hoursPractical == null || hoursSeminar == null) + return false; + if (StringUtils.isEmpty(firstSemesterAvailable) || StringUtils.isEmpty(successorModuleName)) + return false; + if (languageEng == null) + return false; + if (degreeProgramAssignments == null || degreeProgramAssignments.isEmpty()) + return false; + return degreeProgramAssignments.stream() + .allMatch(a -> a.getDegreeProgramSpecialization() != null && a.getDegreeProgram() != null); + } + @JsonIgnore public boolean isCompleted() { return !StringUtils.isEmpty(titleEng) @@ -166,7 +186,4 @@ public boolean isCompleted() { && !StringUtils.isEmpty(lvSwsLecturerEng); } - public boolean isFeedbackGiven() { - return this.getStatus() == ModuleVersionStatus.FEEDBACK_GIVEN; - } } diff --git a/Server/src/main/java/modulemanagement/ls1/models/Proposal.java b/Server/src/main/java/modulemanagement/ls1/models/Proposal.java index a45f3156..607e5961 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/Proposal.java +++ b/Server/src/main/java/modulemanagement/ls1/models/Proposal.java @@ -3,13 +3,10 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.validation.constraints.NotNull; import modulemanagement.ls1.dtos.ModuleVersionViewFeedbackDTO; -import modulemanagement.ls1.enums.ModuleVersionStatus; import modulemanagement.ls1.enums.ProposalStatus; import jakarta.persistence.*; import lombok.Data; import lombok.NoArgsConstructor; -import modulemanagement.ls1.services.ProposalService; - import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; @@ -40,11 +37,6 @@ public class Proposal { @OneToMany(mappedBy = "proposal", cascade = CascadeType.ALL, orphanRemoval = true) private List moduleVersions = new ArrayList<>(); - @JsonIgnore - public void addModuleVersion(ModuleVersion moduleVersion) { - moduleVersions.add(moduleVersion); - } - @JsonIgnore public ModuleVersion getLatestModuleVersionWithContent() { return moduleVersions.stream().max(Comparator.comparing(ModuleVersion::getVersion)).orElse(null); @@ -62,7 +54,7 @@ public void addNewModuleVersion() { newMv.setProposal(this); newMv.setVersion(latestMv.getVersion() + 1); newMv.setCreationDate(LocalDateTime.now()); - newMv.setStatus(ModuleVersionStatus.PENDING_SUBMISSION); + newMv.setStatus(latestMv.getStatus()); newMv.setBulletPoints(latestMv.getBulletPoints()); newMv.setTitleEng(latestMv.getTitleEng()); newMv.setTitleDe(latestMv.getTitleDe()); @@ -100,27 +92,22 @@ public void addNewModuleVersion() { newMv.getDegreeProgramAssignments().add(newAssig); } } - List requiredFeedbacks = new ArrayList<>(); - ProposalService.createNewFeedbacks(newMv, requiredFeedbacks); - newMv.setRequiredFeedbacks(requiredFeedbacks); - addModuleVersion(newMv); - this.setStatus(ProposalStatus.PENDING_SUBMISSION); + newMv.setRequiredFeedbacks(new ArrayList<>()); + moduleVersions.add(newMv); } public List getPreviousModuleVersionFeedback() { List mvs = this.getModuleVersions(); Collections.reverse(mvs); for (ModuleVersion mv : mvs) { - if (mv.isFeedbackGiven()) { - List previousFeedbacks = new ArrayList<>(); - List feedbacks = mv.getRequiredFeedbacks(); - for (Feedback feedback : feedbacks) { - if (feedback.isFeedbackGiven()) { - previousFeedbacks.add(ModuleVersionViewFeedbackDTO.from(feedback)); - } + List previousFeedbacks = new ArrayList<>(); + List feedbacks = mv.getRequiredFeedbacks(); + for (Feedback feedback : feedbacks) { + if (feedback.isFeedbackGiven()) { + previousFeedbacks.add(ModuleVersionViewFeedbackDTO.from(feedback)); } - return previousFeedbacks; } + return previousFeedbacks; } return Collections.emptyList(); } diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java index 6f16a27d..b68d4146 100644 --- a/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java +++ b/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramRepository.java @@ -8,10 +8,15 @@ import java.util.List; import java.util.Optional; +import java.util.UUID; @Repository public interface DegreeProgramRepository extends JpaRepository { + boolean existsByResponsibleUser_UserId(UUID userId); + + List findByResponsibleUser_UserId(UUID userId); + @EntityGraph(attributePaths = { "responsibleUser" }) @Query("SELECT p FROM DegreeProgram p") List findAllWithResponsibleUser(); diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramSpecializationRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramSpecializationRepository.java index 92d3170d..0de01c33 100644 --- a/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramSpecializationRepository.java +++ b/Server/src/main/java/modulemanagement/ls1/repositories/DegreeProgramSpecializationRepository.java @@ -9,10 +9,15 @@ import java.util.List; import java.util.Optional; +import java.util.UUID; @Repository public interface DegreeProgramSpecializationRepository extends JpaRepository { + boolean existsByResponsibleUser_UserId(UUID userId); + + List findByResponsibleUser_UserId(UUID userId); + @EntityGraph(attributePaths = { "responsibleUser" }) @Query("SELECT s FROM DegreeProgramSpecialization s") List findAllWithResponsibleUser(); diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java index 65b70cc4..1b6909f9 100644 --- a/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java +++ b/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java @@ -8,8 +8,12 @@ import java.util.Collection; import java.util.List; +import java.util.UUID; @Repository public interface FeedbackRepository extends JpaRepository { List findByRequiredRoleInAndStatus(Collection requiredRoles, FeedbackStatus status); + + /** Feedbacks for specializations that this user is currently responsible for (position-based). */ + List findByDegreeProgramSpecialization_ResponsibleUser_UserIdAndStatus(UUID userId, FeedbackStatus status); } diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/ProposalRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/ProposalRepository.java index a3be1e58..088e03ae 100644 --- a/Server/src/main/java/modulemanagement/ls1/repositories/ProposalRepository.java +++ b/Server/src/main/java/modulemanagement/ls1/repositories/ProposalRepository.java @@ -4,7 +4,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.UUID; + @Repository public interface ProposalRepository extends JpaRepository { + + List findByCreatedBy_UserId(UUID userId); } diff --git a/Server/src/main/java/modulemanagement/ls1/services/AdminUserService.java b/Server/src/main/java/modulemanagement/ls1/services/AdminUserService.java index 1388666f..43d4747b 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/AdminUserService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/AdminUserService.java @@ -3,22 +3,27 @@ import modulemanagement.ls1.dtos.PageResponseDTO; import modulemanagement.ls1.dtos.UpdateUserRoleDTO; import modulemanagement.ls1.dtos.UserDTO; +import modulemanagement.ls1.enums.UserRole; import modulemanagement.ls1.models.User; import modulemanagement.ls1.repositories.UserRepository; import modulemanagement.ls1.shared.ResourceNotFoundException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.UUID; @Service public class AdminUserService { private final UserRepository userRepository; + private final ResponsibleUserRoleService responsibleUserRoleService; - public AdminUserService(UserRepository userRepository) { + public AdminUserService(UserRepository userRepository, ResponsibleUserRoleService responsibleUserRoleService) { this.userRepository = userRepository; + this.responsibleUserRoleService = responsibleUserRoleService; } public PageResponseDTO getUsersPage(Pageable pageable, String search) { @@ -28,9 +33,18 @@ public PageResponseDTO getUsersPage(Pageable pageable, String search) { return PageResponseDTO.from(page.map(UserDTO::fromUser)); } + @Transactional public UserDTO updateUserRole(UUID userId, UpdateUserRoleDTO dto) { User user = userRepository.findById(userId) .orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId)); + List currentRoles = user.getRoles() != null ? user.getRoles() : List.of(); + List newRoles = dto.getRoles() != null ? dto.getRoles() : List.of(); + if (currentRoles.contains(UserRole.PROGRAM_COORDINATOR) && !newRoles.contains(UserRole.PROGRAM_COORDINATOR)) { + responsibleUserRoleService.unassignFromAllPrograms(userId); + } + if (currentRoles.contains(UserRole.SPECIALIZATION_AREA_RESPONSIBLE) && !newRoles.contains(UserRole.SPECIALIZATION_AREA_RESPONSIBLE)) { + responsibleUserRoleService.unassignFromAllSpecializations(userId); + } user.setRoles(dto.getRoles()); user = userRepository.save(user); return UserDTO.fromUser(user); diff --git a/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java index ad5098b3..16daf7dd 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; @Service @@ -18,13 +19,16 @@ public class DegreeProgramService { private final DegreeProgramRepository degreeProgramRepository; private final DegreeProgramSpecializationRepository degreeProgramSpecializationRepository; private final UserRepository userRepository; + private final ResponsibleUserRoleService responsibleUserRoleService; public DegreeProgramService(DegreeProgramRepository degreeProgramRepository, DegreeProgramSpecializationRepository degreeProgramSpecializationRepository, - UserRepository userRepository) { + UserRepository userRepository, + ResponsibleUserRoleService responsibleUserRoleService) { this.degreeProgramRepository = degreeProgramRepository; this.degreeProgramSpecializationRepository = degreeProgramSpecializationRepository; this.userRepository = userRepository; + this.responsibleUserRoleService = responsibleUserRoleService; } public List getAllDegreePrograms() { @@ -50,6 +54,7 @@ public DegreeProgramDTO createDegreeProgram(CreateDegreeProgramDTO dto) { program.setName(dto.getName()); program.setResponsibleUser(userRepository.getReferenceById(dto.getResponsibleUserId())); program = degreeProgramRepository.save(program); + responsibleUserRoleService.ensureProgramCoordinatorRole(dto.getResponsibleUserId()); program = degreeProgramRepository.findWithSpecializationsByDegreeProgramId(program.getDegreeProgramId()) .orElse(program); return DegreeProgramDTO.fromDegreeProgram(program); @@ -60,18 +65,29 @@ public DegreeProgramDTO updateDegreeProgram(Long id, UpdateDegreeProgramDTO dto) .orElseThrow(() -> new ResourceNotFoundException("Degree program not found: " + id)); if (dto.getName() != null) program.setName(dto.getName()); - if (dto.getResponsibleUserId() != null) + if (dto.getResponsibleUserId() != null) { + UUID previousUserId = program.getResponsibleUser() != null ? program.getResponsibleUser().getUserId() : null; program.setResponsibleUser(userRepository.getReferenceById(dto.getResponsibleUserId())); - program = degreeProgramRepository.save(program); + program = degreeProgramRepository.save(program); + responsibleUserRoleService.ensureProgramCoordinatorRole(dto.getResponsibleUserId()); + if (previousUserId != null && !previousUserId.equals(dto.getResponsibleUserId())) + responsibleUserRoleService.removeProgramCoordinatorRoleIfNotResponsible(previousUserId); + } else { + program = degreeProgramRepository.save(program); + } program = degreeProgramRepository.findWithSpecializationsByDegreeProgramId(id).orElse(program); return DegreeProgramDTO.fromDegreeProgram(program); } public void deleteDegreeProgram(Long id) { - if (!degreeProgramRepository.existsById(id)) { + DegreeProgram program = degreeProgramRepository.findWithSpecializationsByDegreeProgramId(id).orElse(null); + if (program == null) { throw new ResourceNotFoundException("Degree program not found: " + id); } + UUID responsibleUserId = program.getResponsibleUser() != null ? program.getResponsibleUser().getUserId() : null; degreeProgramRepository.deleteById(id); + if (responsibleUserId != null) + responsibleUserRoleService.removeProgramCoordinatorRoleIfNotResponsible(responsibleUserId); } public DegreeProgramDTO addSpecializationsToDegreeProgram(Long degreeProgramId, diff --git a/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramSpecializationService.java b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramSpecializationService.java index 97a6a3a4..99e66588 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramSpecializationService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/DegreeProgramSpecializationService.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Service; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; @Service @@ -15,12 +16,15 @@ public class DegreeProgramSpecializationService { private final DegreeProgramSpecializationRepository degreeProgramSpecializationRepository; private final UserRepository userRepository; + private final ResponsibleUserRoleService responsibleUserRoleService; public DegreeProgramSpecializationService( DegreeProgramSpecializationRepository degreeProgramSpecializationRepository, - UserRepository userRepository) { + UserRepository userRepository, + ResponsibleUserRoleService responsibleUserRoleService) { this.degreeProgramSpecializationRepository = degreeProgramSpecializationRepository; this.userRepository = userRepository; + this.responsibleUserRoleService = responsibleUserRoleService; } public List getAllDegreeProgramSpecializations() { @@ -34,6 +38,7 @@ public DegreeProgramSpecializationDTO createDegreeProgramSpecialization(CreateDe entity.setName(dto.getName()); entity.setResponsibleUser(userRepository.getReferenceById(dto.getResponsibleUserId())); entity = degreeProgramSpecializationRepository.save(entity); + responsibleUserRoleService.ensureSpecializationAreaResponsibleRole(dto.getResponsibleUserId()); return DegreeProgramSpecializationDTO.fromEntity( degreeProgramSpecializationRepository .findByIdWithResponsibleUser(entity.getDegreeProgramSpecializationId()).orElse(entity)); @@ -45,16 +50,27 @@ public DegreeProgramSpecializationDTO updateDegreeProgramSpecialization(Long id, .orElseThrow(() -> new ResourceNotFoundException("Degree program specialization not found: " + id)); if (dto.getName() != null) entity.setName(dto.getName()); - if (dto.getResponsibleUserId() != null) + if (dto.getResponsibleUserId() != null) { + UUID previousUserId = entity.getResponsibleUser() != null ? entity.getResponsibleUser().getUserId() : null; entity.setResponsibleUser(userRepository.getReferenceById(dto.getResponsibleUserId())); - entity = degreeProgramSpecializationRepository.save(entity); + entity = degreeProgramSpecializationRepository.save(entity); + responsibleUserRoleService.ensureSpecializationAreaResponsibleRole(dto.getResponsibleUserId()); + if (previousUserId != null && !previousUserId.equals(dto.getResponsibleUserId())) + responsibleUserRoleService.removeSpecializationAreaResponsibleRoleIfNotResponsible(previousUserId); + } else { + entity = degreeProgramSpecializationRepository.save(entity); + } return DegreeProgramSpecializationDTO.fromEntity(entity); } public void deleteDegreeProgramSpecialization(Long id) { - if (!degreeProgramSpecializationRepository.existsById(id)) { + DegreeProgramSpecialization entity = degreeProgramSpecializationRepository.findByIdWithResponsibleUser(id).orElse(null); + if (entity == null) { throw new ResourceNotFoundException("Degree program specialization not found: " + id); } + UUID responsibleUserId = entity.getResponsibleUser() != null ? entity.getResponsibleUser().getUserId() : null; degreeProgramSpecializationRepository.deleteById(id); + if (responsibleUserId != null) + responsibleUserRoleService.removeSpecializationAreaResponsibleRoleIfNotResponsible(responsibleUserId); } } diff --git a/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java b/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java index 4bc2fa95..80e70625 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java @@ -11,13 +11,18 @@ import modulemanagement.ls1.shared.ResourceNotFoundException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; +import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; @Service +@Validated public class FeedbackService { private final FeedbackRepository feedbackRepository; @@ -27,7 +32,7 @@ public FeedbackService(FeedbackRepository feedbackRepository) { public Feedback Accept(Long feedbackId, User user) { Feedback feedback = getPendingFeedback(feedbackId); - if (user.getRoles() == null || !user.getRoles().contains(feedback.getRequiredRole())) + if (!canUserRespondToFeedback(feedback, user)) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "You do not have permission to accept this feedback"); feedback.setFeedbackFrom(user); @@ -39,7 +44,7 @@ public Feedback Accept(Long feedbackId, User user) { public Feedback GiveFeedback(Long feedbackId, User user, FeedbackDTO givenFeedback) { Feedback feedback = getPendingFeedback(feedbackId); - if (user.getRoles() == null || !user.getRoles().contains(feedback.getRequiredRole())) + if (!canUserRespondToFeedback(feedback, user)) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "You do not have permission to accept this feedback"); feedback.setFeedbackFrom(user); @@ -53,7 +58,7 @@ public Feedback GiveFeedback(Long feedbackId, User user, FeedbackDTO givenFeedba public Feedback RejectFeedback(Long feedbackId, User user, @NotBlank String comment) { Feedback feedback = getPendingFeedback(feedbackId); - if (user.getRoles() == null || !user.getRoles().contains(feedback.getRequiredRole())) + if (!canUserRespondToFeedback(feedback, user)) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "You do not have permission to accept this feedback"); feedback.setFeedbackFrom(user); @@ -65,13 +70,32 @@ public Feedback RejectFeedback(Long feedbackId, User user, @NotBlank String comm } public List getAllFeedbacksForUser(User user) { - return feedbackRepository.findByRequiredRoleInAndStatus(user.getRoles(), FeedbackStatus.PENDING_FEEDBACK) - .stream() + List roleBased = user.getRoles() != null && !user.getRoles().isEmpty() + ? feedbackRepository.findByRequiredRoleInAndStatus(user.getRoles(), FeedbackStatus.PENDING_FEEDBACK) + : Collections.emptyList(); + List bySpecialization = feedbackRepository + .findByDegreeProgramSpecialization_ResponsibleUser_UserIdAndStatus(user.getUserId(), + FeedbackStatus.PENDING_FEEDBACK); + return Stream.of(roleBased.stream(), bySpecialization.stream()) + .flatMap(s -> s) + .distinct() .sorted(Comparator.comparing(Feedback::getFeedbackId)) .map(FeedbackListItemDto::fromFeedback) .toList(); } + private boolean canUserRespondToFeedback(Feedback feedback, User user) { + if (user == null) + return false; + if (feedback.getDegreeProgramSpecialization() != null + && feedback.getDegreeProgramSpecialization().getResponsibleUser() != null) { + return Objects.equals(user.getUserId(), + feedback.getDegreeProgramSpecialization().getResponsibleUser().getUserId()); + } + return user.getRoles() != null && feedback.getRequiredRole() != null + && user.getRoles().contains(feedback.getRequiredRole()); + } + private Feedback getPendingFeedback(Long feedbackId) { Feedback feedback = feedbackRepository.findById(feedbackId) .orElseThrow(() -> new ResourceNotFoundException("Feedback not found")); @@ -81,9 +105,12 @@ private Feedback getPendingFeedback(Long feedbackId) { return feedback; } - public ModuleVersionViewDTO getModuleVersionOfFeedback(Long feedbackId) { + public ModuleVersionViewDTO getModuleVersionOfFeedback(Long feedbackId, User user) { Feedback feedback = feedbackRepository.findById(feedbackId) .orElseThrow(() -> new ResourceNotFoundException("Feedback not found")); + if (!canUserRespondToFeedback(feedback, user)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You do not have access to this feedback."); + } return ModuleVersionViewDTO.from(feedback.getModuleVersion()); } diff --git a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java index b6ea2b4a..865fc02f 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java @@ -30,6 +30,8 @@ import org.springframework.web.server.ResponseStatusException; import jakarta.persistence.EntityManager; + +import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; @@ -58,16 +60,6 @@ public ModuleVersionService(ModuleVersionRepository moduleVersionRepository, this.entityManager = entityManager; } - private boolean hasAccessPermission(Proposal proposal, User user) { - if (proposal.getCreatedBy().getUserId().equals(user.getUserId())) { - return true; - } - - return user.getRoles() != null && (user.getRoles().contains(UserRole.QUALITY_MANAGEMENT) || - user.getRoles().contains(UserRole.EXAMINATION_BOARD) || - user.getRoles().contains(UserRole.ACADEMIC_PROGRAM_ADVISOR)); - } - @Transactional public ModuleVersionViewDTO updateModuleVersionFromRequest(UUID userId, Long moduleVersionId, ModuleVersionUpdateRequestDTO request) { @@ -79,11 +71,91 @@ public ModuleVersionViewDTO updateModuleVersionFromRequest(UUID userId, Long mod if (!mv.getVersion().equals(mv.getProposal().getLatestModuleVersionWithContent().getVersion())) { throw new OptimisticLockingFailureException("Cannot update an outdated ModuleVersion"); } - - if (!mv.getStatus().equals(ModuleVersionStatus.PENDING_SUBMISSION)) { - throw new IllegalStateException("Cannot update a submitted ModuleVersion"); + if (!mv.getRequiredFeedbacks().isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Cannot update ModuleVersion with feedback."); } + applyUpdateRequest(mv, request); + mv = moduleVersionRepository.save(mv); + return ModuleVersionViewDTO.from(mv); + } + + /** + * True if any step-1 field (basic info + assignments) differs between request + * and mv. + */ + // private boolean isStep1DataChanged(ModuleVersionUpdateRequestDTO request, + // ModuleVersion mv) { + // if (!Objects.equals(nullToEmpty(request.getTitleEng()), + // nullToEmpty(mv.getTitleEng()))) + // return true; + // if (!Objects.equals(nullToEmpty(request.getTitleDe()), + // nullToEmpty(mv.getTitleDe()))) + // return true; + // if (!Objects.equals(request.getCredits(), mv.getCredits())) + // return true; + // if (!Objects.equals(nullToEmpty(request.getFrequencyEng()), + // nullToEmpty(mv.getFrequencyEng()))) + // return true; + // if (!Objects.equals(request.getHoursLecture(), mv.getHoursLecture())) + // return true; + // if (!Objects.equals(request.getHoursExercise(), mv.getHoursExercise())) + // return true; + // if (!Objects.equals(request.getHoursPractical(), mv.getHoursPractical())) + // return true; + // if (!Objects.equals(request.getHoursSeminar(), mv.getHoursSeminar())) + // return true; + // if (!Objects.equals(nullToEmpty(request.getFirstSemesterAvailable()), + // nullToEmpty(mv.getFirstSemesterAvailable()))) + // return true; + // if (!Objects.equals(nullToEmpty(request.getSuccessorModuleName()), + // nullToEmpty(mv.getSuccessorModuleName()))) + // return true; + // if (!Objects.equals(request.getLanguageEng(), mv.getLanguageEng())) + // return true; + // if (request.getDegreeProgramAssignments() != null + // && !assignmentSetEquals(request.getDegreeProgramAssignments(), + // mv.getDegreeProgramAssignments())) { + // return true; + // } + // return false; + // } + + // private static String nullToEmpty(String s) { + // return s == null ? "" : s.trim(); + // } + + // private static boolean + // assignmentSetEquals(List requestList, + // List mvList) { + // Set requestSet = new HashSet<>(); + // if (requestList != null) { + // for (ModuleDegreeProgramAssignmentDTO a : requestList) { + // if (a != null && a.getDegreeProgramId() != null && + // a.getDegreeProgramSpecializationId() != null) { + // requestSet.add(a.getDegreeProgramId() + "," + + // a.getDegreeProgramSpecializationId()); + // } + // } + // } + // Set mvSet = new HashSet<>(); + // if (mvList != null) { + // for (ModuleVersionDegreeProgramAssignment a : mvList) { + // if (a.getDegreeProgram() != null && a.getDegreeProgramSpecialization() != + // null) { + // mvSet.add(a.getDegreeProgram().getDegreeProgramId() + "," + // + a.getDegreeProgramSpecialization().getDegreeProgramSpecializationId()); + // } + // } + // } + // return requestSet.equals(mvSet); + // } + + /** + * Applies request content and degree program assignments to the given module + * version. + */ + private void applyUpdateRequest(ModuleVersion mv, ModuleVersionUpdateRequestDTO request) { mv.setBulletPoints(request.getBulletPoints()); mv.setTitleEng(request.getTitleEng()); mv.setTitleDe(request.getTitleDe()); @@ -127,9 +199,10 @@ public ModuleVersionViewDTO updateModuleVersionFromRequest(UUID userId, Long mod "A module cannot be assigned to the same degree program more than once."); } } + assignmentRepository.deleteByModuleVersion_ModuleVersionId(mv.getModuleVersionId()); - entityManager.flush(); mv.getDegreeProgramAssignments().clear(); + entityManager.flush(); for (ModuleDegreeProgramAssignmentDTO item : request.getDegreeProgramAssignments()) { DegreeProgram program = degreeProgramRepository .findWithSpecializationsByDegreeProgramId(item.getDegreeProgramId()) @@ -149,9 +222,6 @@ public ModuleVersionViewDTO updateModuleVersionFromRequest(UUID userId, Long mod mv.getDegreeProgramAssignments().add(assignment); } } - - mv = moduleVersionRepository.save(mv); - return ModuleVersionViewDTO.from(mv); } public void updateStatus(Long moduleVersionId) { @@ -165,10 +235,22 @@ public void updateStatus(Long moduleVersionId) { return; } + List allFeedbacks = mv.getRequiredFeedbacks() != null ? mv.getRequiredFeedbacks() : new ArrayList<>(); + // Coordinator feedbacks for current assignments + all role-based feedbacks (we + // only have one active set of pending at a time). + List coordinatorFeedbacks = allFeedbacks.stream() + .filter(f -> f.getDegreeProgramSpecialization() != null) + .toList(); + List roleBased = allFeedbacks.stream() + .filter(f -> f.getRequiredRole() != null) + .toList(); + List feedbacksToEvaluate = new ArrayList<>(coordinatorFeedbacks); + feedbacksToEvaluate.addAll(roleBased); + boolean allFeedbackPositive = true; boolean oneFeedbackNegative = false; boolean oneFeedbackRejected = false; - for (Feedback feedback : mv.getRequiredFeedbacks()) { + for (Feedback feedback : feedbacksToEvaluate) { if (!feedback.getStatus().equals(FeedbackStatus.APPROVED)) { allFeedbackPositive = false; } @@ -181,12 +263,17 @@ public void updateStatus(Long moduleVersionId) { } if (allFeedbackPositive) { - mv.setStatus(ModuleVersionStatus.ACCEPTED); - p.setStatus(ProposalStatus.ACCEPTED); - mv.getProposal().setStatus(ProposalStatus.ACCEPTED); + boolean anyRoleBased = !roleBased.isEmpty(); + if (anyRoleBased) { + mv.setStatus(ModuleVersionStatus.ACCEPTED); + p.setStatus(ProposalStatus.ACCEPTED); + } else { + mv.setStatus(ModuleVersionStatus.PENDING_FULL_SUBMISSION); + p.setStatus(ProposalStatus.PENDING_FULL_SUBMISSION); + } } if (oneFeedbackNegative) { - mv.setStatus(ModuleVersionStatus.FEEDBACK_GIVEN); + mv.setStatus(ModuleVersionStatus.REQUIRES_REVIEW); p.setStatus(ProposalStatus.REQUIRES_REVIEW); } if (oneFeedbackRejected) { @@ -197,22 +284,7 @@ public void updateStatus(Long moduleVersionId) { moduleVersionRepository.save(mv); } - public ModuleVersionViewDTO getModuleVersionUpdateDtoFromId(Long moduleVersionId, UUID userId) { - var mv = moduleVersionRepository.findById(moduleVersionId) - .orElseThrow(() -> new ResourceNotFoundException("Module Version not found")); - Proposal p = mv.getProposal(); - if (!p.getCreatedBy().getUserId().equals(userId)) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized access"); - } - - if (p.getModuleVersions() == null || p.getModuleVersions().isEmpty()) { - throw new IllegalStateException("Proposal must have at least one ModuleVersion."); - } - - return ModuleVersionViewDTO.from(mv); - } - - public ModuleVersionViewDTO getModuleVersionViewDto(Long moduleVersionId, UUID userId) { + public ModuleVersionViewDTO getModuleVersion(Long moduleVersionId, UUID userId) { ModuleVersion mv = moduleVersionRepository.findById(moduleVersionId) .orElseThrow(() -> new ResourceNotFoundException("Could not find a module version with this ID.")); Proposal p = mv.getProposal(); @@ -260,4 +332,16 @@ public Resource generateProfessorModuleVersionPdf(Long moduleVersionId, UUID use return pdfCreator.createProfessorModuleVersionPdf(mv); } + + /// + /// + private boolean hasAccessPermission(Proposal proposal, User user) { + if (proposal.getCreatedBy().getUserId().equals(user.getUserId())) { + return true; + } + + return user.getRoles() != null && (user.getRoles().contains(UserRole.QUALITY_MANAGEMENT) + || user.getRoles().contains(UserRole.EXAMINATION_BOARD) + || user.getRoles().contains(UserRole.ACADEMIC_PROGRAM_ADVISOR)); + } } diff --git a/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java b/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java index 810fc519..be2ab559 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java @@ -1,6 +1,5 @@ package modulemanagement.ls1.services; -import jakarta.validation.Valid; import modulemanagement.ls1.dtos.*; import modulemanagement.ls1.enums.*; import modulemanagement.ls1.models.DegreeProgram; @@ -18,16 +17,19 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @Service +@Validated public class ProposalService { private final ProposalRepository proposalRepository; @@ -48,7 +50,7 @@ public ProposalViewDTO createProposalFromRequest(User user, ProposalRequestDTO r Proposal p = new Proposal(); p.setCreatedBy(user); p.setCreationDate(LocalDateTime.now()); - p.setStatus(ProposalStatus.PENDING_SUBMISSION); + p.setStatus(ProposalStatus.PENDING_FIRST_SUBMISSION); p = proposalRepository.save(p); ModuleVersion mv = new ModuleVersion(); @@ -56,7 +58,7 @@ public ProposalViewDTO createProposalFromRequest(User user, ProposalRequestDTO r mv.setModuleId(null); mv.setCreationDate(LocalDateTime.now()); mv.setProposal(p); - mv.setStatus(ModuleVersionStatus.PENDING_SUBMISSION); + mv.setStatus(ModuleVersionStatus.PENDING_FIRST_SUBMISSION); mv.setBulletPoints(request.getBulletPoints()); mv.setTitleEng(request.getTitleEng()); mv.setTitleDe(request.getTitleDe()); @@ -88,7 +90,6 @@ public ProposalViewDTO createProposalFromRequest(User user, ProposalRequestDTO r mv.setLiteratureEng(request.getLiteratureEng()); mv.setResponsiblesEng(request.getResponsiblesEng()); mv.setLvSwsLecturerEng(request.getLvSwsLecturerEng()); - mv = moduleVersionRepository.save(mv); if (request.getDegreeProgramAssignments() != null && !request.getDegreeProgramAssignments().isEmpty()) { List programIds = request.getDegreeProgramAssignments().stream() @@ -116,56 +117,17 @@ public ProposalViewDTO createProposalFromRequest(User user, ProposalRequestDTO r assignment.setDegreeProgramSpecialization(spec); mv.getDegreeProgramAssignments().add(assignment); } - moduleVersionRepository.save(mv); } - List feedbacks = new ArrayList<>(); - createNewFeedbacks(mv, feedbacks); - feedbacks = feedbackRepository.saveAll(feedbacks); - mv.setRequiredFeedbacks(feedbacks); + mv.setRequiredFeedbacks(new ArrayList<>()); moduleVersionRepository.save(mv); - p.addModuleVersion(mv); - proposalRepository.save(p); - return ProposalViewDTO.from(p); - } - - public static void createNewFeedbacks(ModuleVersion mv, List feedbacks) { - for (UserRole ad : UserRole.values()) { - if (ad.equals(UserRole.PROFESSOR) || ad.equals(UserRole.ADMIN)) - continue; - Feedback feedback = new Feedback(); - feedback.setStatus(FeedbackStatus.PENDING_SUBMISSION); - feedback.setRequiredRole(ad); - feedback.setModuleVersion(mv); - feedbacks.add(feedback); - } - } - - public ProposalViewDTO addModuleVersion(UUID userId, @Valid AddModuleVersionDTO request) { - Proposal p = proposalRepository.findById(request.getProposalId()) - .orElseThrow(() -> new ResourceNotFoundException("Proposal not found")); - if (!userId.equals(p.getCreatedBy().getUserId())) - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, - "You cannot add a module version to a module you did not create."); - if (!p.getStatus().equals(ProposalStatus.REQUIRES_REVIEW)) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "You can only add a new module version, if the proposal requires a review."); - - for (Feedback f : p.getLatestModuleVersionWithContent().getRequiredFeedbacks()) { - if (f.getStatus().equals(FeedbackStatus.PENDING_FEEDBACK)) { - f.setStatus(FeedbackStatus.OBSOLETE); - } - } - - p.addNewModuleVersion(); + p.getModuleVersions().add(mv); proposalRepository.save(p); return ProposalViewDTO.from(p); } public List getCompactProposalsOfUser(UUID userId) { - return proposalRepository.findAll() - .stream() - .filter(proposal -> proposal.getCreatedBy().getUserId().equals(userId)) + return proposalRepository.findByCreatedBy_UserId(userId).stream() .map(p -> new ProposalsCompactDTO( p.getProposalId(), p.getCreatedBy().getFirstName(), @@ -190,7 +152,8 @@ public ProposalViewDTO getProposalViewDtoById(UUID userId, long id) { } - public ProposalViewDTO submitProposal(Long proposalId, UUID userId) { + @Transactional + public ProposalViewDTO requestCoordinatorsFeedback(Long proposalId, UUID userId) { Proposal proposal = proposalRepository.findById(proposalId) .orElseThrow(() -> new IllegalArgumentException("No proposal with id " + proposalId + " found")); if (!proposal.getCreatedBy().getUserId().equals(userId)) { @@ -202,64 +165,102 @@ public ProposalViewDTO submitProposal(Long proposalId, UUID userId) { ModuleVersion mv = proposal.getLatestModuleVersionWithContent(); - if (!mv.getStatus().equals(ModuleVersionStatus.PENDING_SUBMISSION)) { - throw new IllegalStateException("Proposal is not pending submission. It is " + mv.getStatus() + "."); + if (!mv.getStatus().equals(ModuleVersionStatus.PENDING_FIRST_SUBMISSION)) { + throw new IllegalStateException("Proposal is not pending first submission. It is " + mv.getStatus() + "."); } - if (!mv.isCompleted()) { - throw new IllegalStateException("All required fields in ModuleVersion must be filled."); + if (!mv.isFirstStepComplete()) { + throw new IllegalStateException( + "Step 1 must be complete: English title and at least one degree program assignment with a chosen specialization."); } - mv.setStatus(ModuleVersionStatus.PENDING_FEEDBACK); - proposal.setStatus(ProposalStatus.PENDING_FEEDBACK); - List feedbacks = mv.getRequiredFeedbacks(); - for (Feedback feedback : feedbacks) { + List requiredFeedbacks = new ArrayList<>(); + + for (ModuleVersionDegreeProgramAssignment assignment : mv.getDegreeProgramAssignments()) { + var spec = assignment.getDegreeProgramSpecialization(); + Feedback feedback = new Feedback(); feedback.setStatus(FeedbackStatus.PENDING_FEEDBACK); + feedback.setDegreeProgramSpecialization(spec); + feedback.setRequiredRole(null); + feedback.setModuleVersion(mv); + requiredFeedbacks.add(feedbackRepository.save(feedback)); } - feedbackRepository.saveAll(feedbacks); + + mv.setStatus(ModuleVersionStatus.PENDING_COORDINATOR_FEEDBACK); + proposal.setStatus(ProposalStatus.PENDING_COORDINATOR_FEEDBACK); moduleVersionRepository.save(mv); + + // to keep the version with requested feedback immutable + proposal.addNewModuleVersion(); proposalRepository.save(proposal); + return ProposalViewDTO.from(proposal); } - public ProposalViewDTO cancelSubmission(Long proposalId, UUID userId) { + /** + * Roles that receive feedback in the second submission (after coordinator + * feedback is accepted). + */ + private static final Set FULL_FEEDBACK_ROLES = Set.of( + UserRole.QUALITY_MANAGEMENT, + UserRole.ACADEMIC_PROGRAM_ADVISOR, + UserRole.EXAMINATION_BOARD); + + @Transactional + public ProposalViewDTO requestFullFeedback(Long proposalId, UUID userId) { Proposal proposal = proposalRepository.findById(proposalId) .orElseThrow(() -> new IllegalArgumentException("No proposal with id " + proposalId + " found")); - if (!proposal.getCreatedBy().getUserId().equals(userId)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized access"); } + if (proposal.getModuleVersions() == null || proposal.getModuleVersions().isEmpty()) { + throw new IllegalStateException("Proposal must have at least one ModuleVersion."); + } ModuleVersion mv = proposal.getLatestModuleVersionWithContent(); - if (!mv.getStatus().equals(ModuleVersionStatus.PENDING_FEEDBACK)) { + if (!mv.getStatus().equals(ModuleVersionStatus.PENDING_FULL_SUBMISSION)) { throw new IllegalStateException( - "Only submitted proposals can cancel their submission. This proposal is " + mv.getStatus() + "."); + "Proposal must be in PENDING_FULL_SUBMISSION (coordinator feedback accepted). It is " + + mv.getStatus() + "."); + } + if (!mv.isCompleted()) { + throw new IllegalStateException("All steps must be completed before submitting for full feedback."); } - boolean oneFeedbackNotPending = false; - for (Feedback f : mv.getRequiredFeedbacks()) { - if (!f.getStatus().equals(FeedbackStatus.PENDING_FEEDBACK)) { - oneFeedbackNotPending = true; - break; - } + List requiredFeedbacks = mv.getRequiredFeedbacks() != null ? mv.getRequiredFeedbacks() + : new ArrayList<>(); + List coordinatorFeedbacks = requiredFeedbacks.stream() + .filter(f -> f.getDegreeProgramSpecialization() != null) + .toList(); + if (coordinatorFeedbacks.isEmpty()) { + throw new IllegalStateException( + "No coordinator feedbacks for current assignments. Submit for coordinator feedback first."); + } + boolean allCoordinatorAccepted = coordinatorFeedbacks.stream() + .allMatch(f -> f.getStatus() == FeedbackStatus.APPROVED); + if (!allCoordinatorAccepted) { + throw new IllegalStateException( + "All feedback from program and area coordinators (for current assignments) must be accepted before submitting for full feedback."); } - if (oneFeedbackNotPending) { - for (Feedback f : mv.getRequiredFeedbacks()) { - if (f.getStatus().equals(FeedbackStatus.PENDING_FEEDBACK)) { - f.setStatus(FeedbackStatus.CANCELLED); - } - } - mv.setStatus(ModuleVersionStatus.CANCELLED); - proposal.setStatus(ProposalStatus.REQUIRES_REVIEW); - } else { - for (Feedback f : mv.getRequiredFeedbacks()) { - f.setStatus(FeedbackStatus.PENDING_SUBMISSION); - } - mv.setStatus(ModuleVersionStatus.PENDING_SUBMISSION); - proposal.setStatus(ProposalStatus.PENDING_SUBMISSION); + // Create new feedbacks (one per role); do not reuse old ones. + for (UserRole role : FULL_FEEDBACK_ROLES) { + Feedback feedback = new Feedback(); + feedback.setStatus(FeedbackStatus.PENDING_FEEDBACK); + feedback.setRequiredRole(role); + feedback.setDegreeProgramSpecialization(null); + feedback.setModuleVersion(mv); + requiredFeedbacks.add(feedbackRepository.save(feedback)); } + + mv.setStatus(ModuleVersionStatus.PENDING_FULL_FEEDBACK); + proposal.setStatus(ProposalStatus.PENDING_FULL_FEEDBACK); + moduleVersionRepository.save(mv); + + // to keep the version with requested feedback immutable + proposal.addNewModuleVersion(); proposalRepository.save(proposal); + return ProposalViewDTO.from(proposal); } @@ -269,9 +270,9 @@ public void deleteProposalById(long proposalId, UUID userId) { if (!p.getCreatedBy().getUserId().equals(userId)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized access"); } - if (p.getStatus() != ProposalStatus.PENDING_SUBMISSION) { + if (!p.getStatus().equals(ProposalStatus.PENDING_FIRST_SUBMISSION)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "You can only delete a proposal that is not already submit. This module proposal is " + "You can only delete a proposal that is not already submitted. This module proposal is " + p.getStatus() + "."); } proposalRepository.delete(p); diff --git a/Server/src/main/java/modulemanagement/ls1/services/ResponsibleUserRoleService.java b/Server/src/main/java/modulemanagement/ls1/services/ResponsibleUserRoleService.java new file mode 100644 index 00000000..a1c99a66 --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/services/ResponsibleUserRoleService.java @@ -0,0 +1,96 @@ +package modulemanagement.ls1.services; + +import modulemanagement.ls1.enums.UserRole; +import modulemanagement.ls1.models.DegreeProgram; +import modulemanagement.ls1.models.DegreeProgramSpecialization; +import modulemanagement.ls1.models.User; +import modulemanagement.ls1.repositories.DegreeProgramRepository; +import modulemanagement.ls1.repositories.DegreeProgramSpecializationRepository; +import modulemanagement.ls1.repositories.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Keeps PROGRAM_COORDINATOR and SPECIALIZATION_AREA_RESPONSIBLE roles in sync: + * - Program coordinators (responsible for a degree program) have PROGRAM_COORDINATOR. + * - Specialization area responsibles have SPECIALIZATION_AREA_RESPONSIBLE. + */ +@Service +public class ResponsibleUserRoleService { + + private final UserRepository userRepository; + private final DegreeProgramRepository degreeProgramRepository; + private final DegreeProgramSpecializationRepository degreeProgramSpecializationRepository; + + public ResponsibleUserRoleService(UserRepository userRepository, + DegreeProgramRepository degreeProgramRepository, + DegreeProgramSpecializationRepository degreeProgramSpecializationRepository) { + this.userRepository = userRepository; + this.degreeProgramRepository = degreeProgramRepository; + this.degreeProgramSpecializationRepository = degreeProgramSpecializationRepository; + } + + @Transactional + public void ensureProgramCoordinatorRole(UUID userId) { + User user = userRepository.findById(userId).orElse(null); + if (user == null) return; + if (user.getRoles() != null && user.getRoles().contains(UserRole.PROGRAM_COORDINATOR)) + return; + if (user.getRoles() == null) user.setRoles(new ArrayList<>()); + user.getRoles().add(UserRole.PROGRAM_COORDINATOR); + userRepository.save(user); + } + + @Transactional + public void removeProgramCoordinatorRoleIfNotResponsible(UUID userId) { + if (degreeProgramRepository.existsByResponsibleUser_UserId(userId)) + return; + User user = userRepository.findById(userId).orElse(null); + if (user == null || user.getRoles() == null) return; + if (user.getRoles().remove(UserRole.PROGRAM_COORDINATOR)) + userRepository.save(user); + } + + @Transactional + public void ensureSpecializationAreaResponsibleRole(UUID userId) { + User user = userRepository.findById(userId).orElse(null); + if (user == null) return; + if (user.getRoles() != null && user.getRoles().contains(UserRole.SPECIALIZATION_AREA_RESPONSIBLE)) + return; + if (user.getRoles() == null) user.setRoles(new ArrayList<>()); + user.getRoles().add(UserRole.SPECIALIZATION_AREA_RESPONSIBLE); + userRepository.save(user); + } + + @Transactional + public void removeSpecializationAreaResponsibleRoleIfNotResponsible(UUID userId) { + if (degreeProgramSpecializationRepository.existsByResponsibleUser_UserId(userId)) + return; + User user = userRepository.findById(userId).orElse(null); + if (user == null || user.getRoles() == null) return; + if (user.getRoles().remove(UserRole.SPECIALIZATION_AREA_RESPONSIBLE)) + userRepository.save(user); + } + + @Transactional + public void unassignFromAllPrograms(UUID userId) { + List programs = degreeProgramRepository.findByResponsibleUser_UserId(userId); + for (DegreeProgram p : programs) { + p.setResponsibleUser(null); + } + if (!programs.isEmpty()) degreeProgramRepository.saveAll(programs); + } + + @Transactional + public void unassignFromAllSpecializations(UUID userId) { + List specs = degreeProgramSpecializationRepository.findByResponsibleUser_UserId(userId); + for (DegreeProgramSpecialization s : specs) { + s.setResponsibleUser(null); + } + if (!specs.isEmpty()) degreeProgramSpecializationRepository.saveAll(specs); + } +} diff --git a/Server/src/main/resources/db/changelog/changes/0008_degree_program_and_area_tables.yaml b/Server/src/main/resources/db/changelog/changes/0008_degree_program_and_area_tables.yaml index d37e43dc..e82ed550 100644 --- a/Server/src/main/resources/db/changelog/changes/0008_degree_program_and_area_tables.yaml +++ b/Server/src/main/resources/db/changelog/changes/0008_degree_program_and_area_tables.yaml @@ -21,7 +21,7 @@ databaseChangeLog: name: responsible_user_id type: UUID constraints: - nullable: false + nullable: true - addForeignKeyConstraint: baseTableName: degree_program baseColumnNames: responsible_user_id @@ -46,7 +46,7 @@ databaseChangeLog: name: responsible_user_id type: UUID constraints: - nullable: false + nullable: true - addForeignKeyConstraint: baseTableName: degree_program_specialization baseColumnNames: responsible_user_id diff --git a/Server/src/main/resources/db/changelog/changes/0012_feedback_requested_from_user.yaml b/Server/src/main/resources/db/changelog/changes/0012_feedback_requested_from_user.yaml new file mode 100644 index 00000000..8d273cd0 --- /dev/null +++ b/Server/src/main/resources/db/changelog/changes/0012_feedback_requested_from_user.yaml @@ -0,0 +1,26 @@ +databaseChangeLog: + - changeSet: + id: 0012_feedback_requested_from_user + author: module-management + changes: + - addColumn: + tableName: feedback + columns: + - column: + name: degree_program_specialization_id + type: BIGINT + constraints: + nullable: true + foreignKeyName: feedback_degree_program_specialization_fk + references: degree_program_specialization(degree_program_specialization_id) + - column: + name: invalidated + type: BOOLEAN + constraints: + nullable: false + defaultValueBoolean: false + + - dropNotNullConstraint: + tableName: feedback + columnName: required_role + columnDataType: VARCHAR(255) diff --git a/Server/src/main/resources/db/changelog/changes/0013_grant_program_area_responsible_role.yaml b/Server/src/main/resources/db/changelog/changes/0013_grant_program_area_responsible_role.yaml new file mode 100644 index 00000000..d3842eb3 --- /dev/null +++ b/Server/src/main/resources/db/changelog/changes/0013_grant_program_area_responsible_role.yaml @@ -0,0 +1,33 @@ +databaseChangeLog: + # Grant PROGRAM_COORDINATOR to users responsible for a degree program + - changeSet: + id: 0013a_grant_program_coordinator_role + author: module-management + changes: + - sql: + dbms: postgresql + sql: | + INSERT INTO app_user_role (user_id, role) + SELECT DISTINCT responsible_user_id, 'PROGRAM_COORDINATOR' + FROM degree_program + WHERE responsible_user_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM app_user_role r + WHERE r.user_id = degree_program.responsible_user_id AND r.role = 'PROGRAM_COORDINATOR' + ) + # Grant SPECIALIZATION_AREA_RESPONSIBLE to users responsible for a specialization + - changeSet: + id: 0013b_grant_specialization_area_responsible_role + author: module-management + changes: + - sql: + dbms: postgresql + sql: | + INSERT INTO app_user_role (user_id, role) + SELECT DISTINCT responsible_user_id, 'SPECIALIZATION_AREA_RESPONSIBLE' + FROM degree_program_specialization + WHERE responsible_user_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM app_user_role r + WHERE r.user_id = degree_program_specialization.responsible_user_id AND r.role = 'SPECIALIZATION_AREA_RESPONSIBLE' + ) diff --git a/Server/src/main/resources/db/changelog/master.yaml b/Server/src/main/resources/db/changelog/master.yaml index d2170be5..40436095 100644 --- a/Server/src/main/resources/db/changelog/master.yaml +++ b/Server/src/main/resources/db/changelog/master.yaml @@ -32,3 +32,9 @@ databaseChangeLog: - include: relativeToChangelogFile: true file: initialize/0011_seed_degree_program_specializations.yaml + - include: + relativeToChangelogFile: true + file: changes/0012_feedback_requested_from_user.yaml + - include: + relativeToChangelogFile: true + file: changes/0013_grant_program_area_responsible_role.yaml From 3d22936990e92e2cc810eb84375bc94c035994b7 Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Tue, 17 Mar 2026 22:14:30 +0100 Subject: [PATCH 08/39] rename --- .../create-edit-base.component.html | 4 ++-- .../src/app/core/modules/openapi/model/feedback.ts | 4 ++-- .../model/module-version-view-feedback-dto.ts | 4 ++-- .../modules/openapi/model/update-user-role-dto.ts | 4 ++-- .../src/app/core/modules/openapi/model/user-dto.ts | 4 ++-- Client/src/app/core/modules/openapi/model/user.ts | 4 ++-- Client/src/app/core/shared/user-role.utils.ts | 4 ++-- .../module-version-view.component.html | 2 +- Client/src/app/pipes/feedbackDepartment.pipe.ts | 6 +++--- Client/src/app/pipes/proposalStatus.pipe.ts | 2 +- .../ls1/controllers/FeedbackController.java | 12 ++++++------ .../java/modulemanagement/ls1/enums/UserRole.java | 4 ++-- .../ls1/services/AdminUserService.java | 2 +- .../DegreeProgramSpecializationService.java | 8 ++++---- .../ls1/services/ResponsibleUserRoleService.java | 14 +++++++------- .../0013_grant_program_area_responsible_role.yaml | 8 ++++---- 16 files changed, 43 insertions(+), 43 deletions(-) diff --git a/Client/src/app/components/create-edit-base/create-edit-base.component.html b/Client/src/app/components/create-edit-base/create-edit-base.component.html index a30ab661..494d5c15 100644 --- a/Client/src/app/components/create-edit-base/create-edit-base.component.html +++ b/Client/src/app/components/create-edit-base/create-edit-base.component.html @@ -207,11 +207,11 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for '
@if (isCreateMode()) { - Complete the first step and create the proposal. After saving, you can use this step to submit for feedback from program coordinators and area responsibles. + Complete the first step and create the proposal. After saving, you can use this step to submit for feedback from program coordinators and area coordinators. } @else {

- Submit to request feedback from program coordinators and area responsibles (first submission). One feedback is required per chosen degree program and area. Complete + Submit to request feedback from program coordinators and area coordinators (first submission). One feedback is required per chosen degree program and area. Complete step 1 first, then use the button below. You can cancel and resubmit later if needed.

@if (!isFirstStepComplete()) { diff --git a/Client/src/app/core/modules/openapi/model/feedback.ts b/Client/src/app/core/modules/openapi/model/feedback.ts index a1c66884..85d9d062 100644 --- a/Client/src/app/core/modules/openapi/model/feedback.ts +++ b/Client/src/app/core/modules/openapi/model/feedback.ts @@ -60,7 +60,7 @@ export interface Feedback { comment?: string; } export namespace Feedback { - export type RequiredRoleEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_RESPONSIBLE'; + export type RequiredRoleEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR'; export const RequiredRoleEnum = { Admin: 'ADMIN' as RequiredRoleEnum, QualityManagement: 'QUALITY_MANAGEMENT' as RequiredRoleEnum, @@ -68,7 +68,7 @@ export namespace Feedback { ExaminationBoard: 'EXAMINATION_BOARD' as RequiredRoleEnum, Professor: 'PROFESSOR' as RequiredRoleEnum, ProgramCoordinator: 'PROGRAM_COORDINATOR' as RequiredRoleEnum, - SpecializationAreaResponsible: 'SPECIALIZATION_AREA_RESPONSIBLE' as RequiredRoleEnum + SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RequiredRoleEnum }; export type StatusEnum = 'PENDING_FEEDBACK' | 'APPROVED' | 'FEEDBACK_GIVEN' | 'REJECTED'; export const StatusEnum = { diff --git a/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts b/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts index b940b671..c420ed0a 100644 --- a/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts +++ b/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts @@ -40,7 +40,7 @@ export interface ModuleVersionViewFeedbackDTO { lvSwsLecturerFeedback?: string; } export namespace ModuleVersionViewFeedbackDTO { - export type FeedbackRoleEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_RESPONSIBLE'; + export type FeedbackRoleEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR'; export const FeedbackRoleEnum = { Admin: 'ADMIN' as FeedbackRoleEnum, QualityManagement: 'QUALITY_MANAGEMENT' as FeedbackRoleEnum, @@ -48,7 +48,7 @@ export namespace ModuleVersionViewFeedbackDTO { ExaminationBoard: 'EXAMINATION_BOARD' as FeedbackRoleEnum, Professor: 'PROFESSOR' as FeedbackRoleEnum, ProgramCoordinator: 'PROGRAM_COORDINATOR' as FeedbackRoleEnum, - SpecializationAreaResponsible: 'SPECIALIZATION_AREA_RESPONSIBLE' as FeedbackRoleEnum + SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as FeedbackRoleEnum }; export type FeedbackStatusEnum = 'PENDING_FEEDBACK' | 'APPROVED' | 'FEEDBACK_GIVEN' | 'REJECTED'; export const FeedbackStatusEnum = { diff --git a/Client/src/app/core/modules/openapi/model/update-user-role-dto.ts b/Client/src/app/core/modules/openapi/model/update-user-role-dto.ts index 14813c0a..81cbb4d9 100644 --- a/Client/src/app/core/modules/openapi/model/update-user-role-dto.ts +++ b/Client/src/app/core/modules/openapi/model/update-user-role-dto.ts @@ -13,7 +13,7 @@ export interface UpdateUserRoleDTO { roles: Array; } export namespace UpdateUserRoleDTO { - export type RolesEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_RESPONSIBLE'; + export type RolesEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR'; export const RolesEnum = { Admin: 'ADMIN' as RolesEnum, QualityManagement: 'QUALITY_MANAGEMENT' as RolesEnum, @@ -21,7 +21,7 @@ export namespace UpdateUserRoleDTO { ExaminationBoard: 'EXAMINATION_BOARD' as RolesEnum, Professor: 'PROFESSOR' as RolesEnum, ProgramCoordinator: 'PROGRAM_COORDINATOR' as RolesEnum, - SpecializationAreaResponsible: 'SPECIALIZATION_AREA_RESPONSIBLE' as RolesEnum + SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RolesEnum }; } diff --git a/Client/src/app/core/modules/openapi/model/user-dto.ts b/Client/src/app/core/modules/openapi/model/user-dto.ts index 683e78ea..04ae31aa 100644 --- a/Client/src/app/core/modules/openapi/model/user-dto.ts +++ b/Client/src/app/core/modules/openapi/model/user-dto.ts @@ -18,7 +18,7 @@ export interface UserDTO { roles?: Array; } export namespace UserDTO { - export type RolesEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_RESPONSIBLE'; + export type RolesEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR'; export const RolesEnum = { Admin: 'ADMIN' as RolesEnum, QualityManagement: 'QUALITY_MANAGEMENT' as RolesEnum, @@ -26,7 +26,7 @@ export namespace UserDTO { ExaminationBoard: 'EXAMINATION_BOARD' as RolesEnum, Professor: 'PROFESSOR' as RolesEnum, ProgramCoordinator: 'PROGRAM_COORDINATOR' as RolesEnum, - SpecializationAreaResponsible: 'SPECIALIZATION_AREA_RESPONSIBLE' as RolesEnum + SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RolesEnum }; } diff --git a/Client/src/app/core/modules/openapi/model/user.ts b/Client/src/app/core/modules/openapi/model/user.ts index 536f879a..4dadbd6c 100644 --- a/Client/src/app/core/modules/openapi/model/user.ts +++ b/Client/src/app/core/modules/openapi/model/user.ts @@ -18,7 +18,7 @@ export interface User { roles?: Array; } export namespace User { - export type RolesEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_RESPONSIBLE'; + export type RolesEnum = 'ADMIN' | 'QUALITY_MANAGEMENT' | 'ACADEMIC_PROGRAM_ADVISOR' | 'EXAMINATION_BOARD' | 'PROFESSOR' | 'PROGRAM_COORDINATOR' | 'SPECIALIZATION_AREA_COORDINATOR'; export const RolesEnum = { Admin: 'ADMIN' as RolesEnum, QualityManagement: 'QUALITY_MANAGEMENT' as RolesEnum, @@ -26,7 +26,7 @@ export namespace User { ExaminationBoard: 'EXAMINATION_BOARD' as RolesEnum, Professor: 'PROFESSOR' as RolesEnum, ProgramCoordinator: 'PROGRAM_COORDINATOR' as RolesEnum, - SpecializationAreaResponsible: 'SPECIALIZATION_AREA_RESPONSIBLE' as RolesEnum + SpecializationAreaCoordinator: 'SPECIALIZATION_AREA_COORDINATOR' as RolesEnum }; } diff --git a/Client/src/app/core/shared/user-role.utils.ts b/Client/src/app/core/shared/user-role.utils.ts index cf6843a9..cd674760 100644 --- a/Client/src/app/core/shared/user-role.utils.ts +++ b/Client/src/app/core/shared/user-role.utils.ts @@ -1,12 +1,12 @@ import { User } from '../modules/openapi'; -/** Roles that can review proposals and see pending feedbacks (approval staff, program coordinators, specialization area responsibles). */ +/** Roles that can review proposals and see pending feedbacks (approval staff, program coordinators, specialization area coordinators). */ export const REVIEWER_ROLES: readonly User.RolesEnum[] = [ User.RolesEnum.QualityManagement, User.RolesEnum.AcademicProgramAdvisor, User.RolesEnum.ExaminationBoard, User.RolesEnum.ProgramCoordinator, - User.RolesEnum.SpecializationAreaResponsible + User.RolesEnum.SpecializationAreaCoordinator ] as const; export function isAdminRole(roles: User.RolesEnum[] | undefined | null): boolean { diff --git a/Client/src/app/pages/module-version-view/module-version-view.component.html b/Client/src/app/pages/module-version-view/module-version-view.component.html index 8945f03d..990117da 100644 --- a/Client/src/app/pages/module-version-view/module-version-view.component.html +++ b/Client/src/app/pages/module-version-view/module-version-view.component.html @@ -87,7 +87,7 @@

Overview of '{{ moduleVersionDto.titleEng }}' - Version {{ @if (currentStepIndex() === 1) { + }
@if (canRequestCoordinatorsFeedback()) { fb.feedbackStatus === ModuleVersionViewFeedbackDTO.FeedbackStatusEnum.Rejected); + if (hasRejection) return StepperStatus.ActionRequired; + const allApproved = feedbacks.every((fb) => fb.feedbackStatus === ModuleVersionViewFeedbackDTO.FeedbackStatusEnum.Approved); + return allApproved ? StepperStatus.Completed : StepperStatus.Pending; } + if (step.id === 'submit-full-feedback') { return StepperStatus.Default; } @@ -114,6 +120,28 @@ export abstract class ProposalBaseComponent { return status === 'PENDING_FULL_SUBMISSION' && this.allFormStepsComplete(); }); + /** Coordinator feedbacks for this version (for current assignments). From moduleVersionDto().feedbacks. */ + coordinatorFeedbacksForCurrentAssignmentsFromDto = computed(() => { + const dto = this.moduleVersionDto(); + const feedbacks = (dto as ModuleVersionViewDTO)?.feedbacks ?? []; + const coordinator = feedbacks.filter((f) => f.feedbackRole == null); + const specIds = new Set((dto as ModuleVersionViewDTO)?.degreeProgramAssignments?.map((a) => a.degreeProgramSpecializationId).filter((id): id is number => id != null) ?? []); + if (specIds.size === 0) return coordinator; + return coordinator.filter((f) => f.degreeProgramSpecializationId != null && specIds.has(f.degreeProgramSpecializationId)); + }); + + /** Coordinator feedbacks to show in step 1: current version if any, otherwise previous version (feedbacks() from API). */ + coordinatorFeedbacksForStep1 = computed(() => { + const fromDto = this.coordinatorFeedbacksForCurrentAssignmentsFromDto(); + if (fromDto.length > 0) return { feedbacks: fromDto, fromPrevious: false }; + const prev = this.feedbacks() ?? []; + const coordinator = prev.filter((f) => f.feedbackRole == null); + const dto = this.moduleVersionDto() as ModuleVersionViewDTO | null; + const specIds = new Set(dto?.degreeProgramAssignments?.map((a) => a.degreeProgramSpecializationId).filter((id): id is number => id != null) ?? []); + const filtered = specIds.size === 0 ? coordinator : coordinator.filter((f) => f.degreeProgramSpecializationId != null && specIds.has(f.degreeProgramSpecializationId)); + return { feedbacks: filtered, fromPrevious: true }; + }); + showPrompt: { [key: string]: boolean } = { examination: false, content: false, @@ -239,7 +267,7 @@ export abstract class ProposalBaseComponent { if (newId != null && newId !== this.moduleVersionId) { this.moduleVersionId = newId; this.breadcrumbLabels.versionLabel.set(response?.latestVersion != null ? `Version ${response.latestVersion}` : null); - this.router.navigate(['/proposals/view', proposalId, 'version', newId, 'edit'], { replaceUrl: true }); + this.router.navigate(['/proposals', proposalId, 'version', newId, 'edit'], { replaceUrl: true }); } }, error: (err: HttpErrorResponse) => { @@ -264,7 +292,7 @@ export abstract class ProposalBaseComponent { if (newId != null && newId !== this.moduleVersionId) { this.moduleVersionId = newId; this.breadcrumbLabels.versionLabel.set(response?.latestVersion != null ? `Version ${response.latestVersion}` : null); - this.router.navigate(['/proposals/view', proposalId, 'version', newId, 'edit'], { replaceUrl: true }); + this.router.navigate(['/proposals', proposalId, 'version', newId, 'edit'], { replaceUrl: true }); } }, error: (err: HttpErrorResponse) => { diff --git a/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.css b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.css index 59540382..186c910a 100644 --- a/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.css +++ b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.css @@ -38,6 +38,11 @@ color: var(--p-primary-contrast-color, #1a1a1a); } +.step-item--action-required .step-number { + background: var(--p-error-color, #ef4444); + color: white; +} + .step-number { flex-shrink: 0; width: 1.75rem; @@ -57,31 +62,3 @@ font-size: 1rem; } -.step-feedbacks { - display: flex; - gap: 0.25rem; -} - -.feedback-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; - background: var(--p-surface-400, #adb5bd); -} - -.feedback-dot--PENDING_SUBMISSION { - background: #94a3b8; -} - -.feedback-dot--PENDING_FEEDBACK { - background: #eab308; -} - -.feedback-dot--APPROVED, -.feedback-dot--FEEDBACK_GIVEN { - background: #22c55e; -} - -.feedback-dot--REJECTED { - background: #ef4444; -} diff --git a/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.html b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.html index 3b49daf3..ca313ecd 100644 --- a/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.html +++ b/Client/src/app/components/module-edit-stepper/module-edit-stepper.component.html @@ -6,6 +6,7 @@ [class.step-item--active]="i === currentIndex()" [class.step-item--completed]="stepStatuses()[i] === StepperStatus.Completed" [class.step-item--pending]="stepStatuses()[i] === StepperStatus.Pending" + [class.step-item--action-required]="stepStatuses()[i] === StepperStatus.ActionRequired" (click)="goToStep(i)" > diff --git a/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts b/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts index f4df2148..750903ca 100644 --- a/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts +++ b/Client/src/app/components/module-edit-stepper/module-edit-steps.config.ts @@ -1,8 +1,11 @@ +import { Action } from 'rxjs/internal/scheduler/Action'; + /** Status of a step in the stepper; determines how the step is displayed (number, check, or pending color). */ export const StepperStatus = { Default: 'default', Pending: 'pending', - Completed: 'completed' + Completed: 'completed', + ActionRequired: 'action-required' } as const; export type StepperStatus = (typeof StepperStatus)[keyof typeof StepperStatus]; @@ -66,9 +69,3 @@ export const MODULE_EDIT_STEPS: ModuleEditStepConfig[] = [ controlNames: [] } ]; - -/** Steps for the module overview (view) page: edit steps + Feedbacks as final step. */ -export const MODULE_VIEW_STEPS: ModuleEditStepConfig[] = [ - ...MODULE_EDIT_STEPS, - { id: 'feedbacks', title: 'Feedbacks', controlNames: [] } -]; diff --git a/Client/src/app/components/proposal-list-table/proposal-list-table.component.html b/Client/src/app/components/proposal-list-table/proposal-list-table.component.html index 185ef0f5..29684b57 100644 --- a/Client/src/app/components/proposal-list-table/proposal-list-table.component.html +++ b/Client/src/app/components/proposal-list-table/proposal-list-table.component.html @@ -8,7 +8,7 @@ - + {{ proposal.proposalId }} {{ proposal.latestTitle }} @@ -17,7 +17,7 @@ - + diff --git a/Client/src/app/core/modules/openapi/api/module-version-controller.service.ts b/Client/src/app/core/modules/openapi/api/module-version-controller.service.ts index c98a5eba..936209b4 100644 --- a/Client/src/app/core/modules/openapi/api/module-version-controller.service.ts +++ b/Client/src/app/core/modules/openapi/api/module-version-controller.service.ts @@ -622,7 +622,7 @@ export class ModuleVersionControllerService implements ModuleVersionControllerSe } } - let localVarPath = `/api/module-versions/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}/previous-module-version-feedback`; + let localVarPath = `/api/module-versions/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}/previous-module-versions-feedback`; return this.httpClient.request>('get', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, diff --git a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts index 07a83c44..7b5d6838 100644 --- a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts +++ b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts @@ -5,6 +5,7 @@ import { ActivatedRoute, RouterModule } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { ProposalBaseComponent } from '../../components/create-edit-base/create-edit-base.component'; import { FeedbackDepartmentPipe } from '../../pipes/feedbackDepartment.pipe'; +import { FeedbackStatusPipe } from '../../pipes/feedbackStatus.pipe'; import { ModuleEditStepperComponent } from '../../components/module-edit-stepper/module-edit-stepper.component'; import { ModuleDegreeProgramAssignmentDTO, ModuleVersionUpdateRequestDTO, ModuleVersionViewDTO, ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; import { ToggleButtonGroupComponent } from '../../components/toggle-button-group/toggle-button-group.component'; @@ -26,6 +27,7 @@ import { TagModule } from 'primeng/tag'; CommonModule, RouterModule, FeedbackDepartmentPipe, + FeedbackStatusPipe, ToggleButtonGroupComponent, ModuleEditStepperComponent, ButtonModule, diff --git a/Client/src/app/pages/module-version-view/module-version-view.component.html b/Client/src/app/pages/module-version-view/module-version-view.component.html index 990117da..0c274e5c 100644 --- a/Client/src/app/pages/module-version-view/module-version-view.component.html +++ b/Client/src/app/pages/module-version-view/module-version-view.component.html @@ -14,7 +14,7 @@ } @else if (moduleVersionDto(); as moduleVersionDto) {

Overview of '{{ moduleVersionDto.titleEng }}' - Version {{ moduleVersionDto.version }}

@@ -22,10 +22,10 @@

Overview of '{{ moduleVersionDto.titleEng }}' - Version {{
@if (proposalId) { - + } @if (isLatestVersion() && proposalId) { - + }

@@ -85,16 +85,16 @@

Overview of '{{ moduleVersionDto.titleEng }}' - Version {{ } @if (currentStepIndex() === 1) { - + } @if (currentStepIndex() === 2) { diff --git a/Client/src/app/pages/module-version-view/module-version-view.component.ts b/Client/src/app/pages/module-version-view/module-version-view.component.ts index 2c6925e7..2a46056a 100644 --- a/Client/src/app/pages/module-version-view/module-version-view.component.ts +++ b/Client/src/app/pages/module-version-view/module-version-view.component.ts @@ -5,7 +5,7 @@ import { RouterModule, ActivatedRoute } from '@angular/router'; import { ModuleVersionControllerService, ModuleVersionViewDTO, ModuleVersionViewFeedbackDTO } from '../../core/modules/openapi'; import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; import { ModuleEditStepperComponent } from '../../components/module-edit-stepper/module-edit-stepper.component'; -import { MODULE_VIEW_STEPS, StepperStatus } from '../../components/module-edit-stepper/module-edit-steps.config'; +import { MODULE_EDIT_STEPS, StepperStatus } from '../../components/module-edit-stepper/module-edit-steps.config'; import { FeedbackDepartmentPipe } from '../../pipes/feedbackDepartment.pipe'; import { FeedbackStatusPipe } from '../../pipes/feedbackStatus.pipe'; import { ModuleVersionStatusPipe } from '../../pipes/moduleVersionStatus.pipe'; @@ -56,9 +56,34 @@ export class ModuleVersionViewComponent { moduleVersionStatus = ModuleVersionViewDTO.StatusEnum; error = signal(null); - readonly MODULE_VIEW_STEPS = MODULE_VIEW_STEPS; + readonly MODULE_EDIT_STEPS = MODULE_EDIT_STEPS; currentStepIndex = signal(0); + /** Coordinator feedbacks for this version (for current assignments). From moduleVersionDto().feedbacks. */ + coordinatorFeedbacksForCurrentAssignments = computed(() => { + const dto = this.moduleVersionDto(); + const feedbacks = dto?.feedbacks ?? []; + const coordinator = feedbacks.filter((f) => f.feedbackRole == null); + const specIds = new Set(dto?.degreeProgramAssignments?.map((a) => a.degreeProgramSpecializationId).filter((id): id is number => id != null) ?? []); + if (specIds.size === 0) return coordinator; + return coordinator.filter((f) => f.degreeProgramSpecializationId != null && specIds.has(f.degreeProgramSpecializationId)); + }); + + /** Previous module version feedback (all non-invalidated for proposal). Fetched when loading; used when current version has no feedbacks yet. */ + previousVersionFeedbacks = signal([]); + + /** Coordinator feedbacks to show in step 1: current version if any, otherwise previous version feedback (so latest MV still shows status). */ + coordinatorFeedbacksForStep1 = computed(() => { + const fromCurrent = this.coordinatorFeedbacksForCurrentAssignments(); + if (fromCurrent.length > 0) return { feedbacks: fromCurrent, fromPrevious: false }; + const prev = this.previousVersionFeedbacks(); + const coordinator = prev.filter((f) => f.feedbackRole == null); + const dto = this.moduleVersionDto(); + const specIds = new Set(dto?.degreeProgramAssignments?.map((a) => a.degreeProgramSpecializationId).filter((id): id is number => id != null) ?? []); + const filtered = specIds.size === 0 ? coordinator : coordinator.filter((f) => f.degreeProgramSpecializationId != null && specIds.has(f.degreeProgramSpecializationId)); + return { feedbacks: filtered, fromPrevious: true }; + }); + moduleFields: ModuleField[] = [ { key: 'titleEng', label: 'Title', section: 'basic', feedbackKey: 'titleFeedback' }, { key: 'titleDe', label: 'Title (German)', section: 'basic' }, @@ -105,14 +130,22 @@ export class ModuleVersionViewComponent { stepStatuses = computed(() => { const dto = this.moduleVersionDto(); - if (!dto) return MODULE_VIEW_STEPS.map(() => StepperStatus.Default); + if (!dto) return MODULE_EDIT_STEPS.map(() => StepperStatus.Default); - return MODULE_VIEW_STEPS.map((step, index) => { + return MODULE_EDIT_STEPS.map((step, index) => { if (step.id === 'feedbacks') return StepperStatus.Default; - if (step.id === 'submit-coordinator-feedback') return StepperStatus.Default; + if (step.id === 'submit-coordinator-feedback') { + const feedbacks = this.coordinatorFeedbacksForStep1().feedbacks; + if (feedbacks.length === 0) return StepperStatus.Default; + const hasRejection = feedbacks.some((fb) => fb.feedbackStatus === ModuleVersionViewFeedbackDTO.FeedbackStatusEnum.Rejected); + if (hasRejection) return StepperStatus.ActionRequired; + const allApproved = feedbacks.every((fb) => fb.feedbackStatus === ModuleVersionViewFeedbackDTO.FeedbackStatusEnum.Approved); + return allApproved ? StepperStatus.Completed : StepperStatus.Pending; + } + if (step.id === 'submit-full-feedback') return StepperStatus.Default; - const keys = MODULE_VIEW_STEPS[index].controlNames as (keyof ModuleVersionViewDTO)[]; + const keys = MODULE_EDIT_STEPS[index].controlNames as (keyof ModuleVersionViewDTO)[]; if (!keys?.length) return StepperStatus.Default; const allFilled = keys.every((k) => { const v = dto[k]; @@ -131,6 +164,7 @@ export class ModuleVersionViewComponent { const versionId = params.get('versionId'); this.moduleVersionId = Number(versionId); this.fetchModuleVersionViewDto(this.moduleVersionId); + this.fetchPreviousVersionsFeedbacks(this.moduleVersionId); } goToStep(index: number) { @@ -138,7 +172,7 @@ export class ModuleVersionViewComponent { } getFieldsForViewStep(stepIndex: number): ModuleField[] { - const keys = MODULE_VIEW_STEPS[stepIndex].controlNames as (keyof ModuleVersionViewDTO)[]; + const keys = MODULE_EDIT_STEPS[stepIndex].controlNames as (keyof ModuleVersionViewDTO)[]; if (!keys?.length) return []; return this.moduleFields.filter((f) => keys.includes(f.key)); } @@ -157,6 +191,11 @@ export class ModuleVersionViewComponent { }); } + private fetchPreviousVersionsFeedbacks(moduleVersionId: number) { + this.moduleVersionService.getPreviousModuleVersionFeedback(moduleVersionId).subscribe({ + next: (list) => this.previousVersionFeedbacks.set(list) + }); + } pdfExport() { const mvid = this.moduleVersionId; if (!mvid) { diff --git a/Client/src/app/pages/proposal-create/proposal-create.component.ts b/Client/src/app/pages/proposal-create/proposal-create.component.ts index e9ee0d85..ad54260c 100644 --- a/Client/src/app/pages/proposal-create/proposal-create.component.ts +++ b/Client/src/app/pages/proposal-create/proposal-create.component.ts @@ -72,7 +72,7 @@ export class ProposalCreateComponent extends ProposalBaseComponent { const proposalId = res.proposalId; const moduleVersionId = res.latestModuleVersion?.moduleVersionId; if (proposalId != null && moduleVersionId != null) { - await this.router.navigate(['/proposals/view', proposalId, 'version', moduleVersionId, 'edit'], { queryParams: { created: true } }); + await this.router.navigate(['/proposals', proposalId, 'version', moduleVersionId, 'edit'], { queryParams: { created: true } }); } else { this.error.set('Unexpected response from server.'); } diff --git a/Client/src/app/pages/proposal-view/proposal-view.component.html b/Client/src/app/pages/proposal-view/proposal-view.component.html index 1b1c0391..5f5adeb4 100644 --- a/Client/src/app/pages/proposal-view/proposal-view.component.html +++ b/Client/src/app/pages/proposal-view/proposal-view.component.html @@ -59,7 +59,7 @@

Current Module Versions

- + {{ proposal.latestVersion }} {{ proposal.latestModuleVersion!.titleEng }} @@ -87,14 +87,14 @@

Current Module Versions

label="Edit" outlined severity="contrast" - [routerLink]="['/proposals/view', proposal.proposalId, 'version', proposal.latestModuleVersion!.moduleVersionId, 'edit']" + [routerLink]="['/proposals', proposal.proposalId, 'version', proposal.latestModuleVersion!.moduleVersionId, 'edit']" (click)="$event.stopPropagation()" />

@@ -116,7 +116,7 @@

Previous Module Versions - + {{ version.version }} {{ version.titleEng }} @@ -144,7 +144,7 @@

Previous Module Versions

diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java b/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java index 6695ab3d..97cb8b44 100644 --- a/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java +++ b/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java @@ -56,13 +56,13 @@ public ResponseEntity getModuleVersion(@CurrentUser User u return ResponseEntity.ok(dto); } - @GetMapping("/{id}/previous-module-version-feedback") + @GetMapping("/{id}/previous-module-versions-feedback") @PreAuthorize("hasAnyRole('PROFESSOR')") public ResponseEntity> getPreviousModuleVersionFeedback( @CurrentUser User user, @PathVariable Long id) { - List lastRejectionReasons = moduleVersionService + List previousFeedbacks = moduleVersionService .getPreviousModuleVersionFeedback(user.getUserId(), id); - return ResponseEntity.ok(lastRejectionReasons); + return ResponseEntity.ok(previousFeedbacks); } @PostMapping("/generate/content") diff --git a/Server/src/main/java/modulemanagement/ls1/models/Proposal.java b/Server/src/main/java/modulemanagement/ls1/models/Proposal.java index 607e5961..172a527c 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/Proposal.java +++ b/Server/src/main/java/modulemanagement/ls1/models/Proposal.java @@ -2,14 +2,12 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.validation.constraints.NotNull; -import modulemanagement.ls1.dtos.ModuleVersionViewFeedbackDTO; import modulemanagement.ls1.enums.ProposalStatus; import jakarta.persistence.*; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -96,19 +94,4 @@ public void addNewModuleVersion() { moduleVersions.add(newMv); } - public List getPreviousModuleVersionFeedback() { - List mvs = this.getModuleVersions(); - Collections.reverse(mvs); - for (ModuleVersion mv : mvs) { - List previousFeedbacks = new ArrayList<>(); - List feedbacks = mv.getRequiredFeedbacks(); - for (Feedback feedback : feedbacks) { - if (feedback.isFeedbackGiven()) { - previousFeedbacks.add(ModuleVersionViewFeedbackDTO.from(feedback)); - } - } - return previousFeedbacks; - } - return Collections.emptyList(); - } } diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java index 1b6909f9..e617eedb 100644 --- a/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java +++ b/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java @@ -16,4 +16,7 @@ public interface FeedbackRepository extends JpaRepository { /** Feedbacks for specializations that this user is currently responsible for (position-based). */ List findByDegreeProgramSpecialization_ResponsibleUser_UserIdAndStatus(UUID userId, FeedbackStatus status); + + /** All feedbacks for a proposal that are not invalidated (for display on view/edit). */ + List findByModuleVersion_Proposal_ProposalIdAndInvalidatedFalse(Long proposalId); } diff --git a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java index 865fc02f..f4fb8909 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java @@ -17,6 +17,7 @@ import modulemanagement.ls1.models.Proposal; import modulemanagement.ls1.models.User; import modulemanagement.ls1.repositories.DegreeProgramRepository; +import modulemanagement.ls1.repositories.FeedbackRepository; import modulemanagement.ls1.repositories.ModuleVersionDegreeProgramAssignmentRepository; import modulemanagement.ls1.repositories.ModuleVersionRepository; import modulemanagement.ls1.repositories.ProposalRepository; @@ -45,17 +46,19 @@ public class ModuleVersionService { private final OverlapDetectionService overlapDetectionService; private final PdfCreator pdfCreator; private final EntityManager entityManager; + private final FeedbackRepository feedbackRepository; public ModuleVersionService(ModuleVersionRepository moduleVersionRepository, ModuleVersionDegreeProgramAssignmentRepository assignmentRepository, DegreeProgramRepository degreeProgramRepository, ProposalRepository proposalRepository, OverlapDetectionService overlapDetectionService, PdfCreator pdfCreator, - EntityManager entityManager) { + EntityManager entityManager, FeedbackRepository feedbackRepository) { this.moduleVersionRepository = moduleVersionRepository; this.assignmentRepository = assignmentRepository; this.degreeProgramRepository = degreeProgramRepository; this.proposalRepository = proposalRepository; this.overlapDetectionService = overlapDetectionService; + this.feedbackRepository = feedbackRepository; this.pdfCreator = pdfCreator; this.entityManager = entityManager; } @@ -311,8 +314,9 @@ public List getPreviousModuleVersionFeedback(UUID if (!proposal.getCreatedBy().getUserId().equals(userId)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized access"); } - - return proposal.getPreviousModuleVersionFeedback(); + List list = feedbackRepository + .findByModuleVersion_Proposal_ProposalIdAndInvalidatedFalse(proposal.getProposalId()); + return list.stream().map(ModuleVersionViewFeedbackDTO::from).toList(); } public Resource generateReviewerModuleVersionPdf(Long moduleVersionId, User user) { From 3c1e5a474c30966ac4a94461459114571f94033d Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Wed, 18 Mar 2026 22:20:32 +0100 Subject: [PATCH 10/39] invalidate previous feedbacks when needed --- .../module-version-edit.component.ts | 6 +- .../module-version-view.component.html | 5 +- .../proposal-view.component.html | 22 +++- .../ls1/repositories/FeedbackRepository.java | 4 +- .../ls1/services/FeedbackService.java | 7 +- .../ls1/services/ModuleVersionService.java | 104 ++++++------------ .../ls1/services/ProposalService.java | 2 +- .../ModuleVersionStepsChangeDetector.java | 82 ++++++++++++++ 8 files changed, 145 insertions(+), 87 deletions(-) create mode 100644 Server/src/main/java/modulemanagement/ls1/shared/ModuleVersionStepsChangeDetector.java diff --git a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts index 7b5d6838..f1a0017a 100644 --- a/Client/src/app/pages/module-version-edit/module-version-edit.component.ts +++ b/Client/src/app/pages/module-version-edit/module-version-edit.component.ts @@ -105,7 +105,11 @@ export class ModuleVersionEditComponent extends ProposalBaseComponent { degreeProgramAssignments }; this.moduleVersionService.updateModuleVersion(this.moduleVersionId, payload).subscribe({ - next: (response: ModuleVersionViewDTO) => this.moduleVersionDto.set(response), + next: (response: ModuleVersionViewDTO) => { + this.moduleVersionDto.set(response); + // Step-1 changes can invalidate previous feedbacks; refresh so the UI reflects it. + this.fetchPreviousModuleVersionFeedback(this.moduleVersionId); + }, error: (err: HttpErrorResponse) => this.error.set(err.error), complete: () => this.loading.set(false) }); diff --git a/Client/src/app/pages/module-version-view/module-version-view.component.html b/Client/src/app/pages/module-version-view/module-version-view.component.html index 0c274e5c..7603a9f3 100644 --- a/Client/src/app/pages/module-version-view/module-version-view.component.html +++ b/Client/src/app/pages/module-version-view/module-version-view.component.html @@ -86,10 +86,7 @@

Overview of '{{ moduleVersionDto.titleEng }}' - Version {{ @if (currentStepIndex() === 1) { -

- First submission: request feedback from program coordinators and area coordinators (one per chosen degree program/area). Feedback is tied to the module version that was - submitted (immutable). When viewing the latest version, status below is from the previous submitted version. -

+

First submission: request feedback from program coordinators and area coordinators (one per chosen degree program/area).

@if (coordinatorFeedbacksForStep1().feedbacks.length > 0) {

Coordinator feedback status {{ coordinatorFeedbacksForStep1().fromPrevious ? '(from previous version)' : '(this version)' }}

diff --git a/Client/src/app/pages/proposal-view/proposal-view.component.html b/Client/src/app/pages/proposal-view/proposal-view.component.html index 5f5adeb4..f7f0cb83 100644 --- a/Client/src/app/pages/proposal-view/proposal-view.component.html +++ b/Client/src/app/pages/proposal-view/proposal-view.component.html @@ -72,10 +72,16 @@

Current Module Versions

@if (proposal.latestModuleVersion!.feedbackList!.length) { @for (feedback of proposal.latestModuleVersion!.feedbackList; track feedback.requiredRole) {
} } @@ -129,10 +135,16 @@

Previous Module Versions

} } diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java index e617eedb..1422ee1c 100644 --- a/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java +++ b/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java @@ -12,10 +12,10 @@ @Repository public interface FeedbackRepository extends JpaRepository { - List findByRequiredRoleInAndStatus(Collection requiredRoles, FeedbackStatus status); + List findByRequiredRoleInAndStatusAndInvalidatedFalse(Collection requiredRoles, FeedbackStatus status); /** Feedbacks for specializations that this user is currently responsible for (position-based). */ - List findByDegreeProgramSpecialization_ResponsibleUser_UserIdAndStatus(UUID userId, FeedbackStatus status); + List findByDegreeProgramSpecialization_ResponsibleUser_UserIdAndStatusAndInvalidatedFalse(UUID userId, FeedbackStatus status); /** All feedbacks for a proposal that are not invalidated (for display on view/edit). */ List findByModuleVersion_Proposal_ProposalIdAndInvalidatedFalse(Long proposalId); diff --git a/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java b/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java index 80e70625..4162203f 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/FeedbackService.java @@ -71,10 +71,10 @@ public Feedback RejectFeedback(Long feedbackId, User user, @NotBlank String comm public List getAllFeedbacksForUser(User user) { List roleBased = user.getRoles() != null && !user.getRoles().isEmpty() - ? feedbackRepository.findByRequiredRoleInAndStatus(user.getRoles(), FeedbackStatus.PENDING_FEEDBACK) + ? feedbackRepository.findByRequiredRoleInAndStatusAndInvalidatedFalse(user.getRoles(), FeedbackStatus.PENDING_FEEDBACK) : Collections.emptyList(); List bySpecialization = feedbackRepository - .findByDegreeProgramSpecialization_ResponsibleUser_UserIdAndStatus(user.getUserId(), + .findByDegreeProgramSpecialization_ResponsibleUser_UserIdAndStatusAndInvalidatedFalse(user.getUserId(), FeedbackStatus.PENDING_FEEDBACK); return Stream.of(roleBased.stream(), bySpecialization.stream()) .flatMap(s -> s) @@ -99,6 +99,9 @@ private boolean canUserRespondToFeedback(Feedback feedback, User user) { private Feedback getPendingFeedback(Long feedbackId) { Feedback feedback = feedbackRepository.findById(feedbackId) .orElseThrow(() -> new ResourceNotFoundException("Feedback not found")); + if (feedback.isInvalidated()) { + throw new IllegalStateException("This feedback has been invalidated."); + } if (feedback.getStatus() != FeedbackStatus.PENDING_FEEDBACK) { throw new IllegalStateException("This module is not " + FeedbackStatus.PENDING_FEEDBACK); } diff --git a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java index f4fb8909..4e0676fb 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java @@ -23,6 +23,7 @@ import modulemanagement.ls1.repositories.ProposalRepository; import modulemanagement.ls1.shared.PdfCreator; import modulemanagement.ls1.shared.ResourceNotFoundException; +import modulemanagement.ls1.shared.ModuleVersionStepsChangeDetector; import org.springframework.core.io.Resource; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpStatus; @@ -78,81 +79,36 @@ public ModuleVersionViewDTO updateModuleVersionFromRequest(UUID userId, Long mod throw new ResponseStatusException(HttpStatus.CONFLICT, "Cannot update ModuleVersion with feedback."); } + boolean step1Changed = ModuleVersionStepsChangeDetector.isStep1DataChanged(request, mv); + applyUpdateRequest(mv, request); + + if (step1Changed) { + invalidateActiveFeedbacksAndResetStatuses(mv); + } + mv = moduleVersionRepository.save(mv); return ModuleVersionViewDTO.from(mv); } - /** - * True if any step-1 field (basic info + assignments) differs between request - * and mv. - */ - // private boolean isStep1DataChanged(ModuleVersionUpdateRequestDTO request, - // ModuleVersion mv) { - // if (!Objects.equals(nullToEmpty(request.getTitleEng()), - // nullToEmpty(mv.getTitleEng()))) - // return true; - // if (!Objects.equals(nullToEmpty(request.getTitleDe()), - // nullToEmpty(mv.getTitleDe()))) - // return true; - // if (!Objects.equals(request.getCredits(), mv.getCredits())) - // return true; - // if (!Objects.equals(nullToEmpty(request.getFrequencyEng()), - // nullToEmpty(mv.getFrequencyEng()))) - // return true; - // if (!Objects.equals(request.getHoursLecture(), mv.getHoursLecture())) - // return true; - // if (!Objects.equals(request.getHoursExercise(), mv.getHoursExercise())) - // return true; - // if (!Objects.equals(request.getHoursPractical(), mv.getHoursPractical())) - // return true; - // if (!Objects.equals(request.getHoursSeminar(), mv.getHoursSeminar())) - // return true; - // if (!Objects.equals(nullToEmpty(request.getFirstSemesterAvailable()), - // nullToEmpty(mv.getFirstSemesterAvailable()))) - // return true; - // if (!Objects.equals(nullToEmpty(request.getSuccessorModuleName()), - // nullToEmpty(mv.getSuccessorModuleName()))) - // return true; - // if (!Objects.equals(request.getLanguageEng(), mv.getLanguageEng())) - // return true; - // if (request.getDegreeProgramAssignments() != null - // && !assignmentSetEquals(request.getDegreeProgramAssignments(), - // mv.getDegreeProgramAssignments())) { - // return true; - // } - // return false; - // } - - // private static String nullToEmpty(String s) { - // return s == null ? "" : s.trim(); - // } - - // private static boolean - // assignmentSetEquals(List requestList, - // List mvList) { - // Set requestSet = new HashSet<>(); - // if (requestList != null) { - // for (ModuleDegreeProgramAssignmentDTO a : requestList) { - // if (a != null && a.getDegreeProgramId() != null && - // a.getDegreeProgramSpecializationId() != null) { - // requestSet.add(a.getDegreeProgramId() + "," + - // a.getDegreeProgramSpecializationId()); - // } - // } - // } - // Set mvSet = new HashSet<>(); - // if (mvList != null) { - // for (ModuleVersionDegreeProgramAssignment a : mvList) { - // if (a.getDegreeProgram() != null && a.getDegreeProgramSpecialization() != - // null) { - // mvSet.add(a.getDegreeProgram().getDegreeProgramId() + "," - // + a.getDegreeProgramSpecialization().getDegreeProgramSpecializationId()); - // } - // } - // } - // return requestSet.equals(mvSet); - // } + private void invalidateActiveFeedbacksAndResetStatuses(ModuleVersion mv) { + Proposal proposal = mv.getProposal(); + + List activeFeedbacks = feedbackRepository + .findByModuleVersion_Proposal_ProposalIdAndInvalidatedFalse(proposal.getProposalId()); + if (activeFeedbacks == null || activeFeedbacks.isEmpty()) { + return; + } + + for (Feedback f : activeFeedbacks) { + f.setInvalidated(true); + } + feedbackRepository.saveAll(activeFeedbacks); + + proposal.setStatus(ProposalStatus.PENDING_FIRST_SUBMISSION); + mv.setStatus(ModuleVersionStatus.PENDING_FIRST_SUBMISSION); + proposalRepository.save(proposal); + } /** * Applies request content and degree program assignments to the given module @@ -239,12 +195,16 @@ public void updateStatus(Long moduleVersionId) { } List allFeedbacks = mv.getRequiredFeedbacks() != null ? mv.getRequiredFeedbacks() : new ArrayList<>(); + // Invalidated feedbacks must not affect proposal/module version status. + List nonInvalidatedFeedbacks = allFeedbacks.stream() + .filter(f -> f != null && !f.isInvalidated()) + .toList(); // Coordinator feedbacks for current assignments + all role-based feedbacks (we // only have one active set of pending at a time). - List coordinatorFeedbacks = allFeedbacks.stream() + List coordinatorFeedbacks = nonInvalidatedFeedbacks.stream() .filter(f -> f.getDegreeProgramSpecialization() != null) .toList(); - List roleBased = allFeedbacks.stream() + List roleBased = nonInvalidatedFeedbacks.stream() .filter(f -> f.getRequiredRole() != null) .toList(); List feedbacksToEvaluate = new ArrayList<>(coordinatorFeedbacks); diff --git a/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java b/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java index be2ab559..2048c687 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java @@ -230,7 +230,7 @@ public ProposalViewDTO requestFullFeedback(Long proposalId, UUID userId) { List requiredFeedbacks = mv.getRequiredFeedbacks() != null ? mv.getRequiredFeedbacks() : new ArrayList<>(); List coordinatorFeedbacks = requiredFeedbacks.stream() - .filter(f -> f.getDegreeProgramSpecialization() != null) + .filter(f -> f.getDegreeProgramSpecialization() != null && !f.isInvalidated()) .toList(); if (coordinatorFeedbacks.isEmpty()) { throw new IllegalStateException( diff --git a/Server/src/main/java/modulemanagement/ls1/shared/ModuleVersionStepsChangeDetector.java b/Server/src/main/java/modulemanagement/ls1/shared/ModuleVersionStepsChangeDetector.java new file mode 100644 index 00000000..13d5840c --- /dev/null +++ b/Server/src/main/java/modulemanagement/ls1/shared/ModuleVersionStepsChangeDetector.java @@ -0,0 +1,82 @@ +package modulemanagement.ls1.shared; + +import modulemanagement.ls1.dtos.ModuleDegreeProgramAssignmentDTO; +import modulemanagement.ls1.dtos.ModuleVersionUpdateRequestDTO; +import modulemanagement.ls1.models.ModuleVersion; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public class ModuleVersionStepsChangeDetector { + + private ModuleVersionStepsChangeDetector() { + } + + public static boolean isStep1DataChanged(ModuleVersionUpdateRequestDTO request, ModuleVersion mv) { + // Step-1 (basic + assignment) fields. + if (!Objects.equals(normalize(request.getTitleEng()), normalize(mv.getTitleEng()))) + return true; + if (!Objects.equals(normalize(request.getTitleDe()), normalize(mv.getTitleDe()))) + return true; + if (!Objects.equals(request.getCredits(), mv.getCredits())) + return true; + if (!Objects.equals(normalize(request.getFrequencyEng()), normalize(mv.getFrequencyEng()))) + return true; + if (!Objects.equals(request.getHoursLecture(), mv.getHoursLecture())) + return true; + if (!Objects.equals(request.getHoursExercise(), mv.getHoursExercise())) + return true; + if (!Objects.equals(request.getHoursPractical(), mv.getHoursPractical())) + return true; + if (!Objects.equals(request.getHoursSeminar(), mv.getHoursSeminar())) + return true; + if (!Objects.equals(normalize(request.getFirstSemesterAvailable()), normalize(mv.getFirstSemesterAvailable()))) + return true; + if (!Objects.equals(normalize(request.getSuccessorModuleName()), normalize(mv.getSuccessorModuleName()))) + return true; + if (!Objects.equals(request.getLanguageEng(), mv.getLanguageEng())) + return true; + + Set requestAssignments = assignmentKeySetFromRequest(request.getDegreeProgramAssignments()); + Set mvAssignments = assignmentKeySetFromMv(mv); + return !requestAssignments.equals(mvAssignments); + } + + private static String normalize(String s) { + if (s == null) + return null; + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static Set assignmentKeySetFromRequest(List assignments) { + Set set = new HashSet<>(); + if (assignments == null) { + return set; + } + for (ModuleDegreeProgramAssignmentDTO a : assignments) { + if (a == null || a.getDegreeProgramId() == null || a.getDegreeProgramSpecializationId() == null) { + continue; + } + set.add(a.getDegreeProgramId() + "," + a.getDegreeProgramSpecializationId()); + } + return set; + } + + private static Set assignmentKeySetFromMv(ModuleVersion mv) { + Set set = new HashSet<>(); + if (mv.getDegreeProgramAssignments() == null) { + return set; + } + for (var a : mv.getDegreeProgramAssignments()) { + if (a == null || a.getDegreeProgram() == null || a.getDegreeProgramSpecialization() == null) { + continue; + } + set.add(a.getDegreeProgram().getDegreeProgramId() + "," + + a.getDegreeProgramSpecialization().getDegreeProgramSpecializationId()); + } + return set; + } +} From f7c20a92b89dbe2f64506220f944e4f597756519 Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Wed, 18 Mar 2026 22:57:26 +0100 Subject: [PATCH 11/39] fix --- .../create-edit-base.component.ts | 12 ++++++++++++ .../proposal-view/proposal-view.component.html | 4 ++-- Client/src/app/pipes/feedbackStatus.pipe.ts | 18 ++++++------------ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/Client/src/app/components/create-edit-base/create-edit-base.component.ts b/Client/src/app/components/create-edit-base/create-edit-base.component.ts index 4e6c96aa..5a53ed85 100644 --- a/Client/src/app/components/create-edit-base/create-edit-base.component.ts +++ b/Client/src/app/components/create-edit-base/create-edit-base.component.ts @@ -265,6 +265,12 @@ export abstract class ProposalBaseComponent { // When backend created a new version (immutable versioning), switch to editing the new version const newId = response?.latestModuleVersion?.moduleVersionId; if (newId != null && newId !== this.moduleVersionId) { + + this.moduleVersionService.getPreviousModuleVersionFeedback(newId).subscribe({ + next: (feedbacks) => this.feedbacks.set([...feedbacks]), + error: (err: HttpErrorResponse) => this.error.set(err.error) + }); + this.moduleVersionId = newId; this.breadcrumbLabels.versionLabel.set(response?.latestVersion != null ? `Version ${response.latestVersion}` : null); this.router.navigate(['/proposals', proposalId, 'version', newId, 'edit'], { replaceUrl: true }); @@ -290,6 +296,12 @@ export abstract class ProposalBaseComponent { // When backend created a new version (immutable versioning), switch to editing the new version const newId = response?.latestModuleVersion?.moduleVersionId; if (newId != null && newId !== this.moduleVersionId) { + // Refresh feedbacks so the next step reflects statuses from the freezed version. + this.moduleVersionService.getPreviousModuleVersionFeedback(newId).subscribe({ + next: (feedbacks) => this.feedbacks.set([...feedbacks]), + error: (err: HttpErrorResponse) => this.error.set(err.error) + }); + this.moduleVersionId = newId; this.breadcrumbLabels.versionLabel.set(response?.latestVersion != null ? `Version ${response.latestVersion}` : null); this.router.navigate(['/proposals', proposalId, 'version', newId, 'edit'], { replaceUrl: true }); diff --git a/Client/src/app/pages/proposal-view/proposal-view.component.html b/Client/src/app/pages/proposal-view/proposal-view.component.html index f7f0cb83..fa586d8f 100644 --- a/Client/src/app/pages/proposal-view/proposal-view.component.html +++ b/Client/src/app/pages/proposal-view/proposal-view.component.html @@ -81,7 +81,7 @@

Current Module Versions

" tooltipPosition="top" class="w-8 h-8 mr-2 rounded" - [ngClass]="feedback.invalidated ? (feedback.status | feedbackStatus).fadedColor : (feedback.status | feedbackStatus).normalColor" + [ngClass]="feedback.invalidated ? 'bg-gray-300 text-white' : (feedback.status | feedbackStatus).color" >

} } @@ -144,7 +144,7 @@

Previous Module Versions

} } diff --git a/Client/src/app/pipes/feedbackStatus.pipe.ts b/Client/src/app/pipes/feedbackStatus.pipe.ts index 3b78d102..73b194ca 100644 --- a/Client/src/app/pipes/feedbackStatus.pipe.ts +++ b/Client/src/app/pipes/feedbackStatus.pipe.ts @@ -6,45 +6,39 @@ import { Tag } from 'primeng/tag'; export class FeedbackStatusPipe implements PipeTransform { transform(status: Feedback.StatusEnum | ModuleVersionViewFeedbackDTO.FeedbackStatusEnum): { text: string; - normalColor: string; - fadedColor: string; + color: string; severity: Tag['severity']; } { switch (status) { case 'PENDING_FEEDBACK': return { text: 'Pending Feedback', - normalColor: 'bg-yellow-500 text-white', - fadedColor: 'bg-yellow-300 text-white', + color: 'bg-yellow-500 text-white', severity: 'warn' }; case 'APPROVED': return { text: 'Approved', - normalColor: 'bg-green-500 text-white', - fadedColor: 'bg-green-300 text-white', + color: 'bg-green-500 text-white', severity: 'success' }; case 'FEEDBACK_GIVEN': return { text: 'Feedback given', - normalColor: 'bg-blue-500 text-white', - fadedColor: 'bg-blue-300 text-white', + color: 'bg-blue-500 text-white', severity: 'info' }; case 'REJECTED': return { text: 'Rejected', - normalColor: 'bg-red-500 text-white', - fadedColor: 'bg-red-300 text-white', + color: 'bg-red-500 text-white', severity: 'danger' }; default: return { text: status, - normalColor: 'bg-gray-400 text-white', - fadedColor: 'bg-gray-300 text-white', + color: 'bg-gray-400 text-white', severity: 'secondary' }; } From 54500e7917bb19e5cc1656345e5ecf4510ce6850 Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Thu, 19 Mar 2026 23:56:48 +0100 Subject: [PATCH 12/39] imrpove feedback view --- .../core/modules/openapi/model/feedback.ts | 1 + .../module-degree-program-assignment-dto.ts | 2 + .../model/module-version-view-feedback-dto.ts | 1 + .../feedback-view.component.html | 45 +++++++++++++++++++ .../feedback-view/feedback-view.component.ts | 33 +++++++++++++- .../ModuleDegreeProgramAssignmentDTO.java | 2 + .../ls1/dtos/ModuleVersionViewDTO.java | 2 + .../dtos/ModuleVersionViewFeedbackDTO.java | 2 + .../modulemanagement/ls1/models/Feedback.java | 3 ++ .../ls1/services/ProposalService.java | 2 + .../0012_feedback_requested_from_user.yaml | 5 +++ 11 files changed, 96 insertions(+), 2 deletions(-) diff --git a/Client/src/app/core/modules/openapi/model/feedback.ts b/Client/src/app/core/modules/openapi/model/feedback.ts index 85d9d062..e54237e1 100644 --- a/Client/src/app/core/modules/openapi/model/feedback.ts +++ b/Client/src/app/core/modules/openapi/model/feedback.ts @@ -17,6 +17,7 @@ export interface Feedback { requiredRole?: Feedback.RequiredRoleEnum; status: Feedback.StatusEnum; submissionDate?: string; + createdAt?: string; titleFeedback?: string; titleAccepted?: boolean; levelFeedback?: string; diff --git a/Client/src/app/core/modules/openapi/model/module-degree-program-assignment-dto.ts b/Client/src/app/core/modules/openapi/model/module-degree-program-assignment-dto.ts index 7ce6a604..3c1ae253 100644 --- a/Client/src/app/core/modules/openapi/model/module-degree-program-assignment-dto.ts +++ b/Client/src/app/core/modules/openapi/model/module-degree-program-assignment-dto.ts @@ -12,5 +12,7 @@ export interface ModuleDegreeProgramAssignmentDTO { degreeProgramId: number; degreeProgramSpecializationId: number; + degreeProgramName?: string; + degreeProgramSpecializationName?: string; } diff --git a/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts b/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts index c420ed0a..075ba0bd 100644 --- a/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts +++ b/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts @@ -17,6 +17,7 @@ export interface ModuleVersionViewFeedbackDTO { feedbackRole?: ModuleVersionViewFeedbackDTO.FeedbackRoleEnum; requestedFromUserName?: string; feedbackStatus?: ModuleVersionViewFeedbackDTO.FeedbackStatusEnum; + createdAt?: string; submissionDate?: string; degreeProgramSpecializationId?: number; titleFeedback?: string; diff --git a/Client/src/app/pages/feedback-view/feedback-view.component.html b/Client/src/app/pages/feedback-view/feedback-view.component.html index dad9676a..9a2faedd 100644 --- a/Client/src/app/pages/feedback-view/feedback-view.component.html +++ b/Client/src/app/pages/feedback-view/feedback-view.component.html @@ -20,6 +20,51 @@

Module Proposal for '{{ moduleVersion.titleEng }}'

+ + @if (currentFeedback(); as fb) { +
+
+
+

Feedback Status

+ +
+ +
+

Requested From

+

{{ (fb.feedbackRole | feedbackDepartment).text }} {{ fb.requestedFromUserName ?? 'N/A' }}

+
+
+ + @if (fb.createdAt) { +

Requested at: {{ fb.createdAt | date: 'yyyy-MM-dd HH:mm' }}

+ } + + @if (fb.submissionDate) { +

Submitted at: {{ fb.submissionDate | date: 'yyyy-MM-dd HH:mm' }}

+ } + + @if (fb.rejectionComment) { +
+

Rejection Comment

+

{{ fb.rejectionComment }}

+
+ } +
+ } + + +
+

Assignments

+
    + @for (a of moduleVersion.degreeProgramAssignments ?? []; track a.degreeProgramSpecializationId) { +
  • + {{ a.degreeProgramName ?? 'Unknown Program' }} + {{ a.degreeProgramSpecializationName ?? 'Unknown Specialization' }} +
  • + } +
+
+
@for (field of moduleFields; track field.key) { diff --git a/Client/src/app/pages/feedback-view/feedback-view.component.ts b/Client/src/app/pages/feedback-view/feedback-view.component.ts index 9b18779f..cb9ec894 100644 --- a/Client/src/app/pages/feedback-view/feedback-view.component.ts +++ b/Client/src/app/pages/feedback-view/feedback-view.component.ts @@ -1,22 +1,47 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { Component, inject, signal } from '@angular/core'; -import { FeedbackControllerService, ModuleVersionViewDTO, FeedbackDTO, GiveFeedbackDTO, ModuleVersionControllerService } from '../../core/modules/openapi'; +import { + FeedbackControllerService, + ModuleVersionViewDTO, + FeedbackDTO, + GiveFeedbackDTO, + ModuleVersionControllerService, + ModuleVersionViewFeedbackDTO +} from '../../core/modules/openapi'; import { BreadcrumbLabelsService } from '../../components/breadcrumb/breadcrumb-labels.service'; import { FormBuilder, FormGroup, FormsModule } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; +import { DatePipe } from '@angular/common'; import { MessageService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; +import { TagModule } from 'primeng/tag'; import { TextareaModule } from 'primeng/textarea'; import { DialogModule } from 'primeng/dialog'; import { ToastModule } from 'primeng/toast'; import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { TooltipModule } from 'primeng/tooltip'; import { MessageModule } from 'primeng/message'; +import { FeedbackStatusPipe } from '../../pipes/feedbackStatus.pipe'; +import { FeedbackDepartmentPipe } from '../../pipes/feedbackDepartment.pipe'; @Component({ selector: 'app-feedback-view', standalone: true, - imports: [FormsModule, RouterModule, ButtonModule, TextareaModule, DialogModule, ToastModule, ProgressSpinnerModule, TooltipModule, MessageModule], + imports: [ + FormsModule, + RouterModule, + ButtonModule, + TagModule, + TextareaModule, + DialogModule, + ToastModule, + ProgressSpinnerModule, + TooltipModule, + MessageModule, + FeedbackStatusPipe, + FeedbackDepartmentPipe, + DatePipe + ], templateUrl: './feedback-view.component.html' }) export class FeedbackViewComponent { @@ -28,6 +53,7 @@ export class FeedbackViewComponent { feedbackForm: FormGroup; feedbackId: number | null = null; moduleVersion = signal(null); + currentFeedback = signal(null); loading = signal(true); error = signal(null); rejectionReason: string = ''; @@ -124,6 +150,9 @@ export class FeedbackViewComponent { this.feedbackService.getModuleVersionOfFeedback(feedbackId).subscribe({ next: (response: ModuleVersionViewDTO) => { this.moduleVersion.set(response); + const found = + response.feedbacks?.find((f) => f.feedbackId === feedbackId) ?? null; + this.currentFeedback.set(found); this.breadcrumbLabels.feedbackLabel.set(response?.titleEng ?? null); }, error: (err: HttpErrorResponse) => this.error.set(err.error), diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleDegreeProgramAssignmentDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleDegreeProgramAssignmentDTO.java index 36c0682e..8aa04e81 100644 --- a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleDegreeProgramAssignmentDTO.java +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleDegreeProgramAssignmentDTO.java @@ -9,4 +9,6 @@ public class ModuleDegreeProgramAssignmentDTO { private Long degreeProgramId; @NotNull private Long degreeProgramSpecializationId; + private String degreeProgramName; + private String degreeProgramSpecializationName; } diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewDTO.java index 2a97beca..6920e441 100644 --- a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewDTO.java +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewDTO.java @@ -80,8 +80,10 @@ public static ModuleVersionViewDTO from(ModuleVersion moduleVersion) { .map(a -> { ModuleDegreeProgramAssignmentDTO item = new ModuleDegreeProgramAssignmentDTO(); item.setDegreeProgramId(a.getDegreeProgram().getDegreeProgramId()); + item.setDegreeProgramName(a.getDegreeProgram().getName()); item.setDegreeProgramSpecializationId( a.getDegreeProgramSpecialization().getDegreeProgramSpecializationId()); + item.setDegreeProgramSpecializationName(a.getDegreeProgramSpecialization().getName()); return item; }) .collect(Collectors.toList()); diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java index f25df9af..8e8f2cd7 100644 --- a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java @@ -18,6 +18,7 @@ public class ModuleVersionViewFeedbackDTO { private UserRole feedbackRole; private String requestedFromUserName; private FeedbackStatus feedbackStatus; + private LocalDateTime createdAt; private LocalDateTime submissionDate; private Long degreeProgramSpecializationId; @@ -57,6 +58,7 @@ public static ModuleVersionViewFeedbackDTO from(Feedback f) { + f.getDegreeProgramSpecialization().getName() + ")"); } dto.setFeedbackStatus(f.getStatus()); + dto.setCreatedAt(f.getCreatedAt()); dto.setSubmissionDate(f.getSubmissionDate()); if (f.getDegreeProgramSpecialization() != null) { dto.setDegreeProgramSpecializationId(f.getDegreeProgramSpecialization().getDegreeProgramSpecializationId()); diff --git a/Server/src/main/java/modulemanagement/ls1/models/Feedback.java b/Server/src/main/java/modulemanagement/ls1/models/Feedback.java index 3d01d696..48c66652 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/Feedback.java +++ b/Server/src/main/java/modulemanagement/ls1/models/Feedback.java @@ -51,6 +51,9 @@ public class Feedback { @Column(name = "submission_date") private LocalDateTime submissionDate; + @Column(name = "created_at") + private LocalDateTime createdAt; + @ManyToOne @JoinColumn(name = "module_version_id", nullable = false) @JsonIgnore diff --git a/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java b/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java index 2048c687..71f39853 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/ProposalService.java @@ -180,6 +180,7 @@ public ProposalViewDTO requestCoordinatorsFeedback(Long proposalId, UUID userId) var spec = assignment.getDegreeProgramSpecialization(); Feedback feedback = new Feedback(); feedback.setStatus(FeedbackStatus.PENDING_FEEDBACK); + feedback.setCreatedAt(LocalDateTime.now()); feedback.setDegreeProgramSpecialization(spec); feedback.setRequiredRole(null); feedback.setModuleVersion(mv); @@ -247,6 +248,7 @@ public ProposalViewDTO requestFullFeedback(Long proposalId, UUID userId) { for (UserRole role : FULL_FEEDBACK_ROLES) { Feedback feedback = new Feedback(); feedback.setStatus(FeedbackStatus.PENDING_FEEDBACK); + feedback.setCreatedAt(LocalDateTime.now()); feedback.setRequiredRole(role); feedback.setDegreeProgramSpecialization(null); feedback.setModuleVersion(mv); diff --git a/Server/src/main/resources/db/changelog/changes/0012_feedback_requested_from_user.yaml b/Server/src/main/resources/db/changelog/changes/0012_feedback_requested_from_user.yaml index 8d273cd0..74c3e56d 100644 --- a/Server/src/main/resources/db/changelog/changes/0012_feedback_requested_from_user.yaml +++ b/Server/src/main/resources/db/changelog/changes/0012_feedback_requested_from_user.yaml @@ -19,6 +19,11 @@ databaseChangeLog: constraints: nullable: false defaultValueBoolean: false + - column: + name: created_at + type: TIMESTAMP + constraints: + nullable: true - dropNotNullConstraint: tableName: feedback From d881878f389c0e2e2c609280c034782f4903770d Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Thu, 19 Mar 2026 23:59:46 +0100 Subject: [PATCH 13/39] fix --- .../ls1/services/ModuleVersionService.java | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java index 4e0676fb..8a9769db 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java @@ -18,7 +18,6 @@ import modulemanagement.ls1.models.User; import modulemanagement.ls1.repositories.DegreeProgramRepository; import modulemanagement.ls1.repositories.FeedbackRepository; -import modulemanagement.ls1.repositories.ModuleVersionDegreeProgramAssignmentRepository; import modulemanagement.ls1.repositories.ModuleVersionRepository; import modulemanagement.ls1.repositories.ProposalRepository; import modulemanagement.ls1.shared.PdfCreator; @@ -32,7 +31,6 @@ import org.springframework.web.server.ResponseStatusException; import jakarta.persistence.EntityManager; - import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -41,21 +39,18 @@ @Service public class ModuleVersionService { private final ModuleVersionRepository moduleVersionRepository; - private final ModuleVersionDegreeProgramAssignmentRepository assignmentRepository; private final DegreeProgramRepository degreeProgramRepository; private final ProposalRepository proposalRepository; private final OverlapDetectionService overlapDetectionService; private final PdfCreator pdfCreator; - private final EntityManager entityManager; private final FeedbackRepository feedbackRepository; + private final EntityManager entityManager; public ModuleVersionService(ModuleVersionRepository moduleVersionRepository, - ModuleVersionDegreeProgramAssignmentRepository assignmentRepository, DegreeProgramRepository degreeProgramRepository, ProposalRepository proposalRepository, OverlapDetectionService overlapDetectionService, PdfCreator pdfCreator, - EntityManager entityManager, FeedbackRepository feedbackRepository) { + FeedbackRepository feedbackRepository, EntityManager entityManager) { this.moduleVersionRepository = moduleVersionRepository; - this.assignmentRepository = assignmentRepository; this.degreeProgramRepository = degreeProgramRepository; this.proposalRepository = proposalRepository; this.overlapDetectionService = overlapDetectionService; @@ -159,7 +154,6 @@ private void applyUpdateRequest(ModuleVersion mv, ModuleVersionUpdateRequestDTO } } - assignmentRepository.deleteByModuleVersion_ModuleVersionId(mv.getModuleVersionId()); mv.getDegreeProgramAssignments().clear(); entityManager.flush(); for (ModuleDegreeProgramAssignmentDTO item : request.getDegreeProgramAssignments()) { From 66637dd583b8f829f57c01cae429a073041b7c0e Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Fri, 20 Mar 2026 00:07:04 +0100 Subject: [PATCH 14/39] display new module fields in feedback --- .../modules/openapi/model/feedback-dto.ts | 24 ++++ .../core/modules/openapi/model/feedback.ts | 24 ++++ .../model/module-version-view-feedback-dto.ts | 12 ++ .../feedback-view/feedback-view.component.ts | 36 ++++++ .../ls1/dtos/FeedbackDTO.java | 24 ++++ .../dtos/ModuleVersionViewFeedbackDTO.java | 24 ++++ .../modulemanagement/ls1/models/Feedback.java | 108 ++++++++++++++++++ .../ls1/services/ModuleVersionService.java | 2 +- .../0014_feedback_additional_fields.yaml | 92 +++++++++++++++ .../main/resources/db/changelog/master.yaml | 3 + 10 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 Server/src/main/resources/db/changelog/changes/0014_feedback_additional_fields.yaml diff --git a/Client/src/app/core/modules/openapi/model/feedback-dto.ts b/Client/src/app/core/modules/openapi/model/feedback-dto.ts index a0cb5186..6d604a17 100644 --- a/Client/src/app/core/modules/openapi/model/feedback-dto.ts +++ b/Client/src/app/core/modules/openapi/model/feedback-dto.ts @@ -12,6 +12,10 @@ export interface FeedbackDTO { titleFeedback?: string; titleAccepted?: boolean; + titleDeFeedback?: string; + titleDeAccepted?: boolean; + bulletPointsFeedback?: string; + bulletPointsAccepted?: boolean; levelFeedback?: string; levelAccepted?: boolean; languageFeedback?: string; @@ -28,18 +32,38 @@ export interface FeedbackDTO { hoursSelfStudyAccepted?: boolean; hoursPresenceFeedback?: string; hoursPresenceAccepted?: boolean; + hoursLectureFeedback?: string; + hoursLectureAccepted?: boolean; + hoursExerciseFeedback?: string; + hoursExerciseAccepted?: boolean; + hoursPracticalFeedback?: string; + hoursPracticalAccepted?: boolean; + hoursSeminarFeedback?: string; + hoursSeminarAccepted?: boolean; + firstSemesterAvailableFeedback?: string; + firstSemesterAvailableAccepted?: boolean; + successorModuleNameFeedback?: string; + successorModuleNameAccepted?: boolean; examinationAchievementsFeedback?: string; examinationAchievementsAccepted?: boolean; + examinationAchievementsPromptFeedback?: string; + examinationAchievementsPromptAccepted?: boolean; repetitionFeedback?: string; repetitionAccepted?: boolean; recommendedPrerequisitesFeedback?: string; recommendedPrerequisitesAccepted?: boolean; contentFeedback?: string; contentAccepted?: boolean; + contentPromptFeedback?: string; + contentPromptAccepted?: boolean; learningOutcomesFeedback?: string; learningOutcomesAccepted?: boolean; + learningOutcomesPromptFeedback?: string; + learningOutcomesPromptAccepted?: boolean; teachingMethodsFeedback?: string; teachingMethodsAccepted?: boolean; + teachingMethodsPromptFeedback?: string; + teachingMethodsPromptAccepted?: boolean; mediaFeedback?: string; mediaAccepted?: boolean; literatureFeedback?: string; diff --git a/Client/src/app/core/modules/openapi/model/feedback.ts b/Client/src/app/core/modules/openapi/model/feedback.ts index e54237e1..e89e2cd3 100644 --- a/Client/src/app/core/modules/openapi/model/feedback.ts +++ b/Client/src/app/core/modules/openapi/model/feedback.ts @@ -20,6 +20,10 @@ export interface Feedback { createdAt?: string; titleFeedback?: string; titleAccepted?: boolean; + titleDeFeedback?: string; + titleDeAccepted?: boolean; + bulletPointsFeedback?: string; + bulletPointsAccepted?: boolean; levelFeedback?: string; levelAccepted?: boolean; languageFeedback?: string; @@ -36,18 +40,38 @@ export interface Feedback { hoursSelfStudyAccepted?: boolean; hoursPresenceFeedback?: string; hoursPresenceAccepted?: boolean; + hoursLectureFeedback?: string; + hoursLectureAccepted?: boolean; + hoursExerciseFeedback?: string; + hoursExerciseAccepted?: boolean; + hoursPracticalFeedback?: string; + hoursPracticalAccepted?: boolean; + hoursSeminarFeedback?: string; + hoursSeminarAccepted?: boolean; + firstSemesterAvailableFeedback?: string; + firstSemesterAvailableAccepted?: boolean; + successorModuleNameFeedback?: string; + successorModuleNameAccepted?: boolean; examinationAchievementsFeedback?: string; examinationAchievementsAccepted?: boolean; + examinationAchievementsPromptFeedback?: string; + examinationAchievementsPromptAccepted?: boolean; repetitionFeedback?: string; repetitionAccepted?: boolean; recommendedPrerequisitesFeedback?: string; recommendedPrerequisitesAccepted?: boolean; contentFeedback?: string; contentAccepted?: boolean; + contentPromptFeedback?: string; + contentPromptAccepted?: boolean; learningOutcomesFeedback?: string; learningOutcomesAccepted?: boolean; + learningOutcomesPromptFeedback?: string; + learningOutcomesPromptAccepted?: boolean; teachingMethodsFeedback?: string; teachingMethodsAccepted?: boolean; + teachingMethodsPromptFeedback?: string; + teachingMethodsPromptAccepted?: boolean; mediaFeedback?: string; mediaAccepted?: boolean; literatureFeedback?: string; diff --git a/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts b/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts index 075ba0bd..3f067f8d 100644 --- a/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts +++ b/Client/src/app/core/modules/openapi/model/module-version-view-feedback-dto.ts @@ -21,6 +21,8 @@ export interface ModuleVersionViewFeedbackDTO { submissionDate?: string; degreeProgramSpecializationId?: number; titleFeedback?: string; + titleDeFeedback?: string; + bulletPointsFeedback?: string; levelFeedback?: string; languageFeedback?: string; frequencyFeedback?: string; @@ -29,12 +31,22 @@ export interface ModuleVersionViewFeedbackDTO { hoursTotalFeedback?: string; hoursSelfStudyFeedback?: string; hoursPresenceFeedback?: string; + hoursLectureFeedback?: string; + hoursExerciseFeedback?: string; + hoursPracticalFeedback?: string; + hoursSeminarFeedback?: string; + firstSemesterAvailableFeedback?: string; + successorModuleNameFeedback?: string; examinationAchievementsFeedback?: string; + examinationAchievementsPromptFeedback?: string; repetitionFeedback?: string; recommendedPrerequisitesFeedback?: string; contentFeedback?: string; + contentPromptFeedback?: string; learningOutcomesFeedback?: string; + learningOutcomesPromptFeedback?: string; teachingMethodsFeedback?: string; + teachingMethodsPromptFeedback?: string; mediaFeedback?: string; literatureFeedback?: string; responsiblesFeedback?: string; diff --git a/Client/src/app/pages/feedback-view/feedback-view.component.ts b/Client/src/app/pages/feedback-view/feedback-view.component.ts index cb9ec894..7a30d56f 100644 --- a/Client/src/app/pages/feedback-view/feedback-view.component.ts +++ b/Client/src/app/pages/feedback-view/feedback-view.component.ts @@ -73,20 +73,32 @@ export class FeedbackViewComponent { moduleFields = [ { key: 'titleEng', label: 'Title' }, + { key: 'titleDe', label: 'Title (German)' }, + { key: 'bulletPoints', label: 'Key Points' }, { key: 'levelEng', label: 'Level' }, { key: 'languageEng', label: 'Language' }, { key: 'frequencyEng', label: 'Frequency' }, { key: 'credits', label: 'Credits' }, + { key: 'hoursLecture', label: 'Hours (Lecture)' }, + { key: 'hoursExercise', label: 'Hours (Exercise)' }, + { key: 'hoursPractical', label: 'Hours (Practical)' }, + { key: 'hoursSeminar', label: 'Hours (Seminar)' }, + { key: 'firstSemesterAvailable', label: 'First Semester Available' }, + { key: 'successorModuleName', label: 'Successor Module Name' }, { key: 'duration', label: 'Duration' }, { key: 'hoursTotal', label: 'Total Hours' }, { key: 'hoursSelfStudy', label: 'Self-Study Hours' }, { key: 'hoursPresence', label: 'Presence Hours' }, { key: 'examinationAchievementsEng', label: 'Examination Achievements' }, + { key: 'examinationAchievementsPromptEng', label: 'Examination Prompt' }, { key: 'repetitionEng', label: 'Repetition' }, { key: 'recommendedPrerequisitesEng', label: 'Recommended Prerequisites' }, { key: 'contentEng', label: 'Content' }, + { key: 'contentPromptEng', label: 'Content Prompt' }, { key: 'learningOutcomesEng', label: 'Learning Outcomes' }, + { key: 'learningOutcomesPromptEng', label: 'Learning Outcomes Prompt' }, { key: 'teachingMethodsEng', label: 'Teaching Methods' }, + { key: 'teachingMethodsPromptEng', label: 'Teaching Methods Prompt' }, { key: 'mediaEng', label: 'Media' }, { key: 'literatureEng', label: 'Literature' }, { key: 'responsiblesEng', label: 'Responsibles' }, @@ -101,6 +113,10 @@ export class FeedbackViewComponent { this.feedbackForm = formBulider.group({ titleAccepted: [null], titleFeedback: [''], + titleDeAccepted: [null], + titleDeFeedback: [''], + bulletPointsAccepted: [null], + bulletPointsFeedback: [''], levelAccepted: [null], levelFeedback: [''], languageAccepted: [null], @@ -117,18 +133,38 @@ export class FeedbackViewComponent { hoursSelfStudyFeedback: [''], hoursPresenceAccepted: [null], hoursPresenceFeedback: [''], + hoursLectureAccepted: [null], + hoursLectureFeedback: [''], + hoursExerciseAccepted: [null], + hoursExerciseFeedback: [''], + hoursPracticalAccepted: [null], + hoursPracticalFeedback: [''], + hoursSeminarAccepted: [null], + hoursSeminarFeedback: [''], + firstSemesterAvailableAccepted: [null], + firstSemesterAvailableFeedback: [''], + successorModuleNameAccepted: [null], + successorModuleNameFeedback: [''], examinationAchievementsAccepted: [null], examinationAchievementsFeedback: [''], + examinationAchievementsPromptAccepted: [null], + examinationAchievementsPromptFeedback: [''], repetitionAccepted: [null], repetitionFeedback: [''], recommendedPrerequisitesAccepted: [null], recommendedPrerequisitesFeedback: [''], contentAccepted: [null], contentFeedback: [''], + contentPromptAccepted: [null], + contentPromptFeedback: [''], learningOutcomesAccepted: [null], learningOutcomesFeedback: [''], + learningOutcomesPromptAccepted: [null], + learningOutcomesPromptFeedback: [''], teachingMethodsAccepted: [null], teachingMethodsFeedback: [''], + teachingMethodsPromptAccepted: [null], + teachingMethodsPromptFeedback: [''], mediaAccepted: [null], mediaFeedback: [''], literatureAccepted: [null], diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/FeedbackDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/FeedbackDTO.java index 59b7a822..040b945e 100644 --- a/Server/src/main/java/modulemanagement/ls1/dtos/FeedbackDTO.java +++ b/Server/src/main/java/modulemanagement/ls1/dtos/FeedbackDTO.java @@ -6,6 +6,10 @@ public class FeedbackDTO { private String titleFeedback; private boolean titleAccepted; + private String titleDeFeedback; + private boolean titleDeAccepted; + private String bulletPointsFeedback; + private boolean bulletPointsAccepted; private String levelFeedback; private boolean levelAccepted; private String languageFeedback; @@ -22,18 +26,38 @@ public class FeedbackDTO { private boolean hoursSelfStudyAccepted; private String hoursPresenceFeedback; private boolean hoursPresenceAccepted; + private String hoursLectureFeedback; + private boolean hoursLectureAccepted; + private String hoursExerciseFeedback; + private boolean hoursExerciseAccepted; + private String hoursPracticalFeedback; + private boolean hoursPracticalAccepted; + private String hoursSeminarFeedback; + private boolean hoursSeminarAccepted; + private String firstSemesterAvailableFeedback; + private boolean firstSemesterAvailableAccepted; + private String successorModuleNameFeedback; + private boolean successorModuleNameAccepted; private String examinationAchievementsFeedback; private boolean examinationAchievementsAccepted; + private String examinationAchievementsPromptFeedback; + private boolean examinationAchievementsPromptAccepted; private String repetitionFeedback; private boolean repetitionAccepted; private String recommendedPrerequisitesFeedback; private boolean recommendedPrerequisitesAccepted; private String contentFeedback; private boolean contentAccepted; + private String contentPromptFeedback; + private boolean contentPromptAccepted; private String learningOutcomesFeedback; private boolean learningOutcomesAccepted; + private String learningOutcomesPromptFeedback; + private boolean learningOutcomesPromptAccepted; private String teachingMethodsFeedback; private boolean teachingMethodsAccepted; + private String teachingMethodsPromptFeedback; + private boolean teachingMethodsPromptAccepted; private String mediaFeedback; private boolean mediaAccepted; private String literatureFeedback; diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java index 8e8f2cd7..5b75e3f0 100644 --- a/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java +++ b/Server/src/main/java/modulemanagement/ls1/dtos/ModuleVersionViewFeedbackDTO.java @@ -23,6 +23,8 @@ public class ModuleVersionViewFeedbackDTO { private Long degreeProgramSpecializationId; private String titleFeedback; + private String titleDeFeedback; + private String bulletPointsFeedback; private String levelFeedback; private String languageFeedback; private String frequencyFeedback; @@ -31,12 +33,22 @@ public class ModuleVersionViewFeedbackDTO { private String hoursTotalFeedback; private String hoursSelfStudyFeedback; private String hoursPresenceFeedback; + private String hoursLectureFeedback; + private String hoursExerciseFeedback; + private String hoursPracticalFeedback; + private String hoursSeminarFeedback; + private String firstSemesterAvailableFeedback; + private String successorModuleNameFeedback; private String examinationAchievementsFeedback; + private String examinationAchievementsPromptFeedback; private String repetitionFeedback; private String recommendedPrerequisitesFeedback; private String contentFeedback; + private String contentPromptFeedback; private String learningOutcomesFeedback; + private String learningOutcomesPromptFeedback; private String teachingMethodsFeedback; + private String teachingMethodsPromptFeedback; private String mediaFeedback; private String literatureFeedback; private String responsiblesFeedback; @@ -64,6 +76,8 @@ public static ModuleVersionViewFeedbackDTO from(Feedback f) { dto.setDegreeProgramSpecializationId(f.getDegreeProgramSpecialization().getDegreeProgramSpecializationId()); } dto.setTitleFeedback(f.getTitleFeedback()); + dto.setTitleDeFeedback(f.getTitleDeFeedback()); + dto.setBulletPointsFeedback(f.getBulletPointsFeedback()); dto.setLevelFeedback(f.getLevelFeedback()); dto.setLanguageFeedback(f.getLanguageFeedback()); dto.setFrequencyFeedback(f.getFrequencyFeedback()); @@ -72,12 +86,22 @@ public static ModuleVersionViewFeedbackDTO from(Feedback f) { dto.setHoursTotalFeedback(f.getHoursTotalFeedback()); dto.setHoursSelfStudyFeedback(f.getHoursSelfStudyFeedback()); dto.setHoursPresenceFeedback(f.getHoursPresenceFeedback()); + dto.setHoursLectureFeedback(f.getHoursLectureFeedback()); + dto.setHoursExerciseFeedback(f.getHoursExerciseFeedback()); + dto.setHoursPracticalFeedback(f.getHoursPracticalFeedback()); + dto.setHoursSeminarFeedback(f.getHoursSeminarFeedback()); + dto.setFirstSemesterAvailableFeedback(f.getFirstSemesterAvailableFeedback()); + dto.setSuccessorModuleNameFeedback(f.getSuccessorModuleNameFeedback()); dto.setExaminationAchievementsFeedback(f.getExaminationAchievementsFeedback()); + dto.setExaminationAchievementsPromptFeedback(f.getExaminationAchievementsPromptFeedback()); dto.setRepetitionFeedback(f.getRepetitionFeedback()); dto.setRecommendedPrerequisitesFeedback(f.getRecommendedPrerequisitesFeedback()); dto.setContentFeedback(f.getContentFeedback()); + dto.setContentPromptFeedback(f.getContentPromptFeedback()); dto.setLearningOutcomesFeedback(f.getLearningOutcomesFeedback()); + dto.setLearningOutcomesPromptFeedback(f.getLearningOutcomesPromptFeedback()); dto.setTeachingMethodsFeedback(f.getTeachingMethodsFeedback()); + dto.setTeachingMethodsPromptFeedback(f.getTeachingMethodsPromptFeedback()); dto.setMediaFeedback(f.getMediaFeedback()); dto.setLiteratureFeedback(f.getLiteratureFeedback()); dto.setResponsiblesFeedback(f.getResponsiblesFeedback()); diff --git a/Server/src/main/java/modulemanagement/ls1/models/Feedback.java b/Server/src/main/java/modulemanagement/ls1/models/Feedback.java index 48c66652..660469b8 100644 --- a/Server/src/main/java/modulemanagement/ls1/models/Feedback.java +++ b/Server/src/main/java/modulemanagement/ls1/models/Feedback.java @@ -67,6 +67,18 @@ public class Feedback { @Column(name = "title_accepted") private boolean titleAccepted; + @Column(name = "title_de_feedback", columnDefinition = "CLOB") + private String titleDeFeedback; + + @Column(name = "title_de_accepted") + private boolean titleDeAccepted; + + @Column(name = "bullet_points_feedback", columnDefinition = "CLOB") + private String bulletPointsFeedback; + + @Column(name = "bullet_points_accepted") + private boolean bulletPointsAccepted; + @Column(name = "level_feedback") private String levelFeedback; @@ -115,12 +127,54 @@ public class Feedback { @Column(name = "hours_presence_accepted") private boolean hoursPresenceAccepted; + @Column(name = "hours_lecture_feedback", columnDefinition = "CLOB") + private String hoursLectureFeedback; + + @Column(name = "hours_lecture_accepted") + private boolean hoursLectureAccepted; + + @Column(name = "hours_exercise_feedback", columnDefinition = "CLOB") + private String hoursExerciseFeedback; + + @Column(name = "hours_exercise_accepted") + private boolean hoursExerciseAccepted; + + @Column(name = "hours_practical_feedback", columnDefinition = "CLOB") + private String hoursPracticalFeedback; + + @Column(name = "hours_practical_accepted") + private boolean hoursPracticalAccepted; + + @Column(name = "hours_seminar_feedback", columnDefinition = "CLOB") + private String hoursSeminarFeedback; + + @Column(name = "hours_seminar_accepted") + private boolean hoursSeminarAccepted; + + @Column(name = "first_semester_available_feedback", columnDefinition = "CLOB") + private String firstSemesterAvailableFeedback; + + @Column(name = "first_semester_available_accepted") + private boolean firstSemesterAvailableAccepted; + + @Column(name = "successor_module_name_feedback", columnDefinition = "CLOB") + private String successorModuleNameFeedback; + + @Column(name = "successor_module_name_accepted") + private boolean successorModuleNameAccepted; + @Column(name = "examination_feedback", columnDefinition = "CLOB") private String examinationAchievementsFeedback; @Column(name = "examination_accepted") private boolean examinationAchievementsAccepted; + @Column(name = "examination_prompt_feedback", columnDefinition = "CLOB") + private String examinationAchievementsPromptFeedback; + + @Column(name = "examination_prompt_accepted") + private boolean examinationAchievementsPromptAccepted; + @Column(name = "repetition_feedback", columnDefinition = "CLOB") private String repetitionFeedback; @@ -139,18 +193,36 @@ public class Feedback { @Column(name = "content_accepted") private boolean contentAccepted; + @Column(name = "content_prompt_feedback", columnDefinition = "CLOB") + private String contentPromptFeedback; + + @Column(name = "content_prompt_accepted") + private boolean contentPromptAccepted; + @Column(name = "learning_feedback", columnDefinition = "CLOB") private String learningOutcomesFeedback; @Column(name = "learning_accepted") private boolean learningOutcomesAccepted; + @Column(name = "learning_prompt_feedback", columnDefinition = "CLOB") + private String learningOutcomesPromptFeedback; + + @Column(name = "learning_prompt_accepted") + private boolean learningOutcomesPromptAccepted; + @Column(name = "teaching_feedback", columnDefinition = "CLOB") private String teachingMethodsFeedback; @Column(name = "teaching_accepted") private boolean teachingMethodsAccepted; + @Column(name = "teaching_prompt_feedback", columnDefinition = "CLOB") + private String teachingMethodsPromptFeedback; + + @Column(name = "teaching_prompt_accepted") + private boolean teachingMethodsPromptAccepted; + @Column(name = "media_feedback", columnDefinition = "CLOB") private String mediaFeedback; @@ -182,6 +254,10 @@ public boolean isFeedbackGiven() { public void insert(FeedbackDTO dto) { this.titleFeedback = dto.getTitleFeedback(); this.titleAccepted = dto.isTitleAccepted(); + this.titleDeFeedback = dto.getTitleDeFeedback(); + this.titleDeAccepted = dto.isTitleDeAccepted(); + this.bulletPointsFeedback = dto.getBulletPointsFeedback(); + this.bulletPointsAccepted = dto.isBulletPointsAccepted(); this.levelFeedback = dto.getLevelFeedback(); this.levelAccepted = dto.isLevelAccepted(); this.languageFeedback = dto.getLanguageFeedback(); @@ -198,18 +274,38 @@ public void insert(FeedbackDTO dto) { this.hoursSelfStudyAccepted = dto.isHoursSelfStudyAccepted(); this.hoursPresenceFeedback = dto.getHoursPresenceFeedback(); this.hoursPresenceAccepted = dto.isHoursPresenceAccepted(); + this.hoursLectureFeedback = dto.getHoursLectureFeedback(); + this.hoursLectureAccepted = dto.isHoursLectureAccepted(); + this.hoursExerciseFeedback = dto.getHoursExerciseFeedback(); + this.hoursExerciseAccepted = dto.isHoursExerciseAccepted(); + this.hoursPracticalFeedback = dto.getHoursPracticalFeedback(); + this.hoursPracticalAccepted = dto.isHoursPracticalAccepted(); + this.hoursSeminarFeedback = dto.getHoursSeminarFeedback(); + this.hoursSeminarAccepted = dto.isHoursSeminarAccepted(); + this.firstSemesterAvailableFeedback = dto.getFirstSemesterAvailableFeedback(); + this.firstSemesterAvailableAccepted = dto.isFirstSemesterAvailableAccepted(); + this.successorModuleNameFeedback = dto.getSuccessorModuleNameFeedback(); + this.successorModuleNameAccepted = dto.isSuccessorModuleNameAccepted(); this.examinationAchievementsFeedback = dto.getExaminationAchievementsFeedback(); this.examinationAchievementsAccepted = dto.isExaminationAchievementsAccepted(); + this.examinationAchievementsPromptFeedback = dto.getExaminationAchievementsPromptFeedback(); + this.examinationAchievementsPromptAccepted = dto.isExaminationAchievementsPromptAccepted(); this.repetitionFeedback = dto.getRepetitionFeedback(); this.repetitionAccepted = dto.isRepetitionAccepted(); this.recommendedPrerequisitesFeedback = dto.getRecommendedPrerequisitesFeedback(); this.recommendedPrerequisitesAccepted = dto.isRecommendedPrerequisitesAccepted(); this.contentFeedback = dto.getContentFeedback(); this.contentAccepted = dto.isContentAccepted(); + this.contentPromptFeedback = dto.getContentPromptFeedback(); + this.contentPromptAccepted = dto.isContentPromptAccepted(); this.learningOutcomesFeedback = dto.getLearningOutcomesFeedback(); this.learningOutcomesAccepted = dto.isLearningOutcomesAccepted(); + this.learningOutcomesPromptFeedback = dto.getLearningOutcomesPromptFeedback(); + this.learningOutcomesPromptAccepted = dto.isLearningOutcomesPromptAccepted(); this.teachingMethodsFeedback = dto.getTeachingMethodsFeedback(); this.teachingMethodsAccepted = dto.isTeachingMethodsAccepted(); + this.teachingMethodsPromptFeedback = dto.getTeachingMethodsPromptFeedback(); + this.teachingMethodsPromptAccepted = dto.isTeachingMethodsPromptAccepted(); this.mediaFeedback = dto.getMediaFeedback(); this.mediaAccepted = dto.isMediaAccepted(); this.literatureFeedback = dto.getLiteratureFeedback(); @@ -222,6 +318,8 @@ public void insert(FeedbackDTO dto) { public boolean isAllFeedbackPositive() { return this.titleAccepted + && this.titleDeAccepted + && this.bulletPointsAccepted && this.levelAccepted && this.languageAccepted && this.frequencyAccepted @@ -230,12 +328,22 @@ public boolean isAllFeedbackPositive() { && this.hoursTotalAccepted && this.hoursSelfStudyAccepted && this.hoursPresenceAccepted + && this.hoursLectureAccepted + && this.hoursExerciseAccepted + && this.hoursPracticalAccepted + && this.hoursSeminarAccepted + && this.firstSemesterAvailableAccepted + && this.successorModuleNameAccepted && this.examinationAchievementsAccepted + && this.examinationAchievementsPromptAccepted && this.repetitionAccepted && this.recommendedPrerequisitesAccepted && this.contentAccepted + && this.contentPromptAccepted && this.learningOutcomesAccepted + && this.learningOutcomesPromptAccepted && this.teachingMethodsAccepted + && this.teachingMethodsPromptAccepted && this.mediaAccepted && this.literatureAccepted && this.responsiblesAccepted diff --git a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java index 8a9769db..081bdf9b 100644 --- a/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java +++ b/Server/src/main/java/modulemanagement/ls1/services/ModuleVersionService.java @@ -155,7 +155,7 @@ private void applyUpdateRequest(ModuleVersion mv, ModuleVersionUpdateRequestDTO } mv.getDegreeProgramAssignments().clear(); - entityManager.flush(); +th entityManager.flush(); for (ModuleDegreeProgramAssignmentDTO item : request.getDegreeProgramAssignments()) { DegreeProgram program = degreeProgramRepository .findWithSpecializationsByDegreeProgramId(item.getDegreeProgramId()) diff --git a/Server/src/main/resources/db/changelog/changes/0014_feedback_additional_fields.yaml b/Server/src/main/resources/db/changelog/changes/0014_feedback_additional_fields.yaml new file mode 100644 index 00000000..895a0398 --- /dev/null +++ b/Server/src/main/resources/db/changelog/changes/0014_feedback_additional_fields.yaml @@ -0,0 +1,92 @@ +databaseChangeLog: + - changeSet: + id: 0014_feedback_additional_fields + author: module-management + changes: + - addColumn: + tableName: feedback + columns: + - column: + name: title_de_feedback + type: CLOB + - column: + name: title_de_accepted + type: BOOLEAN + defaultValueBoolean: false + - column: + name: bullet_points_feedback + type: CLOB + - column: + name: bullet_points_accepted + type: BOOLEAN + defaultValueBoolean: false + - column: + name: hours_lecture_feedback + type: CLOB + - column: + name: hours_lecture_accepted + type: BOOLEAN + defaultValueBoolean: false + - column: + name: hours_exercise_feedback + type: CLOB + - column: + name: hours_exercise_accepted + type: BOOLEAN + defaultValueBoolean: false + - column: + name: hours_practical_feedback + type: CLOB + - column: + name: hours_practical_accepted + type: BOOLEAN + defaultValueBoolean: false + - column: + name: hours_seminar_feedback + type: CLOB + - column: + name: hours_seminar_accepted + type: BOOLEAN + defaultValueBoolean: false + - column: + name: first_semester_available_feedback + type: CLOB + - column: + name: first_semester_available_accepted + type: BOOLEAN + defaultValueBoolean: false + - column: + name: successor_module_name_feedback + type: CLOB + - column: + name: successor_module_name_accepted + type: BOOLEAN + defaultValueBoolean: false + - column: + name: examination_prompt_feedback + type: CLOB + - column: + name: examination_prompt_accepted + type: BOOLEAN + defaultValueBoolean: false + - column: + name: content_prompt_feedback + type: CLOB + - column: + name: content_prompt_accepted + type: BOOLEAN + defaultValueBoolean: false + - column: + name: learning_prompt_feedback + type: CLOB + - column: + name: learning_prompt_accepted + type: BOOLEAN + defaultValueBoolean: false + - column: + name: teaching_prompt_feedback + type: CLOB + - column: + name: teaching_prompt_accepted + type: BOOLEAN + defaultValueBoolean: false diff --git a/Server/src/main/resources/db/changelog/master.yaml b/Server/src/main/resources/db/changelog/master.yaml index 40436095..50f77c43 100644 --- a/Server/src/main/resources/db/changelog/master.yaml +++ b/Server/src/main/resources/db/changelog/master.yaml @@ -38,3 +38,6 @@ databaseChangeLog: - include: relativeToChangelogFile: true file: changes/0013_grant_program_area_responsible_role.yaml + - include: + relativeToChangelogFile: true + file: changes/0014_feedback_additional_fields.yaml From 58f774273413a718e9f8792f9224a615c232dcce Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser Date: Fri, 20 Mar 2026 00:29:52 +0100 Subject: [PATCH 15/39] imrpove feedback given visualisation --- .../create-edit-base-layout.css | 57 ++++++ .../create-edit-base.component.html | 162 +++++++++++++++++- .../module-version-view.component.css | 39 +++++ .../module-version-view.component.html | 99 +++++------ .../module-version-view.component.ts | 39 +++-- 5 files changed, 323 insertions(+), 73 deletions(-) diff --git a/Client/src/app/components/create-edit-base/create-edit-base-layout.css b/Client/src/app/components/create-edit-base/create-edit-base-layout.css index fbbb0231..e280d4c5 100644 --- a/Client/src/app/components/create-edit-base/create-edit-base-layout.css +++ b/Client/src/app/components/create-edit-base/create-edit-base-layout.css @@ -20,3 +20,60 @@ .module-edit-form-column { min-width: 0; } + +.feedback-message, +.my-4.inline-block.w-fit, +.my-2.inline-block.w-fit { + position: relative; + border-left: 4px solid #dc2626; + border: 1px solid #fecaca !important; + background: #fef2f2 !important; + border-radius: 8px; + width: 100%; + box-shadow: 0 1px 2px rgba(153, 27, 27, 0.08); + padding: 0.55rem 0.75rem; +} + +.feedback-message::before, +.my-4.inline-block.w-fit::before, +.my-2.inline-block.w-fit::before { + content: "⚠ Reviewer feedback"; + display: inline-flex !important; + align-items: center; + width: fit-content; + max-width: max-content; + margin-right: 0.55rem; + margin-bottom: 0.35rem; + padding: 0.05rem 0.4rem; + border-radius: 999px; + background: #fee2e2; + color: #b91c1c; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.01em; + text-transform: uppercase; + white-space: nowrap; +} + +.feedback-message p, +.my-4.inline-block.w-fit p, +.my-2.inline-block.w-fit p { + color: #7f1d1d; + margin: 0.15rem 0 0; + font-size: 0.92rem; + line-height: 1.25rem; +} + +.feedback-message p:first-of-type, +.my-4.inline-block.w-fit p:first-of-type, +.my-2.inline-block.w-fit p:first-of-type { + margin-top: 0; +} + +.feedback-message p span, +.my-4.inline-block.w-fit p span, +.my-2.inline-block.w-fit p span { + color: #991b1b; + font-weight: 600; + text-decoration: none !important; +} diff --git a/Client/src/app/components/create-edit-base/create-edit-base.component.html b/Client/src/app/components/create-edit-base/create-edit-base.component.html index ced0ec6c..8167fd5e 100644 --- a/Client/src/app/components/create-edit-base/create-edit-base.component.html +++ b/Client/src/app/components/create-edit-base/create-edit-base.component.html @@ -23,7 +23,7 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for ' * @if (moduleVersionId && hasFeedback('titleFeedback')) { - + @for (feedback of feedbacks(); track feedback.feedbackId) { @if (feedback.titleFeedback) {

@@ -43,12 +43,36 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for '

+ @if (moduleVersionId && hasFeedback('titleDeFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackId) { + @if (feedback.titleDeFeedback) { +

+ {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: + {{ feedback.titleDeFeedback }} +

+ } + } +
+ }
- +
@@ -70,6 +94,18 @@

{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for '

+ @if (moduleVersionId && hasFeedback('frequencyFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackId) { + @if (feedback.frequencyFeedback) { +

+ {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: + {{ feedback.frequencyFeedback }} +

+ } + } +
+ }
{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for '
+ @if (moduleVersionId && hasFeedback('hoursLectureFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackId) { + @if (feedback.hoursLectureFeedback) { +

+ {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: + {{ feedback.hoursLectureFeedback }} +

+ } + } +
+ }
+ @if (moduleVersionId && hasFeedback('hoursExerciseFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackId) { + @if (feedback.hoursExerciseFeedback) { +

+ {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: + {{ feedback.hoursExerciseFeedback }} +

+ } + } +
+ }
+ @if (moduleVersionId && hasFeedback('hoursPracticalFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackId) { + @if (feedback.hoursPracticalFeedback) { +

+ {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: + {{ feedback.hoursPracticalFeedback }} +

+ } + } +
+ }
+ @if (moduleVersionId && hasFeedback('hoursSeminarFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackId) { + @if (feedback.hoursSeminarFeedback) { +

+ {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: + {{ feedback.hoursSeminarFeedback }} +

+ } + } +
+ }
+ @if (moduleVersionId && hasFeedback('firstSemesterAvailableFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackId) { + @if (feedback.firstSemesterAvailableFeedback) { +

+ {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: + {{ feedback.firstSemesterAvailableFeedback }} +

+ } + } +
+ }
+ @if (moduleVersionId && hasFeedback('successorModuleNameFeedback')) { + + @for (feedback of feedbacks(); track feedback.feedbackId) { + @if (feedback.successorModuleNameFeedback) { +

+ {{ feedback.requestedFromUserName ?? (feedback.feedbackRole | feedbackDepartment).text }}: + {{ feedback.successorModuleNameFeedback }} +

+ } + } +
+ }
- - - @if (user() !== undefined) { - - - - } @else { - - - - - } +
diff --git a/Client/src/app/components/header/header.component.ts b/Client/src/app/components/header/header.component.ts index 64b94949..ab342555 100644 --- a/Client/src/app/components/header/header.component.ts +++ b/Client/src/app/components/header/header.component.ts @@ -4,17 +4,14 @@ import { SecurityStore } from '../../core/security/security-store.service'; import { ThemeService } from '../../core/theme/theme.service'; import { SidebarService } from '../side-bar/sidebar.service'; import { ButtonModule } from 'primeng/button'; -import { ButtonGroupModule } from 'primeng/buttongroup'; -import { AvatarModule } from 'primeng/avatar'; -import { MenuModule } from 'primeng/menu'; import { TooltipModule } from 'primeng/tooltip'; -import { MenuItem } from 'primeng/api'; +import { SignInComponent } from '../sign-in/sign-in.component'; @Component({ selector: 'app-header', templateUrl: './header.component.html', standalone: true, - imports: [RouterLink, ButtonModule, ButtonGroupModule, AvatarModule, MenuModule, TooltipModule] + imports: [RouterLink, ButtonModule, TooltipModule, SignInComponent] }) export class HeaderComponent { securityStore = inject(SecurityStore); @@ -24,35 +21,7 @@ export class HeaderComponent { user = this.securityStore.user; isDarkMode = this.themeService.isDarkMode; - menuItems: MenuItem[] = [ - { - label: 'Settings', - icon: 'pi pi-cog', - routerLink: '/account' - }, - { - separator: true - }, - { - label: 'Sign Out', - icon: 'pi pi-sign-out', - command: () => this.signOut() - } - ]; - toggleTheme() { this.themeService.toggleTheme(); } - - signIn() { - this.securityStore.signIn(); - } - - signInWithPasskey() { - this.securityStore.signInWithPasskey(); - } - - signOut() { - this.securityStore.signOut(); - } } diff --git a/Client/src/app/components/sign-in/sign-in.component.html b/Client/src/app/components/sign-in/sign-in.component.html new file mode 100644 index 00000000..057109c0 --- /dev/null +++ b/Client/src/app/components/sign-in/sign-in.component.html @@ -0,0 +1,33 @@ +@if (user() !== undefined) { + + + +} @else { + + + + +} diff --git a/Client/src/app/components/sign-in/sign-in.component.ts b/Client/src/app/components/sign-in/sign-in.component.ts new file mode 100644 index 00000000..d92b434a --- /dev/null +++ b/Client/src/app/components/sign-in/sign-in.component.ts @@ -0,0 +1,35 @@ +import { Component, inject } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { ButtonModule } from 'primeng/button'; +import { ButtonGroupModule } from 'primeng/buttongroup'; +import { MenuModule } from 'primeng/menu'; +import { TooltipModule } from 'primeng/tooltip'; +import { MenuItem } from 'primeng/api'; +import { SecurityStore } from '../../core/security/security-store.service'; + +@Component({ + selector: 'app-sign-in', + standalone: true, + imports: [RouterModule, ButtonModule, ButtonGroupModule, MenuModule, TooltipModule], + templateUrl: './sign-in.component.html' +}) +export class SignInComponent { + readonly securityStore = inject(SecurityStore); + readonly user = this.securityStore.user; + + menuItems: MenuItem[] = [ + { + label: 'Settings', + icon: 'pi pi-cog', + routerLink: '/account' + }, + { + separator: true + }, + { + label: 'Sign Out', + icon: 'pi pi-sign-out', + command: () => this.securityStore.signOut() + } + ]; +} diff --git a/Client/src/app/pages/index/index.component.html b/Client/src/app/pages/index/index.component.html index 496a1791..bed247af 100644 --- a/Client/src/app/pages/index/index.component.html +++ b/Client/src/app/pages/index/index.component.html @@ -7,26 +7,9 @@

Sign in to access the Module Management application.

- - - - +
+ +
diff --git a/Client/src/app/pages/index/index.component.ts b/Client/src/app/pages/index/index.component.ts index 3ed0689d..63ac1578 100644 --- a/Client/src/app/pages/index/index.component.ts +++ b/Client/src/app/pages/index/index.component.ts @@ -1,17 +1,16 @@ import { Component, inject } from '@angular/core'; import { RouterModule } from '@angular/router'; import { ButtonModule } from 'primeng/button'; -import { ButtonGroupModule } from 'primeng/buttongroup'; import { DividerModule } from 'primeng/divider'; import { PanelModule } from 'primeng/panel'; -import { TooltipModule } from 'primeng/tooltip'; import { SecurityStore } from '../../core/security/security-store.service'; +import { SignInComponent } from '../../components/sign-in/sign-in.component'; import { isAdminRole, isProfessorRole, isReviewerRole } from '../../core/shared/user-role.utils'; @Component({ selector: 'index-component', standalone: true, - imports: [RouterModule, ButtonModule, ButtonGroupModule, DividerModule, PanelModule, TooltipModule], + imports: [RouterModule, ButtonModule, DividerModule, PanelModule, SignInComponent], templateUrl: './index.component.html' }) export class IndexComponent { From 46b27d32257eb93aa6fa0a7e2e242133fdd19cc3 Mon Sep 17 00:00:00 2001 From: Johannes Pahle Date: Tue, 24 Mar 2026 11:23:23 +0100 Subject: [PATCH 35/39] Fix passkey extension session / key management --- .example.env | 2 + .../src/app/core/security/keycloak.service.ts | 38 +- .../security/passkey-extension.service.ts | 15 +- .../core/security/security-store.service.ts | 21 +- .../keycloak/PasskeyConfigResolver.java | 59 ++ .../example/keycloak/UserPasskeyProvider.java | 8 +- .../keycloak/UserPasskeyProviderFactory.java | 10 +- .../example/keycloak/UserPasskeyResource.java | 532 +++++++++++------- 8 files changed, 435 insertions(+), 250 deletions(-) create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java diff --git a/.example.env b/.example.env index ba1772e2..d33714e3 100644 --- a/.example.env +++ b/.example.env @@ -46,3 +46,5 @@ EMBEDDING_MODEL_NAME= sentence-transformers/all-mpnet-base-v2 CHAT_MODEL_SOURCE = OpenAiChatModel EMBEDDING_MODEL_SOURCE = OpenAiEmbeddingModel +KC_PASSKEY_CLIENT_ID=module-management +KC_ALLOWED_BROWSER_ORIGIN=https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:\\d+)?$|^https://module\\.aet\\.cit\\.tum\\.de$ diff --git a/Client/src/app/core/security/keycloak.service.ts b/Client/src/app/core/security/keycloak.service.ts index c865bb6f..02b655a5 100644 --- a/Client/src/app/core/security/keycloak.service.ts +++ b/Client/src/app/core/security/keycloak.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { environment } from '../../../../environments/environment'; -import Keycloak, { type KeycloakTokenParsed } from 'keycloak-js'; +import Keycloak from 'keycloak-js'; import { KeycloakCredentialType } from './keycloak-credentials.types'; @Injectable({ providedIn: 'root' }) @@ -58,42 +58,6 @@ export class KeycloakService { return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl ?? '') }); } - applyPasskeyTokens(accessToken: string, refreshToken: string): void { - const kc = this.keycloak; - kc.token = accessToken; - kc.refreshToken = refreshToken; - kc.idToken = undefined; - kc.idTokenParsed = undefined; - - const parsed = this.parseJwtPayload(accessToken); - kc.tokenParsed = parsed; - kc.refreshTokenParsed = this.parseJwtPayload(refreshToken); - - if (parsed) { - kc.subject = parsed.sub; - const ext = parsed as KeycloakTokenParsed & { sid?: string; session_state?: string }; - kc.sessionId = ext.sid ?? ext.session_state; - kc.realmAccess = parsed.realm_access; - kc.resourceAccess = parsed.resource_access; - } - kc.timeSkew = 0; - kc.authenticated = true; - } - - private parseJwtPayload(token: string): KeycloakTokenParsed | undefined { - try { - const parts = token.split('.'); - if (parts.length < 2) { - return undefined; - } - const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); - const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); - return JSON.parse(atob(padded)) as KeycloakTokenParsed; - } catch { - return undefined; - } - } - logout() { return this.keycloak.logout({ redirectUri: environment.redirect }); } diff --git a/Client/src/app/core/security/passkey-extension.service.ts b/Client/src/app/core/security/passkey-extension.service.ts index c123d59d..d2a82ca1 100644 --- a/Client/src/app/core/security/passkey-extension.service.ts +++ b/Client/src/app/core/security/passkey-extension.service.ts @@ -56,7 +56,7 @@ export class PasskeyExtensionService { const accountName = String(parsed?.['preferred_username'] ?? parsed?.['email'] ?? ''); const displayName = String( parsed?.['name'] ?? - ([parsed?.['given_name'], parsed?.['family_name']].filter(Boolean).join(' ') || accountName || 'User') + ([parsed?.['given_name'], parsed?.['family_name']].filter(Boolean).join(' ') || accountName || 'User') ); if (!accountId || !accountName) { @@ -115,9 +115,11 @@ export class PasskeyExtensionService { } /** - * Sign in with passkey only (no Keycloak UI redirect). Returns tokens from {@code POST /passkey/authenticate}. + * Sign in with passkey only (no Keycloak UI redirect). + * The extension endpoint sets the Keycloak login cookie; the SPA should then reload + * and let keycloak-js initialize via check-sso. */ - async signInWithPasskey(): Promise<{ access_token: string; refresh_token: string }> { + async signInWithPasskey(): Promise { const optionsResponse = await fetch(this.getUrl('get-credential-id')); const res = (await optionsResponse.json()) as { challenge?: string; credentialId?: string; error?: string }; if (!optionsResponse.ok) { @@ -154,17 +156,14 @@ export class PasskeyExtensionService { const authRes = await fetch(this.getUrl('authenticate'), { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); - const authResult = (await authRes.json()) as { access_token?: string; refresh_token?: string; error?: string }; + const authResult = (await authRes.json()) as { error?: string }; if (!authRes.ok) { throw new Error(authResult?.error || `Passkey authentication failed (${authRes.status})`); } - if (!authResult.access_token || !authResult.refresh_token) { - throw new Error('Invalid token response from server'); - } - return { access_token: authResult.access_token, refresh_token: authResult.refresh_token }; } } diff --git a/Client/src/app/core/security/security-store.service.ts b/Client/src/app/core/security/security-store.service.ts index 9338ef22..153f9426 100644 --- a/Client/src/app/core/security/security-store.service.ts +++ b/Client/src/app/core/security/security-store.service.ts @@ -90,11 +90,10 @@ export class SecurityStore { async signInWithPasskey(): Promise { this.isLoading.set(true); try { - const tokens = await this.passkeyExtension.signInWithPasskey(); - this.keycloakService.applyPasskeyTokens(tokens.access_token, tokens.refresh_token); - await this.loadPasskeys(); - const user = await firstValueFrom(this.userControllerService.getCurrentUser()); - this.user.set(user); + await this.passkeyExtension.signInWithPasskey(); + // Passkey authenticate sets the Keycloak login cookie; reload so init(check-sso) + // can bootstrap keycloak-js token state through the standard adapter flow. + window.location.reload(); } catch (error) { this.messageService.add({ severity: 'error', summary: 'Sign-in', detail: 'Something went wrong' }); console.error(error); @@ -131,12 +130,12 @@ export class SecurityStore { keycloakCredentials .find((credential) => credential.type === 'webauthn-passwordless') ?.userCredentialMetadatas.map((metadata) => { - return { - id: metadata.credential.id, - name: metadata.credential.userLabel, - createdAt: metadata.credential.createdDate ? new Date(metadata.credential.createdDate) : undefined - }; - }) ?? []; + return { + id: metadata.credential.id, + name: metadata.credential.userLabel, + createdAt: metadata.credential.createdDate ? new Date(metadata.credential.createdDate) : undefined + }; + }) ?? []; this.passkeys.set(passkeys); } catch (error) { console.error('Error reloading passkeys:', error); diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java new file mode 100644 index 00000000..266462b1 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java @@ -0,0 +1,59 @@ +package com.example.keycloak; + +import org.keycloak.Config; + +final class PasskeyConfigResolver { + + private static final String DEFAULT_ALLOWED_ORIGIN_PATTERN = "https?://(localhost|127\\\\.0\\\\.0\\\\.1|\\\\[::1\\\\])(:\\\\d+)?$"; + private static final String DEFAULT_CLIENT_ID = "module-management"; + private static final String ENV_ALLOWED_BROWSER_ORIGIN = "KC_ALLOWED_BROWSER_ORIGIN"; + private static final String ENV_DEMO_ADDITIONAL_WEB_ORIGIN = "KC_DEMO_ADDITIONAL_WEB_ORIGIN"; + private static final String ENV_PASSKEY_CLIENT_ID = "KC_PASSKEY_CLIENT_ID"; + + private PasskeyConfigResolver() { + } + + static String resolveAllowedOriginPattern(Config.Scope config) { + return firstNonBlank( + config == null ? null : config.get("allowed-browser-origin"), + System.getenv(ENV_ALLOWED_BROWSER_ORIGIN), + System.getenv(ENV_DEMO_ADDITIONAL_WEB_ORIGIN), + DEFAULT_ALLOWED_ORIGIN_PATTERN + ); + } + + static String resolveClientId(Config.Scope config) { + return firstNonBlank( + config == null ? null : config.get("client-id"), + System.getenv(ENV_PASSKEY_CLIENT_ID), + DEFAULT_CLIENT_ID + ); + } + + static String resolveAllowedOriginPatternFromEnv() { + return firstNonBlank( + System.getenv(ENV_ALLOWED_BROWSER_ORIGIN), + System.getenv(ENV_DEMO_ADDITIONAL_WEB_ORIGIN), + DEFAULT_ALLOWED_ORIGIN_PATTERN + ); + } + + static String resolveClientIdFromEnv() { + return firstNonBlank( + System.getenv(ENV_PASSKEY_CLIENT_ID), + DEFAULT_CLIENT_ID + ); + } + + static String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java index 98908172..7d3c73b2 100644 --- a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java @@ -6,14 +6,18 @@ public class UserPasskeyProvider implements RealmResourceProvider { private final KeycloakSession session; + private final String allowedBrowserOriginPattern; + private final String clientId; - public UserPasskeyProvider(KeycloakSession session ) { + public UserPasskeyProvider(KeycloakSession session, String allowedBrowserOriginPattern, String clientId) { this.session = session; + this.allowedBrowserOriginPattern = allowedBrowserOriginPattern; + this.clientId = clientId; } @Override public Object getResource() { - return new UserPasskeyResource(session); + return new UserPasskeyResource(session, allowedBrowserOriginPattern, clientId); } @Override diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java index d2b12afb..cfb9eae0 100644 --- a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java @@ -9,6 +9,8 @@ public class UserPasskeyProviderFactory implements RealmResourceProviderFactory { public static final String ID = "passkey"; + private String allowedBrowserOriginPattern; + private String clientId; @Override public String getId() { @@ -17,15 +19,19 @@ public String getId() { @Override public RealmResourceProvider create(KeycloakSession session) { - return new UserPasskeyProvider(session); + return new UserPasskeyProvider(session, allowedBrowserOriginPattern, clientId); } @Override - public void init(Config.Scope config) {} + public void init(Config.Scope config) { + allowedBrowserOriginPattern = PasskeyConfigResolver.resolveAllowedOriginPattern(config); + clientId = PasskeyConfigResolver.resolveClientId(config); + } @Override public void postInit(KeycloakSessionFactory factory) {} @Override public void close() {} + } diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java index ce3ff347..6be4ea9c 100644 --- a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java @@ -13,45 +13,57 @@ import com.webauthn4j.data.client.challenge.Challenge; import com.webauthn4j.data.client.challenge.DefaultChallenge; import com.webauthn4j.server.ServerProperty; -import jakarta.inject.Inject; -import jakarta.ws.rs.*; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.OPTIONS; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.jboss.logging.Logger; import org.keycloak.OAuth2Constants; +import org.keycloak.common.ClientConnection; import org.keycloak.common.util.Base64Url; -import org.keycloak.credential.CredentialModel; +import org.keycloak.common.util.Time; import org.keycloak.credential.CredentialProvider; import org.keycloak.credential.WebAuthnCredentialModelInput; import org.keycloak.credential.WebAuthnCredentialProvider; import org.keycloak.credential.WebAuthnPasswordlessCredentialProviderFactory; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientSessionContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.SingleUseObjectProvider; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.credential.WebAuthnCredentialModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.representations.AccessToken; -import org.keycloak.representations.RefreshToken; import org.keycloak.services.Urls; +import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.util.DefaultClientSessionContext; import java.lang.reflect.InvocationTargetException; -import java.nio.charset.StandardCharsets; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.Collections; -import java.util.LinkedHashSet; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; @Path("/") @@ -60,32 +72,53 @@ public class UserPasskeyResource { private final KeycloakSession session; private static final Logger logger = Logger.getLogger(UserPasskeyResource.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final long CHALLENGE_TTL_MILLIS = 2 * 60 * 1000; - private static final ConcurrentHashMap ISSUED_CHALLENGES = new ConcurrentHashMap<>(); + private static final String PASSKEY_TYPE = WebAuthnCredentialModel.TYPE_PASSWORDLESS; + private static final long CHALLENGE_TTL_SECONDS = 120; + private static final long CHALLENGE_TTL_MILLIS = CHALLENGE_TTL_SECONDS * 1000; + private static final String CHALLENGE_KEY_PREFIX = "passkey:challenge:"; private static final String CREDENTIAL_USER_ATTR = "passkey-credential-id"; + private static final String HEADER_ORIGIN = "Origin"; + private final Pattern allowedBrowserOrigin; + private final String clientId; + + // Needed so CDI can construct this class when bean-discovery-mode="all" is used. + public UserPasskeyResource() { + this.session = null; + this.allowedBrowserOrigin = Pattern.compile("^" + PasskeyConfigResolver.resolveAllowedOriginPatternFromEnv() + "$"); + this.clientId = PasskeyConfigResolver.resolveClientIdFromEnv(); + } - /** - * Custom /passkey/* routes do not get Keycloak's OIDC CORS (Web origins). Echo Origin only when it matches. - * Localhost/loopback + staging app host (see module-management client webOrigins). - */ - private static final Pattern ALLOWED_BROWSER_ORIGIN = Pattern.compile( - "^https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:\\d+)?$|^https://module\\.aet\\.cit\\.tum\\.de$"); - - @Inject - public UserPasskeyResource(KeycloakSession session) { + public UserPasskeyResource(KeycloakSession session, String allowedOriginPattern, String clientId) { this.session = session; + String configuredPattern = PasskeyConfigResolver.firstNonBlank( + allowedOriginPattern, + PasskeyConfigResolver.resolveAllowedOriginPatternFromEnv() + ); + this.allowedBrowserOrigin = Pattern.compile("^" + configuredPattern + "$"); + this.clientId = PasskeyConfigResolver.firstNonBlank( + clientId, + PasskeyConfigResolver.resolveClientIdFromEnv() + ); } + private KeycloakSession session() { + if (session == null) { + throw new IllegalStateException("UserPasskeyResource must be created by UserPasskeyProvider (SPI-managed session required)."); + } + return session; + } + + // ----- CORS ----- private String getOriginHeader() { - var headers = session.getContext().getRequestHeaders(); + var headers = session().getContext().getRequestHeaders(); if (headers == null) { return null; } - return headers.getHeaderString("Origin"); + return headers.getHeaderString(HEADER_ORIGIN); } private boolean isAllowedOrigin(String origin) { - return origin != null && ALLOWED_BROWSER_ORIGIN.matcher(origin.trim()).matches(); + return origin != null && allowedBrowserOrigin.matcher(origin.trim()).matches(); } private Response withCors(Response response) { @@ -104,127 +137,67 @@ private Response withCors(Response response) { @Path("{any:.*}") public Response corsPreflight() { Response.ResponseBuilder b = Response.ok() - .header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + .header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") .header("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept") .header("Access-Control-Max-Age", "3600"); return withCors(b.build()); } + // ----- Endpoints ----- @GET @Path("challenge") @Produces(MediaType.APPLICATION_JSON) public Response getChallenge() { - String challengeBase64 = generateChallenge(); - trackChallenge(challengeBase64); - - return withCors(Response.ok("{\"challenge\": \"" + challengeBase64 + "\"}") - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) - .build()); + return jsonOk(Map.of("challenge", issueChallenge())); } @GET - @Path("/get-credential-id") + @Path("get-credential-id") @Produces(MediaType.APPLICATION_JSON) public Response getCredentialId(@QueryParam("username") String username) { - String challengeBase64 = generateChallenge(); - trackChallenge(challengeBase64); + String challengeBase64 = issueChallenge(); if (username == null || username.isBlank()) { - return withCors(Response.ok("{\"challenge\": \"" + challengeBase64 + "\"}") - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) - .build()); + return jsonOk(Map.of("challenge", challengeBase64)); } - RealmModel realm = session.getContext().getRealm(); - UserModel user = session.users().getUserByUsername(realm, username); + UserModel user = findUserByUsername(username); if (user == null) { - return withCors(Response.status(Response.Status.NOT_FOUND) - .entity("{\"error\": \"User not found\"}") - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) - .build()); + return buildErrorResponse(Response.Status.NOT_FOUND, "User not found"); } - List webAuthnCredentials = user.credentialManager() - .getStoredCredentialsStream() - .filter(cred -> WebAuthnCredentialModel.TYPE_PASSWORDLESS.equals(cred.getType())) - .toList(); - - if (webAuthnCredentials.isEmpty()) { - return withCors(Response.status(Response.Status.NOT_FOUND) - .entity("{\"error\": \"No passkey found for user\"}") - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) - .build()); + WebAuthnCredentialModel credentialModel = findFirstPasswordlessCredential(user); + if (credentialModel == null) { + return buildErrorResponse(Response.Status.NOT_FOUND, "No passkey found for user"); } - - WebAuthnCredentialModel credentialModel = WebAuthnCredentialModel.createFromCredentialModel(webAuthnCredentials.get(0)); String credentialIdBase64 = credentialModel.getWebAuthnCredentialData().getCredentialId(); - String jsonResponse = "{\"credentialId\": \"" + credentialIdBase64 + "\", \"challenge\": \"" + challengeBase64 + "\"}"; - - return withCors(Response.ok(jsonResponse) - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) - .build()); + return jsonOk(Map.of( + "credentialId", credentialIdBase64, + "challenge", challengeBase64 + )); } @POST @Path("save") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - public Response savePasskey(PasskeyRequest request, @HeaderParam("Authorization") String authorizationHeader) throws JsonProcessingException { - RealmModel realm = session.getContext().getRealm(); - UserModel user = getUserFromBearerToken(realm, authorizationHeader); + public Response savePasskey(PasskeyRequest request, @HeaderParam("Authorization") String authorizationHeader) { + UserModel user = getUserFromBearerToken(authorizationHeader); if (user == null) { - return withCors(Response.status(Response.Status.UNAUTHORIZED) - .entity("Authenticated user not found from access token") - .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN) - .build()); + return textResponse(Response.Status.UNAUTHORIZED, "Authenticated user not found from access token"); } - String base64ClientDataJSON = request.getClientDataJSON(); - - byte[] decodedBytes = decodeBase64UrlOrStd(base64ClientDataJSON); - String decodedClientDataJSON = new String(decodedBytes, StandardCharsets.UTF_8); - JsonNode clientData = OBJECT_MAPPER.readTree(decodedClientDataJSON); - - Origin origin = new Origin(clientData.get("origin").asText()); - String rpId = clientData.get("origin").asText().replace("http://", "").replace("https://", "").split(":")[0]; - - Challenge challenge = new DefaultChallenge(clientData.get("challenge").asText()); - - Set originSet = new HashSet<>(); - originSet.add(origin); - ServerProperty serverProperty = new ServerProperty(originSet, rpId, challenge, null); - RegistrationParameters registrationParameters = new RegistrationParameters(serverProperty, true); - - byte[] attestationObject = decodeBase64UrlOrStd(request.getAttestationObject()); - byte[] clientDataJSON = decodeBase64UrlOrStd(request.getClientDataJSON()); - RegistrationRequest registrationRequest = new RegistrationRequest(attestationObject, clientDataJSON); - - WebAuthnRegistrationManager webAuthnRegistrationManager = createWebAuthnRegistrationManager(); - RegistrationData registrationData = webAuthnRegistrationManager.parse(registrationRequest); - validateRegistrationCompat(webAuthnRegistrationManager, registrationRequest, registrationData, registrationParameters); - - WebAuthnCredentialModelInput credential = new WebAuthnCredentialModelInput(WebAuthnCredentialModel.TYPE_PASSWORDLESS); - credential.setAttestedCredentialData(registrationData.getAttestationObject().getAuthenticatorData().getAttestedCredentialData()); - credential.setCount(registrationData.getAttestationObject().getAuthenticatorData().getSignCount()); - credential.setAttestationStatementFormat(registrationData.getAttestationObject().getFormat()); - credential.setTransports(registrationData.getTransports()); - - WebAuthnCredentialProvider webAuthnCredProvider = (WebAuthnCredentialProvider) this.session.getProvider(CredentialProvider.class, WebAuthnPasswordlessCredentialProviderFactory.PROVIDER_ID); - WebAuthnCredentialModel credentialModel = webAuthnCredProvider.getCredentialModelFromCredentialInput(credential, user.getUsername()); - - WebAuthnCredentialModel webAuthnCredentialModel = WebAuthnCredentialModel.createFromCredentialModel(credentialModel); - - user.credentialManager().createStoredCredential(webAuthnCredentialModel); - storeCredentialUserMapping(user, webAuthnCredentialModel.getWebAuthnCredentialData().getCredentialId()); - - return withCors(Response.status(Response.Status.CREATED) - .entity("Passkey stored successfully") - .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN) - .build()); + try { + registerPasskey(user, request); + return textResponse(Response.Status.CREATED, "Passkey stored successfully"); + } catch (IllegalArgumentException | JsonProcessingException e) { + return buildErrorResponse(Response.Status.BAD_REQUEST, "Invalid registration payload: " + e.getMessage()); + } } - private UserModel getUserFromBearerToken(RealmModel realm, String authorizationHeader) { + // Uses bearer access token subject as the owner of the passkey registration. + private UserModel getUserFromBearerToken(String authorizationHeader) { if (authorizationHeader == null || authorizationHeader.isBlank()) { return null; } @@ -237,22 +210,23 @@ private UserModel getUserFromBearerToken(RealmModel realm, String authorizationH return null; } - AccessToken accessToken = session.tokens().decode(token, AccessToken.class); + AccessToken accessToken = session().tokens().decode(token, AccessToken.class); if (accessToken == null || accessToken.getSubject() == null || accessToken.getSubject().isBlank()) { return null; } - return session.users().getUserById(realm, accessToken.getSubject()); + RealmModel realm = session().getContext().getRealm(); + return session().users().getUserById(realm, accessToken.getSubject()); } @POST @Path("authenticate") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - public Response authenticatePasskey(PasskeyRequest request) throws JsonProcessingException { - RealmModel realm = session.getContext().getRealm(); + public Response authenticatePasskey(PasskeyRequest request) { + RealmModel realm = session().getContext().getRealm(); - String requestCredentialId = firstNonBlank(request.getCredentialId(), request.getRawId()); + String requestCredentialId = resolveCredentialId(request); if (requestCredentialId == null) { return buildErrorResponse(Response.Status.BAD_REQUEST, "credentialId or rawId is required"); } @@ -263,36 +237,110 @@ public Response authenticatePasskey(PasskeyRequest request) throws JsonProcessin UserModel user = getUserByCredentialId(realm, requestCredentialId); - if (user == null) + if (user == null) { return buildErrorResponse( Response.Status.NOT_FOUND, "User not found for credential (username: )" ); + } WebAuthnCredentialModel webAuthnCredential = getWebAuthnCredential(user, requestCredentialId); - if (webAuthnCredential == null) + if (webAuthnCredential == null) { return buildErrorResponse(Response.Status.NOT_FOUND, "No passkey found for user: " + user.getUsername()); + } + + try { + boolean isValid = isPasskeyValid(request, requestCredentialId, user, realm); + if (isValid) { + return generateTokensResponse(user); + } + return buildErrorResponse(Response.Status.UNAUTHORIZED, "Invalid passkey"); + } catch (IllegalArgumentException | JsonProcessingException e) { + return buildErrorResponse(Response.Status.BAD_REQUEST, "Invalid authentication payload: " + e.getMessage()); + } + } + + // ----- Passkey domain logic ----- + // Registration flow: parse browser payload, verify attestation, and store passkey metadata. + private void registerPasskey(UserModel user, PasskeyRequest request) throws JsonProcessingException { + ClientDataContext clientDataContext = parseClientData(request.getClientDataJSON(), null); + RegistrationRequest registrationRequest = buildRegistrationRequest(request, clientDataContext); + RegistrationParameters registrationParameters = buildRegistrationParameters(clientDataContext); + + WebAuthnRegistrationManager manager = createWebAuthnRegistrationManager(); + RegistrationData registrationData = manager.parse(registrationRequest); + validateRegistrationCompat(manager, registrationRequest, registrationData, registrationParameters); + WebAuthnCredentialModel credentialModel = createCredentialModelFromRegistration(user, registrationData); + user.credentialManager().createStoredCredential(credentialModel); + storeCredentialUserMapping(user, credentialModel.getWebAuthnCredentialData().getCredentialId()); + } + + private RegistrationRequest buildRegistrationRequest(PasskeyRequest request, ClientDataContext clientDataContext) { + byte[] attestationObject = decodeBase64UrlOrStd(request.getAttestationObject()); + return new RegistrationRequest(attestationObject, clientDataContext.decodedClientDataBytes()); + } + + private RegistrationParameters buildRegistrationParameters(ClientDataContext clientDataContext) { + Set originSet = new HashSet<>(); + originSet.add(clientDataContext.origin()); + ServerProperty serverProperty = new ServerProperty(originSet, clientDataContext.rpId(), clientDataContext.challenge(), null); + return new RegistrationParameters(serverProperty, true); + } + + private WebAuthnCredentialModel createCredentialModelFromRegistration(UserModel user, RegistrationData registrationData) { + WebAuthnCredentialModelInput credentialInput = new WebAuthnCredentialModelInput(WebAuthnCredentialModel.TYPE_PASSWORDLESS); + credentialInput.setAttestedCredentialData(registrationData.getAttestationObject().getAuthenticatorData().getAttestedCredentialData()); + credentialInput.setCount(registrationData.getAttestationObject().getAuthenticatorData().getSignCount()); + credentialInput.setAttestationStatementFormat(registrationData.getAttestationObject().getFormat()); + credentialInput.setTransports(registrationData.getTransports()); + + WebAuthnCredentialProvider provider = (WebAuthnCredentialProvider) session() + .getProvider(CredentialProvider.class, WebAuthnPasswordlessCredentialProviderFactory.PROVIDER_ID); + WebAuthnCredentialModel rawModel = provider.getCredentialModelFromCredentialInput(credentialInput, user.getUsername()); + return WebAuthnCredentialModel.createFromCredentialModel(rawModel); + } + + private boolean isPasskeyValid(PasskeyRequest request, String requestCredentialId, UserModel user, RealmModel realm) throws JsonProcessingException { byte[] credentialId = decodeBase64UrlOrStd(requestCredentialId); byte[] authenticatorData = decodeBase64UrlOrStd(request.getAuthenticatorData()); byte[] signature = Base64Url.decode(request.getSignature()); - String clientDataJSON = request.getClientDataJSON(); String challenge = request.getChallenge(); + return isPasskeyValid(credentialId, authenticatorData, request.getClientDataJSON(), signature, challenge, user, realm); + } - boolean isValid = isPasskeyValid(credentialId, authenticatorData, clientDataJSON, signature, challenge, user, realm); - if (isValid) { - return generateTokensResponse(user); - } - return buildErrorResponse(Response.Status.UNAUTHORIZED, "Invalid passkey"); + private String resolveCredentialId(PasskeyRequest request) { + return PasskeyConfigResolver.firstNonBlank(request.getCredentialId(), request.getRawId()); } + private String issueChallenge() { + String challenge = generateChallenge(); + trackChallenge(challenge); + return challenge; + } + + private UserModel findUserByUsername(String username) { + RealmModel realm = session().getContext().getRealm(); + return session().users().getUserByUsername(realm, username); + } + + private WebAuthnCredentialModel findFirstPasswordlessCredential(UserModel user) { + return user.credentialManager() + .getStoredCredentialsStream() + .filter(cred -> PASSKEY_TYPE.equals(cred.getType())) + .findFirst() + .map(WebAuthnCredentialModel::createFromCredentialModel) + .orElse(null); + } + + // ----- User/Credential lookup ----- private UserModel getUserByCredentialId(RealmModel realm, String credentialId) { String normalizedCredentialId = normalizeCredentialId(credentialId); if (normalizedCredentialId == null) { return null; } - UserModel mappedUser = session.users() + UserModel mappedUser = session().users() .searchForUserByUserAttributeStream(realm, CREDENTIAL_USER_ATTR, normalizedCredentialId) .findFirst() .orElse(null); @@ -300,6 +348,7 @@ private UserModel getUserByCredentialId(RealmModel realm, String credentialId) { return mappedUser; } + // Fallback scan for environments where user-attribute lookup does not return results reliably. int first = 0; int maxResults = 200; @@ -327,21 +376,21 @@ private UserModel getUserByCredentialId(RealmModel realm, String credentialId) { } private List getUsersPage(RealmModel realm, int first, int maxResults) { - List users = session.users() + List users = session().users() .searchForUserStream(realm, Map.of("search", ""), first, maxResults) .toList(); if (!users.isEmpty()) { return users; } - users = session.users() + users = session().users() .searchForUserStream(realm, Collections.emptyMap(), first, maxResults) .toList(); if (!users.isEmpty()) { return users; } - return session.users() + return session().users() .searchForUserStream(realm, "", first, maxResults) .toList(); } @@ -353,7 +402,7 @@ private WebAuthnCredentialModel getWebAuthnCredential(UserModel user, String cre } return user.credentialManager() - .getStoredCredentialsByTypeStream(WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .getStoredCredentialsByTypeStream(PASSKEY_TYPE) .map(WebAuthnCredentialModel::createFromCredentialModel) .filter(credential -> { String storedCredentialId = credential.getWebAuthnCredentialData().getCredentialId(); @@ -362,109 +411,151 @@ private WebAuthnCredentialModel getWebAuthnCredential(UserModel user, String cre } byte[] storedCredentialIdBytes = credentialIdToBytes(storedCredentialId); - return storedCredentialIdBytes.length > 0 && java.util.Arrays.equals(requestedCredentialId, storedCredentialIdBytes); + return storedCredentialIdBytes.length > 0 && Arrays.equals(requestedCredentialId, storedCredentialIdBytes); }) .findFirst() .orElse(null); } + // Authentication validation: rebuild WebAuthn server properties from signed client payload. private boolean isPasskeyValid(byte[] credentialId, byte[] authenticatorData, String clientDataJSON, byte[] signature, String challengeRequest, UserModel user, RealmModel realm) throws JsonProcessingException { if (challengeRequest == null || challengeRequest.isBlank()) { return false; } - byte[] decodedClientDataBytes = decodeBase64UrlOrStd(clientDataJSON); - String decodedClientDataJSON = new String(decodedClientDataBytes, StandardCharsets.UTF_8); - JsonNode clientData = OBJECT_MAPPER.readTree(decodedClientDataJSON); - - Origin origin = new Origin(clientData.get("origin").asText()); - String rpId = clientData.get("origin").asText().replace("http://", "").replace("https://", "").split(":")[0]; - Challenge challenge = new DefaultChallenge(challengeRequest); - ServerProperty serverProperty = new ServerProperty(origin, rpId, challenge, null); + ClientDataContext clientDataContext = parseClientData(clientDataJSON, challengeRequest); + ServerProperty serverProperty = new ServerProperty( + clientDataContext.origin(), + clientDataContext.rpId(), + clientDataContext.challenge(), + null + ); String uvPolicy = realm.getWebAuthnPolicyPasswordless().getUserVerificationRequirement(); boolean isUVFlagChecked = uvPolicy != null && "required".equalsIgnoreCase(uvPolicy); - var authReq = new AuthenticationRequest(credentialId, authenticatorData, decodedClientDataBytes, signature); + var authReq = new AuthenticationRequest( + credentialId, + authenticatorData, + clientDataContext.decodedClientDataBytes(), + signature + ); var authParams = new WebAuthnCredentialModelInput.KeycloakWebAuthnAuthenticationParameters(serverProperty, isUVFlagChecked); - var cred = new WebAuthnCredentialModelInput(WebAuthnCredentialModel.TYPE_PASSWORDLESS); + var cred = new WebAuthnCredentialModelInput(PASSKEY_TYPE); cred.setAuthenticationRequest(authReq); cred.setAuthenticationParameters(authParams); return user.credentialManager().isValid(cred); } - /** Same idea as standard auth: include openid + default client scopes so aud/roles mappers apply. */ + // ----- Token issuance ----- private String buildScopeParameterForClient(ClientModel client) { LinkedHashSet scopeNames = new LinkedHashSet<>(); scopeNames.add(OAuth2Constants.SCOPE_OPENID); + scopeNames.add(OAuth2Constants.SCOPE_PROFILE); + scopeNames.add(OAuth2Constants.SCOPE_EMAIL); + scopeNames.add("roles"); + scopeNames.add("web-origins"); + scopeNames.add("account"); scopeNames.addAll(client.getClientScopes(true).keySet()); - scopeNames.addAll(client.getClientScopes(false).keySet()); return String.join(" ", scopeNames); } private Response generateTokensResponse(UserModel user) { try { - RealmModel realm = session.getContext().getRealm(); + RealmModel realm = session().getContext().getRealm(); - ClientModel client = realm.getClientByClientId("module-management"); + if (clientId == null || clientId.isBlank()) { + logger.error("Passkey client_id is not configured"); + return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Server configuration error"); + } + ClientModel client = realm.getClientByClientId(clientId); if (client == null) { - logger.error("Client not found for client_id module-management"); + logger.errorf("Client not found for client_id %s", clientId); return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Client not found"); } - session.getContext().setClient(client); + session().getContext().setClient(client); - UserSessionModel userSession = session.sessions().createUserSession( - realm, user, user.getUsername(), "127.0.0.1", "form", true, null, null); + ClientConnection connection = session().getContext().getConnection(); + String remoteAddress = connection == null ? "127.0.0.1" : connection.getRemoteAddr(); + UserSessionModel userSession = session().sessions().createUserSession( + realm, user, user.getUsername(), remoteAddress, OIDCLoginProtocol.LOGIN_PROTOCOL, false, null, null); + userSession.setLastSessionRefresh(Time.currentTime()); AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId()); if (clientSession == null) { - clientSession = session.sessions().createClientSession(realm, client, userSession); + clientSession = session().sessions().createClientSession(realm, client, userSession); } + clientSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + clientSession.setTimestamp(Time.currentTime()); String scopeParameter = buildScopeParameterForClient(client); + clientSession.setNote(OIDCLoginProtocol.SCOPE_PARAM, scopeParameter); + String issuer = Urls.realmIssuer(session().getContext().getUri().getBaseUri(), realm.getName()); + clientSession.setNote(OIDCLoginProtocol.ISSUER, issuer); ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter( - clientSession, scopeParameter, session); + clientSession, scopeParameter, session()); - if (session.getContext().getClient() == null) { + if (session().getContext().getClient() == null) { logger.error("Client context is still null after setting."); return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Client context is null"); } - TokenManager tokenManager = new TokenManager(); - AccessToken accessToken = tokenManager.createClientAccessToken( - session, realm, client, user, userSession, clientSessionCtx); - - var keycloakUri = session.getContext().getUri(); - if (keycloakUri != null && keycloakUri.getBaseUri() != null) { - URI base = keycloakUri.getBaseUri(); - accessToken.issuer(Urls.realmIssuer(base, realm.getName()).toString()); - } else { - logger.warn("Keycloak URI context missing; token will have no iss (resource server will reject)"); - } + AuthenticationManager.createLoginCookie( + session(), + realm, + user, + userSession, + session().getContext().getUri(), + connection + ); - String accessTokenString = session.tokens().encode(accessToken); - RefreshToken refreshToken = new RefreshToken(accessToken); - String refreshTokenString = session.tokens().encode(refreshToken); - return withCors(Response.ok("{\"access_token\": \"" + accessTokenString + "\", \"refresh_token\": \"" + refreshTokenString + "\"}") - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) - .build()); + EventBuilder event = new EventBuilder(realm, session(), connection) + .event(EventType.LOGIN) + .client(client) + .user(user) + .session(userSession); + + TokenManager tokenManager = new TokenManager(); + var tokenBuilder = tokenManager.responseBuilder( + realm, + client, + event, + session(), + userSession, + clientSessionCtx + ) + .generateAccessToken() + .generateRefreshToken() + .generateIDToken(); + + var tokenResponse = tokenBuilder.build(); + event.success(); + + return jsonOk(Map.of( + "access_token", PasskeyConfigResolver.firstNonBlank(tokenResponse.getToken(), ""), + "refresh_token", PasskeyConfigResolver.firstNonBlank(tokenResponse.getRefreshToken(), ""), + "id_token", PasskeyConfigResolver.firstNonBlank(tokenResponse.getIdToken(), ""), + "expires_in", tokenResponse.getExpiresIn(), + "refresh_expires_in", tokenResponse.getRefreshExpiresIn(), + "token_type", PasskeyConfigResolver.firstNonBlank(tokenResponse.getTokenType(), "Bearer"), + "scope", PasskeyConfigResolver.firstNonBlank(tokenResponse.getScope(), ""), + "session_state", PasskeyConfigResolver.firstNonBlank(tokenResponse.getSessionState(), ""), + "not-before-policy", tokenResponse.getNotBeforePolicy() + )); } catch (Exception e) { logger.error("Token generation failed: " + e.getMessage(), e); return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Token generation failed: " + e.getMessage()); } } + // ----- Shared payload/codec helpers ----- private String generateChallenge() { Challenge challenge = new DefaultChallenge(); return Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.getValue()); } private Response buildErrorResponse(Response.Status status, String message) { - String safe = message == null ? "" : message.replace("\\", "\\\\").replace("\"", "\\\""); - return withCors(Response.status(status) - .entity("{\"error\": \"" + safe + "\"}") - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) - .build()); + return jsonResponse(status, Map.of("error", PasskeyConfigResolver.firstNonBlank(message, ""))); } private byte[] decodeBase64UrlOrStd(String base64String) { @@ -502,34 +593,27 @@ private byte[] credentialIdToBytes(String credentialId) { } } - private String firstNonBlank(String primary, String fallback) { - if (primary != null && !primary.isBlank()) { - return primary; - } - if (fallback != null && !fallback.isBlank()) { - return fallback; - } - return null; - } - + // ----- Challenge storage ----- private void trackChallenge(String challenge) { - clearExpiredChallenges(); - ISSUED_CHALLENGES.put(challenge, System.currentTimeMillis()); + String challengeKey = challengeKey(challenge); + Map marker = Map.of("issuedAt", Long.toString(System.currentTimeMillis())); + session().getProvider(SingleUseObjectProvider.class).put(challengeKey, CHALLENGE_TTL_SECONDS, marker); } private boolean consumeChallenge(String challenge) { if (challenge == null || challenge.isBlank()) { return false; } - - clearExpiredChallenges(); - Long issuedAt = ISSUED_CHALLENGES.remove(challenge); - return issuedAt != null && (System.currentTimeMillis() - issuedAt) <= CHALLENGE_TTL_MILLIS; - } - - private void clearExpiredChallenges() { - long now = System.currentTimeMillis(); - ISSUED_CHALLENGES.entrySet().removeIf(entry -> (now - entry.getValue()) > CHALLENGE_TTL_MILLIS); + Map value = session().getProvider(SingleUseObjectProvider.class).remove(challengeKey(challenge)); + if (value == null) { + return false; + } + try { + long issuedAt = Long.parseLong(value.getOrDefault("issuedAt", "0")); + return issuedAt > 0 && (System.currentTimeMillis() - issuedAt) <= CHALLENGE_TTL_MILLIS; + } catch (NumberFormatException ignored) { + return false; + } } protected WebAuthnRegistrationManager createWebAuthnRegistrationManager() { @@ -583,4 +667,72 @@ private void validateRegistrationCompat(WebAuthnRegistrationManager manager, Reg throw new RuntimeException("Incompatible WebAuthnRegistrationManager methods: neither verify nor validate is supported"); } + // clientDataJSON is the browser-provided WebAuthn metadata payload. + private ClientDataContext parseClientData(String base64ClientDataJSON, String challengeOverride) throws JsonProcessingException { + byte[] decodedClientDataBytes = decodeBase64UrlOrStd(base64ClientDataJSON); + String decodedClientDataJSON = new String(decodedClientDataBytes, StandardCharsets.UTF_8); + JsonNode clientData = OBJECT_MAPPER.readTree(decodedClientDataJSON); + + String originValue = readRequiredText(clientData, "origin"); + String challengeValue = PasskeyConfigResolver.firstNonBlank(challengeOverride, readRequiredText(clientData, "challenge")); + return new ClientDataContext( + decodedClientDataBytes, + new Origin(originValue), + extractRpId(originValue), + new DefaultChallenge(challengeValue) + ); + } + + private String readRequiredText(JsonNode node, String field) { + JsonNode valueNode = node.get(field); + if (valueNode == null || valueNode.asText().isBlank()) { + throw new IllegalArgumentException("clientDataJSON is missing required field: " + field); + } + return valueNode.asText(); + } + + private String extractRpId(String origin) { + URI parsedUri = URI.create(origin); + if (parsedUri.getHost() == null || parsedUri.getHost().isBlank()) { + throw new IllegalArgumentException("Invalid origin in clientDataJSON: " + origin); + } + return parsedUri.getHost(); + } + + private Response jsonOk(Object payload) { + return jsonResponse(Response.Status.OK, payload); + } + + private Response jsonResponse(Response.Status status, Object payload) { + String jsonPayload = toJson(payload); + Response.ResponseBuilder builder = status == Response.Status.OK + ? Response.ok(jsonPayload) + : Response.status(status).entity(jsonPayload); + return withCors(builder.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON).build()); + } + + private Response textResponse(Response.Status status, String payload) { + return withCors(Response.status(status) + .entity(payload) + .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN) + .build()); + } + + private String toJson(Object payload) { + try { + return OBJECT_MAPPER.writeValueAsString(payload); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize JSON response", e); + } + } + + private record ClientDataContext(byte[] decodedClientDataBytes, Origin origin, String rpId, Challenge challenge) { + } + + private String challengeKey(String challenge) { + RealmModel realm = session().getContext().getRealm(); + String realmName = realm == null ? "unknown-realm" : Objects.toString(realm.getName(), "unknown-realm"); + return CHALLENGE_KEY_PREFIX + realmName + ":" + clientId + ":" + challenge; + } + } From 409216b924fd9a4d9317efa59ac3a2dcf1625416 Mon Sep 17 00:00:00 2001 From: Johannes Pahle Date: Tue, 24 Mar 2026 15:27:43 +0100 Subject: [PATCH 36/39] Move to structured passkey extension --- .example.env | 2 +- .../security/passkey-extension.service.ts | 26 +- docker/docker-compose.dev.yaml | 4 + docker/docker-compose.staging.yaml | 2 + keycloak-extension-passkey/pom.xml | 11 +- .../keycloak/PasskeyBrowserLoginService.java | 212 +++++ .../keycloak/PasskeyChallengeService.java | 165 ++++ .../keycloak/PasskeyClientSupport.java | 45 + .../keycloak/PasskeyConfigResolver.java | 8 +- .../keycloak/PasskeyWebAuthnService.java | 382 +++++++++ .../example/keycloak/UserPasskeyResource.java | 794 +++++------------- 11 files changed, 1039 insertions(+), 612 deletions(-) create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyBrowserLoginService.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyChallengeService.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyClientSupport.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java diff --git a/.example.env b/.example.env index d33714e3..37436af4 100644 --- a/.example.env +++ b/.example.env @@ -47,4 +47,4 @@ CHAT_MODEL_SOURCE = OpenAiChatModel EMBEDDING_MODEL_SOURCE = OpenAiEmbeddingModel KC_PASSKEY_CLIENT_ID=module-management -KC_ALLOWED_BROWSER_ORIGIN=https?://(localhost|127\\.0\\.0\\.1|\\[::1\\])(:\\d+)?$|^https://module\\.aet\\.cit\\.tum\\.de$ +KC_ALLOWED_BROWSER_ORIGIN=https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?|https://module\.aet\.cit\.tum\.de diff --git a/Client/src/app/core/security/passkey-extension.service.ts b/Client/src/app/core/security/passkey-extension.service.ts index d2a82ca1..52374263 100644 --- a/Client/src/app/core/security/passkey-extension.service.ts +++ b/Client/src/app/core/security/passkey-extension.service.ts @@ -36,6 +36,18 @@ export class PasskeyExtensionService { return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } + private async readJsonBody(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.toLowerCase().includes('application/json')) { + return undefined; + } + try { + return (await response.json()) as T; + } catch { + return undefined; + } + } + /** * Register a new passkey for the current user (must already be logged in). */ @@ -63,7 +75,7 @@ export class PasskeyExtensionService { throw new Error('Missing user identity in token for passkey registration.'); } - const challengeRes = await fetch(this.getUrl('challenge')); + const challengeRes = await fetch(this.getUrl('challenge'), { credentials: 'include' }); if (!challengeRes.ok) { throw new Error(`Failed to get WebAuthn challenge (${challengeRes.status})`); } @@ -94,11 +106,13 @@ export class PasskeyExtensionService { credentialId: this.bufferToBase64Url(credential.rawId), rawId: this.bufferToBase64Url(credential.rawId), clientDataJSON: this.bufferToBase64Url(response.clientDataJSON), - attestationObject: this.bufferToBase64Url(response.attestationObject) + attestationObject: this.bufferToBase64Url(response.attestationObject), + challenge }; const saveRes = await fetch(this.getUrl('save'), { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` @@ -120,12 +134,12 @@ export class PasskeyExtensionService { * and let keycloak-js initialize via check-sso. */ async signInWithPasskey(): Promise { - const optionsResponse = await fetch(this.getUrl('get-credential-id')); - const res = (await optionsResponse.json()) as { challenge?: string; credentialId?: string; error?: string }; + const optionsResponse = await fetch(this.getUrl('challenge'), { credentials: 'include' }); + const res = await this.readJsonBody<{ challenge?: string; credentialId?: string; error?: string }>(optionsResponse); if (!optionsResponse.ok) { throw new Error(res?.error || `Failed to get passkey options (${optionsResponse.status})`); } - if (!res.challenge) { + if (!res?.challenge) { throw new Error('Invalid challenge response from server'); } @@ -161,7 +175,7 @@ export class PasskeyExtensionService { body: JSON.stringify(payload) }); - const authResult = (await authRes.json()) as { error?: string }; + const authResult = await this.readJsonBody<{ error?: string }>(authRes); if (!authRes.ok) { throw new Error(authResult?.error || `Passkey authentication failed (${authRes.status})`); } diff --git a/docker/docker-compose.dev.yaml b/docker/docker-compose.dev.yaml index 90aae32b..ff03a0da 100644 --- a/docker/docker-compose.dev.yaml +++ b/docker/docker-compose.dev.yaml @@ -24,8 +24,11 @@ services: environment: KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin} KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + KC_PASSKEY_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-module-management} + KC_ALLOWED_BROWSER_ORIGIN: ${KEYCLOAK_ALLOWED_ORIGIN:-http://localhost:4200} command: start-dev --import-realm volumes: + - keycloak_data:/opt/keycloak/data - ../module-management-realm.json:/opt/keycloak/data/import/module-management-realm.json - ../keycloak-themes:/opt/keycloak/themes ports: @@ -69,6 +72,7 @@ services: volumes: postgres_data: + keycloak_data: hf_cache: diff --git a/docker/docker-compose.staging.yaml b/docker/docker-compose.staging.yaml index ce261a2c..0150cf32 100644 --- a/docker/docker-compose.staging.yaml +++ b/docker/docker-compose.staging.yaml @@ -38,6 +38,8 @@ services: environment: - KC_BOOTSTRAP_ADMIN_USERNAME=${KEYCLOAK_ADMIN_USERNAME} - KC_BOOTSTRAP_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD} + - KC_PASSKEY_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-module-management} + - KC_ALLOWED_BROWSER_ORIGIN=${KEYCLOAK_ALLOWED_ORIGIN:-http://localhost:4200} - KC_DB=postgres - KC_DB_URL_HOST=postgres - KC_DB_URL_DATABASE=keycloak diff --git a/keycloak-extension-passkey/pom.xml b/keycloak-extension-passkey/pom.xml index b8ba93a1..8926e274 100644 --- a/keycloak-extension-passkey/pom.xml +++ b/keycloak-extension-passkey/pom.xml @@ -12,7 +12,7 @@ UTF-8 - 26.1.3 + 26.5.0 @@ -20,11 +20,13 @@ org.keycloak keycloak-server-spi ${keycloak.version} + provided org.keycloak keycloak-services ${keycloak.version} + provided org.keycloak @@ -36,6 +38,7 @@ org.jboss.resteasy resteasy-core-spi 6.2.11.Final + provided org.projectlombok @@ -51,7 +54,7 @@ com.fasterxml.jackson.core jackson-databind - 2.13.4.2 + 2.18.3 @@ -59,9 +62,9 @@ org.apache.maven.plugins maven-compiler-plugin + 3.13.0 - 16 - 16 + 17 org.projectlombok diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyBrowserLoginService.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyBrowserLoginService.java new file mode 100644 index 00000000..8f4ec276 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyBrowserLoginService.java @@ -0,0 +1,212 @@ +package com.example.keycloak; + +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import org.keycloak.OAuth2Constants; +import org.keycloak.common.ClientConnection; +import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.UriUtils; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.utils.OIDCResponseType; +import org.keycloak.protocol.oidc.utils.RedirectUtils; +import org.keycloak.services.Urls; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.CommonClientSessionModel; +import org.keycloak.sessions.RootAuthenticationSessionModel; + +import jakarta.ws.rs.core.Response; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +final class PasskeyBrowserLoginService { + + private static final String HEADER_ORIGIN = "Origin"; + private static final String HEADER_REFERER = "Referer"; + private static final String RESPONSE_MODE_QUERY = "query"; + + private final KeycloakSession session; + private final PasskeyClientSupport clientSupport; + + /** + * Creates a helper that continues the standard Keycloak browser login flow. + * + * @param session Keycloak request session + * @param clientSupport helper for resolving configured client + */ + PasskeyBrowserLoginService(KeycloakSession session, PasskeyClientSupport clientSupport) { + this.session = session; + this.clientSupport = clientSupport; + } + + /** + * Builds an authenticated browser flow response for a passkey-authenticated user. + * + * @param user authenticated user + * @param realm current realm + * @return Keycloak follow-up response from the authentication manager + */ + Response completeLogin(UserModel user, RealmModel realm) { + ClientModel client = clientSupport.requireConfiguredClient(realm); + String redirectUri = resolveValidatedRedirectUri(client); + if (redirectUri == null) { + throw new IllegalArgumentException("No valid redirect URI for client"); + } + + session.getContext().setClient(client); + AuthenticationSessionModel authenticationSession = createBrowserAuthenticationSession(realm, client, user, redirectUri); + session.getContext().setAuthenticationSession(authenticationSession); + + ClientConnection connection = session.getContext().getConnection(); + EventBuilder event = new EventBuilder(realm, session, connection) + .event(EventType.LOGIN) + .client(client) + .user(user); + return AuthenticationManager.nextActionAfterAuthentication( + session, + authenticationSession, + connection, + session.getContext().getHttpRequest(), + session.getContext().getUri(), + event + ); + } + + /** + * Creates and pre-populates an authentication session for OIDC browser continuation. + */ + private AuthenticationSessionModel createBrowserAuthenticationSession(RealmModel realm, ClientModel client, UserModel user, String redirectUri) { + AuthenticationSessionManager authenticationSessionManager = new AuthenticationSessionManager(session); + RootAuthenticationSessionModel rootAuthenticationSession = authenticationSessionManager.createAuthenticationSession(realm, true); + AuthenticationSessionModel authenticationSession = rootAuthenticationSession.createAuthenticationSession(client); + + authenticationSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + authenticationSession.setAction(CommonClientSessionModel.Action.AUTHENTICATE.name()); + authenticationSession.setAuthenticatedUser(user); + authenticationSession.setRedirectUri(redirectUri); + authenticationSession.setClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM, OIDCResponseType.NONE); + authenticationSession.setClientNote(OIDCLoginProtocol.RESPONSE_MODE_PARAM, RESPONSE_MODE_QUERY); + authenticationSession.setClientNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, redirectUri); + authenticationSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, resolveScopeParameter(client)); + AuthenticationManager.setClientScopesInSession(session, authenticationSession); + authenticationSession.setClientNote(OIDCLoginProtocol.STATE_PARAM, generateState()); + authenticationSession.setClientNote( + OIDCLoginProtocol.ISSUER, + Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()) + ); + return authenticationSession; + } + + /** + * Derives scope to place into the auth session; falls back to {@code openid}. + */ + private String resolveScopeParameter(ClientModel client) { + return PasskeyConfigResolver.firstNonBlank( + AuthenticationManager.getRequestedScopes(session, client), + OAuth2Constants.SCOPE_OPENID + ); + } + + /** + * Generates a random state value used by the browser flow. + */ + private String generateState() { + Challenge challenge = new DefaultChallenge(); + return Base64Url.encode(challenge.getValue()); + } + + /** + * Finds the first client-validated redirect URI from request hints and configured fallbacks. + */ + private String resolveValidatedRedirectUri(ClientModel client) { + List redirectCandidates = new ArrayList<>(); + redirectCandidates.add(normalizeRedirectCandidate(getHeaderValue(HEADER_REFERER), true)); + redirectCandidates.add(normalizeRedirectCandidate(getHeaderValue(HEADER_ORIGIN), false)); + redirectCandidates.add(normalizeRedirectCandidate(client.getBaseUrl(), true)); + + for (String redirectCandidate : redirectCandidates) { + if (redirectCandidate == null) { + continue; + } + String verifiedRedirectUri = RedirectUtils.verifyRedirectUri(session, redirectCandidate, client); + if (verifiedRedirectUri != null) { + return verifiedRedirectUri; + } + } + + String singleConfiguredRedirect = RedirectUtils.verifyRedirectUri(session, null, client, false); + if (singleConfiguredRedirect != null) { + return singleConfiguredRedirect; + } + + for (String configuredRedirectUri : client.getRedirectUris()) { + if (configuredRedirectUri == null || configuredRedirectUri.isBlank() || configuredRedirectUri.contains("*")) { + continue; + } + String verifiedRedirectUri = RedirectUtils.verifyRedirectUri(session, configuredRedirectUri, client); + if (verifiedRedirectUri != null) { + return verifiedRedirectUri; + } + } + + return null; + } + + /** + * Reads a header value from the current request. + */ + private String getHeaderValue(String headerName) { + var headers = session.getContext().getRequestHeaders(); + if (headers == null) { + return null; + } + return headers.getHeaderString(headerName); + } + + /** + * Normalizes a redirect candidate to a stable URI form before validation. + */ + private String normalizeRedirectCandidate(String rawUri, boolean preservePath) { + if (rawUri == null || rawUri.isBlank()) { + return null; + } + + try { + URI parsedUri = URI.create(rawUri.trim()); + if (parsedUri.getScheme() == null || parsedUri.getHost() == null) { + return null; + } + + if (!preservePath) { + return UriUtils.getOrigin(parsedUri); + } + + String path = parsedUri.getPath(); + if (path == null || path.isBlank()) { + path = "/"; + } + + URI normalizedUri = new URI( + parsedUri.getScheme(), + parsedUri.getUserInfo(), + parsedUri.getHost(), + parsedUri.getPort(), + path, + null, + null + ); + return normalizedUri.toString(); + } catch (Exception ignored) { + return null; + } + } + +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyChallengeService.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyChallengeService.java new file mode 100644 index 00000000..996ad796 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyChallengeService.java @@ -0,0 +1,165 @@ +package com.example.keycloak; + +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import org.keycloak.common.util.Base64Url; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.RootAuthenticationSessionModel; + +import java.util.Objects; + +final class PasskeyChallengeService { + + private static final long CHALLENGE_TTL_MILLIS = 120_000; + private static final String AUTH_NOTE_PASSKEY_CHALLENGE = "passkey.challenge"; + private static final String AUTH_NOTE_PASSKEY_CHALLENGE_ISSUED_AT = "passkey.challenge.issuedAt"; + + private final KeycloakSession session; + private final PasskeyClientSupport clientSupport; + + /** + * Creates a challenge service bound to the current request session. + * + * @param session Keycloak request session + * @param clientSupport helper for resolving configured client + */ + PasskeyChallengeService(KeycloakSession session, PasskeyClientSupport clientSupport) { + this.session = session; + this.clientSupport = clientSupport; + } + + /** + * Generates and stores a single-use passkey challenge in authentication-session notes. + * + * @return base64url encoded challenge value + */ + String issueChallenge() { + RealmModel realm = requireRealm(); + ClientModel client = clientSupport.requireConfiguredClient(realm); + AuthenticationSessionModel challengeSession = getOrCreateChallengeSession(realm, client); + String challenge = generateChallenge(); + challengeSession.setAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE, challenge); + challengeSession.setAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE_ISSUED_AT, Long.toString(System.currentTimeMillis())); + return challenge; + } + + /** + * Consumes a challenge if it matches and is still within the configured TTL. + * + * @param challenge challenge sent by client + * @return {@code true} when challenge exists, matches, and is not expired + */ + boolean consumeChallenge(String challenge) { + if (challenge == null || challenge.isBlank()) { + return false; + } + + RealmModel realm = session.getContext().getRealm(); + if (realm == null) { + return false; + } + + ClientModel client = clientSupport.resolveConfiguredClient(realm); + if (client == null) { + return false; + } + + AuthenticationSessionModel challengeSession = resolveChallengeSession(realm, client); + if (challengeSession == null) { + return false; + } + + String trackedChallenge = challengeSession.getAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE); + if (!Objects.equals(challenge, trackedChallenge)) { + return false; + } + + challengeSession.removeAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE); + String issuedAtRaw = challengeSession.getAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE_ISSUED_AT); + challengeSession.removeAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE_ISSUED_AT); + return isWithinTtl(issuedAtRaw); + } + + /** + * Returns the current realm or throws when realm context is missing. + */ + private RealmModel requireRealm() { + RealmModel realm = session.getContext().getRealm(); + if (realm == null) { + throw new IllegalStateException("Realm context is unavailable"); + } + return realm; + } + + /** + * Verifies whether the stored issue timestamp is still valid. + */ + private boolean isWithinTtl(String issuedAtRaw) { + try { + long issuedAt = Long.parseLong(PasskeyConfigResolver.firstNonBlank(issuedAtRaw, "0")); + return issuedAt > 0 && (System.currentTimeMillis() - issuedAt) <= CHALLENGE_TTL_MILLIS; + } catch (NumberFormatException ignored) { + return false; + } + } + + /** + * Produces a random base64url challenge string. + */ + private String generateChallenge() { + Challenge challenge = new DefaultChallenge(); + return Base64Url.encode(challenge.getValue()); + } + + /** + * Returns an existing challenge session for the client, or creates a new one. + */ + private AuthenticationSessionModel getOrCreateChallengeSession(RealmModel realm, ClientModel client) { + AuthenticationSessionModel existingSession = resolveChallengeSession(realm, client); + if (existingSession != null) { + return existingSession; + } + + AuthenticationSessionManager authenticationSessionManager = new AuthenticationSessionManager(session); + RootAuthenticationSessionModel rootAuthenticationSession = authenticationSessionManager.createAuthenticationSession(realm, true); + AuthenticationSessionModel authenticationSession = rootAuthenticationSession.createAuthenticationSession(client); + authenticationSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + return authenticationSession; + } + + /** + * Resolves the challenge session associated with the configured client. + */ + private AuthenticationSessionModel resolveChallengeSession(RealmModel realm, ClientModel client) { + AuthenticationSessionModel contextAuthenticationSession = session.getContext().getAuthenticationSession(); + if (isSessionForClient(contextAuthenticationSession, client)) { + return contextAuthenticationSession; + } + + AuthenticationSessionManager authenticationSessionManager = new AuthenticationSessionManager(session); + RootAuthenticationSessionModel rootAuthenticationSession = authenticationSessionManager.getCurrentRootAuthenticationSession(realm); + if (rootAuthenticationSession == null) { + return null; + } + + return rootAuthenticationSession.getAuthenticationSessions().values().stream() + .filter(authenticationSession -> isSessionForClient(authenticationSession, client)) + .findFirst() + .orElse(null); + } + + /** + * Checks whether an authentication session belongs to the given client. + */ + private boolean isSessionForClient(AuthenticationSessionModel authenticationSession, ClientModel client) { + return authenticationSession != null + && client != null + && authenticationSession.getClient() != null + && Objects.equals(authenticationSession.getClient().getId(), client.getId()); + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyClientSupport.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyClientSupport.java new file mode 100644 index 00000000..6203cbf7 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyClientSupport.java @@ -0,0 +1,45 @@ +package com.example.keycloak; + +import org.keycloak.models.ClientModel; +import org.keycloak.models.RealmModel; + +final class PasskeyClientSupport { + + private final String clientId; + + /** + * Creates a helper for resolving the configured passkey client. + * + * @param clientId configured client identifier + */ + PasskeyClientSupport(String clientId) { + this.clientId = clientId; + } + + /** + * Resolves the configured client in the given realm. + * + * @param realm current realm + * @return matching client, or {@code null} when configuration/context is incomplete + */ + ClientModel resolveConfiguredClient(RealmModel realm) { + if (realm == null || clientId == null || clientId.isBlank()) { + return null; + } + return realm.getClientByClientId(clientId); + } + + /** + * Resolves the configured client and throws when it cannot be found. + * + * @param realm current realm + * @return configured client model + */ + ClientModel requireConfiguredClient(RealmModel realm) { + ClientModel client = resolveConfiguredClient(realm); + if (client == null) { + throw new IllegalStateException("Client not found for configured passkey client_id"); + } + return client; + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java index 266462b1..ca7ed104 100644 --- a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java @@ -4,8 +4,8 @@ final class PasskeyConfigResolver { - private static final String DEFAULT_ALLOWED_ORIGIN_PATTERN = "https?://(localhost|127\\\\.0\\\\.0\\\\.1|\\\\[::1\\\\])(:\\\\d+)?$"; - private static final String DEFAULT_CLIENT_ID = "module-management"; + private static final String DEFAULT_ALLOWED_ORIGIN = "http://localhost:3000"; + private static final String DEFAULT_CLIENT_ID = "demo-app"; private static final String ENV_ALLOWED_BROWSER_ORIGIN = "KC_ALLOWED_BROWSER_ORIGIN"; private static final String ENV_DEMO_ADDITIONAL_WEB_ORIGIN = "KC_DEMO_ADDITIONAL_WEB_ORIGIN"; private static final String ENV_PASSKEY_CLIENT_ID = "KC_PASSKEY_CLIENT_ID"; @@ -18,7 +18,7 @@ static String resolveAllowedOriginPattern(Config.Scope config) { config == null ? null : config.get("allowed-browser-origin"), System.getenv(ENV_ALLOWED_BROWSER_ORIGIN), System.getenv(ENV_DEMO_ADDITIONAL_WEB_ORIGIN), - DEFAULT_ALLOWED_ORIGIN_PATTERN + DEFAULT_ALLOWED_ORIGIN ); } @@ -34,7 +34,7 @@ static String resolveAllowedOriginPatternFromEnv() { return firstNonBlank( System.getenv(ENV_ALLOWED_BROWSER_ORIGIN), System.getenv(ENV_DEMO_ADDITIONAL_WEB_ORIGIN), - DEFAULT_ALLOWED_ORIGIN_PATTERN + DEFAULT_ALLOWED_ORIGIN ); } diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java new file mode 100644 index 00000000..dd73d720 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java @@ -0,0 +1,382 @@ +package com.example.keycloak; + +import com.webauthn4j.WebAuthnRegistrationManager; +import com.webauthn4j.converter.exception.DataConversionException; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.AuthenticationRequest; +import com.webauthn4j.data.RegistrationData; +import com.webauthn4j.data.RegistrationParameters; +import com.webauthn4j.data.RegistrationRequest; +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import com.webauthn4j.server.ServerProperty; +import com.webauthn4j.verifier.exception.VerificationException; +import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.UriUtils; +import org.keycloak.credential.CredentialModel; +import org.keycloak.credential.CredentialProvider; +import org.keycloak.credential.WebAuthnCredentialModelInput; +import org.keycloak.credential.WebAuthnCredentialProvider; +import org.keycloak.credential.WebAuthnPasswordlessCredentialProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.WebAuthnPolicy; +import org.keycloak.models.credential.WebAuthnCredentialModel; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +final class PasskeyWebAuthnService { + + private static final String PASSKEY_TYPE = WebAuthnCredentialModel.TYPE_PASSWORDLESS; + private static final String CREDENTIAL_USER_ATTR = "passkey-credential-id"; + private static final String HEADER_ORIGIN = "Origin"; + + private final KeycloakSession session; + private final Pattern allowedBrowserOrigin; + + /** + * Creates WebAuthn helper logic bound to the current request. + * + * @param session Keycloak request session + * @param allowedBrowserOrigin compiled allowed browser origin pattern + */ + PasskeyWebAuthnService(KeycloakSession session, Pattern allowedBrowserOrigin) { + this.session = session; + this.allowedBrowserOrigin = allowedBrowserOrigin; + } + + /** + * Resolves credential identifier from request fields. + * + * @param request passkey request payload + * @return credential id, preferring {@code credentialId} over {@code rawId} + */ + String resolveCredentialId(PasskeyRequest request) { + return PasskeyConfigResolver.firstNonBlank(request.getCredentialId(), request.getRawId()); + } + + /** + * Finds a user by normalized credential id mapping stored as user attribute. + * + * @param realm current realm + * @param credentialId credential id from client + * @return matching user or {@code null} when none exists + */ + UserModel findUserByCredentialId(RealmModel realm, String credentialId) { + String normalizedCredentialId = normalizeCredentialId(credentialId); + if (normalizedCredentialId == null) { + return null; + } + + return session.users() + .searchForUserByUserAttributeStream(realm, CREDENTIAL_USER_ATTR, normalizedCredentialId) + .findFirst() + .orElse(null); + } + + /** + * Checks whether the user has a stored passwordless credential matching the requested id. + * + * @param user target user + * @param credentialId credential id from client + * @return {@code true} when a matching passkey credential exists + */ + boolean hasPasskeyCredential(UserModel user, String credentialId) { + byte[] requestedCredentialId = credentialIdToBytes(credentialId); + if (requestedCredentialId.length == 0) { + return false; + } + + return user.credentialManager() + .getStoredCredentialsByTypeStream(PASSKEY_TYPE) + .map(WebAuthnCredentialModel::createFromCredentialModel) + .map(WebAuthnCredentialModel::getWebAuthnCredentialData) + .filter(Objects::nonNull) + .map(data -> data.getCredentialId()) + .filter(Objects::nonNull) + .map(this::credentialIdToBytes) + .anyMatch(storedCredentialId -> Arrays.equals(storedCredentialId, requestedCredentialId)); + } + + /** + * Validates and stores a new passkey credential through Keycloak's credential provider. + * + * @param user target user + * @param request registration payload + * @param expectedChallenge challenge issued by this service + */ + void registerPasskey(UserModel user, PasskeyRequest request, String expectedChallenge) { + RealmModel realm = requireRealm(); + RegistrationRequest registrationRequest = new RegistrationRequest( + decodeRequiredBase64Url(request.getAttestationObject(), "attestationObject"), + decodeRequiredBase64Url(request.getClientDataJSON(), "clientDataJSON") + ); + RegistrationParameters registrationParameters = new RegistrationParameters( + buildServerProperty(realm, expectedChallenge), + isUserVerificationRequired(realm) + ); + + RegistrationData registrationData = validateRegistration(registrationRequest, registrationParameters); + WebAuthnCredentialProvider provider = getPasswordlessCredentialProvider(); + WebAuthnCredentialModelInput credentialInput = createCredentialInput(registrationData); + WebAuthnCredentialModel credentialModel = provider.getCredentialModelFromCredentialInput(credentialInput, user.getUsername()); + CredentialModel storedCredentialModel = user.credentialManager().createStoredCredential(credentialModel); + if (storedCredentialModel == null) { + throw new IllegalStateException("Failed to store passkey credential"); + } + WebAuthnCredentialModel storedCredential = WebAuthnCredentialModel.createFromCredentialModel(storedCredentialModel); + if (storedCredential.getWebAuthnCredentialData() != null) { + storeCredentialUserMapping(user, storedCredential.getWebAuthnCredentialData().getCredentialId()); + } + } + + /** + * Validates a WebAuthn assertion using Keycloak credential-manager validation. + * + * @param user target user + * @param request authentication payload + * @param credentialId resolved credential id + * @return {@code true} when assertion is valid + */ + boolean authenticatePasskey(UserModel user, PasskeyRequest request, String credentialId) { + RealmModel realm = requireRealm(); + AuthenticationRequest authenticationRequest = new AuthenticationRequest( + decodeRequiredBase64Url(credentialId, "credentialId"), + decodeRequiredBase64Url(request.getAuthenticatorData(), "authenticatorData"), + decodeRequiredBase64Url(request.getClientDataJSON(), "clientDataJSON"), + decodeRequiredBase64Url(request.getSignature(), "signature") + ); + + WebAuthnCredentialModelInput credentialInput = new WebAuthnCredentialModelInput(PASSKEY_TYPE); + credentialInput.setAuthenticationRequest(authenticationRequest); + credentialInput.setAuthenticationParameters( + new WebAuthnCredentialModelInput.KeycloakWebAuthnAuthenticationParameters( + buildServerProperty(realm, request.getChallenge()), + isUserVerificationRequired(realm) + ) + ); + + return user.credentialManager().isValid(credentialInput); + } + + /** + * Builds the registration manager used to validate attestation payloads. + */ + protected WebAuthnRegistrationManager createWebAuthnRegistrationManager() { + return WebAuthnRegistrationManager.createNonStrictWebAuthnRegistrationManager(new ObjectConverter()); + } + + /** + * Parses and validates registration payload data. + */ + private RegistrationData validateRegistration(RegistrationRequest request, RegistrationParameters parameters) { + try { + return createWebAuthnRegistrationManager().verify(request, parameters); + } catch (DataConversionException | VerificationException e) { + throw new IllegalArgumentException("Passkey registration validation failed: " + e.getMessage(), e); + } + } + + /** + * Maps parsed registration data into Keycloak's credential input model. + */ + private WebAuthnCredentialModelInput createCredentialInput(RegistrationData registrationData) { + WebAuthnCredentialModelInput credentialInput = new WebAuthnCredentialModelInput(PASSKEY_TYPE); + credentialInput.setAttestedCredentialData(registrationData.getAttestationObject().getAuthenticatorData().getAttestedCredentialData()); + credentialInput.setCount(registrationData.getAttestationObject().getAuthenticatorData().getSignCount()); + credentialInput.setAttestationStatementFormat(registrationData.getAttestationObject().getFormat()); + credentialInput.setTransports(registrationData.getTransports()); + return credentialInput; + } + + /** + * Resolves the passwordless WebAuthn credential provider. + */ + private WebAuthnCredentialProvider getPasswordlessCredentialProvider() { + WebAuthnCredentialProvider provider = (WebAuthnCredentialProvider) session.getProvider( + CredentialProvider.class, + WebAuthnPasswordlessCredentialProviderFactory.PROVIDER_ID + ); + if (provider == null) { + throw new IllegalStateException("Passwordless WebAuthn credential provider is unavailable"); + } + return provider; + } + + /** + * Constructs server property used by WebAuthn registration/authentication validation. + */ + private ServerProperty buildServerProperty(RealmModel realm, String challenge) { + if (challenge == null || challenge.isBlank()) { + throw new IllegalArgumentException("challenge is required"); + } + return new ServerProperty( + resolveAllowedOrigins(realm), + resolveRequiredRpId(realm), + new DefaultChallenge(challenge), + null + ); + } + + /** + * Resolves accepted origins from request origin and passwordless extra-origin policy entries. + */ + private Set resolveAllowedOrigins(RealmModel realm) { + WebAuthnPolicy policy = requirePasswordlessPolicy(realm); + Set origins = new HashSet<>(); + origins.add(new Origin(requireAllowedOrigin())); + + List extraOrigins = policy.getExtraOrigins(); + if (extraOrigins == null) { + return origins; + } + + for (String extraOrigin : extraOrigins) { + if (extraOrigin == null || extraOrigin.isBlank()) { + continue; + } + String normalizedExtraOrigin = normalizeOrigin(extraOrigin.trim()); + origins.add(new Origin(normalizedExtraOrigin)); + } + return origins; + } + + /** + * Extracts and validates the request {@code Origin} header against configured regex. + */ + private String requireAllowedOrigin() { + var headers = session.getContext().getRequestHeaders(); + String originHeader = headers == null ? null : headers.getHeaderString(HEADER_ORIGIN); + if (originHeader == null || originHeader.isBlank()) { + throw new IllegalArgumentException("Origin header is required"); + } + + String origin = normalizeOrigin(originHeader.trim()); + if (!allowedBrowserOrigin.matcher(origin).matches()) { + throw new IllegalArgumentException("Origin is not allowed"); + } + return origin; + } + + /** + * Normalizes and validates origin representation. + */ + private String normalizeOrigin(String candidateOrigin) { + try { + String origin = UriUtils.getOrigin(candidateOrigin); + if (origin == null || !UriUtils.isOrigin(origin)) { + throw new IllegalArgumentException("Invalid origin: " + candidateOrigin); + } + return origin; + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid origin: " + candidateOrigin, e); + } + } + + /** + * Resolves RP ID from passwordless policy, falling back to base URI host. + */ + private String resolveRequiredRpId(RealmModel realm) { + WebAuthnPolicy policy = requirePasswordlessPolicy(realm); + String fallbackRpId = session.getContext().getUri() == null || session.getContext().getUri().getBaseUri() == null + ? null + : session.getContext().getUri().getBaseUri().getHost(); + String rpId = PasskeyConfigResolver.firstNonBlank(policy.getRpId(), fallbackRpId); + if (rpId == null || rpId.isBlank()) { + throw new IllegalStateException("Passwordless WebAuthn RP ID is not configured"); + } + return rpId.trim(); + } + + /** + * Determines whether user verification is mandatory for this realm policy. + */ + private boolean isUserVerificationRequired(RealmModel realm) { + String uvRequirement = requirePasswordlessPolicy(realm).getUserVerificationRequirement(); + return uvRequirement != null && "required".equalsIgnoreCase(uvRequirement); + } + + /** + * Returns passwordless WebAuthn policy or throws when absent. + */ + private WebAuthnPolicy requirePasswordlessPolicy(RealmModel realm) { + if (realm == null || realm.getWebAuthnPolicyPasswordless() == null) { + throw new IllegalStateException("Passwordless WebAuthn policy is not configured"); + } + return realm.getWebAuthnPolicyPasswordless(); + } + + /** + * Decodes a required base64url field from client payload. + */ + private byte[] decodeRequiredBase64Url(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + try { + return Base64Url.decode(value); + } catch (RuntimeException e) { + throw new IllegalArgumentException(field + " must be a valid base64url value", e); + } + } + + /** + * Converts arbitrary credential id input into canonical base64url form. + */ + private String normalizeCredentialId(String credentialId) { + byte[] credentialBytes = credentialIdToBytes(credentialId); + if (credentialBytes.length == 0) { + return null; + } + return Base64Url.encode(credentialBytes); + } + + /** + * Decodes credential id into bytes, returning empty bytes on invalid input. + */ + private byte[] credentialIdToBytes(String credentialId) { + if (credentialId == null || credentialId.isBlank()) { + return new byte[0]; + } + + try { + return Base64Url.decode(credentialId); + } catch (RuntimeException ignored) { + return new byte[0]; + } + } + + /** + * Returns the current realm or throws when request context has none. + */ + private RealmModel requireRealm() { + RealmModel realm = session.getContext().getRealm(); + if (realm == null) { + throw new IllegalStateException("Realm context is unavailable"); + } + return realm; + } + + /** + * Persists credential-to-user mapping used by direct credential-id lookup. + */ + private void storeCredentialUserMapping(UserModel user, String credentialId) { + String normalizedCredentialId = normalizeCredentialId(credentialId); + if (normalizedCredentialId == null) { + return; + } + + List values = new ArrayList<>(user.getAttributeStream(CREDENTIAL_USER_ATTR).toList()); + if (!values.contains(normalizedCredentialId)) { + values.add(normalizedCredentialId); + user.setAttribute(CREDENTIAL_USER_ATTR, values); + } + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java index 6be4ea9c..339b36b4 100644 --- a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java @@ -1,93 +1,60 @@ package com.example.keycloak; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.webauthn4j.WebAuthnRegistrationManager; -import com.webauthn4j.converter.util.ObjectConverter; -import com.webauthn4j.data.AuthenticationRequest; -import com.webauthn4j.data.RegistrationData; -import com.webauthn4j.data.RegistrationParameters; -import com.webauthn4j.data.RegistrationRequest; -import com.webauthn4j.data.client.Origin; -import com.webauthn4j.data.client.challenge.Challenge; -import com.webauthn4j.data.client.challenge.DefaultChallenge; -import com.webauthn4j.server.ServerProperty; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; -import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.OPTIONS; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.jboss.logging.Logger; -import org.keycloak.OAuth2Constants; -import org.keycloak.common.ClientConnection; -import org.keycloak.common.util.Base64Url; -import org.keycloak.common.util.Time; -import org.keycloak.credential.CredentialProvider; -import org.keycloak.credential.WebAuthnCredentialModelInput; -import org.keycloak.credential.WebAuthnCredentialProvider; -import org.keycloak.credential.WebAuthnPasswordlessCredentialProviderFactory; -import org.keycloak.events.EventBuilder; -import org.keycloak.events.EventType; -import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; -import org.keycloak.models.ClientSessionContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; -import org.keycloak.models.SingleUseObjectProvider; import org.keycloak.models.UserModel; -import org.keycloak.models.UserSessionModel; -import org.keycloak.models.credential.WebAuthnCredentialModel; -import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.protocol.oidc.TokenManager; -import org.keycloak.representations.AccessToken; -import org.keycloak.services.Urls; +import org.keycloak.services.ErrorResponse; +import org.keycloak.services.ErrorResponseException; +import org.keycloak.services.cors.Cors; +import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AuthenticationManager; -import org.keycloak.services.util.DefaultClientSessionContext; - -import java.lang.reflect.InvocationTargetException; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.Collections; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.List; + import java.util.Map; -import java.util.Objects; -import java.util.Set; import java.util.regex.Pattern; @Path("/") public class UserPasskeyResource { - private final KeycloakSession session; private static final Logger logger = Logger.getLogger(UserPasskeyResource.class); - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final String PASSKEY_TYPE = WebAuthnCredentialModel.TYPE_PASSWORDLESS; - private static final long CHALLENGE_TTL_SECONDS = 120; - private static final long CHALLENGE_TTL_MILLIS = CHALLENGE_TTL_SECONDS * 1000; - private static final String CHALLENGE_KEY_PREFIX = "passkey:challenge:"; - private static final String CREDENTIAL_USER_ATTR = "passkey-credential-id"; - private static final String HEADER_ORIGIN = "Origin"; + private static final String ERROR_SERVER_CONFIGURATION = "Server configuration error"; + private static final String ERROR_INVALID_OR_EXPIRED_CHALLENGE = "Invalid or expired challenge"; + private static final String ERROR_REQUEST_BODY_REQUIRED = "Request body is required"; + + private final KeycloakSession session; private final Pattern allowedBrowserOrigin; private final String clientId; + private final PasskeyClientSupport clientSupport; - // Needed so CDI can construct this class when bean-discovery-mode="all" is used. + /** + * Default constructor for CDI environments that require a no-arg constructor. + *

+ * The SPI-managed constructor should be used for normal runtime operation. + */ public UserPasskeyResource() { this.session = null; this.allowedBrowserOrigin = Pattern.compile("^" + PasskeyConfigResolver.resolveAllowedOriginPatternFromEnv() + "$"); this.clientId = PasskeyConfigResolver.resolveClientIdFromEnv(); + this.clientSupport = new PasskeyClientSupport(this.clientId); } + /** + * Creates the passkey resource with SPI-provided session and resolved configuration. + * + * @param session Keycloak request session + * @param allowedOriginPattern configured allowed browser origin regex (without anchors) + * @param clientId configured OIDC client identifier used for passkey flows + */ public UserPasskeyResource(KeycloakSession session, String allowedOriginPattern, String clientId) { this.session = session; String configuredPattern = PasskeyConfigResolver.firstNonBlank( @@ -99,640 +66,273 @@ public UserPasskeyResource(KeycloakSession session, String allowedOriginPattern, clientId, PasskeyConfigResolver.resolveClientIdFromEnv() ); + this.clientSupport = new PasskeyClientSupport(this.clientId); } - private KeycloakSession session() { - if (session == null) { - throw new IllegalStateException("UserPasskeyResource must be created by UserPasskeyProvider (SPI-managed session required)."); - } - return session; - } - - // ----- CORS ----- - private String getOriginHeader() { - var headers = session().getContext().getRequestHeaders(); - if (headers == null) { - return null; - } - return headers.getHeaderString(HEADER_ORIGIN); - } - - private boolean isAllowedOrigin(String origin) { - return origin != null && allowedBrowserOrigin.matcher(origin.trim()).matches(); - } - - private Response withCors(Response response) { - String origin = getOriginHeader(); - if (origin == null || !isAllowedOrigin(origin)) { - return response; - } - return Response.fromResponse(response) - .header("Access-Control-Allow-Origin", origin) - .header("Access-Control-Allow-Credentials", "true") - .header("Vary", "Origin") - .build(); - } - + /** + * Handles browser CORS preflight requests for all passkey endpoints. + * + * @return preflight response with CORS headers when client configuration is available + */ @OPTIONS @Path("{any:.*}") public Response corsPreflight() { - Response.ResponseBuilder b = Response.ok() - .header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") - .header("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept") - .header("Access-Control-Max-Age", "3600"); - return withCors(b.build()); + Response.ResponseBuilder responseBuilder = Response.ok(); + applyCors(responseBuilder, true); + return responseBuilder.build(); } - // ----- Endpoints ----- + /** + * Issues a short-lived challenge used by registration and authentication requests. + * + * @return JSON object containing a base64url challenge value + */ @GET @Path("challenge") @Produces(MediaType.APPLICATION_JSON) public Response getChallenge() { - return jsonOk(Map.of("challenge", issueChallenge())); - } - - @GET - @Path("get-credential-id") - @Produces(MediaType.APPLICATION_JSON) - public Response getCredentialId(@QueryParam("username") String username) { - String challengeBase64 = issueChallenge(); - - if (username == null || username.isBlank()) { - return jsonOk(Map.of("challenge", challengeBase64)); - } - - UserModel user = findUserByUsername(username); - - if (user == null) { - return buildErrorResponse(Response.Status.NOT_FOUND, "User not found"); - } - - WebAuthnCredentialModel credentialModel = findFirstPasswordlessCredential(user); - if (credentialModel == null) { - return buildErrorResponse(Response.Status.NOT_FOUND, "No passkey found for user"); + try { + return jsonOk(Map.of("challenge", challengeService().issueChallenge())); + } catch (IllegalStateException e) { + return handleServerConfigurationError("Passkey challenge creation failed due to server configuration", e); } - String credentialIdBase64 = credentialModel.getWebAuthnCredentialData().getCredentialId(); - return jsonOk(Map.of( - "credentialId", credentialIdBase64, - "challenge", challengeBase64 - )); } + /** + * Stores a new passwordless WebAuthn credential for the authenticated bearer token user. + * + * @param request passkey registration payload + * @return created response on success, error response otherwise + */ @POST @Path("save") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - public Response savePasskey(PasskeyRequest request, @HeaderParam("Authorization") String authorizationHeader) { - UserModel user = getUserFromBearerToken(authorizationHeader); + public Response savePasskey(PasskeyRequest request) { + if (request == null) { + return buildErrorResponse(Response.Status.BAD_REQUEST, ERROR_REQUEST_BODY_REQUIRED); + } + + UserModel user = getUserFromBearerToken(); if (user == null) { return textResponse(Response.Status.UNAUTHORIZED, "Authenticated user not found from access token"); } + if (!challengeService().consumeChallenge(request.getChallenge())) { + return buildErrorResponse(Response.Status.UNAUTHORIZED, ERROR_INVALID_OR_EXPIRED_CHALLENGE); + } + try { - registerPasskey(user, request); + webAuthnService().registerPasskey(user, request, request.getChallenge()); return textResponse(Response.Status.CREATED, "Passkey stored successfully"); - } catch (IllegalArgumentException | JsonProcessingException e) { + } catch (IllegalArgumentException e) { return buildErrorResponse(Response.Status.BAD_REQUEST, "Invalid registration payload: " + e.getMessage()); + } catch (IllegalStateException e) { + return handleServerConfigurationError("Passkey registration failed due to server configuration", e); } } - // Uses bearer access token subject as the owner of the passkey registration. - private UserModel getUserFromBearerToken(String authorizationHeader) { - if (authorizationHeader == null || authorizationHeader.isBlank()) { - return null; - } - if (!authorizationHeader.regionMatches(true, 0, "Bearer ", 0, 7)) { - return null; - } - - String token = authorizationHeader.substring(7).trim(); - if (token.isEmpty()) { - return null; - } - - AccessToken accessToken = session().tokens().decode(token, AccessToken.class); - if (accessToken == null || accessToken.getSubject() == null || accessToken.getSubject().isBlank()) { - return null; - } - - RealmModel realm = session().getContext().getRealm(); - return session().users().getUserById(realm, accessToken.getSubject()); - } - + /** + * Verifies a passkey assertion and continues the regular Keycloak browser login flow. + * + * @param request passkey authentication payload + * @return browser flow response on success, error response otherwise + */ @POST @Path("authenticate") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Response authenticatePasskey(PasskeyRequest request) { + if (request == null) { + return buildErrorResponse(Response.Status.BAD_REQUEST, ERROR_REQUEST_BODY_REQUIRED); + } + RealmModel realm = session().getContext().getRealm(); + if (realm == null) { + return handleServerConfigurationError("Realm context unavailable for passkey authentication", new IllegalStateException("Realm context is unavailable")); + } - String requestCredentialId = resolveCredentialId(request); + String requestCredentialId = webAuthnService().resolveCredentialId(request); if (requestCredentialId == null) { return buildErrorResponse(Response.Status.BAD_REQUEST, "credentialId or rawId is required"); } - if (!consumeChallenge(request.getChallenge())) { - return buildErrorResponse(Response.Status.UNAUTHORIZED, "Invalid or expired challenge"); + if (!challengeService().consumeChallenge(request.getChallenge())) { + return buildErrorResponse(Response.Status.UNAUTHORIZED, ERROR_INVALID_OR_EXPIRED_CHALLENGE); } - UserModel user = getUserByCredentialId(realm, requestCredentialId); - + UserModel user = webAuthnService().findUserByCredentialId(realm, requestCredentialId); if (user == null) { - return buildErrorResponse( - Response.Status.NOT_FOUND, - "User not found for credential (username: )" - ); + return buildErrorResponse(Response.Status.NOT_FOUND, "User not found for credential"); } - WebAuthnCredentialModel webAuthnCredential = getWebAuthnCredential(user, requestCredentialId); - if (webAuthnCredential == null) { + if (!webAuthnService().hasPasskeyCredential(user, requestCredentialId)) { return buildErrorResponse(Response.Status.NOT_FOUND, "No passkey found for user: " + user.getUsername()); } try { - boolean isValid = isPasskeyValid(request, requestCredentialId, user, realm); - if (isValid) { - return generateTokensResponse(user); + if (!webAuthnService().authenticatePasskey(user, request, requestCredentialId)) { + return buildErrorResponse(Response.Status.UNAUTHORIZED, "Invalid passkey"); } - return buildErrorResponse(Response.Status.UNAUTHORIZED, "Invalid passkey"); - } catch (IllegalArgumentException | JsonProcessingException e) { + + return withCors(browserLoginService().completeLogin(user, realm)); + } catch (IllegalArgumentException e) { return buildErrorResponse(Response.Status.BAD_REQUEST, "Invalid authentication payload: " + e.getMessage()); + } catch (IllegalStateException e) { + return handleServerConfigurationError("Passkey authentication failed due to server configuration", e); + } catch (Exception e) { + logger.error("Browser-flow completion after passkey authentication failed: " + e.getMessage(), e); + return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Authentication flow failed"); } } - // ----- Passkey domain logic ----- - // Registration flow: parse browser payload, verify attestation, and store passkey metadata. - private void registerPasskey(UserModel user, PasskeyRequest request) throws JsonProcessingException { - ClientDataContext clientDataContext = parseClientData(request.getClientDataJSON(), null); - RegistrationRequest registrationRequest = buildRegistrationRequest(request, clientDataContext); - RegistrationParameters registrationParameters = buildRegistrationParameters(clientDataContext); - - WebAuthnRegistrationManager manager = createWebAuthnRegistrationManager(); - RegistrationData registrationData = manager.parse(registrationRequest); - validateRegistrationCompat(manager, registrationRequest, registrationData, registrationParameters); - - WebAuthnCredentialModel credentialModel = createCredentialModelFromRegistration(user, registrationData); - user.credentialManager().createStoredCredential(credentialModel); - storeCredentialUserMapping(user, credentialModel.getWebAuthnCredentialData().getCredentialId()); - } - - private RegistrationRequest buildRegistrationRequest(PasskeyRequest request, ClientDataContext clientDataContext) { - byte[] attestationObject = decodeBase64UrlOrStd(request.getAttestationObject()); - return new RegistrationRequest(attestationObject, clientDataContext.decodedClientDataBytes()); - } - - private RegistrationParameters buildRegistrationParameters(ClientDataContext clientDataContext) { - Set originSet = new HashSet<>(); - originSet.add(clientDataContext.origin()); - ServerProperty serverProperty = new ServerProperty(originSet, clientDataContext.rpId(), clientDataContext.challenge(), null); - return new RegistrationParameters(serverProperty, true); - } - - private WebAuthnCredentialModel createCredentialModelFromRegistration(UserModel user, RegistrationData registrationData) { - WebAuthnCredentialModelInput credentialInput = new WebAuthnCredentialModelInput(WebAuthnCredentialModel.TYPE_PASSWORDLESS); - credentialInput.setAttestedCredentialData(registrationData.getAttestationObject().getAuthenticatorData().getAttestedCredentialData()); - credentialInput.setCount(registrationData.getAttestationObject().getAuthenticatorData().getSignCount()); - credentialInput.setAttestationStatementFormat(registrationData.getAttestationObject().getFormat()); - credentialInput.setTransports(registrationData.getTransports()); - - WebAuthnCredentialProvider provider = (WebAuthnCredentialProvider) session() - .getProvider(CredentialProvider.class, WebAuthnPasswordlessCredentialProviderFactory.PROVIDER_ID); - WebAuthnCredentialModel rawModel = provider.getCredentialModelFromCredentialInput(credentialInput, user.getUsername()); - return WebAuthnCredentialModel.createFromCredentialModel(rawModel); - } - - private boolean isPasskeyValid(PasskeyRequest request, String requestCredentialId, UserModel user, RealmModel realm) throws JsonProcessingException { - byte[] credentialId = decodeBase64UrlOrStd(requestCredentialId); - byte[] authenticatorData = decodeBase64UrlOrStd(request.getAuthenticatorData()); - byte[] signature = Base64Url.decode(request.getSignature()); - String challenge = request.getChallenge(); - return isPasskeyValid(credentialId, authenticatorData, request.getClientDataJSON(), signature, challenge, user, realm); - } - - private String resolveCredentialId(PasskeyRequest request) { - return PasskeyConfigResolver.firstNonBlank(request.getCredentialId(), request.getRawId()); - } - - private String issueChallenge() { - String challenge = generateChallenge(); - trackChallenge(challenge); - return challenge; - } - - private UserModel findUserByUsername(String username) { + /** + * Resolves the current user from the bearer token in request headers using Keycloak's authenticator. + * + * @return authenticated user, or {@code null} when token validation fails + */ + private UserModel getUserFromBearerToken() { RealmModel realm = session().getContext().getRealm(); - return session().users().getUserByUsername(realm, username); - } - - private WebAuthnCredentialModel findFirstPasswordlessCredential(UserModel user) { - return user.credentialManager() - .getStoredCredentialsStream() - .filter(cred -> PASSKEY_TYPE.equals(cred.getType())) - .findFirst() - .map(WebAuthnCredentialModel::createFromCredentialModel) - .orElse(null); - } - - // ----- User/Credential lookup ----- - private UserModel getUserByCredentialId(RealmModel realm, String credentialId) { - String normalizedCredentialId = normalizeCredentialId(credentialId); - if (normalizedCredentialId == null) { - return null; - } - - UserModel mappedUser = session().users() - .searchForUserByUserAttributeStream(realm, CREDENTIAL_USER_ATTR, normalizedCredentialId) - .findFirst() - .orElse(null); - if (mappedUser != null) { - return mappedUser; - } - - // Fallback scan for environments where user-attribute lookup does not return results reliably. - int first = 0; - int maxResults = 200; - - while (true) { - List users = getUsersPage(realm, first, maxResults); - - if (users.isEmpty()) { - return null; - } - - UserModel match = users.stream() - .filter(user -> getWebAuthnCredential(user, normalizedCredentialId) != null) - .findFirst() - .orElse(null); - - if (match != null) { - return match; - } - - first += users.size(); - if (users.size() < maxResults) { - return null; - } - } - } - - private List getUsersPage(RealmModel realm, int first, int maxResults) { - List users = session().users() - .searchForUserStream(realm, Map.of("search", ""), first, maxResults) - .toList(); - if (!users.isEmpty()) { - return users; - } - - users = session().users() - .searchForUserStream(realm, Collections.emptyMap(), first, maxResults) - .toList(); - if (!users.isEmpty()) { - return users; - } - - return session().users() - .searchForUserStream(realm, "", first, maxResults) - .toList(); - } - - private WebAuthnCredentialModel getWebAuthnCredential(UserModel user, String credentialId) { - byte[] requestedCredentialId = credentialIdToBytes(credentialId); - if (requestedCredentialId.length == 0) { + if (realm == null) { return null; } - return user.credentialManager() - .getStoredCredentialsByTypeStream(PASSKEY_TYPE) - .map(WebAuthnCredentialModel::createFromCredentialModel) - .filter(credential -> { - String storedCredentialId = credential.getWebAuthnCredentialData().getCredentialId(); - if (storedCredentialId == null || storedCredentialId.isBlank()) { - return false; - } - - byte[] storedCredentialIdBytes = credentialIdToBytes(storedCredentialId); - return storedCredentialIdBytes.length > 0 && Arrays.equals(requestedCredentialId, storedCredentialIdBytes); - }) - .findFirst() - .orElse(null); - } - - // Authentication validation: rebuild WebAuthn server properties from signed client payload. - private boolean isPasskeyValid(byte[] credentialId, byte[] authenticatorData, String clientDataJSON, byte[] signature, String challengeRequest, UserModel user, RealmModel realm) throws JsonProcessingException { - if (challengeRequest == null || challengeRequest.isBlank()) { - return false; - } - - ClientDataContext clientDataContext = parseClientData(clientDataJSON, challengeRequest); - ServerProperty serverProperty = new ServerProperty( - clientDataContext.origin(), - clientDataContext.rpId(), - clientDataContext.challenge(), - null - ); - - String uvPolicy = realm.getWebAuthnPolicyPasswordless().getUserVerificationRequirement(); - boolean isUVFlagChecked = uvPolicy != null && "required".equalsIgnoreCase(uvPolicy); - var authReq = new AuthenticationRequest( - credentialId, - authenticatorData, - clientDataContext.decodedClientDataBytes(), - signature - ); - var authParams = new WebAuthnCredentialModelInput.KeycloakWebAuthnAuthenticationParameters(serverProperty, isUVFlagChecked); - var cred = new WebAuthnCredentialModelInput(PASSKEY_TYPE); - - cred.setAuthenticationRequest(authReq); - cred.setAuthenticationParameters(authParams); - return user.credentialManager().isValid(cred); - } - - // ----- Token issuance ----- - private String buildScopeParameterForClient(ClientModel client) { - LinkedHashSet scopeNames = new LinkedHashSet<>(); - scopeNames.add(OAuth2Constants.SCOPE_OPENID); - scopeNames.add(OAuth2Constants.SCOPE_PROFILE); - scopeNames.add(OAuth2Constants.SCOPE_EMAIL); - scopeNames.add("roles"); - scopeNames.add("web-origins"); - scopeNames.add("account"); - scopeNames.addAll(client.getClientScopes(true).keySet()); - return String.join(" ", scopeNames); - } - - private Response generateTokensResponse(UserModel user) { + AuthenticationManager.AuthResult authResult; try { - RealmModel realm = session().getContext().getRealm(); - - if (clientId == null || clientId.isBlank()) { - logger.error("Passkey client_id is not configured"); - return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Server configuration error"); - } - ClientModel client = realm.getClientByClientId(clientId); - if (client == null) { - logger.errorf("Client not found for client_id %s", clientId); - return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Client not found"); - } - session().getContext().setClient(client); - - ClientConnection connection = session().getContext().getConnection(); - String remoteAddress = connection == null ? "127.0.0.1" : connection.getRemoteAddr(); - UserSessionModel userSession = session().sessions().createUserSession( - realm, user, user.getUsername(), remoteAddress, OIDCLoginProtocol.LOGIN_PROTOCOL, false, null, null); - userSession.setLastSessionRefresh(Time.currentTime()); - - AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId()); - if (clientSession == null) { - clientSession = session().sessions().createClientSession(realm, client, userSession); - } - clientSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); - clientSession.setTimestamp(Time.currentTime()); - - String scopeParameter = buildScopeParameterForClient(client); - clientSession.setNote(OIDCLoginProtocol.SCOPE_PARAM, scopeParameter); - String issuer = Urls.realmIssuer(session().getContext().getUri().getBaseUri(), realm.getName()); - clientSession.setNote(OIDCLoginProtocol.ISSUER, issuer); - ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter( - clientSession, scopeParameter, session()); - - if (session().getContext().getClient() == null) { - logger.error("Client context is still null after setting."); - return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Client context is null"); - } - - AuthenticationManager.createLoginCookie( - session(), - realm, - user, - userSession, - session().getContext().getUri(), - connection - ); - - EventBuilder event = new EventBuilder(realm, session(), connection) - .event(EventType.LOGIN) - .client(client) - .user(user) - .session(userSession); - - TokenManager tokenManager = new TokenManager(); - var tokenBuilder = tokenManager.responseBuilder( - realm, - client, - event, - session(), - userSession, - clientSessionCtx - ) - .generateAccessToken() - .generateRefreshToken() - .generateIDToken(); - - var tokenResponse = tokenBuilder.build(); - event.success(); - - return jsonOk(Map.of( - "access_token", PasskeyConfigResolver.firstNonBlank(tokenResponse.getToken(), ""), - "refresh_token", PasskeyConfigResolver.firstNonBlank(tokenResponse.getRefreshToken(), ""), - "id_token", PasskeyConfigResolver.firstNonBlank(tokenResponse.getIdToken(), ""), - "expires_in", tokenResponse.getExpiresIn(), - "refresh_expires_in", tokenResponse.getRefreshExpiresIn(), - "token_type", PasskeyConfigResolver.firstNonBlank(tokenResponse.getTokenType(), "Bearer"), - "scope", PasskeyConfigResolver.firstNonBlank(tokenResponse.getScope(), ""), - "session_state", PasskeyConfigResolver.firstNonBlank(tokenResponse.getSessionState(), ""), - "not-before-policy", tokenResponse.getNotBeforePolicy() - )); - } catch (Exception e) { - logger.error("Token generation failed: " + e.getMessage(), e); - return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Token generation failed: " + e.getMessage()); - } - } - - // ----- Shared payload/codec helpers ----- - private String generateChallenge() { - Challenge challenge = new DefaultChallenge(); - return Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.getValue()); - } - - private Response buildErrorResponse(Response.Status status, String message) { - return jsonResponse(status, Map.of("error", PasskeyConfigResolver.firstNonBlank(message, ""))); - } - - private byte[] decodeBase64UrlOrStd(String base64String) { - if (base64String == null || base64String.isEmpty()) { - return new byte[0]; - } - - String normalized = base64String.replace('-', '+').replace('_', '/'); - int padding = (4 - (normalized.length() % 4)) % 4; - normalized = normalized + "=".repeat(padding); - - return Base64.getDecoder().decode(normalized); - } - - private String normalizeCredentialId(String credentialId) { - if (credentialId == null || credentialId.isBlank()) { + authResult = new AppAuthManager.BearerTokenAuthenticator(session()) + .setRealm(realm) + .setConnection(session().getContext().getConnection()) + .setUriInfo(session().getContext().getUri()) + .setHeaders(session().getContext().getRequestHeaders()) + .authenticate(); + } catch (NotAuthorizedException ignored) { return null; - } - byte[] credentialBytes = credentialIdToBytes(credentialId); - if (credentialBytes.length == 0) { + } catch (RuntimeException ignored) { return null; } - return Base64.getUrlEncoder().withoutPadding().encodeToString(credentialBytes); - } - private byte[] credentialIdToBytes(String credentialId) { - if (credentialId == null || credentialId.isBlank()) { - return new byte[0]; - } - - try { - return decodeBase64UrlOrStd(credentialId); - } catch (IllegalArgumentException ignored) { - return new byte[0]; + if (authResult == null || authResult.getUser() == null) { + return null; } - } - - // ----- Challenge storage ----- - private void trackChallenge(String challenge) { - String challengeKey = challengeKey(challenge); - Map marker = Map.of("issuedAt", Long.toString(System.currentTimeMillis())); - session().getProvider(SingleUseObjectProvider.class).put(challengeKey, CHALLENGE_TTL_SECONDS, marker); - } - private boolean consumeChallenge(String challenge) { - if (challenge == null || challenge.isBlank()) { - return false; - } - Map value = session().getProvider(SingleUseObjectProvider.class).remove(challengeKey(challenge)); - if (value == null) { - return false; - } - try { - long issuedAt = Long.parseLong(value.getOrDefault("issuedAt", "0")); - return issuedAt > 0 && (System.currentTimeMillis() - issuedAt) <= CHALLENGE_TTL_MILLIS; - } catch (NumberFormatException ignored) { - return false; + if (clientId != null && !clientId.isBlank()) { + ClientModel tokenClient = authResult.getClient(); + if (tokenClient == null || !clientId.equals(tokenClient.getClientId())) { + return null; + } } - } - protected WebAuthnRegistrationManager createWebAuthnRegistrationManager() { - return WebAuthnRegistrationManager.createNonStrictWebAuthnRegistrationManager(new ObjectConverter()); + return authResult.getUser(); } - private void storeCredentialUserMapping(UserModel user, String credentialId) { - String normalizedCredentialId = normalizeCredentialId(credentialId); - if (normalizedCredentialId == null) { - return; - } - - List values = new ArrayList<>(user.getAttributeStream(CREDENTIAL_USER_ATTR).toList()); - if (!values.contains(normalizedCredentialId)) { - values.add(normalizedCredentialId); - user.setAttribute(CREDENTIAL_USER_ATTR, values); + /** + * Returns the active SPI session and fails fast if the resource was created without it. + */ + private KeycloakSession session() { + if (session == null) { + throw new IllegalStateException("UserPasskeyResource must be created by UserPasskeyProvider (SPI-managed session required)."); } + return session; } - private void validateRegistrationCompat(WebAuthnRegistrationManager manager, RegistrationRequest request, RegistrationData data, RegistrationParameters parameters) { - String[] methodNames = {"verify", "validate"}; - Class[][] signatures = { - {RegistrationRequest.class, RegistrationParameters.class}, - {RegistrationData.class, RegistrationParameters.class} - }; - Object[][] args = { - {request, parameters}, - {data, parameters} - }; - - for (String methodName : methodNames) { - for (int i = 0; i < signatures.length; i++) { - try { - manager.getClass() - .getMethod(methodName, signatures[i][0], signatures[i][1]) - .invoke(manager, args[i][0], args[i][1]); - return; - } catch (NoSuchMethodException ignored) { - } catch (IllegalAccessException e) { - throw new RuntimeException("Cannot access WebAuthn registration " + methodName + " method", e); - } catch (InvocationTargetException e) { - Throwable cause = e.getCause(); - if (cause instanceof RuntimeException runtimeException) { - throw runtimeException; - } - throw new RuntimeException("Passkey registration verification failed", cause); - } - } - } - - throw new RuntimeException("Incompatible WebAuthnRegistrationManager methods: neither verify nor validate is supported"); + /** + * Creates the challenge service for the current request context. + */ + private PasskeyChallengeService challengeService() { + return new PasskeyChallengeService(session(), clientSupport); } - // clientDataJSON is the browser-provided WebAuthn metadata payload. - private ClientDataContext parseClientData(String base64ClientDataJSON, String challengeOverride) throws JsonProcessingException { - byte[] decodedClientDataBytes = decodeBase64UrlOrStd(base64ClientDataJSON); - String decodedClientDataJSON = new String(decodedClientDataBytes, StandardCharsets.UTF_8); - JsonNode clientData = OBJECT_MAPPER.readTree(decodedClientDataJSON); - - String originValue = readRequiredText(clientData, "origin"); - String challengeValue = PasskeyConfigResolver.firstNonBlank(challengeOverride, readRequiredText(clientData, "challenge")); - return new ClientDataContext( - decodedClientDataBytes, - new Origin(originValue), - extractRpId(originValue), - new DefaultChallenge(challengeValue) - ); + /** + * Creates the WebAuthn service for the current request context. + */ + private PasskeyWebAuthnService webAuthnService() { + return new PasskeyWebAuthnService(session(), allowedBrowserOrigin); } - private String readRequiredText(JsonNode node, String field) { - JsonNode valueNode = node.get(field); - if (valueNode == null || valueNode.asText().isBlank()) { - throw new IllegalArgumentException("clientDataJSON is missing required field: " + field); - } - return valueNode.asText(); + /** + * Creates the browser-login service for the current request context. + */ + private PasskeyBrowserLoginService browserLoginService() { + return new PasskeyBrowserLoginService(session(), clientSupport); } - private String extractRpId(String origin) { - URI parsedUri = URI.create(origin); - if (parsedUri.getHost() == null || parsedUri.getHost().isBlank()) { - throw new IllegalArgumentException("Invalid origin in clientDataJSON: " + origin); - } - return parsedUri.getHost(); + /** + * Logs and returns a uniform internal-server-error response for configuration problems. + */ + private Response handleServerConfigurationError(String logMessage, IllegalStateException exception) { + logger.error(logMessage, exception); + return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, ERROR_SERVER_CONFIGURATION); } + /** + * Builds a 200 JSON response wrapped with CORS headers. + */ private Response jsonOk(Object payload) { return jsonResponse(Response.Status.OK, payload); } + /** + * Builds a JSON response with CORS headers. + */ private Response jsonResponse(Response.Status status, Object payload) { - String jsonPayload = toJson(payload); Response.ResponseBuilder builder = status == Response.Status.OK - ? Response.ok(jsonPayload) - : Response.status(status).entity(jsonPayload); - return withCors(builder.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON).build()); + ? Response.ok(payload) + : Response.status(status).entity(payload); + return withCors(builder.type(MediaType.APPLICATION_JSON_TYPE).build()); } + /** + * Builds a plain-text response with CORS headers. + */ private Response textResponse(Response.Status status, String payload) { return withCors(Response.status(status) .entity(payload) - .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN) + .type(MediaType.TEXT_PLAIN_TYPE) .build()); } - private String toJson(Object payload) { - try { - return OBJECT_MAPPER.writeValueAsString(payload); - } catch (JsonProcessingException e) { - throw new RuntimeException("Failed to serialize JSON response", e); - } + /** + * Builds a Keycloak-style error response and applies CORS headers. + */ + private Response buildErrorResponse(Response.Status status, String message) { + ErrorResponseException errorResponse = ErrorResponse.error( + PasskeyConfigResolver.firstNonBlank(message, ""), + status + ); + return withCors(errorResponse.getResponse()); } - private record ClientDataContext(byte[] decodedClientDataBytes, Origin origin, String rpId, Challenge challenge) { - } + /** + * Rebuilds the response while appending CORS headers for configured clients. + */ + private Response withCors(Response response) { + Response.ResponseBuilder responseBuilder = Response.fromResponse(response); + applyCors(responseBuilder, false); + return responseBuilder.build(); + } + + /** + * Applies Keycloak CORS settings based on configured client web origins. + * + * @param responseBuilder response builder to mutate + * @param preflight whether to apply preflight-specific headers + */ + private void applyCors(Response.ResponseBuilder responseBuilder, boolean preflight) { + ClientModel corsClient = clientSupport.resolveConfiguredClient(session().getContext().getRealm()); + if (corsClient == null) { + return; + } - private String challengeKey(String challenge) { - RealmModel realm = session().getContext().getRealm(); - String realmName = realm == null ? "unknown-realm" : Objects.toString(realm.getName(), "unknown-realm"); - return CHALLENGE_KEY_PREFIX + realmName + ":" + clientId + ":" + challenge; - } + Cors cors = Cors.builder() + .builder(responseBuilder) + .auth() + .allowedMethods("GET", "POST", "OPTIONS"); + if (preflight) { + cors.preflight(); + } + + cors.allowedOrigins(session(), corsClient); + cors.add(); + } } From a5487151163a2f519ed9a02d6fd3ce48ebc094b8 Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser <65477790+mohamedalaaser@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:37:42 +0100 Subject: [PATCH 37/39] Use keycloak passkey extension (#79) Co-authored-by: Johannes Pahle <82645554+ITegs@users.noreply.github.com> --- .dockerignore | 7 + .example.env | 2 + .../components/header/header.component.html | 18 +- .../app/components/header/header.component.ts | 30 +- .../components/sign-in/sign-in.component.html | 33 ++ .../components/sign-in/sign-in.component.ts | 35 ++ .../src/app/core/security/keycloak.service.ts | 8 +- .../security/passkey-extension.service.ts | 183 +++++++++ .../core/security/security-store.service.ts | 69 +++- Client/src/app/layout.component.html | 33 ++ Client/src/app/layout.component.ts | 29 +- .../passkeys/account-passkeys.component.ts | 13 +- .../src/app/pages/index/index.component.html | 6 +- Client/src/app/pages/index/index.component.ts | 3 +- Client/tsconfig.app.json | 8 +- docker/docker-compose.dev.yaml | 10 +- docker/docker-compose.staging.yaml | 4 +- docker/keycloak/Dockerfile | 15 + keycloak-extension-passkey/pom.xml | 79 ++++ .../keycloak/PasskeyBrowserLoginService.java | 212 ++++++++++ .../keycloak/PasskeyChallengeService.java | 165 ++++++++ .../keycloak/PasskeyClientSupport.java | 45 +++ .../keycloak/PasskeyConfigResolver.java | 59 +++ .../com/example/keycloak/PasskeyRequest.java | 35 ++ .../keycloak/PasskeyWebAuthnService.java | 382 ++++++++++++++++++ .../example/keycloak/UserPasskeyProvider.java | 26 ++ .../keycloak/UserPasskeyProviderFactory.java | 37 ++ .../example/keycloak/UserPasskeyResource.java | 338 ++++++++++++++++ .../src/main/resources/META-INF/beans.xml | 4 + ...ices.resource.RealmResourceProviderFactory | 1 + module-management-realm.json | 2 +- 31 files changed, 1822 insertions(+), 69 deletions(-) create mode 100644 .dockerignore create mode 100644 Client/src/app/components/sign-in/sign-in.component.html create mode 100644 Client/src/app/components/sign-in/sign-in.component.ts create mode 100644 Client/src/app/core/security/passkey-extension.service.ts create mode 100644 docker/keycloak/Dockerfile create mode 100644 keycloak-extension-passkey/pom.xml create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyBrowserLoginService.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyChallengeService.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyClientSupport.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java create mode 100644 keycloak-extension-passkey/src/main/resources/META-INF/beans.xml create mode 100644 keycloak-extension-passkey/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..4ca1fe66 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +# Slim context for docker/keycloak/Dockerfile (build context: Module-Management root) +Client/node_modules +Client/dist +Server/build +Server/.gradle +**/.git +.git diff --git a/.example.env b/.example.env index ba1772e2..37436af4 100644 --- a/.example.env +++ b/.example.env @@ -46,3 +46,5 @@ EMBEDDING_MODEL_NAME= sentence-transformers/all-mpnet-base-v2 CHAT_MODEL_SOURCE = OpenAiChatModel EMBEDDING_MODEL_SOURCE = OpenAiEmbeddingModel +KC_PASSKEY_CLIENT_ID=module-management +KC_ALLOWED_BROWSER_ORIGIN=https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?|https://module\.aet\.cit\.tum\.de diff --git a/Client/src/app/components/header/header.component.html b/Client/src/app/components/header/header.component.html index b2a89521..b552d2f3 100644 --- a/Client/src/app/components/header/header.component.html +++ b/Client/src/app/components/header/header.component.html @@ -13,9 +13,7 @@ CIT Module Management

-
- - - @if (user() !== undefined) { - - - - } @else { - - } +
diff --git a/Client/src/app/components/header/header.component.ts b/Client/src/app/components/header/header.component.ts index b552e0af..ab342555 100644 --- a/Client/src/app/components/header/header.component.ts +++ b/Client/src/app/components/header/header.component.ts @@ -4,16 +4,14 @@ import { SecurityStore } from '../../core/security/security-store.service'; import { ThemeService } from '../../core/theme/theme.service'; import { SidebarService } from '../side-bar/sidebar.service'; import { ButtonModule } from 'primeng/button'; -import { AvatarModule } from 'primeng/avatar'; -import { MenuModule } from 'primeng/menu'; import { TooltipModule } from 'primeng/tooltip'; -import { MenuItem } from 'primeng/api'; +import { SignInComponent } from '../sign-in/sign-in.component'; @Component({ selector: 'app-header', templateUrl: './header.component.html', standalone: true, - imports: [RouterLink, ButtonModule, AvatarModule, MenuModule, TooltipModule] + imports: [RouterLink, ButtonModule, TooltipModule, SignInComponent] }) export class HeaderComponent { securityStore = inject(SecurityStore); @@ -23,31 +21,7 @@ export class HeaderComponent { user = this.securityStore.user; isDarkMode = this.themeService.isDarkMode; - menuItems: MenuItem[] = [ - { - label: 'Settings', - icon: 'pi pi-cog', - routerLink: '/account' - }, - { - separator: true - }, - { - label: 'Sign Out', - icon: 'pi pi-sign-out', - command: () => this.signOut() - } - ]; - toggleTheme() { this.themeService.toggleTheme(); } - - signIn() { - this.securityStore.signIn(); - } - - signOut() { - this.securityStore.signOut(); - } } diff --git a/Client/src/app/components/sign-in/sign-in.component.html b/Client/src/app/components/sign-in/sign-in.component.html new file mode 100644 index 00000000..057109c0 --- /dev/null +++ b/Client/src/app/components/sign-in/sign-in.component.html @@ -0,0 +1,33 @@ +@if (user() !== undefined) { + + + +} @else { + + + + +} diff --git a/Client/src/app/components/sign-in/sign-in.component.ts b/Client/src/app/components/sign-in/sign-in.component.ts new file mode 100644 index 00000000..d92b434a --- /dev/null +++ b/Client/src/app/components/sign-in/sign-in.component.ts @@ -0,0 +1,35 @@ +import { Component, inject } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { ButtonModule } from 'primeng/button'; +import { ButtonGroupModule } from 'primeng/buttongroup'; +import { MenuModule } from 'primeng/menu'; +import { TooltipModule } from 'primeng/tooltip'; +import { MenuItem } from 'primeng/api'; +import { SecurityStore } from '../../core/security/security-store.service'; + +@Component({ + selector: 'app-sign-in', + standalone: true, + imports: [RouterModule, ButtonModule, ButtonGroupModule, MenuModule, TooltipModule], + templateUrl: './sign-in.component.html' +}) +export class SignInComponent { + readonly securityStore = inject(SecurityStore); + readonly user = this.securityStore.user; + + menuItems: MenuItem[] = [ + { + label: 'Settings', + icon: 'pi pi-cog', + routerLink: '/account' + }, + { + separator: true + }, + { + label: 'Sign Out', + icon: 'pi pi-sign-out', + command: () => this.securityStore.signOut() + } + ]; +} diff --git a/Client/src/app/core/security/keycloak.service.ts b/Client/src/app/core/security/keycloak.service.ts index f17f6bd7..02b655a5 100644 --- a/Client/src/app/core/security/keycloak.service.ts +++ b/Client/src/app/core/security/keycloak.service.ts @@ -54,18 +54,14 @@ export class KeycloakService { } } - login(returnUrl?: string) { - return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl || ''), action: 'webauthn-register-passwordless:skip_if_exists' }); + loginWithTumRedirect(returnUrl?: string) { + return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl ?? '') }); } logout() { return this.keycloak.logout({ redirectUri: environment.redirect }); } - registerPasskey(returnUrl?: string) { - return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl || ''), action: 'webauthn-register-passwordless' }); - } - getCredentials() { const url = `${environment.keycloak.url}/realms/${environment.keycloak.realm}/account/credentials`; return this.http.get(url); diff --git a/Client/src/app/core/security/passkey-extension.service.ts b/Client/src/app/core/security/passkey-extension.service.ts new file mode 100644 index 00000000..a7e5a480 --- /dev/null +++ b/Client/src/app/core/security/passkey-extension.service.ts @@ -0,0 +1,183 @@ +import { inject, Injectable } from '@angular/core'; +import { environment } from '../../../../environments/environment'; +import { KeycloakService } from './keycloak.service'; + +/** + * In-app WebAuthn via Keycloak realm extension (same flow as ba-test-keycloak {@code public/app.js}): + * register: {@code /passkey/challenge} + {@code /passkey/save}; sign-in: {@code /passkey/get-credential-id} + {@code /passkey/authenticate}. + */ +@Injectable({ providedIn: 'root' }) +export class PasskeyExtensionService { + private readonly keycloakService = inject(KeycloakService); + + private passkeyBaseUrl(): string { + const base = environment.keycloak.url.replace(/\/$/, ''); + const realm = encodeURIComponent(environment.keycloak.realm); + return `${base}/realms/${realm}/passkey`; + } + + private getUrl(path: string): string { + const p = path.replace(/^\/+/, ''); + return `${this.passkeyBaseUrl()}/${p}`; + } + + private base64UrlToUint8Array(value: string): Uint8Array { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); + } + + private bufferToBase64Url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + } + + private async readJsonBody(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.toLowerCase().includes('application/json')) { + return undefined; + } + try { + return (await response.json()) as T; + } catch { + return undefined; + } + } + + /** + * Register a new passkey for the current user (must already be logged in). + */ + async registerPasskeyInBrowser(): Promise { + const kc = this.keycloakService.keycloak; + if (!kc.authenticated || !kc.token) { + throw new Error('You must be signed in to register a passkey.'); + } + + await kc.updateToken(60); + const token = kc.token; + if (!token) { + throw new Error('No access token available.'); + } + + const parsed = kc.tokenParsed as Record | undefined; + const accountId = String(parsed?.['sub'] ?? parsed?.['preferred_username'] ?? ''); + const accountName = String(parsed?.['preferred_username'] ?? parsed?.['email'] ?? ''); + const displayName = String(parsed?.['name'] ?? ([parsed?.['given_name'], parsed?.['family_name']].filter(Boolean).join(' ') || accountName || 'User')); + + if (!accountId || !accountName) { + throw new Error('Missing user identity in token for passkey registration.'); + } + + const challengeRes = await fetch(this.getUrl('challenge'), { credentials: 'include' }); + if (!challengeRes.ok) { + throw new Error(`Failed to get WebAuthn challenge (${challengeRes.status})`); + } + const { challenge } = (await challengeRes.json()) as { challenge: string }; + if (!challenge) { + throw new Error('Invalid challenge response from Keycloak'); + } + + const userIdBytes = new TextEncoder().encode(accountId).slice(0, 64); + + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: this.base64UrlToUint8Array(challenge) as BufferSource, + rp: { name: 'Module Management', id: window.location.hostname }, + user: { id: userIdBytes, name: accountName, displayName }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + authenticatorSelection: { userVerification: 'preferred', residentKey: 'required' }, + attestation: 'none' + } + })) as PublicKeyCredential | null; + + if (!credential?.response) { + throw new Error('Passkey creation was cancelled or failed.'); + } + + const response = credential.response as AuthenticatorAttestationResponse; + const savePayload = { + credentialId: this.bufferToBase64Url(credential.rawId), + rawId: this.bufferToBase64Url(credential.rawId), + clientDataJSON: this.bufferToBase64Url(response.clientDataJSON), + attestationObject: this.bufferToBase64Url(response.attestationObject), + challenge + }; + + const saveRes = await fetch(this.getUrl('save'), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(savePayload) + }); + + const saveText = await saveRes.text(); + if (!saveRes.ok) { + throw new Error(saveText || `Failed to store passkey (${saveRes.status})`); + } + + await kc.updateToken(-1); + } + + /** + * Sign in with passkey only (no Keycloak UI redirect). + * The extension endpoint sets the Keycloak login cookie; the SPA should then reload + * and let keycloak-js initialize via check-sso. + */ + async signInWithPasskey(): Promise { + const optionsResponse = await fetch(this.getUrl('challenge'), { credentials: 'include' }); + const res = await this.readJsonBody<{ challenge?: string; credentialId?: string; error?: string }>(optionsResponse); + if (!optionsResponse.ok) { + throw new Error(res?.error || `Failed to get passkey options (${optionsResponse.status})`); + } + if (!res?.challenge) { + throw new Error('Invalid challenge response from server'); + } + + const publicKey: PublicKeyCredentialRequestOptions = { + challenge: this.base64UrlToUint8Array(res.challenge) as BufferSource, + userVerification: 'preferred' + }; + if (res.credentialId) { + publicKey.allowCredentials = [{ type: 'public-key', id: this.base64UrlToUint8Array(res.credentialId) as BufferSource }]; + } + + const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null; + if (!credential?.response) { + throw new Error('Passkey sign-in was cancelled or failed.'); + } + + const ar = credential.response as AuthenticatorAssertionResponse; + const payload = { + credentialId: this.bufferToBase64Url(credential.rawId), + rawId: this.bufferToBase64Url(credential.rawId), + clientDataJSON: this.bufferToBase64Url(ar.clientDataJSON), + authenticatorData: this.bufferToBase64Url(ar.authenticatorData), + signature: this.bufferToBase64Url(ar.signature), + challenge: res.challenge + }; + + const authRes = await fetch(this.getUrl('authenticate'), { + method: 'POST', + credentials: 'include', + redirect: 'manual', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (authRes.type === 'opaqueredirect') { + return; + } + + const authResult = await this.readJsonBody<{ error?: string }>(authRes); + if (!authRes.ok) { + throw new Error(authResult?.error || `Passkey authentication failed (${authRes.status})`); + } + } +} diff --git a/Client/src/app/core/security/security-store.service.ts b/Client/src/app/core/security/security-store.service.ts index 90909ba4..0adb1bc7 100644 --- a/Client/src/app/core/security/security-store.service.ts +++ b/Client/src/app/core/security/security-store.service.ts @@ -1,18 +1,27 @@ import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; import { isPlatformServer } from '@angular/common'; import { KeycloakService } from './keycloak.service'; +import { PasskeyExtensionService } from './passkey-extension.service'; import { firstValueFrom } from 'rxjs'; import { UserControllerService, User } from '../modules/openapi'; import { Passkey } from './keycloak-credentials.types'; +import { MessageService } from 'primeng/api'; + +function passkeyDialogDismissedStorageKey(sub: string): string { + return `mm_passkey_dialog_dismissed_${sub}`; +} @Injectable({ providedIn: 'root' }) export class SecurityStore { keycloakService = inject(KeycloakService); + passkeyExtension = inject(PasskeyExtensionService); userControllerService = inject(UserControllerService); + private readonly messageService = inject(MessageService); isLoading = signal(false); user = signal(undefined); passkeys = signal([]); + passkeyDialogVisible = signal(false); constructor() { this.onInit(); @@ -29,36 +38,86 @@ export class SecurityStore { const isLoggedIn = await this.keycloakService.init(); if (isLoggedIn) { - this.loadPasskeys(); + await this.loadPasskeys(); try { const user = await firstValueFrom(this.userControllerService.getCurrentUser()); this.user.set(user); } catch (error) { + this.messageService.add({ severity: 'error', summary: 'Sign-in', detail: 'Something went wrong' }); console.error('error fetching user details', error); this.user.set(undefined); } + this.evaluatePasskeyDialogAfterTumLogin(); } this.isLoading.set(false); } + closePasskeyDialog(dontShowAgain: boolean): void { + if (!this.passkeyDialogVisible()) { + return; + } + if (dontShowAgain) { + const sub = this.keycloakService.keycloak.tokenParsed?.sub; + if (sub) { + localStorage.setItem(passkeyDialogDismissedStorageKey(sub), '1'); + } + } + this.passkeyDialogVisible.set(false); + } + + private evaluatePasskeyDialogAfterTumLogin(): void { + if (this.passkeys().length > 0) { + return; + } + const sub = this.keycloakService.keycloak.tokenParsed?.sub; + if (!sub) { + return; + } + if (localStorage.getItem(passkeyDialogDismissedStorageKey(sub)) === '1') { + return; + } + this.passkeyDialogVisible.set(true); + } + + async signInWithTum(returnUrl?: string) { + await this.keycloakService.loginWithTumRedirect(returnUrl); + } + async signIn(returnUrl?: string) { - await this.keycloakService.login(returnUrl); + await this.signInWithTum(returnUrl); + } + + async signInWithPasskey(): Promise { + this.isLoading.set(true); + try { + await this.passkeyExtension.signInWithPasskey(); + // Passkey authenticate sets the Keycloak login cookie; reload so init(check-sso) + // can bootstrap keycloak-js token state through the standard adapter flow. + window.location.reload(); + } catch (error) { + this.messageService.add({ severity: 'error', summary: 'Sign-in', detail: 'Something went wrong' }); + console.error(error); + } finally { + this.isLoading.set(false); + } } async signOut() { await this.keycloakService.logout(); this.user.set(undefined); this.passkeys.set([]); + this.passkeyDialogVisible.set(false); } - async registerPasskey(returnUrl?: string) { - return await this.keycloakService.registerPasskey(returnUrl); + async registerPasskey(_returnUrl?: string) { + await this.passkeyExtension.registerPasskeyInBrowser(); + await this.loadPasskeys(); } async deletePasskey(credentialId: string) { try { await firstValueFrom(this.keycloakService.deleteCredential(credentialId)); - this.loadPasskeys(); + await this.loadPasskeys(); } catch (error) { console.error('Error deleting passkey:', error); } diff --git a/Client/src/app/layout.component.html b/Client/src/app/layout.component.html index 0e9c8948..956e9709 100644 --- a/Client/src/app/layout.component.html +++ b/Client/src/app/layout.component.html @@ -9,4 +9,37 @@
+ + +
+

+ You can add a + passkey + to sign in next time without leaving this app—using your device PIN, fingerprint, or security key. +

+
    +
  • Quicker sign-in for day-to-day work
  • +
  • Phishing-resistant authentication
  • +
  • Your passkey stays on your device; you can remove it anytime in Account → Passkeys
  • +
+
+ + +
+
+ + + + +
diff --git a/Client/src/app/layout.component.ts b/Client/src/app/layout.component.ts index 451ea8f6..3481161f 100644 --- a/Client/src/app/layout.component.ts +++ b/Client/src/app/layout.component.ts @@ -1,19 +1,44 @@ import { Component, inject } from '@angular/core'; -import { RouterModule } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; +import { FormsModule } from '@angular/forms'; import { HeaderComponent } from './components/header/header.component'; import { SideBarComponent } from './components/side-bar/side-bar.component'; import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component'; import { SidebarService } from './components/side-bar/sidebar.service'; import { SecurityStore } from './core/security/security-store.service'; +import { DialogModule } from 'primeng/dialog'; +import { ButtonModule } from 'primeng/button'; +import { CheckboxModule } from 'primeng/checkbox'; @Component({ selector: 'app-layout', standalone: true, - imports: [RouterModule, HeaderComponent, SideBarComponent, BreadcrumbComponent], + imports: [RouterModule, FormsModule, HeaderComponent, SideBarComponent, BreadcrumbComponent, DialogModule, ButtonModule, CheckboxModule], templateUrl: './layout.component.html' }) export class LayoutComponent { sidebarService = inject(SidebarService); securityStore = inject(SecurityStore); + private readonly router = inject(Router); user = this.securityStore.user; + + dontShowPasskeyDialogAgain = false; + + onPasskeyDialogVisibleChange(visible: boolean): void { + if (visible) { + this.dontShowPasskeyDialogAgain = false; + return; + } + this.securityStore.closePasskeyDialog(this.dontShowPasskeyDialogAgain); + } + + goToPasskeyRegistration(): void { + const skip = this.dontShowPasskeyDialogAgain; + this.securityStore.closePasskeyDialog(skip); + void this.router.navigate(['/account/passkeys']); + } + + dismissPasskeyDialog(): void { + this.securityStore.closePasskeyDialog(this.dontShowPasskeyDialogAgain); + } } diff --git a/Client/src/app/pages/account-management/passkeys/account-passkeys.component.ts b/Client/src/app/pages/account-management/passkeys/account-passkeys.component.ts index 9a8e4cfa..80fc66b4 100644 --- a/Client/src/app/pages/account-management/passkeys/account-passkeys.component.ts +++ b/Client/src/app/pages/account-management/passkeys/account-passkeys.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { SecurityStore } from '../../../core/security/security-store.service'; import { PanelModule } from 'primeng/panel'; import { ButtonModule } from 'primeng/button'; +import { MessageService } from 'primeng/api'; @Component({ selector: 'account-passkeys', @@ -12,11 +13,21 @@ import { ButtonModule } from 'primeng/button'; }) export class AccountPasskeysComponent { securityStore = inject(SecurityStore); + private readonly messageService = inject(MessageService); passkeys = this.securityStore.passkeys; async addPasskey() { - await this.securityStore.registerPasskey(window.location.pathname); + try { + await this.securityStore.registerPasskey(window.location.pathname); + } catch (e: unknown) { + const detail = e instanceof Error ? e.message : 'Passkey registration failed'; + this.messageService.add({ + severity: 'error', + summary: 'Passkey registration', + detail + }); + } } async deletePasskey(passkeyId: string) { diff --git a/Client/src/app/pages/index/index.component.html b/Client/src/app/pages/index/index.component.html index 24a3f946..bed247af 100644 --- a/Client/src/app/pages/index/index.component.html +++ b/Client/src/app/pages/index/index.component.html @@ -6,8 +6,10 @@ Sign in required
-

Log in to access the Module Management application.

- +

Sign in to access the Module Management application.

+
+ +
diff --git a/Client/src/app/pages/index/index.component.ts b/Client/src/app/pages/index/index.component.ts index 968daf74..63ac1578 100644 --- a/Client/src/app/pages/index/index.component.ts +++ b/Client/src/app/pages/index/index.component.ts @@ -4,12 +4,13 @@ import { ButtonModule } from 'primeng/button'; import { DividerModule } from 'primeng/divider'; import { PanelModule } from 'primeng/panel'; import { SecurityStore } from '../../core/security/security-store.service'; +import { SignInComponent } from '../../components/sign-in/sign-in.component'; import { isAdminRole, isProfessorRole, isReviewerRole } from '../../core/shared/user-role.utils'; @Component({ selector: 'index-component', standalone: true, - imports: [RouterModule, ButtonModule, DividerModule, PanelModule], + imports: [RouterModule, ButtonModule, DividerModule, PanelModule, SignInComponent], templateUrl: './index.component.html' }) export class IndexComponent { diff --git a/Client/tsconfig.app.json b/Client/tsconfig.app.json index 3775b37e..264f459b 100644 --- a/Client/tsconfig.app.json +++ b/Client/tsconfig.app.json @@ -6,10 +6,10 @@ "outDir": "./out-tsc/app", "types": [] }, - "files": [ - "src/main.ts" - ], "include": [ - "src/**/*.d.ts" + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" ] } diff --git a/docker/docker-compose.dev.yaml b/docker/docker-compose.dev.yaml index 335d7eb8..21bf8e5e 100644 --- a/docker/docker-compose.dev.yaml +++ b/docker/docker-compose.dev.yaml @@ -15,13 +15,20 @@ services: restart: unless-stopped keycloak: - image: quay.io/keycloak/keycloak:26.5.3 + build: + context: .. + dockerfile: docker/keycloak/Dockerfile + image: module-management-keycloak:dev + pull_policy: build container_name: module-management-keycloak environment: KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin} KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + KC_PASSKEY_CLIENT_ID: ${KC_PASSKEY_CLIENT_ID:-module-management} + KC_ALLOWED_BROWSER_ORIGIN: ${KC_ALLOWED_BROWSER_ORIGIN:-http://localhost:4200} command: start-dev --import-realm volumes: + - keycloak_data:/opt/keycloak/data - ../module-management-realm.json:/opt/keycloak/data/import/module-management-realm.json - ../keycloak-themes:/opt/keycloak/themes ports: @@ -65,6 +72,7 @@ services: volumes: postgres_data: + keycloak_data: hf_cache: diff --git a/docker/docker-compose.staging.yaml b/docker/docker-compose.staging.yaml index 3f6b0255..e1d3e570 100644 --- a/docker/docker-compose.staging.yaml +++ b/docker/docker-compose.staging.yaml @@ -33,11 +33,13 @@ services: restart: unless-stopped keycloak: - image: quay.io/keycloak/keycloak:26.5.3 + image: quay.io/keycloak/keycloak:26.5 container_name: module-management-keycloak environment: - KC_BOOTSTRAP_ADMIN_USERNAME=${KEYCLOAK_ADMIN_USERNAME} - KC_BOOTSTRAP_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD} + - KC_PASSKEY_CLIENT_ID=${KC_PASSKEY_CLIENT_ID:-module-management} + - KC_ALLOWED_BROWSER_ORIGIN=${KC_ALLOWED_BROWSER_ORIGIN:-http://localhost:4200} - KC_DB=postgres - KC_DB_URL_HOST=postgres - KC_DB_URL_DATABASE=keycloak diff --git a/docker/keycloak/Dockerfile b/docker/keycloak/Dockerfile new file mode 100644 index 00000000..e5f305a8 --- /dev/null +++ b/docker/keycloak/Dockerfile @@ -0,0 +1,15 @@ +# Keycloak image with Module-Management passkey realm extension (WebAuthn /passkey/*) +FROM maven:3.9-eclipse-temurin-21 AS extension-build +WORKDIR /build +COPY keycloak-extension-passkey /build/extension +WORKDIR /build/extension +RUN mvn -q -DskipTests package + +FROM quay.io/keycloak/keycloak:26.5 AS kc-with-provider +COPY --from=extension-build --chown=keycloak:keycloak \ + /build/extension/target/custom-endpoint-1.0-SNAPSHOT.jar \ + /opt/keycloak/providers/module-management-passkey.jar +RUN /opt/keycloak/bin/kc.sh build + +FROM quay.io/keycloak/keycloak:26.5 +COPY --from=kc-with-provider /opt/keycloak/ /opt/keycloak/ diff --git a/keycloak-extension-passkey/pom.xml b/keycloak-extension-passkey/pom.xml new file mode 100644 index 00000000..8926e274 --- /dev/null +++ b/keycloak-extension-passkey/pom.xml @@ -0,0 +1,79 @@ + + 4.0.0 + + com.example.keycloak + custom-endpoint + 1.0-SNAPSHOT + jar + + custom-endpoint + http://maven.apache.org + + + UTF-8 + 26.5.0 + + + + + org.keycloak + keycloak-server-spi + ${keycloak.version} + provided + + + org.keycloak + keycloak-services + ${keycloak.version} + provided + + + org.keycloak + keycloak-server-spi-private + ${keycloak.version} + provided + + + org.jboss.resteasy + resteasy-core-spi + 6.2.11.Final + provided + + + org.projectlombok + lombok + 1.18.38 + provided + + + com.fasterxml.jackson.core + jackson-core + 2.18.3 + + + com.fasterxml.jackson.core + jackson-databind + 2.18.3 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + + + org.projectlombok + lombok + 1.18.38 + + + + + + + diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyBrowserLoginService.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyBrowserLoginService.java new file mode 100644 index 00000000..8f4ec276 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyBrowserLoginService.java @@ -0,0 +1,212 @@ +package com.example.keycloak; + +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import org.keycloak.OAuth2Constants; +import org.keycloak.common.ClientConnection; +import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.UriUtils; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.utils.OIDCResponseType; +import org.keycloak.protocol.oidc.utils.RedirectUtils; +import org.keycloak.services.Urls; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.CommonClientSessionModel; +import org.keycloak.sessions.RootAuthenticationSessionModel; + +import jakarta.ws.rs.core.Response; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +final class PasskeyBrowserLoginService { + + private static final String HEADER_ORIGIN = "Origin"; + private static final String HEADER_REFERER = "Referer"; + private static final String RESPONSE_MODE_QUERY = "query"; + + private final KeycloakSession session; + private final PasskeyClientSupport clientSupport; + + /** + * Creates a helper that continues the standard Keycloak browser login flow. + * + * @param session Keycloak request session + * @param clientSupport helper for resolving configured client + */ + PasskeyBrowserLoginService(KeycloakSession session, PasskeyClientSupport clientSupport) { + this.session = session; + this.clientSupport = clientSupport; + } + + /** + * Builds an authenticated browser flow response for a passkey-authenticated user. + * + * @param user authenticated user + * @param realm current realm + * @return Keycloak follow-up response from the authentication manager + */ + Response completeLogin(UserModel user, RealmModel realm) { + ClientModel client = clientSupport.requireConfiguredClient(realm); + String redirectUri = resolveValidatedRedirectUri(client); + if (redirectUri == null) { + throw new IllegalArgumentException("No valid redirect URI for client"); + } + + session.getContext().setClient(client); + AuthenticationSessionModel authenticationSession = createBrowserAuthenticationSession(realm, client, user, redirectUri); + session.getContext().setAuthenticationSession(authenticationSession); + + ClientConnection connection = session.getContext().getConnection(); + EventBuilder event = new EventBuilder(realm, session, connection) + .event(EventType.LOGIN) + .client(client) + .user(user); + return AuthenticationManager.nextActionAfterAuthentication( + session, + authenticationSession, + connection, + session.getContext().getHttpRequest(), + session.getContext().getUri(), + event + ); + } + + /** + * Creates and pre-populates an authentication session for OIDC browser continuation. + */ + private AuthenticationSessionModel createBrowserAuthenticationSession(RealmModel realm, ClientModel client, UserModel user, String redirectUri) { + AuthenticationSessionManager authenticationSessionManager = new AuthenticationSessionManager(session); + RootAuthenticationSessionModel rootAuthenticationSession = authenticationSessionManager.createAuthenticationSession(realm, true); + AuthenticationSessionModel authenticationSession = rootAuthenticationSession.createAuthenticationSession(client); + + authenticationSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + authenticationSession.setAction(CommonClientSessionModel.Action.AUTHENTICATE.name()); + authenticationSession.setAuthenticatedUser(user); + authenticationSession.setRedirectUri(redirectUri); + authenticationSession.setClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM, OIDCResponseType.NONE); + authenticationSession.setClientNote(OIDCLoginProtocol.RESPONSE_MODE_PARAM, RESPONSE_MODE_QUERY); + authenticationSession.setClientNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, redirectUri); + authenticationSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, resolveScopeParameter(client)); + AuthenticationManager.setClientScopesInSession(session, authenticationSession); + authenticationSession.setClientNote(OIDCLoginProtocol.STATE_PARAM, generateState()); + authenticationSession.setClientNote( + OIDCLoginProtocol.ISSUER, + Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()) + ); + return authenticationSession; + } + + /** + * Derives scope to place into the auth session; falls back to {@code openid}. + */ + private String resolveScopeParameter(ClientModel client) { + return PasskeyConfigResolver.firstNonBlank( + AuthenticationManager.getRequestedScopes(session, client), + OAuth2Constants.SCOPE_OPENID + ); + } + + /** + * Generates a random state value used by the browser flow. + */ + private String generateState() { + Challenge challenge = new DefaultChallenge(); + return Base64Url.encode(challenge.getValue()); + } + + /** + * Finds the first client-validated redirect URI from request hints and configured fallbacks. + */ + private String resolveValidatedRedirectUri(ClientModel client) { + List redirectCandidates = new ArrayList<>(); + redirectCandidates.add(normalizeRedirectCandidate(getHeaderValue(HEADER_REFERER), true)); + redirectCandidates.add(normalizeRedirectCandidate(getHeaderValue(HEADER_ORIGIN), false)); + redirectCandidates.add(normalizeRedirectCandidate(client.getBaseUrl(), true)); + + for (String redirectCandidate : redirectCandidates) { + if (redirectCandidate == null) { + continue; + } + String verifiedRedirectUri = RedirectUtils.verifyRedirectUri(session, redirectCandidate, client); + if (verifiedRedirectUri != null) { + return verifiedRedirectUri; + } + } + + String singleConfiguredRedirect = RedirectUtils.verifyRedirectUri(session, null, client, false); + if (singleConfiguredRedirect != null) { + return singleConfiguredRedirect; + } + + for (String configuredRedirectUri : client.getRedirectUris()) { + if (configuredRedirectUri == null || configuredRedirectUri.isBlank() || configuredRedirectUri.contains("*")) { + continue; + } + String verifiedRedirectUri = RedirectUtils.verifyRedirectUri(session, configuredRedirectUri, client); + if (verifiedRedirectUri != null) { + return verifiedRedirectUri; + } + } + + return null; + } + + /** + * Reads a header value from the current request. + */ + private String getHeaderValue(String headerName) { + var headers = session.getContext().getRequestHeaders(); + if (headers == null) { + return null; + } + return headers.getHeaderString(headerName); + } + + /** + * Normalizes a redirect candidate to a stable URI form before validation. + */ + private String normalizeRedirectCandidate(String rawUri, boolean preservePath) { + if (rawUri == null || rawUri.isBlank()) { + return null; + } + + try { + URI parsedUri = URI.create(rawUri.trim()); + if (parsedUri.getScheme() == null || parsedUri.getHost() == null) { + return null; + } + + if (!preservePath) { + return UriUtils.getOrigin(parsedUri); + } + + String path = parsedUri.getPath(); + if (path == null || path.isBlank()) { + path = "/"; + } + + URI normalizedUri = new URI( + parsedUri.getScheme(), + parsedUri.getUserInfo(), + parsedUri.getHost(), + parsedUri.getPort(), + path, + null, + null + ); + return normalizedUri.toString(); + } catch (Exception ignored) { + return null; + } + } + +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyChallengeService.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyChallengeService.java new file mode 100644 index 00000000..996ad796 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyChallengeService.java @@ -0,0 +1,165 @@ +package com.example.keycloak; + +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import org.keycloak.common.util.Base64Url; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.RootAuthenticationSessionModel; + +import java.util.Objects; + +final class PasskeyChallengeService { + + private static final long CHALLENGE_TTL_MILLIS = 120_000; + private static final String AUTH_NOTE_PASSKEY_CHALLENGE = "passkey.challenge"; + private static final String AUTH_NOTE_PASSKEY_CHALLENGE_ISSUED_AT = "passkey.challenge.issuedAt"; + + private final KeycloakSession session; + private final PasskeyClientSupport clientSupport; + + /** + * Creates a challenge service bound to the current request session. + * + * @param session Keycloak request session + * @param clientSupport helper for resolving configured client + */ + PasskeyChallengeService(KeycloakSession session, PasskeyClientSupport clientSupport) { + this.session = session; + this.clientSupport = clientSupport; + } + + /** + * Generates and stores a single-use passkey challenge in authentication-session notes. + * + * @return base64url encoded challenge value + */ + String issueChallenge() { + RealmModel realm = requireRealm(); + ClientModel client = clientSupport.requireConfiguredClient(realm); + AuthenticationSessionModel challengeSession = getOrCreateChallengeSession(realm, client); + String challenge = generateChallenge(); + challengeSession.setAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE, challenge); + challengeSession.setAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE_ISSUED_AT, Long.toString(System.currentTimeMillis())); + return challenge; + } + + /** + * Consumes a challenge if it matches and is still within the configured TTL. + * + * @param challenge challenge sent by client + * @return {@code true} when challenge exists, matches, and is not expired + */ + boolean consumeChallenge(String challenge) { + if (challenge == null || challenge.isBlank()) { + return false; + } + + RealmModel realm = session.getContext().getRealm(); + if (realm == null) { + return false; + } + + ClientModel client = clientSupport.resolveConfiguredClient(realm); + if (client == null) { + return false; + } + + AuthenticationSessionModel challengeSession = resolveChallengeSession(realm, client); + if (challengeSession == null) { + return false; + } + + String trackedChallenge = challengeSession.getAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE); + if (!Objects.equals(challenge, trackedChallenge)) { + return false; + } + + challengeSession.removeAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE); + String issuedAtRaw = challengeSession.getAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE_ISSUED_AT); + challengeSession.removeAuthNote(AUTH_NOTE_PASSKEY_CHALLENGE_ISSUED_AT); + return isWithinTtl(issuedAtRaw); + } + + /** + * Returns the current realm or throws when realm context is missing. + */ + private RealmModel requireRealm() { + RealmModel realm = session.getContext().getRealm(); + if (realm == null) { + throw new IllegalStateException("Realm context is unavailable"); + } + return realm; + } + + /** + * Verifies whether the stored issue timestamp is still valid. + */ + private boolean isWithinTtl(String issuedAtRaw) { + try { + long issuedAt = Long.parseLong(PasskeyConfigResolver.firstNonBlank(issuedAtRaw, "0")); + return issuedAt > 0 && (System.currentTimeMillis() - issuedAt) <= CHALLENGE_TTL_MILLIS; + } catch (NumberFormatException ignored) { + return false; + } + } + + /** + * Produces a random base64url challenge string. + */ + private String generateChallenge() { + Challenge challenge = new DefaultChallenge(); + return Base64Url.encode(challenge.getValue()); + } + + /** + * Returns an existing challenge session for the client, or creates a new one. + */ + private AuthenticationSessionModel getOrCreateChallengeSession(RealmModel realm, ClientModel client) { + AuthenticationSessionModel existingSession = resolveChallengeSession(realm, client); + if (existingSession != null) { + return existingSession; + } + + AuthenticationSessionManager authenticationSessionManager = new AuthenticationSessionManager(session); + RootAuthenticationSessionModel rootAuthenticationSession = authenticationSessionManager.createAuthenticationSession(realm, true); + AuthenticationSessionModel authenticationSession = rootAuthenticationSession.createAuthenticationSession(client); + authenticationSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + return authenticationSession; + } + + /** + * Resolves the challenge session associated with the configured client. + */ + private AuthenticationSessionModel resolveChallengeSession(RealmModel realm, ClientModel client) { + AuthenticationSessionModel contextAuthenticationSession = session.getContext().getAuthenticationSession(); + if (isSessionForClient(contextAuthenticationSession, client)) { + return contextAuthenticationSession; + } + + AuthenticationSessionManager authenticationSessionManager = new AuthenticationSessionManager(session); + RootAuthenticationSessionModel rootAuthenticationSession = authenticationSessionManager.getCurrentRootAuthenticationSession(realm); + if (rootAuthenticationSession == null) { + return null; + } + + return rootAuthenticationSession.getAuthenticationSessions().values().stream() + .filter(authenticationSession -> isSessionForClient(authenticationSession, client)) + .findFirst() + .orElse(null); + } + + /** + * Checks whether an authentication session belongs to the given client. + */ + private boolean isSessionForClient(AuthenticationSessionModel authenticationSession, ClientModel client) { + return authenticationSession != null + && client != null + && authenticationSession.getClient() != null + && Objects.equals(authenticationSession.getClient().getId(), client.getId()); + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyClientSupport.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyClientSupport.java new file mode 100644 index 00000000..6203cbf7 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyClientSupport.java @@ -0,0 +1,45 @@ +package com.example.keycloak; + +import org.keycloak.models.ClientModel; +import org.keycloak.models.RealmModel; + +final class PasskeyClientSupport { + + private final String clientId; + + /** + * Creates a helper for resolving the configured passkey client. + * + * @param clientId configured client identifier + */ + PasskeyClientSupport(String clientId) { + this.clientId = clientId; + } + + /** + * Resolves the configured client in the given realm. + * + * @param realm current realm + * @return matching client, or {@code null} when configuration/context is incomplete + */ + ClientModel resolveConfiguredClient(RealmModel realm) { + if (realm == null || clientId == null || clientId.isBlank()) { + return null; + } + return realm.getClientByClientId(clientId); + } + + /** + * Resolves the configured client and throws when it cannot be found. + * + * @param realm current realm + * @return configured client model + */ + ClientModel requireConfiguredClient(RealmModel realm) { + ClientModel client = resolveConfiguredClient(realm); + if (client == null) { + throw new IllegalStateException("Client not found for configured passkey client_id"); + } + return client; + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java new file mode 100644 index 00000000..ca7ed104 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java @@ -0,0 +1,59 @@ +package com.example.keycloak; + +import org.keycloak.Config; + +final class PasskeyConfigResolver { + + private static final String DEFAULT_ALLOWED_ORIGIN = "http://localhost:3000"; + private static final String DEFAULT_CLIENT_ID = "demo-app"; + private static final String ENV_ALLOWED_BROWSER_ORIGIN = "KC_ALLOWED_BROWSER_ORIGIN"; + private static final String ENV_DEMO_ADDITIONAL_WEB_ORIGIN = "KC_DEMO_ADDITIONAL_WEB_ORIGIN"; + private static final String ENV_PASSKEY_CLIENT_ID = "KC_PASSKEY_CLIENT_ID"; + + private PasskeyConfigResolver() { + } + + static String resolveAllowedOriginPattern(Config.Scope config) { + return firstNonBlank( + config == null ? null : config.get("allowed-browser-origin"), + System.getenv(ENV_ALLOWED_BROWSER_ORIGIN), + System.getenv(ENV_DEMO_ADDITIONAL_WEB_ORIGIN), + DEFAULT_ALLOWED_ORIGIN + ); + } + + static String resolveClientId(Config.Scope config) { + return firstNonBlank( + config == null ? null : config.get("client-id"), + System.getenv(ENV_PASSKEY_CLIENT_ID), + DEFAULT_CLIENT_ID + ); + } + + static String resolveAllowedOriginPatternFromEnv() { + return firstNonBlank( + System.getenv(ENV_ALLOWED_BROWSER_ORIGIN), + System.getenv(ENV_DEMO_ADDITIONAL_WEB_ORIGIN), + DEFAULT_ALLOWED_ORIGIN + ); + } + + static String resolveClientIdFromEnv() { + return firstNonBlank( + System.getenv(ENV_PASSKEY_CLIENT_ID), + DEFAULT_CLIENT_ID + ); + } + + static String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java new file mode 100644 index 00000000..603edadb --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java @@ -0,0 +1,35 @@ +package com.example.keycloak; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PasskeyRequest { + + @JsonProperty("credentialId") + private String credentialId; + + @JsonProperty("rawId") + private String rawId; + + @JsonProperty("attestationObject") + private String attestationObject; + + @JsonProperty("clientDataJSON") + private String clientDataJSON; + + @JsonProperty("authenticatorData") + private String authenticatorData; + + @JsonProperty("signature") + private String signature; + + @JsonProperty("challenge") + private String challenge; +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java new file mode 100644 index 00000000..dd73d720 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java @@ -0,0 +1,382 @@ +package com.example.keycloak; + +import com.webauthn4j.WebAuthnRegistrationManager; +import com.webauthn4j.converter.exception.DataConversionException; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.AuthenticationRequest; +import com.webauthn4j.data.RegistrationData; +import com.webauthn4j.data.RegistrationParameters; +import com.webauthn4j.data.RegistrationRequest; +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import com.webauthn4j.server.ServerProperty; +import com.webauthn4j.verifier.exception.VerificationException; +import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.UriUtils; +import org.keycloak.credential.CredentialModel; +import org.keycloak.credential.CredentialProvider; +import org.keycloak.credential.WebAuthnCredentialModelInput; +import org.keycloak.credential.WebAuthnCredentialProvider; +import org.keycloak.credential.WebAuthnPasswordlessCredentialProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.WebAuthnPolicy; +import org.keycloak.models.credential.WebAuthnCredentialModel; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +final class PasskeyWebAuthnService { + + private static final String PASSKEY_TYPE = WebAuthnCredentialModel.TYPE_PASSWORDLESS; + private static final String CREDENTIAL_USER_ATTR = "passkey-credential-id"; + private static final String HEADER_ORIGIN = "Origin"; + + private final KeycloakSession session; + private final Pattern allowedBrowserOrigin; + + /** + * Creates WebAuthn helper logic bound to the current request. + * + * @param session Keycloak request session + * @param allowedBrowserOrigin compiled allowed browser origin pattern + */ + PasskeyWebAuthnService(KeycloakSession session, Pattern allowedBrowserOrigin) { + this.session = session; + this.allowedBrowserOrigin = allowedBrowserOrigin; + } + + /** + * Resolves credential identifier from request fields. + * + * @param request passkey request payload + * @return credential id, preferring {@code credentialId} over {@code rawId} + */ + String resolveCredentialId(PasskeyRequest request) { + return PasskeyConfigResolver.firstNonBlank(request.getCredentialId(), request.getRawId()); + } + + /** + * Finds a user by normalized credential id mapping stored as user attribute. + * + * @param realm current realm + * @param credentialId credential id from client + * @return matching user or {@code null} when none exists + */ + UserModel findUserByCredentialId(RealmModel realm, String credentialId) { + String normalizedCredentialId = normalizeCredentialId(credentialId); + if (normalizedCredentialId == null) { + return null; + } + + return session.users() + .searchForUserByUserAttributeStream(realm, CREDENTIAL_USER_ATTR, normalizedCredentialId) + .findFirst() + .orElse(null); + } + + /** + * Checks whether the user has a stored passwordless credential matching the requested id. + * + * @param user target user + * @param credentialId credential id from client + * @return {@code true} when a matching passkey credential exists + */ + boolean hasPasskeyCredential(UserModel user, String credentialId) { + byte[] requestedCredentialId = credentialIdToBytes(credentialId); + if (requestedCredentialId.length == 0) { + return false; + } + + return user.credentialManager() + .getStoredCredentialsByTypeStream(PASSKEY_TYPE) + .map(WebAuthnCredentialModel::createFromCredentialModel) + .map(WebAuthnCredentialModel::getWebAuthnCredentialData) + .filter(Objects::nonNull) + .map(data -> data.getCredentialId()) + .filter(Objects::nonNull) + .map(this::credentialIdToBytes) + .anyMatch(storedCredentialId -> Arrays.equals(storedCredentialId, requestedCredentialId)); + } + + /** + * Validates and stores a new passkey credential through Keycloak's credential provider. + * + * @param user target user + * @param request registration payload + * @param expectedChallenge challenge issued by this service + */ + void registerPasskey(UserModel user, PasskeyRequest request, String expectedChallenge) { + RealmModel realm = requireRealm(); + RegistrationRequest registrationRequest = new RegistrationRequest( + decodeRequiredBase64Url(request.getAttestationObject(), "attestationObject"), + decodeRequiredBase64Url(request.getClientDataJSON(), "clientDataJSON") + ); + RegistrationParameters registrationParameters = new RegistrationParameters( + buildServerProperty(realm, expectedChallenge), + isUserVerificationRequired(realm) + ); + + RegistrationData registrationData = validateRegistration(registrationRequest, registrationParameters); + WebAuthnCredentialProvider provider = getPasswordlessCredentialProvider(); + WebAuthnCredentialModelInput credentialInput = createCredentialInput(registrationData); + WebAuthnCredentialModel credentialModel = provider.getCredentialModelFromCredentialInput(credentialInput, user.getUsername()); + CredentialModel storedCredentialModel = user.credentialManager().createStoredCredential(credentialModel); + if (storedCredentialModel == null) { + throw new IllegalStateException("Failed to store passkey credential"); + } + WebAuthnCredentialModel storedCredential = WebAuthnCredentialModel.createFromCredentialModel(storedCredentialModel); + if (storedCredential.getWebAuthnCredentialData() != null) { + storeCredentialUserMapping(user, storedCredential.getWebAuthnCredentialData().getCredentialId()); + } + } + + /** + * Validates a WebAuthn assertion using Keycloak credential-manager validation. + * + * @param user target user + * @param request authentication payload + * @param credentialId resolved credential id + * @return {@code true} when assertion is valid + */ + boolean authenticatePasskey(UserModel user, PasskeyRequest request, String credentialId) { + RealmModel realm = requireRealm(); + AuthenticationRequest authenticationRequest = new AuthenticationRequest( + decodeRequiredBase64Url(credentialId, "credentialId"), + decodeRequiredBase64Url(request.getAuthenticatorData(), "authenticatorData"), + decodeRequiredBase64Url(request.getClientDataJSON(), "clientDataJSON"), + decodeRequiredBase64Url(request.getSignature(), "signature") + ); + + WebAuthnCredentialModelInput credentialInput = new WebAuthnCredentialModelInput(PASSKEY_TYPE); + credentialInput.setAuthenticationRequest(authenticationRequest); + credentialInput.setAuthenticationParameters( + new WebAuthnCredentialModelInput.KeycloakWebAuthnAuthenticationParameters( + buildServerProperty(realm, request.getChallenge()), + isUserVerificationRequired(realm) + ) + ); + + return user.credentialManager().isValid(credentialInput); + } + + /** + * Builds the registration manager used to validate attestation payloads. + */ + protected WebAuthnRegistrationManager createWebAuthnRegistrationManager() { + return WebAuthnRegistrationManager.createNonStrictWebAuthnRegistrationManager(new ObjectConverter()); + } + + /** + * Parses and validates registration payload data. + */ + private RegistrationData validateRegistration(RegistrationRequest request, RegistrationParameters parameters) { + try { + return createWebAuthnRegistrationManager().verify(request, parameters); + } catch (DataConversionException | VerificationException e) { + throw new IllegalArgumentException("Passkey registration validation failed: " + e.getMessage(), e); + } + } + + /** + * Maps parsed registration data into Keycloak's credential input model. + */ + private WebAuthnCredentialModelInput createCredentialInput(RegistrationData registrationData) { + WebAuthnCredentialModelInput credentialInput = new WebAuthnCredentialModelInput(PASSKEY_TYPE); + credentialInput.setAttestedCredentialData(registrationData.getAttestationObject().getAuthenticatorData().getAttestedCredentialData()); + credentialInput.setCount(registrationData.getAttestationObject().getAuthenticatorData().getSignCount()); + credentialInput.setAttestationStatementFormat(registrationData.getAttestationObject().getFormat()); + credentialInput.setTransports(registrationData.getTransports()); + return credentialInput; + } + + /** + * Resolves the passwordless WebAuthn credential provider. + */ + private WebAuthnCredentialProvider getPasswordlessCredentialProvider() { + WebAuthnCredentialProvider provider = (WebAuthnCredentialProvider) session.getProvider( + CredentialProvider.class, + WebAuthnPasswordlessCredentialProviderFactory.PROVIDER_ID + ); + if (provider == null) { + throw new IllegalStateException("Passwordless WebAuthn credential provider is unavailable"); + } + return provider; + } + + /** + * Constructs server property used by WebAuthn registration/authentication validation. + */ + private ServerProperty buildServerProperty(RealmModel realm, String challenge) { + if (challenge == null || challenge.isBlank()) { + throw new IllegalArgumentException("challenge is required"); + } + return new ServerProperty( + resolveAllowedOrigins(realm), + resolveRequiredRpId(realm), + new DefaultChallenge(challenge), + null + ); + } + + /** + * Resolves accepted origins from request origin and passwordless extra-origin policy entries. + */ + private Set resolveAllowedOrigins(RealmModel realm) { + WebAuthnPolicy policy = requirePasswordlessPolicy(realm); + Set origins = new HashSet<>(); + origins.add(new Origin(requireAllowedOrigin())); + + List extraOrigins = policy.getExtraOrigins(); + if (extraOrigins == null) { + return origins; + } + + for (String extraOrigin : extraOrigins) { + if (extraOrigin == null || extraOrigin.isBlank()) { + continue; + } + String normalizedExtraOrigin = normalizeOrigin(extraOrigin.trim()); + origins.add(new Origin(normalizedExtraOrigin)); + } + return origins; + } + + /** + * Extracts and validates the request {@code Origin} header against configured regex. + */ + private String requireAllowedOrigin() { + var headers = session.getContext().getRequestHeaders(); + String originHeader = headers == null ? null : headers.getHeaderString(HEADER_ORIGIN); + if (originHeader == null || originHeader.isBlank()) { + throw new IllegalArgumentException("Origin header is required"); + } + + String origin = normalizeOrigin(originHeader.trim()); + if (!allowedBrowserOrigin.matcher(origin).matches()) { + throw new IllegalArgumentException("Origin is not allowed"); + } + return origin; + } + + /** + * Normalizes and validates origin representation. + */ + private String normalizeOrigin(String candidateOrigin) { + try { + String origin = UriUtils.getOrigin(candidateOrigin); + if (origin == null || !UriUtils.isOrigin(origin)) { + throw new IllegalArgumentException("Invalid origin: " + candidateOrigin); + } + return origin; + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid origin: " + candidateOrigin, e); + } + } + + /** + * Resolves RP ID from passwordless policy, falling back to base URI host. + */ + private String resolveRequiredRpId(RealmModel realm) { + WebAuthnPolicy policy = requirePasswordlessPolicy(realm); + String fallbackRpId = session.getContext().getUri() == null || session.getContext().getUri().getBaseUri() == null + ? null + : session.getContext().getUri().getBaseUri().getHost(); + String rpId = PasskeyConfigResolver.firstNonBlank(policy.getRpId(), fallbackRpId); + if (rpId == null || rpId.isBlank()) { + throw new IllegalStateException("Passwordless WebAuthn RP ID is not configured"); + } + return rpId.trim(); + } + + /** + * Determines whether user verification is mandatory for this realm policy. + */ + private boolean isUserVerificationRequired(RealmModel realm) { + String uvRequirement = requirePasswordlessPolicy(realm).getUserVerificationRequirement(); + return uvRequirement != null && "required".equalsIgnoreCase(uvRequirement); + } + + /** + * Returns passwordless WebAuthn policy or throws when absent. + */ + private WebAuthnPolicy requirePasswordlessPolicy(RealmModel realm) { + if (realm == null || realm.getWebAuthnPolicyPasswordless() == null) { + throw new IllegalStateException("Passwordless WebAuthn policy is not configured"); + } + return realm.getWebAuthnPolicyPasswordless(); + } + + /** + * Decodes a required base64url field from client payload. + */ + private byte[] decodeRequiredBase64Url(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + try { + return Base64Url.decode(value); + } catch (RuntimeException e) { + throw new IllegalArgumentException(field + " must be a valid base64url value", e); + } + } + + /** + * Converts arbitrary credential id input into canonical base64url form. + */ + private String normalizeCredentialId(String credentialId) { + byte[] credentialBytes = credentialIdToBytes(credentialId); + if (credentialBytes.length == 0) { + return null; + } + return Base64Url.encode(credentialBytes); + } + + /** + * Decodes credential id into bytes, returning empty bytes on invalid input. + */ + private byte[] credentialIdToBytes(String credentialId) { + if (credentialId == null || credentialId.isBlank()) { + return new byte[0]; + } + + try { + return Base64Url.decode(credentialId); + } catch (RuntimeException ignored) { + return new byte[0]; + } + } + + /** + * Returns the current realm or throws when request context has none. + */ + private RealmModel requireRealm() { + RealmModel realm = session.getContext().getRealm(); + if (realm == null) { + throw new IllegalStateException("Realm context is unavailable"); + } + return realm; + } + + /** + * Persists credential-to-user mapping used by direct credential-id lookup. + */ + private void storeCredentialUserMapping(UserModel user, String credentialId) { + String normalizedCredentialId = normalizeCredentialId(credentialId); + if (normalizedCredentialId == null) { + return; + } + + List values = new ArrayList<>(user.getAttributeStream(CREDENTIAL_USER_ATTR).toList()); + if (!values.contains(normalizedCredentialId)) { + values.add(normalizedCredentialId); + user.setAttribute(CREDENTIAL_USER_ATTR, values); + } + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java new file mode 100644 index 00000000..7d3c73b2 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java @@ -0,0 +1,26 @@ +package com.example.keycloak; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.services.resource.RealmResourceProvider; + +public class UserPasskeyProvider implements RealmResourceProvider { + + private final KeycloakSession session; + private final String allowedBrowserOriginPattern; + private final String clientId; + + public UserPasskeyProvider(KeycloakSession session, String allowedBrowserOriginPattern, String clientId) { + this.session = session; + this.allowedBrowserOriginPattern = allowedBrowserOriginPattern; + this.clientId = clientId; + } + + @Override + public Object getResource() { + return new UserPasskeyResource(session, allowedBrowserOriginPattern, clientId); + } + + @Override + public void close() { + } +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java new file mode 100644 index 00000000..cfb9eae0 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java @@ -0,0 +1,37 @@ +package com.example.keycloak; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.services.resource.RealmResourceProvider; +import org.keycloak.services.resource.RealmResourceProviderFactory; + +public class UserPasskeyProviderFactory implements RealmResourceProviderFactory { + + public static final String ID = "passkey"; + private String allowedBrowserOriginPattern; + private String clientId; + + @Override + public String getId() { + return ID; + } + + @Override + public RealmResourceProvider create(KeycloakSession session) { + return new UserPasskeyProvider(session, allowedBrowserOriginPattern, clientId); + } + + @Override + public void init(Config.Scope config) { + allowedBrowserOriginPattern = PasskeyConfigResolver.resolveAllowedOriginPattern(config); + clientId = PasskeyConfigResolver.resolveClientId(config); + } + + @Override + public void postInit(KeycloakSessionFactory factory) {} + + @Override + public void close() {} + +} diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java new file mode 100644 index 00000000..339b36b4 --- /dev/null +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java @@ -0,0 +1,338 @@ +package com.example.keycloak; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotAuthorizedException; +import jakarta.ws.rs.OPTIONS; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.jboss.logging.Logger; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.ErrorResponse; +import org.keycloak.services.ErrorResponseException; +import org.keycloak.services.cors.Cors; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.AuthenticationManager; + +import java.util.Map; +import java.util.regex.Pattern; + +@Path("/") +public class UserPasskeyResource { + + private static final Logger logger = Logger.getLogger(UserPasskeyResource.class); + private static final String ERROR_SERVER_CONFIGURATION = "Server configuration error"; + private static final String ERROR_INVALID_OR_EXPIRED_CHALLENGE = "Invalid or expired challenge"; + private static final String ERROR_REQUEST_BODY_REQUIRED = "Request body is required"; + + private final KeycloakSession session; + private final Pattern allowedBrowserOrigin; + private final String clientId; + private final PasskeyClientSupport clientSupport; + + /** + * Default constructor for CDI environments that require a no-arg constructor. + *

+ * The SPI-managed constructor should be used for normal runtime operation. + */ + public UserPasskeyResource() { + this.session = null; + this.allowedBrowserOrigin = Pattern.compile("^" + PasskeyConfigResolver.resolveAllowedOriginPatternFromEnv() + "$"); + this.clientId = PasskeyConfigResolver.resolveClientIdFromEnv(); + this.clientSupport = new PasskeyClientSupport(this.clientId); + } + + /** + * Creates the passkey resource with SPI-provided session and resolved configuration. + * + * @param session Keycloak request session + * @param allowedOriginPattern configured allowed browser origin regex (without anchors) + * @param clientId configured OIDC client identifier used for passkey flows + */ + public UserPasskeyResource(KeycloakSession session, String allowedOriginPattern, String clientId) { + this.session = session; + String configuredPattern = PasskeyConfigResolver.firstNonBlank( + allowedOriginPattern, + PasskeyConfigResolver.resolveAllowedOriginPatternFromEnv() + ); + this.allowedBrowserOrigin = Pattern.compile("^" + configuredPattern + "$"); + this.clientId = PasskeyConfigResolver.firstNonBlank( + clientId, + PasskeyConfigResolver.resolveClientIdFromEnv() + ); + this.clientSupport = new PasskeyClientSupport(this.clientId); + } + + /** + * Handles browser CORS preflight requests for all passkey endpoints. + * + * @return preflight response with CORS headers when client configuration is available + */ + @OPTIONS + @Path("{any:.*}") + public Response corsPreflight() { + Response.ResponseBuilder responseBuilder = Response.ok(); + applyCors(responseBuilder, true); + return responseBuilder.build(); + } + + /** + * Issues a short-lived challenge used by registration and authentication requests. + * + * @return JSON object containing a base64url challenge value + */ + @GET + @Path("challenge") + @Produces(MediaType.APPLICATION_JSON) + public Response getChallenge() { + try { + return jsonOk(Map.of("challenge", challengeService().issueChallenge())); + } catch (IllegalStateException e) { + return handleServerConfigurationError("Passkey challenge creation failed due to server configuration", e); + } + } + + /** + * Stores a new passwordless WebAuthn credential for the authenticated bearer token user. + * + * @param request passkey registration payload + * @return created response on success, error response otherwise + */ + @POST + @Path("save") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response savePasskey(PasskeyRequest request) { + if (request == null) { + return buildErrorResponse(Response.Status.BAD_REQUEST, ERROR_REQUEST_BODY_REQUIRED); + } + + UserModel user = getUserFromBearerToken(); + if (user == null) { + return textResponse(Response.Status.UNAUTHORIZED, "Authenticated user not found from access token"); + } + + if (!challengeService().consumeChallenge(request.getChallenge())) { + return buildErrorResponse(Response.Status.UNAUTHORIZED, ERROR_INVALID_OR_EXPIRED_CHALLENGE); + } + + try { + webAuthnService().registerPasskey(user, request, request.getChallenge()); + return textResponse(Response.Status.CREATED, "Passkey stored successfully"); + } catch (IllegalArgumentException e) { + return buildErrorResponse(Response.Status.BAD_REQUEST, "Invalid registration payload: " + e.getMessage()); + } catch (IllegalStateException e) { + return handleServerConfigurationError("Passkey registration failed due to server configuration", e); + } + } + + /** + * Verifies a passkey assertion and continues the regular Keycloak browser login flow. + * + * @param request passkey authentication payload + * @return browser flow response on success, error response otherwise + */ + @POST + @Path("authenticate") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response authenticatePasskey(PasskeyRequest request) { + if (request == null) { + return buildErrorResponse(Response.Status.BAD_REQUEST, ERROR_REQUEST_BODY_REQUIRED); + } + + RealmModel realm = session().getContext().getRealm(); + if (realm == null) { + return handleServerConfigurationError("Realm context unavailable for passkey authentication", new IllegalStateException("Realm context is unavailable")); + } + + String requestCredentialId = webAuthnService().resolveCredentialId(request); + if (requestCredentialId == null) { + return buildErrorResponse(Response.Status.BAD_REQUEST, "credentialId or rawId is required"); + } + + if (!challengeService().consumeChallenge(request.getChallenge())) { + return buildErrorResponse(Response.Status.UNAUTHORIZED, ERROR_INVALID_OR_EXPIRED_CHALLENGE); + } + + UserModel user = webAuthnService().findUserByCredentialId(realm, requestCredentialId); + if (user == null) { + return buildErrorResponse(Response.Status.NOT_FOUND, "User not found for credential"); + } + + if (!webAuthnService().hasPasskeyCredential(user, requestCredentialId)) { + return buildErrorResponse(Response.Status.NOT_FOUND, "No passkey found for user: " + user.getUsername()); + } + + try { + if (!webAuthnService().authenticatePasskey(user, request, requestCredentialId)) { + return buildErrorResponse(Response.Status.UNAUTHORIZED, "Invalid passkey"); + } + + return withCors(browserLoginService().completeLogin(user, realm)); + } catch (IllegalArgumentException e) { + return buildErrorResponse(Response.Status.BAD_REQUEST, "Invalid authentication payload: " + e.getMessage()); + } catch (IllegalStateException e) { + return handleServerConfigurationError("Passkey authentication failed due to server configuration", e); + } catch (Exception e) { + logger.error("Browser-flow completion after passkey authentication failed: " + e.getMessage(), e); + return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Authentication flow failed"); + } + } + + /** + * Resolves the current user from the bearer token in request headers using Keycloak's authenticator. + * + * @return authenticated user, or {@code null} when token validation fails + */ + private UserModel getUserFromBearerToken() { + RealmModel realm = session().getContext().getRealm(); + if (realm == null) { + return null; + } + + AuthenticationManager.AuthResult authResult; + try { + authResult = new AppAuthManager.BearerTokenAuthenticator(session()) + .setRealm(realm) + .setConnection(session().getContext().getConnection()) + .setUriInfo(session().getContext().getUri()) + .setHeaders(session().getContext().getRequestHeaders()) + .authenticate(); + } catch (NotAuthorizedException ignored) { + return null; + } catch (RuntimeException ignored) { + return null; + } + + if (authResult == null || authResult.getUser() == null) { + return null; + } + + if (clientId != null && !clientId.isBlank()) { + ClientModel tokenClient = authResult.getClient(); + if (tokenClient == null || !clientId.equals(tokenClient.getClientId())) { + return null; + } + } + + return authResult.getUser(); + } + + /** + * Returns the active SPI session and fails fast if the resource was created without it. + */ + private KeycloakSession session() { + if (session == null) { + throw new IllegalStateException("UserPasskeyResource must be created by UserPasskeyProvider (SPI-managed session required)."); + } + return session; + } + + /** + * Creates the challenge service for the current request context. + */ + private PasskeyChallengeService challengeService() { + return new PasskeyChallengeService(session(), clientSupport); + } + + /** + * Creates the WebAuthn service for the current request context. + */ + private PasskeyWebAuthnService webAuthnService() { + return new PasskeyWebAuthnService(session(), allowedBrowserOrigin); + } + + /** + * Creates the browser-login service for the current request context. + */ + private PasskeyBrowserLoginService browserLoginService() { + return new PasskeyBrowserLoginService(session(), clientSupport); + } + + /** + * Logs and returns a uniform internal-server-error response for configuration problems. + */ + private Response handleServerConfigurationError(String logMessage, IllegalStateException exception) { + logger.error(logMessage, exception); + return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, ERROR_SERVER_CONFIGURATION); + } + + /** + * Builds a 200 JSON response wrapped with CORS headers. + */ + private Response jsonOk(Object payload) { + return jsonResponse(Response.Status.OK, payload); + } + + /** + * Builds a JSON response with CORS headers. + */ + private Response jsonResponse(Response.Status status, Object payload) { + Response.ResponseBuilder builder = status == Response.Status.OK + ? Response.ok(payload) + : Response.status(status).entity(payload); + return withCors(builder.type(MediaType.APPLICATION_JSON_TYPE).build()); + } + + /** + * Builds a plain-text response with CORS headers. + */ + private Response textResponse(Response.Status status, String payload) { + return withCors(Response.status(status) + .entity(payload) + .type(MediaType.TEXT_PLAIN_TYPE) + .build()); + } + + /** + * Builds a Keycloak-style error response and applies CORS headers. + */ + private Response buildErrorResponse(Response.Status status, String message) { + ErrorResponseException errorResponse = ErrorResponse.error( + PasskeyConfigResolver.firstNonBlank(message, ""), + status + ); + return withCors(errorResponse.getResponse()); + } + + /** + * Rebuilds the response while appending CORS headers for configured clients. + */ + private Response withCors(Response response) { + Response.ResponseBuilder responseBuilder = Response.fromResponse(response); + applyCors(responseBuilder, false); + return responseBuilder.build(); + } + + /** + * Applies Keycloak CORS settings based on configured client web origins. + * + * @param responseBuilder response builder to mutate + * @param preflight whether to apply preflight-specific headers + */ + private void applyCors(Response.ResponseBuilder responseBuilder, boolean preflight) { + ClientModel corsClient = clientSupport.resolveConfiguredClient(session().getContext().getRealm()); + if (corsClient == null) { + return; + } + + Cors cors = Cors.builder() + .builder(responseBuilder) + .auth() + .allowedMethods("GET", "POST", "OPTIONS"); + + if (preflight) { + cors.preflight(); + } + + cors.allowedOrigins(session(), corsClient); + cors.add(); + } +} diff --git a/keycloak-extension-passkey/src/main/resources/META-INF/beans.xml b/keycloak-extension-passkey/src/main/resources/META-INF/beans.xml new file mode 100644 index 00000000..ded49af4 --- /dev/null +++ b/keycloak-extension-passkey/src/main/resources/META-INF/beans.xml @@ -0,0 +1,4 @@ + + diff --git a/keycloak-extension-passkey/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory b/keycloak-extension-passkey/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory new file mode 100644 index 00000000..285e6994 --- /dev/null +++ b/keycloak-extension-passkey/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory @@ -0,0 +1 @@ +com.example.keycloak.UserPasskeyProviderFactory \ No newline at end of file diff --git a/module-management-realm.json b/module-management-realm.json index 20e30a7b..ccd230cc 100644 --- a/module-management-realm.json +++ b/module-management-realm.json @@ -2353,7 +2353,7 @@ "cibaInterval": "5", "realmReusableOtpCode": "false" }, - "keycloakVersion": "26.0.6", + "keycloakVersion": "26.5.0", "userManagedAccessAllowed": false, "organizationsEnabled": false, "clientProfiles": { From 3fea383101f677af9d8cc87716dd3c1e0856b075 Mon Sep 17 00:00:00 2001 From: Johannes Pahle Date: Wed, 25 Mar 2026 17:05:57 +0100 Subject: [PATCH 38/39] Fix chromium cors issue --- Client/src/app/core/security/passkey-extension.service.ts | 5 +++++ docker/docker-compose.dev.yaml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Client/src/app/core/security/passkey-extension.service.ts b/Client/src/app/core/security/passkey-extension.service.ts index 52374263..aa486c9d 100644 --- a/Client/src/app/core/security/passkey-extension.service.ts +++ b/Client/src/app/core/security/passkey-extension.service.ts @@ -171,10 +171,15 @@ export class PasskeyExtensionService { const authRes = await fetch(this.getUrl('authenticate'), { method: 'POST', credentials: 'include', + redirect: 'manual', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); + if (authRes.type === 'opaqueredirect') { + return; + } + const authResult = await this.readJsonBody<{ error?: string }>(authRes); if (!authRes.ok) { throw new Error(authResult?.error || `Passkey authentication failed (${authRes.status})`); diff --git a/docker/docker-compose.dev.yaml b/docker/docker-compose.dev.yaml index ff03a0da..d3375506 100644 --- a/docker/docker-compose.dev.yaml +++ b/docker/docker-compose.dev.yaml @@ -25,7 +25,7 @@ services: KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin} KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} KC_PASSKEY_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-module-management} - KC_ALLOWED_BROWSER_ORIGIN: ${KEYCLOAK_ALLOWED_ORIGIN:-http://localhost:4200} + KC_ALLOWED_BROWSER_ORIGIN: ${KC_ALLOWED_BROWSER_ORIGIN:-http://localhost:4200} command: start-dev --import-realm volumes: - keycloak_data:/opt/keycloak/data From 5292edb979c14367a055146d843631509745e381 Mon Sep 17 00:00:00 2001 From: Johannes Pahle Date: Tue, 31 Mar 2026 18:05:16 +0200 Subject: [PATCH 39/39] Update keycloak passkey extension; Adapt keycloak.service.ts to match dev guide; Change login buttons --- .../components/header/header.component.html | 5 +- .../components/sign-in/sign-in.component.html | 56 ++-- .../components/sign-in/sign-in.component.ts | 3 +- .../src/app/core/security/keycloak.service.ts | 172 ++++++++---- .../security/passkey-extension.service.ts | 188 ------------- .../app/core/security/security-interceptor.ts | 2 +- .../core/security/security-store.service.ts | 16 +- Client/src/app/layout.component.html | 2 +- Client/src/app/layout.component.ts | 8 +- keycloak-extension-passkey/README.md | 248 ++++++++++++++++++ .../com/example/keycloak/PasskeyRequest.java | 10 - .../keycloak/PasskeyWebAuthnService.java | 13 +- .../example/keycloak/UserPasskeyResource.java | 117 ++++++--- 13 files changed, 515 insertions(+), 325 deletions(-) delete mode 100644 Client/src/app/core/security/passkey-extension.service.ts create mode 100644 keycloak-extension-passkey/README.md diff --git a/Client/src/app/components/header/header.component.html b/Client/src/app/components/header/header.component.html index b552d2f3..e96b06f4 100644 --- a/Client/src/app/components/header/header.component.html +++ b/Client/src/app/components/header/header.component.html @@ -22,7 +22,8 @@ tooltipPosition="bottom" [ariaLabel]="isDarkMode() ? 'Switch to Light Mode' : 'Switch to Dark Mode'" /> - - + @if (user() !== undefined) { + + }

diff --git a/Client/src/app/components/sign-in/sign-in.component.html b/Client/src/app/components/sign-in/sign-in.component.html index 057109c0..45e3de05 100644 --- a/Client/src/app/components/sign-in/sign-in.component.html +++ b/Client/src/app/components/sign-in/sign-in.component.html @@ -8,26 +8,40 @@ [style]="{ 'min-width': '150px' }" /> - + } @else { - - - - + @if (securityStore.isLoading()) { +
+ + Loading... +
+ } @else { +
+ + + +
+ } } diff --git a/Client/src/app/components/sign-in/sign-in.component.ts b/Client/src/app/components/sign-in/sign-in.component.ts index d92b434a..fbed7ae7 100644 --- a/Client/src/app/components/sign-in/sign-in.component.ts +++ b/Client/src/app/components/sign-in/sign-in.component.ts @@ -1,7 +1,6 @@ import { Component, inject } from '@angular/core'; import { RouterModule } from '@angular/router'; import { ButtonModule } from 'primeng/button'; -import { ButtonGroupModule } from 'primeng/buttongroup'; import { MenuModule } from 'primeng/menu'; import { TooltipModule } from 'primeng/tooltip'; import { MenuItem } from 'primeng/api'; @@ -10,7 +9,7 @@ import { SecurityStore } from '../../core/security/security-store.service'; @Component({ selector: 'app-sign-in', standalone: true, - imports: [RouterModule, ButtonModule, ButtonGroupModule, MenuModule, TooltipModule], + imports: [RouterModule, ButtonModule, MenuModule, TooltipModule], templateUrl: './sign-in.component.html' }) export class SignInComponent { diff --git a/Client/src/app/core/security/keycloak.service.ts b/Client/src/app/core/security/keycloak.service.ts index 02b655a5..ac57074b 100644 --- a/Client/src/app/core/security/keycloak.service.ts +++ b/Client/src/app/core/security/keycloak.service.ts @@ -1,65 +1,143 @@ -import { Injectable, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { environment } from '../../../../environments/environment'; +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {environment} from '../../../../environments/environment'; import Keycloak from 'keycloak-js'; -import { KeycloakCredentialType } from './keycloak-credentials.types'; +import {KeycloakCredentialType} from './keycloak-credentials.types'; -@Injectable({ providedIn: 'root' }) +function toBase64Url(buffer: ArrayBuffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function fromBase64Url(value: string) { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); +} + +@Injectable({providedIn: 'root'}) export class KeycloakService { - private http = inject(HttpClient); - _keycloak: Keycloak | undefined; + checkSsoOptions = { + onLoad: 'check-sso' as const, + pkceMethod: 'S256' as const, + silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`, + silentCheckSsoFallback: false + }; + private readonly http = inject(HttpClient); - get keycloak() { + private _keycloak?: Keycloak; + + get keycloak(): Keycloak { if (!this._keycloak) { - this._keycloak = new Keycloak({ - url: environment.keycloak.url, - realm: environment.keycloak.realm, - clientId: environment.keycloak.clientId - }); + throw new Error('Keycloak not initialized'); } return this._keycloak; } - async init() { - return await this.keycloak.init({ - onLoad: 'check-sso', - silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html', - silentCheckSsoFallback: true, - checkLoginIframe: true, - pkceMethod: 'S256' - }); + passkeyUrl(path: string) { + return `${environment.keycloak.url}/realms/${encodeURIComponent(environment.keycloak.realm)}/passkey/${path}`; } - get bearer() { - return this.keycloak.token; + async getChallenge() { + const res = await fetch(this.passkeyUrl('challenge'), {credentials: 'include'}); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Failed to get challenge'); + return body.challenge; } - /** - * Update access token if it is about to expire or has expired - * This is independent from the silent check sso or refresh token validity. - * @returns - */ - async updateToken() { - if (!this.keycloak.isTokenExpired(60)) { - return false; - } - try { - // Try to refresh token - return await this.keycloak.updateToken(60); - } catch (error) { - console.error('Failed to refresh token:', error); - // Redirect to login if refresh fails - await this.keycloak.login(); - return false; + initAuth(): Promise { + this._keycloak = new Keycloak(environment.keycloak); + return this._keycloak.init(this.checkSsoOptions); + } + + updateToken(minValidity = 60): Promise { + if (!this.keycloak.authenticated) { + return Promise.resolve(false); } + return this.keycloak.updateToken(minValidity); } - loginWithTumRedirect(returnUrl?: string) { - return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl ?? '') }); + signInWithTum(returnUrl?: string) { + return this.keycloak.login({redirectUri: returnUrl || window.location.href}); } - logout() { - return this.keycloak.logout({ redirectUri: environment.redirect }); + async registerPasskey() { + if (!this.keycloak.authenticated || !this.keycloak.token) { + throw new Error('User must be logged in first'); + } + + const challenge = await this.getChallenge(); + const username = (this.keycloak.tokenParsed?.['preferred_username'] as string | undefined) || this.keycloak.tokenParsed?.sub || 'user'; + const displayName = (this.keycloak.tokenParsed?.['name'] as string | undefined) || username; + const userIdBytes = new TextEncoder().encode(username).slice(0, 64); + + const credential = await navigator.credentials.create({ + publicKey: { + challenge: fromBase64Url(challenge), + rp: {name: 'Module Management', id: window.location.hostname}, + user: {id: userIdBytes, name: username, displayName}, + pubKeyCredParams: [{type: 'public-key', alg: -7}], + authenticatorSelection: {residentKey: 'required', userVerification: 'preferred'}, + attestation: 'none' + } + }); + if (!(credential instanceof PublicKeyCredential)) { + throw new Error('Failed to create public key credential'); + } + if (!(credential.response instanceof AuthenticatorAttestationResponse)) { + throw new Error('Invalid attestation response'); + } + + const res = await fetch(this.passkeyUrl('save'), { + method: 'POST', credentials: 'include', headers: { + 'Content-Type': 'application/json', Authorization: `Bearer ${this.keycloak.token}` + }, body: JSON.stringify({ + credentialId: toBase64Url(credential.rawId), + rawId: toBase64Url(credential.rawId), + clientDataJSON: toBase64Url(credential.response.clientDataJSON), + attestationObject: toBase64Url(credential.response.attestationObject), + challenge + }) + }); + + if (!res.ok) throw new Error(await res.text()); + } + + async signInWithPasskey() { + const challenge = await this.getChallenge(); + const assertion = await navigator.credentials.get({ + publicKey: {challenge: fromBase64Url(challenge), userVerification: 'preferred'} + }); + if (!(assertion instanceof PublicKeyCredential)) { + throw new Error('Failed to create public key assertion'); + } + if (!(assertion.response instanceof AuthenticatorAssertionResponse)) { + throw new Error('Invalid assertion response'); + } + + const res = await fetch(this.passkeyUrl('authenticate'), { + method: 'POST', + credentials: 'include', + headers: {'Content-Type': 'application/json', Accept: 'application/json'}, + body: JSON.stringify({ + credentialId: toBase64Url(assertion.rawId), + rawId: toBase64Url(assertion.rawId), + clientDataJSON: toBase64Url(assertion.response.clientDataJSON), + authenticatorData: toBase64Url(assertion.response.authenticatorData), + signature: toBase64Url(assertion.response.signature), + challenge + }) + }); + + if (res.status !== 204) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Passkey auth failed: ${res.status}`); + } + + const authenticated = await this.initAuth(); // silent check-sso refresh + if (!authenticated) throw new Error('No session after passkey auth'); } getCredentials() { @@ -69,6 +147,10 @@ export class KeycloakService { deleteCredential(credentialId: string) { const url = `${environment.keycloak.url}/realms/${environment.keycloak.realm}/account/credentials/${credentialId}`; - return this.http.delete(url); + return this.http.delete(url); + } + + logout() { + return this.keycloak.logout({redirectUri: environment.redirect}); } } diff --git a/Client/src/app/core/security/passkey-extension.service.ts b/Client/src/app/core/security/passkey-extension.service.ts deleted file mode 100644 index aa486c9d..00000000 --- a/Client/src/app/core/security/passkey-extension.service.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { inject, Injectable } from '@angular/core'; -import { environment } from '../../../../environments/environment'; -import { KeycloakService } from './keycloak.service'; - -/** - * In-app WebAuthn via Keycloak realm extension (same flow as ba-test-keycloak {@code public/app.js}): - * register: {@code /passkey/challenge} + {@code /passkey/save}; sign-in: {@code /passkey/get-credential-id} + {@code /passkey/authenticate}. - */ -@Injectable({ providedIn: 'root' }) -export class PasskeyExtensionService { - private readonly keycloakService = inject(KeycloakService); - - private passkeyBaseUrl(): string { - const base = environment.keycloak.url.replace(/\/$/, ''); - const realm = encodeURIComponent(environment.keycloak.realm); - return `${base}/realms/${realm}/passkey`; - } - - private getUrl(path: string): string { - const p = path.replace(/^\/+/, ''); - return `${this.passkeyBaseUrl()}/${p}`; - } - - private base64UrlToUint8Array(value: string): Uint8Array { - const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); - const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); - return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); - } - - private bufferToBase64Url(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ''; - for (let i = 0; i < bytes.length; i += 1) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); - } - - private async readJsonBody(response: Response): Promise { - const contentType = response.headers.get('content-type') ?? ''; - if (!contentType.toLowerCase().includes('application/json')) { - return undefined; - } - try { - return (await response.json()) as T; - } catch { - return undefined; - } - } - - /** - * Register a new passkey for the current user (must already be logged in). - */ - async registerPasskeyInBrowser(): Promise { - const kc = this.keycloakService.keycloak; - if (!kc.authenticated || !kc.token) { - throw new Error('You must be signed in to register a passkey.'); - } - - await kc.updateToken(60); - const token = kc.token; - if (!token) { - throw new Error('No access token available.'); - } - - const parsed = kc.tokenParsed as Record | undefined; - const accountId = String(parsed?.['sub'] ?? parsed?.['preferred_username'] ?? ''); - const accountName = String(parsed?.['preferred_username'] ?? parsed?.['email'] ?? ''); - const displayName = String( - parsed?.['name'] ?? - ([parsed?.['given_name'], parsed?.['family_name']].filter(Boolean).join(' ') || accountName || 'User') - ); - - if (!accountId || !accountName) { - throw new Error('Missing user identity in token for passkey registration.'); - } - - const challengeRes = await fetch(this.getUrl('challenge'), { credentials: 'include' }); - if (!challengeRes.ok) { - throw new Error(`Failed to get WebAuthn challenge (${challengeRes.status})`); - } - const { challenge } = (await challengeRes.json()) as { challenge: string }; - if (!challenge) { - throw new Error('Invalid challenge response from Keycloak'); - } - - const userIdBytes = new TextEncoder().encode(accountId).slice(0, 64); - - const credential = (await navigator.credentials.create({ - publicKey: { - challenge: this.base64UrlToUint8Array(challenge) as BufferSource, - rp: { name: 'Module Management', id: window.location.hostname }, - user: { id: userIdBytes, name: accountName, displayName }, - pubKeyCredParams: [{ type: 'public-key', alg: -7 }], - authenticatorSelection: { userVerification: 'preferred', residentKey: 'required' }, - attestation: 'none' - } - })) as PublicKeyCredential | null; - - if (!credential?.response) { - throw new Error('Passkey creation was cancelled or failed.'); - } - - const response = credential.response as AuthenticatorAttestationResponse; - const savePayload = { - credentialId: this.bufferToBase64Url(credential.rawId), - rawId: this.bufferToBase64Url(credential.rawId), - clientDataJSON: this.bufferToBase64Url(response.clientDataJSON), - attestationObject: this.bufferToBase64Url(response.attestationObject), - challenge - }; - - const saveRes = await fetch(this.getUrl('save'), { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - }, - body: JSON.stringify(savePayload) - }); - - const saveText = await saveRes.text(); - if (!saveRes.ok) { - throw new Error(saveText || `Failed to store passkey (${saveRes.status})`); - } - - await kc.updateToken(-1); - } - - /** - * Sign in with passkey only (no Keycloak UI redirect). - * The extension endpoint sets the Keycloak login cookie; the SPA should then reload - * and let keycloak-js initialize via check-sso. - */ - async signInWithPasskey(): Promise { - const optionsResponse = await fetch(this.getUrl('challenge'), { credentials: 'include' }); - const res = await this.readJsonBody<{ challenge?: string; credentialId?: string; error?: string }>(optionsResponse); - if (!optionsResponse.ok) { - throw new Error(res?.error || `Failed to get passkey options (${optionsResponse.status})`); - } - if (!res?.challenge) { - throw new Error('Invalid challenge response from server'); - } - - const publicKey: PublicKeyCredentialRequestOptions = { - challenge: this.base64UrlToUint8Array(res.challenge) as BufferSource, - userVerification: 'preferred' - }; - if (res.credentialId) { - publicKey.allowCredentials = [ - { type: 'public-key', id: this.base64UrlToUint8Array(res.credentialId) as BufferSource } - ]; - } - - const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null; - if (!credential?.response) { - throw new Error('Passkey sign-in was cancelled or failed.'); - } - - const ar = credential.response as AuthenticatorAssertionResponse; - const payload = { - credentialId: this.bufferToBase64Url(credential.rawId), - rawId: this.bufferToBase64Url(credential.rawId), - clientDataJSON: this.bufferToBase64Url(ar.clientDataJSON), - authenticatorData: this.bufferToBase64Url(ar.authenticatorData), - signature: this.bufferToBase64Url(ar.signature), - challenge: res.challenge - }; - - const authRes = await fetch(this.getUrl('authenticate'), { - method: 'POST', - credentials: 'include', - redirect: 'manual', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - if (authRes.type === 'opaqueredirect') { - return; - } - - const authResult = await this.readJsonBody<{ error?: string }>(authRes); - if (!authRes.ok) { - throw new Error(authResult?.error || `Passkey authentication failed (${authRes.status})`); - } - } -} diff --git a/Client/src/app/core/security/security-interceptor.ts b/Client/src/app/core/security/security-interceptor.ts index d990205c..188f8c19 100644 --- a/Client/src/app/core/security/security-interceptor.ts +++ b/Client/src/app/core/security/security-interceptor.ts @@ -10,7 +10,7 @@ export const securityInterceptor: HttpInterceptorFn = (req, next) => { return from(keycloakService.updateToken()).pipe( switchMap(() => { // add bearer token to request - const bearer = keycloakService.bearer; + const bearer = keycloakService.keycloak.token; if (!bearer) { return next(req); diff --git a/Client/src/app/core/security/security-store.service.ts b/Client/src/app/core/security/security-store.service.ts index 153f9426..89c6526b 100644 --- a/Client/src/app/core/security/security-store.service.ts +++ b/Client/src/app/core/security/security-store.service.ts @@ -1,7 +1,6 @@ import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; import { isPlatformServer } from '@angular/common'; import { KeycloakService } from './keycloak.service'; -import { PasskeyExtensionService } from './passkey-extension.service'; import { firstValueFrom } from 'rxjs'; import { UserControllerService, User } from '../modules/openapi'; import { Passkey } from './keycloak-credentials.types'; @@ -14,7 +13,6 @@ function passkeyDialogDismissedStorageKey(sub: string): string { @Injectable({ providedIn: 'root' }) export class SecurityStore { keycloakService = inject(KeycloakService); - passkeyExtension = inject(PasskeyExtensionService); userControllerService = inject(UserControllerService); private readonly messageService = inject(MessageService); @@ -35,7 +33,7 @@ export class SecurityStore { } this.isLoading.set(true); - const isLoggedIn = await this.keycloakService.init(); + const isLoggedIn = await this.keycloakService.initAuth(); if (isLoggedIn) { await this.loadPasskeys(); @@ -80,7 +78,7 @@ export class SecurityStore { } async signInWithTum(returnUrl?: string) { - await this.keycloakService.loginWithTumRedirect(returnUrl); + await this.keycloakService.signInWithTum(returnUrl); } async signIn(returnUrl?: string) { @@ -90,10 +88,10 @@ export class SecurityStore { async signInWithPasskey(): Promise { this.isLoading.set(true); try { - await this.passkeyExtension.signInWithPasskey(); - // Passkey authenticate sets the Keycloak login cookie; reload so init(check-sso) - // can bootstrap keycloak-js token state through the standard adapter flow. - window.location.reload(); + await this.keycloakService.signInWithPasskey(); + await this.loadPasskeys(); + const user = await firstValueFrom(this.userControllerService.getCurrentUser()); + this.user.set(user); } catch (error) { this.messageService.add({ severity: 'error', summary: 'Sign-in', detail: 'Something went wrong' }); console.error(error); @@ -110,7 +108,7 @@ export class SecurityStore { } async registerPasskey(_returnUrl?: string) { - await this.passkeyExtension.registerPasskeyInBrowser(); + await this.keycloakService.registerPasskey(); await this.loadPasskeys(); } diff --git a/Client/src/app/layout.component.html b/Client/src/app/layout.component.html index 956e9709..e73fea98 100644 --- a/Client/src/app/layout.component.html +++ b/Client/src/app/layout.component.html @@ -39,7 +39,7 @@
- +
diff --git a/Client/src/app/layout.component.ts b/Client/src/app/layout.component.ts index 3481161f..b08e002c 100644 --- a/Client/src/app/layout.component.ts +++ b/Client/src/app/layout.component.ts @@ -32,10 +32,10 @@ export class LayoutComponent { this.securityStore.closePasskeyDialog(this.dontShowPasskeyDialogAgain); } - goToPasskeyRegistration(): void { - const skip = this.dontShowPasskeyDialogAgain; - this.securityStore.closePasskeyDialog(skip); - void this.router.navigate(['/account/passkeys']); + async registerPasskeyFromDialog(): Promise { + await this.securityStore.registerPasskey(); + this.securityStore.closePasskeyDialog(this.dontShowPasskeyDialogAgain); + await this.router.navigate(['/account/passkeys']); } dismissPasskeyDialog(): void { diff --git a/keycloak-extension-passkey/README.md b/keycloak-extension-passkey/README.md new file mode 100644 index 00000000..4016d103 --- /dev/null +++ b/keycloak-extension-passkey/README.md @@ -0,0 +1,248 @@ +# Keycloak Passkey Extension Integration (Quick Guide) + +## Admin/Dev quick summary + +To use this extension, you install one custom Keycloak provider JAR and configure Keycloak plus realm/client settings to match your environment. Required customizations are: + +- Deploy `custom-endpoint-1.0-SNAPSHOT.jar` to `keycloak/providers/` and restart Keycloak. +- Set `KC_PASSKEY_CLIENT_ID` to the OIDC client used for browser login completion. +- Set `KC_ALLOWED_BROWSER_ORIGIN` to your client origin(s) for CORS. +- Enable Passwordless WebAuthn in the realm and set the Passwordless RP ID to your app host. +- Configure the matching client with correct `Web Origins` and `Redirect URIs`. +- Configure silent `check-sso` in your SPA and serve a callback page (for example `/silent-check-sso.html`). + +After that, your client calls `/realms/{realm}/passkey/{challenge|save|authenticate}` with `fetch(..., { credentials: 'include' })`. The extension sets CORS via Keycloak's `Cors.auth()` behavior (including `Access-Control-Allow-Credentials: true`) for cross-origin credentialed requests. + +--- + +## How to use it? + +This extension adds passkey APIs to Keycloak at: + +`/realms/{realm}/passkey/*` + +Endpoints: + +- `GET /challenge` +- `POST /save` +- `POST /authenticate` + +## How the plugin works + +The plugin is a Keycloak `RealmResourceProvider` mounted at `/realms/{realm}/passkey/*`. + +1. `GET /challenge` creates a short-lived, single-use challenge in Keycloak server storage. +2. `POST /save` stores a verified passkey for the currently logged-in user (resolved from the bearer access token). +3. `POST /authenticate` verifies the WebAuthn assertion, completes the standard Keycloak browser login flow (including required actions), sets the Keycloak login cookie, and returns `204 No Content`. + +How `check-sso` uses that session: + +- The client calls `/authenticate` with `credentials: 'include'` so the Keycloak login cookie is written in the browser. +- After successful authentication, run `keycloak.init({ onLoad: 'check-sso' })` again to hydrate tokens from the new cookie-backed browser session (silent mode recommended). +- `check-sso` uses the existing Keycloak browser session (cookie) to silently authenticate and provide fresh tokens in `keycloak.token`/`keycloak.tokenParsed` for subsequent API calls. + +## 1. Build and deploy + +```bash +cd keycloak-custom-passkey-login +mvn clean package +cp target/custom-endpoint-1.0-SNAPSHOT.jar ../keycloak/providers/ +``` + +Restart Keycloak after copying the JAR. + +## 2. Configure Keycloak env vars + +- `KC_PASSKEY_CLIENT_ID`: OIDC client used by `/authenticate` to complete browser login flow. +- `KC_ALLOWED_BROWSER_ORIGIN`: CORS origin regex. + +Example: + +```env +KC_PASSKEY_CLIENT_ID=my-spa-client +KC_ALLOWED_BROWSER_ORIGIN=https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)? +``` + +## 3. Configure realm/client + +In your realm: + +1. Enable Passwordless WebAuthn. +2. Set Passwordless RP ID to your app host (e.g. `localhost` in local dev). +3. Ensure client `clientId == KC_PASSKEY_CLIENT_ID`. +4. Add your client URL to `Web Origins`. +5. Add callback URLs to `Redirect URIs`. +6. Add your silent check-sso callback URL to `Redirect URIs` (for example `http://localhost:3000/silent-check-sso.html`, or covered by wildcard). + +## 4. One client module (`keycloakClient.js`) + +### 4.1 Add silent check-sso callback page + +Create `public/silent-check-sso.html`: + +```html + + + + + + +``` + +### 4.2 Create one `keycloakClient.js` + +```js +import Keycloak from 'keycloak-js'; + +const keycloakConfig = { + url: 'http://localhost:8080', + realm: 'demo', + clientId: 'demo-app' +}; + +const checkSsoOptions = { + onLoad: 'check-sso', + pkceMethod: 'S256', + silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`, + silentCheckSsoFallback: false +}; + +let keycloak = new Keycloak(keycloakConfig); + +function toBase64Url(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function fromBase64Url(value) { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); +} + +function passkeyUrl(path) { + return `${keycloakConfig.url}/realms/${encodeURIComponent(keycloakConfig.realm)}/passkey/${path}`; +} + +async function getChallenge() { + const res = await fetch(passkeyUrl('challenge'), { credentials: 'include' }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Failed to get challenge'); + return body.challenge; +} + +export async function initAuth() { + keycloak = new Keycloak(keycloakConfig); + return keycloak.init(checkSsoOptions); +} + +export function loginWithPassword() { + return keycloak.login({ redirectUri: window.location.href }); +} + +export async function registerPasskey() { + if (!keycloak.authenticated || !keycloak.token) { + throw new Error('User must be logged in first'); + } + + const challenge = await getChallenge(); + const username = keycloak.tokenParsed?.preferred_username || keycloak.tokenParsed?.sub || 'user'; + const displayName = keycloak.tokenParsed?.name || username; + const userIdBytes = new TextEncoder().encode(username).slice(0, 64); + + const credential = await navigator.credentials.create({ + publicKey: { + challenge: fromBase64Url(challenge), + rp: { name: 'My App', id: window.location.hostname }, + user: { id: userIdBytes, name: username, displayName }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, + attestation: 'none' + } + }); + + const res = await fetch(passkeyUrl('save'), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${keycloak.token}` + }, + body: JSON.stringify({ + credentialId: toBase64Url(credential.rawId), + rawId: toBase64Url(credential.rawId), + clientDataJSON: toBase64Url(credential.response.clientDataJSON), + attestationObject: toBase64Url(credential.response.attestationObject), + challenge + }) + }); + + if (!res.ok) throw new Error(await res.text()); +} + +export async function loginWithPasskey() { + const challenge = await getChallenge(); + const assertion = await navigator.credentials.get({ + publicKey: { challenge: fromBase64Url(challenge), userVerification: 'preferred' } + }); + + const res = await fetch(passkeyUrl('authenticate'), { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + credentialId: toBase64Url(assertion.rawId), + rawId: toBase64Url(assertion.rawId), + clientDataJSON: toBase64Url(assertion.response.clientDataJSON), + authenticatorData: toBase64Url(assertion.response.authenticatorData), + signature: toBase64Url(assertion.response.signature), + challenge + }) + }); + + if (res.status !== 204) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Passkey auth failed: ${res.status}`); + } + + const authenticated = await initAuth(); // silent check-sso refresh + if (!authenticated) throw new Error('No session after passkey auth'); +} + +export function logout() { + return keycloak.logout({ redirectUri: `${window.location.origin}/` }); +} + +export function getKeycloak() { + return keycloak; +} +``` + +### 4.3 Use these functions from anywhere in your client + +```js +import { initAuth, login, loginWithPasskey, registerPasskey, logout, getKeycloak } from './keycloakClient.js'; + +// call once on app startup +const authenticated = await initAuth(); + +// later, for example on button clicks +await loginWithPassword(); + +await loginWithPasskey(); + +await registerPasskey(); + +await logout(); +``` + +## 5. Important notes + +- Challenge TTL is `120s` and single-use. +- `/challenge` and `/save` should be called with `credentials: 'include'` so auth cookies/sessions are preserved. +- `/save` requires `Authorization: Bearer ` and the issued registration `challenge`. +- `/authenticate` should be called with `credentials: 'include'`; on success it typically returns `204` after writing login cookies. +- Re-run `check-sso` after `/authenticate` to hydrate tokens into your SPA client (prefer `silentCheckSsoRedirectUri` + `silentCheckSsoFallback: false`). +- If you get CORS errors, fix `KC_ALLOWED_BROWSER_ORIGIN` and client `Web Origins`. diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java index 603edadb..9a140912 100644 --- a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java @@ -1,7 +1,5 @@ package com.example.keycloak; -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -9,27 +7,19 @@ @Getter @Setter @NoArgsConstructor -@AllArgsConstructor public class PasskeyRequest { - @JsonProperty("credentialId") private String credentialId; - @JsonProperty("rawId") private String rawId; - @JsonProperty("attestationObject") private String attestationObject; - @JsonProperty("clientDataJSON") private String clientDataJSON; - @JsonProperty("authenticatorData") private String authenticatorData; - @JsonProperty("signature") private String signature; - @JsonProperty("challenge") private String challenge; } diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java index dd73d720..1f9512ce 100644 --- a/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java @@ -127,7 +127,7 @@ void registerPasskey(UserModel user, PasskeyRequest request, String expectedChal WebAuthnCredentialProvider provider = getPasswordlessCredentialProvider(); WebAuthnCredentialModelInput credentialInput = createCredentialInput(registrationData); WebAuthnCredentialModel credentialModel = provider.getCredentialModelFromCredentialInput(credentialInput, user.getUsername()); - CredentialModel storedCredentialModel = user.credentialManager().createStoredCredential(credentialModel); + CredentialModel storedCredentialModel = provider.createCredential(realm, user, credentialModel); if (storedCredentialModel == null) { throw new IllegalStateException("Failed to store passkey credential"); } @@ -166,19 +166,14 @@ boolean authenticatePasskey(UserModel user, PasskeyRequest request, String crede return user.credentialManager().isValid(credentialInput); } - /** - * Builds the registration manager used to validate attestation payloads. - */ - protected WebAuthnRegistrationManager createWebAuthnRegistrationManager() { - return WebAuthnRegistrationManager.createNonStrictWebAuthnRegistrationManager(new ObjectConverter()); - } - /** * Parses and validates registration payload data. */ private RegistrationData validateRegistration(RegistrationRequest request, RegistrationParameters parameters) { try { - return createWebAuthnRegistrationManager().verify(request, parameters); + return WebAuthnRegistrationManager + .createNonStrictWebAuthnRegistrationManager(new ObjectConverter()) + .verify(request, parameters); } catch (DataConversionException | VerificationException e) { throw new IllegalArgumentException("Passkey registration validation failed: " + e.getMessage(), e); } diff --git a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java index 339b36b4..3860aeb3 100644 --- a/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java +++ b/keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java @@ -2,7 +2,6 @@ import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; -import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.OPTIONS; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; @@ -15,7 +14,6 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.services.ErrorResponse; -import org.keycloak.services.ErrorResponseException; import org.keycloak.services.cors.Cors; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AuthenticationManager; @@ -27,9 +25,6 @@ public class UserPasskeyResource { private static final Logger logger = Logger.getLogger(UserPasskeyResource.class); - private static final String ERROR_SERVER_CONFIGURATION = "Server configuration error"; - private static final String ERROR_INVALID_OR_EXPIRED_CHALLENGE = "Invalid or expired challenge"; - private static final String ERROR_REQUEST_BODY_REQUIRED = "Request body is required"; private final KeycloakSession session; private final Pattern allowedBrowserOrigin; @@ -42,10 +37,7 @@ public class UserPasskeyResource { * The SPI-managed constructor should be used for normal runtime operation. */ public UserPasskeyResource() { - this.session = null; - this.allowedBrowserOrigin = Pattern.compile("^" + PasskeyConfigResolver.resolveAllowedOriginPatternFromEnv() + "$"); - this.clientId = PasskeyConfigResolver.resolveClientIdFromEnv(); - this.clientSupport = new PasskeyClientSupport(this.clientId); + this(null, null, null); } /** @@ -57,15 +49,8 @@ public UserPasskeyResource() { */ public UserPasskeyResource(KeycloakSession session, String allowedOriginPattern, String clientId) { this.session = session; - String configuredPattern = PasskeyConfigResolver.firstNonBlank( - allowedOriginPattern, - PasskeyConfigResolver.resolveAllowedOriginPatternFromEnv() - ); - this.allowedBrowserOrigin = Pattern.compile("^" + configuredPattern + "$"); - this.clientId = PasskeyConfigResolver.firstNonBlank( - clientId, - PasskeyConfigResolver.resolveClientIdFromEnv() - ); + this.allowedBrowserOrigin = compileAllowedOriginPattern(allowedOriginPattern); + this.clientId = resolveClientId(clientId); this.clientSupport = new PasskeyClientSupport(this.clientId); } @@ -109,8 +94,9 @@ public Response getChallenge() { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Response savePasskey(PasskeyRequest request) { - if (request == null) { - return buildErrorResponse(Response.Status.BAD_REQUEST, ERROR_REQUEST_BODY_REQUIRED); + Response requestValidation = validateRequestBody(request); + if (requestValidation != null) { + return requestValidation; } UserModel user = getUserFromBearerToken(); @@ -118,8 +104,9 @@ public Response savePasskey(PasskeyRequest request) { return textResponse(Response.Status.UNAUTHORIZED, "Authenticated user not found from access token"); } - if (!challengeService().consumeChallenge(request.getChallenge())) { - return buildErrorResponse(Response.Status.UNAUTHORIZED, ERROR_INVALID_OR_EXPIRED_CHALLENGE); + Response challengeValidation = validateChallenge(challengeService(), request.getChallenge()); + if (challengeValidation != null) { + return challengeValidation; } try { @@ -143,8 +130,9 @@ public Response savePasskey(PasskeyRequest request) { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Response authenticatePasskey(PasskeyRequest request) { - if (request == null) { - return buildErrorResponse(Response.Status.BAD_REQUEST, ERROR_REQUEST_BODY_REQUIRED); + Response requestValidation = validateRequestBody(request); + if (requestValidation != null) { + return requestValidation; } RealmModel realm = session().getContext().getRealm(); @@ -157,8 +145,9 @@ public Response authenticatePasskey(PasskeyRequest request) { return buildErrorResponse(Response.Status.BAD_REQUEST, "credentialId or rawId is required"); } - if (!challengeService().consumeChallenge(request.getChallenge())) { - return buildErrorResponse(Response.Status.UNAUTHORIZED, ERROR_INVALID_OR_EXPIRED_CHALLENGE); + Response challengeValidation = validateChallenge(challengeService(), request.getChallenge()); + if (challengeValidation != null) { + return challengeValidation; } UserModel user = webAuthnService().findUserByCredentialId(realm, requestCredentialId); @@ -175,7 +164,7 @@ public Response authenticatePasskey(PasskeyRequest request) { return buildErrorResponse(Response.Status.UNAUTHORIZED, "Invalid passkey"); } - return withCors(browserLoginService().completeLogin(user, realm)); + return completeBrowserFlowLogin(user, realm); } catch (IllegalArgumentException e) { return buildErrorResponse(Response.Status.BAD_REQUEST, "Invalid authentication payload: " + e.getMessage()); } catch (IllegalStateException e) { @@ -205,8 +194,6 @@ private UserModel getUserFromBearerToken() { .setUriInfo(session().getContext().getUri()) .setHeaders(session().getContext().getRequestHeaders()) .authenticate(); - } catch (NotAuthorizedException ignored) { - return null; } catch (RuntimeException ignored) { return null; } @@ -261,7 +248,7 @@ private PasskeyBrowserLoginService browserLoginService() { */ private Response handleServerConfigurationError(String logMessage, IllegalStateException exception) { logger.error(logMessage, exception); - return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, ERROR_SERVER_CONFIGURATION); + return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Server configuration error"); } /** @@ -295,11 +282,10 @@ private Response textResponse(Response.Status status, String payload) { * Builds a Keycloak-style error response and applies CORS headers. */ private Response buildErrorResponse(Response.Status status, String message) { - ErrorResponseException errorResponse = ErrorResponse.error( + return withCors(ErrorResponse.error( PasskeyConfigResolver.firstNonBlank(message, ""), status - ); - return withCors(errorResponse.getResponse()); + ).getResponse()); } /** @@ -335,4 +321,69 @@ private void applyCors(Response.ResponseBuilder responseBuilder, boolean preflig cors.allowedOrigins(session(), corsClient); cors.add(); } + + private static Pattern compileAllowedOriginPattern(String configuredPattern) { + String resolvedPattern = PasskeyConfigResolver.firstNonBlank( + configuredPattern, + PasskeyConfigResolver.resolveAllowedOriginPatternFromEnv() + ); + return Pattern.compile("^" + resolvedPattern + "$"); + } + + private static String resolveClientId(String configuredClientId) { + return PasskeyConfigResolver.firstNonBlank( + configuredClientId, + PasskeyConfigResolver.resolveClientIdFromEnv() + ); + } + + private Response validateRequestBody(PasskeyRequest request) { + if (request == null) { + return buildErrorResponse(Response.Status.BAD_REQUEST, "Request body is required"); + } + return null; + } + + private Response validateChallenge(PasskeyChallengeService challengeService, String challenge) { + if (challengeService.consumeChallenge(challenge)) { + return null; + } + return buildErrorResponse(Response.Status.UNAUTHORIZED, "Invalid or expired challenge"); + } + + private Response completeBrowserFlowLogin(UserModel user, RealmModel realm) { + Response browserFlowResponse = browserLoginService().completeLogin(user, realm); + if (browserFlowResponse == null) { + return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Authentication flow failed"); + } + + if (!isRedirectResponse(browserFlowResponse)) { + return withCors(browserFlowResponse); + } + + Response.ResponseBuilder responseBuilder = Response.noContent(); + copySetCookieHeaders(browserFlowResponse, responseBuilder); + return withCors(responseBuilder.build()); + } + + private boolean isRedirectResponse(Response response) { + int status = response.getStatus(); + return (status >= 300 && status < 400) || response.getLocation() != null; + } + + private void copySetCookieHeaders(Response source, Response.ResponseBuilder target) { + var headers = source.getHeaders(); + if (headers == null) { + return; + } + + var setCookieHeaders = headers.get("Set-Cookie"); + if (setCookieHeaders == null) { + return; + } + + for (Object setCookieHeader : setCookieHeaders) { + target.header("Set-Cookie", setCookieHeader); + } + } }