diff --git a/.env.example b/.env.example index 781a695..2868aa6 100644 --- a/.env.example +++ b/.env.example @@ -10,11 +10,27 @@ DB_NAME=codestardb # Spring Boot / JWT JWT_SECRET=change_me_use_a_long_random_string_at_least_64_chars -JWT_EXPIRATION=86400000 +JWT_EXPIRATION=604800 # one week by default (in seconds) + +# Signup open (true = No invitation code needed) +SIGNUP_OPEN=true + +# Bootstrap super-admin on first boot. +# If the email exists in the database with a lower role, it is promoted. The password NEVER overwrites an existing user. +CODESTAR_BOOTSTRAP_SUPER_ADMIN_EMAIL=example@gmail.com +CODESTAR_BOOTSTRAP_SUPER_ADMIN_PASSWORD=change_me_to_a_strong_password +CODESTAR_BOOTSTRAP_SUPER_ADMIN_DISPLAY_NAME=John # Ports (optional — defaults shown) BACKEND_PORT=8080 FRONTEND_PORT=3000 -# Next.js — URL the browser uses to reach the backend API -NEXT_PUBLIC_API_URL=http://localhost:8080 +# Path to an external branding JSON file (optional) - Leave empty to use the bundled default +# You can customize the branding either by pointing to an external file OR by editing the bundled one. +INSTANCE_CONFIG_PATH= + +# Frontend +# Name of the httpOnly cookie storing the JWT (must match backend + frontend code expectations). +AUTH_COOKIE_NAME=codestar_token +# Public canonical origin (metadata, robots.txt, sitemap.xml). +SITE_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore index 479d80d..d39aa33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,80 @@ -# Environment secrets +# Environment / secrets .env +.env.* +!.env.example +*.pem +*.key +*.crt # OS .DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +Desktop.ini +$RECYCLE.BIN/ +*.lnk +.fuse_hidden* +.directory +.Trash-* +.nfs* -# IDE +# IDE / Editors .idea/ *.iml +*.iws +*.ipr +out/ -# Claude +# VS Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace +.history/ + +# Eclipse +.classpath +.project +.settings/ +.metadata/ +.factorypath +.apt_generated/ + +# NetBeans +nbproject/private/ +nbbuild/ +nbdist/ +.nb-gradle/ + +# Vim / Emacs / Sublime / Misc +*.swp +*.swo +*~ +.*.sw? +*.sublime-workspace +*.sublime-project +.\#* +\#*\# + +# AI assistants / agents .claude/ +.aider* +.cursor/ + +# Logs / temp +*.log +logs/ +tmp/ +temp/ + +# have to be delete +codestar-draft-design/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f62f9ff --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "i18n-ally.localesPaths": [ + "apps/frontend/messages" + ], + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore new file mode 100644 index 0000000..4a4f2a7 --- /dev/null +++ b/apps/backend/.gitignore @@ -0,0 +1,41 @@ +# Maven +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +dependency-reduced-pom.xml +.flattened-pom.xml +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +buildNumber.properties + +# Gradle (safety net) +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +# Java +*.class +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +*.ctxt +.mtj.tmp/ +hs_err_pid* +replay_pid* + +# Spring Boot +HELP.md +.springBeans + +# Logs +*.log \ No newline at end of file diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index d7855ca..ba34b56 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -1,4 +1,4 @@ -# Stage 1: Build +# Build FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app @@ -9,7 +9,7 @@ RUN mvn dependency:go-offline -B COPY src ./src RUN mvn package -DskipTests -B -# Stage 2: Runtime +# Runtime FROM eclipse-temurin:17-jre-alpine WORKDIR /app diff --git a/apps/backend/pom.xml b/apps/backend/pom.xml index c129e2e..0d6a403 100644 --- a/apps/backend/pom.xml +++ b/apps/backend/pom.xml @@ -14,7 +14,7 @@ backend 0.0.1-SNAPSHOT backend - Demo project for Spring Boot + API backend for CodeStar 17 @@ -42,12 +42,27 @@ spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-validation + + org.postgresql postgresql runtime + + org.flywaydb + flyway-core + + + + org.flywaydb + flyway-database-postgresql + + io.jsonwebtoken jjwt-api diff --git a/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java b/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java index 4b9c552..704e1ab 100644 --- a/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java +++ b/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java @@ -1,21 +1,66 @@ package com.codestar.backend.config; +import com.codestar.backend.security.ApiSecurityExceptionHandler; +import com.codestar.backend.security.JwtAuthenticationFilter; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration @EnableWebSecurity +@EnableMethodSecurity public class SecurityConfig { + private final JwtAuthenticationFilter jwtFilter; + private final ApiSecurityExceptionHandler securityExceptionHandler; + + @Value("${codestar.cors.allowed-origins}") + private List allowedOrigins; + + public SecurityConfig(JwtAuthenticationFilter jwtFilter, ApiSecurityExceptionHandler securityExceptionHandler) { + this.jwtFilter = jwtFilter; + this.securityExceptionHandler = securityExceptionHandler; + } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + http + .cors(cors -> cors.configurationSource(corsSource())) + .csrf(csrf -> csrf.disable()) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // public auth + branding + .requestMatchers(HttpMethod.POST, + "/api/v1/auth/login", + "/api/v1/auth/register").permitAll() + .requestMatchers(HttpMethod.GET, + "/api/v1/instance/branding").permitAll() + // swager/openAPI + .requestMatchers( + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html").permitAll() + // TODO .requestMatchers("/api/v1/courses/**").permitAll() + .anyRequest().authenticated()) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(securityExceptionHandler) + .accessDeniedHandler(securityExceptionHandler)) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); } @@ -23,5 +68,16 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } -} + @Bean + public CorsConfigurationSource corsSource() { + CorsConfiguration cfg = new CorsConfiguration(); + cfg.setAllowedOrigins(allowedOrigins); + cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + cfg.setAllowedHeaders(List.of("*")); + cfg.setAllowCredentials(false); + UrlBasedCorsConfigurationSource src = new UrlBasedCorsConfigurationSource(); + src.registerCorsConfiguration("/**", cfg); + return src; + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/config/SuperAdminBootstrap.java b/apps/backend/src/main/java/com/codestar/backend/config/SuperAdminBootstrap.java new file mode 100644 index 0000000..88cfa96 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/config/SuperAdminBootstrap.java @@ -0,0 +1,68 @@ +package com.codestar.backend.config; + +import com.codestar.backend.model.Role; +import com.codestar.backend.model.User; +import com.codestar.backend.repository.IUserRepository; +import com.codestar.backend.utils.Emails; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +/** + * Create or promotes a super-admin on boot or desactivate if both variables aren't set + */ +@Configuration +public class SuperAdminBootstrap { + + private static final Logger log = LoggerFactory.getLogger(SuperAdminBootstrap.class); + + @Value("${codestar.bootstrap.super-admin.email:}") + private String bootstrapEmail; + + @Value("${codestar.bootstrap.super-admin.password:}") + private String bootstrapPassword; + + @Value("${codestar.bootstrap.super-admin.display-name:Super-admin}") + private String bootstrapDisplayName; + + @Bean + public CommandLineRunner superAdminRunner(IUserRepository userRepository, + PasswordEncoder passwordEncoder) { + return args -> { + if (bootstrapEmail == null || bootstrapEmail.isBlank()) { + return; + } + if (bootstrapPassword == null || bootstrapPassword.isBlank()) { + log.warn("Bootstrap : super-admin email set but password missing — skipping."); + return; + } + + String normalizedEmail = Emails.normalize(bootstrapEmail); + Optional existing = userRepository.findByEmail(normalizedEmail); + + if (existing.isEmpty()) { + User u = new User( + normalizedEmail, + passwordEncoder.encode(bootstrapPassword), + bootstrapDisplayName, + Role.SUPER_ADMIN); + userRepository.save(u); + log.info("Bootstrap : super-admin created"); + return; + } + + User user = existing.get(); + if (user.getRole() != Role.SUPER_ADMIN) { + log.info("Bootstrap : user promoted from {} role to super-admin role", user.getRole()); + user.setRole(Role.SUPER_ADMIN); + userRepository.save(user); + } + }; + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/controller/AuthController.java b/apps/backend/src/main/java/com/codestar/backend/controller/AuthController.java index 1f4aa01..8ed07fa 100644 --- a/apps/backend/src/main/java/com/codestar/backend/controller/AuthController.java +++ b/apps/backend/src/main/java/com/codestar/backend/controller/AuthController.java @@ -3,103 +3,113 @@ import com.codestar.backend.dto.ApiResponseDto; import com.codestar.backend.dto.LoginRequestDto; import com.codestar.backend.dto.LoginResponseDto; +import com.codestar.backend.dto.MeResponseDto; +import com.codestar.backend.dto.RegisterRequestDto; +import com.codestar.backend.exception.ApiException; +import com.codestar.backend.model.Role; import com.codestar.backend.model.User; import com.codestar.backend.repository.IUserRepository; +import com.codestar.backend.security.AuthenticatedUser; +import com.codestar.backend.service.GroupService; +import com.codestar.backend.service.InvitationService; +import com.codestar.backend.utils.Emails; import com.codestar.backend.utils.JwtUtils; -import org.springframework.beans.factory.annotation.Autowired; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -import java.util.Optional; - +/** + * authentification endpoints + */ @RestController -@RequestMapping("/api/auth") -@CrossOrigin(origins = "*") +@RequestMapping("/api/v1/auth") public class AuthController { private final IUserRepository userRepository; private final JwtUtils jwtUtils; + private final PasswordEncoder passwordEncoder; + private final InvitationService invitationService; + private final GroupService groupService; - @Autowired - private PasswordEncoder passwordEncoder; + @Value("${codestar.signup.open:false}") + private boolean signupOpen; - public AuthController(IUserRepository userRepository, JwtUtils jwtUtils){ + public AuthController(IUserRepository userRepository, JwtUtils jwtUtils, PasswordEncoder passwordEncoder, InvitationService invitationService, GroupService groupService) { this.userRepository = userRepository; this.jwtUtils = jwtUtils; + this.passwordEncoder = passwordEncoder; + this.invitationService = invitationService; + this.groupService = groupService; } @PostMapping("/login") - public ResponseEntity> login(@RequestBody LoginRequestDto request){ - - Optional userOptionnal = userRepository.findByUsername(request.getUsername()); - - if (userOptionnal.isEmpty()){ - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(new ApiResponseDto<>(false, "Identifiants invalides", null)); - } - - User user = userOptionnal.get(); - if (passwordEncoder.matches(request.getPassword(), user.getPassword())){ - String token = jwtUtils.generateToken(user.getUsername()); - LoginResponseDto loginData = new LoginResponseDto(token, "Bearer"); - return ResponseEntity.ok(new ApiResponseDto<>(true, "Connexion réussie", loginData)); - } - - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(new ApiResponseDto<>(false, "Identifiants invalides", null)); + public ResponseEntity> login(@Valid @RequestBody LoginRequestDto request) { + User user = userRepository.findByEmail(Emails.normalize(request.getEmail())) + .filter(User::isActive) + .filter(u -> passwordEncoder.matches(request.getPassword(), u.getPasswordHash())) + .orElseThrow(() -> ApiException.unauthorized("Invalid credentials")); + + String token = jwtUtils.generateToken(user); + return ResponseEntity.ok(new ApiResponseDto<>(true, "Login successful", new LoginResponseDto(token, "Bearer"))); } @PostMapping("/register") - public ResponseEntity> register(@RequestBody User user){ - if(userRepository.findByUsername(user.getUsername()).isPresent()){ - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(new ApiResponseDto<>(false, "Nom d'utilisateur non disponible", null)); + @Transactional + public ResponseEntity> register(@Valid @RequestBody RegisterRequestDto request) { + String normalizedEmail = Emails.normalize(request.getEmail()); + if (userRepository.existsByEmail(normalizedEmail)) { + throw ApiException.conflict("Your email is already linked with an account."); } - user.setPassword(passwordEncoder.encode(user.getPassword())); - userRepository.save(user); - return ResponseEntity.status(HttpStatus.CREATED) - .body(new ApiResponseDto<>(true, "Compte créé", null)); - } - - - -} - - - - - - - - - - - - - - - - - - - - - - - - - - - - + boolean hasCode = request.getInvitationCode() != null && !request.getInvitationCode().isBlank(); + if (!signupOpen && !hasCode) { + throw ApiException.badRequest("Registration closed — an invitation code is required."); + } + User user = new User( + normalizedEmail, + passwordEncoder.encode(request.getPassword()), + request.getDisplayName(), + Role.STUDENT); + user = userRepository.save(user); + if (hasCode) { + invitationService.consumeAndJoin(request.getInvitationCode(), user.getId()); + } + String token = jwtUtils.generateToken(user); + return ResponseEntity.status(HttpStatus.CREATED).body(new ApiResponseDto<>(true, "Account created", new LoginResponseDto(token, "Bearer"))); + } + @GetMapping("/me") + public ResponseEntity> me(@AuthenticationPrincipal AuthenticatedUser principal) { + if (principal == null) throw ApiException.unauthorized("Unauthenticated"); + User user = userRepository.findById(principal.getId()) + .orElseThrow(() -> ApiException.unauthorized("Unable to find the user")); + MeResponseDto body = new MeResponseDto( + user.getId(), + user.getEmail(), + user.getDisplayName(), + user.getRole(), + user.getCreatedAt(), + groupService.listMine(user.getId())); + return ResponseEntity.ok(new ApiResponseDto<>(true, "OK", body)); + } + // TODO v2 + /** + * Logout stateless : avec JWT, la déconnexion serveur nécessite une blacklist symétrie + futur durcissement. + */ + @PostMapping("/logout") + public ResponseEntity> logout() { + return ResponseEntity.noContent().build(); + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/controller/CourseController.java b/apps/backend/src/main/java/com/codestar/backend/controller/CourseController.java index 68bc5bf..21abba6 100644 --- a/apps/backend/src/main/java/com/codestar/backend/controller/CourseController.java +++ b/apps/backend/src/main/java/com/codestar/backend/controller/CourseController.java @@ -15,8 +15,10 @@ import java.util.List; +// TODO + @RestController -@RequestMapping("/api/courses") +@RequestMapping("/api/v1/courses") @CrossOrigin(origins = "*") public class CourseController { diff --git a/apps/backend/src/main/java/com/codestar/backend/controller/GroupController.java b/apps/backend/src/main/java/com/codestar/backend/controller/GroupController.java new file mode 100644 index 0000000..5a8a16e --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/controller/GroupController.java @@ -0,0 +1,119 @@ +package com.codestar.backend.controller; + +import com.codestar.backend.dto.ApiResponseDto; +import com.codestar.backend.dto.CreateGroupRequestDto; +import com.codestar.backend.dto.CreateInvitationRequestDto; +import com.codestar.backend.dto.GroupResponseDto; +import com.codestar.backend.dto.GroupSummaryDto; +import com.codestar.backend.dto.InvitationResponseDto; +import com.codestar.backend.dto.JoinGroupRequestDto; +import com.codestar.backend.dto.UpdateGroupRequestDto; +import com.codestar.backend.exception.ApiException; +import com.codestar.backend.model.InvitationCode; +import com.codestar.backend.repository.IInvitationCodeRepository; +import com.codestar.backend.security.AuthenticatedUser; +import com.codestar.backend.service.GroupService; +import com.codestar.backend.service.InvitationService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +// TODO les permissions fines (Teacher animant son groupe) sont actuellement traitées au niveau Admin/Super-admin. Le scope « Teacher de son groupe » sera affiné en pass 3 via PermissionService dédié. + +/** + * groups + invitations endpoints + */ +@RestController +@RequestMapping("/api/v1/groups") +public class GroupController { + + private final GroupService groupService; + private final InvitationService invitationService; + private final IInvitationCodeRepository invitations; + + public GroupController(GroupService groupService, InvitationService invitationService, IInvitationCodeRepository invitations) { + this.groupService = groupService; + this.invitationService = invitationService; + this.invitations = invitations; + } + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN', 'SUPER_ADMIN')") + public ResponseEntity>> listAll() { + return ResponseEntity.ok(new ApiResponseDto<>(true, "OK", groupService.listAll())); + } + + @GetMapping("/mine") + public ResponseEntity>> listMine(@AuthenticationPrincipal AuthenticatedUser principal) { + if (principal == null) throw ApiException.unauthorized("Unauthenticated"); + return ResponseEntity.ok(new ApiResponseDto<>(true, "OK", groupService.listMine(principal.getId()))); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'SUPER_ADMIN')") + public ResponseEntity> getOne(@PathVariable UUID id) { + return ResponseEntity.ok(new ApiResponseDto<>(true, "OK", groupService.getOne(id))); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'SUPER_ADMIN')") + public ResponseEntity> create(@Valid @RequestBody CreateGroupRequestDto request, @AuthenticationPrincipal AuthenticatedUser principal) { + GroupResponseDto created = groupService.create(request, principal.getId()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(new ApiResponseDto<>(true, "Group created", created)); + } + + @PatchMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'SUPER_ADMIN')") + public ResponseEntity> update(@PathVariable UUID id, @Valid @RequestBody UpdateGroupRequestDto request) { + return ResponseEntity.ok(new ApiResponseDto<>(true, "Group updated", groupService.update(id, request))); + } + + @PostMapping("/join") + public ResponseEntity> join(@Valid @RequestBody JoinGroupRequestDto request, @AuthenticationPrincipal AuthenticatedUser principal) { + if (principal == null) throw ApiException.unauthorized("Unauthenticated"); + UUID groupId = invitationService.consumeAndJoin(request.getCode(), principal.getId()); + return ResponseEntity.ok(new ApiResponseDto<>(true, "Group joined", groupId)); + } + + @PostMapping("/{groupId}/invitations") + @PreAuthorize("@groupPermissionService.canManageGroup(principal, #groupId)") + public ResponseEntity> createInvitation(@PathVariable UUID groupId, @Valid @RequestBody CreateInvitationRequestDto request, @AuthenticationPrincipal AuthenticatedUser principal) { + InvitationCode created = invitationService.create( + groupId, request.getMaxUses(), request.getExpiresAt(), principal.getId()); + + return ResponseEntity.status(HttpStatus.CREATED).body(new ApiResponseDto<>(true, "Invitation created", toDto(created))); + } + + @GetMapping("/{groupId}/invitations") + @PreAuthorize("@groupPermissionService.canManageGroup(principal, #groupId)") + public ResponseEntity>> listInvitations(@PathVariable UUID groupId) { + List body = invitations + .findByGroupIdAndRevokedAtIsNull(groupId) + .stream() + .map(GroupController::toDto) + .toList(); + + return ResponseEntity.ok(new ApiResponseDto<>(true, "OK", body)); + } + + // helper + private static InvitationResponseDto toDto(InvitationCode i) { + return new InvitationResponseDto( + i.getId(), + i.getCode(), + i.getGroupId(), + i.getMaxUses(), + i.getUsedCount(), + i.getExpiresAt(), + i.getCreatedAt(), + i.getRevokedAt() + ); + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/controller/InstanceController.java b/apps/backend/src/main/java/com/codestar/backend/controller/InstanceController.java new file mode 100644 index 0000000..2af56f1 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/controller/InstanceController.java @@ -0,0 +1,27 @@ +package com.codestar.backend.controller; + +import com.codestar.backend.dto.ApiResponseDto; +import com.codestar.backend.dto.InstanceBrandingDto; +import com.codestar.backend.service.InstanceBrandingService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +// TODO pouvoir modifier le branding + +@RestController +@RequestMapping("/api/v1/instance") +public class InstanceController { + + private final InstanceBrandingService brandingService; + + public InstanceController(InstanceBrandingService brandingService) { + this.brandingService = brandingService; + } + + @GetMapping("/branding") + public ResponseEntity> branding() { + return ResponseEntity.ok(new ApiResponseDto<>(true, "OK", brandingService.read())); + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/controller/InvitationController.java b/apps/backend/src/main/java/com/codestar/backend/controller/InvitationController.java new file mode 100644 index 0000000..61c77e5 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/controller/InvitationController.java @@ -0,0 +1,32 @@ +package com.codestar.backend.controller; + +import com.codestar.backend.dto.ApiResponseDto; +import com.codestar.backend.security.AuthenticatedUser; +import com.codestar.backend.service.InvitationService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +/** + * Invitation revocation endpoints + */ +@RestController +@RequestMapping("/api/v1/invitations") +public class InvitationController { + + private final InvitationService invitationService; + + public InvitationController(InvitationService invitationService) { + this.invitationService = invitationService; + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'SUPER_ADMIN', 'TEACHER')") + public ResponseEntity> revoke(@PathVariable UUID id, @AuthenticationPrincipal AuthenticatedUser principal) { + invitationService.revoke(id, principal.getId()); + return ResponseEntity.ok(new ApiResponseDto<>(true, "Invitation revoked", null)); + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/dto/CreateGroupRequestDto.java b/apps/backend/src/main/java/com/codestar/backend/dto/CreateGroupRequestDto.java new file mode 100644 index 0000000..d277102 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/dto/CreateGroupRequestDto.java @@ -0,0 +1,33 @@ +package com.codestar.backend.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; + +public class CreateGroupRequestDto { + + @NotBlank + @Size(min = 1, max = 120) + private String name; + + // optional + @Pattern(regexp = "^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$", message = "Slug : lowercase, alphanumeric + hyphens, 1..80 chars") + private String slug; + + private LocalDate startsAt; + private LocalDate endsAt; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + + public LocalDate getStartsAt() { return startsAt; } + public void setStartsAt(LocalDate startsAt) { this.startsAt = startsAt; } + + public LocalDate getEndsAt() { return endsAt; } + public void setEndsAt(LocalDate endsAt) { this.endsAt = endsAt; } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/dto/CreateInvitationRequestDto.java b/apps/backend/src/main/java/com/codestar/backend/dto/CreateInvitationRequestDto.java new file mode 100644 index 0000000..4c38155 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/dto/CreateInvitationRequestDto.java @@ -0,0 +1,22 @@ +package com.codestar.backend.dto; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.Min; + +import java.time.OffsetDateTime; + +public class CreateInvitationRequestDto { + + @Min(value = 1, message = "maxUses must be >= 1") + private int maxUses = 1; + + // optional, if null the code wont expires; when set, must be in the future + @Future(message = "expiresAt must be in the future") + private OffsetDateTime expiresAt; + + public int getMaxUses() { return maxUses; } + public void setMaxUses(int maxUses) { this.maxUses = maxUses; } + + public OffsetDateTime getExpiresAt() { return expiresAt; } + public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/dto/GroupResponseDto.java b/apps/backend/src/main/java/com/codestar/backend/dto/GroupResponseDto.java new file mode 100644 index 0000000..6460691 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/dto/GroupResponseDto.java @@ -0,0 +1,15 @@ +package com.codestar.backend.dto; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.UUID; + +public record GroupResponseDto( + UUID id, + String name, + String slug, + LocalDate startsAt, + LocalDate endsAt, + UUID createdBy, + OffsetDateTime createdAt +) {} diff --git a/apps/backend/src/main/java/com/codestar/backend/dto/GroupSummaryDto.java b/apps/backend/src/main/java/com/codestar/backend/dto/GroupSummaryDto.java new file mode 100644 index 0000000..7643cfc --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/dto/GroupSummaryDto.java @@ -0,0 +1,15 @@ +package com.codestar.backend.dto; + +import com.codestar.backend.model.GroupMemberRole; + +import java.time.LocalDate; +import java.util.UUID; + +public record GroupSummaryDto( + UUID id, + String name, + String slug, + LocalDate startsAt, + LocalDate endsAt, + GroupMemberRole roleInGroup +) {} diff --git a/apps/backend/src/main/java/com/codestar/backend/dto/InstanceBrandingDto.java b/apps/backend/src/main/java/com/codestar/backend/dto/InstanceBrandingDto.java new file mode 100644 index 0000000..901f1d1 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/dto/InstanceBrandingDto.java @@ -0,0 +1,15 @@ +package com.codestar.backend.dto; + +// TODO Logo +public record InstanceBrandingDto( + String name, + String tagline, + LogoDto logo, + String accent, + String heroTitle, + String heroSubtitle, + String heroCta, + String locale +) { + public record LogoDto(String kind, String value) {} +} diff --git a/apps/backend/src/main/java/com/codestar/backend/dto/InvitationResponseDto.java b/apps/backend/src/main/java/com/codestar/backend/dto/InvitationResponseDto.java new file mode 100644 index 0000000..390546e --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/dto/InvitationResponseDto.java @@ -0,0 +1,15 @@ +package com.codestar.backend.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public record InvitationResponseDto( + UUID id, + String code, + UUID groupId, + int maxUses, + int usedCount, + OffsetDateTime expiresAt, + OffsetDateTime createdAt, + OffsetDateTime revokedAt +) {} diff --git a/apps/backend/src/main/java/com/codestar/backend/dto/JoinGroupRequestDto.java b/apps/backend/src/main/java/com/codestar/backend/dto/JoinGroupRequestDto.java new file mode 100644 index 0000000..f200f25 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/dto/JoinGroupRequestDto.java @@ -0,0 +1,14 @@ +package com.codestar.backend.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public class JoinGroupRequestDto { + + @NotBlank + @Pattern(regexp = "^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$", message = "Format attendu : XXXX-XXXX-XXXX") + private String code; + + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/dto/LoginRequestDto.java b/apps/backend/src/main/java/com/codestar/backend/dto/LoginRequestDto.java index f5017ef..fa01a1d 100644 --- a/apps/backend/src/main/java/com/codestar/backend/dto/LoginRequestDto.java +++ b/apps/backend/src/main/java/com/codestar/backend/dto/LoginRequestDto.java @@ -1,11 +1,20 @@ package com.codestar.backend.dto; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + public class LoginRequestDto { - private String username; + + @NotBlank + @Email + private String email; + + @NotBlank private String password; - public String getUsername() { return username; } - public void setUsername(String username) { this.username = username; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } } diff --git a/apps/backend/src/main/java/com/codestar/backend/dto/MeResponseDto.java b/apps/backend/src/main/java/com/codestar/backend/dto/MeResponseDto.java new file mode 100644 index 0000000..f4aa028 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/dto/MeResponseDto.java @@ -0,0 +1,16 @@ +package com.codestar.backend.dto; + +import com.codestar.backend.model.Role; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public record MeResponseDto( + UUID id, + String email, + String displayName, + Role role, + OffsetDateTime createdAt, + List groups +) {} diff --git a/apps/backend/src/main/java/com/codestar/backend/dto/RegisterRequestDto.java b/apps/backend/src/main/java/com/codestar/backend/dto/RegisterRequestDto.java new file mode 100644 index 0000000..dbbaa60 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/dto/RegisterRequestDto.java @@ -0,0 +1,40 @@ +package com.codestar.backend.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * Account creation payload. + */ +public class RegisterRequestDto { + + @NotBlank + @Email + private String email; + + @NotBlank + @Size(min = 8, max = 255) + private String password; + + @NotBlank + @Size(min = 1, max = 120) + private String displayName; + + // optional, unless codestar.signup.open=false + @Pattern(regexp = "^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$", message = "Format attendu : XXXX-XXXX-XXXX") + private String invitationCode; + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + + public String getInvitationCode() { return invitationCode; } + public void setInvitationCode(String invitationCode) { this.invitationCode = invitationCode; } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/dto/UpdateGroupRequestDto.java b/apps/backend/src/main/java/com/codestar/backend/dto/UpdateGroupRequestDto.java new file mode 100644 index 0000000..a8651c9 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/dto/UpdateGroupRequestDto.java @@ -0,0 +1,29 @@ +package com.codestar.backend.dto; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; + +public class UpdateGroupRequestDto { + + @Size(min = 1, max = 120) + private String name; + + private LocalDate startsAt; + private LocalDate endsAt; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public LocalDate getStartsAt() { return startsAt; } + public void setStartsAt(LocalDate startsAt) { this.startsAt = startsAt; } + + public LocalDate getEndsAt() { return endsAt; } + public void setEndsAt(LocalDate endsAt) { this.endsAt = endsAt; } + + @AssertTrue(message = "endsAt must be on or after startsAt") + public boolean isDateRangeValid() { + return startsAt == null || endsAt == null || !endsAt.isBefore(startsAt); + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/exception/ApiException.java b/apps/backend/src/main/java/com/codestar/backend/exception/ApiException.java new file mode 100644 index 0000000..8109c4c --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/exception/ApiException.java @@ -0,0 +1,40 @@ +package com.codestar.backend.exception; + +import org.springframework.http.HttpStatus; + +/** + * Business exception carrying an HTTP status + */ +public class ApiException extends RuntimeException { + + private final HttpStatus status; + + public ApiException(HttpStatus status, String message) { + super(message); + this.status = status; + } + + public HttpStatus getStatus() { + return status; + } + + public static ApiException notFound(String message) { + return new ApiException(HttpStatus.NOT_FOUND, message); + } + + public static ApiException badRequest(String message) { + return new ApiException(HttpStatus.BAD_REQUEST, message); + } + + public static ApiException conflict(String message) { + return new ApiException(HttpStatus.CONFLICT, message); + } + + public static ApiException forbidden(String message) { + return new ApiException(HttpStatus.FORBIDDEN, message); + } + + public static ApiException unauthorized(String message){ + return new ApiException(HttpStatus.UNAUTHORIZED, message); + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/exception/GlobalExceptionHandler.java b/apps/backend/src/main/java/com/codestar/backend/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..0d6095d --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/exception/GlobalExceptionHandler.java @@ -0,0 +1,58 @@ +package com.codestar.backend.exception; + +import com.codestar.backend.dto.ApiResponseDto; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +/** + * Converts common exceptions into a consistent {@link ApiResponseDto} JSON. + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ApiException.class) + public ResponseEntity> handleApi(ApiException ex) { + return ResponseEntity.status(ex.getStatus()) + .body(new ApiResponseDto<>(false, ex.getMessage(), null)); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + String msg = ex.getBindingResult().getFieldErrors().stream() + .map(GlobalExceptionHandler::format) + .collect(Collectors.joining(" · ")); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ApiResponseDto<>(false, msg.isEmpty() ? "Invalid request" : msg, null)); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ApiResponseDto<>(false, "Access denied", null)); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity> handleAuth(AuthenticationException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ApiResponseDto<>(false, "Unauthenticated", null)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAny(Exception ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ApiResponseDto<>(false, "Internal error", null)); + } + + private static String format(FieldError e) { + return e.getField() + ": " + e.getDefaultMessage(); + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/model/Group.java b/apps/backend/src/main/java/com/codestar/backend/model/Group.java new file mode 100644 index 0000000..e781897 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/model/Group.java @@ -0,0 +1,67 @@ +package com.codestar.backend.model; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Learner group - {@link GroupMembership} + */ +@Entity +@Table(name = "groups") +public class Group { + + @Id + @GeneratedValue + @Column(columnDefinition = "uuid") + private UUID id; + + @Column(nullable = false, length = 120) + private String name; + + @Column(nullable = false, unique = true, length = 80) + private String slug; + + @Column(name = "starts_at") + private LocalDate startsAt; + + @Column(name = "ends_at") + private LocalDate endsAt; + + @Column(name = "created_by", nullable = false, columnDefinition = "uuid") + private UUID createdBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + public Group() {} + + public Group(String name, String slug, UUID createdBy) { + this.name = name; + this.slug = slug; + this.createdBy = createdBy; + } + + public UUID getId() { return id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + + public LocalDate getStartsAt() { return startsAt; } + public void setStartsAt(LocalDate startsAt) { this.startsAt = startsAt; } + + public LocalDate getEndsAt() { return endsAt; } + public void setEndsAt(LocalDate endsAt) { this.endsAt = endsAt; } + + public UUID getCreatedBy() { return createdBy; } + public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; } + + public OffsetDateTime getCreatedAt() { return createdAt; } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/model/GroupMemberRole.java b/apps/backend/src/main/java/com/codestar/backend/model/GroupMemberRole.java new file mode 100644 index 0000000..3385067 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/model/GroupMemberRole.java @@ -0,0 +1,9 @@ +package com.codestar.backend.model; + +/** + * User role in a group (student or teacher). Independent of the global role + */ +public enum GroupMemberRole { + STUDENT, + TEACHER +} diff --git a/apps/backend/src/main/java/com/codestar/backend/model/GroupMembership.java b/apps/backend/src/main/java/com/codestar/backend/model/GroupMembership.java new file mode 100644 index 0000000..05048dd --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/model/GroupMembership.java @@ -0,0 +1,40 @@ +package com.codestar.backend.model; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.OffsetDateTime; + +/** + * User membership/role with/in a group + */ +@Entity +@Table(name = "group_memberships") +public class GroupMembership { + + @EmbeddedId + private GroupMembershipId id; + + @Enumerated(EnumType.STRING) + @Column(name = "role_in_group", nullable = false, length = 16) + private GroupMemberRole roleInGroup = GroupMemberRole.STUDENT; + + @CreationTimestamp + @Column(name = "joined_at", nullable = false, updatable = false) + private OffsetDateTime joinedAt; + + public GroupMembership() {} + + public GroupMembership(GroupMembershipId id, GroupMemberRole role) { + this.id = id; + this.roleInGroup = role != null ? role : GroupMemberRole.STUDENT; + } + + public GroupMembershipId getId() { return id; } + public void setId(GroupMembershipId id) { this.id = id; } + + public GroupMemberRole getRoleInGroup() { return roleInGroup; } + public void setRoleInGroup(GroupMemberRole roleInGroup) { this.roleInGroup = roleInGroup; } + + public OffsetDateTime getJoinedAt() { return joinedAt; } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/model/GroupMembershipId.java b/apps/backend/src/main/java/com/codestar/backend/model/GroupMembershipId.java new file mode 100644 index 0000000..441b1af --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/model/GroupMembershipId.java @@ -0,0 +1,48 @@ +package com.codestar.backend.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.io.Serializable; +import java.util.Objects; +import java.util.UUID; + +/** + * Composite key of {@link GroupMembership} -> (user_id, group_id) + * N:N users <=> group + */ +@Embeddable +public class GroupMembershipId implements Serializable { + + @Column(name = "user_id", nullable = false, columnDefinition = "uuid") + private UUID userId; + + @Column(name = "group_id", nullable = false, columnDefinition = "uuid") + private UUID groupId; + + public GroupMembershipId() {} + + public GroupMembershipId(UUID userId, UUID groupId) { + this.userId = userId; + this.groupId = groupId; + } + + public UUID getUserId() { return userId; } + public void setUserId(UUID userId) { this.userId = userId; } + + public UUID getGroupId() { return groupId; } + public void setGroupId(UUID groupId) { this.groupId = groupId; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof GroupMembershipId other)) return false; + + return Objects.equals(userId, other.userId) && Objects.equals(groupId, other.groupId); + } + + @Override + public int hashCode() { + return Objects.hash(userId, groupId); + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/model/InvitationCode.java b/apps/backend/src/main/java/com/codestar/backend/model/InvitationCode.java new file mode 100644 index 0000000..43277f7 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/model/InvitationCode.java @@ -0,0 +1,88 @@ +package com.codestar.backend.model; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Group invitation code + * Format : {@code XXXX-XXXX-XXXX} + * Validity : {@code revoked_at IS NULL} AND ({@code expires_at IS NULL} OR {@code expires_at > now()}) AND {@code used_count < max_uses} + */ +@Entity +@Table(name = "invitation_codes") +public class InvitationCode { + + @Id + @GeneratedValue + @Column(columnDefinition = "uuid") + private UUID id; + + @Column(nullable = false, unique = true, length = 14) + private String code; + + @Column(name = "group_id", nullable = false, columnDefinition = "uuid") + private UUID groupId; + + @Column(name = "max_uses", nullable = false) + private int maxUses = 1; + + @Column(name = "used_count", nullable = false) + private int usedCount = 0; + + @Column(name = "expires_at") + private OffsetDateTime expiresAt; + + @Column(name = "created_by", nullable = false, columnDefinition = "uuid") + private UUID createdBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "revoked_at") + private OffsetDateTime revokedAt; + + public InvitationCode() {} + + public InvitationCode(String code, UUID groupId, int maxUses, OffsetDateTime expiresAt, UUID createdBy) { + this.code = code; + this.groupId = groupId; + this.maxUses = maxUses; + this.expiresAt = expiresAt; + this.createdBy = createdBy; + } + + public boolean isUsable(OffsetDateTime now) { + if (revokedAt != null) return false; + if (expiresAt != null && !expiresAt.isAfter(now)) return false; + return usedCount < maxUses; + } + + public UUID getId() { return id; } + + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + + public UUID getGroupId() { return groupId; } + public void setGroupId(UUID groupId) { this.groupId = groupId; } + + public int getMaxUses() { return maxUses; } + public void setMaxUses(int maxUses) { this.maxUses = maxUses; } + + public int getUsedCount() { return usedCount; } + public void setUsedCount(int usedCount) { this.usedCount = usedCount; } + + public OffsetDateTime getExpiresAt() { return expiresAt; } + public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; } + + public UUID getCreatedBy() { return createdBy; } + public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; } + + public OffsetDateTime getCreatedAt() { return createdAt; } + + public OffsetDateTime getRevokedAt() { return revokedAt; } + public void setRevokedAt(OffsetDateTime revokedAt) { this.revokedAt = revokedAt; } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/model/Role.java b/apps/backend/src/main/java/com/codestar/backend/model/Role.java new file mode 100644 index 0000000..32af07e --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/model/Role.java @@ -0,0 +1,28 @@ +package com.codestar.backend.model; + +import java.util.Objects; + +/** + * Global roles + */ +public enum Role { + STUDENT(10), + TEACHER(20), + ADMIN(30), + SUPER_ADMIN(40); + + private final int privilegeLevel; + + Role(int privilegeLevel) { + this.privilegeLevel = privilegeLevel; + } + + /** + * @return {@code true} if this role is at least as privileged as + * {@code other}, per the explicit {@code privilegeLevel} ranking. + */ + public boolean isAtLeast(Role other) { + Objects.requireNonNull(other, "other role must not be null"); + return this.privilegeLevel >= other.privilegeLevel; + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/model/User.java b/apps/backend/src/main/java/com/codestar/backend/model/User.java index 368722a..d7061d5 100644 --- a/apps/backend/src/main/java/com/codestar/backend/model/User.java +++ b/apps/backend/src/main/java/com/codestar/backend/model/User.java @@ -1,30 +1,71 @@ package com.codestar.backend.model; +import com.codestar.backend.utils.Emails; import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * CodeStar user (learner, teacher, admin) - {@code users}. + */ @Entity @Table(name = "users") public class User { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private long id; + @GeneratedValue + @Column(columnDefinition = "uuid") + private UUID id; + + @Column(nullable = false, unique = true, length = 255) + private String email; + + @Column(name = "password_hash", nullable = false, length = 255) + private String passwordHash; - @Column(unique = true) - private String username; + @Column(name = "display_name", nullable = false, length = 120) + private String displayName; - private String password; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private Role role = Role.STUDENT; - public User(){} + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; - public User(String username, String password){ - this.username = username; - this.password = password; + @Column(name = "disabled_at") + private OffsetDateTime disabledAt; + + public User() {} + + public User(String email, String passwordHash, String displayName, Role role) { + this.email = Emails.normalize(email); + this.passwordHash = passwordHash; + this.displayName = displayName; + this.role = role != null ? role : Role.STUDENT; } - public Long getId(){return id;} - public String getUsername(){return username;} - public String getPassword(){return password;} - public void setPassword(String password) { this.password = password; } + public UUID getId() { return id; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = Emails.normalize(email); } + + public String getPasswordHash() { return passwordHash; } + public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } + + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + + public Role getRole() { return role; } + public void setRole(Role role) { this.role = role; } + + public OffsetDateTime getCreatedAt() { return createdAt; } + + public OffsetDateTime getDisabledAt() { return disabledAt; } + public void setDisabledAt(OffsetDateTime disabledAt) { this.disabledAt = disabledAt; } + public boolean isActive() { return disabledAt == null; } } diff --git a/apps/backend/src/main/java/com/codestar/backend/repository/IGroupMembershipRepository.java b/apps/backend/src/main/java/com/codestar/backend/repository/IGroupMembershipRepository.java new file mode 100644 index 0000000..e26cc41 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/repository/IGroupMembershipRepository.java @@ -0,0 +1,20 @@ +package com.codestar.backend.repository; + +import com.codestar.backend.model.GroupMemberRole; +import com.codestar.backend.model.GroupMembership; +import com.codestar.backend.model.GroupMembershipId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface IGroupMembershipRepository extends JpaRepository { + + List findByIdUserId(UUID userId); + + List findByIdGroupId(UUID groupId); + + boolean existsByIdUserIdAndIdGroupId(UUID userId, UUID groupId); + + boolean existsByIdUserIdAndIdGroupIdAndRoleInGroup(UUID userId, UUID groupId, GroupMemberRole roleInGroup); +} diff --git a/apps/backend/src/main/java/com/codestar/backend/repository/IGroupRepository.java b/apps/backend/src/main/java/com/codestar/backend/repository/IGroupRepository.java new file mode 100644 index 0000000..bfd7ecd --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/repository/IGroupRepository.java @@ -0,0 +1,14 @@ +package com.codestar.backend.repository; + +import com.codestar.backend.model.Group; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface IGroupRepository extends JpaRepository { + + Optional findBySlug(String slug); + + boolean existsBySlug(String slug); +} diff --git a/apps/backend/src/main/java/com/codestar/backend/repository/IInvitationCodeRepository.java b/apps/backend/src/main/java/com/codestar/backend/repository/IInvitationCodeRepository.java new file mode 100644 index 0000000..ac52f5b --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/repository/IInvitationCodeRepository.java @@ -0,0 +1,29 @@ +package com.codestar.backend.repository; + +import com.codestar.backend.model.InvitationCode; +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 java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface IInvitationCodeRepository extends JpaRepository { + + Optional findByCode(String code); + + List findByGroupIdAndRevokedAtIsNull(UUID groupId); + + /** + * Returns the number of rows updated (1 = consumed, 0 = no longer usable). + */ + @Modifying + @Query("UPDATE InvitationCode i SET i.usedCount = i.usedCount + 1 " + + "WHERE i.id = :id AND i.revokedAt IS NULL " + + "AND (i.expiresAt IS NULL OR i.expiresAt > :now) " + + "AND i.usedCount < i.maxUses") + int tryConsume(@Param("id") UUID id, @Param("now") OffsetDateTime now); +} diff --git a/apps/backend/src/main/java/com/codestar/backend/repository/IUserRepository.java b/apps/backend/src/main/java/com/codestar/backend/repository/IUserRepository.java index be1edac..560314f 100644 --- a/apps/backend/src/main/java/com/codestar/backend/repository/IUserRepository.java +++ b/apps/backend/src/main/java/com/codestar/backend/repository/IUserRepository.java @@ -4,9 +4,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import java.util.UUID; -public interface IUserRepository extends JpaRepository { +public interface IUserRepository extends JpaRepository { - Optional findByUsername(String username); + Optional findByEmail(String email); + boolean existsByEmail(String email); } diff --git a/apps/backend/src/main/java/com/codestar/backend/security/ApiSecurityExceptionHandler.java b/apps/backend/src/main/java/com/codestar/backend/security/ApiSecurityExceptionHandler.java new file mode 100644 index 0000000..17e50c8 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/security/ApiSecurityExceptionHandler.java @@ -0,0 +1,51 @@ +package com.codestar.backend.security; + +import com.codestar.backend.dto.ApiResponseDto; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * Renders security filter-chain failures as {@link ApiResponseDto} JSON. + *

+ * {@code ExceptionTranslationFilter} catches authentication / authorization + * failures before they reach the dispatcher, so {@code GlobalExceptionHandler} + * never sees them. This handler keeps 401/403 responses API-shaped. + */ +@Component +public class ApiSecurityExceptionHandler implements AuthenticationEntryPoint, AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + public ApiSecurityExceptionHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException ex) throws IOException { + write(response, HttpStatus.UNAUTHORIZED, "Unauthenticated"); + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException ex) throws IOException { + write(response, HttpStatus.FORBIDDEN, "Access denied"); + } + + private void write(HttpServletResponse response, HttpStatus status, String message) throws IOException { + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), new ApiResponseDto<>(false, message, null)); + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/security/AuthenticatedUser.java b/apps/backend/src/main/java/com/codestar/backend/security/AuthenticatedUser.java new file mode 100644 index 0000000..e849889 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/security/AuthenticatedUser.java @@ -0,0 +1,58 @@ +package com.codestar.backend.security; + +import com.codestar.backend.model.Role; +import com.codestar.backend.model.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +/** + * Custom principal representing the authenticated user after JWT validation. + */ +public class AuthenticatedUser implements UserDetails { + + private final UUID id; + private final String email; + private final String passwordHash; + private final Role role; + private final boolean active; + + public AuthenticatedUser(User user) { + this.id = user.getId(); + this.email = user.getEmail(); + this.passwordHash = user.getPasswordHash(); + this.role = user.getRole(); + this.active = user.isActive(); + } + + public UUID getId() { return id; } + public String getEmail() { return email; } + public Role getRole() { return role; } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); + } + + @Override + public String getPassword() { return passwordHash; } + + @Override + public String getUsername() { return email; } + + @Override + public boolean isAccountNonExpired() { return active; } + + @Override + public boolean isAccountNonLocked() { return active; } + + @Override + public boolean isCredentialsNonExpired() { return active; } + + @Override + public boolean isEnabled() { return active; } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/security/GroupPermissionService.java b/apps/backend/src/main/java/com/codestar/backend/security/GroupPermissionService.java new file mode 100644 index 0000000..3606750 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/security/GroupPermissionService.java @@ -0,0 +1,42 @@ +package com.codestar.backend.security; + +import com.codestar.backend.model.GroupMemberRole; +import com.codestar.backend.model.Role; +import com.codestar.backend.repository.IGroupMembershipRepository; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +/** + * Permission helper exposed to {@code @PreAuthorize} SpEL expressions. + */ +@Service("groupPermissionService") +public class GroupPermissionService { + + private final IGroupMembershipRepository memberships; + + public GroupPermissionService(IGroupMembershipRepository memberships) { + this.memberships = memberships; + } + + /** + * Returns {@code true} if the principal is allowed to manage the given group: + * - ADMIN and SUPER_ADMIN can manage any group. + * - TEACHER can manage only groups where they hold the TEACHER membership role. + * - All other principals (anonymous, STUDENT) are denied. + */ + public boolean canManageGroup(Object principal, UUID groupId) { + if (!(principal instanceof AuthenticatedUser user) || groupId == null) { + return false; + } + Role role = user.getRole(); + if (role == Role.ADMIN || role == Role.SUPER_ADMIN) { + return true; + } + if (role == Role.TEACHER) { + return memberships.existsByIdUserIdAndIdGroupIdAndRoleInGroup( + user.getId(), groupId, GroupMemberRole.TEACHER); + } + return false; + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/security/JwtAuthenticationFilter.java b/apps/backend/src/main/java/com/codestar/backend/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..cedc3b6 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/security/JwtAuthenticationFilter.java @@ -0,0 +1,66 @@ +package com.codestar.backend.security; + +import com.codestar.backend.model.User; +import com.codestar.backend.repository.IUserRepository; +import com.codestar.backend.utils.JwtUtils; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +/** + * JWT filter that authenticates users via the {@code Authorization} header. + * Validates the token and populates the {@code SecurityContext} if successful. + * If validation fails, continues the filter chain without authentication. + */ +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtUtils jwtUtils; + private final IUserRepository userRepository; + + public JwtAuthenticationFilter(JwtUtils jwtUtils, IUserRepository userRepository) { + this.jwtUtils = jwtUtils; + this.userRepository = userRepository; + } + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain) throws ServletException, IOException { + + String header = request.getHeader("Authorization"); + if (header == null || !header.startsWith(BEARER_PREFIX) || SecurityContextHolder.getContext().getAuthentication() != null) { + chain.doFilter(request, response); + return; + } + + String token = header.substring(BEARER_PREFIX.length()); + try { + UUID userId = jwtUtils.getUserIdFromToken(token); + User user = userRepository.findById(userId).orElse(null); + + if (user != null && user.isActive()) { + AuthenticatedUser principal = new AuthenticatedUser(user); + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(auth); + } + } catch (JwtException | IllegalArgumentException ignored) { + // invalid / expired / malformed token = no authentication + SecurityContextHolder.clearContext(); + } + + chain.doFilter(request, response); + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/security/UserDetailsServiceImpl.java b/apps/backend/src/main/java/com/codestar/backend/security/UserDetailsServiceImpl.java new file mode 100644 index 0000000..346db99 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/security/UserDetailsServiceImpl.java @@ -0,0 +1,26 @@ +package com.codestar.backend.security; + +import com.codestar.backend.repository.IUserRepository; +import com.codestar.backend.utils.Emails; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + + private final IUserRepository userRepository; + + public UserDetailsServiceImpl(IUserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + String normalized = Emails.normalize(email); + return userRepository.findByEmail(normalized) + .map(AuthenticatedUser::new) + .orElseThrow(() -> new UsernameNotFoundException("Invalid credentials")); + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/service/GroupService.java b/apps/backend/src/main/java/com/codestar/backend/service/GroupService.java new file mode 100644 index 0000000..fb5dfbb --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/service/GroupService.java @@ -0,0 +1,131 @@ +package com.codestar.backend.service; + +import com.codestar.backend.dto.CreateGroupRequestDto; +import com.codestar.backend.dto.GroupResponseDto; +import com.codestar.backend.dto.GroupSummaryDto; +import com.codestar.backend.dto.UpdateGroupRequestDto; +import com.codestar.backend.exception.ApiException; +import com.codestar.backend.model.Group; +import com.codestar.backend.model.GroupMemberRole; +import com.codestar.backend.model.GroupMembership; +import com.codestar.backend.model.GroupMembershipId; +import com.codestar.backend.repository.IGroupMembershipRepository; +import com.codestar.backend.repository.IGroupRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.Normalizer; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class GroupService { + + private final IGroupRepository groups; + private final IGroupMembershipRepository memberships; + + public GroupService(IGroupRepository groups, IGroupMembershipRepository memberships) { + this.groups = groups; + this.memberships = memberships; + } + + @Transactional + public GroupResponseDto create(CreateGroupRequestDto req, UUID createdBy) { + String slug = req.getSlug() != null ? req.getSlug() : slugify(req.getName()); + if (groups.existsBySlug(slug)) { + throw ApiException.conflict("Slug already exist."); + } + + Group g = new Group(req.getName(), slug, createdBy); + g.setStartsAt(req.getStartsAt()); + g.setEndsAt(req.getEndsAt()); + validateDates(g); + + groups.save(g); + memberships.save(new GroupMembership(new GroupMembershipId(createdBy, g.getId()), GroupMemberRole.TEACHER)); + + return toDto(g); + } + + @Transactional + public GroupResponseDto update(UUID id, UpdateGroupRequestDto req) { + Group g = groups.findById(id) + .orElseThrow(() -> ApiException.notFound("Cannot find group")); + + if (req.getName() != null) g.setName(req.getName()); + if (req.getStartsAt() != null) g.setStartsAt(req.getStartsAt()); + if (req.getEndsAt() != null) g.setEndsAt(req.getEndsAt()); + validateDates(g); + + groups.save(g); + return toDto(g); + } + + @Transactional(readOnly = true) + public List listAll() { + return groups.findAll().stream().map(GroupService::toDto).toList(); + } + + @Transactional(readOnly = true) + public List listMine(UUID userId) { + List myMemberships = memberships.findByIdUserId(userId); + if (myMemberships.isEmpty()) return List.of(); + + List groupIds = myMemberships.stream() + .map(m -> m.getId().getGroupId()) + .toList(); + + Map byId = groups.findAllById(groupIds).stream() + .collect(Collectors.toMap(Group::getId, g -> g)); + + return myMemberships.stream() + .map(m -> { + Group g = byId.get(m.getId().getGroupId()); + if (g == null) return null; + return new GroupSummaryDto( + g.getId(), g.getName(), g.getSlug(), + g.getStartsAt(), g.getEndsAt(), + m.getRoleInGroup()); + }) + .filter(s -> s != null) + .toList(); + } + + @Transactional(readOnly = true) + public GroupResponseDto getOne(UUID id) { + return groups.findById(id) + .map(GroupService::toDto) + .orElseThrow(() -> ApiException.notFound("Cannot find group")); + } + + // helpers + private static GroupResponseDto toDto(Group g) { + return new GroupResponseDto( + g.getId(), + g.getName(), + g.getSlug(), + g.getStartsAt(), + g.getEndsAt(), + g.getCreatedBy(), + g.getCreatedAt()); + } + + private static void validateDates(Group g) { + if (g.getStartsAt() != null && g.getEndsAt() != null && g.getEndsAt().isBefore(g.getStartsAt())) { + throw ApiException.badRequest("endsAt must be >= startsAt"); + } + } + + private static String slugify(String name) { + String normalized = Normalizer.normalize(name, Normalizer.Form.NFD) + .replaceAll("\\p{InCombiningDiacriticalMarks}+", "") + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-+)|(-+$)", ""); + if (normalized.isEmpty()) normalized = "group"; + return normalized.length() > 80 ? normalized.substring(0, 80) : normalized; + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/service/InstanceBrandingService.java b/apps/backend/src/main/java/com/codestar/backend/service/InstanceBrandingService.java new file mode 100644 index 0000000..4e25685 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/service/InstanceBrandingService.java @@ -0,0 +1,73 @@ +package com.codestar.backend.service; + +import com.codestar.backend.dto.InstanceBrandingDto; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Service; + +import java.io.InputStream; + + +@Service +public class InstanceBrandingService { + + private static final Logger log = LoggerFactory.getLogger(InstanceBrandingService.class); + + private static final InstanceBrandingDto FALLBACK = new InstanceBrandingDto( + "Codestar", + "Open-source e-learning platform", + new InstanceBrandingDto.LogoDto("preset", "star"), + "#7AA9FF", + null, null, null, + "en"); + + @Value("${codestar.instance.config-path}") + private String configPath; + + private final ResourceLoader resourceLoader; + private final ObjectMapper objectMapper; + + private volatile InstanceBrandingDto cached; + + public InstanceBrandingService(ResourceLoader resourceLoader, ObjectMapper objectMapper) { + this.resourceLoader = resourceLoader; + this.objectMapper = objectMapper; + } + + @PostConstruct + void warmCheck() { + cached = load(); + log.info("Instance branding loaded: name='{}' accent={}", cached.name(), cached.accent()); + } + + public InstanceBrandingDto read() { + return cached; + } + + /** Re-reads the branding file and replaces the cache */ + public InstanceBrandingDto reload() { + cached = load(); + return cached; + } + + private InstanceBrandingDto load() { + Resource resource = resourceLoader.getResource(configPath); + if (!resource.exists()) { + log.warn("The branding file not found at '{}', using fallback defaults", configPath); + return FALLBACK; + } + try (InputStream in = resource.getInputStream()) { + return objectMapper.readValue(in, InstanceBrandingDto.class); + } catch (Exception e) { + log.error("Failed to parse the branding file at '{}': {}. Fallback in use.", configPath, e.getMessage()); + return FALLBACK; + } + } + + // TODO Can modify the branding (endpoint PUT /api/v1/instance/branding (super-admin) qui réécrit fichier) +} diff --git a/apps/backend/src/main/java/com/codestar/backend/service/InvitationCodeGenerator.java b/apps/backend/src/main/java/com/codestar/backend/service/InvitationCodeGenerator.java new file mode 100644 index 0000000..cfb47ce --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/service/InvitationCodeGenerator.java @@ -0,0 +1,29 @@ +package com.codestar.backend.service; + +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; + +@Component +public class InvitationCodeGenerator { + + private static final char[] ALPHABET ="ABCDEFGHJKLMNPQRSTUVWXYZ23456789".toCharArray(); + private static final int BLOCK_SIZE = 4; + private static final int BLOCKS = 3; + + private final SecureRandom random = new SecureRandom(); + + public String generate() { + StringBuilder sb = new StringBuilder(BLOCK_SIZE * BLOCKS + (BLOCKS - 1)); + + for (int b = 0; b < BLOCKS; b++) { + if (b > 0) sb.append('-'); + + for (int i = 0; i < BLOCK_SIZE; i++) { + sb.append(ALPHABET[random.nextInt(ALPHABET.length)]); + } + } + + return sb.toString(); + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/service/InvitationService.java b/apps/backend/src/main/java/com/codestar/backend/service/InvitationService.java new file mode 100644 index 0000000..da4b1b1 --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/service/InvitationService.java @@ -0,0 +1,80 @@ +package com.codestar.backend.service; + +import com.codestar.backend.exception.ApiException; +import com.codestar.backend.model.GroupMemberRole; +import com.codestar.backend.model.GroupMembership; +import com.codestar.backend.model.GroupMembershipId; +import com.codestar.backend.model.InvitationCode; +import com.codestar.backend.repository.IGroupMembershipRepository; +import com.codestar.backend.repository.IGroupRepository; +import com.codestar.backend.repository.IInvitationCodeRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Service +public class InvitationService { + + private final IInvitationCodeRepository invitations; + private final IGroupRepository groups; + private final IGroupMembershipRepository memberships; + private final InvitationCodeGenerator generator; + + public InvitationService(IInvitationCodeRepository invitations, IGroupRepository groups, IGroupMembershipRepository memberships, InvitationCodeGenerator generator) { + this.invitations = invitations; + this.groups = groups; + this.memberships = memberships; + this.generator = generator; + } + + @Transactional + public InvitationCode create(UUID groupId, int maxUses, OffsetDateTime expiresAt, UUID createdBy) { + if (!groups.existsById(groupId)) { + throw ApiException.notFound("Invalid group"); + } + + // Attempts until a unique code is found. Probability of collision = ~10^-18 + String code; + do { + code = generator.generate(); + } while (invitations.findByCode(code).isPresent()); + + InvitationCode entity = new InvitationCode(code, groupId, maxUses, expiresAt, createdBy); + return invitations.save(entity); + } + + @Transactional + public void revoke(UUID invitationId, UUID actorUserId) { + InvitationCode inv = invitations.findById(invitationId).orElseThrow(() -> ApiException.notFound("Invalid invintation")); + if (inv.getRevokedAt() != null) return; + inv.setRevokedAt(OffsetDateTime.now()); + invitations.save(inv); + } + + @Transactional + public UUID consumeAndJoin(String rawCode, UUID userId) { + String code = rawCode == null ? "" : rawCode.toUpperCase(); + InvitationCode inv = invitations.findByCode(code).orElseThrow(() -> ApiException.notFound("Invalid code")); + + OffsetDateTime now = OffsetDateTime.now(); + if (!inv.isUsable(now)) { + throw ApiException.badRequest("Code expired, revoked or used up"); + } + + UUID groupId = inv.getGroupId(); + + if (memberships.existsByIdUserIdAndIdGroupId(userId, groupId)) { + return groupId; + } + + memberships.save(new GroupMembership(new GroupMembershipId(userId, groupId), GroupMemberRole.STUDENT)); + + if (invitations.tryConsume(inv.getId(), now) == 0) { + throw ApiException.badRequest("Code expired, revoked or used up"); + } + + return groupId; + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/utils/Emails.java b/apps/backend/src/main/java/com/codestar/backend/utils/Emails.java new file mode 100644 index 0000000..74d928c --- /dev/null +++ b/apps/backend/src/main/java/com/codestar/backend/utils/Emails.java @@ -0,0 +1,13 @@ +package com.codestar.backend.utils; + +import java.util.Locale; + +public final class Emails { + + private Emails() {} + + public static String normalize(String email) { + if (email == null) return null; + return email.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/apps/backend/src/main/java/com/codestar/backend/utils/JwtUtils.java b/apps/backend/src/main/java/com/codestar/backend/utils/JwtUtils.java index 41b972c..1efd035 100644 --- a/apps/backend/src/main/java/com/codestar/backend/utils/JwtUtils.java +++ b/apps/backend/src/main/java/com/codestar/backend/utils/JwtUtils.java @@ -1,5 +1,10 @@ package com.codestar.backend.utils; +import com.codestar.backend.model.Role; +import com.codestar.backend.model.User; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import jakarta.annotation.PostConstruct; @@ -8,9 +13,14 @@ import java.security.Key; import java.util.Date; +import java.util.UUID; +/** + * Generates and verifies JWTs + */ @Component public class JwtUtils { + @Value("${jwt.secret}") private String secret; @@ -20,25 +30,35 @@ public class JwtUtils { private Key key; @PostConstruct - public void init(){ + public void init() { key = Keys.hmacShaKeyFor(secret.getBytes()); } - public String generateToken(String username){ + public String generateToken(User user) { + Date now = new Date(); return Jwts.builder() - .setSubject(username) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis()+expiration)) + .setSubject(user.getId().toString()) + .claim("email", user.getEmail()) + .claim("role", user.getRole().name()) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + expiration)) .signWith(key) .compact(); } - public String getUsernameFromToken(String token){ - return Jwts.parserBuilder() + public Claims parse(String token) throws JwtException { + Jws jws = Jwts.parserBuilder() .setSigningKey(key) .build() - .parseClaimsJws(token) - .getBody() - .getSubject(); + .parseClaimsJws(token); + return jws.getBody(); + } + + public UUID getUserIdFromToken(String token) { + return UUID.fromString(parse(token).getSubject()); + } + + public Role getRoleFromToken(String token) { + return Role.valueOf(parse(token).get("role", String.class)); } } diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index 139eaad..2593e5d 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -1,16 +1,31 @@ spring.config.import=optional:classpath:application.dev.properties spring.application.name=backend -spring.datasource.url=jdbc:postgresql://localhost:5432/codestardb -spring.datasource.username=admin -spring.datasource.password=admin +# DataSource +spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/codestardb} +spring.datasource.username=${DB_USER} +spring.datasource.password=${DB_PASSWORD} spring.datasource.driver-class-name=org.postgresql.Driver -spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=true +# JPA / Hibernate +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.open-in-view=false +# Flyway +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration +spring.flyway.baseline-on-migrate=true +# Profil + JWT spring.profiles.active=dev jwt.secret=${JWT_SECRET} -jwt.expiration=${JWT_EXPIRATION} \ No newline at end of file +jwt.expiration=${JWT_EXPIRATION} + +# Codestar — instance configuration +codestar.instance.config-path=${INSTANCE_CONFIG_PATH:classpath:instance.json} +codestar.signup.open=${SIGNUP_OPEN:false} + +# CORS — comma-separated frontend origins +codestar.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000} diff --git a/apps/backend/src/main/resources/db/migration/V001.sql b/apps/backend/src/main/resources/db/migration/V001.sql new file mode 100644 index 0000000..0a559ce --- /dev/null +++ b/apps/backend/src/main/resources/db/migration/V001.sql @@ -0,0 +1,82 @@ +-- ============================================================ +-- V001 — db schema v1 +-- ============================================================ + +-- USERS +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(120) NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'STUDENT', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + disabled_at TIMESTAMPTZ NULL, + CONSTRAINT users_role_check + CHECK (role IN ('STUDENT', 'TEACHER', 'ADMIN', 'SUPER_ADMIN')), + CONSTRAINT users_email_lowercase_chk + CHECK (email = LOWER(email)) +); + +CREATE INDEX idx_users_role ON users (role); + +-- GROUPS +CREATE TABLE groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(120) NOT NULL, + slug VARCHAR(80) NOT NULL UNIQUE, + starts_at DATE NULL, + ends_at DATE NULL, + created_by UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT groups_created_by_fk + FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE RESTRICT, + CONSTRAINT groups_dates_chk + CHECK (ends_at IS NULL OR starts_at IS NULL OR ends_at >= starts_at) +); + +CREATE INDEX idx_groups_created_by ON groups (created_by); + +-- GROUP_MEMBERSHIPS - Link N:N user ↔ group + role in group (STUDENT | TEACHER) +CREATE TABLE group_memberships ( + user_id UUID NOT NULL, + group_id UUID NOT NULL, + role_in_group VARCHAR(16) NOT NULL DEFAULT 'STUDENT', + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, group_id), + CONSTRAINT group_memberships_user_fk + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT group_memberships_group_fk + FOREIGN KEY (group_id) REFERENCES groups (id) ON DELETE CASCADE, + CONSTRAINT group_memberships_role_chk + CHECK (role_in_group IN ('STUDENT', 'TEACHER')) +); + +CREATE INDEX idx_group_memberships_group ON group_memberships (group_id); + +-- INVITATION_CODES +CREATE TABLE invitation_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(14) NOT NULL UNIQUE, + group_id UUID NOT NULL, + max_uses INTEGER NOT NULL DEFAULT 1, + used_count INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ NULL, + created_by UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ NULL, + CONSTRAINT invitation_codes_group_fk + FOREIGN KEY (group_id) REFERENCES groups (id) ON DELETE CASCADE, + CONSTRAINT invitation_codes_created_by_fk + FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE RESTRICT, + CONSTRAINT invitation_codes_max_uses_chk + CHECK (max_uses > 0), + CONSTRAINT invitation_codes_used_count_chk + CHECK (used_count >= 0 AND used_count <= max_uses), + CONSTRAINT invitation_codes_format_chk + CHECK (code ~ '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$') +); + +CREATE INDEX idx_invitation_codes_group ON invitation_codes (group_id); +CREATE INDEX idx_invitation_codes_active + ON invitation_codes (group_id) + WHERE revoked_at IS NULL; diff --git a/apps/backend/src/main/resources/instance.json b/apps/backend/src/main/resources/instance.json new file mode 100644 index 0000000..74b3b06 --- /dev/null +++ b/apps/backend/src/main/resources/instance.json @@ -0,0 +1,10 @@ +{ + "name": "Codestar", + "tagline": "Open-source e-learning platform", + "logo": { "kind": "preset", "value": "star" }, + "accent": "#7AA9FF", + "heroTitle": null, + "heroSubtitle": null, + "heroCta": null, + "locale": "en" +} diff --git a/apps/backend/target/classes/application-dev.properties b/apps/backend/target/classes/application-dev.properties deleted file mode 100644 index fe2c7a9..0000000 --- a/apps/backend/target/classes/application-dev.properties +++ /dev/null @@ -1,2 +0,0 @@ -jwt.secret=MaCleSecreteDeDeveloppementLocal12345! -jwt.expiration=86400000 \ No newline at end of file diff --git a/apps/backend/target/classes/application.properties b/apps/backend/target/classes/application.properties deleted file mode 100644 index 139eaad..0000000 --- a/apps/backend/target/classes/application.properties +++ /dev/null @@ -1,16 +0,0 @@ -spring.config.import=optional:classpath:application.dev.properties -spring.application.name=backend - -spring.datasource.url=jdbc:postgresql://localhost:5432/codestardb -spring.datasource.username=admin -spring.datasource.password=admin -spring.datasource.driver-class-name=org.postgresql.Driver - -spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=true -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect - - -spring.profiles.active=dev -jwt.secret=${JWT_SECRET} -jwt.expiration=${JWT_EXPIRATION} \ No newline at end of file diff --git a/apps/backend/target/classes/com/codestar/backend/BackendApplication.class b/apps/backend/target/classes/com/codestar/backend/BackendApplication.class deleted file mode 100644 index f497906..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/BackendApplication.class and /dev/null differ diff --git a/apps/backend/target/classes/com/codestar/backend/config/SecurityConfig.class b/apps/backend/target/classes/com/codestar/backend/config/SecurityConfig.class deleted file mode 100644 index 9ae2715..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/config/SecurityConfig.class and /dev/null differ diff --git a/apps/backend/target/classes/com/codestar/backend/controller/AuthController.class b/apps/backend/target/classes/com/codestar/backend/controller/AuthController.class deleted file mode 100644 index 96f3dfe..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/controller/AuthController.class and /dev/null differ diff --git a/apps/backend/target/classes/com/codestar/backend/controller/CourseController.class b/apps/backend/target/classes/com/codestar/backend/controller/CourseController.class deleted file mode 100644 index 71b0896..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/controller/CourseController.class and /dev/null differ diff --git a/apps/backend/target/classes/com/codestar/backend/dto/ApiResponseDto.class b/apps/backend/target/classes/com/codestar/backend/dto/ApiResponseDto.class deleted file mode 100644 index d026ae6..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/dto/ApiResponseDto.class and /dev/null differ diff --git a/apps/backend/target/classes/com/codestar/backend/dto/LoginRequestDto.class b/apps/backend/target/classes/com/codestar/backend/dto/LoginRequestDto.class deleted file mode 100644 index eb245a5..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/dto/LoginRequestDto.class and /dev/null differ diff --git a/apps/backend/target/classes/com/codestar/backend/dto/LoginResponseDto.class b/apps/backend/target/classes/com/codestar/backend/dto/LoginResponseDto.class deleted file mode 100644 index 76f6dd8..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/dto/LoginResponseDto.class and /dev/null differ diff --git a/apps/backend/target/classes/com/codestar/backend/dto/course/CourseBlockDto.class b/apps/backend/target/classes/com/codestar/backend/dto/course/CourseBlockDto.class deleted file mode 100644 index f914cf7..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/dto/course/CourseBlockDto.class and /dev/null differ diff --git a/apps/backend/target/classes/com/codestar/backend/dto/course/CourseBlockType.class b/apps/backend/target/classes/com/codestar/backend/dto/course/CourseBlockType.class deleted file mode 100644 index 048afe3..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/dto/course/CourseBlockType.class and /dev/null differ diff --git a/apps/backend/target/classes/com/codestar/backend/dto/course/CourseDto.class b/apps/backend/target/classes/com/codestar/backend/dto/course/CourseDto.class deleted file mode 100644 index 155d1ab..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/dto/course/CourseDto.class and /dev/null differ diff --git a/apps/backend/target/classes/com/codestar/backend/dto/course/CoursePageDto.class b/apps/backend/target/classes/com/codestar/backend/dto/course/CoursePageDto.class deleted file mode 100644 index 7b22d13..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/dto/course/CoursePageDto.class and /dev/null differ diff --git a/apps/backend/target/classes/com/codestar/backend/model/User.class b/apps/backend/target/classes/com/codestar/backend/model/User.class deleted file mode 100644 index f72fd98..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/model/User.class and /dev/null differ diff --git a/apps/backend/target/classes/com/codestar/backend/repository/IUserRepository.class b/apps/backend/target/classes/com/codestar/backend/repository/IUserRepository.class deleted file mode 100644 index 654984c..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/repository/IUserRepository.class and /dev/null differ diff --git a/apps/backend/target/classes/com/codestar/backend/utils/JwtUtils.class b/apps/backend/target/classes/com/codestar/backend/utils/JwtUtils.class deleted file mode 100644 index c032509..0000000 Binary files a/apps/backend/target/classes/com/codestar/backend/utils/JwtUtils.class and /dev/null differ diff --git a/apps/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/apps/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst deleted file mode 100644 index 57d2fbc..0000000 --- a/apps/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ /dev/null @@ -1,14 +0,0 @@ -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\BackendApplication.java -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\config\SecurityConfig.java -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\controller\AuthController.java -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\controller\CourseController.java -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\dto\ApiResponseDto.java -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\dto\course\CourseBlockDto.java -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\dto\course\CourseBlockType.java -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\dto\course\CourseDto.java -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\dto\course\CoursePageDto.java -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\dto\LoginRequestDto.java -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\dto\LoginResponseDto.java -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\model\User.java -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\repository\IUserRepository.java -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\main\java\com\codestar\backend\utils\JwtUtils.java diff --git a/apps/backend/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst b/apps/backend/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst deleted file mode 100644 index e69de29..0000000 diff --git a/apps/backend/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst b/apps/backend/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst deleted file mode 100644 index cea84f6..0000000 --- a/apps/backend/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst +++ /dev/null @@ -1 +0,0 @@ -C:\Users\timmf\Documents\GitHub\CodeStar-backend\src\test\java\com\codestar\backend\BackendApplicationTests.java diff --git a/apps/backend/target/test-classes/com/codestar/backend/BackendApplicationTests.class b/apps/backend/target/test-classes/com/codestar/backend/BackendApplicationTests.class deleted file mode 100644 index c7c70a2..0000000 Binary files a/apps/backend/target/test-classes/com/codestar/backend/BackendApplicationTests.class and /dev/null differ diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example new file mode 100644 index 0000000..195a09e --- /dev/null +++ b/apps/frontend/.env.example @@ -0,0 +1,8 @@ +# Backend URL +API_URL=http://localhost:8080 + +# Name of the httpOnly token that store the auth token +AUTH_COOKIE_NAME=codestar_token + +# Public canonical origin (used by metadata, robots.txt, sitemap.xml) +SITE_URL=https://codestar.dev diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore index 5ef6a52..f411610 100644 --- a/apps/frontend/.gitignore +++ b/apps/frontend/.gitignore @@ -30,8 +30,9 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) +# local env files .env* +!.env.example # vercel .vercel @@ -39,3 +40,12 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# testing (Playwright) +/playwright-report/ +/test-results/ +/playwright/.cache/ + +# next.js / build caches +.swc/ +.turbo/ diff --git a/apps/frontend/CLAUDE.md b/apps/frontend/CLAUDE.md index 104e10f..b2451d6 100644 --- a/apps/frontend/CLAUDE.md +++ b/apps/frontend/CLAUDE.md @@ -1,32 +1,81 @@ @AGENTS.md -# Direction Artistique — Couleurs validées +# Contexte projet -Palette validée par l'utilisateur le 2026-05-04. Maquette de référence : `design-mockup.html` à la racine du repo. +Source de vérité technique du projet : [`/hand-off.md`](../../hand-off.md) à la racine du repo. +Y consulter avant toute décision touchant : roles & permissions, modèle de données, endpoints REST, scope par phase (v1/v2/v3), spécifications UI page-par-page. -## Couleur principale -`#F28022` (orange) — accent unique, jamais utilisé en fond large. +Source de vérité visuelle : `/codestar-draft-design/` (mocks React du draft initial). Le draft est obsolète sur la DA (cf. ci-dessous) mais reste valable pour la structure des écrans. -## Thème clair (défaut) -- Fond : `#F5F5F7` (gris neutre Apple) -- Cards : `#FFFFFF` -- Texte principal : `#1D1D1F` — secondaire : `#6E6E73` — tertiaire : `#AEAEB2` -- Borders : `rgba(0,0,0,0.08)` / `rgba(0,0,0,0.13)` -- Ombres : neutres `rgba(0,0,0,…)`, jamais warm +# Direction Artistique — Liquid Glass -## Thème sombre (navy + orange) -- Fond : `#0B1220` — cards : `#111D32` -- Texte : `#EDF1F9` — secondaire : `#8B9BB8` — tertiaire : `#4A5B78` -- Borders : `rgba(255,255,255,0.07)` +DA validée le 2026-05-09. Remplace l'ancienne DA Outfit + #F28022 + Apple-grey (commit antérieur, désormais obsolète). Détails complets : `hand-off.md` §7. -## Éléments sombres (code, cartes IA) -- Fond : `#111318` ou `#18181E` — jamais de teinte brune/warm -- Texte code : `#CDD6F4` (neutre froid) -- Syntaxe : Catppuccin Mocha, orange brand (`#F28022`) uniquement sur les types +## Principes +- Vitres translucides (`backdrop-filter: blur(20px) saturate(180%)`). +- Palette pastel iOS (lavande, pêche, menthe en mesh-gradient sur le fond). +- Accent paramétrable par instance via `instance.json` (fallback `#7AA9FF`). +- Chaleureux + professionnel. Références : Apple Vision OS, Linear, Arc Browser. Pas de néon, pas de flat brutalisme. +- Mobile-first dès la v1. WCAG AA respecté (contraste sur glass vérifié, focus visibles, ARIA). + +## Tokens CSS (à poser dans `app/globals.css`) + +```css +:root { + --bg-base: #F4F6FB; + --bg-mesh-1: #DCE8FF; + --bg-mesh-2: #FFE4D6; + --bg-mesh-3: #E1F5E8; + + --glass-bg: rgba(255, 255, 255, 0.55); + --glass-bg-strong: rgba(255, 255, 255, 0.72); + --glass-border: rgba(255, 255, 255, 0.65); + --glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.08); + --glass-blur: blur(20px) saturate(180%); + + --text: #1A1F2E; + --text-soft: #4A5366; + --muted: #8892A6; + + --accent: #7AA9FF; + --accent-fg: #FFFFFF; + --accent-soft: rgba(122, 169, 255, 0.18); + + --success: #5DC9A8; + --warning: #FFB672; + --danger: #FF8A95; + + --font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif; + --font-display: 'Instrument Serif', 'Fraunces', Georgia, serif; + --font-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace; + + --r-sm: 8px; + --r: 14px; + --r-lg: 22px; + --r-xl: 32px; +} + +[data-theme="dark"] { + --bg-base: #0E1422; + --glass-bg: rgba(20, 28, 48, 0.55); + --glass-bg-strong: rgba(20, 28, 48, 0.78); + --glass-border: rgba(255, 255, 255, 0.10); + --text: #EDF1F9; + --text-soft: #B6C0D6; + --muted: #7C8BA8; +} +``` + +## Composants à créer dans `components/ui/` +``, ``, ``, ``, ``, ``. Variantes glass : `default | strong | tinted-accent`. + +## Garde-fous +- Contraste texte sur glass : tester systématiquement au-dessus de chaque mesh-spot. Si < 4.5:1 → utiliser `--glass-bg-strong`. +- Focus ring : `2px solid var(--accent)` + `outline-offset: 2px`, jamais supprimé. +- `prefers-reduced-motion` : désactiver mesh animation et page-fade transitions. +- `prefers-contrast: more` : remplacer glass par surfaces opaques. ## Typographie -**Outfit** (Google Fonts) — validée le 2026-05-04. -- Titres display : weight 800–900 -- Corps / UI : weight 400–600 -- Import : `family=Outfit:wght@300..900` -- Fallback : `-apple-system, BlinkMacSystemFont, sans-serif` +**Outfit** (Google Fonts) — body / UI. +**Instrument Serif** ou **Fraunces** — display / titres éditoriaux. +Import : `family=Outfit:wght@300..900&family=Instrument+Serif:ital@0;1`. diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index b899eb2..a3a16c9 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -1,20 +1,28 @@ -# Stage 1: Install dependencies +# Install dependencies FROM node:20-alpine AS deps WORKDIR /app RUN npm install -g pnpm COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile -# Stage 2: Build +# Build FROM node:20-alpine AS build WORKDIR /app RUN npm install -g pnpm COPY --from=deps /app/node_modules ./node_modules COPY . . + +ARG JWT_SECRET +ARG AUTH_COOKIE_NAME=codestar_token +ARG SITE_URL=http://localhost:3000 +ENV JWT_SECRET=$JWT_SECRET +ENV AUTH_COOKIE_NAME=$AUTH_COOKIE_NAME +ENV SITE_URL=$SITE_URL + ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm build -# Stage 3: Runtime (standalone) +# Runtime (standalone) FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENV=production diff --git a/apps/frontend/app/actions/auth.ts b/apps/frontend/app/actions/auth.ts new file mode 100644 index 0000000..338984b --- /dev/null +++ b/apps/frontend/app/actions/auth.ts @@ -0,0 +1,140 @@ +"use server"; + +/** + * Auth server actions +*/ + +import { cookies } from "next/headers"; +import { revalidatePath } from "next/cache"; +import { getTranslations } from "next-intl/server"; + +import { ApiError, apiFetch, authCookieName } from "@/lib/api"; +import type { LoginResponse, MeResponse } from "@/lib/types"; + +interface AuthActionResult { + ok: boolean; + error?: string; + expired?: boolean; +} + +interface SignInInput { + email: string; + password: string; +} + +interface SignUpInput { + email: string; + password: string; + displayName: string; + invitationCode?: string; +} + +interface JoinGroupInput { + code: string; +} + +const COOKIE_OPTS = { + path: "/", + maxAge: 60 * 60 * 24 * 365, + httpOnly: true, + sameSite: "lax" as const, + secure: process.env.NODE_ENV === "production", +}; + +async function setAuthCookie(token: string) { + const store = await cookies(); + store.set(authCookieName(), token, COOKIE_OPTS); +} + +export async function signInAction(input: SignInInput): Promise { + const t = await getTranslations("login.errors"); + try { + const data = await apiFetch("/api/v1/auth/login", { + method: "POST", + body: input, + withAuth: false, + }); + if (!data?.accessToken) return { ok: false, error: t("backendInvalid") }; + await setAuthCookie(data.accessToken); + revalidatePath("/", "layout"); + return { ok: true }; + } catch (e) { + return { ok: false, error: errorMessage(e, t) }; + } +} + +export async function signUpAction(input: SignUpInput): Promise { + const t = await getTranslations("login.errors"); + try { + const data = await apiFetch("/api/v1/auth/register", { + method: "POST", + body: input, + withAuth: false, + }); + if (!data?.accessToken) return { ok: false, error: t("backendInvalid") }; + await setAuthCookie(data.accessToken); + revalidatePath("/", "layout"); + return { ok: true }; + } catch (e) { + return { ok: false, error: errorMessage(e, t) }; + } +} + +export async function joinGroupAction(input: JoinGroupInput): Promise { + const t = await getTranslations("login.errors"); + try { + await apiFetch("/api/v1/groups/join", { + method: "POST", + body: { code: input.code }, + }); + revalidatePath("/", "layout"); + return { ok: true }; + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + return { ok: false, expired: true, error: e.message }; + } + return { ok: false, error: errorMessage(e, t) }; + } +} + +export async function signOutAction(): Promise { + const store = await cookies(); + const hasToken = !!store.get(authCookieName()); + + if (hasToken) { + try { + await apiFetch("/api/v1/auth/logout", { method: "POST" }); + } catch { + // logout backend stateless / ignore failure + } + } + + store.delete(authCookieName()); + revalidatePath("/", "layout"); + return { ok: true }; +} + + +export async function getMe(): Promise { + const store = await cookies(); + if (!store.get(authCookieName())) return null; + + try { + const response = await apiFetch("/api/v1/auth/me", { method: "GET" }); + return response ?? null; + } catch (e) { + if (e instanceof ApiError && (e.status === 401 || e.status === 403)) { + return null; + } + throw e; + } +} + +function errorMessage( + e: unknown, + t: (key: "network" | "unknown" | "backendInvalid") => string +): string { + if (e instanceof ApiError) return e.message; + if (e instanceof Error) return e.message; + return t("network"); +} diff --git a/apps/frontend/app/actions/courses.ts b/apps/frontend/app/actions/courses.ts new file mode 100644 index 0000000..f96cfa7 --- /dev/null +++ b/apps/frontend/app/actions/courses.ts @@ -0,0 +1,30 @@ +import "server-only"; + +/** + * Fetch courses. + */ + +import { apiFetch } from "@/lib/api"; +import type { Course } from "@/lib/types"; + +export async function getCourses(): Promise { + try { + const data = await apiFetch("/api/v1/courses", { + method: "GET", + }); + return data ?? []; + } catch { + return []; + } +} + +export async function getCourseById(id: number): Promise { + try { + const data = await apiFetch(`/api/v1/courses/${id}`, { + method: "GET", + }); + return data ?? null; + } catch { + return null; + } +} diff --git a/apps/frontend/app/actions/instance.ts b/apps/frontend/app/actions/instance.ts new file mode 100644 index 0000000..b7012c5 --- /dev/null +++ b/apps/frontend/app/actions/instance.ts @@ -0,0 +1,23 @@ +import "server-only"; + +/** + * Fetch instance branding +*/ + +import { apiFetch } from "@/lib/api"; +import { DEFAULT_INSTANCE } from "@/lib/instance"; +import type { InstanceBranding } from "@/lib/types"; + +export async function getInstanceBranding(): Promise { + try { + const data = await apiFetch("/api/v1/instance/branding", { + method: "GET", + withAuth: false, + next: { revalidate: 60 }, + }); + return data ?? DEFAULT_INSTANCE; + } catch { + // Frontend static fallback + return DEFAULT_INSTANCE; + } +} diff --git a/apps/frontend/app/actions/locale.ts b/apps/frontend/app/actions/locale.ts new file mode 100644 index 0000000..9536b3f --- /dev/null +++ b/apps/frontend/app/actions/locale.ts @@ -0,0 +1,22 @@ +"use server"; + +/** + * Change the locale via `LOCALE_COOKIE`. +*/ + +import { cookies } from "next/headers"; +import { revalidatePath } from "next/cache"; + +import { LOCALE_COOKIE, isLocale } from "@/i18n/routing"; + +export async function setLocaleAction(value: string) { + if (!isLocale(value)) return; + const cookieStore = await cookies(); + cookieStore.set(LOCALE_COOKIE, value, { + path: "/", + maxAge: 60 * 60 * 24 * 365, // 1 year + sameSite: "lax", + httpOnly: false, + }); + revalidatePath("/", "layout"); +} diff --git a/apps/frontend/app/courses/[id]/page.tsx b/apps/frontend/app/courses/[id]/page.tsx new file mode 100644 index 0000000..ad17ca0 --- /dev/null +++ b/apps/frontend/app/courses/[id]/page.tsx @@ -0,0 +1,88 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; + +import { getCourseById } from "@/app/actions/courses"; +import { SiteFooter } from "@/components/site-footer"; +import { TopNav } from "@/components/top-nav"; +import { GlassButton } from "@/components/ui/glass-button"; +import { + GlassCard, + GlassCardContent, + GlassCardDescription, + GlassCardTitle, +} from "@/components/ui/glass-card"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { id } = await params; + const courseId = Number(id); + if (!Number.isInteger(courseId) || courseId <= 0) return {}; + + const course = await getCourseById(courseId); + if (!course) return {}; + + return { + title: course.title, + description: course.description, + }; +} + +// TODO + +export default async function CoursePage({ params }: PageProps) { + const { id } = await params; + const courseId = Number(id); + if (!Number.isInteger(courseId) || courseId <= 0) notFound(); + + const [course, t] = await Promise.all([ + getCourseById(courseId), + getTranslations("course"), + ]); + + if (!course) notFound(); + + return ( + <> + +

+ + ← {t("back")} + + +
+

+ {course.title} +

+

+ {course.description} +

+
+ +
    + {course.pages.map((page) => ( +
  • + + + + {t("pageLabel", { number: page.pageNumber })} + + + {t("blocksCount", { count: page.blocks.length })} + + + +
  • + ))} +
+
+ + + ); +} diff --git a/apps/frontend/app/error.tsx b/apps/frontend/app/error.tsx new file mode 100644 index 0000000..34a5e56 --- /dev/null +++ b/apps/frontend/app/error.tsx @@ -0,0 +1,46 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +import { GlassButton } from "@/components/ui/glass-button"; +import { GlassCard } from "@/components/ui/glass-card"; + +export default function GlobalError({ + error, + unstable_retry, +}: { + error: Error & { digest?: string }; + unstable_retry: () => void; +}) { + const t = useTranslations("errors"); + + React.useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+ +

+ {t("title")} +

+

{t("description")}

+ {error.digest ? ( +

+ #{error.digest} +

+ ) : null} +
+ + {t("retry")} + + + {t("home")} + +
+
+
+ ); +} diff --git a/apps/frontend/app/globals.css b/apps/frontend/app/globals.css index a2dc41e..3290288 100644 --- a/apps/frontend/app/globals.css +++ b/apps/frontend/app/globals.css @@ -1,26 +1,208 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --color-bg-base-raw: #f4f6fb; + --color-bg-mesh-1-raw: #dce8ff; + --color-bg-mesh-2-raw: #ffe4d6; + --color-bg-mesh-3-raw: #e1f5e8; + + --glass-bg: rgba(255, 255, 255, 0.55); + --glass-bg-strong: rgba(255, 255, 255, 0.72); + --glass-border: rgba(255, 255, 255, 0.65); + --glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.08); + --glass-blur: blur(20px) saturate(180%); + + --color-text-raw: #1a1f2e; + --color-text-soft-raw: #4a5366; + --color-muted-raw: #8892a6; + + --color-accent-raw: #7aa9ff; + --color-accent-fg-raw: #ffffff; + --color-accent-soft-raw: rgba(122, 169, 255, 0.18); + + --color-success-raw: #5dc9a8; + --color-warning-raw: #ffb672; + --color-danger-raw: #ff8a95; + + --r-sm: 8px; + --r: 14px; + --r-lg: 22px; + --r-xl: 32px; + --radius: 0.875rem; +} + +[data-theme="dark"] { + --color-bg-base-raw: #0e1422; + --color-bg-mesh-1-raw: #1a2440; + --color-bg-mesh-2-raw: #2a1f30; + --color-bg-mesh-3-raw: #14283a; + + --glass-bg: rgba(20, 28, 48, 0.55); + --glass-bg-strong: rgba(20, 28, 48, 0.78); + --glass-border: rgba(255, 255, 255, 0.1); + --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); + + --color-text-raw: #edf1f9; + --color-text-soft-raw: #b6c0d6; + --color-muted-raw: #7c8ba8; + + --color-accent-soft-raw: rgba(122, 169, 255, 0.22); } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --color-bg-base: var(--color-bg-base-raw); + --color-bg-mesh-1: var(--color-bg-mesh-1-raw); + --color-bg-mesh-2: var(--color-bg-mesh-2-raw); + --color-bg-mesh-3: var(--color-bg-mesh-3-raw); + --color-text: var(--color-text-raw); + --color-text-soft: var(--color-text-soft-raw); + --color-muted: var(--color-muted-raw); + --color-accent: var(--color-accent-raw); + --color-accent-fg: var(--color-accent-fg-raw); + --color-accent-soft: var(--color-accent-soft-raw); + --color-success: var(--color-success-raw); + --color-warning: var(--color-warning-raw); + --color-danger: var(--color-danger-raw); + + --color-background: var(--color-bg-base-raw); + --color-foreground: var(--color-text-raw); + --color-card: var(--glass-bg); + --color-card-foreground: var(--color-text-raw); + --color-popover: var(--glass-bg-strong); + --color-popover-foreground: var(--color-text-raw); + --color-primary: var(--color-accent-raw); + --color-primary-foreground: var(--color-accent-fg-raw); + --color-secondary: var(--glass-bg); + --color-secondary-foreground: var(--color-text-raw); + --color-muted-foreground: var(--color-muted-raw); + --color-destructive: var(--color-danger-raw); + --color-destructive-foreground: #ffffff; + --color-border: var(--glass-border); + --color-input: var(--glass-border); + --color-ring: var(--color-accent-raw); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --font-sans: + var(--font-outfit), -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + --font-display: + var(--font-instrument-serif), "Fraunces", Georgia, serif; + --font-mono: + "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +:root { + color-scheme: light; +} +[data-theme="dark"] { + color-scheme: dark; +} + +html { + scroll-behavior: smooth; + scroll-padding-top: 88px; } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + position: relative; + min-height: 100vh; + background: var(--color-bg-base-raw); + color: var(--color-text-raw); + font-family: var(--font-sans); + font-feature-settings: "ss01", "cv11"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +::selection { + background: var(--color-accent-raw); + color: var(--color-accent-fg-raw); +} + + +.glass { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + box-shadow: var(--glass-shadow); + border-radius: var(--r-lg); +} + +.glass-strong { + background: var(--glass-bg-strong); + border: 1px solid var(--glass-border); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + box-shadow: var(--glass-shadow); + border-radius: var(--r-lg); +} + +.glass-tinted { + background: var(--color-accent-soft); + border: 1px solid var(--glass-border); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + box-shadow: var(--glass-shadow); + border-radius: var(--r-lg); +} + +/* Focus ring — non-supprimable (a11y WCAG AA) */ +:where(a, button, input, textarea, select, [tabindex]):focus-visible { + outline: 2px solid var(--color-accent-raw); + outline-offset: 2px; + border-radius: var(--r-sm); +} + +@keyframes mesh-drift { + 0%, + 100% { + transform: translate3d(0, 0, 0) scale(1); + } + 33% { + transform: translate3d(3%, -2%, 0) scale(1.06); + } + 66% { + transform: translate3d(-2%, 3%, 0) scale(0.96); + } +} + +.mesh-spot { + will-change: transform; +} + +.font-display { + font-family: var(--font-display); + font-weight: 400; + letter-spacing: -0.01em; + line-height: 1.02; +} + +.font-mono { + font-family: var(--font-mono); +} + +@media (prefers-reduced-motion: reduce) { + .mesh-spot { + animation: none !important; + } + html { + scroll-behavior: auto; + } +} + +@media (prefers-contrast: more) { + .glass, + .glass-strong, + .glass-tinted { + background: var(--color-bg-base-raw); + backdrop-filter: none; + -webkit-backdrop-filter: none; + border-color: var(--color-text-raw); + box-shadow: none; + } } diff --git a/apps/frontend/app/layout.tsx b/apps/frontend/app/layout.tsx index 976eb90..dbd23e1 100644 --- a/apps/frontend/app/layout.tsx +++ b/apps/frontend/app/layout.tsx @@ -1,33 +1,136 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import type { Metadata, Viewport } from "next"; +import { Outfit, Instrument_Serif } from "next/font/google"; +import { NextIntlClientProvider } from "next-intl"; +import { getLocale, getMessages } from "next-intl/server"; + +import { getMe } from "@/app/actions/auth"; +import { getInstanceBranding } from "@/app/actions/instance"; +import { AuthProvider } from "@/components/auth-provider"; +import { BrandingProvider } from "@/components/branding-provider"; +import { MeshBackground } from "@/components/ui/mesh-background"; +import { SITE_URL } from "@/lib/site"; + import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const outfit = Outfit({ + variable: "--font-outfit", subsets: ["latin"], + weight: ["300", "400", "500", "600", "700", "800", "900"], + display: "swap", }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const instrumentSerif = Instrument_Serif({ + variable: "--font-instrument-serif", subsets: ["latin"], + weight: ["400"], + style: ["normal", "italic"], + display: "swap", }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + metadataBase: new URL(SITE_URL), + title: { + default: "Codestar — Open-source e-learning, hosted by you", + template: "%s · Codestar", + }, + description: + "Codestar is an open-source e-learning platform under GPL v3. Self-host your instance via Docker. Your data never leaves your server.", + applicationName: "Codestar", + keywords: [ + "open-source e-learning", + "self-hosted LMS", + "GPL v3 learning platform", + "Moodle alternative", + "educational data sovereignty", + "Docker LMS", + "block course editor", + "learner leaderboard", + "Codestar", + ], + authors: [ + { name: "Codestar Project", url: "https://github.com/CodeStar-Project" }, + ], + creator: "Codestar Project", + publisher: "Codestar Project", + category: "education", + alternates: { canonical: SITE_URL }, + openGraph: { + type: "website", + url: SITE_URL, + siteName: "Codestar", + title: "Codestar — Sovereign e-learning, on your servers", + description: + "Open-source e-learning platform, self-hosted via Docker, under GPL v3. Your data stays with you.", + }, + twitter: { + card: "summary_large_image", + title: "Codestar — Sovereign e-learning", + description: + "Open-source e-learning platform, hosted by you, under GPL v3.", + }, + robots: { + index: true, + follow: true, + googleBot: { index: true, follow: true, "max-image-preview": "large" }, + }, + icons: { icon: "/favicon.ico" }, +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f4f6fb" }, + { media: "(prefers-color-scheme: dark)", color: "#0e1422" }, + ], + width: "device-width", + initialScale: 1, + colorScheme: "light dark", }; -export default function RootLayout({ +const jsonLd = { + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: "Codestar", + url: SITE_URL, + description: "Open-source self-hosted e-learning platform under GPL v3.", + applicationCategory: "EducationalApplication", + operatingSystem: "Linux, macOS, Windows (WSL2)", + offers: { "@type": "Offer", price: "0", priceCurrency: "EUR" }, + license: "https://www.gnu.org/licenses/gpl-3.0.html", + codeRepository: "https://github.com/CodeStar-Project", +}; + +export default async function RootLayout({ children, -}: Readonly<{ - children: React.ReactNode; -}>) { +}: Readonly<{ children: React.ReactNode }>) { + const [locale, messages, branding, me] = await Promise.all([ + getLocale(), + getMessages(), + getInstanceBranding(), + getMe(), + ]); + return ( - {children} + +