improved, more featured, fixed

Exceptions and Errors are better
Files structure is better
New ComponentContext.ts
New DataTable.tsx tables.ts
Massive components refactoring
New Group.java
New LoggingRequestFilter.java LoggingSessionListener.java
New NotificationStore.ts SysInfoStore.ts
New reactiveValue.ts ReactiveControls.tsx
New dependencies
And much more
This commit is contained in:
Maksim Skobaro 2025-02-07 07:05:15 +03:00
parent 96ffb3ad41
commit c2d19a7724
88 changed files with 1858 additions and 458 deletions

View File

@ -1,8 +1,6 @@
package ru.tubryansk.tdms.config;
import jakarta.servlet.http.HttpSessionEvent;
import jakarta.servlet.http.HttpSessionListener;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Qualifier;
@ -19,7 +17,6 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@ -42,7 +39,7 @@ public class SecurityConfiguration {
) throws Exception {
return httpSecurity
.authorizeHttpRequests(this::configureHttpAuthorization)
.csrf(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable) /* todo: настроить csrf */
.cors(a -> a.configurationSource(cors))
.authenticationManager(authenticationManager)
.sessionManagement(cfg -> {
@ -76,21 +73,6 @@ public class SecurityConfiguration {
};
}
@Bean
public HttpSessionListener httpSessionListener() {
return new HttpSessionListener() {
@Override
public void sessionCreated(HttpSessionEvent se) {
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());
}
};
}
@Bean
public AuthenticationManager authenticationManager(UserDetailsService userDetailsService) {
return new ProviderManager(authenticationProvider(userDetailsService));
@ -102,15 +84,22 @@ public class SecurityConfiguration {
}
private void configureHttpAuthorization(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry httpAuthorization) {
/* API ROUTES */
/* SysInfoController */
httpAuthorization.requestMatchers("/api/v1/sysinfo/**").permitAll();
/* UserController */
httpAuthorization.requestMatchers("/api/v1/user/logout").authenticated();
httpAuthorization.requestMatchers("/api/v1/user/login").anonymous();
httpAuthorization.requestMatchers("/api/v1/user/current").permitAll();
httpAuthorization.requestMatchers("/api/v1/user/get-all").hasAuthority("ROLE_ADMINISTRATOR");
httpAuthorization.requestMatchers("/api/v1/user/register").hasAuthority("ROLE_ADMINISTRATOR");
httpAuthorization.requestMatchers("/api/v1/user/validate-registration").hasAuthority("ROLE_ADMINISTRATOR");
/* StudentController */
httpAuthorization.requestMatchers("/api/v1/student/current").permitAll();
/* GroupController */
httpAuthorization.requestMatchers("/api/v1/group/get-all").permitAll();
/* deny all other api requests */
httpAuthorization.requestMatchers("/api/**").denyAll();
/* STATIC ROUTES */
/* since api already blocked, all other requests are static resources */
httpAuthorization.requestMatchers("/**").permitAll();
}

View File

@ -0,0 +1,20 @@
package ru.tubryansk.tdms.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import ru.tubryansk.tdms.controller.payload.GroupDTO;
import ru.tubryansk.tdms.service.GroupService;
import java.util.Collection;
@RestController("/api/v1/group/")
public class GroupController {
@Autowired
private GroupService groupService;
@GetMapping("/get-all-groups")
public Collection<GroupDTO> getAllGroups() {
return groupService.getAllGroups();
}
}

View File

@ -4,7 +4,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import ru.tubryansk.tdms.dto.StudentDTO;
import ru.tubryansk.tdms.controller.payload.StudentDTO;
import ru.tubryansk.tdms.service.StudentService;
@RestController
@ -15,6 +15,6 @@ public class StudentController {
@GetMapping("/current")
public StudentDTO getCurrentStudent() {
return studentService.getCallerStudent().map(StudentDTO::from).orElse(null);
return studentService.getCallerStudentDTO();
}
}

View File

@ -0,0 +1,21 @@
package ru.tubryansk.tdms.controller;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import ru.tubryansk.tdms.service.SysInfoService;
@RestController
@RequestMapping("/api/v1/sysinfo")
public class SysInfoController {
@Autowired
private SysInfoService sysInfoService;
@SneakyThrows
@GetMapping("/version")
public String getVersion() {
return sysInfoService.getVersion();
}
}

View File

@ -1,12 +1,17 @@
package ru.tubryansk.tdms.controller;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import ru.tubryansk.tdms.dto.LoginDTO;
import ru.tubryansk.tdms.dto.UserDTO;
import ru.tubryansk.tdms.controller.payload.LoginDTO;
import ru.tubryansk.tdms.controller.payload.RegistrationDTO;
import ru.tubryansk.tdms.controller.payload.UserDTO;
import ru.tubryansk.tdms.service.AuthenticationService;
import ru.tubryansk.tdms.service.CallerService;
import ru.tubryansk.tdms.service.UserService;
import java.util.List;
@RestController
@RequestMapping("/api/v1/user")
@ -16,10 +21,12 @@ public class UserController {
private AuthenticationService authenticationService;
@Autowired
private CallerService callerService;
@Autowired
private UserService userService;
@GetMapping("/current")
public UserDTO getCurrentUser() {
return callerService.getCallerUser().map(user -> UserDTO.from(user, true)).orElse(UserDTO.unauthenticated());
return callerService.getCallerUserDTO();
}
@PostMapping("/logout")
@ -28,7 +35,17 @@ public class UserController {
}
@PostMapping("/login")
public void login(@RequestBody LoginDTO loginDTO) {
authenticationService.login(loginDTO.username(), loginDTO.password());
public void login(@RequestBody @Valid LoginDTO loginDTO) {
authenticationService.login(loginDTO.getUsername(), loginDTO.getPassword());
}
@PostMapping("/register")
public void post(@RequestBody @Valid RegistrationDTO registrationDTO) {
userService.registerUser(registrationDTO);
}
@GetMapping("/get-all")
public List<UserDTO> getAllUsers() {
return userService.getAllUsers();
}
}

View File

@ -1,4 +1,4 @@
package ru.tubryansk.tdms.dto;
package ru.tubryansk.tdms.controller.payload;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@ -8,7 +8,7 @@ public record ErrorResponse(String message, ErrorCode errorCode) {
@RequiredArgsConstructor
@Getter
public enum ErrorCode {
BAD_REQUEST(HttpStatus.BAD_REQUEST),
BUSINESS_ERROR(HttpStatus.BAD_REQUEST),
VALIDATION_ERROR(HttpStatus.BAD_REQUEST),
INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR),
NOT_FOUND(HttpStatus.NOT_FOUND),

View File

@ -0,0 +1,14 @@
package ru.tubryansk.tdms.controller.payload;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
public class GroupDTO {
private String name;
private String principalName;
private Boolean isMePrincipal;
}

View File

@ -0,0 +1,12 @@
package ru.tubryansk.tdms.controller.payload;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
@Getter
public class LoginDTO {
@NotEmpty(message = "Логин не может быть пустым")
private String username;
@NotEmpty(message = "Пароль не может быть пустым")
private String password;
}

View File

@ -0,0 +1,34 @@
package ru.tubryansk.tdms.controller.payload;
import jakarta.validation.constraints.*;
import lombok.Getter;
import org.hibernate.validator.constraints.Length;
@Getter
public class RegistrationDTO {
@NotEmpty(message = "Логин не может быть пустым")
@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;
@NotEmpty(message = "Имя не может быть пустым")
@Length(min = 3, message = "Имя должно содержать минимум 3 символа")
@Pattern(regexp = "^[a-zA-Zа-яА-ЯёЁ\\s]+$", message = "Имя должно содержать только буквы английского или русского алфавита и пробелы")
private String fullName;
@NotNull(message = "Почта не может быть пустой")
@Email(message = "Почта должна быть валидным адресом электронной почты")
private String email;
@NotNull(message = "Номер телефона не может быть пустым")
@Pattern(regexp = "^\\+[1-9]\\d{6,14}$", message = "Номер телефона должен начинаться с '+' и содержать от 7 до 15 цифр")
private String numberPhone;
private StudentRegistrationDTO studentData;
@Getter
public static class StudentRegistrationDTO {
@NotNull(message = "Группа не может быть пустой")
private Long groupId;
}
}

View File

@ -1,4 +1,4 @@
package ru.tubryansk.tdms.dto;
package ru.tubryansk.tdms.controller.payload;
import ru.tubryansk.tdms.entity.Role;

View File

@ -0,0 +1,50 @@
package ru.tubryansk.tdms.controller.payload;
import lombok.Data;
import ru.tubryansk.tdms.entity.Student;
@Data
public class StudentDTO {
// private Boolean form;
// private Integer protectionOrder;
// private String magistracy;
// private Boolean digitalFormatPresent;
// private Integer markComment;
// private Integer markPractice;
// private String predefenceComment;
// private String normalControl;
// private Integer antiPlagiarism;
// private String note;
// private Boolean recordBookReturned;
// private String work;
// private UserDTO user;
// private String diplomaTopic;
// private UserDTO mentorUser;
// private GroupDTO group;
public static StudentDTO from(Student student) {
StudentDTO studentDTO = new StudentDTO();
// studentDTO.setForm(student.getForm());
// return studentDTO;
// student.getForm(),
// student.getProtectionOrder(),
// student.getMagistracy(),
// student.getDigitalFormatPresent(),
// student.getMarkComment(),
// student.getMarkPractice(),
// student.getPredefenceComment(),
// student.getNormalControl(),
// student.getAntiPlagiarism(),
// student.getNote(),
// student.getRecordBookReturned(),
// student.getWork(),
// UserDTO.from(student.getUser()),
// student.getDiplomaTopic().getName(),
// UserDTO.from(student.getMentorUser()),
// GroupDTO.from(student.getGroup())
// );
return studentDTO;
}
}

View File

@ -1,4 +1,4 @@
package ru.tubryansk.tdms.dto;
package ru.tubryansk.tdms.controller.payload;
import com.fasterxml.jackson.annotation.JsonInclude;
@ -14,7 +14,6 @@ import java.util.List;
public record UserDTO(
boolean authenticated,
String login,
String password,
String fullName,
String email,
String phone,
@ -28,13 +27,12 @@ public record UserDTO(
.build();
}
public static UserDTO from(User user, boolean anonymize) {
public static UserDTO from(User user) {
return UserDTO.builder()
.authenticated(true)
.login(user.getLogin())
.password(anonymize ? "" : user.getPassword())
.fullName(user.getFullName())
.email(user.getMail())
.email(user.getEmail())
.phone(user.getNumberPhone())
.createdAt(user.getCreatedAt())
.updatedAt(user.getUpdatedAt())

View File

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

View File

@ -1,6 +0,0 @@
package ru.tubryansk.tdms.dto;
public record LoginDTO(
String username,
String password) {
}

View File

@ -1,51 +0,0 @@
package ru.tubryansk.tdms.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import ru.tubryansk.tdms.entity.Student;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class StudentDTO {
private Boolean form;
private Integer protectionOrder;
private String magistracy;
private Boolean digitalFormatPresent;
private Integer markComment;
private Integer markPractice;
private String predefenceComment;
private String normalControl;
private Integer antiPlagiarism;
private String note;
private Boolean recordBookReturned;
private String work;
private UserDTO user;
private String diplomaTopic;
private UserDTO mentorUser;
private GroupDTO group;
public static StudentDTO from(Student student) {
return new StudentDTO(
student.getForm(),
student.getProtectionOrder(),
student.getMagistracy(),
student.getDigitalFormatPresent(),
student.getMarkComment(),
student.getMarkPractice(),
student.getPredefenceComment(),
student.getNormalControl(),
student.getAntiPlagiarism(),
student.getNote(),
student.getRecordBookReturned(),
student.getWork(),
UserDTO.from(student.getUser(), true),
student.getDiplomaTopic().getName(),
UserDTO.from(student.getMentorUser(), true),
GroupDTO.from(student.getGroup())
);
}
}

View File

@ -2,17 +2,15 @@ package ru.tubryansk.tdms.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Entity
@Table(name = "diploma_topic")
public class DiplomaTopic {
@Id
@ -22,3 +20,4 @@ public class DiplomaTopic {
@Column(name = "name")
private String name;
}

View File

@ -2,17 +2,15 @@ package ru.tubryansk.tdms.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Entity
@Table(name = "group")
public class Group {
@Id
@ -21,7 +19,7 @@ public class Group {
private Long id;
@Column(name = "name")
private String name;
@ManyToOne()
@JoinColumn(name = "principal_user_id")
private User principalUser;
@ManyToOne
@JoinColumn(name = "curator_user_id")
private User groupCurator;
}

View File

@ -1,25 +1,22 @@
package ru.tubryansk.tdms.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.springframework.security.core.GrantedAuthority;
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Entity
@Table(name = "`role`")
public class Role implements GrantedAuthority {
@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name")
private String name;

View File

@ -2,10 +2,14 @@ package ru.tubryansk.tdms.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Data
@Getter
@Setter
@ToString
@Entity
@Table(name = "student")
public class Student {

View File

@ -2,10 +2,11 @@ package ru.tubryansk.tdms.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
@ -15,11 +16,10 @@ import java.util.Collection;
import java.util.List;
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Entity
@Table(name = "`user`")
public class User implements UserDetails {
@Id
@ -32,16 +32,19 @@ public class User implements UserDetails {
private String password;
@Column(name = "full_name")
private String fullName;
@Column(name = "mail")
private String mail;
@Column(name = "email")
private String email;
@Column(name = "number_phone")
private String numberPhone;
@Column(name = "created_at")
@CreationTimestamp
private ZonedDateTime createdAt;
@Column(name = "updated_at")
@UpdateTimestamp
private ZonedDateTime updatedAt;
@ManyToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@JoinTable(name = "user_role",
@ManyToMany
@JoinTable(
name = "user_role",
joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"))
private List<Role> roles;

View File

@ -0,0 +1,4 @@
package ru.tubryansk.tdms.entity.repository;
public interface DefenceRepository {
}

View File

@ -1,4 +1,4 @@
package ru.tubryansk.tdms.repository;
package ru.tubryansk.tdms.entity.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

View File

@ -0,0 +1,13 @@
package ru.tubryansk.tdms.entity.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.tubryansk.tdms.entity.Group;
import ru.tubryansk.tdms.exception.NotFoundException;
@Repository
public interface GroupRepository extends JpaRepository<Group, Long> {
default Group findByIdThrow(Long id) {
return this.findById(id).orElseThrow(() -> new NotFoundException(Group.class, id));
}
}

View File

@ -0,0 +1,9 @@
package ru.tubryansk.tdms.entity.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.tubryansk.tdms.entity.Role;
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {
}

View File

@ -1,4 +1,4 @@
package ru.tubryansk.tdms.repository;
package ru.tubryansk.tdms.entity.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

View File

@ -1,7 +1,6 @@
package ru.tubryansk.tdms.repository;
package ru.tubryansk.tdms.entity.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.security.core.userdetails.UserDetails;
import ru.tubryansk.tdms.entity.User;
import java.util.Optional;

View File

@ -1,6 +1,6 @@
package ru.tubryansk.tdms.exception;
import ru.tubryansk.tdms.dto.ErrorResponse;
import ru.tubryansk.tdms.controller.payload.ErrorResponse;
public class AccessDeniedException extends BusinessException {
public AccessDeniedException() {

View File

@ -1,6 +1,6 @@
package ru.tubryansk.tdms.exception;
import ru.tubryansk.tdms.dto.ErrorResponse;
import ru.tubryansk.tdms.controller.payload.ErrorResponse;
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
@ -8,6 +8,6 @@ public class BusinessException extends RuntimeException {
}
public ErrorResponse.ErrorCode getErrorCode() {
return ErrorResponse.ErrorCode.INTERNAL_ERROR;
return ErrorResponse.ErrorCode.BUSINESS_ERROR;
}
}

View File

@ -2,42 +2,55 @@ package ru.tubryansk.tdms.exception;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import ru.tubryansk.tdms.dto.ErrorResponse;
import ru.tubryansk.tdms.controller.payload.ErrorResponse;
import java.util.stream.Collectors;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
// todo: make a better error message
return new ErrorResponse(e.getMessage(), ErrorResponse.ErrorCode.VALIDATION_ERROR);
public ErrorResponse handleMethodArgumentNotValidException(BindException e) {
log.debug("Validation error: {}", e.getMessage());
String validationErrors = e.getAllErrors().stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.joining(", "));
return new ErrorResponse(validationErrors, ErrorResponse.ErrorCode.VALIDATION_ERROR);
}
@ExceptionHandler(BusinessException.class)
public ErrorResponse handleBusinessException(BusinessException e, HttpServletResponse response) {
log.info("Business error", e);
response.setStatus(e.getErrorCode().getHttpStatus().value());
return new ErrorResponse(e.getMessage(), e.getErrorCode());
}
@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);
}
@ExceptionHandler(NoResourceFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNoResourceFoundException(NoResourceFoundException e) {
// todo: make error page
log.error("Resource not found", e);
return new ErrorResponse(e.getMessage(), ErrorResponse.ErrorCode.NOT_FOUND);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleUnexpectedException(Exception e) {
// todo: make error page
log.error("Unexpected exception.", e);
return new ErrorResponse(e.getMessage(), ErrorResponse.ErrorCode.INTERNAL_ERROR);
}

View File

@ -1,10 +1,10 @@
package ru.tubryansk.tdms.exception;
import ru.tubryansk.tdms.dto.ErrorResponse;
import ru.tubryansk.tdms.controller.payload.ErrorResponse;
public class NotFoundException extends BusinessException {
public NotFoundException(Class<?> entityClass, Integer id) {
super(entityClass.getSimpleName() + " with id " + id + " not found");
public NotFoundException(Class<?> entityClass, Object id) {
super(entityClass.getSimpleName() + " с идентификатором " + id + " не наеден");
}
@Override

View File

@ -8,6 +8,7 @@ import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.tubryansk.tdms.entity.User;
import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY;
@ -32,6 +33,7 @@ public class AuthenticationService {
}
}
@Transactional
public void login(String username, String password) {
try {
var context = SecurityContextHolder.createEmptyContext();
@ -41,8 +43,8 @@ public class AuthenticationService {
SecurityContextHolder.setContext(context);
request.getSession(true).setAttribute(SPRING_SECURITY_CONTEXT_KEY, context);
} catch (Exception e) {
log.error("Failed to log in user: {}. {}", username, e.getMessage());
return;
log.error("Failed to log in user: {}", username, e);
throw e;
}
log.debug("User {} logged in", username);
}

View File

@ -3,6 +3,7 @@ package ru.tubryansk.tdms.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import ru.tubryansk.tdms.controller.payload.UserDTO;
import ru.tubryansk.tdms.entity.User;
import java.util.Optional;
@ -18,4 +19,8 @@ public class CallerService {
}
return Optional.empty();
}
public UserDTO getCallerUserDTO() {
return getCallerUser().map(UserDTO::from).orElse(UserDTO.unauthenticated());
}
}

View File

@ -0,0 +1,15 @@
package ru.tubryansk.tdms.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.tubryansk.tdms.controller.payload.GroupDTO;
import java.util.Collection;
@Service
@Transactional
public class GroupService {
public Collection<GroupDTO> getAllGroups() {
return null;
}
}

View File

@ -0,0 +1,38 @@
package ru.tubryansk.tdms.service;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.tubryansk.tdms.entity.Role;
import ru.tubryansk.tdms.entity.repository.RoleRepository;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class RoleService {
public enum Authority {
ROLE_ADMINISTRATOR,
ROLE_COMMISSION_MEMBER,
ROLE_TEACHER,
ROLE_SECRETARY,
ROLE_STUDENT,
}
public transient Map<String, Role> roles;
@Autowired
private RoleRepository roleRepository;
@PostConstruct
@Transactional
public void init() {
roles = new ConcurrentHashMap<>();
roleRepository.findAll().forEach(role -> roles.put(role.getAuthority(), role));
}
public Role getRoleByAuthority(Authority authority) {
return roles.get(authority.name());
}
}

View File

@ -3,11 +3,12 @@ package ru.tubryansk.tdms.service;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import ru.tubryansk.tdms.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 ru.tubryansk.tdms.repository.DiplomaTopicRepository;
import ru.tubryansk.tdms.repository.StudentRepository;
import java.util.Map;
import java.util.Optional;
@ -43,4 +44,13 @@ public class StudentService {
public Optional<Student> getCallerStudent() {
return studentRepository.findByUser(callerService.getCallerUser().orElse(null));
}
public StudentDTO getCallerStudentDTO() {
Student callerStudent = getCallerStudent().orElse(null);
if (callerStudent == null) {
return null;
}
return StudentDTO.from(callerStudent);
}
}

View File

@ -0,0 +1,14 @@
package ru.tubryansk.tdms.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class SysInfoService {
@Value("${application.version}")
private String version;
public String getVersion() {
return version;
}
}

View File

@ -3,14 +3,21 @@ package ru.tubryansk.tdms.service;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import ru.tubryansk.tdms.controller.payload.RegistrationDTO;
import ru.tubryansk.tdms.controller.payload.UserDTO;
import ru.tubryansk.tdms.entity.Role;
import ru.tubryansk.tdms.entity.Student;
import ru.tubryansk.tdms.entity.User;
import ru.tubryansk.tdms.repository.UserRepository;
import ru.tubryansk.tdms.entity.repository.GroupRepository;
import ru.tubryansk.tdms.entity.repository.StudentRepository;
import ru.tubryansk.tdms.entity.repository.UserRepository;
import java.util.Optional;
import java.util.ArrayList;
import java.util.List;
@Service
@Transactional
@ -18,10 +25,75 @@ import java.util.Optional;
public class UserService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Autowired
private GroupRepository groupRepository;
@Autowired
private StudentRepository studentRepository;
@Autowired
private RoleService roleService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public User loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findUserByLogin(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
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);
return user;
}
public List<UserDTO> getAllUsers() {
log.info("Loading all users");
List<UserDTO> users = userRepository.findAll().stream()
.map(UserDTO::from)
.toList();
log.info("{} users loaded", users.size());
return users;
}
public void registerUser(RegistrationDTO registrationDTO) {
log.info("Registering new user with login: {}", registrationDTO.getLogin());
User user = transientUser(registrationDTO);
Student student = transientStudent(registrationDTO.getStudentData());
fillRoles(user, registrationDTO);
log.info("Saving new user: {}", user);
userRepository.save(user);
if (student != null) {
student.setUser(user);
log.info("User is student, saving student: {}", student);
studentRepository.save(student);
}
}
private User transientUser(RegistrationDTO registrationDTO) {
User user = new User();
user.setLogin(registrationDTO.getLogin());
user.setPassword(passwordEncoder.encode(registrationDTO.getPassword()));
user.setFullName(registrationDTO.getFullName());
user.setEmail(registrationDTO.getEmail());
user.setNumberPhone(registrationDTO.getNumberPhone());
return user;
}
private Student transientStudent(RegistrationDTO.StudentRegistrationDTO studentData) {
if (studentData == null) {
return null;
}
Student student = new Student();
student.setGroup(groupRepository.findByIdThrow(studentData.getGroupId()));
return student;
}
private void fillRoles(User user, RegistrationDTO registrationDTO) {
List<Role> roles = new ArrayList<>();
if (registrationDTO.getStudentData() != null) {
roles.add(roleService.getRoleByAuthority(RoleService.Authority.ROLE_STUDENT));
}
user.setRoles(roles);
}
}

View File

@ -0,0 +1,29 @@
package ru.tubryansk.tdms.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@Slf4j
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());
try {
filterChain.doFilter(request, response);
} finally {
long duration = System.currentTimeMillis() - startTime;
log.info("Request finished with {} status. duration: {} ms", response.getStatus(), duration);
}
}
}

View File

@ -0,0 +1,23 @@
package ru.tubryansk.tdms.web;
import jakarta.servlet.http.HttpSessionEvent;
import jakarta.servlet.http.HttpSessionListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class LoggingSessionListener implements HttpSessionListener {
@Override
public void sessionCreated(HttpSessionEvent se) {
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());
}
}

View File

@ -1,27 +0,0 @@
package ru.tubryansk.tdms.web;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.AbstractRequestLoggingFilter;
@Component
@Slf4j
public class RequestLogger extends AbstractRequestLoggingFilter {
@Override
protected void beforeRequest(HttpServletRequest request, String message) {
String logMessage = message +
", method=" + request.getMethod() +
", uri=" + request.getRequestURI() +
", query=" + request.getQueryString() +
", remote=" + request.getRemoteAddr() +
", user=" + request.getRemoteUser();
log.debug(logMessage);
}
@Override
protected void afterRequest(HttpServletRequest request, String message) {
// do nothing
}
}

View File

@ -1,10 +1,13 @@
create table role
(
id bigint primary key,
name text not null unique,
authority text not null unique
);
-- COMMENTS
comment on table role is 'Таблица ролей пользователей';
comment on column role.name is 'Человекочитаемое имя роли';
comment on column role.authority is 'Имя роли в системе';

View File

@ -1,20 +1,22 @@
create table "user"
(
id bigserial primary key,
login text not null unique,
password text not null,
full_name text not null,
mail text not null unique,
email text not null unique,
number_phone text not null unique,
created_at timestamptz not null,
updated_at timestamptz
);
-- COMMENTS
comment on table "user" is 'Таблица пользователей';
comment on column "user".login is 'Логин пользователя';
comment on column "user".password is 'Пароль пользователя';
comment on column "user".full_name is 'Полное имя пользователя в формате Фамилия Имя Отчество';
comment on column "user".mail is 'Почта пользователя';
comment on column "user".email is 'Почта пользователя';
comment on column "user".number_phone is 'Номер телефона пользователя';
comment on column "user".created_at is 'Дата создания записи';
comment on column "user".updated_at is 'Дата последнего обновления записи';

View File

@ -1,6 +1,7 @@
create table user_role
(
id bigserial primary key,
user_id bigint not null,
role_id bigint not null
);
@ -14,5 +15,7 @@ alter table user_role
foreign key (role_id) references role (id);
-- COMMENTS
comment on table user_role is 'Таблица связи пользователей и ролей';
comment on column user_role.user_id is 'Идентификатор пользователя';
comment on column user_role.role_id is 'Идентификатор роли';

View File

@ -1,8 +1,11 @@
create table diploma_topic
(
id bigserial primary key,
name text not null unique
name text not null
);
-- COMMENTS
comment on table diploma_topic is 'Таблица тем дипломных работ';
comment on column diploma_topic.name is 'Название темы дипломной работы';

View File

@ -1,16 +1,22 @@
create table "group"
(
id bigserial primary key,
name text not null unique,
principal_user_id bigint not null
curator_user_id bigint,
created_at timestamptz not null,
updated_at timestamptz
);
-- FOREIGN KEY
alter table "group"
add constraint fk_group_principal_user_id
foreign key (principal_user_id) references "user" (id)
on delete cascade;
add constraint fk_group_curator_user_id
foreign key (curator_user_id) references "user" (id)
on delete set null on update cascade;
-- COMMENTS
comment on table "group" is 'Таблица групп студентов';
comment on column "group".name is 'Название группы';
comment on column "group".principal_user_id is 'Идентификатор куратора группы';
comment on column "group".curator_user_id is 'Идентификатор куратора группы';

View File

@ -1,22 +1,25 @@
create table student
(
id bigserial primary key,
user_id bigint not null,
diploma_topic_id bigint not null,
mentor_user_id bigint not null,
group_id bigint not null,
form boolean,
protection_order integer not null,
protection_day int,
protection_order int,
magistracy text,
digital_format_present boolean,
mark_comment integer,
mark_practice integer,
mark_comment int,
mark_practice int,
predefence_comment text,
normal_control text,
anti_plagiarism int,
note text,
record_book_returned boolean,
work text,
user_id bigint not null,
diploma_topic_id bigint not null,
mentor_user_id bigint not null,
group_id bigint not null,
created_at timestamptz not null,
updated_at timestamptz
);
@ -25,22 +28,30 @@ create table student
alter table student
add constraint fk_student_user_id
foreign key (user_id) references "user" (id)
on delete cascade;
on delete cascade on update cascade;
alter table student
add constraint fk_student_diploma_topic_id
foreign key (diploma_topic_id) references diploma_topic (id)
on delete cascade;
on delete set null on update cascade;
alter table student
add constraint fk_student_mentor_user_id
foreign key (mentor_user_id) references "user" (id)
on delete cascade;
on delete set null on update cascade;
alter table student
add constraint fk_student_group_id
foreign key (group_id) references "group" (id)
on delete cascade;
on delete set null on update cascade;
-- COMMENTS
comment on table student is 'Таблица студентов';
comment on column student.user_id is 'Идентификатор пользователя';
comment on column student.diploma_topic_id is 'Идентификатор темы дипломной работы';
comment on column student.mentor_user_id is 'Идентификатор научного руководителя';
comment on column student.group_id is 'Идентификатор группы';
comment on column student.form is 'Форма обучения';
comment on column student.protection_day is 'День защиты';
comment on column student.protection_order is 'Порядок защиты';
comment on column student.magistracy is 'Магистратура';
comment on column student.digital_format_present is 'Предоставлен в электронном виде';
@ -52,7 +63,3 @@ comment on column student.anti_plagiarism is 'Антиплагиат';
comment on column student.note is 'Примечание';
comment on column student.record_book_returned is 'Ведомость возвращена';
comment on column student.work is 'Работа';
comment on column student.user_id is 'Идентификатор пользователя';
comment on column student.diploma_topic_id is 'Идентификатор темы дипломной работы';
comment on column student.mentor_user_id is 'Идентификатор научного руководителя';
comment on column student.group_id is 'Идентификатор группы';

View File

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

51
web/package-lock.json generated
View File

@ -13,14 +13,17 @@
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"@types/lodash": "^4.17.15",
"axios": "^1.7.7",
"bootstrap": "^5.3.3",
"lodash": "^4.17.21",
"mobx": "^6.13.1",
"mobx-react": "^9.1.1",
"mobx-state-router": "^6.0.1",
"react": "^18.2.0",
"react-bootstrap": "^2.10.4",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"uuid": "^11.0.5"
},
"devDependencies": {
"@babel/plugin-proposal-decorators": "^7.25.7",
@ -2105,6 +2108,12 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
"node_modules/@types/lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==",
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -3858,20 +3867,6 @@
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -4591,7 +4586,7 @@
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
@ -5974,6 +5969,16 @@
"websocket-driver": "^0.7.4"
}
},
"node_modules/sockjs/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -6454,12 +6459,16 @@
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/value-equal": {

View File

@ -12,14 +12,17 @@
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"@types/lodash": "^4.17.15",
"axios": "^1.7.7",
"bootstrap": "^5.3.3",
"lodash": "^4.17.21",
"mobx": "^6.13.1",
"mobx-react": "^9.1.1",
"mobx-state-router": "^6.0.1",
"react": "^18.2.0",
"react-bootstrap": "^2.10.4",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"uuid": "^11.0.5"
},
"devDependencies": {
"@babel/plugin-proposal-decorators": "^7.25.7",

View File

@ -3,7 +3,7 @@ import './index.css'
import 'bootstrap/dist/css/bootstrap.min.css';
import {RouterContext, RouterView} from "mobx-state-router";
import {initApp} from "./utils/init";
import {RootStoreContext} from './context/RootStoreContext';
import {RootStoreContext} from './store/RootStoreContext';
import {viewMap} from "./router/viewMap";
const rootStore = initApp();

View File

@ -0,0 +1,89 @@
import {ComponentContext} from "../utils/ComponentContext";
import {observer} from "mobx-react";
import {Notification, NotificationType} from "../store/NotificationStore";
import {Card, CardBody, CardHeader, CardText, CardTitle, Col, Row} from "react-bootstrap";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {action, makeObservable} from "mobx";
@observer
export class NotificationContainer extends ComponentContext {
forEachNotificationRender(notifications: Notification[], type: NotificationType) {
return notifications.map(notification => (
<NotificationPopup key={notification.uuid} notification={notification} type={type}/>
));
}
render() {
return <div style={{position: 'fixed', left: '50%', transform: 'translateX(-50%)', zIndex: 1000}}>
{this.forEachNotificationRender(this.notificationStore.errors, NotificationType.ERROR)}
{this.forEachNotificationRender(this.notificationStore.successes, NotificationType.SUCCESS)}
{this.forEachNotificationRender(this.notificationStore.warnings, NotificationType.WARNING)}
{this.forEachNotificationRender(this.notificationStore.infos, NotificationType.INFO)}
</div>
}
}
@observer
class NotificationPopup extends ComponentContext<{ notification: Notification, type: NotificationType }> {
constructor(props: { notification: Notification, type: NotificationType }) {
super(props);
makeObservable(this);
}
@action.bound
close() {
this.notificationStore.close(this.props.notification.uuid);
}
get cardClassName() {
switch (this.props.type) {
case NotificationType.ERROR:
return 'text-bg-danger';
case NotificationType.WARNING:
return 'text-bg-warning';
case NotificationType.INFO:
return 'text-bg-info';
case NotificationType.SUCCESS:
return 'text-bg-success';
}
}
render() {
const hasTitle = !!this.props.notification.title && this.props.notification.title.length > 0;
const closeIcon = <FontAwesomeIcon icon={'close'} onClick={this.close}/>;
return <Card className={`position-relative mt-3 opacity-75 ${this.cardClassName}`}>
{
hasTitle &&
<CardHeader>
<CardTitle>
<Row>
<Col sm={11}>
{this.props.notification.title}
</Col>
<Col className={'text-end'}>
{closeIcon}
</Col>
</Row>
</CardTitle>
</CardHeader>
}
<CardBody>
<CardText>
<Row>
<Col sm={11}>
{this.props.notification.message}
</Col>
{
!hasTitle &&
<Col className={'text-end'}>
{closeIcon}
</Col>
}
</Row>
</CardText>
</CardBody>
</Card>
}
}

View File

@ -0,0 +1,147 @@
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";
export interface DataTableProps<T> {
tableDescriptor: TableDescriptor<T>;
}
@observer
export class DataTable<T> extends ComponentContext<DataTableProps<T>> {
constructor(props: DataTableProps<T>) {
super(props);
makeObservable(this);
}
header() {
return <tr>
{this.props.tableDescriptor.columns.map(column => <th className={'text-center'}
key={column.key}>{column.title}</th>)}
</tr>
}
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]}>
{
this.props.tableDescriptor.columns.map(column => {
return <td
className={'text-center'}
key={column.key}>
{column.format(rowAny[column.key])}
</td>
})
}
</tr>
});
}
isFirstPage() {
if (typeof this.props.tableDescriptor.page === 'undefined') {
return true;
}
return this.props.tableDescriptor.page === 0;
}
isLastPage() {
if (typeof this.props.tableDescriptor.page === 'undefined' || typeof this.props.tableDescriptor.pageSize === 'undefined') {
return true;
}
return this.props.tableDescriptor.page === (this.props.tableDescriptor.data.length / this.props.tableDescriptor.pageSize);
}
@action.bound
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}>
<option>10</option>
<option>20</option>
<option>50</option>
<option>100</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>
}
}

View File

@ -0,0 +1,3 @@
.l-no-bg label::after {
background-color: rgba(0, 0, 0, 0) !important;
}

View File

@ -0,0 +1,116 @@
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 {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import './ReactiveControls.css';
export interface ReactiveInputProps<T> {
value: ReactiveValue<T>;
label?: string;
disabled?: boolean;
className?: string;
}
@observer
export class StringInput extends React.Component<ReactiveInputProps<string>> {
constructor(props: any) {
super(props);
makeObservable(this);
if (this.props.value.value === undefined) {
this.props.value.setAuto('');
}
this.props.value.setField(this.props.label);
}
@action.bound
onChange(event: React.ChangeEvent<HTMLInputElement>) {
this.props.value.set(event.currentTarget.value);
}
render() {
return <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`}/>
</FloatingLabel>
<FormText children={this.props.value.firstError} className={`text-danger mt-0 mb-0 d-block`}/>
</div>
}
}
@observer
export class PasswordInput extends React.Component<ReactiveInputProps<string>> {
@observable showPassword = false;
constructor(props: any) {
super(props);
makeObservable(this);
if (this.props.value.value === undefined) {
this.props.value.setAuto('');
}
this.props.value.setField(this.props.label);
}
@action.bound
onChange(event: React.ChangeEvent<HTMLInputElement>) {
this.props.value.set(event.currentTarget.value);
}
@action.bound
toggleShowPassword() {
this.showPassword = !this.showPassword;
}
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`}>
<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"}>
<FontAwesomeIcon icon={this.showPassword ? 'eye-slash' : 'eye'}/>
</Button>
</div>
<FormText children={this.props.value.firstError} className={'text-danger d-block mt-0 mb-0'}/>
</div>
}
}
@observer
export class SelectButtonInput extends React.Component<ReactiveInputProps<string>> {
constructor(props: any) {
super(props);
makeObservable(this);
if (this.props.value.value === undefined) {
this.props.value.setAuto('');
}
this.props.value.setField(this.props.label);
}
@action.bound
onChange(event: React.ChangeEvent<HTMLInputElement>) {
this.props.value.set(event.currentTarget.value);
}
render() {
return <>
<ButtonGroup className={'d-block l-no-bg'}>
<ToggleButton key={'admin'} value={'admin'} id={`radio-admin`} type="radio"
variant={'outline-primary'} children={'Администратор'}
checked={this.props.value.value === 'admin'} onChange={this.onChange}/>
<ToggleButton key={'student'} id={`radio-student`} type="radio" value={'student'}
variant={'outline-primary'}
checked={this.props.value.value === 'student'} onChange={this.onChange}
children={'Студент'}/>
</ButtonGroup>
<FormText children={this.props.value.firstError} className={'text-danger d-block'}/>
</>
}
}

View File

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

View File

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

View File

@ -0,0 +1,41 @@
import {ComponentContext} from "../../utils/ComponentContext";
import {observer} from "mobx-react";
import {makeObservable} from "mobx";
import {Container, Nav, Navbar, NavbarText, NavLink} from "react-bootstrap";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {findIconDefinition} from "@fortawesome/fontawesome-svg-core";
@observer
export class Footer extends ComponentContext {
constructor(props: any) {
super(props);
makeObservable(this);
}
render() {
return <footer>
<Navbar className="bg-body-tertiary">
<Container>
<div>
<NavbarText>Thesis Defence Management System &mdash; </NavbarText>
{
this.thinkStore.isThinking('updateVersion') &&
<FontAwesomeIcon icon='gear' spin/>
}
{
!this.thinkStore.isThinking('updateVersion') &&
<NavbarText>{this.sysInfoStore.version}</NavbarText>
}
</div>
<Nav>
<NavLink href="https://git.mskobaro.ru/mskobaro/TDMS">
<FontAwesomeIcon icon={findIconDefinition({iconName: 'github', prefix: 'fab'})} size="xl"/>
</NavLink>
</Nav>
</Container>
</Navbar>
</footer>
}
}

View File

@ -1,19 +1,17 @@
import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap";
import {Component} from "react";
import {RouterLink} from "mobx-state-router";
import {IAuthenticated} from "../../../models/user";
import {RootStoreContext, RootStoreContextType} from "../../../context/RootStoreContext";
import {IAuthenticated} from "../../models/user";
import {RootStoreContext, RootStoreContextType} from "../../store/RootStoreContext";
import {observer} from "mobx-react";
import {post} from "../../../utils/request";
import {LoginModal} from "../../user/LoginModal";
import {ModalState} from "../../../state/ModalState";
import {post} from "../../utils/request";
import {LoginModal} from "../user/LoginModal";
import {ModalState} from "../../utils/modalState";
import {action, makeObservable} from "mobx";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {ComponentContext} from "../../utils/ComponentContext";
@observer
class Header extends Component {
declare context: RootStoreContextType;
static contextType = RootStoreContext;
class Header extends ComponentContext {
loginModalState = new ModalState();
@ -22,13 +20,10 @@ class Header extends Component {
makeObservable(this);
}
get loginThink() {
return this.context.pendingStore.isThinking('updateCurrentUser');
}
render() {
const userStore = this.context.userStore;
const user = userStore.user;
let thinking = this.thinkStore.isThinking('updateCurrentUser');
return <>
<header>
@ -38,23 +33,27 @@ class Header extends Component {
<Nav.Link as={RouterLink} routeName='root'>TDMS</Nav.Link>
</Navbar.Brand>
<Nav>
<NavDropdown title="Группы">
<NavDropdown.Item>Список</NavDropdown.Item>
<NavDropdown.Item>Редактировать</NavDropdown.Item>
{
user.authenticated && userStore.isAdministrator() &&
<NavDropdown title="Пользователи">
<NavDropdown.Item as={RouterLink} routeName={'userList'} children={'Список'}/>
<NavDropdown.Item as={RouterLink} routeName={'userRegistration'}
children={'Зарегистрировать'}/>
</NavDropdown>
}
</Nav>
<Nav className="ms-auto">
{
this.loginThink &&
thinking &&
<FontAwesomeIcon icon='gear' spin/>
}
{
user.authenticated && !this.loginThink &&
user.authenticated && !thinking &&
<AuthenticatedItems/>
}
{
!user.authenticated && !this.loginThink &&
!user.authenticated && !thinking &&
<>
<Nav.Link onClick={this.loginModalState.open}>Войти</Nav.Link>
</>
@ -68,7 +67,8 @@ class Header extends Component {
}
}
class AuthenticatedItems extends Component {
@observer
class AuthenticatedItems extends ComponentContext<any, any> {
declare context: RootStoreContextType;
static contextType = RootStoreContext;

View File

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

View File

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

View File

@ -1,22 +0,0 @@
import {Container, Nav, Navbar} from "react-bootstrap";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {findIconDefinition} from "@fortawesome/fontawesome-svg-core";
const Footer = () => {
return (
<footer>
<Navbar className="bg-body-tertiary">
<Container>
<Navbar.Text>Thesis Defence Management System &copy;</Navbar.Text>
<Nav>
<Nav.Link href="https://github.com/Velixeor/Thesis-Defense-Management-System">
<FontAwesomeIcon icon={findIconDefinition({iconName:'github', prefix:'fab'})} size="xl"/>
</Nav.Link>
</Nav>
</Container>
</Navbar>
</footer>
)
}
export default Footer;

View File

@ -1,25 +1,22 @@
import {ChangeEvent, Component} from "react";
import {ChangeEvent} from "react";
import {Button, FormControl, FormGroup, FormLabel, FormText, Modal} from "react-bootstrap";
import {ModalState} from "../../state/ModalState";
import {ModalState} from "../../utils/modalState";
import {observer} from "mobx-react";
import {action, computed, makeObservable, observable, reaction} from "mobx";
import {post} from "../../utils/request";
import {RootStoreContext, RootStoreContextType} from "../../context/RootStoreContext";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {ComponentContext} from "../../utils/ComponentContext";
interface LoginModalProps {
modalState: ModalState;
}
@observer
export class LoginModal extends Component<LoginModalProps> {
declare context: RootStoreContextType;
static contextType = RootStoreContext;
modalState = this.props.modalState;
export class LoginModal extends ComponentContext<LoginModalProps> {
@observable login = '';
@observable loginError = '';
@observable password = '';
@observable passwordError = '';
@observable rememberMe = false;
constructor(props: LoginModalProps) {
super(props);
@ -74,24 +71,47 @@ export class LoginModal extends Component<LoginModalProps> {
}
@action.bound
onLogin() {
tryLogin() {
if (this.loginButtonDisabled)
return;
this.thinkStore.think('loginModal');
post('user/login', {
username: this.login,
password: this.password
}).then(() => {
this.context.userStore.updateCurrentUser();
this.modalState.close();
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() {
return <Modal show={this.modalState.isOpen} centered>
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>
@ -111,11 +131,12 @@ export class LoginModal extends Component<LoginModalProps> {
</FormGroup>
</Modal.Body>
<Modal.Footer>
<Button variant={'primary'} onClick={this.onLogin} disabled={this.loginButtonDisabled}>Войти</Button>
<Button variant={'secondary'} onClick={this.modalState.close}>Закрыть</Button>
<Button variant={'primary'} onClick={this.tryLogin}
disabled={this.loginButtonDisabled}>Войти</Button>
<Button variant={'secondary'} onClick={this.props.modalState.close}>Закрыть</Button>
</Modal.Footer>
</>
}
</Modal>
}
}

View File

@ -0,0 +1,60 @@
import {observer} from "mobx-react";
import {action, makeObservable, observable, reaction, runInAction} from "mobx";
import {DefaultPage} from "../layout/DefaultPage";
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 {
constructor(props: {}) {
super(props);
makeObservable(this);
}
componentDidMount() {
this.requestUsers();
reaction(() => this.users, () => {
if (typeof this.users === 'undefined') {
return;
}
this.tableDescriptor = new TableDescriptor(this.userColumns, this.users);
}, {fireImmediately: true});
}
@observable users?: IAuthenticated[];
@observable tableDescriptor?: TableDescriptor<IAuthenticated>;
userColumns = [
new Column('login', 'Логин'),
new Column('fullName', 'Полное имя'),
new Column('email', 'Email'),
new Column('phone', 'Телефон'),
new Column('createdAt', 'Дата создания'),
new Column('updatedAt', 'Дата обновления', (value: string) => value ? value : 'Не обновлялось'),
];
@action.bound
requestUsers() {
this.thinkStore.think('userList');
get<IAuthenticated[]>('user/get-all').then((users) => {
runInAction(() => {
this.users = users;
});
}).finally(() => {
this.thinkStore.completeAll('userList');
});
}
get page() {
return <>
{
this.tableDescriptor &&
<DataTable tableDescriptor={this.tableDescriptor}/>
}
</>
}
}

View File

@ -1,7 +1,7 @@
import {DefaultPage} from "../page/layout/DefaultPage";
import {DefaultPage} from "../layout/DefaultPage";
import {Col, Form, Row} from "react-bootstrap";
import {observer} from "mobx-react";
import {RootStoreContext, type RootStoreContextType} from "../../context/RootStoreContext";
import {RootStoreContext, type RootStoreContextType} from "../../store/RootStoreContext";
import {IAuthenticated} from "../../models/user";
import {Component} from "react";
import {dateConverter} from "../../utils/converters";

View File

@ -0,0 +1,87 @@
import {observer} from "mobx-react";
import {DefaultPage} from "../layout/DefaultPage";
import {action, computed, makeObservable, observable} from "mobx";
import {Button, Col, Form, Row} from "react-bootstrap";
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 {
email,
loginChars,
loginLength,
nameChars,
nameLength,
passwordChars,
passwordLength,
phone,
required
} from "../../utils/reactive/validators";
@observer
export class UserRegistration extends DefaultPage {
constructor(props: any) {
super(props);
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 accountType = new ReactiveValue<string>().addValidator(required).addValidator((value) => {
if (!['student', 'admin'].includes(value)) {
return 'Тип аккаунта должен быть "СТУДЕНТ" или "АДМИНИСТРАТОР"';
}
});
@computed
get formInvalid() {
return this.login.invalid || !this.login.touched
|| this.password.invalid || !this.password.touched
|| this.fullName.invalid || !this.fullName.touched
|| this.email.invalid || !this.email.touched
|| this.numberPhone.invalid || !this.numberPhone.touched
|| this.accountType.invalid || !this.accountType.touched;
}
@action.bound
submit() {
post('user/register', {
login: this.login.value,
password: this.password.value,
fullName: this.fullName.value,
email: this.email.value,
numberPhone: this.numberPhone.value,
// studentData: { groupId: 1 }
} as UserRegistrationDTO).then(() => {
this.notificationStore.success('Пользователь успешно зарегистрирован');
}).catch(() => {
this.notificationStore.error('Ошибка регистрации пользователя');
});
}
get page() {
return <div className={'w-75 ms-auto me-auto'}>
<Form>
<Row>
<Col>
<StringInput value={this.login} label={"Логин"}/>
<PasswordInput value={this.password} label={"Пароль"}/>
</Col>
<Col>
<StringInput value={this.fullName} label={"Полное имя"}/>
<StringInput value={this.email} label={"Почта"}/>
<StringInput value={this.numberPhone} label={"Телефон"}/>
</Col>
</Row>
<SelectButtonInput value={this.accountType} label={'Тип аккаунта'}/>
<Button disabled={this.formInvalid} onClick={this.submit}>Зарегистрировать</Button>
</Form>
</div>
}
}

View File

@ -0,0 +1,12 @@
export interface UserRegistrationDTO {
login: string,
password: string,
fullName: string,
email: string,
numberPhone: string,
studentData?: StudentRegistrationDTO
}
export interface StudentRegistrationDTO {
groupId: number;
}

View File

@ -1,8 +1,7 @@
// todo: update
export enum Role {
STUDENT = 'ROLE_STUDENT',
TUTOR = 'ROLE_TUTOR',
DIRECTOR = 'ROLE_DIRECTOR',
ADMINISTRATOR = "ROLE_ADMINISTRATOR",
STUDENT = "ROLE_STUDENT",
}
export interface IAuthority {

View File

@ -6,6 +6,12 @@ export const routes: Route[] = [{
}, {
name: 'profile',
pattern: '/profile',
}, {
name: 'userList',
pattern: '/users',
}, {
name: 'userRegistration',
pattern: '/user-registration',
}, {
name: 'error',
pattern: '/error',

View File

@ -1,10 +1,14 @@
import {ViewMap} from "mobx-state-router";
import Home from "../components/page/Home";
import Error from "../components/page/Error";
import Home from "../components/layout/Home";
import Error from "../components/layout/Error";
import UserProfilePage from "../components/user/UserProfilePage";
import {UserList} from "../components/user/UserList";
import {UserRegistration} from "../components/user/UserRegistration";
export const viewMap: ViewMap = {
root: <Home/>,
profile: <UserProfilePage/>,
userList: <UserList/>,
userRegistration: <UserRegistration/>,
error: <Error/>,
}

View File

@ -0,0 +1,42 @@
import {NotificationStore} from "../store/NotificationStore";
export class NotificationService {
static store?: NotificationStore;
static init(store: NotificationStore) {
this.store = store;
console.debug('NotificationService initialized');
}
static error(message: string, title?: string) {
if (!this.store) {
console.log(`NotificationStore is not initialized\nMessage: ${message}\nTitle: ${title}`);
return;
}
this.store.error(message, title);
}
static warn(message: string, title?: string) {
if (!this.store) {
console.log(`NotificationStore is not initialized\nMessage: ${message}\nTitle: ${title}`);
return;
}
this.store.warn(message, title);
}
static info(message: string, title?: string) {
if (!this.store) {
console.log(`NotificationStore is not initialized\nMessage: ${message}\nTitle: ${title}`);
return;
}
this.store.info(message, title);
}
static success(message: string, title?: string) {
if (!this.store) {
console.log(`NotificationStore is not initialized\nMessage: ${message}\nTitle: ${title}`);
return;
}
this.store.success(message, title);
}
}

View File

@ -9,6 +9,7 @@ export class RouterService {
static init(router: MyRouterStore) {
this.router = router;
console.debug('RouterService initialized');
}
static redirect(state: string, options?: IRouterOptions) {

View File

@ -12,6 +12,7 @@ export class MyRouterStore extends RouterStore {
init() {
const historyAdapter = new HistoryAdapter(this, browserHistory);
historyAdapter.observeRouterStateChanges();
console.debug('MyRouterStore initialized');
return this;
}
}

View File

@ -0,0 +1,93 @@
import {RootStore} from "./RootStore";
import {action, makeObservable, observable, runInAction} from "mobx";
import {v7 as uuidGen} from 'uuid';
export class Notification {
uuid: string;
message: string;
title?: string;
constructor(message: string, title?: string) {
this.title = title;
this.message = message;
this.uuid = uuidGen();
}
}
export enum NotificationType {
ERROR = 'error',
WARNING = 'warning',
INFO = 'info',
SUCCESS = 'success'
}
export class NotificationStore {
rootStore: RootStore;
@observable errors: Notification[] = [];
@observable warnings: Notification[] = [];
@observable infos: Notification[] = [];
@observable successes: Notification[] = [];
timeout: number = 5000;
constructor(rootStore: RootStore) {
this.rootStore = rootStore;
makeObservable(this);
}
@action.bound
notify(message: string, type: NotificationType, title?: string) {
let notification = new Notification(message, title);
let container: Notification[];
switch (type) {
case NotificationType.ERROR:
container = this.errors;
break;
case NotificationType.WARNING:
container = this.warnings;
break;
case NotificationType.INFO:
container = this.infos;
break;
case NotificationType.SUCCESS:
container = this.successes;
break;
}
container.push(notification);
setTimeout(() => runInAction(() => {
this.close(notification.uuid);
}), this.timeout);
}
@action.bound
error(message: string, title?: string) {
this.notify(message, NotificationType.ERROR, title);
}
@action.bound
warn(message: string, title?: string) {
this.notify(message, NotificationType.WARNING, title);
}
@action.bound
info(message: string, title?: string) {
this.notify(message, NotificationType.INFO, title);
}
@action.bound
success(message: string, title?: string) {
this.notify(message, NotificationType.SUCCESS, title);
}
@action.bound
close(uuid: string) {
this.errors = this.errors.filter(n => n.uuid !== uuid);
this.warnings = this.warnings.filter(n => n.uuid !== uuid);
this.infos = this.infos.filter(n => n.uuid !== uuid);
this.successes = this.successes.filter(n => n.uuid !== uuid);
}
init() {
console.debug('NotificationStore initialized');
}
}

View File

@ -1,41 +0,0 @@
import {action, makeObservable, observable} from "mobx";
import {RootStore} from "./RootStore";
export class PendingStore {
rootStore: RootStore;
@observable pendingMap = new Map<string, number>();
constructor(rootStore: RootStore) {
this.rootStore = rootStore;
makeObservable(this);
}
@action.bound
think(key: string = '$default') {
const count = this.pendingMap.get(key) || 0;
this.pendingMap.set(key, count + 1);
}
@action.bound
completeOne(key: string) {
const count = this.pendingMap.get(key);
if (count === undefined) {
return;
}
this.pendingMap.set(key, count - 1);
if (this.pendingMap.get(key) === 0) {
this.pendingMap.delete(key);
}
}
@action.bound
completeAll(key: string) {
this.pendingMap.delete(key);
}
@action.bound
isThinking(key: string = '$default') {
return this.pendingMap.get(key) !== undefined;
}
}

View File

@ -1,15 +1,23 @@
import {MyRouterStore} from "./MyRouterStore";
import {UserStore} from "./UserStore";
import {PendingStore} from "./PendingStore";
import {ThinkStore} from "./ThinkStore";
import {NotificationStore} from "./NotificationStore";
import {SysInfoStore} from "./SysInfoStore";
export class RootStore {
pendingStore = new PendingStore(this);
thinkStore = new ThinkStore(this);
userStore = new UserStore(this);
routerStore = new MyRouterStore(this);
notificationStore = new NotificationStore(this);
sysInfoStore = new SysInfoStore(this);
init() {
this.userStore.init();
this.thinkStore.init();
this.routerStore.init();
this.notificationStore.init();
this.sysInfoStore.init();
this.userStore.init();
console.debug('RootStore initialized');
return this;
}
}

View File

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

View File

@ -0,0 +1,32 @@
import {RootStore} from "./RootStore";
import {action, makeObservable, observable, runInAction} from "mobx";
import {get} from "../utils/request";
export class SysInfoStore {
rootStore: RootStore;
@observable version: string = 'unknown';
constructor(rootStore: RootStore) {
makeObservable(this);
this.rootStore = rootStore;
}
@action.bound
updateVersion() {
this.rootStore.thinkStore.think('updateVersion');
get<string>('/sysinfo/version').then((response) => {
runInAction(() => {
this.version = response;
});
}).finally(() => {
runInAction(() => {
this.rootStore.thinkStore.completeOne('updateVersion');
});
});
}
init() {
this.updateVersion();
console.debug('SysInfoStore initialized');
}
}

View File

@ -0,0 +1,35 @@
import {action, makeObservable, observable} from "mobx";
import {RootStore} from "./RootStore";
export class ThinkStore {
rootStore: RootStore;
@observable thinks: string[] = [];
constructor(rootStore: RootStore) {
this.rootStore = rootStore;
makeObservable(this);
}
@action.bound
think(key: string = '$default') {
this.thinks.push(key);
}
@action.bound
completeOne(key: string) {
this.thinks.splice(this.thinks.indexOf(key), 1);
}
@action.bound
completeAll(key: string) {
this.thinks = this.thinks.filter(k => k !== key);
}
isThinking(key: string = '$default') {
return this.thinks.includes(key);
}
init() {
console.debug('ThinkStore initialized');
}
}

View File

@ -16,9 +16,8 @@ export class UserStore {
}
@action.bound
updateCurrentUser() {
// todo: store token in localStorage
this.rootStore.pendingStore.think('updateCurrentUser');
updateCurrentUser(callback?: (user: IUser) => void) {
this.rootStore.thinkStore.think('updateCurrentUser');
get<IUser>('/user/current').then((response) => {
runInAction(() => {
this.user = response;
@ -32,13 +31,21 @@ export class UserStore {
}
}).finally(() => {
runInAction(() => {
this.rootStore.pendingStore.completeOne('updateCurrentUser');
this.rootStore.thinkStore.completeOne('updateCurrentUser');
if (callback) {
callback(this.user);
}
});
});
}
isAdministrator() {
return this.user.authenticated && this.user.authorities.some(a => a.authority === Role.ADMINISTRATOR);
}
init() {
this.updateCurrentUser();
console.debug('UserStore initialized');
return this;
}
}

View File

@ -0,0 +1,31 @@
import {Component} from "react";
import {RootStoreContext, RootStoreContextType} from "../store/RootStoreContext";
export abstract class ComponentContext<P = {}, S = {}, SS = any> extends Component<P, S, SS> {
declare context: RootStoreContextType;
static contextType = RootStoreContext;
get ctx() {
return this.context;
}
get thinkStore() {
return this.context.thinkStore;
}
get userStore() {
return this.context.userStore;
}
get routerStore() {
return this.context.routerStore;
}
get notificationStore() {
return this.context.notificationStore;
}
get sysInfoStore() {
return this.context.sysInfoStore;
}
}

View File

@ -1,25 +1,47 @@
import {configure} from "mobx";
import {RootStore} from "../store/RootStore";
import {RouterService} from "../services/RouterService";
import { library } from '@fortawesome/fontawesome-svg-core';
import { fas } from '@fortawesome/free-solid-svg-icons';
import {library} from '@fortawesome/fontawesome-svg-core';
import {fas} from '@fortawesome/free-solid-svg-icons';
import {fab} from "@fortawesome/free-brands-svg-icons";
import {far} from "@fortawesome/free-regular-svg-icons";
import {NotificationService} from "../services/NotificationService";
const initMobX = () => {
configure({enforceActions: 'observed'});
console.debug('MobX initialized');
}
const initFontAwesome = () => {
library.add(fas);
library.add(fab);
library.add(far);
console.debug('FontAwesome initialized');
}
const initLibs = () => {
initMobX();
initFontAwesome();
console.debug('Libraries initialized');
}
const initServices = (rootStore: RootStore) => {
RouterService.init(rootStore.routerStore);
NotificationService.init(rootStore.notificationStore);
console.debug('Services initialized');
}
export const initApp = () => {
initMobX();
initFontAwesome();
console.debug('Initializing app');
console.debug('>>>>>>>>>>>>>>>>>>>>>>>>');
initLibs();
let rootStore = new RootStore().init();
RouterService.init(rootStore.routerStore);
initServices(rootStore);
console.debug('<<<<<<<<<<<<<<<<<<<<<<<<');
console.debug('App initialized');
return rootStore;
}

View File

@ -0,0 +1,127 @@
import {action, computed, makeObservable, observable, reaction} from "mobx";
import React from "react";
import _ from "lodash";
export class ReactiveValue<T> {
@observable private val: T;
@observable private isTouched: boolean = false;
@observable private validators: ((value: T, field: string) => string)[] = [];
@observable private errors: string[] = [];
@observable private fieldName: string;
constructor(fireImmediately: boolean = false) {
makeObservable(this);
reaction(() => ({value: this.val, touched: this.isTouched}), () => {
if (!this.isTouched) {
this.errors = [];
return;
}
this.errors = this.validators
.map(validator => validator(this.val, this.fieldName))
.filter(error => error && error.length > 0);
}, {fireImmediately});
}
@computed
get value() {
return this.val;
}
@action.bound
set(value: T) {
this.val = value;
this.isTouched = true;
return this;
}
@action.bound
setAuto(value: T) {
this.val = value;
return this;
}
@action.bound
touch() {
this.isTouched = true;
}
@action.bound
untouch() {
this.isTouched = false;
}
@computed
get touched() {
return this.isTouched;
}
@action.bound
addValidator(validator: (value: T, field?: string) => string) {
this.validators.push(validator);
return this;
}
@computed
get invalid() {
return this.errors.length > 0;
}
@computed
get allErrors() {
return this.errors;
}
@computed
get firstError() {
return this.errors[0];
}
@action.bound
setField(field: string) {
this.fieldName = field;
}
@computed
get field() {
return this.fieldName;
}
@action.bound
clear() {
this.val = null;
this.isTouched = false;
this.errors = [];
}
}
export class NumberField extends ReactiveValue<number> {
constructor(fireImmediately: boolean = false) {
super(fireImmediately);
makeObservable(this);
this.addValidator(value => {
if (_.isNaN(value)) {
return 'Должно быть числом';
}
return null;
});
}
@action.bound
onChange(event: React.ChangeEvent<HTMLInputElement>) {
this.set(_.toNumber(event.currentTarget.value));
}
}
export class BooleanField extends ReactiveValue<boolean> {
constructor(fireImmediately: boolean = false) {
super(fireImmediately);
makeObservable(this);
}
@action.bound
onChange(event: React.ChangeEvent<HTMLInputElement>) {
this.set(event.currentTarget.checked);
}
}

View File

@ -0,0 +1,106 @@
export const required = (value: any, field = 'Поле') => {
if (!value || (typeof value === 'string' && value.trim().length === 0)) {
return `${field} обязательно для заполнения`;
}
}
export const greaterThan = (min: number) => (value: number, field = 'Значение') => {
if (value && value <= min) {
return `${field} должно быть больше ${min}`;
}
}
export const lessThan = (max: number) => (value: number, field = 'Значение') => {
if (value && value >= max) {
return `${field} должно быть меньше ${max}`;
}
}
export const equals = (expected: any) => (value: any, field = 'Поле') => {
if (value && value !== expected) {
return `${field} должно быть равно ${expected}`;
}
}
export const length = (min: number, max: number) => (value: string, field = 'Поле') => {
if (value && (value.length < min || value.length > max)) {
return `${field} должно содержать от ${min} до ${max} символов`;
}
}
export const pattern = (regexp: RegExp, message: string) => (value: string, field = '') => {
if (!regexp.test(value)) {
return message;
}
}
export const email = (value: string, field = 'Поле') => {
if (!/^.+@.+\..+$/.test(value)) {
return `${field} должно быть корректным адресом электронной почты`;
}
}
export const phone = (value: string, field = 'Поле') => {
if (!/^\+[1-9]\d{6,14}$/.test(value)) {
return `${field} должно быть корректным номером телефона`;
}
}
export const loginChars = (value: string, field = 'Поле') => {
if (!/^[a-zA-Z0-9]*$/.test(value)) {
return `${field} должно содержать только латинские буквы, цифры и знак подчеркивания`;
}
}
export const loginLength = (value: string, field = 'Поле') => {
if (value.length < 5 || value.length > 32) {
return `${field} должно содержать от 5 до 32 символов`;
}
}
export const passwordChars = (value: string, field = 'Поле') => {
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]*$/.test(value)) {
return `${field} должен содержать хотя бы одну цифру, одну заглавную и одну строчную букву`;
}
}
export const passwordLength = (value: string, field = 'Поле') => {
if (value.length < 8 || value.length > 32) {
return `${field} должен содержать от 8 до 32 символов`;
}
}
export const nameChars = (value: string, field = 'Поле') => {
if (!/^[a-zA-Zа-яА-ЯёЁ\s]+$/.test(value)) {
return `${field} должно содержать только буквы английского или русского алфавита и пробелы`;
}
}
export const nameLength = (value: string, field = 'Поле') => {
if (value.length < 3) {
return `${field} должно содержать минимум 3 символа`;
}
}
export const number = (value: string, field = 'Поле') => {
if (!/^\d+$/.test(value)) {
return `${field} должно быть числом`;
}
}
export const date = (value: string, field = 'Поле') => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return `${field} должно быть датой в формате ГГГГ-ММ-ДД`;
}
}
export const time = (value: string, field = 'Поле') => {
if (!/^\d{2}:\d{2}$/.test(value)) {
return `${field} должно быть временем в формате ЧЧ:ММ`;
}
}
export const dateTime = (value: string, field = 'Поле') => {
if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(value)) {
return `${field} должно быть датой и временем в формате ГГГГ-ММ-ДД ЧЧ:ММ`;
}
}

32
web/src/utils/request.ts Normal file
View File

@ -0,0 +1,32 @@
import axios, {AxiosError, AxiosRequestConfig} from "axios";
import {NotificationService} from "../services/NotificationService";
export const apiUrl = "http://localhost:8080/api/v1/";
export const get = async <R>(url: string, data?: any, doReject = true, showError = true) => await request<R>({
url: url,
method: 'GET',
data: data,
}, doReject, showError);
export const post = async <R>(url: string, data?: any, doReject = true, showError = true) => await request<R>({
url: url,
method: 'POST',
data: data,
}, doReject, showError);
export const request = async <R>(config: AxiosRequestConfig<any>, doReject: boolean, showError: boolean) => {
return new Promise<R>((resolve, reject) => {
axios.request<R>({...config, baseURL: apiUrl, withCredentials: true}).then((response) => {
resolve(response.data);
}).catch((error: AxiosError) => {
if (showError) {
if (error.response) NotificationService.error(JSON.stringify(error.response.data), 'Сервер вернул ошибку, код статуса: ' + error.response.status);
else if (error.request) NotificationService.error(error.request, 'Не удалось получить ответ от сервера');
else NotificationService.error(error.message, 'Запрос отправить не удалось');
}
if (doReject) reject(error);
});
});
}

View File

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

33
web/src/utils/tables.ts Normal file
View File

@ -0,0 +1,33 @@
export class Column {
key: string;
title: string;
format: (value: any) => string;
constructor(key: string, title: string, format: (value: any) => string = value => value) {
this.key = key;
this.title = title;
this.format = format;
}
}
export interface Sort {
column: Column;
order: 'asc' | 'desc';
}
export class TableDescriptor<T> {
columns: Column[];
sorts: Sort[];
filters: ((row: T) => boolean)[] = [() => true];
data: T[];
pageable = false;
pageSize = 10;
page = 0;
constructor(columns: Column[], data: T[], sorts: Sort[] = [], filters: ((row: T) => boolean)[] = [() => true]) {
this.columns = columns;
this.data = data;
this.sorts = sorts;
this.filters = filters;
}
}

View File

@ -15,10 +15,11 @@
"emitDecoratorMetadata": true,
/* lint */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitAny": true,
"noImplicitThis": true,
"strictFunctionTypes": true,
"alwaysStrict": true,
"strictBindCallApply": true,
"allowSyntheticDefaultImports": true
}
}