Compare commits
3
Commits
8e70180266
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cea5de046 | ||
|
|
b797aed073 | ||
|
|
deb18f48c0 |
@@ -0,0 +1,26 @@
|
||||
# Панель администратора для настройки устройств и управления данными системы
|
||||
|
||||
Приложение предоставляет полный доступ для управления данными в базе данных, а также возможность настройки оборудования и отслеживание заявок.
|
||||
Защищена авторизацией
|
||||
|
||||
## Запуск приложения
|
||||
|
||||
Для приложения требуется БД и конфигурация подключения к БД.
|
||||
Также для работы приложения нужен запущенный сервер авторизации.
|
||||
|
||||
Для запуска приложения нужно выполнить команду
|
||||
|
||||
`gradle bootRun`
|
||||
|
||||
## Сборка приложения
|
||||
|
||||
`gradle vaadinBuildFrontend bootJar`
|
||||
|
||||
## Основные страницы
|
||||
|
||||
- MainLayout - оболочка панели администратора с навигацией;
|
||||
- ClientView - страница просмотра, редактирования и удаления клиентов;
|
||||
- DoorView - страница просмотра, редактирования и удаления дверей. Здесь же настраиваются дополнительные двери и расписание для дверей;
|
||||
- PartnerView - страница просмотра, редактирования и удаления дверей;
|
||||
- RentView - страница просмотра, редактирования и удаления бронирований;
|
||||
- UnitView - страница просмотра, редактирования и удаления устройств.
|
||||
@@ -38,7 +38,7 @@ public class ClientView extends VerticalLayout {
|
||||
|
||||
addButton.addClickListener(e -> editClient(new ClientEntity()));
|
||||
|
||||
grid.setHeight("200px");
|
||||
grid.setHeight("600px");
|
||||
grid.asSingleSelect().addValueChangeListener(e -> editClient(e.getValue()));
|
||||
|
||||
refreshClientGrid();
|
||||
|
||||
@@ -19,6 +19,7 @@ import ru.vyatsu.qr_access_admin.client.view.ClientView;
|
||||
import ru.vyatsu.qr_access_admin.common.view.MainView;
|
||||
import ru.vyatsu.qr_access_admin.door.view.DoorView;
|
||||
import ru.vyatsu.qr_access_admin.partner.view.PartnerView;
|
||||
import ru.vyatsu.qr_access_admin.rent.view.RentView;
|
||||
import ru.vyatsu.qr_access_admin.unit.view.UnitView;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -120,6 +121,7 @@ public class MainLayout extends AppLayout {
|
||||
createTab("Устройства", UnitView.class),
|
||||
createTab("Двери", DoorView.class),
|
||||
createTab("Партнеры", PartnerView.class),
|
||||
createTab("Бронирование", RentView.class),
|
||||
createTab("Клиенты", ClientView.class)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package ru.vyatsu.qr_access_admin.common.config;
|
||||
|
||||
import com.vaadin.flow.spring.security.VaadinWebSecurity;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@@ -14,4 +17,9 @@ public class SecurityConfiguration extends VaadinWebSecurity {
|
||||
super.configure(http);
|
||||
http.oauth2Login(c -> c.loginProcessingUrl("/login/oauth2/code/own"));
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.vaadin.flow.component.combobox.ComboBox;
|
||||
import com.vaadin.flow.component.icon.VaadinIcon;
|
||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||
import com.vaadin.flow.component.textfield.BigDecimalField;
|
||||
import com.vaadin.flow.component.textfield.IntegerField;
|
||||
import com.vaadin.flow.component.textfield.TextField;
|
||||
import com.vaadin.flow.data.binder.BeanValidationBinder;
|
||||
@@ -170,7 +171,7 @@ public class DoorEditor extends Composite<HorizontalLayout> {
|
||||
unitField.setItemLabelGenerator(UnitComboBoxModel::clientName);
|
||||
IntegerField countField = new IntegerField("Количество мест");
|
||||
TextField descriptionField = new TextField("Описание");
|
||||
|
||||
BigDecimalField price = new BigDecimalField("Цена за час");
|
||||
|
||||
var save = new Button("Сохранить", VaadinIcon.CHECK.create());
|
||||
var cancel = new Button("Отмена");
|
||||
@@ -180,6 +181,7 @@ public class DoorEditor extends Composite<HorizontalLayout> {
|
||||
.withConverter(UnitComboBoxModel::clientId, units::get, "Invalid value")
|
||||
.bind("unitId");
|
||||
binder.forField(countField).bind("count");
|
||||
binder.forField(price).bind("price");
|
||||
binder.forField(descriptionField).bind("description");
|
||||
|
||||
save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||
@@ -203,7 +205,7 @@ public class DoorEditor extends Composite<HorizontalLayout> {
|
||||
cancel.addClickListener(e -> cancelListener.onCancel());
|
||||
|
||||
VerticalLayout mainForm = new VerticalLayout();
|
||||
mainForm.add(unitField, countField, descriptionField, scheduleEditor, new HorizontalLayout(save, cancel, delete));
|
||||
mainForm.add(unitField, countField, descriptionField, price, scheduleEditor, new HorizontalLayout(save, cancel, delete));
|
||||
|
||||
getContent().add(mainForm, additionalDoorsEditor);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Entity
|
||||
@Table(name = "doors")
|
||||
@Getter
|
||||
@@ -23,6 +25,9 @@ public class DoorEntity {
|
||||
@Column
|
||||
private int count;
|
||||
|
||||
@Column
|
||||
private BigDecimal price;
|
||||
|
||||
@Column
|
||||
private String parentDoorIds;
|
||||
}
|
||||
|
||||
@@ -52,8 +52,7 @@ public class DoorView extends VerticalLayout {
|
||||
|
||||
addButton.addClickListener(e -> editDoor(new DoorEntity()));
|
||||
|
||||
grid.setHeight("200px");
|
||||
|
||||
grid.setHeight("600px");
|
||||
grid.asSingleSelect().addValueChangeListener(e -> editDoor(e.getValue()));
|
||||
|
||||
refreshDoorsGrid();
|
||||
|
||||
@@ -17,11 +17,11 @@ import ru.vyatsu.qr_access_admin.partner.entity.PartnerEntity;
|
||||
public class PartnerEditor extends Composite<VerticalLayout> {
|
||||
|
||||
public interface SaveListener {
|
||||
void onSave(PartnerEntity qr);
|
||||
void onSave(PartnerEntity partner);
|
||||
}
|
||||
|
||||
public interface DeleteListener {
|
||||
void onDelete(PartnerEntity qr);
|
||||
void onDelete(PartnerEntity partner);
|
||||
}
|
||||
|
||||
public interface CancelListener {
|
||||
|
||||
@@ -38,7 +38,7 @@ public class PartnerView extends VerticalLayout {
|
||||
|
||||
addButton.addClickListener(e -> editPartner(new PartnerEntity()));
|
||||
|
||||
grid.setHeight("200px");
|
||||
grid.setHeight("600px");
|
||||
grid.asSingleSelect().addValueChangeListener(e -> editPartner(e.getValue()));
|
||||
|
||||
refreshPartnerGrid();
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package ru.vyatsu.qr_access_admin.rent.component;
|
||||
|
||||
import com.vaadin.flow.component.Composite;
|
||||
import com.vaadin.flow.component.Key;
|
||||
import com.vaadin.flow.component.button.Button;
|
||||
import com.vaadin.flow.component.button.ButtonVariant;
|
||||
import com.vaadin.flow.component.checkbox.Checkbox;
|
||||
import com.vaadin.flow.component.combobox.ComboBox;
|
||||
import com.vaadin.flow.component.datepicker.DatePicker;
|
||||
import com.vaadin.flow.component.icon.VaadinIcon;
|
||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||
import com.vaadin.flow.component.textfield.TextField;
|
||||
import com.vaadin.flow.component.timepicker.TimePicker;
|
||||
import com.vaadin.flow.data.binder.BeanValidationBinder;
|
||||
import com.vaadin.flow.data.binder.Binder;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import ru.vyatsu.qr_access_admin.client.entity.ClientEntity;
|
||||
import ru.vyatsu.qr_access_admin.client.entity.ClientRepository;
|
||||
import ru.vyatsu.qr_access_admin.door.entity.DoorEntity;
|
||||
import ru.vyatsu.qr_access_admin.door.entity.DoorRepository;
|
||||
import ru.vyatsu.qr_access_admin.rent.entity.RentEntity;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class RentEditor extends Composite<VerticalLayout> {
|
||||
|
||||
public interface SaveListener {
|
||||
void onSave(RentEntity rent);
|
||||
}
|
||||
|
||||
public interface DeleteListener {
|
||||
void onDelete(RentEntity rent);
|
||||
}
|
||||
|
||||
public interface CancelListener {
|
||||
void onCancel();
|
||||
}
|
||||
|
||||
private volatile RentEntity currentRent;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private SaveListener saveListener;
|
||||
@Getter
|
||||
@Setter
|
||||
private DeleteListener deleteListener;
|
||||
@Getter
|
||||
@Setter
|
||||
private CancelListener cancelListener;
|
||||
|
||||
private final Binder<RentEntity> binder = new BeanValidationBinder<>(RentEntity.class);
|
||||
|
||||
public void setCurrentRent(RentEntity rent) {
|
||||
this.currentRent = rent;
|
||||
binder.setBean(rent);
|
||||
}
|
||||
|
||||
public RentEditor(DoorRepository doorRepository, ClientRepository clientRepository) {
|
||||
var startTimeField = new TimePicker("Время начала действия");
|
||||
var endTimeField = new TimePicker("Время окончания действия");
|
||||
var dateField = new DatePicker("Дата бронирования");
|
||||
var qrCodeField = new TextField("Закодированный идентификатор");
|
||||
var payedField = new Checkbox("Оплачено");
|
||||
ComboBox<DoorEntity> doorField = new ComboBox<>("Дверь");
|
||||
doorField.setItemLabelGenerator(DoorEntity::getDescription);
|
||||
ComboBox<ClientEntity> clientField = new ComboBox<>("Клиент");
|
||||
clientField.setItemLabelGenerator(ClientEntity::getEmail);
|
||||
Map<String, DoorEntity> doors = doorRepository.findAllByParentDoorIdsIsNull()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(DoorEntity::getId, d -> d));
|
||||
Map<String, ClientEntity> clients = clientRepository.findAll()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(ClientEntity::getId, c -> c));
|
||||
doorField.setItems(doors.values());
|
||||
clientField.setItems(clients.values());
|
||||
|
||||
binder.forField(doorField)
|
||||
.withConverter(DoorEntity::getId, doors::get)
|
||||
.bind("doorId");
|
||||
binder.forField(clientField)
|
||||
.withConverter(ClientEntity::getId, clients::get)
|
||||
.bind("clientId");
|
||||
binder.forField(startTimeField).bind("startTime");
|
||||
binder.forField(endTimeField).bind("endTime");
|
||||
binder.forField(dateField).bind("date");
|
||||
binder.forField(qrCodeField).bind("qrCode");
|
||||
binder.forField(payedField).bind("payed");
|
||||
|
||||
var save = new Button("Сохранить", VaadinIcon.CHECK.create());
|
||||
var cancel = new Button("Отмена");
|
||||
var delete = new Button("Удалить", VaadinIcon.TRASH.create());
|
||||
|
||||
save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||
save.addClickListener(e -> saveListener.onSave(currentRent));
|
||||
save.addClickShortcut(Key.ENTER);
|
||||
|
||||
delete.addThemeVariants(ButtonVariant.LUMO_ERROR);
|
||||
delete.addClickListener(e -> deleteListener.onDelete(currentRent));
|
||||
|
||||
cancel.addClickListener(e -> cancelListener.onCancel());
|
||||
|
||||
getContent().add(startTimeField, endTimeField, dateField, doorField, clientField, qrCodeField, payedField, new HorizontalLayout(save, cancel, delete));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package ru.vyatsu.qr_access_admin.rent.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.SourceType;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
|
||||
@Table(name = "rents")
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
public class RentEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private String id;
|
||||
|
||||
@Column
|
||||
private LocalTime startTime;
|
||||
|
||||
@Column
|
||||
private LocalTime endTime;
|
||||
|
||||
@Column
|
||||
private String clientId;
|
||||
|
||||
@Column
|
||||
private LocalDate date;
|
||||
|
||||
@Column
|
||||
private String doorId;
|
||||
|
||||
@Column
|
||||
private String qrCode;
|
||||
|
||||
@Column
|
||||
private Boolean payed;
|
||||
|
||||
@Column
|
||||
@CreationTimestamp(source = SourceType.DB)
|
||||
private LocalDateTime dateCreated;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.vyatsu.qr_access_admin.rent.entity;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface RentRepository extends JpaRepository<RentEntity, String> {
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package ru.vyatsu.qr_access_admin.rent.view;
|
||||
|
||||
import com.vaadin.flow.component.button.Button;
|
||||
import com.vaadin.flow.component.grid.Grid;
|
||||
import com.vaadin.flow.component.icon.VaadinIcon;
|
||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||
import com.vaadin.flow.router.PageTitle;
|
||||
import com.vaadin.flow.router.Route;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import ru.vyatsu.qr_access_admin.client.entity.ClientRepository;
|
||||
import ru.vyatsu.qr_access_admin.common.MainLayout;
|
||||
import ru.vyatsu.qr_access_admin.door.entity.DoorRepository;
|
||||
import ru.vyatsu.qr_access_admin.rent.component.RentEditor;
|
||||
import ru.vyatsu.qr_access_admin.rent.entity.RentEntity;
|
||||
import ru.vyatsu.qr_access_admin.rent.entity.RentRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Route(value = "rent", layout = MainLayout.class)
|
||||
@PageTitle("Бронирование")
|
||||
@PermitAll
|
||||
public class RentView extends VerticalLayout {
|
||||
private final RentRepository repository;
|
||||
private final Grid<RentEntity> grid;
|
||||
private final RentEditor editor;
|
||||
|
||||
public RentView(RentRepository repository, DoorRepository doorRepository, ClientRepository clientRepository) {
|
||||
this.repository = repository;
|
||||
|
||||
var addButton = new Button("Добавить бронирование", VaadinIcon.PLUS.create());
|
||||
grid = new Grid<>(RentEntity.class);
|
||||
grid.setColumns("clientId", "date", "startTime", "endTime", "payed", "dateCreated");
|
||||
editor = new RentEditor(doorRepository, clientRepository);
|
||||
|
||||
var actionsLayout = new HorizontalLayout(addButton);
|
||||
add(actionsLayout, grid, editor);
|
||||
|
||||
this.configureEditor();
|
||||
|
||||
addButton.addClickListener(e -> editRent(new RentEntity()));
|
||||
|
||||
grid.setHeight("600px");
|
||||
grid.asSingleSelect().addValueChangeListener(e -> editRent(e.getValue()));
|
||||
|
||||
refreshRentGrid();
|
||||
}
|
||||
|
||||
private void refreshRentGrid() {
|
||||
List<RentEntity> entities = repository.findAll();
|
||||
grid.setItems(entities);
|
||||
}
|
||||
|
||||
private void editRent(RentEntity rent) {
|
||||
if (rent == null) {
|
||||
editor.setVisible(false);
|
||||
} else {
|
||||
editor.setVisible(true);
|
||||
editor.setCurrentRent(rent);
|
||||
}
|
||||
}
|
||||
|
||||
private void configureEditor() {
|
||||
editor.setVisible(false);
|
||||
|
||||
editor.setSaveListener(rent -> {
|
||||
repository.save(rent);
|
||||
refreshRentGrid();
|
||||
editRent(null);
|
||||
});
|
||||
|
||||
editor.setDeleteListener(rent -> {
|
||||
repository.deleteById(rent.getId());
|
||||
refreshRentGrid();
|
||||
editRent(null);
|
||||
});
|
||||
|
||||
editor.setCancelListener(() -> editRent(null));
|
||||
}
|
||||
}
|
||||
+1
-10
@@ -1,10 +1,7 @@
|
||||
package ru.vyatsu.qr_access_admin.unit.mapper;
|
||||
|
||||
import ru.vyatsu.qr_access_admin.unit.model.UnitComboBoxModel;
|
||||
import ru.vyatsu.qr_access_admin.unit.entity.UnitEntity;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import ru.vyatsu.qr_access_admin.unit.model.UnitComboBoxModel;
|
||||
|
||||
public class UnitEntityUnitComboBoxModelMapper {
|
||||
|
||||
@@ -14,10 +11,4 @@ public class UnitEntityUnitComboBoxModelMapper {
|
||||
}
|
||||
return new UnitComboBoxModel(entity.getClientId(), entity.getClientName());
|
||||
}
|
||||
|
||||
public List<UnitComboBoxModel> toModels(Collection<UnitEntity> entities) {
|
||||
return entities.stream()
|
||||
.map(this::toModel)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package ru.vyatsu.qr_access_admin.unit.mapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.stereotype.Component;
|
||||
import ru.vyatsu.qr_access_admin.unit.entity.UnitEntity;
|
||||
import ru.vyatsu.qr_access_admin.unit.model.UnitModel;
|
||||
@@ -8,8 +12,11 @@ import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class UnitEntityUnitModelMapper {
|
||||
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public static final String DEFAULT_CLIENT_SETTINGS = "{\"@class\":\"java.util.Collections$UnmodifiableMap\",\"settings.client.require-proof-key\":false,\"settings.client.require-authorization-consent\":false}";
|
||||
private static final String DEFAULT_TOKEN_SETTINGS = """
|
||||
{"@class":"java.util.Collections$UnmodifiableMap","settings.token.reuse-refresh-tokens":true,"settings.token.x509-certificate-bound-access-tokens":false,"settings.token.id-token-signature-algorithm":["org.springframework.security.oauth2.jose.jws.SignatureAlgorithm","RS256"],"settings.token.access-token-time-to-live":["java.time.Duration",31540000.000000000],"settings.token.access-token-format":{"@class":"org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat","value":"self-contained"},"settings.token.refresh-token-time-to-live":["java.time.Duration",3600.000000000],"settings.token.authorization-code-time-to-live":["java.time.Duration",300.000000000],"settings.token.device-code-time-to-live":["java.time.Duration",300.000000000]}
|
||||
@@ -19,15 +26,12 @@ public class UnitEntityUnitModelMapper {
|
||||
UnitEntity entity = new UnitEntity();
|
||||
entity.setId(model.getId());
|
||||
entity.setClientId(model.getClientId());
|
||||
// TODO: Приделать PasswordEncoder когда заедет security
|
||||
entity.setClientSecret(model.getClientSecret());
|
||||
entity.setClientSecret(passwordEncoder.encode(model.getClientSecret()));
|
||||
entity.setClientSecretExpiresAt(model.getClientSecretExpiresAt());
|
||||
entity.setClientName(model.getClientName());
|
||||
entity.setScopes("");
|
||||
// TODO: Использовать enum, когда заедет oauth2
|
||||
entity.setAuthorizationGrantTypes("client_credentials");
|
||||
// TODO: Использовать enum, когда заедет oauth2
|
||||
entity.setClientAuthenticationMethods("client_secret_post");
|
||||
entity.setAuthorizationGrantTypes(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue());
|
||||
entity.setClientAuthenticationMethods(ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue());
|
||||
entity.setClientSettings(DEFAULT_CLIENT_SETTINGS);
|
||||
entity.setTokenSettings(DEFAULT_TOKEN_SETTINGS);
|
||||
entity.setRedirectUris("");
|
||||
|
||||
@@ -44,7 +44,7 @@ public class UnitView extends VerticalLayout {
|
||||
|
||||
addButton.addClickListener(e -> editUnit(new UnitModel()));
|
||||
|
||||
grid.setHeight("200px");
|
||||
grid.setHeight("600px");
|
||||
grid.asSingleSelect().addValueChangeListener(e -> editUnit(e.getValue()));
|
||||
|
||||
refreshUnitsGrid();
|
||||
@@ -69,6 +69,7 @@ public class UnitView extends VerticalLayout {
|
||||
editor.setVisible(false);
|
||||
|
||||
editor.setSaveListener(unit -> {
|
||||
// TODO: Сделать, чтобы секрет клиента при изменении не перехешировался, иначе станет не рабочим
|
||||
UnitEntity entity = entityModelMapper.mapModelToEntity(unit);
|
||||
unitRepository.save(entity);
|
||||
refreshUnitsGrid();
|
||||
|
||||
Reference in New Issue
Block a user