Compare commits

...

2 Commits

Author SHA1 Message Date
Maksim Skobaro
fb13834062 Basic group login and other major improvements 2025-02-09 06:58:24 +03:00
Maksim Skobaro
a5695ccab6 add InputRestriction support for reactiveValue.ts 2025-02-08 03:33:42 +03:00
46 changed files with 1012 additions and 557 deletions

View File

@ -4,13 +4,12 @@ package ru.tubryansk.tdms;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication @SpringBootApplication
@Slf4j @Slf4j
public class TdmsApplication { public class TdmsApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(TdmsApplication.class, args); SpringApplication.run(TdmsApplication.class, args).start();
} }
} }

View File

@ -43,7 +43,7 @@ public class SecurityConfiguration {
.cors(a -> a.configurationSource(cors)) .cors(a -> a.configurationSource(cors))
.authenticationManager(authenticationManager) .authenticationManager(authenticationManager)
.sessionManagement(cfg -> { .sessionManagement(cfg -> {
cfg.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED); cfg.sessionCreationPolicy(SessionCreationPolicy.NEVER);
cfg.maximumSessions(1); cfg.maximumSessions(1);
}) })
.build(); .build();
@ -57,20 +57,19 @@ public class SecurityConfiguration {
@Value("${application.protocol}") String protocol, @Value("${application.protocol}") String protocol,
Environment environment Environment environment
) { ) {
return request -> { CorsConfiguration corsConfiguration = new CorsConfiguration();
String url = StringUtils.join(protocol, "://", domain, ":", port); corsConfiguration.setAllowedMethods(List.of(HttpMethod.GET.name(), HttpMethod.POST.name(), HttpMethod.OPTIONS.name()));
CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
corsConfiguration.setMaxAge(Duration.ofDays(1)); corsConfiguration.setAllowCredentials(true);
corsConfiguration.addAllowedOrigin(url); corsConfiguration.setMaxAge(Duration.ofDays(1));
if (environment.matchesProfiles("dev")) { corsConfiguration.addAllowedOrigin(StringUtils.join(protocol, "://", domain, ":", port));
corsConfiguration.addAllowedOrigin("http://localhost:8081"); if (environment.matchesProfiles("dev")) {
} corsConfiguration.addAllowedOrigin("http://localhost:8888");
}
corsConfiguration.setAllowedMethods(List.of(HttpMethod.GET.name(), HttpMethod.POST.name(), HttpMethod.OPTIONS.name())); log.info("CORS configuration: [headers: {}, methods: {}, origins: {}, credentials: {}, maxAge: {}]",
corsConfiguration.setAllowedHeaders(List.of("Authorization", "Content-Type")); corsConfiguration.getAllowedHeaders(), corsConfiguration.getAllowedMethods(), corsConfiguration.getAllowedOrigins(),
corsConfiguration.setAllowCredentials(true); corsConfiguration.getAllowCredentials(), corsConfiguration.getMaxAge());
return corsConfiguration; return request -> corsConfiguration;
};
} }
@Bean @Bean
@ -96,8 +95,8 @@ public class SecurityConfiguration {
/* StudentController */ /* StudentController */
httpAuthorization.requestMatchers("/api/v1/student/current").permitAll(); httpAuthorization.requestMatchers("/api/v1/student/current").permitAll();
/* GroupController */ /* GroupController */
httpAuthorization.requestMatchers("/api/v1/group/get-all").permitAll(); httpAuthorization.requestMatchers("/api/v1/group/get-all-groups").permitAll();
httpAuthorization.requestMatchers("api/v1/group/create-group").hasAuthority("ROLE_ADMINISTRATOR"); httpAuthorization.requestMatchers("/api/v1/group/create-group").hasAuthority("ROLE_ADMINISTRATOR");
/* deny all other api requests */ /* deny all other api requests */
httpAuthorization.requestMatchers("/api/**").denyAll(); httpAuthorization.requestMatchers("/api/**").denyAll();
/* since api already blocked, all other requests are static resources */ /* since api already blocked, all other requests are static resources */

View File

@ -3,8 +3,9 @@ package ru.tubryansk.tdms.controller;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import ru.tubryansk.tdms.controller.payload.GroupCreateDTO;
import ru.tubryansk.tdms.controller.payload.GroupDTO; import ru.tubryansk.tdms.controller.payload.GroupDTO;
import ru.tubryansk.tdms.controller.payload.GroupRegistrationDTO; import ru.tubryansk.tdms.controller.payload.GroupEditDTO;
import ru.tubryansk.tdms.service.GroupService; import ru.tubryansk.tdms.service.GroupService;
import java.util.Collection; import java.util.Collection;
@ -21,7 +22,12 @@ public class GroupController {
} }
@PostMapping("/create-group") @PostMapping("/create-group")
public void createGroup(@RequestBody @Valid GroupRegistrationDTO groupRegistrationDTO) { public void createGroup(@RequestBody @Valid GroupCreateDTO groupCreateDTO) {
groupService.createGroup(groupRegistrationDTO.getName()); groupService.createGroup(groupCreateDTO.getName());
}
@PostMapping("/edit-group")
public void editGroup(@RequestBody @Valid GroupEditDTO groupEditDTO) {
groupService.editGroup(groupEditDTO);
} }
} }

View File

@ -36,7 +36,7 @@ public class UserController {
@PostMapping("/login") @PostMapping("/login")
public void login(@RequestBody @Valid LoginDTO loginDTO) { public void login(@RequestBody @Valid LoginDTO loginDTO) {
authenticationService.login(loginDTO.getUsername(), loginDTO.getPassword()); authenticationService.login(loginDTO.getLogin(), loginDTO.getPassword());
} }
@PostMapping("/register") @PostMapping("/register")

View File

@ -6,7 +6,7 @@ import jakarta.validation.constraints.Size;
import lombok.Getter; import lombok.Getter;
@Getter @Getter
public class GroupRegistrationDTO { public class GroupCreateDTO {
@NotEmpty(message = "Имя группы не может быть пустым") @NotEmpty(message = "Имя группы не может быть пустым")
@Size(min = 3, max = 50, message = "Имя группы должно быть от 3 до 50 символов") @Size(min = 3, max = 50, message = "Имя группы должно быть от 3 до 50 символов")
@Pattern(regexp = "^[а-яА-ЯёЁ0-9_-]*$", message = "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры") @Pattern(regexp = "^[а-яА-ЯёЁ0-9_-]*$", message = "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры")

View File

@ -8,6 +8,7 @@ import lombok.ToString;
@Setter @Setter
@ToString @ToString
public class GroupDTO { public class GroupDTO {
private Long id;
private String name; private String name;
private String curatorName; private String curatorName;
private Boolean iAmCurator; private Boolean iAmCurator;

View File

@ -0,0 +1,17 @@
package ru.tubryansk.tdms.controller.payload;
import jakarta.validation.constraints.*;
import lombok.Getter;
import lombok.ToString;
@Getter
@ToString
public class GroupEditDTO {
@NotNull(message = "Идентификатор группы не может быть пустым")
@Min(value = 1, message = "Идентификатор группы должен быть больше 0")
private Long id;
@NotEmpty(message = "Имя группы не может быть пустым")
@Size(min = 3, max = 50, message = "Имя группы должно быть от 3 до 50 символов")
@Pattern(regexp = "^[а-яА-ЯёЁ0-9_-]*$", message = "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры")
private String name;
}

View File

@ -1,12 +1,18 @@
package ru.tubryansk.tdms.controller.payload; package ru.tubryansk.tdms.controller.payload;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter; import lombok.Getter;
@Getter @Getter
public class LoginDTO { public class LoginDTO {
@NotEmpty(message = "Логин не может быть пустым") @NotEmpty(message = "Логин не может быть пустым")
private String username; @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Логин должен содержать только латинские буквы, цифры и знак подчеркивания")
@Size(min = 5, message = "Логин должен содержать минимум 5 символов")
@Size(max = 32, message = "Логин должен содержать максимум 32 символов")
private String login;
@NotEmpty(message = "Пароль не может быть пустым") @NotEmpty(message = "Пароль не может быть пустым")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$", message = "Пароль должен содержать хотя бы одну цифру, одну заглавную и одну строчную букву, минимум 8 символов")
private String password; private String password;
} }

View File

@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
@ -11,7 +12,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException;
import ru.tubryansk.tdms.controller.payload.ErrorResponse; import ru.tubryansk.tdms.controller.payload.ErrorResponse;
import java.util.Random; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestControllerAdvice @RestControllerAdvice
@ -23,13 +24,13 @@ public class GlobalExceptionHandler {
log.debug("Validation error: {}", e.getMessage()); log.debug("Validation error: {}", e.getMessage());
String validationErrors = e.getAllErrors().stream() String validationErrors = e.getAllErrors().stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage) .map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.joining(", ")); .collect(Collectors.joining("\n"));
return new ErrorResponse(validationErrors, ErrorResponse.ErrorCode.VALIDATION_ERROR); return new ErrorResponse(validationErrors, ErrorResponse.ErrorCode.VALIDATION_ERROR);
} }
@ExceptionHandler(BusinessException.class) @ExceptionHandler(BusinessException.class)
public ErrorResponse handleBusinessException(BusinessException e, HttpServletResponse response) { public ErrorResponse handleBusinessException(BusinessException e, HttpServletResponse response) {
log.info("Business error", e); log.info("Business error: {}", e.getMessage());
response.setStatus(e.getErrorCode().getHttpStatus().value()); response.setStatus(e.getErrorCode().getHttpStatus().value());
return new ErrorResponse(e.getMessage(), e.getErrorCode()); return new ErrorResponse(e.getMessage(), e.getErrorCode());
} }
@ -37,24 +38,30 @@ public class GlobalExceptionHandler {
@ExceptionHandler(org.springframework.security.access.AccessDeniedException.class) @ExceptionHandler(org.springframework.security.access.AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN) @ResponseStatus(HttpStatus.FORBIDDEN)
public ErrorResponse handleAccessDeniedException(AccessDeniedException e) { public ErrorResponse handleAccessDeniedException(AccessDeniedException e) {
log.debug("Access denied", e); log.info("Access denied: {}", e.getMessage());
return new ErrorResponse("", ErrorResponse.ErrorCode.ACCESS_DENIED); return new ErrorResponse("Доступ запрещен", ErrorResponse.ErrorCode.ACCESS_DENIED);
} }
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ErrorResponse handleAuthenticationException(AuthenticationException e) {
log.info("Authentication error: {}", e.getMessage());
return new ErrorResponse("Неверный логин или пароль", ErrorResponse.ErrorCode.ACCESS_DENIED);
}
@ExceptionHandler(NoResourceFoundException.class) @ExceptionHandler(NoResourceFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND) @ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNoResourceFoundException(NoResourceFoundException e) { public ErrorResponse handleNoResourceFoundException(NoResourceFoundException e) {
log.error("Resource not found", e); UUID uuid = UUID.randomUUID();
return new ErrorResponse(e.getMessage(), ErrorResponse.ErrorCode.NOT_FOUND); log.error("Resource not found ({})", uuid, e);
return new ErrorResponse("Идентификатор ошибки: (" + uuid + ")\nРесурс не был наеден, обратитесь к администратору", ErrorResponse.ErrorCode.NOT_FOUND);
} }
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleUnexpectedException(Exception e) { public ErrorResponse handleUnexpectedException(Exception e) {
Random random = new Random(); UUID uuid = UUID.randomUUID();
long errorInx = random.nextLong(); log.error("Unexpected exception ({})", uuid, e);
log.error("Unexpected exception. random: {}", errorInx, e); return new ErrorResponse("Идентификатор ошибки: (" + uuid + ")\роизошла непредвиденная ошибка, обратитесь к администратору", ErrorResponse.ErrorCode.INTERNAL_ERROR);
return new ErrorResponse("Произошла непредвиденная ошибка, обратитесь к администратору. Номер ошибки: " + errorInx, ErrorResponse.ErrorCode.INTERNAL_ERROR);
} }
} }

View File

@ -27,25 +27,26 @@ public class AuthenticationService {
} }
public void logout() { public void logout() {
log.info("Logging out user: {}", SecurityContextHolder.getContext().getAuthentication().getName());
HttpSession session = request.getSession(false); HttpSession session = request.getSession(false);
if(session != null) { if(session != null) {
session.invalidate(); session.invalidate();
} }
SecurityContextHolder.clearContext();
log.info("User logged out");
} }
@Transactional @Transactional
public void login(String username, String password) { public void login(String username, String password) {
try { log.info("Logging in user: {}, ip: {}", username, request.getRemoteAddr());
var context = SecurityContextHolder.createEmptyContext();
var token = new UsernamePasswordAuthenticationToken(username, password); var context = SecurityContextHolder.createEmptyContext();
var authenticated = authenticationManager.authenticate(token); var token = new UsernamePasswordAuthenticationToken(username, password);
context.setAuthentication(authenticated); var authenticated = authenticationManager.authenticate(token);
SecurityContextHolder.setContext(context); context.setAuthentication(authenticated);
request.getSession(true).setAttribute(SPRING_SECURITY_CONTEXT_KEY, context); SecurityContextHolder.setContext(context);
} catch (Exception e) { request.getSession(true).setAttribute(SPRING_SECURITY_CONTEXT_KEY, context);
log.error("Failed to log in user: {}", username, e);
throw e; log.info("User {} logged in", username);
}
log.debug("User {} logged in", username);
} }
} }

View File

@ -1,9 +1,11 @@
package ru.tubryansk.tdms.service; package ru.tubryansk.tdms.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import ru.tubryansk.tdms.controller.payload.GroupDTO; import ru.tubryansk.tdms.controller.payload.GroupDTO;
import ru.tubryansk.tdms.controller.payload.GroupEditDTO;
import ru.tubryansk.tdms.entity.Group; import ru.tubryansk.tdms.entity.Group;
import ru.tubryansk.tdms.entity.User; import ru.tubryansk.tdms.entity.User;
import ru.tubryansk.tdms.entity.repository.GroupRepository; import ru.tubryansk.tdms.entity.repository.GroupRepository;
@ -14,6 +16,7 @@ import java.util.List;
@Service @Service
@Transactional @Transactional
@Slf4j
public class GroupService { public class GroupService {
@Autowired @Autowired
private GroupRepository groupRepository; private GroupRepository groupRepository;
@ -21,33 +24,44 @@ public class GroupService {
private CallerService callerService; private CallerService callerService;
public Collection<GroupDTO> getAllGroups() { public Collection<GroupDTO> getAllGroups() {
log.info("Getting all groups");
List<Group> groups = groupRepository.findAll(); List<Group> groups = groupRepository.findAll();
User callerUser = callerService.getCallerUser().orElse(null); User callerUser = callerService.getCallerUser().orElse(null);
return groups.stream() List<GroupDTO> result = groups.stream().map(group -> {
.map(g -> { GroupDTO groupDTO = new GroupDTO();
GroupDTO groupDTO = new GroupDTO(); groupDTO.setName(group.getName());
groupDTO.setName(g.getName()); groupDTO.setId(group.getId());
if (g.getGroupCurator() != null) { if (group.getGroupCurator() != null) {
groupDTO.setCuratorName(g.getGroupCurator().getUser().getFullName()); groupDTO.setCuratorName(group.getGroupCurator().getUser().getFullName());
if (callerUser != null) { if (callerUser != null) {
groupDTO.setIAmCurator(g.getGroupCurator().getUser().equals(callerUser)); groupDTO.setIAmCurator(group.getGroupCurator().getUser().equals(callerUser));
}
} }
return groupDTO; }
}) return groupDTO;
.toList(); }).toList();
log.info("Found {} groups", result.size());
return result;
} }
public void createGroup(String groupName) { public void createGroup(String groupName) {
boolean existsByName = groupRepository.existsByName(groupName); log.info("Creating group with name {}", groupName);
if (existsByName) { if (groupRepository.existsByName(groupName)) {
throw new BusinessException("Группа с именем " + groupName + " уже существует"); throw new BusinessException("Группа с именем " + groupName + " уже существует");
} }
Group group = new Group(); Group group = new Group();
group.setName(groupName); group.setName(groupName);
groupRepository.save(group); Group saved = groupRepository.save(group);
log.info("Group saved: {}", saved);
}
public void editGroup(GroupEditDTO groupEditDTO) {
log.info("Updating group with dto: {}", groupEditDTO);
Group group = groupRepository.findByIdThrow(groupEditDTO.getId());
group.setName(groupEditDTO.getName());
log.info("Group updated: {}", group);
} }
} }

View File

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

View File

@ -1,6 +1,7 @@
package ru.tubryansk.tdms.service; package ru.tubryansk.tdms.service;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -11,6 +12,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@Service @Service
@Slf4j
public class RoleService { public class RoleService {
public enum Authority { public enum Authority {
ROLE_ADMINISTRATOR, ROLE_ADMINISTRATOR,
@ -28,8 +30,10 @@ public class RoleService {
@PostConstruct @PostConstruct
@Transactional @Transactional
public void init() { public void init() {
log.debug("Initializing roles");
roles = new ConcurrentHashMap<>(); roles = new ConcurrentHashMap<>();
roleRepository.findAll().forEach(role -> roles.put(role.getAuthority(), role)); roleRepository.findAll().forEach(role -> roles.put(role.getAuthority(), role));
log.info("Roles initialized: {}", roles);
} }
public Role getRoleByAuthority(Authority authority) { public Role getRoleByAuthority(Authority authority) {

View File

@ -4,11 +4,8 @@ import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import ru.tubryansk.tdms.controller.payload.StudentDTO; import ru.tubryansk.tdms.controller.payload.StudentDTO;
import ru.tubryansk.tdms.entity.DiplomaTopic;
import ru.tubryansk.tdms.entity.Student; import ru.tubryansk.tdms.entity.Student;
import ru.tubryansk.tdms.entity.repository.DiplomaTopicRepository;
import ru.tubryansk.tdms.entity.repository.StudentRepository; import ru.tubryansk.tdms.entity.repository.StudentRepository;
import ru.tubryansk.tdms.exception.AccessDeniedException;
import java.util.Optional; import java.util.Optional;
@ -18,28 +15,8 @@ public class StudentService {
@Autowired @Autowired
private StudentRepository studentRepository; private StudentRepository studentRepository;
@Autowired @Autowired
private DiplomaTopicRepository diplomaTopicRepository;
@Autowired
private Optional<Student> student;
@Autowired
private CallerService callerService; private CallerService callerService;
/** @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() { public Optional<Student> getCallerStudent() {
return studentRepository.findByUser(callerService.getCallerUser().orElse(null)); return studentRepository.findByUser(callerService.getCallerUser().orElse(null));
} }

View File

@ -36,17 +36,15 @@ public class UserService implements UserDetailsService {
@Override @Override
public User loadUserByUsername(String username) throws UsernameNotFoundException { public User loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("Loading user with username: {}", username); log.debug("Loading user with username: {}", username);
User user = userRepository.findUserByLogin(username).orElseThrow(() -> { User user = userRepository.findUserByLogin(username).orElseThrow(
log.info("User with login {} not found", username); () -> new UsernameNotFoundException("User with login " + username + " not found"));
return new UsernameNotFoundException("User with login " + username + " not found"); log.debug("User with login {} loaded", username);
});
log.info("User with login {} loaded", username);
return user; return user;
} }
public List<UserDTO> getAllUsers() { public List<UserDTO> getAllUsers() {
log.info("Loading all users"); log.debug("Loading all users");
List<UserDTO> users = userRepository.findAll().stream() List<UserDTO> users = userRepository.findAll().stream()
.map(UserDTO::from) .map(UserDTO::from)
.toList(); .toList();
@ -55,7 +53,7 @@ public class UserService implements UserDetailsService {
} }
public void registerUser(RegistrationDTO registrationDTO) { public void registerUser(RegistrationDTO registrationDTO) {
log.info("Registering new user with login: {}", registrationDTO.getLogin()); log.debug("Registering new user with login: {}", registrationDTO.getLogin());
User user = transientUser(registrationDTO); User user = transientUser(registrationDTO);
Student student = transientStudent(registrationDTO.getStudentData()); Student student = transientStudent(registrationDTO.getStudentData());
fillRoles(user, registrationDTO); fillRoles(user, registrationDTO);
@ -92,6 +90,7 @@ public class UserService implements UserDetailsService {
private void fillRoles(User user, RegistrationDTO registrationDTO) { private void fillRoles(User user, RegistrationDTO registrationDTO) {
List<Role> roles = new ArrayList<>(); List<Role> roles = new ArrayList<>();
if (registrationDTO.getStudentData() != null) { if (registrationDTO.getStudentData() != null) {
log.debug("User is student, adding role ROLE_STUDENT");
roles.add(roleService.getRoleByAuthority(RoleService.Authority.ROLE_STUDENT)); roles.add(roleService.getRoleByAuthority(RoleService.Authority.ROLE_STUDENT));
} }
user.setRoles(roles); user.setRoles(roles);

View File

@ -16,14 +16,13 @@ public class LoggingRequestFilter extends OncePerRequestFilter {
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
log.info("Making request: {}. user: {}, session: {}, remote ip: {}", log.info("Request received: {}. user: {}, session: {}, remote ip: {}",
request.getRequestURI(), request.getRemoteUser(), request.getRequestURI(), request.getRemoteUser(), request.getSession().getId(), request.getRemoteAddr());
request.getSession().getId(), request.getRemoteAddr());
try { try {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} finally { } finally {
long duration = System.currentTimeMillis() - startTime; long duration = System.currentTimeMillis() - startTime;
log.info("Request finished with {} status. duration: {} ms", response.getStatus(), duration); log.info("Response with {} status. duration: {} ms", response.getStatus(), duration);
} }
} }
} }

View File

@ -7,9 +7,9 @@ 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: http protocol: https
spring: spring:
application: application:

View File

@ -9,10 +9,13 @@ create table user_role
-- FOREIGN KEY -- FOREIGN KEY
alter table user_role alter table user_role
add constraint fk_user_role_user_id add constraint fk_user_role_user_id
foreign key (user_id) references "user" (id); foreign key (user_id) references "user" (id)
on delete cascade on update cascade;
alter table user_role alter table user_role
add constraint fk_user_role_role_id add constraint fk_user_role_role_id
foreign key (role_id) references role (id); foreign key (role_id) references role (id)
on delete restrict on update cascade;
-- COMMENTS -- COMMENTS
comment on table user_role is 'Таблица связи пользователей и ролей'; comment on table user_role is 'Таблица связи пользователей и ролей';

View File

@ -1,5 +1,5 @@
insert into "user" (id, login, password, full_name, email, number_phone, created_at) insert into "user" (id, login, password, full_name, email, number_phone, created_at)
values (1, 'admin', '{noop}admin', 'Администратор', 'admin@tdms.tu-byransk.ru', '', now()); values (1, 'admin', '{noop}Admin000', 'Администратор', 'admin@tdms.tu-byransk.ru', '', now());
insert into user_role (id, user_id, role_id) insert into user_role (id, user_id, role_id)
values (1, 1, 4); values (1, 1, 4);

View File

@ -9,4 +9,4 @@
["@babel/plugin-proposal-class-properties"], ["@babel/plugin-proposal-class-properties"],
["@babel/plugin-transform-typescript"] ["@babel/plugin-transform-typescript"]
] ]
} }

View File

@ -52,20 +52,26 @@ class NotificationPopup extends ComponentContext<{ notification: Notification, t
render() { render() {
const hasTitle = !!this.props.notification.title && this.props.notification.title.length > 0; const hasTitle = !!this.props.notification.title && this.props.notification.title.length > 0;
const closeIcon = <span className={'ms-2'}><FontAwesomeIcon icon={'close'} onClick={this.close}/></span>; const closeIcon = <span className={'ms-2'}><FontAwesomeIcon icon={'close'} onClick={this.close}/></span>;
const title = this.props.notification.title.split('\n').map((item, key) => <span key={key}>{item}<br/></span>);
const message = this.props.notification.message.split('\n').map((item, key) => <span key={key}>{item}<br/></span>);
return <Card className={`position-relative mt-3 opacity-75 ${this.cardClassName}`}> return <Card className={`position-relative mt-3 opacity-75 ${this.cardClassName}`}>
{ {
hasTitle && hasTitle &&
<CardHeader> <CardHeader>
<CardTitle className={'d-flex justify-content-between align-items-start'}> <CardTitle className={'d-flex justify-content-between align-items-start'}>
{this.props.notification.title} <span>
{title}
</span>
{closeIcon} {closeIcon}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
} }
<CardBody> <CardBody>
<CardText className={'d-flex justify-content-between align-items-start'}> <CardText className={'d-flex justify-content-between align-items-start'}>
{this.props.notification.message} <span>
{message}
</span>
{ {
!hasTitle && !hasTitle &&
closeIcon closeIcon

View File

@ -1,147 +1,292 @@
import {ComponentContext} from "../../utils/ComponentContext"; import {ComponentContext} from "../../utils/ComponentContext";
import {TableDescriptor} from "../../utils/tables"; import {TableDescriptor} from "../../utils/tables";
import {observer} from "mobx-react"; import {observer} from "mobx-react";
import {action, makeObservable} from "mobx"; import {action, computed, makeObservable, observable, runInAction} from "mobx";
import {FormSelect, Pagination, Table} from "react-bootstrap"; import {Button, ButtonGroup, FormSelect, FormText, Table} from "react-bootstrap";
import _ from "lodash";
import {ChangeEvent} from "react";
import {ModalState} from "../../utils/modalState";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
export interface DataTableProps<T> { export interface DataTableProps<T> {
tableDescriptor: TableDescriptor<T>; tableDescriptor: TableDescriptor<T>;
filterModalState?: ModalState;
name?: string;
headless?: boolean;
} }
@observer @observer
export class DataTable<T> extends ComponentContext<DataTableProps<T>> { export class DataTable<R> extends ComponentContext<DataTableProps<R>> {
constructor(props: DataTableProps<T>) { constructor(props: DataTableProps<R>) {
super(props); super(props);
makeObservable(this); makeObservable(this);
} }
header() { @observable descriptor = this.props.tableDescriptor;
return <tr> @observable name = this.props.name;
{this.props.tableDescriptor.columns.map(column => <th className={'text-center'} @observable headless = this.props.headless;
key={column.key}>{column.title}</th>)} @observable filterModalState = this.props.filterModalState;
</tr>
@computed
get isFirstPage() {
return this.descriptor.page === 0;
} }
body() { @computed
const firstColumnKey = this.props.tableDescriptor.columns[0].key; get isLastPage() {
return this.props.tableDescriptor.data.map(row => { return this.descriptor.page === Math.floor(this.descriptor.data.length / this.descriptor.pageSize)
const rowAny = row as any; || this.descriptor.pageSize === this.descriptor.data.length;
return <tr key={rowAny[firstColumnKey]}>
{
this.props.tableDescriptor.columns.map(column => {
return <td
className={'text-center'}
key={column.key}>
{column.format(rowAny[column.key])}
</td>
})
}
</tr>
});
}
isFirstPage() {
if (typeof this.props.tableDescriptor.page === 'undefined') {
return true;
}
return this.props.tableDescriptor.page === 0;
}
isLastPage() {
if (typeof this.props.tableDescriptor.page === 'undefined' || typeof this.props.tableDescriptor.pageSize === 'undefined') {
return true;
}
return this.props.tableDescriptor.page === (this.props.tableDescriptor.data.length / this.props.tableDescriptor.pageSize);
} }
@action.bound @action.bound
goFirstPage() { goFirstPage() {
if (typeof this.props.tableDescriptor.page === 'undefined') { this.descriptor.page = 0;
return;
}
this.props.tableDescriptor.page = 0;
} }
@action.bound @action.bound
goLastPage() { goLastPage() {
if (typeof this.props.tableDescriptor.page === 'undefined' || typeof this.props.tableDescriptor.pageSize === 'undefined') { this.descriptor.page = Math.floor(this.descriptor.data.length / this.descriptor.pageSize);
return;
}
this.props.tableDescriptor.page = this.props.tableDescriptor.data.length / this.props.tableDescriptor.pageSize;
} }
@action.bound @action.bound
goNextPage() { goNextPage() {
if (typeof this.props.tableDescriptor.page === 'undefined' || typeof this.props.tableDescriptor.pageSize === 'undefined') { this.descriptor.page++;
return;
}
this.props.tableDescriptor.page++;
} }
@action.bound @action.bound
goPrevPage() { goPrevPage() {
if (typeof this.props.tableDescriptor.page === 'undefined') { this.descriptor.page--;
return;
}
this.props.tableDescriptor.page--;
} }
@action.bound @action.bound
changePageSize(e: any) { changePageSize(e: ChangeEvent<HTMLSelectElement>) {
this.props.tableDescriptor.pageSize = parseInt(e.target.value); if (e.target.value === "\u221E") {
} this.descriptor.pageSize = this.descriptor.data.length;
return;
footer() {
const table = this.props.tableDescriptor;
if (typeof table.page === 'undefined' || typeof table.pageSize === 'undefined') {
return null;
} }
return <tr className={'text-center'}> this.descriptor.page = 0;
<td colSpan={table.columns.length}> this.descriptor.pageSize = _.toNumber(e.target.value);
<div className={'d-flex justify-content-between'}> }
<div/>
<Pagination className={'mb-0'}> // not computed, since we want to show initial data, when no sorts applied
<Pagination.First onClick={this.goFirstPage} disabled={this.isFirstPage()}/> get filteredData() {
<Pagination.Ellipsis disabled={this.isFirstPage()}/> const filters = this.descriptor.filters.filter(filter => filter);
<Pagination.Prev onClick={this.goPrevPage} disabled={this.isFirstPage()}/> return this.descriptor.data.filter(row => ((filters && filters.length) > 0 ? filters.every(filter => filter(row)) : true));
<Pagination.Item active>{this.props.tableDescriptor.page}</Pagination.Item>
<Pagination.Next onClick={this.goNextPage} disabled={!this.isLastPage()}/>
<Pagination.Ellipsis disabled={!this.isLastPage()}/>
<Pagination.Last onClick={this.goLastPage} disabled={!this.isLastPage()}/>
</Pagination>
<FormSelect className={'w-auto'} onChange={this.changePageSize}>
<option>10</option>
<option>20</option>
<option>50</option>
<option>100</option>
</FormSelect>
</div>
</td>
</tr>
} }
render() { render() {
const table = this.props.tableDescriptor; const style = {
return <Table hover striped> border: '1px solid #dee2e6',
<thead> borderTopLeftRadius: this.headless ? '0.25rem' : '0',
{this.header()} borderTopRightRadius: this.headless ? '0.25rem' : '0',
</thead> borderBottomLeftRadius: this.descriptor.pageable ? '0' : '0.25rem',
<tbody> borderBottomRightRadius: this.descriptor.pageable ? '0' : '0.25rem',
{this.body()} }
</tbody>
return <>
{ {
table.pageable && !this.headless &&
<tfoot> this.header
{this.footer()}
</tfoot>
} }
</Table> <div className={"overflow-auto table m-0"} style={style}>
<Table hover striped bordered className={'m-0'}>
<thead>
{this.tableHeader}
</thead>
<tbody>
{this.body}
</tbody>
</Table>
</div>
{
this.descriptor.pageable &&
this.footer
}
</>
}
@computed
get header() {
const style = {
borderTop: '1px solid #dee2e6',
borderTopLeftRadius: '0.25rem',
borderTopRightRadius: '0.25rem',
borderLeft: '1px solid #dee2e6',
borderRight: '1px solid #dee2e6',
}
return <div style={style}>
<div className={`d-flex ${this.name ? 'justify-content-between' : 'justify-content-end'} align-items-center ms-2 me-2`}>
<span className={'h3 text-uppercase fw-bold mb-0'}>{this.name}</span>
<FormText className={'mt-0'}>
{
this.descriptor.pageable &&
<div>{`Записей на странице: ${this.descriptor.pageSize} `}</div>
}
<div className={'text-end'}>
{
<>
<span>{`Всего записей: ${this.filteredData.length}`}</span>
<span>
{
this.descriptor.pageable &&
<>
{` (${this.descriptor.page + 1}/${Math.ceil(this.filteredData.length / this.descriptor.pageSize)})`}
</>
}
</span>
{
this.filterModalState &&
<div>
<Button variant={"outline-secondary"} size={'sm'} className={'pt-0 pb-0 ps-1 pe-1 mb-1'}
onClick={this.filterModalState.open}>
Фильтр
</Button>
</div>
}
</>
}
</div>
</FormText>
</div>
</div>
}
@computed
get tableHeader() {
return <>
<tr style={{borderTop: 'none'}}>
{
this.descriptor.columns.map((column, i) => {
const firstColumn = i === 0;
const lastColumn = i === this.descriptor.columns.length - 1;
const style = {
borderLeft: firstColumn ? 'none' : '1px solid var(--bs-table-border-color)',
borderRight: lastColumn ? 'none' : '1px solid var(--bs-table-border-color)',
};
return <th key={column.key} style={style}>
<div className={'d-flex align-items-center justify-content-center position-relative user-select-none'} style={{cursor: "pointer"}}
onClick={() => runInAction(() => {
const other = this.descriptor.columns
.filter(c => c.key !== column.key)
.map(c => c.sort);
column.sort.toggle(other);
})}>
<span style={{paddingLeft: '25px', paddingRight: '25px'}}>{column.title}</span>
<span style={{position: 'absolute', left: '100%', transform: 'translateX(-100%)'}}>
{
<div className={'d-flex justify-content-center align-items-center'} style={{
width: '25px', height: '15px', border: 'none', background: 'none',
fontSize: '10px', padding: '0', margin: '0',
}}>
{column.sort.apply ? column.sort.order === 'asc' ? '▲' : '▼' : '△▽'}
{
column.sort.apply &&
column.sort.applyOrder
}
</div>
}
</span>
</div>
</th>
}
)
}
</tr>
</>
}
@computed
get body() {
const firstColumnKey = this.descriptor.columns[0].key;
const sortingColumns = this.descriptor.columns.filter(column => column.sort.apply).sort((a, b) => a.sort.applyOrder - b.sort.applyOrder);
const masterComparator = (a: any /*row*/, b: any /*row*/) => {
for (const column of sortingColumns) {
const sortKey = column.key;
const result = column.sort.comparator(a[sortKey], b[sortKey]);
if (result !== 0) {
return column.sort.order === 'asc' ? result : -result;
}
}
return 0;
}
return this.filteredData
.sort(masterComparator)
.slice(this.descriptor.page * this.descriptor.pageSize, (this.descriptor.page + 1) * this.descriptor.pageSize)
.map((row, i) => {
const rowAny = row as any;
const lastRow = i === this.descriptor.pageSize - 1;
return <tr key={rowAny[firstColumnKey]} style={{borderBottom: lastRow ? 'none' : '1px solid var(--bs-table-border-color)'}}>
{
this.descriptor.columns.map(column => {
const firstColumn = column === this.descriptor.columns[0];
const lastColumn = column === this.descriptor.columns[this.descriptor.columns.length - 1];
const suffixElement = column.suffixElement ? column.suffixElement(row) : undefined;
const style = {
borderLeft: firstColumn ? 'none' : '1px solid var(--bs-table-border-color)',
borderRight: lastColumn ? 'none' : '1px solid var(--bs-table-border-color)',
borderBottom: lastRow ? 'none' : '1px solid var(--bs-table-border-color)',
}
return <td className={'text-center'} key={column.key}
style={style}>
<span>{column.format(rowAny[column.key], row)}</span>
{
suffixElement &&
<span className={'ms-2'}>{suffixElement}</span>
}
</td>
})
}
</tr>
});
}
@computed
get footer() {
const style = {
borderBottom: '1px solid #dee2e6',
borderBottomLeftRadius: '0.25rem',
borderBottomRightRadius: '0.25rem',
borderLeft: '1px solid #dee2e6',
borderRight: '1px solid #dee2e6',
width: '100%',
height: '40px',
}
const buttonSizeStyle = {
width: '30px',
height: '30px',
padding: '0',
}
return <div style={style} className={'position-relative'}>
<ButtonGroup style={{position: 'absolute', top: '5px', left: '50%', transform: 'translateX(-50%)'}}>
<Button onClick={this.goFirstPage} disabled={this.isFirstPage} style={buttonSizeStyle} variant={'outline-secondary'}>
<FontAwesomeIcon icon={'angle-double-left'}/>
</Button>
<Button onClick={this.goPrevPage} disabled={this.isFirstPage} style={buttonSizeStyle} variant={'outline-secondary'}>
<FontAwesomeIcon icon={'angle-left'}/>
</Button>
<Button disabled style={buttonSizeStyle} variant={'outline-secondary'}>{this.descriptor.page + 1}</Button>
<Button onClick={this.goNextPage} disabled={this.isLastPage} style={buttonSizeStyle} variant={'outline-secondary'}>
<FontAwesomeIcon icon={'angle-right'}/>
</Button>
<Button onClick={this.goLastPage} disabled={this.isLastPage} style={buttonSizeStyle} variant={'outline-secondary'}>
<FontAwesomeIcon icon={'angle-double-right'}/>
</Button>
</ButtonGroup>
<FormSelect className={'w-auto'} onChange={this.changePageSize} style={{position: 'absolute', top: '3px', right: 0, border: 'none'}}>
<option>10</option>
<option>20</option>
<option>50</option>
<option>100</option>
<option>&infin;</option>
</FormSelect>
</div>
} }
} }

View File

@ -2,7 +2,7 @@ import React from "react";
import {ReactiveValue} from "../../../utils/reactive/reactiveValue"; import {ReactiveValue} from "../../../utils/reactive/reactiveValue";
import {observer} from "mobx-react"; import {observer} from "mobx-react";
import {action, makeObservable, observable} from "mobx"; import {action, makeObservable, observable} from "mobx";
import {Button, ButtonGroup, FloatingLabel, FormControl, FormText, ToggleButton} from "react-bootstrap"; import {Button, FloatingLabel, FormControl, FormText} from "react-bootstrap";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import './ReactiveControls.css'; import './ReactiveControls.css';
@ -11,6 +11,7 @@ export interface ReactiveInputProps<T> {
label?: string; label?: string;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
validateless?: boolean;
} }
@observer @observer
@ -30,12 +31,13 @@ export class StringInput extends React.Component<ReactiveInputProps<string>> {
} }
render() { render() {
const inputClassName = `${this.props.validateless ? '' : this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`;
return <div className={'mb-1 l-no-bg'}> return <div className={'mb-1 l-no-bg'}>
{/*todo: disable background-color for label*/}
<FloatingLabel label={this.props.label} className={`${this.props.className} mt-0 mb-0`}> <FloatingLabel label={this.props.label} className={`${this.props.className} mt-0 mb-0`}>
<FormControl type='text' placeholder={this.props.label} disabled={this.props.disabled} <FormControl type='text' placeholder={this.props.label} disabled={this.props.disabled}
onChange={this.onChange} value={this.props.value.value} onChange={this.onChange} value={this.props.value.value}
className={`${this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`}/> className={inputClassName}/>
</FloatingLabel> </FloatingLabel>
<FormText children={this.props.value.firstError} className={`text-danger mt-0 mb-0 d-block`}/> <FormText children={this.props.value.firstError} className={`text-danger mt-0 mb-0 d-block`}/>
</div> </div>
@ -67,14 +69,15 @@ export class PasswordInput extends React.Component<ReactiveInputProps<string>> {
render() { render() {
return <div className={'mb-1 l-no-bg'}> return <div className={'mb-1 l-no-bg'}>
<div className={'d-flex justify-content-between align-items-center'}> <div className={'position-relative d-flex justify-content-between align-items-center'} style={{minHeight: '58px'}}>
<FloatingLabel label={this.props.label} className={`${this.props.className} w-100`}> <FloatingLabel label={this.props.label} className={`${this.props.className} w-100 position-absolute`}>
<FormControl type={`${this.showPassword ? 'text' : 'password'}`} placeholder={this.props.label} <FormControl type={`${this.showPassword ? 'text' : 'password'}`} placeholder={this.props.label}
disabled={this.props.disabled} disabled={this.props.disabled}
className={`${this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`} className={`${this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`}
onChange={this.onChange} value={this.props.value.value}/> onChange={this.onChange} value={this.props.value.value}/>
</FloatingLabel> </FloatingLabel>
<Button onClick={this.toggleShowPassword} variant={"outline-secondary"} className={'ms-2'}> <Button onClick={this.toggleShowPassword} variant={"outline-secondary"} className={'position-absolute rounded-pill'}
style={{width: '40px', height: '40px', left: '100%', transform: 'translateX(-120%)', padding: '0px'}}>
<FontAwesomeIcon icon={this.showPassword ? 'eye-slash' : 'eye'}/> <FontAwesomeIcon icon={this.showPassword ? 'eye-slash' : 'eye'}/>
</Button> </Button>
</div> </div>
@ -82,35 +85,3 @@ export class PasswordInput extends React.Component<ReactiveInputProps<string>> {
</div> </div>
} }
} }
@observer
export class SelectButtonInput extends React.Component<ReactiveInputProps<string>> {
constructor(props: any) {
super(props);
makeObservable(this);
if (this.props.value.value === undefined) {
this.props.value.setAuto('');
}
this.props.value.setField(this.props.label);
}
@action.bound
onChange(event: React.ChangeEvent<HTMLInputElement>) {
this.props.value.set(event.currentTarget.value);
}
render() {
return <>
<ButtonGroup className={'d-block l-no-bg'}>
<ToggleButton key={'admin'} value={'admin'} id={`radio-admin`} type="radio"
variant={'outline-primary'} children={'Администратор'}
checked={this.props.value.value === 'admin'} onChange={this.onChange}/>
<ToggleButton key={'student'} id={`radio-student`} type="radio" value={'student'}
variant={'outline-primary'}
checked={this.props.value.value === 'student'} onChange={this.onChange}
children={'Студент'}/>
</ButtonGroup>
<FormText children={this.props.value.firstError} className={'text-danger d-block'}/>
</>
}
}

View File

@ -1,8 +1,8 @@
import {observer} from "mobx-react"; import {observer} from "mobx-react";
import {DefaultPage} from "./DefaultPage"; import {Page} from "./Page";
@observer @observer
export default class Error extends DefaultPage { export default class Error extends Page {
get page() { get page() {
return <h1>Error</h1> return <h1>Error</h1>
} }

View File

@ -1,4 +1,4 @@
import {ComponentContext} from "../../utils/ComponentContext"; import {ComponentContext} from "../../../utils/ComponentContext";
import {observer} from "mobx-react"; import {observer} from "mobx-react";
import {makeObservable} from "mobx"; import {makeObservable} from "mobx";
import {Container, Nav, Navbar, NavbarText, NavLink} from "react-bootstrap"; import {Container, Nav, Navbar, NavbarText, NavLink} from "react-bootstrap";

View File

@ -1,22 +1,22 @@
import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap"; import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap";
import {RouterLink} from "mobx-state-router"; import {RouterLink} from "mobx-state-router";
import {IAuthenticated} from "../../models/user"; import {IAuthenticated} from "../../../models/user";
import {RootStoreContext, RootStoreContextType} from "../../store/RootStoreContext"; import {RootStoreContext, RootStoreContextType} from "../../../store/RootStoreContext";
import {observer} from "mobx-react"; import {observer} from "mobx-react";
import {post} from "../../utils/request"; import {post} from "../../../utils/request";
import {LoginModal} from "../user/LoginModal"; import {UserLoginModal} from "../../user/UserLoginModal";
import {ModalState} from "../../utils/modalState"; import {ModalState} from "../../../utils/modalState";
import {action, makeObservable, observable} from "mobx"; import {action, makeObservable, observable} from "mobx";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {ComponentContext} from "../../utils/ComponentContext"; import {ComponentContext} from "../../../utils/ComponentContext";
import {CreateGroupModal} from "../group/CreateGroupModal"; import {AddGroupModal} from "../../group/AddGroupModal";
import {UserRegistrationModal} from "../user/UserRegistrationModal"; import {UserRegistrationModal} from "../../user/UserRegistrationModal";
@observer @observer
class Header extends ComponentContext { class Header extends ComponentContext {
@observable loginModalState = new ModalState(); @observable loginModalState = new ModalState();
@observable createGroupModalState = new ModalState(); @observable addGroupModalState = new ModalState();
@observable userRegistrationModalState = new ModalState(); @observable userRegistrationModalState = new ModalState();
constructor(props: any) { constructor(props: any) {
@ -25,56 +25,50 @@ class Header extends ComponentContext {
} }
render() { render() {
const userStore = this.context.userStore; let userThink = this.thinkStore.isThinking('updateCurrentUser');
const user = userStore.user;
let thinking = this.thinkStore.isThinking('updateCurrentUser');
return <> return <>
<header> <header>
<Navbar className="bg-body-tertiary" fixed="top"> <Navbar className="bg-body-tertiary" fixed="top">
<Container> <Container>
<Navbar.Brand> <Navbar.Brand>
<Nav.Link as={RouterLink} routeName='root'>TDMS</Nav.Link> <Nav.Link as={RouterLink} routeName='home'>TDMS</Nav.Link>
</Navbar.Brand> </Navbar.Brand>
<Nav> <Nav>
{ {
user.authenticated && userStore.isAdministrator() && this.userStore.isAdministrator &&
<NavDropdown title="Пользователи"> <NavDropdown title="Пользователи">
<NavDropdown.Item as={RouterLink} routeName={'userList'} children={'Список'}/> <NavDropdown.Item as={RouterLink} routeName={'userList'} children={'Список'}/>
<NavDropdown.Item onClick={this.userRegistrationModalState.open} <NavDropdown.Item onClick={this.userRegistrationModalState.open} children={'Зарегистрировать'}/>
children={'Зарегистрировать'}/>
</NavDropdown>
}
{
user.authenticated && userStore.isAdministrator() &&
<NavDropdown title="Группы">
<NavDropdown.Item as={RouterLink} routeName={'groupList'} children={'Список'}/>
<NavDropdown.Item onClick={this.createGroupModalState.open} children={'Добавить'}/>
</NavDropdown> </NavDropdown>
} }
<NavDropdown title="Группы">
<NavDropdown.Item as={RouterLink} routeName={'groupList'} children={'Список'}/>
{
this.userStore.isAdministrator &&
<NavDropdown.Item onClick={this.addGroupModalState.open} children={'Добавить'}/>
}
</NavDropdown>
</Nav> </Nav>
<Nav className="ms-auto"> <Nav className="ms-auto">
{ {
thinking && userThink &&
<FontAwesomeIcon icon='gear' spin/> <FontAwesomeIcon icon='gear' spin/>
} }
{ {
user.authenticated && !thinking && this.userStore.authenticated && !userThink &&
<AuthenticatedItems/> <AuthenticatedItems/>
} }
{ {
!user.authenticated && !thinking && !this.userStore.authenticated && !userThink &&
<> <Nav.Link onClick={this.loginModalState.open}>Войти</Nav.Link>
<Nav.Link onClick={this.loginModalState.open}>Войти</Nav.Link>
</>
} }
</Nav> </Nav>
</Container> </Container>
</Navbar> </Navbar>
</header> </header>
<LoginModal modalState={this.loginModalState}/> <UserLoginModal modalState={this.loginModalState}/>
<CreateGroupModal modalState={this.createGroupModalState}/> <AddGroupModal modalState={this.addGroupModalState}/>
<UserRegistrationModal modalState={this.userRegistrationModalState}/> <UserRegistrationModal modalState={this.userRegistrationModalState}/>
</> </>
} }
@ -92,17 +86,17 @@ class AuthenticatedItems extends ComponentContext<any, any> {
@action.bound @action.bound
logout() { logout() {
post('user/logout').then(() => this.context.userStore.updateCurrentUser()); post('user/logout').then(() => {
this.context.userStore.updateCurrentUser();
this.routerStore.goTo('home').then();
this.notificationStore.success('Вы успешно вышли из системы', 'Выход');
});
} }
render() { render() {
const userStore = this.context.userStore;
const user = userStore.user;
return <> return <>
<Navbar.Text>Пользователь:</Navbar.Text> <Navbar.Text>Пользователь:</Navbar.Text>
<NavDropdown <NavDropdown title={(this.userStore.user as IAuthenticated).fullName}>
title={(user as IAuthenticated).fullName}>
<NavDropdown.Item as={RouterLink} routeName='profile'>Моя страница</NavDropdown.Item> <NavDropdown.Item as={RouterLink} routeName='profile'>Моя страница</NavDropdown.Item>
<NavDropdown.Divider/> <NavDropdown.Divider/>
<NavDropdown.Item onClick={this.logout}>Выйти</NavDropdown.Item> <NavDropdown.Item onClick={this.logout}>Выйти</NavDropdown.Item>

View File

@ -1,8 +1,8 @@
import {DefaultPage} from "./DefaultPage"; import {Page} from "./Page";
import {observer} from "mobx-react"; import {observer} from "mobx-react";
@observer @observer
export default class Home extends DefaultPage { export default class Home extends Page {
get page() { get page() {
return <h1>Home</h1> return <h1>Home</h1>
} }

View File

@ -2,11 +2,11 @@ import {ReactNode} from "react";
import {Container} from "react-bootstrap"; import {Container} from "react-bootstrap";
import Header from "./Header"; import Header from "./Header";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {ComponentContext} from "../../utils/ComponentContext"; import {ComponentContext} from "../../../utils/ComponentContext";
import {NotificationContainer} from "../NotificationContainer"; import {NotificationContainer} from "../../NotificationContainer";
import {Footer} from "./Footer"; import {Footer} from "./Footer";
export abstract class DefaultPage extends ComponentContext { export abstract class Page extends ComponentContext {
get page(): ReactNode { get page(): ReactNode {
throw new Error('This is not abstract method, ' + throw new Error('This is not abstract method, ' +
'because mobx cant handle abstract methods. ' + 'because mobx cant handle abstract methods. ' +

View File

@ -13,20 +13,20 @@ export interface CreateGroupModalProps {
} }
@observer @observer
export class CreateGroupModal extends ComponentContext<CreateGroupModalProps> { export class AddGroupModal extends ComponentContext<CreateGroupModalProps> {
@observable name = new ReactiveValue<string>()
.addValidator(required)
.addValidator(strLength(3, 50))
.addValidator(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры"));
constructor(props: any) { constructor(props: any) {
super(props); super(props);
makeObservable(this); makeObservable(this);
} }
@observable name = new ReactiveValue<string>()
.addValidator(required)
.addValidator(strLength(3, 50))
.addInputRestriction(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, цифры и символы _-"));
@action.bound @action.bound
creationRequest() { creationRequest() {
post<void>('/group/create-group', {name: this.name.value}).then(() => { post<void>('/group/create-group', {name: this.name.value}, false).then(() => {
this.notificationStore.success(`Группа ${this.name.value} создана`); this.notificationStore.success(`Группа ${this.name.value} создана`);
}).finally(() => { }).finally(() => {
this.props.modalState.close(); this.props.modalState.close();
@ -41,14 +41,14 @@ export class CreateGroupModal extends ComponentContext<CreateGroupModalProps> {
render() { render() {
return <Modal show={this.props.modalState.isOpen}> return <Modal show={this.props.modalState.isOpen}>
<ModalHeader> <ModalHeader>
<ModalTitle>Создание группы</ModalTitle> <ModalTitle>Добавление группы</ModalTitle>
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<StringInput value={this.name} label={'Имя группы'}/> <StringInput value={this.name} label={'Имя группы'}/>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button onClick={this.creationRequest} disabled={this.formInvalid}>Создать</Button> <Button onClick={this.creationRequest} disabled={this.formInvalid}>Создать</Button>
<Button onClick={this.props.modalState.close}>Закрыть</Button> <Button onClick={this.props.modalState.close} variant={'secondary'}>Закрыть</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
} }

View File

@ -0,0 +1,203 @@
import {observer} from "mobx-react";
import {Page} from "../custom/layout/Page";
import {action, computed, makeObservable, observable, reaction, runInAction} from "mobx";
import {Column, TableDescriptor} from "../../utils/tables";
import {get} from "../../utils/request";
import {DataTable} from "../custom/DataTable";
import {Group} from "../../models/group";
import {Component} from "react";
import {Button, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle} from "react-bootstrap";
import {StringInput} from "../custom/controls/ReactiveControls";
import {ReactiveValue} from "../../utils/reactive/reactiveValue";
import {ModalState} from "../../utils/modalState";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {ComponentContext} from "../../utils/ComponentContext";
import {required, strLength, strPattern} from "../../utils/reactive/validators";
@observer
export class GroupListPage extends Page {
constructor(props: {}) {
super(props);
makeObservable(this);
reaction(() => this.groups, () => {
this.tableDescriptor = new TableDescriptor<Group>(this.groupColumns, this.groups);
});
}
componentDidMount() {
this.requestGroups();
}
componentDidUpdate() {
runInAction(() => {
this.isAdministrator = this.userStore.isAdministrator;
});
}
@observable filterModalState = new ModalState();
@observable editModalState = new ModalState();
@observable currentGroup: Group;
@observable groups: Group[];
@observable tableDescriptor: TableDescriptor<Group>;
@observable isAdministrator: boolean;
groupColumns = [
new Column<Group, string>('name', 'Название', x => x, (grp) => {
return this.isAdministrator && <FontAwesomeIcon style={{cursor: 'pointer'}} onClick={() => {
this.openEditModal(grp)
}} icon={'pen-to-square'}/>
}
),
new Column<Group, string>('curatorName', 'Куратор', (value: string) => value ?? 'Не назначен'),
];
@action.bound
requestGroups() {
this.thinkStore.think();
get<Group[]>('/group/get-all-groups').then(groups => {
runInAction(() => {
this.groups = groups;
});
}).finally(() => {
this.thinkStore.completeOne();
});
}
@action.bound
openEditModal(group: Group) {
runInAction(() => {
this.currentGroup = group;
this.editModalState.open();
});
}
get page() {
return <>
{
this.tableDescriptor &&
<>
<DataTable tableDescriptor={this.tableDescriptor} name={'Группы'} filterModalState={this.filterModalState}/>
<GroupListFilterModal modalState={this.filterModalState} filters={this.tableDescriptor.filters}/>
{
this.currentGroup &&
<EditGroupModal modalState={this.editModalState} group={this.currentGroup}/>
}
</>
}
</>
}
}
interface GroupListFilterProps {
modalState: ModalState;
filters: ((group: Group) => boolean)[];
}
@observer
class GroupListFilterModal extends Component<GroupListFilterProps> {
constructor(props: GroupListFilterProps) {
super(props);
makeObservable(this);
runInAction(() => {
this.filters.push(this.nameFilter);
this.filters.push(this.curatorFilter);
});
}
@observable filters = this.props.filters;
@observable modalState = this.props.modalState;
@observable nameField = new ReactiveValue<string>().syncWithParam('name');
@observable curatorField = new ReactiveValue<string>().syncWithParam('curator');
@observable nameFilter = (group: Group) => {
if (!this.nameField.value) return true;
return group.name?.includes(this.nameField.value)
};
@observable curatorFilter = (group: Group) => {
if (!this.curatorField.value) return true;
return group.curatorName?.includes(this.curatorField.value)
};
@action.bound
reset() {
this.nameField.set("");
this.curatorField.set("");
}
render() {
return <Modal show={this.modalState.isOpen} centered>
<ModalHeader>
<ModalTitle>Фильтр</ModalTitle>
</ModalHeader>
<ModalBody>
<StringInput value={this.nameField} label={'Название'} validateless/>
<StringInput value={this.curatorField} label={'Куратор'} validateless/>
</ModalBody>
<ModalFooter>
<Button onClick={this.reset} variant={'secondary'}>Сбросить</Button>
<Button onClick={this.modalState.close} variant={'outline-secondary'}>Закрыть</Button>
</ModalFooter>
</Modal>
}
}
interface EditGroupModalProps {
modalState: ModalState;
group: Group
}
@observer
class EditGroupModal extends ComponentContext<EditGroupModalProps> {
constructor(props: EditGroupModalProps) {
super(props);
makeObservable(this);
}
componentDidUpdate() {
runInAction(() => {
this.group = this.props.group;
this.nameField.setAuto(this.group.name);
this.curatorField.setAuto(this.group.curatorName ?? '');
});
}
@observable group = this.props.group;
@observable modalState = this.props.modalState;
@observable nameField = new ReactiveValue<string>().setAuto(this.group.name)
.addValidator(required)
.addValidator(strLength(3, 50))
.addInputRestriction(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, цифры и символы _-"));
@observable curatorField = new ReactiveValue<string>().setAuto(this.group.curatorName ?? '');
@action.bound
save() {
this.notificationStore.warn('Приносим извинения за неудобства', 'Сохранение не реализовано');
this.modalState.close();
}
@computed
get formInvalid() {
return this.nameField.invalid || !this.nameField.touched
|| this.curatorField.invalid || !this.curatorField.touched;
}
render() {
return <Modal show={this.modalState.isOpen} centered>
<ModalHeader>
<ModalTitle>Редактирование группы {this.group.name}</ModalTitle>
</ModalHeader>
<ModalBody>
<StringInput value={this.nameField} label={'Название'}/>
<StringInput value={this.curatorField} label={'Куратор'}/>
</ModalBody>
<ModalFooter>
<Button onClick={this.save} variant={'primary'} disabled={this.formInvalid}>Сохранить</Button>
<Button onClick={this.modalState.close} variant={'outline-secondary'}>Закрыть</Button>
</ModalFooter>
</Modal>
}
}

View File

@ -1,142 +0,0 @@
import {ChangeEvent} from "react";
import {Button, FormControl, FormGroup, FormLabel, FormText, Modal} from "react-bootstrap";
import {ModalState} from "../../utils/modalState";
import {observer} from "mobx-react";
import {action, computed, makeObservable, observable, reaction} from "mobx";
import {post} from "../../utils/request";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {ComponentContext} from "../../utils/ComponentContext";
interface LoginModalProps {
modalState: ModalState;
}
@observer
export class LoginModal extends ComponentContext<LoginModalProps> {
@observable login = '';
@observable loginError = '';
@observable password = '';
@observable passwordError = '';
constructor(props: LoginModalProps) {
super(props);
makeObservable(this);
reaction(() => this.login, this.validateLogin);
reaction(() => this.password, this.validatePassword);
}
@action.bound
validateLogin() {
if (!this.login) {
this.loginError = 'Имя пользователя не может быть пустым';
} else if (this.login.length < 5) {
this.loginError = 'Имя пользователя должно быть не менее 5 символов';
} else if (this.login.length > 50) {
this.loginError = 'Имя пользователя должно быть не более 50 символов';
} else if (!/^[a-zA-Z0-9_]+$/.test(this.login)) {
this.loginError = 'Имя пользователя должно содержать только латинские буквы, цифры и знак подчеркивания';
} else {
this.loginError = '';
}
}
@action.bound
validatePassword() {
if (!this.password) {
this.passwordError = 'Пароль не может быть пустым';
} else if (this.password.length < 5) {
this.passwordError = 'Пароль должен быть не менее 5 символов';
} else if (this.password.length > 32) {
this.passwordError = 'Пароль должен быть не более 32 символов';
} else if (!/^[a-zA-Z0-9!@#$%^&*()_+]+$/.test(this.password)) {
this.passwordError = 'Пароль должен содержать только латинские буквы, цифры и специальные символы';
} else {
this.passwordError = '';
}
}
@computed
get loginButtonDisabled() {
return !this.login || !this.password || !!this.loginError || !!this.passwordError;
}
@action.bound
onLoginInput(event: ChangeEvent<HTMLInputElement>) {
this.login = event.target.value;
}
@action.bound
onPasswordInput(event: ChangeEvent<HTMLInputElement>) {
this.password = event.target.value;
}
@action.bound
tryLogin() {
if (this.loginButtonDisabled)
return;
this.thinkStore.think('loginModal');
post('user/login', {
username: this.login,
password: this.password
}).then(() => {
this.userStore.updateCurrentUser((user) => {
if (user.authenticated) {
this.routerStore.goTo('profile').then();
this.notificationStore.success('Вы успешно вошли в систему, ' + user.fullName, 'Успешный вход');
} else {
this.routerStore.goTo('root').then();
this.notificationStore.error('Произошла ошибка при попытке входа в систему', 'Ошибка входа');
}
});
}).finally(() => {
this.props.modalState.close();
this.thinkStore.completeAll('loginModal');
});
}
render() {
const open = this.props.modalState.isOpen;
const thinking = this.thinkStore.isThinking('loginModal');
return <Modal show={open} centered>
<Modal.Header>
<Modal.Title>Вход</Modal.Title>
</Modal.Header>
{
thinking &&
<Modal.Body>
<FontAwesomeIcon icon={'gear'} spin/>
</Modal.Body>
}
{
!thinking &&
<>
<Modal.Body>
<FormGroup className={'mb-3'}>
<FormLabel>Имя пользователя</FormLabel>
<FormControl type="text" onChange={this.onLoginInput}/>
{
this.loginError &&
<FormText className={'text-danger'}>{this.loginError}</FormText>
}
</FormGroup>
<FormGroup className={'mb-3'}>
<FormLabel>Пароль</FormLabel>
<FormControl type="password" onChange={this.onPasswordInput}/>
{
this.passwordError &&
<FormText className={'text-danger'}>{this.passwordError}</FormText>
}
</FormGroup>
</Modal.Body>
<Modal.Footer>
<Button variant={'primary'} onClick={this.tryLogin}
disabled={this.loginButtonDisabled}>Войти</Button>
<Button variant={'secondary'} onClick={this.props.modalState.close}>Закрыть</Button>
</Modal.Footer>
</>
}
</Modal>
}
}

View File

@ -1,13 +1,13 @@
import {observer} from "mobx-react"; import {observer} from "mobx-react";
import {action, makeObservable, observable, reaction, runInAction} from "mobx"; import {action, makeObservable, observable, reaction, runInAction} from "mobx";
import {DefaultPage} from "../layout/DefaultPage"; import {Page} from "../custom/layout/Page";
import {IAuthenticated} from "../../models/user"; import {IAuthenticated} from "../../models/user";
import {DataTable} from "../custom/DataTable"; import {DataTable} from "../custom/DataTable";
import {get} from "../../utils/request"; import {get} from "../../utils/request";
import {Column, TableDescriptor} from "../../utils/tables"; import {Column, TableDescriptor} from "../../utils/tables";
@observer @observer
export class UserList extends DefaultPage { export class UserListPage extends Page {
constructor(props: {}) { constructor(props: {}) {
super(props); super(props);
makeObservable(this); makeObservable(this);
@ -53,7 +53,7 @@ export class UserList extends DefaultPage {
return <> return <>
{ {
this.tableDescriptor && this.tableDescriptor &&
<DataTable tableDescriptor={this.tableDescriptor}/> <DataTable tableDescriptor={this.tableDescriptor} name={'Пользователи'}/>
} }
</> </>
} }

View File

@ -0,0 +1,92 @@
import {Button, Modal} from "react-bootstrap";
import {ModalState} from "../../utils/modalState";
import {observer} from "mobx-react";
import {action, computed, makeObservable, observable} from "mobx";
import {post} from "../../utils/request";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {ComponentContext} from "../../utils/ComponentContext";
import {ReactiveValue} from "../../utils/reactive/reactiveValue";
import {password, passwordChars, passwordLength, passwordMaxLength, required} from "../../utils/reactive/validators";
import {PasswordInput, StringInput} from "../custom/controls/ReactiveControls";
interface LoginModalProps {
modalState: ModalState;
}
@observer
export class UserLoginModal extends ComponentContext<LoginModalProps> {
constructor(props: LoginModalProps) {
super(props);
makeObservable(this);
}
@observable login = new ReactiveValue<string>()
// .addValidator(required).addValidator(loginLength)
// .addInputRestriction(loginChars).addInputRestriction(loginMaxLength);
@observable password = new ReactiveValue<string>()
.addValidator(required).addValidator(password).addValidator(passwordLength)
.addInputRestriction(passwordChars).addInputRestriction(passwordMaxLength);
@computed
get modalInvalid() {
return this.login.invalid || !this.login.touched
|| this.password.invalid || !this.password.touched;
}
@action.bound
tryLogin() {
if (this.modalInvalid)
return;
this.thinkStore.think('loginModal');
post('user/login', {
login: this.login.value,
password: this.password.value
}).then(() => {
this.userStore.updateCurrentUser((user) => {
if (user.authenticated) {
this.routerStore.goTo('profile').then();
this.notificationStore.success('Вы успешно вошли в систему, ' + user.fullName, 'Успешный вход');
} else {
this.routerStore.goTo('root').then();
this.notificationStore.error('Произошла ошибка при попытке входа в систему', 'Ошибка входа');
}
});
}).finally(() => {
this.props.modalState.close();
this.thinkStore.completeAll('loginModal');
});
}
render() {
const open = this.props.modalState.isOpen;
const thinking = this.thinkStore.isThinking('loginModal');
return <Modal show={open} centered>
<Modal.Header>
<Modal.Title>Вход</Modal.Title>
</Modal.Header>
{
thinking &&
<Modal.Body>
<div className={'text-center'}>
<FontAwesomeIcon icon={'gear'} spin size={'4x'}/>
</div>
</Modal.Body>
}
{
!thinking &&
<>
<Modal.Body>
<StringInput value={this.login} label={'Логин'}/>
<PasswordInput value={this.password} label={'Пароль'}/>
</Modal.Body>
<Modal.Footer>
<Button variant={'primary'} onClick={this.tryLogin} disabled={this.modalInvalid}>Войти</Button>
<Button variant={'secondary'} onClick={this.props.modalState.close}>Закрыть</Button>
</Modal.Footer>
</>
}
</Modal>
}
}

View File

@ -1,4 +1,4 @@
import {DefaultPage} from "../layout/DefaultPage"; import {Page} from "../custom/layout/Page";
import {Col, Form, Row} from "react-bootstrap"; import {Col, Form, Row} from "react-bootstrap";
import {observer} from "mobx-react"; import {observer} from "mobx-react";
import {RootStoreContext, type RootStoreContextType} from "../../store/RootStoreContext"; import {RootStoreContext, type RootStoreContextType} from "../../store/RootStoreContext";
@ -151,7 +151,7 @@ class StudentInfo extends Component<{student: IStudent}> {
} }
} }
export default class UserProfilePage extends DefaultPage { export default class UserProfilePage extends Page {
declare context: RootStoreContextType; declare context: RootStoreContextType;
static contextType = RootStoreContext; static contextType = RootStoreContext;

View File

@ -4,20 +4,26 @@ import {Button, Col, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle, Row
import {UserRegistrationDTO} from "../../models/registration"; import {UserRegistrationDTO} from "../../models/registration";
import {post} from "../../utils/request"; import {post} from "../../utils/request";
import {ReactiveValue} from "../../utils/reactive/reactiveValue"; import {ReactiveValue} from "../../utils/reactive/reactiveValue";
import {PasswordInput, SelectButtonInput, StringInput} from "../custom/controls/ReactiveControls"; import {PasswordInput, StringInput} from "../custom/controls/ReactiveControls";
import { import {
email, email,
emailChars,
loginChars, loginChars,
loginLength, loginLength,
loginMaxLength,
nameChars, nameChars,
nameLength, nameLength,
password,
passwordChars, passwordChars,
passwordLength, passwordLength,
passwordMaxLength,
phone, phone,
phoneChars,
required required
} from "../../utils/reactive/validators"; } from "../../utils/reactive/validators";
import {ComponentContext} from "../../utils/ComponentContext"; import {ComponentContext} from "../../utils/ComponentContext";
import {ModalState} from "../../utils/modalState"; import {ModalState} from "../../utils/modalState";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
export interface UserRegistrationModalProps { export interface UserRegistrationModalProps {
modalState: ModalState; modalState: ModalState;
@ -30,11 +36,21 @@ export class UserRegistrationModal extends ComponentContext<UserRegistrationModa
makeObservable(this); makeObservable(this);
} }
@observable login = new ReactiveValue<string>().addValidator(required).addValidator(loginLength).addValidator(loginChars); @observable login = new ReactiveValue<string>()
@observable password = new ReactiveValue<string>().addValidator(required).addValidator(passwordLength).addValidator(passwordChars); .addValidator(required).addValidator(loginLength)
@observable fullName = new ReactiveValue<string>().addValidator(required).addValidator(nameLength).addValidator(nameChars); .addInputRestriction(loginChars).addInputRestriction(loginMaxLength);
@observable email = new ReactiveValue<string>().addValidator(required).addValidator(email); @observable password = new ReactiveValue<string>()
@observable numberPhone = new ReactiveValue<string>().addValidator(required).addValidator(phone).setAuto('+7'); .addValidator(required).addValidator(password).addValidator(passwordLength)
.addInputRestriction(passwordChars).addInputRestriction(passwordMaxLength);
@observable fullName = new ReactiveValue<string>()
.addValidator(required).addValidator(nameLength)
.addInputRestriction(nameChars);
@observable email = new ReactiveValue<string>()
.addValidator(required).addValidator(email)
.addInputRestriction(emailChars);
@observable numberPhone = new ReactiveValue<string>().setAuto('+7')
.addValidator(required).addValidator(phone)
.addInputRestriction(phoneChars);
@observable accountType = new ReactiveValue<string>().addValidator(required).addValidator((value) => { @observable accountType = new ReactiveValue<string>().addValidator(required).addValidator((value) => {
if (!['student', 'admin'].includes(value)) { if (!['student', 'admin'].includes(value)) {
@ -49,12 +65,17 @@ export class UserRegistrationModal extends ComponentContext<UserRegistrationModa
|| this.fullName.invalid || !this.fullName.touched || this.fullName.invalid || !this.fullName.touched
|| this.email.invalid || !this.email.touched || this.email.invalid || !this.email.touched
|| this.numberPhone.invalid || !this.numberPhone.touched || this.numberPhone.invalid || !this.numberPhone.touched
|| this.accountType.invalid || !this.accountType.touched; || this.accountType.invalid || !this.accountType.touched
|| this.thinkStore.isThinking('userRegistration');
} }
@action.bound @action.bound
submit() { submit() {
post('user/register', { if (this.formInvalid)
return;
this.thinkStore.think('userRegistration');
post<void>('user/register', {
login: this.login.value, login: this.login.value,
password: this.password.value, password: this.password.value,
fullName: this.fullName.value, fullName: this.fullName.value,
@ -65,29 +86,43 @@ export class UserRegistrationModal extends ComponentContext<UserRegistrationModa
this.notificationStore.success('Пользователь успешно зарегистрирован'); this.notificationStore.success('Пользователь успешно зарегистрирован');
}).catch(() => { }).catch(() => {
this.notificationStore.error('Ошибка регистрации пользователя'); this.notificationStore.error('Ошибка регистрации пользователя');
}).finally(() => {
this.thinkStore.completeOne('userRegistration');
this.props.modalState.close();
}); });
} }
render() { render() {
return <Modal show={this.props.modalState.isOpen} size={'lg'}> const thinking = this.thinkStore.isThinking('userRegistration');
return <Modal show={this.props.modalState.isOpen} size={'lg'} centered>
<ModalHeader> <ModalHeader>
<ModalTitle>Регистрация пользователя</ModalTitle> <ModalTitle>Регистрация пользователя</ModalTitle>
</ModalHeader> </ModalHeader>
{
<ModalBody> thinking &&
<Row> <Modal.Body>
<Col> <div className={'text-center'}>
<StringInput value={this.login} label={"Логин"}/> <FontAwesomeIcon icon={'gear'} spin size={'4x'}/>
<PasswordInput value={this.password} label={"Пароль"}/> </div>
</Col> </Modal.Body>
<Col> }
<StringInput value={this.fullName} label={"Полное имя"}/> {
<StringInput value={this.email} label={"Почта"}/> !thinking &&
<StringInput value={this.numberPhone} label={"Телефон"}/> <ModalBody>
</Col> <Row>
</Row> <Col>
<SelectButtonInput value={this.accountType} label={'Тип аккаунта'}/> <StringInput value={this.login} label={"Логин"}/>
</ModalBody> <PasswordInput value={this.password} label={"Пароль"}/>
</Col>
<Col>
<StringInput value={this.fullName} label={"Полное имя"}/>
<StringInput value={this.email} label={"Почта"}/>
<StringInput value={this.numberPhone} label={"Телефон"}/>
</Col>
</Row>
</ModalBody>
}
<ModalFooter> <ModalFooter>
<Button disabled={this.formInvalid} onClick={this.submit}>Зарегистрировать</Button> <Button disabled={this.formInvalid} onClick={this.submit}>Зарегистрировать</Button>
<Button onClick={this.props.modalState.close} variant={'secondary'}>Закрыть</Button> <Button onClick={this.props.modalState.close} variant={'secondary'}>Закрыть</Button>

5
web/src/models/group.ts Normal file
View File

@ -0,0 +1,5 @@
export interface Group {
name: string;
curatorName?: string;
iAmCurator?: boolean;
}

View File

@ -1,7 +1,7 @@
import {Route} from "mobx-state-router"; import {Route} from "mobx-state-router";
export const routes: Route[] = [{ export const routes: Route[] = [{
name: 'root', name: 'home',
pattern: '/', pattern: '/',
}, { }, {
name: 'profile', name: 'profile',
@ -9,6 +9,9 @@ export const routes: Route[] = [{
}, { }, {
name: 'userList', name: 'userList',
pattern: '/users', pattern: '/users',
}, {
name: 'groupList',
pattern: '/groups',
}, { }, {
name: 'error', name: 'error',
pattern: '/error', pattern: '/error',

View File

@ -1,12 +1,14 @@
import {ViewMap} from "mobx-state-router"; import {ViewMap} from "mobx-state-router";
import Home from "../components/layout/Home"; import Home from "../components/custom/layout/Home";
import Error from "../components/layout/Error"; import ErrorPage from "../components/custom/layout/Error";
import UserProfilePage from "../components/user/UserProfilePage"; import UserProfilePage from "../components/user/UserProfilePage";
import {UserList} from "../components/user/UserList"; import {UserListPage} from "../components/user/UserListPage";
import {GroupListPage} from "../components/group/GroupListPage";
export const viewMap: ViewMap = { export const viewMap: ViewMap = {
root: <Home/>, home: <Home/>,
profile: <UserProfilePage/>, profile: <UserProfilePage/>,
userList: <UserList/>, userList: <UserListPage/>,
error: <Error/>, groupList: <GroupListPage/>,
error: <ErrorPage/>,
} }

View File

@ -16,12 +16,12 @@ export class ThinkStore {
} }
@action.bound @action.bound
completeOne(key: string) { completeOne(key: string = '$default') {
this.thinks.splice(this.thinks.indexOf(key), 1); this.thinks.splice(this.thinks.indexOf(key), 1);
} }
@action.bound @action.bound
completeAll(key: string) { completeAll(key: string = '$default') {
this.thinks = this.thinks.filter(k => k !== key); this.thinks = this.thinks.filter(k => k !== key);
} }

View File

@ -1,5 +1,5 @@
import {get} from "../utils/request"; import {get} from "../utils/request";
import {action, makeObservable, observable, runInAction} from "mobx"; import {action, computed, makeObservable, observable, runInAction} from "mobx";
import {RootStore} from "./RootStore"; import {RootStore} from "./RootStore";
import type {IUser} from "../models/user"; import type {IUser} from "../models/user";
import {IStudent} from "../models/student"; import {IStudent} from "../models/student";
@ -39,10 +39,16 @@ export class UserStore {
}); });
} }
isAdministrator() { @computed
get isAdministrator() {
return this.user.authenticated && this.user.authorities.some(a => a.authority === Role.ADMINISTRATOR); return this.user.authenticated && this.user.authorities.some(a => a.authority === Role.ADMINISTRATOR);
} }
@computed
get authenticated() {
return this.user.authenticated
}
init() { init() {
this.updateCurrentUser(); this.updateCurrentUser();
console.debug('UserStore initialized'); console.debug('UserStore initialized');

View File

@ -0,0 +1 @@
export const defaultComparator = (a: any, b: any) => a > b ? 1 : a < b ? -1 : 0;

View File

@ -1,20 +1,26 @@
import {action, computed, makeObservable, observable, reaction} from "mobx"; import {action, computed, makeObservable, observable, reaction} from "mobx";
import _ from "lodash";
export class ReactiveValue<T> { export class ReactiveValue<T> {
@observable private val: T; @observable private val: T;
@observable private isTouched: boolean = false; @observable private isTouched: boolean = false;
@observable private validators: ((value: T, field: string) => string)[] = []; @observable private validators: ((value: T, field: string) => string)[] = [];
@observable private inputRestrictions: ((value: T, field: string) => string)[] = [];
@observable private errors: string[] = []; @observable private errors: string[] = [];
@observable private inputRestrictionError: string;
@observable private fieldName: string; @observable private fieldName: string;
@observable private syncWithUrlParameter?: string;
constructor(fireImmediately: boolean = false) { constructor(fireImmediately: boolean = false) {
makeObservable(this); makeObservable(this);
reaction(() => ({value: this.val, touched: this.isTouched}), () => { reaction(() => ({value: this.val, touched: this.isTouched}), () => {
if (!this.isTouched) { if (!this.isTouched) {
this.errors = []; this.errors = [];
this.inputRestrictionError = undefined;
return; return;
} }
this.errors = this.validators this.errors = this.validators
.map(validator => validator(this.val, this.fieldName)) .map(validator => validator(this.val, this.fieldName))
.filter(error => error && error.length > 0); .filter(error => error && error.length > 0);
@ -28,14 +34,47 @@ export class ReactiveValue<T> {
@action.bound @action.bound
set(value: T) { set(value: T) {
this.val = value;
this.isTouched = true; this.isTouched = true;
this.inputRestrictionError = undefined;
if (!_.isEmpty(value)) {
for (const restriction of this.inputRestrictions) {
const error = restriction(value, this.fieldName);
if (error) {
this.inputRestrictionError = error;
}
}
}
const lengthIsLessIfStringOrTrue = _.isString(value) && _.isString(this.val) ? value.length < this.val?.length : true;
if (!this.inputRestrictionError || lengthIsLessIfStringOrTrue) {
this.setAuto(value);
}
return this; return this;
} }
@action.bound @action.bound
setAuto(value: T) { setAuto(value: T) {
this.val = value; this.val = value;
if (this.syncWithUrlParameter) {
const url = new URL(window.location.href);
url.searchParams.set(this.syncWithUrlParameter, JSON.stringify(value));
window.history.pushState({}, '', url.toString());
}
return this;
}
@action.bound
syncWithParam(param: string) {
this.syncWithUrlParameter = param;
const url = new URL(window.location.href);
const value = url.searchParams.get(param);
if (value) {
this.set(JSON.parse(value));
}
return this; return this;
} }
@ -56,23 +95,35 @@ export class ReactiveValue<T> {
@action.bound @action.bound
addValidator(validator: (value: T, field?: string) => string) { addValidator(validator: (value: T, field?: string) => string) {
this.validators.push(validator); if (validator) {
this.validators.push(validator);
}
return this;
}
@action.bound
addInputRestriction(restriction: (value: T) => string) {
if (restriction) {
this.inputRestrictions.push(restriction);
}
return this; return this;
} }
@computed @computed
get invalid() { get invalid() {
return this.errors.length > 0; return this.errors.length > 0 || !!this.inputRestrictionError;
} }
@computed @computed
get allErrors() { get allErrors() {
return this.errors; return [...this.errors, this.inputRestrictionError].filter(error => error);
} }
@computed @computed
get firstError() { get firstError() {
return this.errors[0]; return this.inputRestrictionError ?? this.errors[0];
} }
@action.bound @action.bound
@ -86,40 +137,10 @@ export class ReactiveValue<T> {
} }
@action.bound @action.bound
clear() { rebirth() {
this.val = null; this.val = undefined;
this.isTouched = false; this.isTouched = false;
this.errors = []; this.errors = [];
this.inputRestrictionError = undefined;
} }
} }
// export class NumberField extends ReactiveValue<number> {
// constructor(fireImmediately: boolean = false) {
// super(fireImmediately);
// makeObservable(this);
// this.addValidator(value => {
// if (_.isNaN(value)) {
// return 'Должно быть числом';
// }
//
// return null;
// });
// }
//
// @action.bound
// onChange(event: React.ChangeEvent<HTMLInputElement>) {
// this.set(_.toNumber(event.currentTarget.value));
// }
// }
//
// export class BooleanField extends ReactiveValue<boolean> {
// constructor(fireImmediately: boolean = false) {
// super(fireImmediately);
// makeObservable(this);
// }
//
// @action.bound
// onChange(event: React.ChangeEvent<HTMLInputElement>) {
// this.set(event.currentTarget.checked);
// }
// }

View File

@ -41,19 +41,32 @@ export const strPattern = (regexp: RegExp, message: string) => (value: string, f
} }
export const email = (value: string, field = 'Поле') => { export const email = (value: string, field = 'Поле') => {
if (!/^.+@.+\..+$/.test(value)) { // валидация email очень сложная, и этот паттерн не учитывает многие валидные адреса, но используется в Google
if (!/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(value)) {
return `${field} должно быть корректным адресом электронной почты`; return `${field} должно быть корректным адресом электронной почты`;
} }
} }
export const emailChars = (value: string, field = 'Поле') => {
if (!/^[a-zA-Z\d@._-]*$/.test(value)) {
return `${field} должно содержать только латинские буквы, цифры и символы @._-`;
}
}
export const phone = (value: string, field = 'Поле') => { export const phone = (value: string, field = 'Поле') => {
if (!/^\+[1-9]\d{6,14}$/.test(value)) { if (!/^\+[1-9]\d{6,14}$/.test(value)) {
return `${field} должно быть корректным номером телефона`; return `${field} должно быть корректным номером телефона`;
} }
} }
export const phoneChars = (value: string, field = 'Поле') => {
if (!/^\+\d*$/.test(value)) {
return `${field} должно содержать только цифры и символ +`;
}
}
export const loginChars = (value: string, field = 'Поле') => { export const loginChars = (value: string, field = 'Поле') => {
if (!/^[a-zA-Z0-9]*$/.test(value)) { if (!/^[a-zA-Z\d_]*$/.test(value)) {
return `${field} должно содержать только латинские буквы, цифры и знак подчеркивания`; return `${field} должно содержать только латинские буквы, цифры и знак подчеркивания`;
} }
} }
@ -64,15 +77,33 @@ export const loginLength = (value: string, field = 'Поле') => {
} }
} }
export const passwordChars = (value: string, field = 'Поле') => { export const loginMaxLength = (value: string, field = 'Поле') => {
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]*$/.test(value)) { if (value.length > 32) {
return `${field} должно содержать не более 32 символов`;
}
}
export const password = (value: string, field = 'Поле') => {
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d!@#$%^&*]*$/.test(value)) {
return `${field} должен содержать хотя бы одну цифру, одну заглавную и одну строчную букву`; return `${field} должен содержать хотя бы одну цифру, одну заглавную и одну строчную букву`;
} }
} }
export const passwordMaxLength = (value: string, field = 'Поле') => {
if (value.length > 32) {
return `${field} должно содержать не более 32 символов`;
}
}
export const passwordLength = (value: string, field = 'Поле') => { export const passwordLength = (value: string, field = 'Поле') => {
if (value.length < 8 || value.length > 32) { if (value.length < 8 || value.length > 32) {
return `${field} должен содержать от 8 до 32 символов`; return `${field} должно содержать от 8 до 32 символов`;
}
}
export const passwordChars = (value: string, field = 'Поле') => {
if (!/^[a-zA-Z\d!@#$%^&*]*$/.test(value)) {
return `${field} должен содержать только латинские буквы, цифры и специальные символы`;
} }
} }

View File

@ -1,33 +1,83 @@
export class Column { import {action, makeObservable, observable} from "mobx";
key: string; import {defaultComparator} from "./comparators";
title: string; import _ from "lodash";
format: (value: any) => string; import {ReactNode} from "react";
constructor(key: string, title: string, format: (value: any) => string = value => value) { export class TableDescriptor<R> {
@observable columns: Column<R, any>[];
@observable filters: ((row: R) => boolean)[];
@observable data: R[];
@observable pageable: boolean;
@observable pageSize = 10;
@observable page = 0;
constructor(
columns: Column<R, any>[],
data: R[],
pageable = true,
filters: ((row: R) => boolean)[] = [() => true]
) {
makeObservable(this);
this.columns = columns;
this.data = data;
this.filters = filters;
this.pageable = pageable;
}
}
export class Column<R, C> {
@observable key: string; /* key of the field in the data object */
@observable title: string;
@observable sort: Sort<C>;
@observable suffixElement?: (data: R) => ReactNode;
@observable format: (value: C, data: R) => ReactNode;
constructor(
key: string, title: string,
format: (value: any) => string = value => value,
suffix?: (data: R) => ReactNode,
sort: Sort<C> = new Sort<C>()
) {
makeObservable(this);
this.suffixElement = suffix;
this.key = key; this.key = key;
this.title = title; this.title = title;
this.format = format; this.format = format;
this.sort = sort;
} }
} }
export interface Sort { export class Sort<C> {
column: Column; @observable order: 'asc' | 'desc';
order: 'asc' | 'desc'; @observable comparator: (a: C, b: C) => number;
} @observable apply: boolean;
@observable applyOrder: number;
export class TableDescriptor<T> { constructor(
columns: Column[]; apply = false, applyOrder?: number,
sorts: Sort[]; comparator: (a: C, b: C) => number = defaultComparator,
filters: ((row: T) => boolean)[] = [() => true]; order: 'asc' | 'desc' = 'asc'
data: T[]; ) {
pageable = false; makeObservable(this);
pageSize = 10; this.order = order;
page = 0; this.comparator = comparator;
this.apply = apply;
this.applyOrder = applyOrder;
}
constructor(columns: Column[], data: T[], sorts: Sort[] = [], filters: ((row: T) => boolean)[] = [() => true]) { @action.bound
this.columns = columns; toggle(other: Sort<C>[]) {
this.data = data; if (!this.apply) {
this.sorts = sorts; this.apply = true;
this.filters = filters; this.order = 'asc';
const maxOrder = Math.max(0, ...(other.map(sort => sort.applyOrder).filter(order => _.isNumber(order))));
this.applyOrder = maxOrder + 1;
} else if (this.order === 'asc') {
this.order = 'desc';
} else {
this.apply = false;
other.filter(sort => sort.applyOrder > this.applyOrder).forEach(sort => sort.applyOrder--);
this.applyOrder = undefined;
}
} }
} }

View File

@ -22,4 +22,4 @@
"strictBindCallApply": true, "strictBindCallApply": true,
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
} }
} }

View File

@ -38,7 +38,7 @@ module.exports = {
historyApiFallback: true, historyApiFallback: true,
static: path.join(__dirname, "dist"), static: path.join(__dirname, "dist"),
compress: true, compress: true,
port: 8081, port: 8888,
}, },
plugins: [ plugins: [
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({