Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
650775a
style: change font and polish UI
markstockhausen Jan 27, 2026
79e45a2
style: update font weight
markstockhausen Jan 27, 2026
430e1c2
style: conform to Prettier formatting
markstockhausen Feb 3, 2026
73a402b
Implement optimized Scheduling Service and Pipeline Logic
markstockhausen Feb 3, 2026
8850cf6
style: update comments
markstockhausen Feb 3, 2026
fe676c3
Implement AI suggestions
markstockhausen Feb 9, 2026
b199432
Update Axios, Optimise scheduling pipeline
markstockhausen Feb 9, 2026
67d6e58
Optimise pipeline logic and clean up code
markstockhausen Feb 10, 2026
60d7f66
Optimise pipeline
markstockhausen Feb 10, 2026
6251a3a
Update logic for bidirectional relationship types
markstockhausen Feb 10, 2026
e683410
Streamline implementation
markstockhausen Feb 10, 2026
141bebd
Conform to Prettier formatting
markstockhausen Feb 10, 2026
64c4f20
Implement AI suggestions
markstockhausen Feb 17, 2026
d863e77
Further AI suggestions
markstockhausen Feb 17, 2026
846fd3d
Merge branch 'main' into feature/implement-scheduling-service
markstockhausen Feb 17, 2026
7916901
feat: add dashboard with contribution heatmap, badges, and session su…
markstockhausen Feb 17, 2026
4b03244
style: fix Prettier formatting
markstockhausen Feb 17, 2026
2190551
Update heatmap
markstockhausen Feb 24, 2026
785d9fa
Conform to Prettier formatting
markstockhausen Feb 24, 2026
2704820
Fix security audit
markstockhausen Feb 24, 2026
32ccd39
feat: Add initial version of onboarding
markstockhausen Feb 25, 2026
99f89e1
fix onboarding flow
markstockhausen Feb 25, 2026
4a8eeba
conform to Prettier formatting
markstockhausen Feb 25, 2026
49e98c2
Update profile setup
markstockhausen Feb 27, 2026
c4a25a2
add simple auth implementation
vtotalova Feb 27, 2026
cf2faae
Merge branch 'main' into feature/36-security-implement-secure-authent…
vtotalova Feb 27, 2026
513e8c4
Fix skip feature, update package-lock.json
markstockhausen Mar 2, 2026
47042eb
Add milestone celebration toasts during mapping sessions
markstockhausen Mar 3, 2026
23c8d8b
Update screenshot on main page, extract CompetencyNetworkViz into reu…
markstockhausen Mar 3, 2026
65c98b0
implement auth
vtotalova Mar 4, 2026
f4f7497
fix issues
vtotalova Mar 4, 2026
7303eea
fix issues
vtotalova Mar 4, 2026
2d85326
Merge remote-tracking branch 'origin/main' into feature/onboarding
markstockhausen Mar 4, 2026
f1de300
Merge branch 'main' into feature/36-security-implement-secure-authent…
vtotalova Mar 4, 2026
f3de42b
Merge branch 'main' into feature/36-security-implement-secure-authent…
vtotalova Mar 4, 2026
6f48404
Fix package-lock.json
markstockhausen Mar 4, 2026
333175a
Merge branch 'main' into feature/onboarding
markstockhausen Mar 4, 2026
305892f
Conform to Prettier formatting
markstockhausen Mar 4, 2026
ed96f32
Merge branch 'feature/onboarding' of https://github.com/ls1intum/memo…
markstockhausen Mar 4, 2026
44e8467
Fix package-lock.json
markstockhausen Mar 4, 2026
0cb944d
Fix package-lock.json
markstockhausen Mar 4, 2026
3d6d590
fix merging of main issues
vtotalova Mar 4, 2026
f05a802
Implement AI suggestions
markstockhausen Mar 4, 2026
5c0dcff
Merge branch 'feature/onboarding' into feature/36-security-implement-…
vtotalova Mar 4, 2026
6b55740
fix auth process after merging
vtotalova Mar 4, 2026
15c09ab
implement auth in the onboarding flow
vtotalova Mar 4, 2026
93fb29b
Merge branch 'main' into feature/36-security-implement-secure-authent…
vtotalova Mar 4, 2026
a072b81
fix merge conflict issues
vtotalova Mar 4, 2026
781c6fe
update onboarding with policy link
vtotalova Mar 4, 2026
72bebe5
add fast fail if login email not authorized
vtotalova Mar 4, 2026
1fc4738
remove duplicates
vtotalova Mar 4, 2026
32b1769
fix warnings
vtotalova Mar 23, 2026
d37c5d8
fix quality issues in package
vtotalova Mar 23, 2026
fd6dc02
add admin page
vtotalova Mar 24, 2026
172c52a
refine admin panel
vtotalova Apr 7, 2026
db5fbd2
Merge branch 'main' into feature/40-feat-implement-role-based-access-…
vtotalova Apr 7, 2026
6290d0a
fix npm packeges
vtotalova Apr 7, 2026
75cca56
add night mode support
vtotalova Apr 7, 2026
de0a1f8
fix formating
vtotalova Apr 7, 2026
246cf78
Merge branch 'main' into feature/40-feat-implement-role-based-access-…
vtotalova Apr 14, 2026
e55c739
fix npm packages
vtotalova Apr 14, 2026
95ab67e
integrate copilot feedback
vtotalova Apr 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ dependencies {
// API Documentation
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4'

// CSV parsing
implementation 'org.apache.commons:commons-csv:1.11.0'

// Utilities
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package de.tum.cit.memo.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import de.tum.cit.memo.dto.CompetencyImportRow;
import de.tum.cit.memo.dto.ImportResult;
import de.tum.cit.memo.service.CompetencyService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVRecord;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
@Tag(name = "Admin", description = "Admin-only operations")
@SecurityRequirement(name = "bearer-jwt")
@SuppressWarnings("null")
public class AdminController {

private final CompetencyService competencyService;
private final ObjectMapper objectMapper;

@PostMapping("/competencies/import")
@Operation(summary = "Bulk import competencies from JSON array")
public ResponseEntity<ImportResult> importJson(
@Valid @RequestBody List<CompetencyImportRow> rows
) {
return ResponseEntity.ok(competencyService.bulkImportCompetencies(rows));
}

@PostMapping(value = "/competencies/import/file", consumes = "multipart/form-data")
@Operation(summary = "Bulk import competencies from uploaded CSV or JSON file")
public ResponseEntity<ImportResult> importFile(
@RequestParam("file") MultipartFile file
) throws IOException {
String originalFilename = file.getOriginalFilename();
List<CompetencyImportRow> rows;
if (originalFilename != null && originalFilename.endsWith(".csv")) {
rows = parseCsv(file);
} else {
rows = parseJson(file);
}
return ResponseEntity.ok(competencyService.bulkImportCompetencies(rows));
}

private List<CompetencyImportRow> parseCsv(MultipartFile file) throws IOException {
List<CompetencyImportRow> rows = new ArrayList<>();
CSVFormat format = CSVFormat.DEFAULT.builder()
.setHeader()
.setSkipHeaderRecord(true)
.setTrim(true)
.build();
try (var reader = new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8);
var parser = format.parse(reader)) {
for (CSVRecord record : parser) {
String title;
if (record.size() > 0) {
title = record.get(0);
} else {
title = "";
}
String description;
if (record.size() > 1) {
description = record.get(1);
} else {
description = null;
}
rows.add(new CompetencyImportRow(title, description));
}
}
return rows;
}

private List<CompetencyImportRow> parseJson(MultipartFile file) throws IOException {
return objectMapper.readValue(
file.getInputStream(),
objectMapper.getTypeFactory().constructCollectionType(List.class, CompetencyImportRow.class)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package de.tum.cit.memo.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class CompetencyImportRow {

@NotBlank(message = "Title is required")
private String title;

private String description;
}
5 changes: 5 additions & 0 deletions server/src/main/java/de/tum/cit/memo/dto/ImportResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package de.tum.cit.memo.dto;

import java.util.List;

public record ImportResult(int imported, int skipped, List<String> errors) { }
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ public interface CompetencyRepository extends JpaRepository<Competency, String>
@org.springframework.data.jpa.repository.Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(value = "UPDATE competencies SET degree = GREATEST(degree - 1, 0) WHERE id IN :ids", nativeQuery = true)
void decrementDegree(@Param("ids") List<String> ids);

boolean existsByTitle(String title);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package de.tum.cit.memo.security;

import de.tum.cit.memo.entity.User;
import de.tum.cit.memo.enums.UserRole;
import de.tum.cit.memo.repository.UserRepository;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.core.convert.converter.Converter;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
@SuppressWarnings("null")
public class DbRoleJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

private final UserRepository userRepository;

@Override
public AbstractAuthenticationToken convert(@NonNull Jwt jwt) {
String subject = Objects.requireNonNullElse(jwt.getSubject(), "");
UserRole role = userRepository.findById(subject)
.map(User::getRole)
.orElse(UserRole.USER);
return new JwtAuthenticationToken(jwt,
List.of(new SimpleGrantedAuthority("ROLE_" + role.name())));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
Expand All @@ -20,7 +19,7 @@
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
public SecurityFilterChain filterChain(HttpSecurity http, DbRoleJwtAuthenticationConverter jwtConverter) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
Expand All @@ -29,9 +28,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/api-docs/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/users/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/competencies/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/learning-resources/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));

return http.build();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package de.tum.cit.memo.service;

import de.tum.cit.memo.dto.CompetencyImportRow;
import de.tum.cit.memo.dto.CreateCompetencyRequest;
import de.tum.cit.memo.dto.ImportResult;
import de.tum.cit.memo.dto.UpdateCompetencyRequest;
import de.tum.cit.memo.entity.Competency;
import de.tum.cit.memo.exception.ResourceNotFoundException;
Expand All @@ -10,6 +12,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

@Service
Expand Down Expand Up @@ -70,4 +73,31 @@ public void deleteCompetency(String id) {
}
competencyRepository.deleteById(id);
}

@Transactional
public ImportResult bulkImportCompetencies(List<CompetencyImportRow> rows) {
List<Competency> toSave = new ArrayList<>();
List<String> errors = new ArrayList<>();
int skipped = 0;

for (int i = 0; i < rows.size(); i++) {
CompetencyImportRow row = rows.get(i);
if (row.getTitle() == null || row.getTitle().isBlank()) {
errors.add("Row " + (i + 1) + ": title is required");
continue;
}
if (competencyRepository.existsByTitle(row.getTitle())) {
skipped++;
continue;
}
toSave.add(Competency.builder()
.id(IdGenerator.generateCuid())
.title(row.getTitle())
.description(row.getDescription())
.build());
}

competencyRepository.saveAll(toSave);
return new ImportResult(toSave.size(), skipped, errors);
}
}
7 changes: 7 additions & 0 deletions server/src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@ spring:
jpa:
show-sql: true

flyway:
validate-on-migrate: false

server:
port: 8080

memo:
allowed-email-domains:
- memo.local

logging:
level:
root: INFO
Expand Down
9 changes: 6 additions & 3 deletions server/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,15 @@ spring:
jdbc:
time_zone: UTC

servlet:
multipart:
max-file-size: 5MB
max-request-size: 5MB

flyway:
enabled: true
baseline-on-migrate: true
validate-on-migrate: false
validate-on-migrate: true
locations: classpath:db/migration

server:
Expand Down Expand Up @@ -125,8 +130,6 @@ memo:
# --- UniversitΓ€t Erlangen-NΓΌrnberg ---
- fau.de
- fau.eu
# --- Dev / demo (remove in production) ---
- memo.local

logging:
level:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- V9 extended competency_relationships_votes.user_id to VARCHAR(36) for Keycloak UUIDs
-- but missed competency_resource_links.user_id which is still VARCHAR(30).
-- A 36-char Keycloak UUID inserted as user_id would fail with "value too long".
ALTER TABLE competency_resource_links ALTER COLUMN user_id TYPE VARCHAR(36);
16 changes: 16 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import { ImprintPage } from './pages/ImprintPage';
import { PrivacyPage } from './pages/PrivacyPage';
import { DashboardPage } from './pages/DashboardPage';
import { ProtectedRoute } from './components/ProtectedRoute';
import { AdminRoute } from './components/AdminRoute';
import { AdminPage } from './pages/AdminPage';
import { ModerationPage } from './pages/ModerationPage';
import { ImportPage } from './pages/ImportPage';
import { RolesPage } from './pages/RolesPage';
import { ExportPage } from './pages/ExportPage';
import { AuthProvider } from './contexts/AuthContext';

export function App() {
Expand Down Expand Up @@ -42,6 +48,16 @@ export function App() {
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/session" element={<SessionPage />} />
<Route element={<AdminRoute />}>
<Route path="/admin" element={<AdminPage />} />
<Route
path="/admin/moderation"
element={<ModerationPage />}
/>
<Route path="/admin/import" element={<ImportPage />} />
<Route path="/admin/roles" element={<RolesPage />} />
<Route path="/admin/export" element={<ExportPage />} />
</Route>
</Route>
</Route>
</Routes>
Expand Down
Loading
Loading