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 extends GrantedAuthority> 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}
+
+
+
+
+
+ {children}
+
+
+
);
}
diff --git a/apps/frontend/app/login/login-shell.tsx b/apps/frontend/app/login/login-shell.tsx
new file mode 100644
index 0000000..bab29c8
--- /dev/null
+++ b/apps/frontend/app/login/login-shell.tsx
@@ -0,0 +1,529 @@
+"use client";
+
+/**
+ * Shell client de la page /login.
+ * Layout split 50/50 desktop, stack 100% mobile.
+ * Tabs glass pour basculer signin / signup / join.
+ */
+
+import * as React from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import {
+ joinGroupAction,
+ signInAction,
+ signUpAction,
+} from "@/app/actions/auth";
+import { BrandMark } from "@/components/brand-mark";
+import { useInstanceBranding } from "@/components/branding-provider";
+import { GlassButton } from "@/components/ui/glass-button";
+import { GlassChip } from "@/components/ui/glass-chip";
+import { GlassField, GlassInput } from "@/components/ui/glass-input";
+import { ArrowRightIcon, KeyIcon, StarIcon } from "@/components/ui/icons";
+
+type LoginMode = "signin" | "signup" | "join";
+
+interface LoginShellProps {
+ initialMode: LoginMode;
+ initialCode?: string;
+ sessionExpired?: boolean;
+ nextPath?: string;
+}
+
+export function LoginShell({
+ initialMode,
+ initialCode,
+ sessionExpired = false,
+ nextPath = "/",
+}: LoginShellProps) {
+ const t = useTranslations("login");
+ const tSwitch = useTranslations("login.switch");
+ const tNav = useTranslations("nav");
+ const branding = useInstanceBranding();
+ const [mode, setMode] = React.useState(initialMode);
+
+ const headline =
+ mode === "signin" ? (
+ <>{t("headlines.signin")}>
+ ) : mode === "signup" ? (
+ <>
+ {t("headlines.signupPrefix")} {t("headlines.signupSuffix")}
+ >
+ ) : (
+ <>
+ {t("headlines.joinPrefix")}{" "}
+
+ {t("headlines.joinHighlight")}
+
+ {t("headlines.joinSuffix")}
+ >
+ );
+
+ const tagline = t(
+ mode === "signin"
+ ? "taglines.signin"
+ : mode === "signup"
+ ? "taglines.signup"
+ : "taglines.join"
+ );
+
+ return (
+
+
+
+
+
+
+ {branding.name}
+
+
+
+
+
+
+ {headline}
+
+
{tagline}
+
+ {sessionExpired && (
+
+ {t("expiredBanner")}
+
+ )}
+
+
+
+
+ {mode === "signin" ? (
+ <>
+ {tSwitch("noAccount")}{" "}
+
+ >
+ ) : mode === "signup" ? (
+ <>
+ {tSwitch("alreadyRegistered")}{" "}
+
+ >
+ ) : (
+ <>
+ {tSwitch("noCode")}{" "}
+
+ >
+ )}
+
+
+
+
+
+
+ );
+}
+
+/* ============================================================
+ Mode tabs
+ ============================================================ */
+
+function ModeTabs({
+ mode,
+ onChange,
+}: {
+ mode: LoginMode;
+ onChange: (m: LoginMode) => void;
+}) {
+ const t = useTranslations("login.modes");
+ const tAria = useTranslations("login");
+ const modes: LoginMode[] = ["signin", "signup", "join"];
+ return (
+
+ {modes.map((m) => {
+ const active = m === mode;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+/* ============================================================
+ Form
+ ============================================================ */
+
+interface FormState {
+ email: string;
+ password: string;
+ code: string;
+}
+
+interface FormErrors {
+ email?: string;
+ password?: string;
+ code?: string;
+ submit?: string;
+}
+
+const CODE_RE = /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
+
+function LoginForm({
+ mode,
+ initialCode,
+ nextPath,
+}: {
+ mode: LoginMode;
+ initialCode?: string;
+ nextPath: string;
+}) {
+ const tFields = useTranslations("login.fields");
+ const tErrors = useTranslations("login.errors");
+ const tCtas = useTranslations("login.ctas");
+ const tConsent = useTranslations("login.consent");
+ const router = useRouter();
+
+ const [state, setState] = React.useState({
+ email: "",
+ password: "",
+ code: initialCode ?? "",
+ });
+ const [errors, setErrors] = React.useState({});
+ const [submitting, setSubmitting] = React.useState(false);
+
+ const requireCode = mode === "join";
+ const showCode = mode === "join" || mode === "signup";
+
+ const validate = (s: FormState): FormErrors => {
+ const e: FormErrors = {};
+ if (!s.email) e.email = tErrors("emailRequired");
+ else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.email))
+ e.email = tErrors("emailInvalid");
+
+ if (!s.password) e.password = tErrors("passwordRequired");
+ else if (mode === "signup" && s.password.length < 8)
+ e.password = tErrors("passwordTooShort");
+
+ const trimmedCode = s.code.trim();
+ if (requireCode && !trimmedCode) {
+ e.code = tErrors("codeRequired");
+ } else if (trimmedCode && !CODE_RE.test(trimmedCode.toUpperCase())) {
+ e.code = tErrors("codeInvalid");
+ }
+ return e;
+ };
+
+ const onSubmit = async (event: React.SyntheticEvent) => {
+ event.preventDefault();
+ const v = validate(state);
+ setErrors(v);
+ if (Object.keys(v).length > 0) return;
+
+ setSubmitting(true);
+ setErrors({});
+
+ try {
+ let result: { ok: boolean; error?: string; expired?: boolean };
+
+ if (mode === "signin") {
+ result = await signInAction({
+ email: state.email,
+ password: state.password,
+ });
+ } else if (mode === "signup") {
+ const trimmedCode = state.code.trim().toUpperCase();
+ result = await signUpAction({
+ email: state.email,
+ password: state.password,
+ // TODO
+ // V1 — pas de champ displayName dans le form. Fallback : partie locale
+ // de l'email. À remplacer par un input dédié ou une étape post-signup.
+ displayName: state.email.split("@")[0] || state.email,
+ invitationCode: trimmedCode || undefined,
+ });
+ } else {
+ // join : signin existant puis join du groupe via le code.
+ result = await signInAction({
+ email: state.email,
+ password: state.password,
+ });
+ if (result.ok) {
+ result = await joinGroupAction({ code: state.code.toUpperCase() });
+ }
+ }
+
+ if (result.ok) {
+ router.push(nextPath);
+ // Force le re-fetch du RootLayout (auth + branding) pour que la nav
+ // bascule immédiatement en état authentifié.
+ router.refresh();
+ } else if (result.expired) {
+ window.location.href = "/login?expired=1";
+ } else {
+ setErrors({ submit: result.error ?? tErrors("unknown") });
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+/* ============================================================
+ Decorative right panel
+ ============================================================ */
+
+function DecorativeAside() {
+ const branding = useInstanceBranding();
+ const t = useTranslations("login.decorative");
+
+ return (
+
+ );
+}
diff --git a/apps/frontend/app/login/page.tsx b/apps/frontend/app/login/page.tsx
new file mode 100644
index 0000000..eb57c65
--- /dev/null
+++ b/apps/frontend/app/login/page.tsx
@@ -0,0 +1,57 @@
+/**
+ * /login — Page d'authentification (Visitor).
+ * Spec : /hand-off.md §6 Écran 2.
+ *
+ * 3 modes via tabs :
+ * - signin : email + password
+ * - signup : email + password (+ code optionnel)
+ * - join : email + password + code obligatoire (XXXX-XXXX-XXXX)
+ *
+ * Backend non-câblé en v1 (cf. tâche 1.3 / 1.11).
+ * OAuth Google/GitHub : reporté en v3 (§8 roadmap).
+ */
+
+import type { Metadata } from "next";
+import { getTranslations } from "next-intl/server";
+
+import { sanitizeNextPath } from "@/lib/safe-redirect";
+
+import { LoginShell } from "./login-shell";
+
+const VALID_MODES = ["signin", "signup", "join"] as const;
+type LoginMode = (typeof VALID_MODES)[number];
+
+export async function generateMetadata(): Promise {
+ const t = await getTranslations("login");
+ return {
+ title: t("metaTitle"),
+ description: t("metaDescription"),
+ };
+}
+
+interface LoginPageProps {
+ searchParams: Promise<{
+ mode?: string;
+ code?: string;
+ expired?: string;
+ next?: string;
+ }>;
+}
+
+export default async function LoginPage({ searchParams }: LoginPageProps) {
+ const params = await searchParams;
+ const initialMode: LoginMode = VALID_MODES.includes(
+ params.mode as LoginMode
+ )
+ ? (params.mode as LoginMode)
+ : "signin";
+
+ return (
+
+ );
+}
diff --git a/apps/frontend/app/not-found.tsx b/apps/frontend/app/not-found.tsx
new file mode 100644
index 0000000..f180d4d
--- /dev/null
+++ b/apps/frontend/app/not-found.tsx
@@ -0,0 +1,28 @@
+import Link from "next/link";
+import { getTranslations } from "next-intl/server";
+
+import { GlassButton } from "@/components/ui/glass-button";
+import { GlassCard } from "@/components/ui/glass-card";
+
+export default async function NotFound() {
+ const t = await getTranslations("notFound");
+
+ return (
+
+
+
+ 404
+
+
+ {t("title")}
+
+ {t("description")}
+
+
+ {t("home")}
+
+
+
+
+ );
+}
diff --git a/apps/frontend/app/page.tsx b/apps/frontend/app/page.tsx
index 3f36f7c..3d39b2c 100644
--- a/apps/frontend/app/page.tsx
+++ b/apps/frontend/app/page.tsx
@@ -1,65 +1,312 @@
-import Image from "next/image";
+import Link from "next/link";
+import { getTranslations } from "next-intl/server";
+
+import { getInstanceBranding } from "@/app/actions/instance";
+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";
+import { GlassChip } from "@/components/ui/glass-chip";
+import {
+ ArrowRightIcon,
+ BookIcon,
+ SparklesIcon,
+ StarIcon,
+ TrophyIcon,
+} from "@/components/ui/icons";
+import { getCourses } from "@/app/actions/courses";
+import type { Course } from "@/lib/types";
+
+export default async function HomePage() {
+ const [tInstance, branding, courses] = await Promise.all([
+ getTranslations("instance"),
+ getInstanceBranding(),
+ getCourses(),
+ ]);
+
+ const heroTitle = branding.heroTitle ?? tInstance("heroTitle");
+ const heroSubtitle = branding.heroSubtitle ?? tInstance("heroSubtitle");
+ const heroCta = branding.heroCta ?? tInstance("heroCta");
-export default function Home() {
return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+async function Hero({
+ title,
+ subtitle,
+ cta,
+}: {
+ title: string;
+ subtitle: string;
+ cta: string;
+}) {
+ const t = await getTranslations("home.hero");
+
+ return (
+
+
+
+
+
+
+ {t("chip")}
+
+
+
+ {title}
+
+
+
+ {subtitle}
+
+
+
+
+
+
+ {cta}
+
+
+
+
+
+ {t("ctaSecondary")}
+
+ {t("comingSoon")}
+
+
+
+
+
+
+
- {t("stats.courses")}
+ - 12+
+ {t("stats.courses")}
+
+ ·
+
+
- {t("stats.learners")}
+ -
+ {t("stats.learnersValue", { count: 2400 })}
+
+ {t("stats.learners")}
+
+ ·
+
+
-
+ {t("stats.ratingLabel", { value: "★ 4,8" })}
+
+ - ★ 4,8
+ {t("stats.ratingLabel", { value: "" }).trim()}
+
+
+
+
+
-
+
+ );
+}
+
+async function HeroVisual() {
+ const [t, branding] = await Promise.all([
+ getTranslations("home.hero"),
+ getInstanceBranding(),
+ ]);
+ return (
+
+
+
+
+
+
+ {t("previewLabel")}
+
+
+ {branding.name}
+
+ {branding.tagline ? (
+
+ {branding.tagline}
+
+ ) : null}
+
+
-
+
);
}
+
+async function FeaturedCourses({ courses }: { courses: Course[] }) {
+ if (courses.length === 0) return null;
+ const t = await getTranslations("home.featured");
+
+ return (
+
+
+
+
+ {t("title")}
+
+
+ {t("subtitle")}
+
+
+
+
+ {courses.map((course) => (
+
+ ))}
+
+
+
+ );
+}
+
+function FeaturedCard({
+ course,
+ linkAria,
+}: {
+ course: Course;
+ linkAria: string;
+}) {
+ return (
+
+
+
+
+
+ {course.title}
+
+
+ {course.description}
+
+
+
+
+
+ );
+}
+
+async function Pillars() {
+ const t = await getTranslations("home.pillars");
+
+ const pillars = [
+ {
+ Icon: BookIcon,
+ title: t("reader.title"),
+ description: t("reader.description"),
+ },
+ {
+ Icon: TrophyIcon,
+ title: t("progress.title"),
+ description: t("progress.description"),
+ },
+ {
+ Icon: SparklesIcon,
+ title: t("authors.title"),
+ description: t("authors.description"),
+ },
+ ] as const;
+
+ return (
+
+
+
+ {pillars.map(({ Icon, title, description }) => (
+ -
+
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ ))}
+
+
+
+ );
+}
+
+async function FinalCta() {
+ const t = await getTranslations("home.finalCta");
+
+ return (
+
+
+
+
+ {t("titlePart1")}{" "}
+
+ {t("titlePart2")}
+
+ {t("titlePart3")}
+
+
+ {t("subtitle")}
+
+
+
+
+ {t("primary")}
+
+
+
+
+ {t("secondary")}
+
+
+
+
+
+ );
+}
diff --git a/apps/frontend/app/robots.ts b/apps/frontend/app/robots.ts
new file mode 100644
index 0000000..69ce6fb
--- /dev/null
+++ b/apps/frontend/app/robots.ts
@@ -0,0 +1,17 @@
+import type { MetadataRoute } from "next";
+
+import { SITE_URL } from "@/lib/site";
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: [
+ {
+ userAgent: "*",
+ allow: "/",
+ disallow: ["/login", "/courses/", "/api/"],
+ },
+ ],
+ sitemap: `${SITE_URL}/sitemap.xml`,
+ host: SITE_URL,
+ };
+}
diff --git a/apps/frontend/app/sitemap.ts b/apps/frontend/app/sitemap.ts
new file mode 100644
index 0000000..328dd74
--- /dev/null
+++ b/apps/frontend/app/sitemap.ts
@@ -0,0 +1,15 @@
+import type { MetadataRoute } from "next";
+
+import { SITE_URL } from "@/lib/site";
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ const now = new Date();
+ return [
+ {
+ url: `${SITE_URL}/`,
+ lastModified: now,
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ ];
+}
diff --git a/apps/frontend/components.json b/apps/frontend/components.json
new file mode 100644
index 0000000..4ee62ee
--- /dev/null
+++ b/apps/frontend/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/apps/frontend/components/auth-provider.tsx b/apps/frontend/components/auth-provider.tsx
new file mode 100644
index 0000000..aff44a9
--- /dev/null
+++ b/apps/frontend/components/auth-provider.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+/**
+ * Contexte d'authentification côté client.
+ * Initial state injecté par le RootLayout (server) via `getMe()`.
+ * Pour rafraîchir : appeler `router.refresh()` après une mutation auth
+ * (les server actions le font déjà via `revalidatePath`).
+ */
+
+import * as React from "react";
+import { useRouter } from "next/navigation";
+
+import { signOutAction } from "@/app/actions/auth";
+import type { MeResponse } from "@/lib/types";
+
+interface AuthContextValue {
+ user: MeResponse | null;
+ isAuthenticated: boolean;
+ signOut: () => Promise
;
+}
+
+const AuthContext = React.createContext(null);
+
+interface AuthProviderProps {
+ initialUser: MeResponse | null;
+ children: React.ReactNode;
+}
+
+export function AuthProvider({ initialUser, children }: AuthProviderProps) {
+ const router = useRouter();
+ const [pending, startTransition] = React.useTransition();
+
+ const value = React.useMemo(
+ () => ({
+ user: initialUser,
+ isAuthenticated: initialUser !== null,
+ signOut: async () => {
+ await signOutAction();
+ startTransition(() => router.refresh());
+ },
+ }),
+ [initialUser, router]
+ );
+
+ // pending non exposé pour l'instant — disponible si besoin futur.
+ void pending;
+
+ return {children};
+}
+
+export function useAuth(): AuthContextValue {
+ const ctx = React.useContext(AuthContext);
+ if (!ctx) throw new Error("useAuth must be used within ");
+ return ctx;
+}
diff --git a/apps/frontend/components/brand-mark.tsx b/apps/frontend/components/brand-mark.tsx
new file mode 100644
index 0000000..74ac153
--- /dev/null
+++ b/apps/frontend/components/brand-mark.tsx
@@ -0,0 +1,80 @@
+/**
+ * Logo / mark de l'instance — étoile par défaut.
+ * Le preset et l'accent peuvent être surchargés via `instance.json`.
+ */
+
+import * as React from "react";
+
+import { DEFAULT_INSTANCE } from "@/lib/instance";
+
+export type LogoPreset = "star" | "alpha" | "flame" | "hash";
+
+interface BrandMarkProps {
+ size?: number;
+ accent?: string;
+ preset?: LogoPreset;
+ className?: string;
+}
+
+const PRESET_GLYPH: Record = {
+ star: null, // SVG dédié
+ alpha: "α",
+ flame: "✦",
+ hash: "#",
+};
+
+export function BrandMark({
+ size = 28,
+ accent = DEFAULT_INSTANCE.accent,
+ preset = DEFAULT_INSTANCE.logo.value as LogoPreset,
+ className,
+}: BrandMarkProps) {
+ const glyph = PRESET_GLYPH[preset];
+ const gradientID = "mark-" + String(accent).replace(/[^a-zA-Z0-9_-]/g, "")
+
+ if (glyph) {
+ return (
+
+ {glyph}
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/frontend/components/branding-provider.tsx b/apps/frontend/components/branding-provider.tsx
new file mode 100644
index 0000000..4710322
--- /dev/null
+++ b/apps/frontend/components/branding-provider.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+/**
+ * Expose le branding de l'instance aux client components.
+ * Initial value injectée par le RootLayout (server) via `getInstanceBranding()`.
+ */
+
+import * as React from "react";
+
+import type { InstanceBranding } from "@/lib/types";
+
+const BrandingContext = React.createContext(null);
+
+export function BrandingProvider({
+ branding,
+ children,
+}: {
+ branding: InstanceBranding;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useInstanceBranding(): InstanceBranding {
+ const ctx = React.useContext(BrandingContext);
+ if (!ctx)
+ throw new Error(
+ "useInstanceBranding must be used within "
+ );
+ return ctx;
+}
diff --git a/apps/frontend/components/locale-switcher.tsx b/apps/frontend/components/locale-switcher.tsx
new file mode 100644
index 0000000..a9daba7
--- /dev/null
+++ b/apps/frontend/components/locale-switcher.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+/**
+ * Mini sélecteur de langue, posé dans le footer.
+ * Persiste via cookie `NEXT_LOCALE` (server action `setLocaleAction`).
+ */
+
+import * as React from "react";
+import { useLocale, useTranslations } from "next-intl";
+
+import { setLocaleAction } from "@/app/actions/locale";
+import { LOCALES, type Locale } from "@/i18n/routing";
+
+export function LocaleSwitcher() {
+ const t = useTranslations("localeSwitcher");
+ const current = useLocale() as Locale;
+ const [pending, startTransition] = React.useTransition();
+
+ const onChange = (next: Locale) => {
+ if (next === current) return;
+ startTransition(() => {
+ setLocaleAction(next);
+ });
+ };
+
+ return (
+
+ {LOCALES.map((loc) => {
+ const active = loc === current;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/frontend/components/site-footer.tsx b/apps/frontend/components/site-footer.tsx
new file mode 100644
index 0000000..92ebc39
--- /dev/null
+++ b/apps/frontend/components/site-footer.tsx
@@ -0,0 +1,73 @@
+/**
+ * Footer figé — mention "Built on Codestar" non retirable (licence GPLv3).
+ * Spec : hand-off §2 + §6 Écran 1.
+ */
+
+import { getTranslations } from "next-intl/server";
+
+import { getInstanceBranding } from "@/app/actions/instance";
+import { BrandMark } from "@/components/brand-mark";
+import { LocaleSwitcher } from "@/components/locale-switcher";
+
+const CODESTAR_REPO = "https://github.com/CodeStar-Project";
+
+export async function SiteFooter() {
+ const [t, branding] = await Promise.all([
+ getTranslations("footer"),
+ getInstanceBranding(),
+ ]);
+ const year = new Date().getFullYear();
+
+ return (
+
+ );
+}
diff --git a/apps/frontend/components/top-nav.tsx b/apps/frontend/components/top-nav.tsx
new file mode 100644
index 0000000..6bec12a
--- /dev/null
+++ b/apps/frontend/components/top-nav.tsx
@@ -0,0 +1,97 @@
+/**
+ * TopNav publique — adaptative selon l'état d'authentification.
+ * - Visiteur : CTA "Rejoindre avec un code" + "Se connecter"
+ * - Authentifié : avatar + UserMenu (rôle, groupes, déconnexion)
+ *
+ * Liens contextuels supplémentaires affichés selon le rôle (admin, teacher).
+ */
+
+import Link from "next/link";
+import { getTranslations } from "next-intl/server";
+
+import { getMe } from "@/app/actions/auth";
+import { getInstanceBranding } from "@/app/actions/instance";
+import { BrandMark, type LogoPreset } from "@/components/brand-mark";
+import { UserMenu } from "@/components/user-menu";
+import { GlassButton } from "@/components/ui/glass-button";
+import { GlassNav, GlassNavInner } from "@/components/ui/glass-nav";
+import { KeyIcon } from "@/components/ui/icons";
+import { isAdmin, isStaff } from "@/lib/roles";
+
+export async function TopNav() {
+ const [t, branding, me] = await Promise.all([
+ getTranslations("nav"),
+ getInstanceBranding(),
+ getMe(),
+ ]);
+
+ const preset = branding.logo.value as LogoPreset;
+
+ return (
+
+
+
+
+ {branding.name}
+
+
+ {me && (
+
+ )}
+
+
+
+ {me ? (
+
+ ) : (
+
+
+
+
+ {t("join")}
+
+
+
+ {t("signin")}
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/frontend/components/ui/glass-button.tsx b/apps/frontend/components/ui/glass-button.tsx
new file mode 100644
index 0000000..eb06e05
--- /dev/null
+++ b/apps/frontend/components/ui/glass-button.tsx
@@ -0,0 +1,128 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const glassButtonVariants = cva(
+ [
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full font-medium select-none",
+ "transition-[transform,box-shadow,background-color,color] duration-200 ease-out",
+ "active:scale-[0.97]",
+ "disabled:pointer-events-none disabled:opacity-50",
+ ].join(" "),
+ {
+ variants: {
+ variant: {
+ primary: [
+ "bg-[color:var(--color-accent)] text-[color:var(--color-accent-fg)]",
+ "border border-[color:var(--color-accent)]",
+ "shadow-[0_8px_28px_-6px_color-mix(in_oklab,var(--color-accent)_55%,transparent)]",
+ "hover:shadow-[0_14px_36px_-8px_color-mix(in_oklab,var(--color-accent)_60%,transparent)]",
+ "hover:-translate-y-0.5",
+ ].join(" "),
+ glass: [
+ "glass text-text",
+ "hover:bg-[color:var(--glass-bg-strong)]",
+ "hover:-translate-y-0.5",
+ ].join(" "),
+ outline: [
+ "bg-transparent text-text",
+ "border border-[color:var(--glass-border)]",
+ "hover:bg-[color:var(--glass-bg)] hover:backdrop-blur-md",
+ ].join(" "),
+ ghost: [
+ "bg-transparent text-text-soft",
+ "hover:text-text hover:bg-[color:var(--glass-bg)]",
+ ].join(" "),
+ danger: [
+ "bg-[color:var(--color-danger)] text-white",
+ "border border-[color:var(--color-danger)]",
+ "hover:-translate-y-0.5",
+ ].join(" "),
+ },
+ size: {
+ sm: "h-9 px-4 text-[0.85rem]",
+ md: "h-11 px-5 text-[0.92rem]",
+ lg: "h-14 px-7 text-[1rem]",
+ icon: "h-11 w-11 p-0",
+ },
+ },
+ defaultVariants: {
+ variant: "primary",
+ size: "md",
+ },
+ }
+);
+
+export interface GlassButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+ loading?: boolean;
+}
+
+const Spinner = () => (
+
+);
+
+const GlassButton = React.forwardRef(
+ (
+ {
+ className,
+ variant,
+ size,
+ asChild = false,
+ loading = false,
+ disabled,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ {loading ? (
+ <>
+
+ {children}
+ >
+ ) : (
+ children
+ )}
+
+ );
+ }
+);
+GlassButton.displayName = "GlassButton";
+
+export { GlassButton, glassButtonVariants };
diff --git a/apps/frontend/components/ui/glass-card.tsx b/apps/frontend/components/ui/glass-card.tsx
new file mode 100644
index 0000000..848885d
--- /dev/null
+++ b/apps/frontend/components/ui/glass-card.tsx
@@ -0,0 +1,130 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const glassCardVariants = cva(
+ "relative rounded-[var(--r-lg)] border text-text transition-shadow",
+ {
+ variants: {
+ variant: {
+ default: "glass",
+ strong: "glass-strong",
+ tinted: "glass-tinted",
+ plain:
+ "border-[color:var(--glass-border)] bg-[color:var(--glass-bg-strong)]",
+ },
+ interactive: {
+ true: "cursor-pointer hover:shadow-[0_12px_44px_rgba(31,38,135,0.14)] hover:-translate-y-0.5 transition-transform",
+ false: "",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ interactive: false,
+ },
+ }
+);
+
+export interface GlassCardProps
+ extends React.HTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const GlassCard = React.forwardRef(
+ ({ className, variant, interactive, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "div";
+ return (
+
+ );
+ }
+);
+GlassCard.displayName = "GlassCard";
+
+const GlassCardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+GlassCardHeader.displayName = "GlassCardHeader";
+
+const GlassCardKicker = React.forwardRef<
+ HTMLSpanElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+GlassCardKicker.displayName = "GlassCardKicker";
+
+const GlassCardTitle = React.forwardRef<
+ HTMLHeadingElement,
+ React.HTMLAttributes & { as?: "h2" | "h3" | "h4" }
+>(({ className, as: As = "h3", ...props }, ref) => (
+
+));
+GlassCardTitle.displayName = "GlassCardTitle";
+
+const GlassCardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+GlassCardDescription.displayName = "GlassCardDescription";
+
+const GlassCardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+GlassCardContent.displayName = "GlassCardContent";
+
+const GlassCardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+GlassCardFooter.displayName = "GlassCardFooter";
+
+export {
+ GlassCard,
+ GlassCardHeader,
+ GlassCardKicker,
+ GlassCardTitle,
+ GlassCardDescription,
+ GlassCardContent,
+ GlassCardFooter,
+ glassCardVariants,
+};
diff --git a/apps/frontend/components/ui/glass-chip.tsx b/apps/frontend/components/ui/glass-chip.tsx
new file mode 100644
index 0000000..ca7de4b
--- /dev/null
+++ b/apps/frontend/components/ui/glass-chip.tsx
@@ -0,0 +1,88 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const glassChipVariants = cva(
+ [
+ "inline-flex items-center gap-1.5 rounded-full font-medium",
+ "border border-[color:var(--glass-border)] backdrop-blur-md",
+ "transition-colors",
+ ].join(" "),
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-[color:var(--glass-bg)] text-text-soft hover:text-text",
+ accent:
+ "bg-[color:var(--color-accent-soft)] text-[color:var(--color-accent)] border-[color:color-mix(in_oklab,var(--color-accent)_25%,transparent)]",
+ success:
+ "bg-[color:color-mix(in_oklab,var(--color-success)_18%,transparent)] text-[color:color-mix(in_oklab,var(--color-success)_75%,#0d3a2e)] border-[color:color-mix(in_oklab,var(--color-success)_30%,transparent)]",
+ warning:
+ "bg-[color:color-mix(in_oklab,var(--color-warning)_18%,transparent)] text-[color:color-mix(in_oklab,var(--color-warning)_75%,#5a3300)] border-[color:color-mix(in_oklab,var(--color-warning)_30%,transparent)]",
+ danger:
+ "bg-[color:color-mix(in_oklab,var(--color-danger)_18%,transparent)] text-[color:color-mix(in_oklab,var(--color-danger)_75%,#4a0010)] border-[color:color-mix(in_oklab,var(--color-danger)_30%,transparent)]",
+ outline:
+ "bg-transparent text-text-soft border-[color:var(--glass-border)]",
+ },
+ size: {
+ sm: "px-2.5 py-0.5 text-[0.7rem]",
+ md: "px-3 py-1 text-[0.78rem]",
+ lg: "px-3.5 py-1.5 text-[0.85rem]",
+ },
+ interactive: {
+ true: "cursor-pointer hover:-translate-y-0.5 active:scale-[0.97] transition-transform",
+ false: "",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "md",
+ interactive: false,
+ },
+ }
+);
+
+export interface GlassChipProps
+ extends Omit, "size">,
+ VariantProps {
+ asButton?: boolean;
+}
+
+const GlassChip = React.forwardRef(
+ (
+ { className, variant, size, interactive, asButton = false, ...props },
+ ref
+ ) => {
+ if (asButton) {
+ return (
+