diff --git a/package-lock.json b/package-lock.json index dedc0f4..626545d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5316,14 +5316,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/bail": { @@ -6150,9 +6150,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -7913,10 +7913,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -8837,9 +8840,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/server/build.gradle b/server/build.gradle index 346c2d2..093c451 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -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' diff --git a/server/src/main/java/de/tum/cit/memo/controller/AdminController.java b/server/src/main/java/de/tum/cit/memo/controller/AdminController.java new file mode 100644 index 0000000..ecd3f67 --- /dev/null +++ b/server/src/main/java/de/tum/cit/memo/controller/AdminController.java @@ -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 importJson( + @Valid @RequestBody List 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 importFile( + @RequestParam("file") MultipartFile file + ) throws IOException { + String originalFilename = file.getOriginalFilename(); + List rows; + if (originalFilename != null && originalFilename.endsWith(".csv")) { + rows = parseCsv(file); + } else { + rows = parseJson(file); + } + return ResponseEntity.ok(competencyService.bulkImportCompetencies(rows)); + } + + private List parseCsv(MultipartFile file) throws IOException { + List 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 parseJson(MultipartFile file) throws IOException { + return objectMapper.readValue( + file.getInputStream(), + objectMapper.getTypeFactory().constructCollectionType(List.class, CompetencyImportRow.class) + ); + } +} diff --git a/server/src/main/java/de/tum/cit/memo/dto/CompetencyImportRow.java b/server/src/main/java/de/tum/cit/memo/dto/CompetencyImportRow.java new file mode 100644 index 0000000..7c5a402 --- /dev/null +++ b/server/src/main/java/de/tum/cit/memo/dto/CompetencyImportRow.java @@ -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; +} diff --git a/server/src/main/java/de/tum/cit/memo/dto/ImportResult.java b/server/src/main/java/de/tum/cit/memo/dto/ImportResult.java new file mode 100644 index 0000000..1ffe641 --- /dev/null +++ b/server/src/main/java/de/tum/cit/memo/dto/ImportResult.java @@ -0,0 +1,5 @@ +package de.tum.cit.memo.dto; + +import java.util.List; + +public record ImportResult(int imported, int skipped, List errors) { } diff --git a/server/src/main/java/de/tum/cit/memo/repository/CompetencyRepository.java b/server/src/main/java/de/tum/cit/memo/repository/CompetencyRepository.java index fc80203..f655c94 100644 --- a/server/src/main/java/de/tum/cit/memo/repository/CompetencyRepository.java +++ b/server/src/main/java/de/tum/cit/memo/repository/CompetencyRepository.java @@ -27,4 +27,6 @@ public interface CompetencyRepository extends JpaRepository @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 ids); + + boolean existsByTitle(String title); } diff --git a/server/src/main/java/de/tum/cit/memo/security/DbRoleJwtAuthenticationConverter.java b/server/src/main/java/de/tum/cit/memo/security/DbRoleJwtAuthenticationConverter.java new file mode 100644 index 0000000..005447b --- /dev/null +++ b/server/src/main/java/de/tum/cit/memo/security/DbRoleJwtAuthenticationConverter.java @@ -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 { + + 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()))); + } +} diff --git a/server/src/main/java/de/tum/cit/memo/security/SecurityConfig.java b/server/src/main/java/de/tum/cit/memo/security/SecurityConfig.java index 5b22e75..1883953 100644 --- a/server/src/main/java/de/tum/cit/memo/security/SecurityConfig.java +++ b/server/src/main/java/de/tum/cit/memo/security/SecurityConfig.java @@ -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; @@ -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())) @@ -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(); } diff --git a/server/src/main/java/de/tum/cit/memo/service/CompetencyService.java b/server/src/main/java/de/tum/cit/memo/service/CompetencyService.java index f724f8f..05082ed 100644 --- a/server/src/main/java/de/tum/cit/memo/service/CompetencyService.java +++ b/server/src/main/java/de/tum/cit/memo/service/CompetencyService.java @@ -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; @@ -10,6 +12,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; @Service @@ -70,4 +73,31 @@ public void deleteCompetency(String id) { } competencyRepository.deleteById(id); } + + @Transactional + public ImportResult bulkImportCompetencies(List rows) { + List toSave = new ArrayList<>(); + List 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); + } } diff --git a/server/src/main/resources/application-local.yml b/server/src/main/resources/application-local.yml index c4bff48..0c3b8c7 100644 --- a/server/src/main/resources/application-local.yml +++ b/server/src/main/resources/application-local.yml @@ -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 diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index d02da65..0e1e650 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -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: @@ -125,8 +130,6 @@ memo: # --- Universität Erlangen-Nürnberg --- - fau.de - fau.eu - # --- Dev / demo (remove in production) --- - - memo.local logging: level: diff --git a/server/src/main/resources/db/migration/V10__fix_competency_resource_links_user_id_length.sql b/server/src/main/resources/db/migration/V10__fix_competency_resource_links_user_id_length.sql new file mode 100644 index 0000000..5f2cfac --- /dev/null +++ b/server/src/main/resources/db/migration/V10__fix_competency_resource_links_user_id_length.sql @@ -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); diff --git a/src/App.tsx b/src/App.tsx index b567793..2e3a8f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { @@ -42,6 +48,16 @@ export function App() { }> } /> } /> + }> + } /> + } + /> + } /> + } /> + } /> + diff --git a/src/components/AdminRoute.tsx b/src/components/AdminRoute.tsx new file mode 100644 index 0000000..6db1bba --- /dev/null +++ b/src/components/AdminRoute.tsx @@ -0,0 +1,24 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '../contexts/useAuth'; + +export function AdminRoute() { + const { isAuthenticated, isLoading, role } = useAuth(); + + if (isLoading) { + return ( +
+

Loading…

+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + if (role !== 'ADMIN') { + return ; + } + + return ; +} diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index 12b3754..4397fa3 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -2,7 +2,7 @@ import { Link, useLocation } from 'react-router-dom'; import { useTheme } from './use-theme'; import { useEffect, useState } from 'react'; import { cn } from '@/lib/utils'; -import { CircleUser } from 'lucide-react'; +import { CircleUser, Shield } from 'lucide-react'; import { useAuth } from '../contexts/useAuth'; const navItems = [ @@ -16,7 +16,7 @@ export function Navbar() { const location = useLocation(); const pathname = location.pathname; const { theme, setTheme } = useTheme(); - const { isAuthenticated, login, logout } = useAuth(); + const { isAuthenticated, login, logout, role } = useAuth(); const [mounted, setMounted] = useState(false); const [scrolled, setScrolled] = useState(false); const activeIndex = navItems.findIndex(item => item.href === pathname); @@ -169,6 +169,21 @@ export function Navbar() { Dashboard + {role === 'ADMIN' && ( + + + Admin + + )} diff --git a/src/pages/ImportPage.tsx b/src/pages/ImportPage.tsx new file mode 100644 index 0000000..ef6dfeb --- /dev/null +++ b/src/pages/ImportPage.tsx @@ -0,0 +1,264 @@ +import { useState, useRef } from 'react'; +import { Link } from 'react-router-dom'; +import { + ArrowLeft, + Upload, + FileText, + CheckCircle, + AlertCircle, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { adminApi, type CompetencyImportRow } from '@/lib/api/admin'; +import type { ImportResult } from '@/lib/api/types'; + +type Mode = 'file' | 'json'; +type Status = 'idle' | 'uploading' | 'success' | 'error'; + +const PLACEHOLDER = `[ + { "title": "Understand Big-O notation", "description": "Time and space complexity analysis" }, + { "title": "Apply sorting algorithms" } +]`; + +export function ImportPage() { + const [mode, setMode] = useState('file'); + const [status, setStatus] = useState('idle'); + const [result, setResult] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + + const [selectedFile, setSelectedFile] = useState(null); + const [jsonText, setJsonText] = useState(''); + const [jsonError, setJsonError] = useState(''); + + const fileInputRef = useRef(null); + + const reset = () => { + setStatus('idle'); + setResult(null); + setErrorMessage(''); + setSelectedFile(null); + setJsonText(''); + setJsonError(''); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + setSelectedFile(file); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files[0]; + if (file) setSelectedFile(file); + }; + + const handleJsonChange = (e: React.ChangeEvent) => { + setJsonText(e.target.value); + setJsonError(''); + }; + + const handleSubmit = async () => { + setStatus('uploading'); + setResult(null); + setErrorMessage(''); + + try { + let importResult: ImportResult; + + if (mode === 'file') { + if (!selectedFile) { + setErrorMessage('Please select a file.'); + setStatus('error'); + return; + } + importResult = await adminApi.importCompetenciesFile(selectedFile); + } else { + let rows: CompetencyImportRow[]; + try { + rows = JSON.parse(jsonText) as CompetencyImportRow[]; + } catch { + setJsonError('Invalid JSON. Please check the format.'); + setStatus('idle'); + return; + } + importResult = await adminApi.importCompetenciesJson(rows); + } + + setResult(importResult); + setStatus('success'); + } catch { + setErrorMessage('Import failed. Please check your file and try again.'); + setStatus('error'); + } + }; + + return ( +
+
+
+
+
+
+ +
+
+ + + Admin + + / + + Import Competencies + +
+ +
+

+ Import +

+

+ Bulk-import competencies from a JSON or CSV file. +

+
+ +
+ {status === 'success' && result ? ( +
+
+
+ + Import complete +
+
    +
  • + ✓ Imported: {result.imported} +
  • + {result.skipped > 0 && ( +
  • + ⚠ Skipped (duplicates): {result.skipped} +
  • + )} +
+ {result.errors.length > 0 && ( +
+

Errors:

+
    + {result.errors.map((e, i) => ( +
  • • {e}
  • + ))} +
+
+ )} +
+ +
+ ) : ( + <> +
+ {(['file', 'json'] as Mode[]).map(m => ( + + ))} +
+ + {mode === 'file' ? ( +
+
e.preventDefault()} + onClick={() => fileInputRef.current?.click()} + className="flex cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed border-slate-200 bg-slate-50/50 p-10 transition hover:border-[#0a4da2]/50 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800/30 dark:hover:border-[#7c6cff]/50" + > + {selectedFile ? ( + <> + +

+ {selectedFile.name} +

+

+ {(selectedFile.size / 1024).toFixed(1)} KB +

+ + ) : ( + <> + +

+ Drop a file here or click to browse +

+

+ Accepts .json or .csv (max 5 MB) +

+ + )} + +
+
+

+ CSV format +

+
+                      {`title,description\n"Understand Big-O notation","Time and space complexity"\n"Apply sorting algorithms",`}
+                    
+
+
+ ) : ( +
+