Basic group login and other major improvements
This commit is contained in:
parent
a5695ccab6
commit
bc71a87414
@ -4,13 +4,12 @@ package ru.tubryansk.tdms;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
|
||||
@SpringBootApplication
|
||||
@Slf4j
|
||||
public class TdmsApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(TdmsApplication.class, args);
|
||||
SpringApplication.run(TdmsApplication.class, args).start();
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,7 +43,7 @@ public class SecurityConfiguration {
|
||||
.cors(a -> a.configurationSource(cors))
|
||||
.authenticationManager(authenticationManager)
|
||||
.sessionManagement(cfg -> {
|
||||
cfg.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
|
||||
cfg.sessionCreationPolicy(SessionCreationPolicy.NEVER);
|
||||
cfg.maximumSessions(1);
|
||||
})
|
||||
.build();
|
||||
@ -57,20 +57,19 @@ public class SecurityConfiguration {
|
||||
@Value("${application.protocol}") String protocol,
|
||||
Environment environment
|
||||
) {
|
||||
return request -> {
|
||||
String url = StringUtils.join(protocol, "://", domain, ":", port);
|
||||
CorsConfiguration corsConfiguration = new CorsConfiguration();
|
||||
corsConfiguration.setMaxAge(Duration.ofDays(1));
|
||||
corsConfiguration.addAllowedOrigin(url);
|
||||
if (environment.matchesProfiles("dev")) {
|
||||
corsConfiguration.addAllowedOrigin("http://localhost:8081");
|
||||
}
|
||||
|
||||
corsConfiguration.setAllowedMethods(List.of(HttpMethod.GET.name(), HttpMethod.POST.name(), HttpMethod.OPTIONS.name()));
|
||||
corsConfiguration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
||||
corsConfiguration.setAllowCredentials(true);
|
||||
return corsConfiguration;
|
||||
};
|
||||
corsConfiguration.setMaxAge(Duration.ofDays(1));
|
||||
corsConfiguration.addAllowedOrigin(StringUtils.join(protocol, "://", domain, ":", port));
|
||||
if (environment.matchesProfiles("dev")) {
|
||||
corsConfiguration.addAllowedOrigin("http://localhost:8888");
|
||||
}
|
||||
log.info("CORS configuration: [headers: {}, methods: {}, origins: {}, credentials: {}, maxAge: {}]",
|
||||
corsConfiguration.getAllowedHeaders(), corsConfiguration.getAllowedMethods(), corsConfiguration.getAllowedOrigins(),
|
||||
corsConfiguration.getAllowCredentials(), corsConfiguration.getMaxAge());
|
||||
return request -> corsConfiguration;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ -96,8 +95,8 @@ public class SecurityConfiguration {
|
||||
/* StudentController */
|
||||
httpAuthorization.requestMatchers("/api/v1/student/current").permitAll();
|
||||
/* GroupController */
|
||||
httpAuthorization.requestMatchers("/api/v1/group/get-all").permitAll();
|
||||
httpAuthorization.requestMatchers("api/v1/group/create-group").hasAuthority("ROLE_ADMINISTRATOR");
|
||||
httpAuthorization.requestMatchers("/api/v1/group/get-all-groups").permitAll();
|
||||
httpAuthorization.requestMatchers("/api/v1/group/create-group").hasAuthority("ROLE_ADMINISTRATOR");
|
||||
/* deny all other api requests */
|
||||
httpAuthorization.requestMatchers("/api/**").denyAll();
|
||||
/* since api already blocked, all other requests are static resources */
|
||||
|
||||
@ -3,8 +3,9 @@ package ru.tubryansk.tdms.controller;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
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.GroupRegistrationDTO;
|
||||
import ru.tubryansk.tdms.controller.payload.GroupEditDTO;
|
||||
import ru.tubryansk.tdms.service.GroupService;
|
||||
|
||||
import java.util.Collection;
|
||||
@ -21,7 +22,12 @@ public class GroupController {
|
||||
}
|
||||
|
||||
@PostMapping("/create-group")
|
||||
public void createGroup(@RequestBody @Valid GroupRegistrationDTO groupRegistrationDTO) {
|
||||
groupService.createGroup(groupRegistrationDTO.getName());
|
||||
public void createGroup(@RequestBody @Valid GroupCreateDTO groupCreateDTO) {
|
||||
groupService.createGroup(groupCreateDTO.getName());
|
||||
}
|
||||
|
||||
@PostMapping("/edit-group")
|
||||
public void editGroup(@RequestBody @Valid GroupEditDTO groupEditDTO) {
|
||||
groupService.editGroup(groupEditDTO);
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ public class UserController {
|
||||
|
||||
@PostMapping("/login")
|
||||
public void login(@RequestBody @Valid LoginDTO loginDTO) {
|
||||
authenticationService.login(loginDTO.getUsername(), loginDTO.getPassword());
|
||||
authenticationService.login(loginDTO.getLogin(), loginDTO.getPassword());
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
|
||||
@ -6,7 +6,7 @@ import jakarta.validation.constraints.Size;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class GroupRegistrationDTO {
|
||||
public class GroupCreateDTO {
|
||||
@NotEmpty(message = "Имя группы не может быть пустым")
|
||||
@Size(min = 3, max = 50, message = "Имя группы должно быть от 3 до 50 символов")
|
||||
@Pattern(regexp = "^[а-яА-ЯёЁ0-9_-]*$", message = "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры")
|
||||
@ -8,6 +8,7 @@ import lombok.ToString;
|
||||
@Setter
|
||||
@ToString
|
||||
public class GroupDTO {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String curatorName;
|
||||
private Boolean iAmCurator;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -1,12 +1,18 @@
|
||||
package ru.tubryansk.tdms.controller.payload;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class LoginDTO {
|
||||
@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 = "Пароль не может быть пустым")
|
||||
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$", message = "Пароль должен содержать хотя бы одну цифру, одну заглавную и одну строчную букву, минимум 8 символов")
|
||||
private String password;
|
||||
}
|
||||
@ -3,6 +3,7 @@ package ru.tubryansk.tdms.controller.payload;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import ru.tubryansk.tdms.entity.User;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
@ -19,8 +20,8 @@ public record UserDTO(
|
||||
String phone,
|
||||
ZonedDateTime createdAt,
|
||||
ZonedDateTime updatedAt,
|
||||
List<RoleDTO> authorities) {
|
||||
|
||||
List<String> authorities
|
||||
) {
|
||||
public static UserDTO unauthenticated() {
|
||||
return UserDTO.builder()
|
||||
.authenticated(false)
|
||||
@ -36,7 +37,7 @@ public record UserDTO(
|
||||
.phone(user.getNumberPhone())
|
||||
.createdAt(user.getCreatedAt())
|
||||
.updatedAt(user.getUpdatedAt())
|
||||
.authorities(RoleDTO.from(user))
|
||||
.authorities(user.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.support.DefaultMessageSourceResolvable;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
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 ru.tubryansk.tdms.controller.payload.ErrorResponse;
|
||||
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestControllerAdvice
|
||||
@ -23,13 +24,13 @@ public class GlobalExceptionHandler {
|
||||
log.debug("Validation error: {}", e.getMessage());
|
||||
String validationErrors = e.getAllErrors().stream()
|
||||
.map(DefaultMessageSourceResolvable::getDefaultMessage)
|
||||
.collect(Collectors.joining(", "));
|
||||
.collect(Collectors.joining("\n"));
|
||||
return new ErrorResponse(validationErrors, ErrorResponse.ErrorCode.VALIDATION_ERROR);
|
||||
}
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ErrorResponse handleBusinessException(BusinessException e, HttpServletResponse response) {
|
||||
log.info("Business error", e);
|
||||
log.info("Business error: {}", e.getMessage());
|
||||
response.setStatus(e.getErrorCode().getHttpStatus().value());
|
||||
return new ErrorResponse(e.getMessage(), e.getErrorCode());
|
||||
}
|
||||
@ -37,24 +38,30 @@ public class GlobalExceptionHandler {
|
||||
@ExceptionHandler(org.springframework.security.access.AccessDeniedException.class)
|
||||
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||
public ErrorResponse handleAccessDeniedException(AccessDeniedException e) {
|
||||
log.debug("Access denied", e);
|
||||
return new ErrorResponse("", ErrorResponse.ErrorCode.ACCESS_DENIED);
|
||||
log.info("Access denied: {}", e.getMessage());
|
||||
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)
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public ErrorResponse handleNoResourceFoundException(NoResourceFoundException e) {
|
||||
log.error("Resource not found", e);
|
||||
return new ErrorResponse(e.getMessage(), ErrorResponse.ErrorCode.NOT_FOUND);
|
||||
UUID uuid = UUID.randomUUID();
|
||||
log.error("Resource not found ({})", uuid, e);
|
||||
return new ErrorResponse("Идентификатор ошибки: (" + uuid + ")\nРесурс не был наеден, обратитесь к администратору", ErrorResponse.ErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public ErrorResponse handleUnexpectedException(Exception e) {
|
||||
Random random = new Random();
|
||||
long errorInx = random.nextLong();
|
||||
log.error("Unexpected exception. random: {}", errorInx, e);
|
||||
return new ErrorResponse("Произошла непредвиденная ошибка, обратитесь к администратору. Номер ошибки: " + errorInx, ErrorResponse.ErrorCode.INTERNAL_ERROR);
|
||||
UUID uuid = UUID.randomUUID();
|
||||
log.error("Unexpected exception ({})", uuid, e);
|
||||
return new ErrorResponse("Идентификатор ошибки: (" + uuid + ")\nПроизошла непредвиденная ошибка, обратитесь к администратору", ErrorResponse.ErrorCode.INTERNAL_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,25 +27,26 @@ public class AuthenticationService {
|
||||
}
|
||||
|
||||
public void logout() {
|
||||
log.info("Logging out user: {}", SecurityContextHolder.getContext().getAuthentication().getName());
|
||||
HttpSession session = request.getSession(false);
|
||||
if(session != null) {
|
||||
session.invalidate();
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
log.info("User logged out");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
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 authenticated = authenticationManager.authenticate(token);
|
||||
context.setAuthentication(authenticated);
|
||||
SecurityContextHolder.setContext(context);
|
||||
request.getSession(true).setAttribute(SPRING_SECURITY_CONTEXT_KEY, context);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to log in user: {}", username, e);
|
||||
throw e;
|
||||
}
|
||||
log.debug("User {} logged in", username);
|
||||
|
||||
log.info("User {} logged in", username);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
package ru.tubryansk.tdms.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
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.User;
|
||||
import ru.tubryansk.tdms.entity.repository.GroupRepository;
|
||||
@ -14,6 +16,7 @@ import java.util.List;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
@Slf4j
|
||||
public class GroupService {
|
||||
@Autowired
|
||||
private GroupRepository groupRepository;
|
||||
@ -21,33 +24,44 @@ public class GroupService {
|
||||
private CallerService callerService;
|
||||
|
||||
public Collection<GroupDTO> getAllGroups() {
|
||||
log.info("Getting all groups");
|
||||
List<Group> groups = groupRepository.findAll();
|
||||
User callerUser = callerService.getCallerUser().orElse(null);
|
||||
|
||||
return groups.stream()
|
||||
.map(g -> {
|
||||
List<GroupDTO> result = groups.stream().map(group -> {
|
||||
GroupDTO groupDTO = new GroupDTO();
|
||||
groupDTO.setName(g.getName());
|
||||
groupDTO.setName(group.getName());
|
||||
groupDTO.setId(group.getId());
|
||||
|
||||
if (g.getGroupCurator() != null) {
|
||||
groupDTO.setCuratorName(g.getGroupCurator().getUser().getFullName());
|
||||
if (group.getGroupCurator() != null) {
|
||||
groupDTO.setCuratorName(group.getGroupCurator().getUser().getFullName());
|
||||
if (callerUser != null) {
|
||||
groupDTO.setIAmCurator(g.getGroupCurator().getUser().equals(callerUser));
|
||||
groupDTO.setIAmCurator(group.getGroupCurator().getUser().equals(callerUser));
|
||||
}
|
||||
}
|
||||
return groupDTO;
|
||||
})
|
||||
.toList();
|
||||
}).toList();
|
||||
|
||||
log.info("Found {} groups", result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
public void createGroup(String groupName) {
|
||||
boolean existsByName = groupRepository.existsByName(groupName);
|
||||
if (existsByName) {
|
||||
log.info("Creating group with name {}", groupName);
|
||||
if (groupRepository.existsByName(groupName)) {
|
||||
throw new BusinessException("Группа с именем " + groupName + " уже существует");
|
||||
}
|
||||
|
||||
Group group = new Group();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
package ru.tubryansk.tdms.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.event.ContextStartedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class LifeCycleService {
|
||||
public class LifecycleService {
|
||||
@EventListener(ContextStartedEvent.class)
|
||||
public void onStartup(ContextStartedEvent event) {
|
||||
ApplicationContext applicationContext = event.getApplicationContext();
|
||||
log.info("Static files location: {}", applicationContext.getEnvironment().getProperty("spring.web.resources.static-locations"));
|
||||
Environment environment = event.getApplicationContext().getEnvironment();
|
||||
log.info("Static files location: {}", environment.getProperty("spring.web.resources.static-locations"));
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package ru.tubryansk.tdms.service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@ -11,6 +12,7 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class RoleService {
|
||||
public enum Authority {
|
||||
ROLE_ADMINISTRATOR,
|
||||
@ -28,8 +30,10 @@ public class RoleService {
|
||||
@PostConstruct
|
||||
@Transactional
|
||||
public void init() {
|
||||
log.debug("Initializing roles");
|
||||
roles = new ConcurrentHashMap<>();
|
||||
roleRepository.findAll().forEach(role -> roles.put(role.getAuthority(), role));
|
||||
log.info("Roles initialized: {}", roles);
|
||||
}
|
||||
|
||||
public Role getRoleByAuthority(Authority authority) {
|
||||
|
||||
@ -4,11 +4,8 @@ import jakarta.transaction.Transactional;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import ru.tubryansk.tdms.controller.payload.StudentDTO;
|
||||
import ru.tubryansk.tdms.entity.DiplomaTopic;
|
||||
import ru.tubryansk.tdms.entity.Student;
|
||||
import ru.tubryansk.tdms.entity.repository.DiplomaTopicRepository;
|
||||
import ru.tubryansk.tdms.entity.repository.StudentRepository;
|
||||
import ru.tubryansk.tdms.exception.AccessDeniedException;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@ -18,28 +15,8 @@ public class StudentService {
|
||||
@Autowired
|
||||
private StudentRepository studentRepository;
|
||||
@Autowired
|
||||
private DiplomaTopicRepository diplomaTopicRepository;
|
||||
@Autowired
|
||||
private Optional<Student> student;
|
||||
@Autowired
|
||||
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() {
|
||||
return studentRepository.findByUser(callerService.getCallerUser().orElse(null));
|
||||
}
|
||||
|
||||
@ -36,17 +36,15 @@ public class UserService implements UserDetailsService {
|
||||
|
||||
@Override
|
||||
public User loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
log.info("Loading user with username: {}", username);
|
||||
User user = userRepository.findUserByLogin(username).orElseThrow(() -> {
|
||||
log.info("User with login {} not found", username);
|
||||
return new UsernameNotFoundException("User with login " + username + " not found");
|
||||
});
|
||||
log.info("User with login {} loaded", username);
|
||||
log.debug("Loading user with username: {}", username);
|
||||
User user = userRepository.findUserByLogin(username).orElseThrow(
|
||||
() -> new UsernameNotFoundException("User with login " + username + " not found"));
|
||||
log.debug("User with login {} loaded", username);
|
||||
return user;
|
||||
}
|
||||
|
||||
public List<UserDTO> getAllUsers() {
|
||||
log.info("Loading all users");
|
||||
log.debug("Loading all users");
|
||||
List<UserDTO> users = userRepository.findAll().stream()
|
||||
.map(UserDTO::from)
|
||||
.toList();
|
||||
@ -55,7 +53,7 @@ public class UserService implements UserDetailsService {
|
||||
}
|
||||
|
||||
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);
|
||||
Student student = transientStudent(registrationDTO.getStudentData());
|
||||
fillRoles(user, registrationDTO);
|
||||
@ -92,6 +90,7 @@ public class UserService implements UserDetailsService {
|
||||
private void fillRoles(User user, RegistrationDTO registrationDTO) {
|
||||
List<Role> roles = new ArrayList<>();
|
||||
if (registrationDTO.getStudentData() != null) {
|
||||
log.debug("User is student, adding role ROLE_STUDENT");
|
||||
roles.add(roleService.getRoleByAuthority(RoleService.Authority.ROLE_STUDENT));
|
||||
}
|
||||
user.setRoles(roles);
|
||||
|
||||
@ -16,14 +16,13 @@ public class LoggingRequestFilter extends OncePerRequestFilter {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
long startTime = System.currentTimeMillis();
|
||||
log.info("Making request: {}. user: {}, session: {}, remote ip: {}",
|
||||
request.getRequestURI(), request.getRemoteUser(),
|
||||
request.getSession().getId(), request.getRemoteAddr());
|
||||
log.info("Request received: {}. user: {}, session: {}, remote ip: {}",
|
||||
request.getRequestURI(), request.getRemoteUser(), request.getSession().getId(), request.getRemoteAddr());
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,13 +11,12 @@ import org.springframework.stereotype.Component;
|
||||
public class LoggingSessionListener implements HttpSessionListener {
|
||||
@Override
|
||||
public void sessionCreated(HttpSessionEvent se) {
|
||||
log.debug("Session created: {}, user {}",
|
||||
se.getSession().getId(), SecurityContextHolder.getContext().getAuthentication().getName());
|
||||
log.debug("Session created: {}, user {}", se.getSession().getId(),
|
||||
SecurityContextHolder.getContext().getAuthentication().getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sessionDestroyed(HttpSessionEvent se) {
|
||||
log.debug("Session destroyed: {}, user: {}",
|
||||
se.getSession().getId(), SecurityContextHolder.getContext().getAuthentication().getName());
|
||||
log.debug("Session destroyed: {}", se.getSession().getId());
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,9 +7,9 @@ application:
|
||||
name: @name@
|
||||
version: @version@
|
||||
type: production
|
||||
port: 80
|
||||
port: 443
|
||||
domain: tdms.tu-bryansk.ru
|
||||
protocol: http
|
||||
protocol: https
|
||||
|
||||
spring:
|
||||
application:
|
||||
|
||||
@ -9,10 +9,13 @@ create table user_role
|
||||
-- FOREIGN KEY
|
||||
alter table user_role
|
||||
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
|
||||
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
|
||||
comment on table user_role is 'Таблица связи пользователей и ролей';
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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)
|
||||
values (1, 1, 4);
|
||||
|
||||
@ -1,10 +1,23 @@
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import './index.css'
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import {RouterContext, RouterView} from "mobx-state-router";
|
||||
import {RouterContext, RouterView, ViewMap} from "mobx-state-router";
|
||||
import {initApp} from "./utils/init";
|
||||
import {RootStoreContext} from './store/RootStoreContext';
|
||||
import {viewMap} from "./router/viewMap";
|
||||
import React from "react";
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import './index.css'
|
||||
import {RootStoreContext} from './utils/context';
|
||||
import {Home} from "./components/custom/layout/Home";
|
||||
import {UserProfilePage} from "./components/user/UserProfilePage";
|
||||
import {UserListPage} from "./components/user/UserListPage";
|
||||
import {GroupListPage} from "./components/group/GroupListPage";
|
||||
import {Error} from "./components/custom/layout/Error";
|
||||
|
||||
const viewMap: ViewMap = {
|
||||
home: <Home/>,
|
||||
profile: <UserProfilePage/>,
|
||||
userList: <UserListPage/>,
|
||||
groupList: <GroupListPage/>,
|
||||
error: <Error/>,
|
||||
}
|
||||
|
||||
const rootStore = initApp();
|
||||
|
||||
|
||||
@ -52,20 +52,26 @@ class NotificationPopup extends ComponentContext<{ notification: Notification, t
|
||||
render() {
|
||||
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 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}`}>
|
||||
{
|
||||
hasTitle &&
|
||||
<CardHeader>
|
||||
<CardTitle className={'d-flex justify-content-between align-items-start'}>
|
||||
{this.props.notification.title}
|
||||
<span>
|
||||
{title}
|
||||
</span>
|
||||
{closeIcon}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
}
|
||||
<CardBody>
|
||||
<CardText className={'d-flex justify-content-between align-items-start'}>
|
||||
{this.props.notification.message}
|
||||
<span>
|
||||
{message}
|
||||
</span>
|
||||
{
|
||||
!hasTitle &&
|
||||
closeIcon
|
||||
|
||||
@ -1,38 +1,244 @@
|
||||
import {ComponentContext} from "../../utils/ComponentContext";
|
||||
import {TableDescriptor} from "../../utils/tables";
|
||||
import {observer} from "mobx-react";
|
||||
import {action, makeObservable} from "mobx";
|
||||
import {FormSelect, Pagination, Table} from "react-bootstrap";
|
||||
import {action, computed, makeObservable, observable, runInAction} from "mobx";
|
||||
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> {
|
||||
tableDescriptor: TableDescriptor<T>;
|
||||
filterModalState?: ModalState;
|
||||
name?: string;
|
||||
headless?: boolean;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class DataTable<T> extends ComponentContext<DataTableProps<T>> {
|
||||
constructor(props: DataTableProps<T>) {
|
||||
export class DataTable<R> extends ComponentContext<DataTableProps<R>> {
|
||||
constructor(props: DataTableProps<R>) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
header() {
|
||||
return <tr>
|
||||
{this.props.tableDescriptor.columns.map(column => <th className={'text-center'}
|
||||
key={column.key}>{column.title}</th>)}
|
||||
</tr>
|
||||
@observable descriptor = this.props.tableDescriptor;
|
||||
@observable name = this.props.name;
|
||||
@observable headless = this.props.headless;
|
||||
@observable filterModalState = this.props.filterModalState;
|
||||
|
||||
@computed
|
||||
get isFirstPage() {
|
||||
return this.descriptor.page === 0;
|
||||
}
|
||||
|
||||
body() {
|
||||
const firstColumnKey = this.props.tableDescriptor.columns[0].key;
|
||||
return this.props.tableDescriptor.data.map(row => {
|
||||
const rowAny = row as any;
|
||||
return <tr key={rowAny[firstColumnKey]}>
|
||||
@computed
|
||||
get isLastPage() {
|
||||
return this.descriptor.page === Math.floor(this.descriptor.data.length / this.descriptor.pageSize)
|
||||
|| this.descriptor.pageSize === this.descriptor.data.length;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
goFirstPage() {
|
||||
this.descriptor.page = 0;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
goLastPage() {
|
||||
this.descriptor.page = Math.floor(this.descriptor.data.length / this.descriptor.pageSize);
|
||||
}
|
||||
|
||||
@action.bound
|
||||
goNextPage() {
|
||||
this.descriptor.page++;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
goPrevPage() {
|
||||
this.descriptor.page--;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
changePageSize(e: ChangeEvent<HTMLSelectElement>) {
|
||||
if (e.target.value === "\u221E") {
|
||||
this.descriptor.pageSize = this.descriptor.data.length;
|
||||
return;
|
||||
}
|
||||
|
||||
this.descriptor.page = 0;
|
||||
this.descriptor.pageSize = _.toNumber(e.target.value);
|
||||
}
|
||||
|
||||
// not computed, since we want to show initial data, when no sorts applied
|
||||
get filteredData() {
|
||||
const filters = this.descriptor.filters.filter(filter => filter);
|
||||
return this.descriptor.data.filter(row => ((filters && filters.length) > 0 ? filters.every(filter => filter(row)) : true));
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = {
|
||||
border: '1px solid #dee2e6',
|
||||
borderTopLeftRadius: this.headless ? '0.25rem' : '0',
|
||||
borderTopRightRadius: this.headless ? '0.25rem' : '0',
|
||||
borderBottomLeftRadius: this.descriptor.pageable ? '0' : '0.25rem',
|
||||
borderBottomRightRadius: this.descriptor.pageable ? '0' : '0.25rem',
|
||||
}
|
||||
|
||||
return <>
|
||||
{
|
||||
this.props.tableDescriptor.columns.map(column => {
|
||||
return <td
|
||||
className={'text-center'}
|
||||
key={column.key}>
|
||||
{column.format(rowAny[column.key])}
|
||||
!this.headless &&
|
||||
this.header
|
||||
}
|
||||
<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>
|
||||
})
|
||||
}
|
||||
@ -40,108 +246,47 @@ export class DataTable<T> extends ComponentContext<DataTableProps<T>> {
|
||||
});
|
||||
}
|
||||
|
||||
isFirstPage() {
|
||||
if (typeof this.props.tableDescriptor.page === 'undefined') {
|
||||
return true;
|
||||
@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',
|
||||
}
|
||||
|
||||
return this.props.tableDescriptor.page === 0;
|
||||
const buttonSizeStyle = {
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
padding: '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
|
||||
goFirstPage() {
|
||||
if (typeof this.props.tableDescriptor.page === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.tableDescriptor.page = 0;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
goLastPage() {
|
||||
if (typeof this.props.tableDescriptor.page === 'undefined' || typeof this.props.tableDescriptor.pageSize === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.tableDescriptor.page = this.props.tableDescriptor.data.length / this.props.tableDescriptor.pageSize;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
goNextPage() {
|
||||
if (typeof this.props.tableDescriptor.page === 'undefined' || typeof this.props.tableDescriptor.pageSize === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.tableDescriptor.page++;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
goPrevPage() {
|
||||
if (typeof this.props.tableDescriptor.page === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.tableDescriptor.page--;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
changePageSize(e: any) {
|
||||
this.props.tableDescriptor.pageSize = parseInt(e.target.value);
|
||||
}
|
||||
|
||||
footer() {
|
||||
const table = this.props.tableDescriptor;
|
||||
if (typeof table.page === 'undefined' || typeof table.pageSize === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <tr className={'text-center'}>
|
||||
<td colSpan={table.columns.length}>
|
||||
<div className={'d-flex justify-content-between'}>
|
||||
<div/>
|
||||
<Pagination className={'mb-0'}>
|
||||
<Pagination.First onClick={this.goFirstPage} disabled={this.isFirstPage()}/>
|
||||
<Pagination.Ellipsis disabled={this.isFirstPage()}/>
|
||||
<Pagination.Prev onClick={this.goPrevPage} disabled={this.isFirstPage()}/>
|
||||
<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}>
|
||||
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>∞</option>
|
||||
</FormSelect>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
render() {
|
||||
const table = this.props.tableDescriptor;
|
||||
return <Table hover striped>
|
||||
<thead>
|
||||
{this.header()}
|
||||
</thead>
|
||||
<tbody>
|
||||
{this.body()}
|
||||
</tbody>
|
||||
{
|
||||
table.pageable &&
|
||||
<tfoot>
|
||||
{this.footer()}
|
||||
</tfoot>
|
||||
}
|
||||
</Table>
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@ import React from "react";
|
||||
import {ReactiveValue} from "../../../utils/reactive/reactiveValue";
|
||||
import {observer} from "mobx-react";
|
||||
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 './ReactiveControls.css';
|
||||
|
||||
@ -11,6 +11,11 @@ export interface ReactiveInputProps<T> {
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
validateless?: boolean;
|
||||
}
|
||||
|
||||
export interface ReactiveSelectInputProps<T> extends ReactiveInputProps<T> {
|
||||
possibleValues: { value: T, label: string }[];
|
||||
}
|
||||
|
||||
@observer
|
||||
@ -30,12 +35,13 @@ export class StringInput extends React.Component<ReactiveInputProps<string>> {
|
||||
}
|
||||
|
||||
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'}>
|
||||
{/*todo: disable background-color for label*/}
|
||||
<FloatingLabel label={this.props.label} className={`${this.props.className} mt-0 mb-0`}>
|
||||
<FormControl type='text' placeholder={this.props.label} disabled={this.props.disabled}
|
||||
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>
|
||||
<FormText children={this.props.value.firstError} className={`text-danger mt-0 mb-0 d-block`}/>
|
||||
</div>
|
||||
@ -67,14 +73,15 @@ export class PasswordInput extends React.Component<ReactiveInputProps<string>> {
|
||||
|
||||
render() {
|
||||
return <div className={'mb-1 l-no-bg'}>
|
||||
<div className={'d-flex justify-content-between align-items-center'}>
|
||||
<FloatingLabel label={this.props.label} className={`${this.props.className} w-100`}>
|
||||
<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 position-absolute`}>
|
||||
<FormControl type={`${this.showPassword ? 'text' : 'password'}`} placeholder={this.props.label}
|
||||
disabled={this.props.disabled}
|
||||
className={`${this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`}
|
||||
onChange={this.onChange} value={this.props.value.value}/>
|
||||
</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'}/>
|
||||
</Button>
|
||||
</div>
|
||||
@ -84,33 +91,13 @@ export class PasswordInput extends React.Component<ReactiveInputProps<string>> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class SelectButtonInput extends React.Component<ReactiveInputProps<string>> {
|
||||
constructor(props: any) {
|
||||
export class SelectInput<T> extends React.Component<ReactiveSelectInputProps<T>> {
|
||||
constructor(props: ReactiveSelectInputProps<T>) {
|
||||
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'}/>
|
||||
</>
|
||||
return <div></div>;
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import {observer} from "mobx-react";
|
||||
import {DefaultPage} from "./DefaultPage";
|
||||
import {Page} from "./Page";
|
||||
|
||||
@observer
|
||||
export default class Error extends DefaultPage {
|
||||
export class Error extends Page {
|
||||
get page() {
|
||||
return <h1>Error</h1>
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import {ComponentContext} from "../../utils/ComponentContext";
|
||||
import {ComponentContext} from "../../../utils/ComponentContext";
|
||||
import {observer} from "mobx-react";
|
||||
import {makeObservable} from "mobx";
|
||||
import {Container, Nav, Navbar, NavbarText, NavLink} from "react-bootstrap";
|
||||
@ -1,80 +1,72 @@
|
||||
import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap";
|
||||
import {RouterLink} from "mobx-state-router";
|
||||
import {IAuthenticated} from "../../models/user";
|
||||
import {RootStoreContext, RootStoreContextType} from "../../store/RootStoreContext";
|
||||
import {IAuthenticated} from "../../../models/user";
|
||||
import {observer} from "mobx-react";
|
||||
import {post} from "../../utils/request";
|
||||
import {LoginModal} from "../user/LoginModal";
|
||||
import {ModalState} from "../../utils/modalState";
|
||||
import {post} from "../../../utils/request";
|
||||
import {UserLoginModal} from "../../user/UserLoginModal";
|
||||
import {ModalState} from "../../../utils/modalState";
|
||||
import {action, makeObservable, observable} from "mobx";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {ComponentContext} from "../../utils/ComponentContext";
|
||||
import {CreateGroupModal} from "../group/CreateGroupModal";
|
||||
import {UserRegistrationModal} from "../user/UserRegistrationModal";
|
||||
import {ComponentContext} from "../../../utils/ComponentContext";
|
||||
import {AddGroupModal} from "../../group/AddGroupModal";
|
||||
import {UserRegistrationModal} from "../../user/UserRegistrationModal";
|
||||
|
||||
@observer
|
||||
class Header extends ComponentContext {
|
||||
|
||||
@observable loginModalState = new ModalState();
|
||||
@observable createGroupModalState = new ModalState();
|
||||
@observable userRegistrationModalState = new ModalState();
|
||||
|
||||
export class Header extends ComponentContext {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
@observable loginModalState = new ModalState();
|
||||
@observable addGroupModalState = new ModalState();
|
||||
@observable userRegistrationModalState = new ModalState();
|
||||
|
||||
render() {
|
||||
const userStore = this.context.userStore;
|
||||
const user = userStore.user;
|
||||
let thinking = this.thinkStore.isThinking('updateCurrentUser');
|
||||
let userThink = this.thinkStore.isThinking('updateCurrentUser');
|
||||
|
||||
return <>
|
||||
<header>
|
||||
<Navbar className="bg-body-tertiary" fixed="top">
|
||||
<Container>
|
||||
<Navbar.Brand>
|
||||
<Nav.Link as={RouterLink} routeName='root'>TDMS</Nav.Link>
|
||||
<Nav.Link as={RouterLink} routeName='home'>TDMS</Nav.Link>
|
||||
</Navbar.Brand>
|
||||
<Nav>
|
||||
{
|
||||
user.authenticated && userStore.isAdministrator() &&
|
||||
this.userStore.isAdministrator &&
|
||||
<NavDropdown title="Пользователи">
|
||||
<NavDropdown.Item as={RouterLink} routeName={'userList'} children={'Список'}/>
|
||||
<NavDropdown.Item onClick={this.userRegistrationModalState.open}
|
||||
children={'Зарегистрировать'}/>
|
||||
<NavDropdown.Item onClick={this.userRegistrationModalState.open} children={'Зарегистрировать'}/>
|
||||
</NavDropdown>
|
||||
}
|
||||
{
|
||||
user.authenticated && userStore.isAdministrator() &&
|
||||
<NavDropdown title="Группы">
|
||||
<NavDropdown.Item as={RouterLink} routeName={'groupList'} children={'Список'}/>
|
||||
<NavDropdown.Item onClick={this.createGroupModalState.open} children={'Добавить'}/>
|
||||
</NavDropdown>
|
||||
{
|
||||
this.userStore.isAdministrator &&
|
||||
<NavDropdown.Item onClick={this.addGroupModalState.open} children={'Добавить'}/>
|
||||
}
|
||||
</NavDropdown>
|
||||
</Nav>
|
||||
|
||||
<Nav className="ms-auto">
|
||||
{
|
||||
thinking &&
|
||||
userThink &&
|
||||
<FontAwesomeIcon icon='gear' spin/>
|
||||
}
|
||||
{
|
||||
user.authenticated && !thinking &&
|
||||
this.userStore.authenticated && !userThink &&
|
||||
<AuthenticatedItems/>
|
||||
}
|
||||
{
|
||||
!user.authenticated && !thinking &&
|
||||
<>
|
||||
!this.userStore.authenticated && !userThink &&
|
||||
<Nav.Link onClick={this.loginModalState.open}>Войти</Nav.Link>
|
||||
</>
|
||||
}
|
||||
</Nav>
|
||||
</Container>
|
||||
</Navbar>
|
||||
</header>
|
||||
<LoginModal modalState={this.loginModalState}/>
|
||||
<CreateGroupModal modalState={this.createGroupModalState}/>
|
||||
<UserLoginModal modalState={this.loginModalState}/>
|
||||
<AddGroupModal modalState={this.addGroupModalState}/>
|
||||
<UserRegistrationModal modalState={this.userRegistrationModalState}/>
|
||||
</>
|
||||
}
|
||||
@ -82,9 +74,6 @@ class Header extends ComponentContext {
|
||||
|
||||
@observer
|
||||
class AuthenticatedItems extends ComponentContext<any, any> {
|
||||
declare context: RootStoreContextType;
|
||||
static contextType = RootStoreContext;
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
@ -92,17 +81,17 @@ class AuthenticatedItems extends ComponentContext<any, any> {
|
||||
|
||||
@action.bound
|
||||
logout() {
|
||||
post('user/logout').then(() => this.context.userStore.updateCurrentUser());
|
||||
post('user/logout').then(() => {
|
||||
this.userStore.updateCurrentUser();
|
||||
this.routerStore.goTo('home').then();
|
||||
this.notificationStore.success('Вы успешно вышли из системы', 'Выход');
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const userStore = this.context.userStore;
|
||||
const user = userStore.user;
|
||||
|
||||
return <>
|
||||
<Navbar.Text>Пользователь:</Navbar.Text>
|
||||
<NavDropdown
|
||||
title={(user as IAuthenticated).fullName}>
|
||||
<NavDropdown title={(this.userStore.user as IAuthenticated).fullName}>
|
||||
<NavDropdown.Item as={RouterLink} routeName='profile'>Моя страница</NavDropdown.Item>
|
||||
<NavDropdown.Divider/>
|
||||
<NavDropdown.Item onClick={this.logout}>Выйти</NavDropdown.Item>
|
||||
@ -110,5 +99,3 @@ class AuthenticatedItems extends ComponentContext<any, any> {
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@ -1,8 +1,8 @@
|
||||
import {DefaultPage} from "./DefaultPage";
|
||||
import {Page} from "./Page";
|
||||
import {observer} from "mobx-react";
|
||||
|
||||
@observer
|
||||
export default class Home extends DefaultPage {
|
||||
export class Home extends Page {
|
||||
get page() {
|
||||
return <h1>Home</h1>
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
import {ReactNode} from "react";
|
||||
import {Container} from "react-bootstrap";
|
||||
import Header from "./Header";
|
||||
import {Header} from "./Header";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {ComponentContext} from "../../utils/ComponentContext";
|
||||
import {NotificationContainer} from "../NotificationContainer";
|
||||
import {ComponentContext} from "../../../utils/ComponentContext";
|
||||
import {NotificationContainer} from "../../NotificationContainer";
|
||||
import {Footer} from "./Footer";
|
||||
|
||||
export abstract class DefaultPage extends ComponentContext {
|
||||
export abstract class Page extends ComponentContext {
|
||||
get page(): ReactNode {
|
||||
throw new Error('This is not abstract method, ' +
|
||||
'because mobx cant handle abstract methods. ' +
|
||||
@ -20,6 +20,7 @@ export abstract class DefaultPage extends ComponentContext {
|
||||
return <>
|
||||
<Header/>
|
||||
<Container className={"mt-5 mb-5"}>
|
||||
<NotificationContainer/>
|
||||
{
|
||||
thinking &&
|
||||
<div id='fullscreen-loader'>
|
||||
@ -29,7 +30,6 @@ export abstract class DefaultPage extends ComponentContext {
|
||||
{
|
||||
!thinking &&
|
||||
<>
|
||||
<NotificationContainer/>
|
||||
{this.page}
|
||||
</>
|
||||
}
|
||||
@ -13,20 +13,20 @@ export interface CreateGroupModalProps {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class CreateGroupModal extends ComponentContext<CreateGroupModalProps> {
|
||||
@observable name = new ReactiveValue<string>()
|
||||
.addValidator(required)
|
||||
.addValidator(strLength(3, 50))
|
||||
.addValidator(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры"));
|
||||
|
||||
export class AddGroupModal extends ComponentContext<CreateGroupModalProps> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
@observable name = new ReactiveValue<string>()
|
||||
.addValidator(required)
|
||||
.addValidator(strLength(3, 50))
|
||||
.addInputRestriction(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, цифры и символы _-"));
|
||||
|
||||
@action.bound
|
||||
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} создана`);
|
||||
}).finally(() => {
|
||||
this.props.modalState.close();
|
||||
@ -39,16 +39,16 @@ export class CreateGroupModal extends ComponentContext<CreateGroupModalProps> {
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Modal show={this.props.modalState.isOpen}>
|
||||
return <Modal show={this.props.modalState.isOpen} centered>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Создание группы</ModalTitle>
|
||||
<ModalTitle>Добавление группы</ModalTitle>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<StringInput value={this.name} label={'Имя группы'}/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<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>
|
||||
</Modal>
|
||||
}
|
||||
203
web/src/components/group/GroupListPage.tsx
Normal file
203
web/src/components/group/GroupListPage.tsx
Normal 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>
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
import {observer} from "mobx-react";
|
||||
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 {DataTable} from "../custom/DataTable";
|
||||
import {get} from "../../utils/request";
|
||||
import {Column, TableDescriptor} from "../../utils/tables";
|
||||
|
||||
@observer
|
||||
export class UserList extends DefaultPage {
|
||||
export class UserListPage extends Page {
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
@ -53,7 +53,7 @@ export class UserList extends DefaultPage {
|
||||
return <>
|
||||
{
|
||||
this.tableDescriptor &&
|
||||
<DataTable tableDescriptor={this.tableDescriptor}/>
|
||||
<DataTable tableDescriptor={this.tableDescriptor} name={'Пользователи'}/>
|
||||
}
|
||||
</>
|
||||
}
|
||||
92
web/src/components/user/UserLoginModal.tsx
Normal file
92
web/src/components/user/UserLoginModal.tsx
Normal 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 {loginChars, loginLength, loginMaxLength, 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>
|
||||
}
|
||||
}
|
||||
@ -1,25 +1,32 @@
|
||||
import {DefaultPage} from "../layout/DefaultPage";
|
||||
import {Page} from "../custom/layout/Page";
|
||||
import {Col, Form, Row} from "react-bootstrap";
|
||||
import {observer} from "mobx-react";
|
||||
import {RootStoreContext, type RootStoreContextType} from "../../store/RootStoreContext";
|
||||
import {IAuthenticated} from "../../models/user";
|
||||
import {Component} from "react";
|
||||
import {dateConverter} from "../../utils/converters";
|
||||
import {IStudent} from "../../models/student";
|
||||
import {makeObservable, observable} from "mobx";
|
||||
import {action, makeObservable, observable, runInAction} from "mobx";
|
||||
import {IAuthenticated} from "../../models/user";
|
||||
import {ComponentContext} from "../../utils/ComponentContext";
|
||||
import {getAuthorityByCode} from "../../models/authorities";
|
||||
import {dateConverter} from "../../utils/converters";
|
||||
|
||||
@observer
|
||||
class UserInfo extends Component<{user: IAuthenticated}> {
|
||||
@observable
|
||||
user = this.props.user;
|
||||
|
||||
class UserInfo extends ComponentContext {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
runInAction(() => {
|
||||
this.user = this.userStore.user as IAuthenticated;
|
||||
});
|
||||
}
|
||||
|
||||
@observable user: IAuthenticated;
|
||||
|
||||
render() {
|
||||
return (
|
||||
return <div>
|
||||
{
|
||||
this.user &&
|
||||
<Row>
|
||||
<Col sm={6}>
|
||||
<Form.Group className={"mt-2"}>
|
||||
@ -45,7 +52,7 @@ class UserInfo extends Component<{user: IAuthenticated}> {
|
||||
<Form.Label column={"sm"}>Роли</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={this.user.authorities?.map(a => a.name).join(', ')}
|
||||
value={this.user.authorities?.map(getAuthorityByCode).map(a => a.code).join(', ')}
|
||||
disabled={true}/>
|
||||
</Form.Group>
|
||||
<Form.Group className={"mt-2"}>
|
||||
@ -58,20 +65,26 @@ class UserInfo extends Component<{user: IAuthenticated}> {
|
||||
</Form.Group>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@observer
|
||||
class StudentInfo extends Component<{student: IStudent}> {
|
||||
@observable
|
||||
student = this.props.student;
|
||||
|
||||
class StudentInfo extends ComponentContext {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
runInAction(() => {
|
||||
this.student = this.userStore.student;
|
||||
});
|
||||
}
|
||||
|
||||
@observable student: IStudent;
|
||||
|
||||
render() {
|
||||
let student = this.student;
|
||||
|
||||
@ -151,27 +164,58 @@ class StudentInfo extends Component<{student: IStudent}> {
|
||||
}
|
||||
}
|
||||
|
||||
export default class UserProfilePage extends DefaultPage {
|
||||
declare context: RootStoreContextType;
|
||||
static contextType = RootStoreContext;
|
||||
@observer
|
||||
export class UserProfilePage extends Page {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
runInAction(() => {
|
||||
this.user = this.userStore.user as IAuthenticated;
|
||||
this.student = this.userStore.student;
|
||||
});
|
||||
this.redirectIfNotAuthenticated();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
runInAction(() => {
|
||||
this.user = this.userStore.user as IAuthenticated;
|
||||
this.student = this.userStore.student;
|
||||
});
|
||||
this.redirectIfNotAuthenticated();
|
||||
}
|
||||
|
||||
@observable user: IAuthenticated;
|
||||
@observable student: IStudent;
|
||||
|
||||
@action.bound
|
||||
redirectIfNotAuthenticated() {
|
||||
if (this.thinkStore.isThinking('updateCurrentUser')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.user.authenticated) {
|
||||
this.routerStore.goToNotFound();
|
||||
}
|
||||
}
|
||||
|
||||
get page() {
|
||||
let user = this.context.userStore.user;
|
||||
if (!user.authenticated) {
|
||||
// todo: implement login page with redirects
|
||||
this.context.routerStore.goTo('login', {redirect: 'profile'});
|
||||
}
|
||||
let student = this.context.userStore.student;
|
||||
|
||||
return <Form>
|
||||
return <>
|
||||
{
|
||||
user.authenticated &&
|
||||
<UserInfo user={user}/>
|
||||
!this.thinkStore.isThinking('updateCurrentUser') &&
|
||||
<Form>
|
||||
{
|
||||
this.user?.authenticated &&
|
||||
<UserInfo/>
|
||||
}
|
||||
{
|
||||
student && user.authenticated &&
|
||||
<StudentInfo student={student}/>
|
||||
this.student &&
|
||||
<StudentInfo/>
|
||||
}
|
||||
</Form>
|
||||
}
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,20 +4,26 @@ import {Button, Col, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle, Row
|
||||
import {UserRegistrationDTO} from "../../models/registration";
|
||||
import {post} from "../../utils/request";
|
||||
import {ReactiveValue} from "../../utils/reactive/reactiveValue";
|
||||
import {PasswordInput, SelectButtonInput, StringInput} from "../custom/controls/ReactiveControls";
|
||||
import {PasswordInput, StringInput} from "../custom/controls/ReactiveControls";
|
||||
import {
|
||||
email,
|
||||
emailChars,
|
||||
loginChars,
|
||||
loginLength,
|
||||
loginMaxLength,
|
||||
nameChars,
|
||||
nameLength,
|
||||
password,
|
||||
passwordChars,
|
||||
passwordLength,
|
||||
passwordMaxLength,
|
||||
phone,
|
||||
phoneChars,
|
||||
required
|
||||
} from "../../utils/reactive/validators";
|
||||
import {ComponentContext} from "../../utils/ComponentContext";
|
||||
import {ModalState} from "../../utils/modalState";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
|
||||
export interface UserRegistrationModalProps {
|
||||
modalState: ModalState;
|
||||
@ -30,11 +36,22 @@ export class UserRegistrationModal extends ComponentContext<UserRegistrationModa
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
@observable login = new ReactiveValue<string>().addValidator(required).addValidator(loginLength).addValidator(loginChars);
|
||||
@observable password = new ReactiveValue<string>().addValidator(required).addValidator(passwordLength).addValidator(passwordChars);
|
||||
@observable fullName = new ReactiveValue<string>().addValidator(required).addValidator(nameLength).addValidator(nameChars);
|
||||
@observable email = new ReactiveValue<string>().addValidator(required).addValidator(email);
|
||||
@observable numberPhone = new ReactiveValue<string>().addValidator(required).addValidator(phone).setAuto('+7');
|
||||
@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);
|
||||
@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 userKindEnum = new ReactiveValue<string>().addValidator(required);
|
||||
|
||||
@observable accountType = new ReactiveValue<string>().addValidator(required).addValidator((value) => {
|
||||
if (!['student', 'admin'].includes(value)) {
|
||||
@ -49,12 +66,17 @@ export class UserRegistrationModal extends ComponentContext<UserRegistrationModa
|
||||
|| this.fullName.invalid || !this.fullName.touched
|
||||
|| this.email.invalid || !this.email.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
|
||||
submit() {
|
||||
post('user/register', {
|
||||
if (this.formInvalid)
|
||||
return;
|
||||
|
||||
this.thinkStore.think('userRegistration');
|
||||
post<void>('user/register', {
|
||||
login: this.login.value,
|
||||
password: this.password.value,
|
||||
fullName: this.fullName.value,
|
||||
@ -65,15 +87,29 @@ export class UserRegistrationModal extends ComponentContext<UserRegistrationModa
|
||||
this.notificationStore.success('Пользователь успешно зарегистрирован');
|
||||
}).catch(() => {
|
||||
this.notificationStore.error('Ошибка регистрации пользователя');
|
||||
}).finally(() => {
|
||||
this.thinkStore.completeOne('userRegistration');
|
||||
this.props.modalState.close();
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
<ModalTitle>Регистрация пользователя</ModalTitle>
|
||||
</ModalHeader>
|
||||
|
||||
{
|
||||
thinking &&
|
||||
<Modal.Body>
|
||||
<div className={'text-center'}>
|
||||
<FontAwesomeIcon icon={'gear'} spin size={'4x'}/>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
}
|
||||
{
|
||||
!thinking &&
|
||||
<ModalBody>
|
||||
<Row>
|
||||
<Col>
|
||||
@ -86,8 +122,8 @@ export class UserRegistrationModal extends ComponentContext<UserRegistrationModa
|
||||
<StringInput value={this.numberPhone} label={"Телефон"}/>
|
||||
</Col>
|
||||
</Row>
|
||||
<SelectButtonInput value={this.accountType} label={'Тип аккаунта'}/>
|
||||
</ModalBody>
|
||||
}
|
||||
<ModalFooter>
|
||||
<Button disabled={this.formInvalid} onClick={this.submit}>Зарегистрировать</Button>
|
||||
<Button onClick={this.props.modalState.close} variant={'secondary'}>Закрыть</Button>
|
||||
|
||||
20
web/src/models/authorities.ts
Normal file
20
web/src/models/authorities.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export interface IAuthority {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const Authorities = Object.freeze({
|
||||
TEACHER: {code: "ROLE_TEACHER", name: "Преподаватель"} as IAuthority,
|
||||
STUDENT: {code: "ROLE_STUDENT", name: "Студент"} as IAuthority,
|
||||
COMMISSION_MEMBER: {code: "ROLE_COMMISSION_MEMBER", name: "Член комиссии ГЭК"} as IAuthority,
|
||||
ADMINISTRATOR: {code: "ROLE_ADMINISTRATOR", name: "Администратор"} as IAuthority,
|
||||
SECRETARY: {code: "ROLE_SECRETARY", name: "Секретарь"} as IAuthority,
|
||||
});
|
||||
|
||||
export type Authority = typeof Authorities[keyof typeof Authorities];
|
||||
export type AuthorityCode = Authority['code'];
|
||||
export type AuthorityName = Authority['name'];
|
||||
|
||||
export function getAuthorityByCode(code: AuthorityCode): Authority {
|
||||
return Object.values(Authorities).find(authority => authority.code === code) as Authority;
|
||||
}
|
||||
5
web/src/models/group.ts
Normal file
5
web/src/models/group.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface Group {
|
||||
name: string;
|
||||
curatorName?: string;
|
||||
iAmCurator?: boolean;
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
// todo: update
|
||||
export enum Role {
|
||||
ADMINISTRATOR = "ROLE_ADMINISTRATOR",
|
||||
STUDENT = "ROLE_STUDENT",
|
||||
}
|
||||
|
||||
export interface IAuthority {
|
||||
authority: Role;
|
||||
name: string;
|
||||
}
|
||||
@ -1,15 +1,13 @@
|
||||
import {IAuthority} from "./role";
|
||||
|
||||
export interface IAuthenticated {
|
||||
authenticated: true,
|
||||
login: string,
|
||||
password: string,
|
||||
fullName: string,
|
||||
email: string,
|
||||
phone: string,
|
||||
authorities: string[],
|
||||
|
||||
createdAt: string,
|
||||
updatedAt: string,
|
||||
authorities: IAuthority[],
|
||||
}
|
||||
|
||||
export declare type IUser = {authenticated: false} | IAuthenticated;
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
import {Route} from "mobx-state-router";
|
||||
|
||||
export const routes: Route[] = [{
|
||||
name: 'root',
|
||||
pattern: '/',
|
||||
}, {
|
||||
name: 'profile',
|
||||
pattern: '/profile',
|
||||
}, {
|
||||
name: 'userList',
|
||||
pattern: '/users',
|
||||
}, {
|
||||
name: 'error',
|
||||
pattern: '/error',
|
||||
}];
|
||||
@ -1,12 +0,0 @@
|
||||
import {ViewMap} from "mobx-state-router";
|
||||
import Home from "../components/layout/Home";
|
||||
import Error from "../components/layout/Error";
|
||||
import UserProfilePage from "../components/user/UserProfilePage";
|
||||
import {UserList} from "../components/user/UserList";
|
||||
|
||||
export const viewMap: ViewMap = {
|
||||
root: <Home/>,
|
||||
profile: <UserProfilePage/>,
|
||||
userList: <UserList/>,
|
||||
error: <Error/>,
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
import {MyRouterStore} from "../store/MyRouterStore";
|
||||
import {AppRouterStore} from "../store/AppRouterStore";
|
||||
|
||||
export interface IRouterOptions {
|
||||
redirect: string;
|
||||
}
|
||||
|
||||
export class RouterService {
|
||||
private static router: MyRouterStore;
|
||||
private static router: AppRouterStore;
|
||||
|
||||
static init(router: MyRouterStore) {
|
||||
static init(router: AppRouterStore) {
|
||||
this.router = router;
|
||||
console.debug('RouterService initialized');
|
||||
}
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import {Role} from "../models/role";
|
||||
import {IAuthenticated, IUser} from "../models/user";
|
||||
|
||||
export class UserService {
|
||||
static isUserInRole(user: IUser, role: Role): boolean {
|
||||
if (!user.authenticated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
user = user as IAuthenticated;
|
||||
return user.authorities.some(a => a.authority === role);
|
||||
}
|
||||
}
|
||||
36
web/src/store/AppRouterStore.tsx
Normal file
36
web/src/store/AppRouterStore.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import {browserHistory, createRouterState, HistoryAdapter, Route, RouterStore} from "mobx-state-router";
|
||||
import {RootStore} from "./RootStore";
|
||||
|
||||
export class AppRouterStore extends RouterStore {
|
||||
constructor(rootStore: RootStore) {
|
||||
super(routes, createRouterState('error', {notFound: true}));
|
||||
// makeObservable(this);
|
||||
this.rootStore = rootStore;
|
||||
}
|
||||
|
||||
rootStore: RootStore;
|
||||
|
||||
init() {
|
||||
const historyAdapter = new HistoryAdapter(this, browserHistory);
|
||||
historyAdapter.observeRouterStateChanges();
|
||||
console.debug('MyRouterStore initialized');
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
const routes: Route[] = [{
|
||||
name: 'home',
|
||||
pattern: '/',
|
||||
}, {
|
||||
name: 'profile',
|
||||
pattern: '/profile',
|
||||
}, {
|
||||
name: 'userList',
|
||||
pattern: '/users',
|
||||
}, {
|
||||
name: 'groupList',
|
||||
pattern: '/groups',
|
||||
}, {
|
||||
name: 'error',
|
||||
pattern: '/error',
|
||||
}];
|
||||
@ -1,18 +0,0 @@
|
||||
import {browserHistory, createRouterState, HistoryAdapter, RouterStore} from "mobx-state-router";
|
||||
import {RootStore} from "./RootStore";
|
||||
import {routes} from "../router/routes";
|
||||
|
||||
export class MyRouterStore extends RouterStore {
|
||||
constructor(rootStore: RootStore) {
|
||||
super(routes,
|
||||
createRouterState('error', {notFound: true}),
|
||||
{rootStore: rootStore});
|
||||
}
|
||||
|
||||
init() {
|
||||
const historyAdapter = new HistoryAdapter(this, browserHistory);
|
||||
historyAdapter.observeRouterStateChanges();
|
||||
console.debug('MyRouterStore initialized');
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@ -90,4 +90,9 @@ export class NotificationStore {
|
||||
init() {
|
||||
console.debug('NotificationStore initialized');
|
||||
}
|
||||
|
||||
@action.bound
|
||||
accessDenied() {
|
||||
this.error('Доступ запрещен', 'Ошибка доступа');
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import {MyRouterStore} from "./MyRouterStore";
|
||||
import {AppRouterStore} from "./AppRouterStore";
|
||||
import {UserStore} from "./UserStore";
|
||||
import {ThinkStore} from "./ThinkStore";
|
||||
import {NotificationStore} from "./NotificationStore";
|
||||
@ -7,10 +7,14 @@ import {SysInfoStore} from "./SysInfoStore";
|
||||
export class RootStore {
|
||||
thinkStore = new ThinkStore(this);
|
||||
userStore = new UserStore(this);
|
||||
routerStore = new MyRouterStore(this);
|
||||
routerStore = new AppRouterStore(this);
|
||||
notificationStore = new NotificationStore(this);
|
||||
sysInfoStore = new SysInfoStore(this);
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
|
||||
init() {
|
||||
this.thinkStore.init();
|
||||
this.routerStore.init();
|
||||
|
||||
@ -16,12 +16,12 @@ export class ThinkStore {
|
||||
}
|
||||
|
||||
@action.bound
|
||||
completeOne(key: string) {
|
||||
completeOne(key: string = '$default') {
|
||||
this.thinks.splice(this.thinks.indexOf(key), 1);
|
||||
}
|
||||
|
||||
@action.bound
|
||||
completeAll(key: string) {
|
||||
completeAll(key: string = '$default') {
|
||||
this.thinks = this.thinks.filter(k => k !== key);
|
||||
}
|
||||
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
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 type {IUser} from "../models/user";
|
||||
import {IStudent} from "../models/student";
|
||||
import {Role} from "../models/role";
|
||||
import {Authorities} from "../models/authorities";
|
||||
|
||||
export class UserStore {
|
||||
rootStore: RootStore;
|
||||
@observable user: IUser = {authenticated: false};
|
||||
@observable student?: IStudent;
|
||||
@observable student: IStudent;
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
makeObservable(this);
|
||||
@ -22,7 +22,7 @@ export class UserStore {
|
||||
runInAction(() => {
|
||||
this.user = response;
|
||||
});
|
||||
if (response.authenticated && response.authorities.some(a => a.authority === Role.STUDENT)) {
|
||||
if (this.isStudent) {
|
||||
get<IStudent>('/student/current').then((student) => {
|
||||
runInAction(() => {
|
||||
this.student = student;
|
||||
@ -39,8 +39,34 @@ export class UserStore {
|
||||
});
|
||||
}
|
||||
|
||||
isAdministrator() {
|
||||
return this.user.authenticated && this.user.authorities.some(a => a.authority === Role.ADMINISTRATOR);
|
||||
@computed
|
||||
get isAdministrator() {
|
||||
return this.user.authenticated && this.user.authorities.some(a => a === Authorities.ADMINISTRATOR.code);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isStudent() {
|
||||
return this.user.authenticated && this.user.authorities.some(a => a === Authorities.STUDENT.code);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isTeacher() {
|
||||
return this.user.authenticated && this.user.authorities.some(a => a === Authorities.TEACHER.code);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isCommissionMember() {
|
||||
return this.user.authenticated && this.user.authorities.some(a => a === Authorities.COMMISSION_MEMBER.code);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isSecretary() {
|
||||
return this.user.authenticated && this.user.authorities.some(a => a === Authorities.SECRETARY.code);
|
||||
}
|
||||
|
||||
@computed
|
||||
get authenticated() {
|
||||
return this.user.authenticated
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {Component} from "react";
|
||||
import {RootStoreContext, RootStoreContextType} from "../store/RootStoreContext";
|
||||
import {RootStoreContext, RootStoreContextType} from "./context";
|
||||
|
||||
export abstract class ComponentContext<P = {}, S = {}, SS = any> extends Component<P, S, SS> {
|
||||
declare context: RootStoreContextType;
|
||||
|
||||
1
web/src/utils/comparators.ts
Normal file
1
web/src/utils/comparators.ts
Normal file
@ -0,0 +1 @@
|
||||
export const defaultComparator = (a: any, b: any) => a > b ? 1 : a < b ? -1 : 0;
|
||||
@ -1,5 +1,5 @@
|
||||
import {RootStore} from "./RootStore";
|
||||
import {ContextType, createContext} from "react";
|
||||
import {RootStore} from "../store/RootStore";
|
||||
|
||||
export const RootStoreContext = createContext<RootStore>(new RootStore());
|
||||
export type RootStoreContextType = ContextType<typeof RootStoreContext>;
|
||||
@ -10,6 +10,7 @@ export class ReactiveValue<T> {
|
||||
@observable private errors: string[] = [];
|
||||
@observable private inputRestrictionError: string;
|
||||
@observable private fieldName: string;
|
||||
@observable private syncWithUrlParameter?: string;
|
||||
|
||||
constructor(fireImmediately: boolean = false) {
|
||||
makeObservable(this);
|
||||
@ -56,6 +57,24 @@ export class ReactiveValue<T> {
|
||||
@action.bound
|
||||
setAuto(value: T) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -118,41 +137,10 @@ export class ReactiveValue<T> {
|
||||
}
|
||||
|
||||
@action.bound
|
||||
clear() {
|
||||
rebirth() {
|
||||
this.val = undefined;
|
||||
this.isTouched = false;
|
||||
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);
|
||||
// }
|
||||
// }
|
||||
|
||||
@ -41,19 +41,32 @@ export const strPattern = (regexp: RegExp, message: string) => (value: string, f
|
||||
}
|
||||
|
||||
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} должно быть корректным адресом электронной почты`;
|
||||
}
|
||||
}
|
||||
|
||||
export const emailChars = (value: string, field = 'Поле') => {
|
||||
if (!/^[a-zA-Z\d@._-]*$/.test(value)) {
|
||||
return `${field} должно содержать только латинские буквы, цифры и символы @._-`;
|
||||
}
|
||||
}
|
||||
|
||||
export const phone = (value: string, field = 'Поле') => {
|
||||
if (!/^\+[1-9]\d{6,14}$/.test(value)) {
|
||||
return `${field} должно быть корректным номером телефона`;
|
||||
}
|
||||
}
|
||||
|
||||
export const phoneChars = (value: string, field = 'Поле') => {
|
||||
if (!/^\+\d*$/.test(value)) {
|
||||
return `${field} должно содержать только цифры и символ +`;
|
||||
}
|
||||
}
|
||||
|
||||
export const loginChars = (value: string, field = 'Поле') => {
|
||||
if (!/^[a-zA-Z0-9]*$/.test(value)) {
|
||||
if (!/^[a-zA-Z\d_]*$/.test(value)) {
|
||||
return `${field} должно содержать только латинские буквы, цифры и знак подчеркивания`;
|
||||
}
|
||||
}
|
||||
@ -64,15 +77,33 @@ export const loginLength = (value: string, field = 'Поле') => {
|
||||
}
|
||||
}
|
||||
|
||||
export const passwordChars = (value: string, field = 'Поле') => {
|
||||
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]*$/.test(value)) {
|
||||
export const loginMaxLength = (value: string, field = 'Поле') => {
|
||||
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} должен содержать хотя бы одну цифру, одну заглавную и одну строчную букву`;
|
||||
}
|
||||
}
|
||||
|
||||
export const passwordMaxLength = (value: string, field = 'Поле') => {
|
||||
if (value.length > 32) {
|
||||
return `${field} должно содержать не более 32 символов`;
|
||||
}
|
||||
}
|
||||
|
||||
export const passwordLength = (value: string, field = 'Поле') => {
|
||||
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} должен содержать только латинские буквы, цифры и специальные символы`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,33 +1,83 @@
|
||||
export class Column {
|
||||
key: string;
|
||||
title: string;
|
||||
format: (value: any) => string;
|
||||
import {action, makeObservable, observable} from "mobx";
|
||||
import {defaultComparator} from "./comparators";
|
||||
import _ from "lodash";
|
||||
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.title = title;
|
||||
this.format = format;
|
||||
this.sort = sort;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Sort {
|
||||
column: Column;
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
export class Sort<C> {
|
||||
@observable order: 'asc' | 'desc';
|
||||
@observable comparator: (a: C, b: C) => number;
|
||||
@observable apply: boolean;
|
||||
@observable applyOrder: number;
|
||||
|
||||
export class TableDescriptor<T> {
|
||||
columns: Column[];
|
||||
sorts: Sort[];
|
||||
filters: ((row: T) => boolean)[] = [() => true];
|
||||
data: T[];
|
||||
pageable = false;
|
||||
pageSize = 10;
|
||||
page = 0;
|
||||
constructor(
|
||||
apply = false, applyOrder?: number,
|
||||
comparator: (a: C, b: C) => number = defaultComparator,
|
||||
order: 'asc' | 'desc' = 'asc'
|
||||
) {
|
||||
makeObservable(this);
|
||||
this.order = order;
|
||||
this.comparator = comparator;
|
||||
this.apply = apply;
|
||||
this.applyOrder = applyOrder;
|
||||
}
|
||||
|
||||
constructor(columns: Column[], data: T[], sorts: Sort[] = [], filters: ((row: T) => boolean)[] = [() => true]) {
|
||||
this.columns = columns;
|
||||
this.data = data;
|
||||
this.sorts = sorts;
|
||||
this.filters = filters;
|
||||
@action.bound
|
||||
toggle(other: Sort<C>[]) {
|
||||
if (!this.apply) {
|
||||
this.apply = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
mode: 'none',
|
||||
mode: 'development',
|
||||
entry: {
|
||||
app: path.join(__dirname, 'src', 'Application.tsx')
|
||||
},
|
||||
@ -38,7 +38,7 @@ module.exports = {
|
||||
historyApiFallback: true,
|
||||
static: path.join(__dirname, "dist"),
|
||||
compress: true,
|
||||
port: 8081,
|
||||
port: 8888,
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user