Implemented UserProfile.tsx, without editing

* added font awesome
* replaced GitHub logo in Footer.tsx with one provided by FontAwesome
* added loader, when userStore fetching data from server
* allow circular dependencies, since this is no problem
* fix default (e.g. prod) profile
* fix a problem, when no authenticated person calls /api/v1/user/current endpoint
This commit is contained in:
Maksim Skobaro 2024-10-20 16:52:24 +03:00
parent 212249becd
commit 181dc824a1
52 changed files with 843 additions and 339 deletions

View File

@ -88,6 +88,16 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.4</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@ -11,8 +11,6 @@ import org.springframework.context.ConfigurableApplicationContext;
@Slf4j @Slf4j
public class TdmsApplication { public class TdmsApplication {
public static void main(String[] args) { public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(TdmsApplication.class, args); SpringApplication.run(TdmsApplication.class, args);
String staticLocation = context.getEnvironment().getProperty("spring.web.resources.static-locations");
log.info("Static location: {}", staticLocation);
} }
} }

View File

@ -3,9 +3,14 @@ package ru.tubryansk.tdms.config;
import jakarta.servlet.http.HttpSessionEvent; import jakarta.servlet.http.HttpSessionEvent;
import jakarta.servlet.http.HttpSessionListener; import jakarta.servlet.http.HttpSessionListener;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.ProviderManager;
@ -26,24 +31,31 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import java.time.Duration;
import java.util.List;
import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY; import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY;
@Configuration @Configuration
public class SecurityConfiguration { public class SecurityConfiguration {
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, AuthenticationManager authenticationManager) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity,
AuthenticationManager authenticationManager,
@Qualifier("corsConfig") CorsConfigurationSource cors) throws Exception {
return httpSecurity return httpSecurity
.authorizeHttpRequests(this::configureHttpAuthorization) .authorizeHttpRequests(this::configureHttpAuthorization)
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(a -> a.configurationSource(corsConfiguration())) .cors(a -> a.configurationSource(cors))
.authenticationManager(authenticationManager) .authenticationManager(authenticationManager)
.sessionManagement(this::configureSessionManagement) .sessionManagement(this::configureSessionManagement)
.build(); .build();
} }
@Bean @Bean
public CorsConfigurationSource corsConfiguration() { @Profile("dev")
@Qualifier("corsConfig")
public CorsConfigurationSource corsConfigurationDev() {
return request -> { return request -> {
CorsConfiguration corsConfiguration = new CorsConfiguration(); CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.applyPermitDefaultValues(); corsConfiguration.applyPermitDefaultValues();
@ -54,15 +66,34 @@ public class SecurityConfiguration {
}; };
} }
@Bean
@Profile("!dev")
@Qualifier("corsConfig")
public CorsConfigurationSource corsConfigurationProd(@Value("${application.domain}") String domain,
@Value("${application.port}") String port,
@Value("${application.protocol}") String protocol) {
return request -> {
String url = StringUtils.join(protocol, "://", domain, ":", port);
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setMaxAge(Duration.ofHours(1));
corsConfiguration.addAllowedOrigin(url);
corsConfiguration.setAllowedMethods(List.of(HttpMethod.GET.name(), HttpMethod.POST.name()));
// corsConfiguration.setAllowedHeaders();
return corsConfiguration;
};
}
private void configureHttpAuthorization(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry httpAuthorization) { private void configureHttpAuthorization(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry httpAuthorization) {
/* API ROUTES */ /* API ROUTES */
httpAuthorization.requestMatchers("/api/v1/diploma-topic/**").permitAll(); httpAuthorization.requestMatchers("/api/v1/user/logout").authenticated();
httpAuthorization.requestMatchers("/api/v1/user/**").permitAll(); httpAuthorization.requestMatchers("/api/v1/user/login").anonymous();
httpAuthorization.requestMatchers("/api/v1/user/current").permitAll();
httpAuthorization.requestMatchers("/api/v1/student/current").permitAll();
httpAuthorization.requestMatchers("/api/**").denyAll(); httpAuthorization.requestMatchers("/api/**").denyAll();
/* STATIC ROUTES */ /* STATIC ROUTES */
httpAuthorization.requestMatchers("/**").permitAll(); httpAuthorization.requestMatchers("/**").permitAll();
/* OTHER */
httpAuthorization.anyRequest().denyAll();
} }
@Bean @Bean
@ -76,18 +107,21 @@ public class SecurityConfiguration {
return provider; return provider;
} }
private PasswordEncoder passwordEncoder() { @Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder(); return PasswordEncoderFactories.createDelegatingPasswordEncoder();
} }
@Bean @Bean
@Profile("dev")
public HttpSessionListener autoAuthenticateUnderAdmin(AuthenticationManager authenticationManager) { public HttpSessionListener autoAuthenticateUnderAdmin(AuthenticationManager authenticationManager) {
return new HttpSessionListener() { return new HttpSessionListener() {
@Override @Override
public void sessionCreated(HttpSessionEvent se) { public void sessionCreated(HttpSessionEvent se) {
LoggerFactory.getLogger(this.getClass()).info("Session created {}. Authenticated, as izrailev_v_ya_1", se.getSession().getId()); String username = "akulenko_mikhail";
LoggerFactory.getLogger(this.getClass()).info("Session created {}. Authenticated, as {}", se.getSession().getId(), username);
SecurityContext context = SecurityContextHolder.createEmptyContext(); SecurityContext context = SecurityContextHolder.createEmptyContext();
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken("izrailev_v_ya_1", "1"); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, "1");
Authentication authenticated = authenticationManager.authenticate(authentication); Authentication authenticated = authenticationManager.authenticate(authentication);
context.setAuthentication(authenticated); context.setAuthentication(authenticated);
SecurityContextHolder.setContext(context); SecurityContextHolder.setContext(context);

View File

@ -1,33 +1,13 @@
package ru.tubryansk.tdms.controller; package ru.tubryansk.tdms.controller;
import jakarta.validation.constraints.PositiveOrZero;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import ru.tubryansk.tdms.dto.DiplomaTopicDTO;
import ru.tubryansk.tdms.service.DiplomaTopicService;
import java.util.List;
@RestController @RestController
@RequestMapping("/api/v1/diploma-topic/") @RequestMapping("/api/v1/diploma-topic/")
@Validated @Validated
public class DiplomaTopicController { public class DiplomaTopicController {
@Autowired
private DiplomaTopicService diplomaTopicService;
@GetMapping("/get-all")
public List<DiplomaTopicDTO> getAll() {
return diplomaTopicService.getAll();
}
@GetMapping("/get-by-id/{id:[\\-+]?\\d+}")
public DiplomaTopicDTO getById(@PathVariable @PositiveOrZero Integer id) {
return diplomaTopicService.getById(id);
}
} }

View File

@ -0,0 +1,20 @@
package ru.tubryansk.tdms.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import ru.tubryansk.tdms.dto.StudentDTO;
import ru.tubryansk.tdms.service.StudentService;
@RestController
@RequestMapping("/api/v1/student/")
public class StudentController {
@Autowired
private StudentService studentService;
@GetMapping("/current")
public StudentDTO getCurrentStudent() {
return studentService.getCallerStudent().map(StudentDTO::from).orElse(null);
}
}

View File

@ -2,31 +2,32 @@ package ru.tubryansk.tdms.controller;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import ru.tubryansk.tdms.dto.UserDTO; import ru.tubryansk.tdms.dto.UserDTO;
import ru.tubryansk.tdms.entity.User; import ru.tubryansk.tdms.service.AuthenticationService;
import ru.tubryansk.tdms.service.UserService; import ru.tubryansk.tdms.service.UserService;
@RestController @RestController
@Validated
@RequestMapping("/api/v1/user") @RequestMapping("/api/v1/user")
@Slf4j @Slf4j
public class UserController { public class UserController {
@Autowired
private AuthenticationService authenticationService;
@Autowired @Autowired
private UserService userService; private UserService userService;
@GetMapping("/current") @GetMapping("/current")
public UserDTO getCurrentUser() { public UserDTO getCurrentUser() {
User principal = userService.getCallerPrincipal(); return userService.getCallerUser().map(user -> UserDTO.from(user, true)).orElse(UserDTO.unauthenticated());
return principal != null ? UserDTO.from(principal, true) : UserDTO.fromUnauthenticated();
} }
@PostMapping("/logout") @PostMapping("/logout")
public void logout() { public void logout() {
userService.logout(); authenticationService.logout();
}
@PostMapping("/login")
public void login(@RequestParam String username, @RequestParam String password) {
authenticationService.login(username, password);
} }
} }

View File

@ -1,16 +0,0 @@
package ru.tubryansk.tdms.dto;
import lombok.Builder;
import ru.tubryansk.tdms.entity.DiplomaTopic;
@Builder
public record DiplomaTopicDTO(Integer id, String name) {
public static DiplomaTopicDTO fromEntity(DiplomaTopic diplomaTopic) {
return DiplomaTopicDTO.builder()
.id(diplomaTopic.getId())
.name(diplomaTopic.getName())
.build();
}
}

View File

@ -0,0 +1,13 @@
package ru.tubryansk.tdms.dto;
import ru.tubryansk.tdms.entity.Group;
public record GroupDTO(String name, UserDTO principalUser) {
public static GroupDTO from(Group group) {
return new GroupDTO(
group.getName(),
UserDTO.from(group.getPrincipalUser(), true)
);
}
}

View File

@ -1,15 +1,19 @@
package ru.tubryansk.tdms.dto; package ru.tubryansk.tdms.dto;
import lombok.AllArgsConstructor; import ru.tubryansk.tdms.entity.Role;
import lombok.Data; import ru.tubryansk.tdms.entity.User;
import lombok.NoArgsConstructor;
import java.util.List;
@Data public record RoleDTO(String name, String authority) {
@AllArgsConstructor
@NoArgsConstructor public static RoleDTO from(Role role) {
public class RoleDTO { return new RoleDTO(role.getName(), role.getAuthority());
private Integer id; }
private String name;
public static List<RoleDTO> from(User user) {
return user.getRoles().stream().map(RoleDTO::from).toList();
}
} }

View File

@ -4,13 +4,13 @@ package ru.tubryansk.tdms.dto;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import ru.tubryansk.tdms.entity.Student;
@Data @Data
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
public class StudentDTO { public class StudentDTO {
private Integer id;
private Boolean form; private Boolean form;
private Integer protectionOrder; private Integer protectionOrder;
private String magistracy; private String magistracy;
@ -23,8 +23,29 @@ public class StudentDTO {
private String note; private String note;
private Boolean recordBookReturned; private Boolean recordBookReturned;
private String work; private String work;
private Integer userId; private UserDTO user;
private Integer diplomaTopicId; private String diplomaTopic;
private Integer mentorUserId; private UserDTO mentorUser;
private Integer groupId; private GroupDTO group;
public static StudentDTO from(Student student) {
return new StudentDTO(
student.getForm(),
student.getProtectionOrder(),
student.getMagistracy(),
student.getDigitalFormatPresent(),
student.getMarkComment(),
student.getMarkPractice(),
student.getPredefenceComment(),
student.getNormalControl(),
student.getAntiPlagiarism(),
student.getNote(),
student.getRecordBookReturned(),
student.getWork(),
UserDTO.from(student.getUser(), true),
student.getDiplomaTopic().getName(),
UserDTO.from(student.getMentorUser(), true),
GroupDTO.from(student.getGroup())
);
}
} }

View File

@ -1,8 +1,8 @@
package ru.tubryansk.tdms.dto; package ru.tubryansk.tdms.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder; import lombok.Builder;
import ru.tubryansk.tdms.entity.Role;
import ru.tubryansk.tdms.entity.User; import ru.tubryansk.tdms.entity.User;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
@ -10,18 +10,19 @@ import java.util.List;
@Builder @Builder
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record UserDTO( public record UserDTO(
boolean authenticated, boolean authenticated,
String login, String login,
String password, String password,
String fullName, String fullName,
String email, String email,
String phoneNumber, String phone,
ZonedDateTime createdAt, ZonedDateTime createdAt,
ZonedDateTime updatedAt, ZonedDateTime updatedAt,
List<String> authorities) { List<RoleDTO> authorities) {
public static UserDTO fromUnauthenticated() { public static UserDTO unauthenticated() {
return UserDTO.builder() return UserDTO.builder()
.authenticated(false) .authenticated(false)
.build(); .build();
@ -34,10 +35,10 @@ public record UserDTO(
.password(anonymize ? "" : user.getPassword()) .password(anonymize ? "" : user.getPassword())
.fullName(user.getFullName()) .fullName(user.getFullName())
.email(user.getMail()) .email(user.getMail())
.phoneNumber(user.getNumberPhone()) .phone(user.getNumberPhone())
.createdAt(user.getCreatedAt()) .createdAt(user.getCreatedAt())
.updatedAt(user.getUpdatedAt()) .updatedAt(user.getUpdatedAt())
.authorities(user.getRoles().stream().map(Role::getAuthority).toList()) .authorities(RoleDTO.from(user))
.build(); .build();
} }
} }

View File

@ -3,6 +3,8 @@ package ru.tubryansk.tdms.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import org.springframework.context.annotation.Scope;
import org.springframework.web.context.annotation.SessionScope;
@Data @Data
@ -49,5 +51,4 @@ public class Student {
@ManyToOne @ManyToOne
@JoinColumn(name = "group_id", nullable = false) @JoinColumn(name = "group_id", nullable = false)
private Group group; private Group group;
} }

View File

@ -3,7 +3,11 @@ package ru.tubryansk.tdms.repository;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import ru.tubryansk.tdms.entity.DiplomaTopic; import ru.tubryansk.tdms.entity.DiplomaTopic;
import ru.tubryansk.tdms.exception.NotFoundException;
@Repository @Repository
public interface DiplomaTopicRepository extends JpaRepository<DiplomaTopic, Integer> { public interface DiplomaTopicRepository extends JpaRepository<DiplomaTopic, Integer> {
default DiplomaTopic findByIdThrow(Integer id) {
return this.findById(id).orElseThrow(() -> new NotFoundException(DiplomaTopic.class, id));
}
} }

View File

@ -0,0 +1,18 @@
package ru.tubryansk.tdms.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.tubryansk.tdms.entity.Student;
import ru.tubryansk.tdms.entity.User;
import ru.tubryansk.tdms.exception.NotFoundException;
import java.util.Optional;
@Repository
public interface StudentRepository extends JpaRepository<Student, Integer> {
default Student findByIdThrow(Integer id) {
return this.findById(id).orElseThrow(() -> new NotFoundException(Student.class, id));
}
Optional<Student> findByUser(User user);
}

View File

@ -0,0 +1,40 @@
package ru.tubryansk.tdms.service;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import ru.tubryansk.tdms.entity.User;
import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY;
@Service
public class AuthenticationService {
@Autowired
private HttpServletRequest request;
@Autowired
private AuthenticationManager authenticationManager;
public boolean authenticated() {
var authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication.isAuthenticated() && (authentication.getPrincipal() instanceof User);
}
public void logout() {
HttpSession session = request.getSession(false);
if(session != null) {
session.invalidate();
}
}
public void login(String username, String password) {
var context = SecurityContextHolder.createEmptyContext();
var token = new UsernamePasswordAuthenticationToken(username, password);
var authenticated = authenticationManager.authenticate(token);
context.setAuthentication(authenticated);
request.getSession(true).setAttribute(SPRING_SECURITY_CONTEXT_KEY, context);
}
}

View File

@ -1,31 +1,9 @@
package ru.tubryansk.tdms.service; package ru.tubryansk.tdms.service;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import ru.tubryansk.tdms.entity.DiplomaTopic;
import ru.tubryansk.tdms.exception.NotFoundException;
import ru.tubryansk.tdms.repository.DiplomaTopicRepository;
import ru.tubryansk.tdms.dto.DiplomaTopicDTO;
import java.util.List;
import java.util.stream.Collectors;
@Service @Service
@Transactional @Transactional
public class DiplomaTopicService { public class DiplomaTopicService {
@Autowired
private DiplomaTopicRepository diplomaTopicRepository;
public List<DiplomaTopicDTO> getAll() {
return diplomaTopicRepository.findAll()
.stream()
.map(DiplomaTopicDTO::fromEntity)
.collect(Collectors.toList());
}
public DiplomaTopicDTO getById(Integer id) {
return DiplomaTopicDTO.fromEntity(diplomaTopicRepository.findById(id)
.orElseThrow(() -> new NotFoundException(DiplomaTopic.class, id)));
}
} }

View File

@ -0,0 +1,17 @@
package ru.tubryansk.tdms.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.ContextStartedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class LifeCycleService {
@EventListener(ContextStartedEvent.class)
public void onStartup(ContextStartedEvent event) {
ApplicationContext applicationContext = event.getApplicationContext();
log.info("Static files location: {}", applicationContext.getEnvironment().getProperty("spring.web.resources.static-locations"));
}
}

View File

@ -0,0 +1,46 @@
package ru.tubryansk.tdms.service;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import ru.tubryansk.tdms.entity.DiplomaTopic;
import ru.tubryansk.tdms.entity.Student;
import ru.tubryansk.tdms.exception.AccessDeniedException;
import ru.tubryansk.tdms.repository.DiplomaTopicRepository;
import ru.tubryansk.tdms.repository.StudentRepository;
import java.util.Map;
import java.util.Optional;
@Service
@Transactional
public class StudentService {
@Autowired
private StudentRepository studentRepository;
@Autowired
private DiplomaTopicRepository diplomaTopicRepository;
@Autowired
private Optional<Student> student;
@Autowired
private UserService userService;
/** @param studentToDiplomaTopic Map of @{@link Student} id and @{@link DiplomaTopic} id */
public void changeDiplomaTopic(Map<Integer, Integer> studentToDiplomaTopic) {
studentToDiplomaTopic.forEach(this::changeDiplomaTopic);
}
public void changeDiplomaTopic(Integer studentId, Integer diplomaTopicId) {
Student student = studentRepository.findByIdThrow(studentId);
DiplomaTopic diplomaTopic = diplomaTopicRepository.findByIdThrow(diplomaTopicId);
student.setDiplomaTopic(diplomaTopic);
}
public void changeCallerDiplomaTopic(Integer diplomaTopicId) {
DiplomaTopic diplomaTopic = diplomaTopicRepository.findByIdThrow(diplomaTopicId);
student.ifPresentOrElse(s -> s.setDiplomaTopic(diplomaTopic), () -> {throw new AccessDeniedException();});
}
public Optional<Student> getCallerStudent() {
return studentRepository.findByUser(userService.getCallerUser().orElse(null));
}
}

View File

@ -1,11 +1,8 @@
package ru.tubryansk.tdms.service; package ru.tubryansk.tdms.service;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
@ -13,7 +10,7 @@ import org.springframework.stereotype.Service;
import ru.tubryansk.tdms.entity.User; import ru.tubryansk.tdms.entity.User;
import ru.tubryansk.tdms.repository.UserRepository; import ru.tubryansk.tdms.repository.UserRepository;
import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY; import java.util.Optional;
@Service @Service
@Transactional @Transactional
@ -22,20 +19,7 @@ public class UserService implements UserDetailsService {
@Autowired @Autowired
private UserRepository userRepository; private UserRepository userRepository;
@Autowired @Autowired
private HttpServletRequest httpServletRequest; private AuthenticationService authenticationService;
public User getCallerPrincipal() {
if(!authenticated()) {
return null;
}
return (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
public boolean authenticated() {
var authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken);
}
@Override @Override
public User loadUserByUsername(String username) throws UsernameNotFoundException { public User loadUserByUsername(String username) throws UsernameNotFoundException {
@ -43,11 +27,10 @@ public class UserService implements UserDetailsService {
.orElseThrow(() -> new UsernameNotFoundException("User not found")); .orElseThrow(() -> new UsernameNotFoundException("User not found"));
} }
public void logout() { public Optional<User> getCallerUser() {
HttpSession session = httpServletRequest.getSession(true); if(authenticationService.authenticated()) {
// if(session != null) { return Optional.of((User) SecurityContextHolder.getContext().getAuthentication().getPrincipal());
// session.invalidate(); }
// } return Optional.empty();
session.setAttribute(SPRING_SECURITY_CONTEXT_KEY, null);
} }
} }

View File

@ -7,13 +7,15 @@ application:
name: @name@ name: @name@
version: @version@ version: @version@
type: production type: production
port: 80 port: 443
domain: tdms.tu-bryansk.ru domain: tdms.tu-bryansk.ru
protocol: https protocol: https
spring: spring:
application: application:
name: tdms name: ${application.name}
main:
allow-circular-references: true
datasource: datasource:
url: ${db.url} url: ${db.url}
username: ${db.user} username: ${db.user}

69
web/package-lock.json generated
View File

@ -8,6 +8,11 @@
"name": "web", "name": "web",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"axios": "^1.7.7", "axios": "^1.7.7",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"mobx": "^6.13.1", "mobx": "^6.13.1",
@ -1733,6 +1738,70 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz",
"integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz",
"integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz",
"integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz",
"integrity": "sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz",
"integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz",
"integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"react": ">=16.3"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",

View File

@ -3,10 +3,15 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build" : "webpack --mode production", "build": "webpack --mode production",
"dev": "webpack-dev-server --mode development" "dev": "webpack-dev-server --mode development"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"axios": "^1.7.7", "axios": "^1.7.7",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"mobx": "^6.13.1", "mobx": "^6.13.1",

View File

@ -1,20 +1,17 @@
import React from "react";
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import './index.css' import './index.css'
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import {RouterContext, RouterView} from "mobx-state-router"; import {RouterContext, RouterView} from "mobx-state-router";
import {initApp} from "./utils/init.ts"; import {initApp} from "./utils/init";
import {MyRouterStore} from "./store/MyRouterStore.ts"; import {RootStoreContext} from './context/RootStoreContext';
import { RootStoreContext } from "./store/RootStore.tsx"; import {viewMap} from "./router/viewMap";
const rootStore = initApp(); const rootStore = initApp();
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <RootStoreContext.Provider value={rootStore}>
<RootStoreContext.Provider value={rootStore}> <RouterContext.Provider value={rootStore.routerStore}>
<RouterContext.Provider value={rootStore.routerStore}> <RouterView viewMap={viewMap}/>
<RouterView viewMap={MyRouterStore.makeViewMap()} /> </RouterContext.Provider>
</RouterContext.Provider> </RootStoreContext.Provider>
</RootStoreContext.Provider>
</React.StrictMode>
); );

View File

@ -1,19 +0,0 @@
import {Component, ReactNode} from "react";
import Header from "./Header.tsx";
import {Container} from "react-bootstrap";
import Footer from "./Footer.tsx";
export abstract class DefaultPage extends Component {
abstract get page(): ReactNode;
// declare context: ContextType<typeof RootStoreContext>
render() {
return <>
<Header/>
<Container className={"mt-5 mb-5"}>
{this.page}
</Container>
<Footer/>
</>
}
}

View File

@ -1,49 +0,0 @@
import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap";
import {FC} from "react";
import {RouterLink} from "mobx-state-router";
import {useRootStore} from "../../store/RootStore.tsx";
import {IAuthenticated} from "../../models/user.ts";
import {observer} from "mobx-react";
export const Header: FC = observer(() => {
const store = useRootStore();
const user = store.userStore.user;
return <header>
<Navbar className="bg-body-tertiary" fixed="top">
<Container>
<Navbar.Brand>
<Nav.Link as={RouterLink} routeName='root'>TDMS</Nav.Link>
</Navbar.Brand>
<Nav>
<NavDropdown title="Группы">
<NavDropdown.Item>Список</NavDropdown.Item>
<NavDropdown.Item>Редактировать</NavDropdown.Item>
</NavDropdown>
</Nav>
<Nav className="ms-auto">
{
user.authenticated &&
<>
<Navbar.Text>Пользователь:</Navbar.Text>
<NavDropdown
title={(user as IAuthenticated).fullName}>
<NavDropdown.Item>Моя страница</NavDropdown.Item>
<NavDropdown.Divider/>
<NavDropdown.Item onClick={() => store.userStore.logout()}>Выйти</NavDropdown.Item>
</NavDropdown>
</>
}
{
!user.authenticated &&
<Nav.Link as={RouterLink} routeName='login'>Войти</Nav.Link>
}
</Nav>
</Container>
</Navbar>
</header>
});
export default Header;

View File

@ -1,7 +0,0 @@
import {DefaultPage} from "./DefaultPage.tsx";
export default class Root extends DefaultPage {
get page() {
return <h1>Home</h1>
}
}

View File

@ -1,7 +0,0 @@
import {DefaultPage} from "./DefaultPage.tsx";
export default class UserProfile extends DefaultPage {
get page() {
return <h1>User Profile</h1>
}
}

View File

@ -1,4 +1,4 @@
import {DefaultPage} from "./DefaultPage.tsx"; import {DefaultPage} from "./layout/DefaultPage";
export default class Error extends DefaultPage { export default class Error extends DefaultPage {
get page() { get page() {

View File

@ -0,0 +1,11 @@
import {DefaultPage} from "./layout/DefaultPage";
import {RootStoreContext, RootStoreContextType} from "../../context/RootStoreContext";
export default class Home extends DefaultPage {
declare context: RootStoreContextType;
static contextType = RootStoreContext;
get page() {
return <h1>Home</h1>
}
}

View File

@ -0,0 +1,177 @@
import {DefaultPage} from "./layout/DefaultPage";
import {Col, Form, Row} from "react-bootstrap";
import {observer} from "mobx-react";
import {RootStoreContext, type RootStoreContextType} from "../../context/RootStoreContext";
import {IAuthenticated} from "../../models/user";
import {Component} from "react";
import {dateConverter} from "../../utils/converters";
import {IStudent} from "../../models/student";
import {makeObservable, observable} from "mobx";
@observer
class UserInfo extends Component<{user: IAuthenticated}> {
@observable
user = this.props.user;
constructor(props: any) {
super(props);
makeObservable(this);
}
render() {
return (
<Row>
<Col sm={6}>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>ФИО</Form.Label>
<Form.Control type="text" value={this.user.fullName} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Имя пользователя</Form.Label>
<Form.Control type="text" value={this.user.login} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Электронная почта</Form.Label>
<Form.Control type="email" value={this.user.email} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Телефон</Form.Label>
{/* todo: format phone */}
<Form.Control type="text" value={this.user.phone} disabled={true}/>
</Form.Group>
</Col>
<Col sm={6}>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Роли</Form.Label>
<Form.Control
type="text"
value={this.user.authorities?.map(a => a.name).join(', ')}
disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Дата создания</Form.Label>
<Form.Control type="text" value={dateConverter(this.user.createdAt)} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Дата последней модификации</Form.Label>
<Form.Control type="text" value={dateConverter(this.user.updatedAt)} disabled={true}/>
</Form.Group>
</Col>
</Row>
)
}
}
@observer
class StudentInfo extends Component<{student: IStudent}> {
@observable
student = this.props.student;
constructor(props: any) {
super(props);
makeObservable(this);
}
render() {
let student = this.student;
return (
<Row className={"mt-4"}>
<Col sm={6}>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Тема дипломной работы</Form.Label>
<Form.Control type="text" value={student.diplomaTopic} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Очередь защиты</Form.Label>
<Form.Control type="text" value={student.protectionOrder.toString()} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Презентация в электронном формате</Form.Label>
<Form.Control type="text" value={student.digitalFormatPresent ? "Да" : "Нет"} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Оценка за комментарий</Form.Label> {/* todo: обсудить с аналитиком */}
<Form.Control type="text" value={student.markComment.toString()} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Оценка за практику</Form.Label> {/* todo: обсудить с аналитиком */}
<Form.Control type="text" value={student.markPractice.toString()} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Комментарий к предзащите</Form.Label>
<Form.Control type="text" value={student.predefenceComment} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Форма контроля</Form.Label> {/* todo: обсудить с аналитиком */}
<Form.Control type="text" value={student.normalControl} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Антиплагиат (процент
уникальности)</Form.Label> {/* todo: обсудить с аналитиком */}
<Form.Control type="text" value={student.antiPlagiarism.toString()} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Примечание</Form.Label>
<Form.Control type="text" value={student.note} disabled={true}/>
</Form.Group>
</Col>
<Col sm={6}>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Группа</Form.Label>
<Form.Control type="text" value={student.group.name} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Куратор</Form.Label>
<Form.Control type="text" value={student.group.principalUser.fullName} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Форма обучения</Form.Label> {/* todo: обсудить с аналитиком */}
<Form.Control type="text" value={student.form ? "Очная" : "Заочная"} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Научный руководитель</Form.Label>
<Form.Control type="text" value={student.mentorUser.fullName} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Зачетная книжка сдана</Form.Label>
<Form.Control type="text" value={student.recordBookReturned ? "Да" : "Нет"} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Работа</Form.Label> {/* todo: обсудить с аналитиком */}
<Form.Control type="text" value={student.work} disabled={true}/>
</Form.Group>
<Form.Group className={"mt-2"}>
<Form.Label column={"sm"}>Магистратура</Form.Label> {/* todo: обсудить с аналитиком */}
<Form.Control type="text" value={student.magistracy} disabled={true}/>
</Form.Group>
</Col>
</Row>
);
}
}
export default class UserProfile extends DefaultPage {
declare context: RootStoreContextType;
static contextType = RootStoreContext;
get page() {
let user = this.context.userStore.user;
if (!user.authenticated) {
// todo: implement login page with redirects
this.context.routerStore.goTo('login', {redirect: 'profile'});
}
let student = this.context.userStore.student;
return <Form>
{
user.authenticated &&
<UserInfo user={user}/>
}
{
student && user.authenticated &&
<StudentInfo student={student}/>
}
</Form>
}
}

View File

@ -0,0 +1,42 @@
import {Component, ReactNode} from "react";
import {Container} from "react-bootstrap";
import Footer from "./Footer";
import Header from "./Header";
import {RootStoreContext, RootStoreContextType} from "../../../context/RootStoreContext";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {observer} from "mobx-react";
@observer
class DefaultPage extends Component<any> {
get page(): ReactNode {
throw new Error('This is not abstract method, ' +
'because mobx cant handle abstract methods. ' +
'Please override this method in child class. ' +
'Do not call it directly.');
}
declare context: RootStoreContextType;
static contextType = RootStoreContext;
render() {
let isLoading = this.context.userStore.isLoading;
return <>
<Header/>
<Container className={"mt-5 mb-5"}>
{
isLoading &&
<div id='fullscreen-loader'>
<FontAwesomeIcon icon='gear' size="4x" spin/>
</div>
}
{
!isLoading &&
this.page
}
</Container>
<Footer/>
</>
}
}
export {DefaultPage};

View File

@ -1,5 +1,6 @@
import {Container, Nav, Navbar} from "react-bootstrap"; import {Container, Nav, Navbar} from "react-bootstrap";
import {GitHubLogo} from "../../utils/svg.tsx"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {findIconDefinition} from "@fortawesome/fontawesome-svg-core";
const Footer = () => { const Footer = () => {
return ( return (
@ -9,7 +10,7 @@ const Footer = () => {
<Navbar.Text>Thesis Defence Management System &copy;</Navbar.Text> <Navbar.Text>Thesis Defence Management System &copy;</Navbar.Text>
<Nav> <Nav>
<Nav.Link href="https://github.com/Velixeor/Thesis-Defense-Management-System"> <Nav.Link href="https://github.com/Velixeor/Thesis-Defense-Management-System">
<GitHubLogo width={32} height={32}/> <FontAwesomeIcon icon={findIconDefinition({iconName:'github', prefix:'fab'})} size="xl"/>
</Nav.Link> </Nav.Link>
</Nav> </Nav>
</Container> </Container>

View File

@ -0,0 +1,62 @@
import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap";
import {Component} from "react";
import {RouterLink} from "mobx-state-router";
import {IAuthenticated} from "../../../models/user";
import {makeObservable} from "mobx";
import {RootStoreContext, RootStoreContextType} from "../../../context/RootStoreContext";
import {observer} from "mobx-react";
@observer
class Header extends Component {
declare context: RootStoreContextType;
static contextType = RootStoreContext;
constructor(props: any) {
super(props);
makeObservable(this);
}
render() {
const userStore = this.context.userStore;
const routerStore = this.context.routerStore;
const user = userStore.user;
return <header>
<Navbar className="bg-body-tertiary" fixed="top">
<Container>
<Navbar.Brand>
<Nav.Link as={RouterLink} routeName='root'>TDMS</Nav.Link>
</Navbar.Brand>
<Nav>
<NavDropdown title="Группы">
<NavDropdown.Item>Список</NavDropdown.Item>
<NavDropdown.Item>Редактировать</NavDropdown.Item>
</NavDropdown>
</Nav>
<Nav className="ms-auto">
{
user.authenticated &&
<>
<Navbar.Text>Пользователь:</Navbar.Text>
<NavDropdown
title={(user as IAuthenticated).fullName}>
<NavDropdown.Item onClick={() => {routerStore.goTo('profile')}}>Моя страница</NavDropdown.Item>
<NavDropdown.Divider/>
<NavDropdown.Item>Выйти</NavDropdown.Item>
</NavDropdown>
</>
}
{
!user.authenticated &&
<Nav.Link as={RouterLink} routeName='login'>Войти</Nav.Link>
}
</Nav>
</Container>
</Navbar>
</header>
}
}
export default Header;

View File

@ -0,0 +1,5 @@
import {RootStore} from "../store/RootStore";
import {ContextType, createContext} from "react";
export const RootStoreContext = createContext<RootStore>(new RootStore());
export type RootStoreContextType = ContextType<typeof RootStoreContext>;

View File

@ -64,4 +64,11 @@ body {
footer { footer {
margin-top: auto; margin-top: auto;
} }
#fullscreen-loader {
min-height: calc(100vh - (56px + 64px + 48px + 48px));
display: flex;
justify-content: center;
align-items: center;
}

10
web/src/models/role.ts Normal file
View File

@ -0,0 +1,10 @@
export enum Role {
STUDENT = 'ROLE_STUDENT',
TUTOR = 'ROLE_TUTOR',
DIRECTOR = 'ROLE_DIRECTOR',
}
export interface IAuthority {
authority: Role;
name: string;
}

25
web/src/models/student.ts Normal file
View File

@ -0,0 +1,25 @@
import {IAuthenticated, IUser} from "./user";
export interface IGRoup {
name: string;
principalUser: IAuthenticated;
}
export interface IStudent {
form: boolean;
protectionOrder: number;
magistracy: string;
digitalFormatPresent: boolean;
markComment: number;
markPractice: number;
predefenceComment: string;
normalControl: string;
antiPlagiarism: number;
note: string;
recordBookReturned: boolean;
work: string;
user: IUser;
diplomaTopic: string;
mentorUser: IAuthenticated;
group: IGRoup;
}

View File

@ -1,3 +1,5 @@
import {IAuthority} from "./role";
export interface IAuthenticated { export interface IAuthenticated {
authenticated: true, authenticated: true,
login: string, login: string,
@ -7,7 +9,7 @@ export interface IAuthenticated {
phone: string, phone: string,
createdAt: string, createdAt: string,
updatedAt: string, updatedAt: string,
authorities: string[], authorities: IAuthority[],
} }
export declare type IUser = {authenticated: false} | IAuthenticated; export declare type IUser = {authenticated: false} | IAuthenticated;

12
web/src/router/routes.ts Normal file
View File

@ -0,0 +1,12 @@
import {Route} from "mobx-state-router";
export const routes: Route[] = [{
name: 'root',
pattern: '/',
}, {
name: 'profile',
pattern: '/profile',
}, {
name: 'error',
pattern: '/error',
}];

View File

@ -0,0 +1,10 @@
import {ViewMap} from "mobx-state-router";
import Home from "../components/page/Home";
import Error from "../components/page/Error";
import UserProfile from "../components/page/UserProfile";
export const viewMap: ViewMap = {
root: <Home/>,
profile: <UserProfile/>,
error: <Error/>,
}

View File

@ -1,24 +0,0 @@
import UserProfile from "./components/Page/UserProfile.tsx";
import React from "react";
import Root from "./components/Page/Root.tsx";
import Error from "./components/Page/Error.tsx";
interface Route {
name: string;
pattern: string;
view: React.ReactElement;
}
export const routes: Route[] = [{
name: 'root',
pattern: '/',
view: <Root/>,
}, {
name: 'profile',
pattern: '/profile',
view: <UserProfile/>,
}, {
name: 'error',
pattern: '/error',
view: <Error/>,
}];

View File

@ -0,0 +1,17 @@
import {MyRouterStore} from "../store/MyRouterStore";
export interface IRouterOptions {
redirect: string;
}
export class RouterService {
private static router: MyRouterStore;
static init(router: MyRouterStore) {
this.router = router;
}
static redirect(state: string, options?: IRouterOptions) {
this.router.goTo(state, options);
}
}

View File

@ -1,3 +1,13 @@
export default class UserService { import {Role} from "../models/role";
import {IAuthenticated, IUser} from "../models/user";
export class UserService {
static isUserInRole(user: IUser, role: Role): boolean {
if (!user.authenticated) {
return false;
}
user = user as IAuthenticated;
return user.authorities.some(a => a.authority === role);
}
} }

View File

@ -1,30 +1,17 @@
import {browserHistory, createRouterState, HistoryAdapter, RouterStore} from "mobx-state-router"; import {browserHistory, createRouterState, HistoryAdapter, RouterStore} from "mobx-state-router";
import {routes} from "../routes.tsx"; import {RootStore} from "./RootStore";
import {RootStore} from "./RootStore.tsx"; import {routes} from "../router/routes";
export class MyRouterStore extends RouterStore { export class MyRouterStore extends RouterStore {
constructor(rootStore: RootStore) { constructor(rootStore: RootStore) {
super(MyRouterStore.makeRoutesMap(), super(routes,
createRouterState('error', {notFound: true}), createRouterState('error', {notFound: true}),
{rootStore: rootStore}); {rootStore: rootStore});
} }
static makeViewMap() {
return routes.reduce<any>((map, route) => {
map[route.name] = route.view;
return map;
}, {});
}
static makeRoutesMap() {
return routes.map(route => {
return {...route, view: undefined};
});
}
init() { init() {
const historyAdapter = new HistoryAdapter(this, browserHistory); const historyAdapter = new HistoryAdapter(this, browserHistory);
historyAdapter.observeRouterStateChanges(); historyAdapter.observeRouterStateChanges();
return this;
} }
} }

View File

@ -0,0 +1,13 @@
import {MyRouterStore} from "./MyRouterStore";
import {UserStore} from "./UserStore";
export class RootStore {
userStore = new UserStore(this);
routerStore = new MyRouterStore(this);
init() {
this.userStore.init();
this.routerStore.init();
return this;
}
}

View File

@ -1,31 +0,0 @@
import {UserStore} from "./UserStore.ts";
import {MyRouterStore} from "./MyRouterStore.ts";
import {createContext, useContext} from "react";
export class RootStore {
userStore = new UserStore(this);
routerStore = new MyRouterStore(this);
constructor() {
this.init();
}
init() {
this.userStore.init();
this.routerStore.init();
return this;
}
}
export const RootStoreContext = createContext<RootStore | undefined>(
undefined
);
export function useRootStore(): RootStore {
const rootStore = useContext(RootStoreContext);
if (rootStore === undefined) {
throw new Error('useRootStore must be used within a RootStoreProvider');
}
return rootStore;
}

View File

@ -1,30 +1,46 @@
import {get, post} from "../utils/request.tsx"; import {get} from "../utils/request";
import {makeObservable, observable, runInAction} from "mobx"; import {makeObservable, observable, runInAction} from "mobx";
import {RootStore} from "./RootStore.ts"; import {RootStore} from "./RootStore";
import {IUser} from "../models/user.ts"; import type {IUser} from "../models/user";
import {IStudent} from "../models/student";
import {Role} from "../models/role";
export class UserStore { export class UserStore {
rootStore: RootStore; rootStore: RootStore;
@observable
user: IUser = {authenticated: false}; user: IUser = {authenticated: false};
@observable
student: IStudent | undefined;
@observable
isLoading: boolean = true;
constructor(rootStore: RootStore) { constructor(rootStore: RootStore) {
makeObservable(this, { makeObservable(this);
user: observable,
});
this.rootStore = rootStore; this.rootStore = rootStore;
} }
init() { fetchCurrentUserData() {
// todo: store token in localStorage
get<IUser>('/user/current').then((response) => { get<IUser>('/user/current').then((response) => {
runInAction(() => { runInAction(() => {
this.user = response; this.user = response;
}); });
if(response.authenticated && response.authorities.some(a => a.authority === Role.STUDENT)) {
get<IStudent>('/student/current').then((student) => {
runInAction(() => {
this.student = student;
});
});
}
}).finally(() => {
runInAction(() => {
this.isLoading = false;
});
}); });
} }
logout() { init() {
post('/user/logout').then(() => { this.fetchCurrentUserData();
this.init(); return this;
});
} }
} }

View File

@ -0,0 +1,3 @@
export const dateConverter = (date: string) => {
return new Date(date).toLocaleString();
}

View File

@ -1,12 +1,25 @@
import {configure} from "mobx"; import {configure} from "mobx";
import {RootStore} from "../store/RootStore.tsx"; import {RootStore} from "../store/RootStore";
import {RouterService} from "../services/RouterService";
import { library } from '@fortawesome/fontawesome-svg-core';
import { fas } from '@fortawesome/free-solid-svg-icons';
import {fab} from "@fortawesome/free-brands-svg-icons";
import {far} from "@fortawesome/free-regular-svg-icons";
const initMobX = () => {
export const initMobX = () => {
configure({enforceActions: 'observed'}); configure({enforceActions: 'observed'});
} }
const initFontAwesome = () => {
library.add(fas);
library.add(fab);
library.add(far);
}
export const initApp = () => { export const initApp = () => {
initMobX(); initMobX();
return new RootStore(); initFontAwesome();
} let rootStore = new RootStore().init();
RouterService.init(rootStore.routerStore);
return rootStore;
}

View File

@ -2,23 +2,23 @@ import axios, {AxiosRequestConfig} from "axios";
export const apiUrl = "http://localhost:8080/api/v1/"; export const apiUrl = "http://localhost:8080/api/v1/";
export const get = async <T extends unknown> (url: string, data?: T) => { export const get = async <T,> (url: string, data?: any) => {
return await request({ return await request<T>({
url: url, url: url,
method: 'GET', method: 'GET',
data: data, data: data,
}) as T; });
} }
export const post = async <T extends unknown> (url: string, data?: T) => { export const post = async <T,> (url: string, data?: any) => {
return request({ return await request<T>({
url: url, url: url,
method: 'POST', method: 'POST',
data: data, data: data,
}); });
} }
export const request = async <T extends unknown> (config: AxiosRequestConfig<T>) => { export const request = async <T,> (config: AxiosRequestConfig<any>) => {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
console.debug(`${config.method} ${config.url} request: ${config.method === 'GET' ? JSON.stringify(config.params) : JSON.stringify(config.data)}`); console.debug(`${config.method} ${config.url} request: ${config.method === 'GET' ? JSON.stringify(config.params) : JSON.stringify(config.data)}`);
axios.request({...config, baseURL: apiUrl}).then((response) => { axios.request({...config, baseURL: apiUrl}).then((response) => {

View File

@ -1,16 +0,0 @@
import React from "react";
interface Dimensions {
width?: number,
height?: number,
}
export const GitHubLogo: React.FC<Dimensions> = ({width = 10, height = 10}: Dimensions) => {
return (
<svg viewBox="0, 0, 98, 96" width={width} height={height} xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd"
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
fill="#24292f"/>
</svg>
);
}

View File

@ -28,8 +28,16 @@ module.exports = {
path: path.resolve(__dirname, 'dist') path: path.resolve(__dirname, 'dist')
}, },
devServer: { devServer: {
client: {
overlay: {
errors: true,
warnings: false,
runtimeErrors: false,
},
},
historyApiFallback: true,
static: path.join(__dirname, "dist"), static: path.join(__dirname, "dist"),
compress: false, compress: true,
port: 8081, port: 8081,
}, },
plugins: [ plugins: [