Commit 483652c1 authored by Kateryna Kostiuk's avatar Kateryna Kostiuk

account: add device revocation

Change-Id: I04b22585342725c8a574679668ae7a53d7b7bd95
parent df3a0e7c
......@@ -145,6 +145,11 @@ struct AccountModelHelper {
}
}
public var havePassword: Bool {
let noPassword: String = self.account.details?.get(withConfigKeyModel: ConfigKeyModel(withKey: ConfigKey.archiveHasPassword)) ?? "false"
return noPassword == "true" ? true : false
}
static func uri(fromRingId ringId: String) -> String {
return self.ringIdPrefix.appending(ringId)
}
......
......@@ -65,6 +65,10 @@
- (NSDictionary *)getKnownRingDevices:(NSString *)accountID;
- (bool)revokeDevice:(NSString *)accountID
password:(NSString *)password
deviceId:(NSString *)deviceId;
- (Boolean)exportOnRing:(NSString *)accountID
password: (NSString *)password;
......
......@@ -80,6 +80,15 @@ static id <AccountAdapterDelegate> _delegate;
[AccountAdapter.delegate knownDevicesChangedFor:accountId devices:knDev];
}
}));
confHandlers.insert(exportable_callback<ConfigurationSignal::DeviceRevocationEnded>([&](const std::string& account_id, const std::string& device, int status) {
if (AccountAdapter.delegate) {
NSString* accountId = [NSString stringWithUTF8String:account_id.c_str()];
NSInteger state = status;
NSString* deviceId = [NSString stringWithUTF8String:device.c_str()];
[AccountAdapter.delegate deviceRevocationEndedFor: accountId state: state deviceId: deviceId];
}
}));
registerSignalHandlers(confHandlers);
}
#pragma mark -
......@@ -133,6 +142,14 @@ static id <AccountAdapterDelegate> _delegate;
auto ringDevices = getKnownRingDevices(std::string([accountID UTF8String]));
return [Utils mapToDictionnary:ringDevices];
}
- (bool)revokeDevice:(NSString *)accountID
password:(NSString *)password
deviceId:(NSString *)deviceId
{
return revokeDevice(std::string([accountID UTF8String]),std::string([password UTF8String]), std::string([deviceId UTF8String]));
}
#pragma mark -
#pragma mark AccountAdapterDelegate
......
......@@ -48,6 +48,7 @@ internal enum Asset {
internal static let pauseCall = ImageAsset(name: "pause_call")
internal static let qrCode = ImageAsset(name: "qr_code")
internal static let qrCodeScan = ImageAsset(name: "qr_code_scan")
internal static let revokeDevice = ImageAsset(name: "revoke_device")
internal static let ringLogo = ImageAsset(name: "ring_logo")
internal static let scan = ImageAsset(name: "scan")
internal static let sendButton = ImageAsset(name: "send_button")
......
......@@ -17,6 +17,20 @@ internal enum L10n {
internal static let blockedContacts = L10n.tr("Localizable", "accountPage.blockedContacts")
/// Account Details
internal static let credentialsHeader = L10n.tr("Localizable", "accountPage.credentialsHeader")
/// Device revocation error
internal static let deviceRevocationError = L10n.tr("Localizable", "accountPage.deviceRevocationError")
/// Revoking...
internal static let deviceRevocationProgress = L10n.tr("Localizable", "accountPage.deviceRevocationProgress")
/// Device was revoked
internal static let deviceRevocationSuccess = L10n.tr("Localizable", "accountPage.deviceRevocationSuccess")
/// Try again
internal static let deviceRevocationTryAgain = L10n.tr("Localizable", "accountPage.deviceRevocationTryAgain")
/// Unknown device
internal static let deviceRevocationUnknownDevice = L10n.tr("Localizable", "accountPage.deviceRevocationUnknownDevice")
/// Incorrect password
internal static let deviceRevocationWrongPassword = L10n.tr("Localizable", "accountPage.deviceRevocationWrongPassword")
/// Device revocation completed
internal static let deviceRevoked = L10n.tr("Localizable", "accountPage.deviceRevoked")
/// Devices
internal static let devicesListHeader = L10n.tr("Localizable", "accountPage.devicesListHeader")
/// Enable Notifications
......@@ -33,6 +47,14 @@ internal enum L10n {
internal static let proxyDisabledAlertTitle = L10n.tr("Localizable", "accountPage.proxyDisabledAlertTitle")
/// Proxy address
internal static let proxyPaceholder = L10n.tr("Localizable", "accountPage.proxyPaceholder")
/// Revoke
internal static let revokeDeviceButton = L10n.tr("Localizable", "accountPage.revokeDeviceButton")
/// Are you sure you want to revoke this device? This action could not be undone.
internal static let revokeDeviceMessage = L10n.tr("Localizable", "accountPage.revokeDeviceMessage")
/// Enter your passord
internal static let revokeDevicePlaceholder = L10n.tr("Localizable", "accountPage.revokeDevicePlaceholder")
/// Revoke device
internal static let revokeDeviceTitle = L10n.tr("Localizable", "accountPage.revokeDeviceTitle")
/// Save
internal static let saveProxyAddress = L10n.tr("Localizable", "accountPage.saveProxyAddress")
/// Settings
......
......@@ -105,12 +105,10 @@ class LinkNewDeviceViewModel: ViewModel, Stateable {
}
self.accountService.exportOnRing(withPassword: password).subscribe(onCompleted: {
if let account = self.accountService.currentAccount {
let accountHelper = AccountModelHelper(withAccount: account)
let uri = accountHelper.ringId
self.accountService.sharedResponseStream
.filter({ exportComplitedEvent in
return exportComplitedEvent.eventType == ServiceEventType.exportOnRingEnded
&& exportComplitedEvent.getEventInput(.uri) == uri
&& exportComplitedEvent.getEventInput(.id) == account.id
})
.subscribe(onNext: { [unowned self] exportComplitedEvent in
if let state: Int = exportComplitedEvent.getEventInput(.state) {
......
......@@ -25,4 +25,10 @@ import RxSwift
class DeviceCell: UITableViewCell, NibReusable {
@IBOutlet weak var deviceIdLabel: UILabel!
@IBOutlet weak var deviceNameLabel: UILabel!
@IBOutlet weak var removeDevice: UIButton!
var disposeBag = DisposeBag()
override func prepareForReuse() {
self.disposeBag = DisposeBag()
}
}
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="13771" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13772"/>
<capability name="Constraints to layout margins" minToolsVersion="6.0"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
......@@ -31,22 +30,48 @@
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="xNp-cR-95I">
<rect key="frame" x="329" y="25" width="30" height="30"/>
<constraints>
<constraint firstAttribute="height" constant="30" id="PBa-iQ-J9e"/>
<constraint firstAttribute="width" constant="30" id="tgl-mD-JbM"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="tintColor" name="windowFrameColor" catalog="System" colorSpace="catalog"/>
<state key="normal" image="revoke_device">
<color key="titleColor" red="0.0" green="0.29964192709999998" blue="0.37647058820000001" alpha="1" colorSpace="calibratedRGB"/>
</state>
</button>
</subviews>
<constraints>
<constraint firstItem="OQs-TS-z4j" firstAttribute="top" secondItem="Emx-P1-xQc" secondAttribute="bottom" constant="2" id="1u2-sP-E0H"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="Emx-P1-xQc" secondAttribute="trailing" constant="20" symbolic="YES" id="3NP-Bl-2xN"/>
<constraint firstItem="xNp-cR-95I" firstAttribute="trailing" secondItem="fvy-0B-phe" secondAttribute="trailingMargin" id="NLH-b0-OeZ"/>
<constraint firstItem="xNp-cR-95I" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="Emx-P1-xQc" secondAttribute="trailing" constant="30" id="TSU-5H-8DY"/>
<constraint firstItem="Emx-P1-xQc" firstAttribute="leading" secondItem="OQs-TS-z4j" secondAttribute="leading" id="VnE-T5-ZB0"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="OQs-TS-z4j" secondAttribute="trailing" constant="10" id="c1v-0c-oUl"/>
<constraint firstItem="Emx-P1-xQc" firstAttribute="top" secondItem="fvy-0B-phe" secondAttribute="top" constant="10" id="jpW-b6-d6g"/>
<constraint firstItem="OQs-TS-z4j" firstAttribute="leading" secondItem="fvy-0B-phe" secondAttribute="leadingMargin" id="m5d-gg-Ugj"/>
<constraint firstAttribute="bottomMargin" secondItem="OQs-TS-z4j" secondAttribute="bottom" constant="10" id="mzU-aw-WHP"/>
<constraint firstItem="xNp-cR-95I" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="OQs-TS-z4j" secondAttribute="trailing" constant="30" id="tY2-vb-6Ga"/>
<constraint firstItem="xNp-cR-95I" firstAttribute="centerY" secondItem="fvy-0B-phe" secondAttribute="centerY" id="uwR-Dv-qrh"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="borderWidth">
<real key="value" value="10"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="color" keyPath="borderColor">
<color key="value" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</tableViewCellContentView>
<connections>
<outlet property="deviceIdLabel" destination="OQs-TS-z4j" id="UZg-Z8-A28"/>
<outlet property="deviceNameLabel" destination="Emx-P1-xQc" id="Z5C-rX-Jmc"/>
<outlet property="removeDevice" destination="xNp-cR-95I" id="z1p-t2-u4P"/>
</connections>
<point key="canvasLocation" x="85.5" y="131"/>
</tableViewCell>
</objects>
<resources>
<image name="revoke_device" width="50" height="50"/>
</resources>
</document>
......@@ -25,7 +25,9 @@ import Reusable
import RxSwift
import RxCocoa
import RxDataSources
import PKHUD
// swiftlint:disable type_body_length
class MeViewController: EditProfileViewController, StoryboardBased, ViewModelBased {
// MARK: - outlets
......@@ -102,7 +104,22 @@ class MeViewController: EditProfileViewController, StoryboardBased, ViewModelBas
self.qrCodeItemTapped()
})
.disposed(by: self.disposeBag)
self.viewModel.showActionState.asObservable()
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self](action) in
switch action {
case .noAction:
break
case .hideLoading:
self?.stopLoadingView()
case .showLoading:
self?.showLoadingView()
case .deviceRevokedWithSuccess(let deviceId):
self?.showDeviceRevokedAlert(deviceId: deviceId)
case .deviceRevokationError(let deviceId, let errorMessage):
self?.showDeviceRevocationError(deviceId: deviceId, errorMessage: errorMessage)
}
}).disposed(by: self.disposeBag)
self.navigationItem.rightBarButtonItem = infoItem
self.navigationItem.leftBarButtonItem = qrCodeButtonItem
......@@ -134,6 +151,43 @@ class MeViewController: EditProfileViewController, StoryboardBased, ViewModelBas
self.viewModel.showBlockedContacts()
}
private func stopLoadingView() {
HUD.hide(animated: false)
}
private func showLoadingView() {
HUD.show(.labeledProgress(title: L10n.AccountPage.deviceRevocationProgress, subtitle: nil))
}
private func showDeviceRevocationError(deviceId: String, errorMessage: String) {
HUD.hide(animated: true) { _ in
let alert = UIAlertController(title: errorMessage,
message: nil,
preferredStyle: .alert)
let actionCancel = UIAlertAction(title: L10n.Actions.cancelAction,
style: .cancel)
let actionAgain = UIAlertAction(title: L10n.AccountPage.deviceRevocationTryAgain,
style: .default) { [weak self] _ in
self?.confirmRevokeDeviceAlert(deviceID: deviceId)
}
alert.addAction(actionCancel)
alert.addAction(actionAgain)
self.present(alert, animated: true, completion: nil)
}
}
private func showDeviceRevokedAlert(deviceId: String) {
HUD.hide(animated: true) { _ in
let alert = UIAlertController(title: L10n.AccountPage.deviceRevocationSuccess,
message: nil,
preferredStyle: .alert)
let actionOk = UIAlertAction(title: L10n.Global.ok,
style: .default)
alert.addAction(actionOk)
self.present(alert, animated: true, completion: nil)
}
}
private func infoItemTapped() {
var compileDate: String {
let dateDefault = "20180131"
......@@ -213,6 +267,10 @@ class MeViewController: EditProfileViewController, StoryboardBased, ViewModelBas
cell.deviceNameLabel.text = deviceName
}
cell.selectionStyle = .none
cell.removeDevice.isHidden = device.isCurrent
cell.removeDevice.rx.tap.subscribe(onNext: { [weak self, device] in
self?.confirmRevokeDeviceAlert(deviceID: device.deviceId)
}).disposed(by: cell.disposeBag)
return cell
case .linkNew:
......@@ -367,6 +425,46 @@ class MeViewController: EditProfileViewController, StoryboardBased, ViewModelBas
}
self.present(alert, animated: true, completion: nil)
}
func confirmRevokeDeviceAlert(deviceID: String) {
let alert = UIAlertController(title: L10n.AccountPage.revokeDeviceTitle,
message: L10n.AccountPage.revokeDeviceMessage,
preferredStyle: .alert)
let actionCancel = UIAlertAction(title: L10n.Actions.cancelAction,
style: .cancel)
let actionConfirm = UIAlertAction(title: L10n.AccountPage.revokeDeviceButton,
style: .default) { [weak self] _ in
self?.showLoadingView()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
if let textFields = alert.textFields,
!textFields.isEmpty,
let text = textFields[0].text,
!text.isEmpty {
self?.viewModel.revokeDevice(deviceId: deviceID, accountPassword: text)
} else {
self?.viewModel.revokeDevice(deviceId: deviceID, accountPassword: "")
self?.stopLoadingView()
}
}
}
alert.addAction(actionCancel)
alert.addAction(actionConfirm)
if self.viewModel.havePassord {
alert.addTextField {(textField) in
textField.placeholder = L10n.AccountPage.revokeDevicePlaceholder
}
if let textFields = alert.textFields {
textFields[0].rx.text.map({text in
if let text = text {
return !text.isEmpty
}
return false
}).bind(to: actionConfirm.rx.isEnabled).disposed(by: self.disposeBag)
}
}
self.present(alert, animated: true, completion: nil)
}
}
extension MeViewController: UITableViewDelegate {
......
......@@ -70,6 +70,14 @@ enum SettingsSection: SectionModelType {
}
}
enum ActionsState {
case deviceRevokedWithSuccess(deviceId: String)
case deviceRevokationError(deviceId: String, errorMessage: String)
case showLoading
case hideLoading
case noAction
}
class MeViewModel: ViewModel, Stateable {
// MARK: - Rx Stateable
......@@ -111,6 +119,8 @@ class MeViewModel: ViewModel, Stateable {
})
}()
var showActionState = Variable<ActionsState>(.noAction)
lazy var ringId: Observable<String> = {
if let uri = self.accountService.currentAccount?.details?.get(withConfigKeyModel: ConfigKeyModel(withKey: .accountUsername)) {
let ringId = uri.replacingOccurrences(of: "ring:", with: "")
......@@ -152,6 +162,11 @@ class MeViewModel: ViewModel, Stateable {
.blockedList, .proxy, .notifications]))
}()
lazy var havePassord: Bool = {
guard let currentAccount = self.accountService.currentAccount else {return true}
return AccountModelHelper(withAccount: currentAccount).havePassword
}()
// swiftlint:disable identifier_name
lazy var linkedDevices: Observable<SettingsSection> = {
// if account does not exist or devices list empty return empty section
......@@ -191,6 +206,27 @@ class MeViewModel: ViewModel, Stateable {
required init (with injectionBag: InjectionBag) {
self.accountService = injectionBag.accountService
self.nameService = injectionBag.nameService
guard let accountId = self.accountService.currentAccount?.id else {return}
self.accountService.sharedResponseStream
.filter({ (deviceEvent) -> Bool in
return deviceEvent.eventType == ServiceEventType.deviceRevocationEnded
&& deviceEvent.getEventInput(.id) == accountId
})
.subscribe(onNext: { [unowned self] deviceEvent in
if let state: Int = deviceEvent.getEventInput(.state),
let deviceID: String = deviceEvent.getEventInput(.deviceId) {
switch state {
case DeviceRevocationState.success.rawValue:
self.showActionState.value = .deviceRevokedWithSuccess(deviceId: deviceID)
case DeviceRevocationState.wrongPassword.rawValue:
self.showActionState.value = .deviceRevokationError(deviceId:deviceID, errorMessage: L10n.AccountPage.deviceRevocationWrongPassword)
case DeviceRevocationState.unknownDevice.rawValue:
self.showActionState.value = .deviceRevokationError(deviceId:deviceID, errorMessage: L10n.AccountPage.deviceRevocationUnknownDevice)
default:
self.showActionState.value = .deviceRevokationError(deviceId:deviceID, errorMessage: L10n.AccountPage.deviceRevocationError)
}
}
}).disposed(by: self.disposeBag)
}
func linkDevice() {
......@@ -201,6 +237,14 @@ class MeViewModel: ViewModel, Stateable {
self.stateSubject.onNext(MeState.blockedContacts)
}
func revokeDevice(deviceId: String, accountPassword password: String) {
guard let accountId = self.accountService.currentAccount?.id else {
self.showActionState.value = .hideLoading
return
}
self.accountService.revokeDevice(for: accountId, withPassword: password, deviceId: deviceId)
}
// MARK: - DHT Proxy
lazy var proxyEnabled: Variable<Bool> = {
......
......@@ -100,6 +100,7 @@ enum ConfigKey: String {
case proxyEnabled = "Account.proxyEnabled"
case proxyServer = "Account.proxyServer"
case devicePushToken = "Account.proxyPushToken"
case archiveHasPassword = "Account.archiveHasPassword"
}
/**
......
......@@ -25,10 +25,12 @@ class DeviceModel: Object {
@objc dynamic var deviceId = ""
@objc dynamic var deviceName: String?
@objc dynamic var isCurrent = false
convenience init(withDeviceId deviceId: String, deviceName: String?) {
convenience init(withDeviceId deviceId: String, deviceName: String?, isCurrent: Bool) {
self.init()
self.deviceId = deviceId
self.deviceName = deviceName
self.isCurrent = isCurrent
}
}
{
"images" : [
{
"idiom" : "universal",
"filename" : "revoke_device-1.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "revoke_device-2.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "revoke_device.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}
\ No newline at end of file
......@@ -135,6 +135,17 @@
"accountPage.enableNotifications" = "Enable Notifications";
"accountPage.proxyDisabledAlertTitle" = "Proxy Server Disabled";
"accountPage.proxyDisabledAlertBody" = "In order to receive notifications, please enable proxy";
"accountPage.revokeDeviceTitle" = "Revoke device";
"accountPage.revokeDeviceMessage" = "Are you sure you want to revoke this device? This action could not be undone.";
"accountPage.revokeDeviceButton" = "Revoke";
"accountPage.revokeDevicePlaceholder" = "Enter your passord";
"accountPage.deviceRevoked" = "Device revocation completed";
"accountPage.deviceRevocationProgress" = "Revoking...";
"accountPage.deviceRevocationSuccess" = "Device was revoked";
"accountPage.deviceRevocationTryAgain" = "Try again";
"accountPage.deviceRevocationWrongPassword" = "Incorrect password";
"accountPage.deviceRevocationUnknownDevice" = "Unknown device";
"accountPage.deviceRevocationError" = "Device revocation error";
//Link New Device
"linkDevice.title" = "Link a new device";
......
......@@ -24,4 +24,5 @@
func registrationStateChanged(with response: RegistrationResponse)
func knownDevicesChanged(for account: String, devices: [String: String])
func exportOnRingEnded(for account: String, state: Int, pin: String)
func deviceRevocationEnded(for account: String, state: Int, deviceId: String)
}
......@@ -29,6 +29,12 @@ enum LinkNewDeviceError: Error {
case unknownError
}
enum DeviceRevocationState: Int {
case success = 0
case wrongPassword = 1
case unknownDevice = 2
}
enum AddAccountError: Error {
case templateNotConform
case unknownError
......@@ -387,15 +393,26 @@ class AccountsService: AccountAdapterDelegate {
var devices = [DeviceModel]()
let accountDetails = self.getAccountDetails(fromAccountId: id)
let currentDeviceId = accountDetails.get(withConfigKeyModel: ConfigKeyModel(withKey: ConfigKey.accountDeviceId))
for key in knownRingDevices.allKeys {
if let key = key as? String {
devices.append(DeviceModel(withDeviceId: key, deviceName: knownRingDevices.value(forKey: key) as? String))
devices.append(DeviceModel(withDeviceId: key,
deviceName: knownRingDevices.value(forKey: key) as? String,
isCurrent: key == currentDeviceId))
}
}
return devices
}
func revokeDevice(for account: String,
withPassword password: String,
deviceId: String) {
accountAdapter.revokeDevice(account, password: password, deviceId: deviceId)
}
/**
Gathers all the initial default details contained by any accounts, Ring or SIP.
......@@ -488,17 +505,19 @@ class AccountsService: AccountAdapterDelegate {
}
func exportOnRingEnded(for account: String, state: Int, pin: String) {
let changedAccount = getAccount(fromAccountId: account)
if let changedAccount = changedAccount {
let accountHelper = AccountModelHelper(withAccount: changedAccount)
if let uri = accountHelper.ringId {
var event = ServiceEvent(withEventType: .exportOnRingEnded)
event.addEventInput(.uri, value: uri)
event.addEventInput(.state, value: state)
event.addEventInput(.pin, value: pin)
self.responseStream.onNext(event)
}
}
var event = ServiceEvent(withEventType: .exportOnRingEnded)
event.addEventInput(.id, value: account)
event.addEventInput(.state, value: state)
event.addEventInput(.pin, value: pin)
self.responseStream.onNext(event)
}
func deviceRevocationEnded(for account: String, state: Int, deviceId: String) {
var event = ServiceEvent(withEventType: .deviceRevocationEnded)
event.addEventInput(.id, value: account)
event.addEventInput(.state, value: state)
event.addEventInput(.deviceId, value: deviceId)
self.responseStream.onNext(event)
}
// MARK: Push Notifications
......
......@@ -41,6 +41,7 @@ enum ServiceEventType {
case dataTransferCreated
case dataTransferChanged
case dataTransferMessageUpdated
case deviceRevocationEnded
}
/**
......@@ -62,6 +63,7 @@ enum ServiceEventInput {
case transferId
case localPhotolID
case proxyAddress
case deviceId
}
/**
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment