Начал 5-6 лабу, вроде пока все рабоатет, добавил роли

This commit is contained in:
Никита Потапов 2024-05-13 01:21:52 +04:00
parent 5453c9291b
commit a4a6a74775
19 changed files with 601 additions and 5 deletions

View File

@ -19,10 +19,17 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
implementation 'org.modelmapper:modelmapper:3.2.0' implementation 'org.modelmapper:modelmapper:3.2.0'
// implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.5'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.h2database:h2:2.2.224' implementation 'com.h2database:h2:2.2.224'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.3.0'
runtimeOnly 'org.webjars.npm:bootstrap:5.3.3'
runtimeOnly 'org.webjars.npm:bootstrap-icons:1.11.3'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
} }
tasks.named('test') { tasks.named('test') {

View File

@ -0,0 +1,18 @@
package com.example.nekontakte.core.api;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import jakarta.servlet.http.HttpServletRequest;
@ControllerAdvice
public class GlobalController {
public GlobalController() {
}
@ModelAttribute("servletPath")
String getRequestServletPath(HttpServletRequest request) {
return request.getServletPath();
}
}

View File

@ -0,0 +1,18 @@
package com.example.nekontakte.core.api;
import java.util.Map;
import java.util.function.Function;
import org.springframework.data.domain.Page;
public class PageAttributesMapper {
private PageAttributesMapper() {
}
public static <E, D> Map<String, Object> toAttributes(Page<E> page, Function<E, D> mapper) {
return Map.of(
"items", page.getContent().stream().map(mapper::apply).toList(),
"currentPage", page.getNumber(),
"totalPages", page.getTotalPages());
}
}

View File

@ -5,6 +5,17 @@ public class Constants {
public static final String API_URL = "/api"; public static final String API_URL = "/api";
public static final int DEFUALT_PAGE_SIZE = 5;
public static final String REDIRECT_VIEW = "redirect:";
public static final String ADMIN_PREFIX = "/admin";
public static final String LOGIN_URL = "/login";
public static final String LOGOUT_URL = "/logout";
public static final String DEFAULT_PASSWORD = "123456";
private Constants() { private Constants() {
} }
} }

View File

@ -1,13 +1,23 @@
package com.example.nekontakte.core.configurations; package com.example.nekontakte.core.configurations;
import org.modelmapper.ModelMapper; import org.modelmapper.ModelMapper;
import org.modelmapper.PropertyMap;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import com.example.nekontakte.core.model.BaseEntity;
@Configuration @Configuration
public class MapperConfiguration { public class MapperConfiguration {
@Bean @Bean
ModelMapper modelMapper() { ModelMapper modelMapper() {
return new ModelMapper(); final ModelMapper mapper = new ModelMapper();
mapper.addMappings(new PropertyMap<Object, BaseEntity>() {
@Override
protected void configure() {
skip(destination.getId());
}
});
return mapper;
} }
} }

View File

@ -3,6 +3,7 @@ package com.example.nekontakte.core.configurations;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration @Configuration
@ -13,4 +14,9 @@ public class WebConfig implements WebMvcConfigurer {
.allowedMethods("GET", "POST", "PUT", "DELETE"); .allowedMethods("GET", "POST", "PUT", "DELETE");
} }
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/login").setViewName("login");
}
} }

View File

@ -0,0 +1,53 @@
package com.example.nekontakte.core.errors;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.ModelAndView;
import jakarta.servlet.http.HttpServletRequest;
@ControllerAdvice
public class AdviceController {
private final Logger log = LoggerFactory.getLogger(AdviceController.class);
private static Throwable getRootCause(Throwable throwable) {
Throwable rootCause = throwable;
while (rootCause.getCause() != null && rootCause.getCause() != rootCause) {
rootCause = rootCause.getCause();
}
return rootCause;
}
private static Map<String, Object> getAttributes(HttpServletRequest request, Throwable throwable) {
final Throwable rootCause = getRootCause(throwable);
final StackTraceElement firstError = rootCause.getStackTrace()[0];
return Map.of(
"message", rootCause.getMessage(),
"url", request.getRequestURL(),
"exception", rootCause.getClass().getName(),
"file", firstError.getFileName(),
"method", firstError.getMethodName(),
"line", firstError.getLineNumber());
}
@ExceptionHandler(value = Exception.class)
public ModelAndView defaultErrorHandler(HttpServletRequest request, Throwable throwable) throws Throwable {
if (AnnotationUtils.findAnnotation(throwable.getClass(),
ResponseStatus.class) != null) {
throw throwable;
}
log.error("{}", throwable.getMessage());
throwable.printStackTrace();
final ModelAndView model = new ModelAndView();
model.addAllObjects(getAttributes(request, throwable));
model.setViewName("error");
return model;
}
}

View File

@ -0,0 +1,63 @@
package com.example.nekontakte.core.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import com.example.nekontakte.core.configurations.Constants;
import com.example.nekontakte.users.api.UserSignupController;
import com.example.nekontakte.users.model.UserRole;
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Bean
SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.headers(headers -> headers.frameOptions(FrameOptionsConfig::sameOrigin));
httpSecurity.csrf(AbstractHttpConfigurer::disable);
httpSecurity.cors(Customizer.withDefaults());
httpSecurity.authorizeHttpRequests(requests -> requests
.requestMatchers("/css/**", "/webjars/**", "/*.svg")
.permitAll());
httpSecurity.authorizeHttpRequests(requests -> requests
.requestMatchers(Constants.ADMIN_PREFIX + "/**").hasRole(UserRole.ADMIN.name())
.requestMatchers("/h2-console/**").hasRole(UserRole.ADMIN.name())
.requestMatchers(UserSignupController.URL).anonymous()
.requestMatchers(Constants.LOGIN_URL).anonymous()
.anyRequest().authenticated());
httpSecurity.formLogin(formLogin -> formLogin
.loginPage(Constants.LOGIN_URL));
httpSecurity.rememberMe(rememberMe -> rememberMe.key("uniqueAndSecret"));
httpSecurity.logout(logout -> logout
.deleteCookies("JSESSIONID"));
return httpSecurity.build();
}
@Bean
DaoAuthenticationProvider authenticationProvider(UserDetailsService userDetailsService) {
final DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@ -0,0 +1,64 @@
package com.example.nekontakte.core.security;
import java.util.Collection;
import java.util.Set;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import com.example.nekontakte.users.model.UserEntity;
public class UserPrincipal implements UserDetails {
private final long id;
private final String username;
private final String password;
private final Set<? extends GrantedAuthority> roles;
private final boolean active;
public UserPrincipal(UserEntity user) {
this.id = user.getId();
this.username = user.getUsername();
this.password = user.getPassword();
this.roles = Set.of(user.getRole());
this.active = true;
}
public Long getId() {
return id;
}
@Override
public String getUsername() {
return username;
}
@Override
public String getPassword() {
return password;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles;
}
@Override
public boolean isEnabled() {
return active;
}
@Override
public boolean isAccountNonExpired() {
return isEnabled();
}
@Override
public boolean isAccountNonLocked() {
return isEnabled();
}
@Override
public boolean isCredentialsNonExpired() {
return isEnabled();
}
}

View File

@ -13,6 +13,8 @@ public class UserDTO {
@JsonProperty(access = JsonProperty.Access.READ_ONLY) @JsonProperty(access = JsonProperty.Access.READ_ONLY)
private Integer id; private Integer id;
private String role;
@NotBlank @NotBlank
private String name; private String name;
@ -118,4 +120,11 @@ public class UserDTO {
this.status = status; this.status = status;
} }
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
} }

View File

@ -0,0 +1,65 @@
package com.example.nekontakte.users.api;
import java.util.Objects;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.example.nekontakte.core.configurations.Constants;
import com.example.nekontakte.users.model.UserEntity;
import com.example.nekontakte.users.service.UserService;
import jakarta.validation.Valid;
@Controller
@RequestMapping(UserSignupController.URL)
public class UserSignupController {
public static final String URL = "/signup";
private static final String SIGNUP_VIEW = "signup";
private static final String USER_ATTRIBUTE = "user";
private final UserService userService;
private final ModelMapper modelMapper;
public UserSignupController(
UserService userService,
ModelMapper modelMapper) {
this.userService = userService;
this.modelMapper = modelMapper;
}
private UserEntity toEntity(UserSignupDto dto) {
return modelMapper.map(dto, UserEntity.class);
}
@GetMapping
public String getSignup(Model model) {
model.addAttribute(USER_ATTRIBUTE, new UserSignupDto());
return SIGNUP_VIEW;
}
@PostMapping
public String signup(
@ModelAttribute(name = USER_ATTRIBUTE) @Valid UserSignupDto user,
BindingResult bindingResult,
Model model) {
if (bindingResult.hasErrors()) {
return SIGNUP_VIEW;
}
if (!Objects.equals(user.getPassword(), user.getPasswordConfirm())) {
bindingResult.rejectValue("password", "signup:passwords", "Пароли не совпадают.");
model.addAttribute(USER_ATTRIBUTE, user);
return SIGNUP_VIEW;
}
userService.create(toEntity(user));
return Constants.REDIRECT_VIEW + Constants.LOGIN_URL + "?signup";
}
}

View File

@ -0,0 +1,40 @@
package com.example.nekontakte.users.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class UserSignupDto {
@NotBlank
@Size(min = 3, max = 20)
private String login;
@NotBlank
@Size(min = 3, max = 20)
private String password;
@NotBlank
@Size(min = 3, max = 20)
private String passwordConfirm;
public String getLogin() {
return login;
}
public void setLogin(String login) {
this.login = login;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getPasswordConfirm() {
return passwordConfirm;
}
public void setPasswordConfirm(String passwordConfirm) {
this.passwordConfirm = passwordConfirm;
}
}

View File

@ -27,6 +27,7 @@ public class UserEntity extends BaseEntity {
@Temporal(value = TemporalType.DATE) @Temporal(value = TemporalType.DATE)
@Column(nullable = false) @Column(nullable = false)
private Date birthday; private Date birthday;
private UserRole role;
@Column @Column
private String city; private String city;
@Column @Column
@ -61,6 +62,14 @@ public class UserEntity extends BaseEntity {
setIsAdmin(isAdmin); setIsAdmin(isAdmin);
} }
public UserRole getRole() {
return role;
}
public void setRole(UserRole role) {
this.role = role;
}
public Set<PostEntity> getPosts() { public Set<PostEntity> getPosts() {
return posts; return posts;
} }
@ -149,7 +158,8 @@ public class UserEntity extends BaseEntity {
status, status,
city, city,
birthday, birthday,
avatarImg); avatarImg,
role);
} }
@Override @Override
@ -168,6 +178,7 @@ public class UserEntity extends BaseEntity {
Objects.equals(other.getStatus(), status) && Objects.equals(other.getStatus(), status) &&
Objects.equals(other.getCity(), city) && Objects.equals(other.getCity(), city) &&
Objects.equals(other.getBirthday(), birthday) && Objects.equals(other.getBirthday(), birthday) &&
Objects.equals(other.getAvatarImg(), avatarImg); Objects.equals(other.getAvatarImg(), avatarImg) &&
Objects.equals(other.getRole(), role);
} }
} }

View File

@ -0,0 +1,15 @@
package com.example.nekontakte.users.model;
import org.springframework.security.core.GrantedAuthority;
public enum UserRole implements GrantedAuthority {
ADMIN,
USER;
private static final String PREFIX = "ROLE_";
@Override
public String getAuthority() {
return PREFIX + this.name();
}
}

View File

@ -2,9 +2,13 @@ package com.example.nekontakte.users.service;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.example.nekontakte.core.errors.NotFoundException; import com.example.nekontakte.core.errors.NotFoundException;
import com.example.nekontakte.core.security.UserPrincipal;
import com.example.nekontakte.users.model.UserEntity; import com.example.nekontakte.users.model.UserEntity;
import com.example.nekontakte.users.repository.UserRepository; import com.example.nekontakte.users.repository.UserRepository;
@ -14,7 +18,7 @@ import java.util.List;
import java.util.stream.StreamSupport; import java.util.stream.StreamSupport;
@Service @Service
public class UserService { public class UserService implements UserDetailsService {
private final UserRepository repository; private final UserRepository repository;
private boolean checkUsernameIsExist(String username) { private boolean checkUsernameIsExist(String username) {
@ -79,4 +83,17 @@ public class UserService {
repository.delete(existsEntity); repository.delete(existsEntity);
return existsEntity; return existsEntity;
} }
@Transactional(readOnly = true)
public UserEntity getByUsername(String username) {
return repository.findByUsernameIgnoreCase(username)
.orElseThrow(() -> new IllegalArgumentException("Invalid login"));
}
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
final UserEntity existsUser = getByUsername(username);
return new UserPrincipal(existsUser);
}
} }

View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="ru" data-bs-theme="dark" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title layout:title-pattern="$LAYOUT_TITLE - $CONTENT_TITLE">My shop</title>
<script type="text/javascript" src="/webjars/bootstrap/5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<link rel="stylesheet" href="/webjars/bootstrap/5.3.3/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="/webjars/bootstrap-icons/1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/css/style.css" />
</head>
<body class="h-100 d-flex flex-column">
<nav class="navbar navbar-expand-md my-navbar" data-bs-theme="dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<i class="bi bi-cart2 d-inline-block align-top me-1 logo"></i>
MyShop
</a>
<th:block sec:authorize="isAuthenticated()" th:with="userName=${#authentication.name}">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#main-navbar"
aria-controls="main-navbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="main-navbar">
<ul class="navbar-nav me-auto link" th:with="activeLink=${#objects.nullSafe(servletPath, '')}">
<th:block sec:authorize="hasRole('ADMIN')">
<a class="nav-link" href="/admin/user"
th:classappend="${activeLink.startsWith('/admin/user') ? 'active' : ''}">
Пользователи
</a>
<a class="nav-link" href="/admin/type"
th:classappend="${activeLink.startsWith('/admin/type') ? 'active' : ''}">
Типы заказов
</a>
<a class="nav-link" href="/admin/subscription"
th:classappend="${activeLink.startsWith('/admin/subscription') ? 'active' : ''}">
Списки рассылки
</a>
<a class="nav-link" href="/h2-console/" target="_blank">Консоль H2</a>
</th:block>
<a class="nav-link" href="/123" target="_blank">Ошибка 1</a>
<a class="nav-link" href="/admin/123" target="_blank">Ошибка 2</a>
</ul>
<ul class="navbar-nav" th:if="${not #strings.isEmpty(userName)}">
<form th:action="@{/logout}" method="post">
<button type="submit" class="navbar-brand nav-link" onclick="return confirm('Вы уверены?')">
Выход ([[${userName}]])
</button>
</form>
<a class="navbar-brand" href="/cart">
<i class="bi bi-cart2 d-inline-block align-top me-1 logo"></i>
[[${#numbers.formatDecimal(totalCart, 1, 2)}]] &#8381;
</a>
</ul>
</div>
</th:block>
</div>
</nav>
<main class="container-fluid p-2" layout:fragment="content">
</main>
<footer class="my-footer mt-auto d-flex flex-shrink-0 justify-content-center align-items-center">
Автор, [[${#dates.year(#dates.createNow())}]]
</footer>
</body>
</html>

View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="ru" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{default}">
<head>
<title>Ошибка</title>
</head>
<body>
<main layout:fragment="content">
<ul class="list-group mb-2">
<th:block th:if="${#strings.isEmpty(message)}">
<li class="list-group-item">
Неизвестная ошибка
</li>
</th:block>
<th:block th:if="${not #strings.isEmpty(message)}">
<li class="list-group-item">
<strong>Ошибка:</strong> [[${message}]]
</li>
</th:block>
<th:block th:if="${not #strings.isEmpty(url)}">
<li class="list-group-item">
<strong>Адрес:</strong> [[${url}]]
</li>
<li class="list-group-item">
<strong>Класс исключения:</strong> [[${exception}]]
</li>
<li class="list-group-item">
[[${method}]] ([[${file}]]:[[${line}]])
</li>
</th:block>
</ul>
<a class="btn btn-primary button-fixed-width" href="/">На главную</a>
</main>
</body>
</html>

View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="ru" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{default}">
<head>
<title>Вход</title>
</head>
<body>
<main layout:fragment="content">
<form action="#" th:action="@{/login}" method="post">
<div th:if="${param.error}" class="alert alert-danger">
Неверный логин или пароль
</div>
<div th:if="${param.logout}" class="alert alert-success">
Выход успешно произведен
</div>
<div th:if="${param.signup}" class="alert alert-success">
Пользователь успешно создан
</div>
<div class="mb-3">
<label for="username" class="form-label">Имя пользователя</label>
<input type="text" id="username" name="username" class="form-control" required minlength="3"
maxlength="20">
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль</label>
<input type="password" id="password" name="password" class="form-control" required minlength="3"
maxlength="20">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="remember-me" name="remember-me" checked>
<label class="form-check-label" for="remember-me">Запомнить меня</label>
</div>
<div class="mb-3 d-flex flex-row">
<button class="btn btn-primary me-2 button-fixed-width" type="submit">Войти</button>
<a class="btn btn-secondary button-fixed-width" href="/signup">Регистрация</a>
</div>
</form>
</main>
</body>
</html>
</html>

View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="ru" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{default}">
<head>
<title>Вход</title>
</head>
<body>
<main layout:fragment="content">
<form action="#" th:action="@{/signup}" th:object="${user}" method="post">
<div class="mb-3">
<label for="login" class="form-label">Имя пользователя</label>
<input type="text" th:field="*{login}" id="login" class="form-control">
<div th:if="${#fields.hasErrors('login')}" th:errors="*{login}" class="invalid-feedback"></div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль</label>
<input type="password" th:field="*{password}" id="password" class="form-control">
<div th:if="${#fields.hasErrors('password')}" th:errors="*{password}" class="invalid-feedback"></div>
</div>
<div class="mb-3">
<label for="passwordConfirm" class="form-label">Пароль (подтверждение)</label>
<input type="password" th:field="*{passwordConfirm}" id="passwordConfirm" class="form-control">
<div th:if="${#fields.hasErrors('passwordConfirm')}" th:errors="*{passwordConfirm}"
class="invalid-feedback"></div>
</div>
<div class="mb-3 d-flex flex-row">
<button class="btn btn-primary me-2 button-fixed-width" type="submit">Регистрация</button>
<a class="btn btn-secondary button-fixed-width" href="/">Отмена</a>
</div>
</form>
</main>
</body>
</html>
</html>