mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
@@ -543,11 +543,9 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
}
|
||||
|
||||
private func startSync() {
|
||||
ServiceLocator.shared.analytics.signpost.beginSync()
|
||||
guard let userSession else {
|
||||
fatalError("User session not setup")
|
||||
}
|
||||
guard let userSession else { return }
|
||||
|
||||
ServiceLocator.shared.analytics.signpost.beginSync()
|
||||
userSession.clientProxy.startSync()
|
||||
|
||||
let identifier = "StaleDataIndicator"
|
||||
|
||||
@@ -938,6 +938,8 @@ public enum L10n {
|
||||
public static var screenSessionVerificationWaitingToAcceptSubtitle: String { return L10n.tr("Localizable", "screen_session_verification_waiting_to_accept_subtitle") }
|
||||
/// Waiting to accept request
|
||||
public static var screenSessionVerificationWaitingToAcceptTitle: String { return L10n.tr("Localizable", "screen_session_verification_waiting_to_accept_title") }
|
||||
/// Account and devices
|
||||
public static var screenSettingsOidcAccount: String { return L10n.tr("Localizable", "screen_settings_oidc_account") }
|
||||
/// Share location
|
||||
public static var screenShareLocationTitle: String { return L10n.tr("Localizable", "screen_share_location_title") }
|
||||
/// Share my location
|
||||
|
||||
@@ -31,6 +31,23 @@ class SDKClientMock: SDKClientProtocol {
|
||||
return accountDataEventTypeReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - `accountUrl`
|
||||
|
||||
public var accountUrlCallsCount = 0
|
||||
public var accountUrlCalled: Bool {
|
||||
return accountUrlCallsCount > 0
|
||||
}
|
||||
public var accountUrlReturnValue: String?
|
||||
public var accountUrlClosure: (() -> String?)?
|
||||
|
||||
public func `accountUrl`() -> String? {
|
||||
accountUrlCallsCount += 1
|
||||
if let accountUrlClosure = accountUrlClosure {
|
||||
return accountUrlClosure()
|
||||
} else {
|
||||
return accountUrlReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - `avatarUrl`
|
||||
|
||||
public var avatarUrlThrowableError: Error?
|
||||
@@ -367,14 +384,19 @@ class SDKClientMock: SDKClientProtocol {
|
||||
public var logoutCalled: Bool {
|
||||
return logoutCallsCount > 0
|
||||
}
|
||||
public var logoutClosure: (() throws -> Void)?
|
||||
public var logoutReturnValue: String?
|
||||
public var logoutClosure: (() throws -> String?)?
|
||||
|
||||
public func `logout`() throws {
|
||||
public func `logout`() throws -> String? {
|
||||
if let error = logoutThrowableError {
|
||||
throw error
|
||||
}
|
||||
logoutCallsCount += 1
|
||||
try logoutClosure?()
|
||||
if let logoutClosure = logoutClosure {
|
||||
return try logoutClosure()
|
||||
} else {
|
||||
return logoutReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - `notificationClient`
|
||||
|
||||
|
||||
@@ -100,7 +100,16 @@ class AuthenticationCoordinator: CoordinatorProtocol {
|
||||
if isModallyPresented {
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
} else {
|
||||
showLoginScreen()
|
||||
// We are here because the default server failed to respond.
|
||||
if authenticationService.homeserver.value.loginMode == .password {
|
||||
// Add the password login screen directly to the flow, its fine.
|
||||
showLoginScreen()
|
||||
} else {
|
||||
// OIDC is presented from the confirmation screen so replace the
|
||||
// server selection screen which was inserted to handle the failure.
|
||||
navigationStackCoordinator.pop()
|
||||
showServerConfirmationScreen()
|
||||
}
|
||||
}
|
||||
case .dismiss:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import AuthenticationServices
|
||||
|
||||
/// Presents a web authentication session that will display the user's account settings page.
|
||||
///
|
||||
/// A web authentication session is used so that the same session used for login is available
|
||||
/// meaning that the user doesn't need to sign in again. `SFSafariViewController` doesn't
|
||||
/// have access to this session, and for some reason `prefersEphemeralWebBrowserSession`
|
||||
/// isn't sharing the session back to Safari.
|
||||
@MainActor
|
||||
class OIDCAccountSettingsPresenter: NSObject {
|
||||
private let accountURL: URL
|
||||
private let presentationAnchor: UIWindow
|
||||
private let oidcRedirectURL: URL
|
||||
|
||||
init(accountURL: URL, presentationAnchor: UIWindow) {
|
||||
self.accountURL = accountURL
|
||||
self.presentationAnchor = presentationAnchor
|
||||
oidcRedirectURL = ServiceLocator.shared.settings.oidcRedirectURL
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Presents a web authentication session for the supplied data.
|
||||
func start() {
|
||||
let session = ASWebAuthenticationSession(url: accountURL, callbackURLScheme: oidcRedirectURL.scheme) { _, _ in }
|
||||
session.prefersEphemeralWebBrowserSession = false
|
||||
session.presentationContextProvider = self
|
||||
session.start()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: ASWebAuthenticationPresentationContextProviding
|
||||
|
||||
extension OIDCAccountSettingsPresenter: ASWebAuthenticationPresentationContextProviding {
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { presentationAnchor }
|
||||
}
|
||||
@@ -48,6 +48,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
|
||||
switch action {
|
||||
case .close:
|
||||
self.callback?(.dismiss)
|
||||
case .account:
|
||||
self.presentAccountSettings()
|
||||
case .analytics:
|
||||
self.presentAnalyticsScreen()
|
||||
case .reportBug:
|
||||
@@ -74,6 +76,26 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var accountSettingsPresenter: OIDCAccountSettingsPresenter?
|
||||
private func presentAccountSettings() {
|
||||
guard let accountURL = viewModel.context.viewState.accountURL else {
|
||||
MXLog.error("Account URL is missing.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let window = viewModel.context.viewState.window else {
|
||||
MXLog.error("The window is missing.")
|
||||
return
|
||||
}
|
||||
|
||||
// Safari never works in the simulator, use a Web Authentication Session instead.
|
||||
accountSettingsPresenter = OIDCAccountSettingsPresenter(accountURL: accountURL, presentationAnchor: window)
|
||||
accountSettingsPresenter?.start()
|
||||
|
||||
// Safari isn't working with the shared browser session 😕
|
||||
// UIApplication.shared.open(accountURL)
|
||||
}
|
||||
|
||||
private func presentAnalyticsScreen() {
|
||||
let coordinator = AnalyticsSettingsScreenCoordinator(parameters: .init(appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics))
|
||||
|
||||
@@ -19,6 +19,7 @@ import UIKit
|
||||
|
||||
enum SettingsScreenViewModelAction {
|
||||
case close
|
||||
case account
|
||||
case analytics
|
||||
case reportBug
|
||||
case about
|
||||
@@ -32,11 +33,15 @@ struct SettingsScreenViewState: BindableState {
|
||||
var bindings: SettingsScreenViewStateBindings
|
||||
var deviceID: String?
|
||||
var userID: String
|
||||
var accountURL: URL?
|
||||
var userAvatarURL: URL?
|
||||
var userDisplayName: String?
|
||||
var showSessionVerificationSection: Bool
|
||||
var showNotificationSettings: Bool
|
||||
var showDeveloperOptions: Bool
|
||||
|
||||
/// The presentation anchor used to display the OIDC account URL.
|
||||
var window: UIWindow?
|
||||
}
|
||||
|
||||
struct SettingsScreenViewStateBindings {
|
||||
@@ -45,6 +50,7 @@ struct SettingsScreenViewStateBindings {
|
||||
|
||||
enum SettingsScreenViewAction {
|
||||
case close
|
||||
case account
|
||||
case analytics
|
||||
case reportBug
|
||||
case about
|
||||
@@ -53,4 +59,7 @@ enum SettingsScreenViewAction {
|
||||
case changedTimelineStyle
|
||||
case developerOptions
|
||||
case notifications
|
||||
|
||||
/// Updates the window used for the OIDC account URL anchor.
|
||||
case updateWindow(UIWindow)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
|
||||
super.init(initialViewState: .init(bindings: bindings,
|
||||
deviceID: userSession.deviceID,
|
||||
userID: userSession.userID,
|
||||
accountURL: userSession.clientProxy.accountURL,
|
||||
showSessionVerificationSection: showSessionVerificationSection,
|
||||
showNotificationSettings: appSettings.notificationSettingsEnabled,
|
||||
showDeveloperOptions: appSettings.canShowDeveloperOptions),
|
||||
@@ -85,6 +86,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
|
||||
switch viewAction {
|
||||
case .close:
|
||||
callback?(.close)
|
||||
case .account:
|
||||
callback?(.account)
|
||||
case .analytics:
|
||||
callback?(.analytics)
|
||||
case .reportBug:
|
||||
@@ -101,6 +104,12 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
|
||||
callback?(.notifications)
|
||||
case .developerOptions:
|
||||
callback?(.developerOptions)
|
||||
|
||||
case .updateWindow(let window):
|
||||
Task {
|
||||
guard state.window != window else { return }
|
||||
state.window = window
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ struct SettingsScreen: View {
|
||||
doneButton
|
||||
}
|
||||
}
|
||||
.introspect(.window, on: .iOS(.v16)) { window in
|
||||
context.send(viewAction: .updateWindow(window))
|
||||
}
|
||||
}
|
||||
|
||||
private var versionText: Text {
|
||||
@@ -100,6 +103,17 @@ struct SettingsScreen: View {
|
||||
|
||||
private var simplifiedSection: some View {
|
||||
Section {
|
||||
// Account
|
||||
if context.viewState.accountURL != nil {
|
||||
ListRow(label: .default(title: L10n.screenSettingsOidcAccount,
|
||||
systemIcon: .person),
|
||||
kind: .button {
|
||||
context.send(viewAction: .account)
|
||||
})
|
||||
.accessibilityIdentifier("notificationsButton")
|
||||
}
|
||||
|
||||
// Message layout
|
||||
ListRow(label: .default(title: L10n.commonMessageLayout,
|
||||
systemIcon: .rectangleGrid1x2),
|
||||
kind: .picker(selection: $context.timelineStyle,
|
||||
@@ -207,7 +221,8 @@ struct SettingsScreen_Previews: PreviewProvider {
|
||||
verificationController.isVerified = false
|
||||
let userSession = MockUserSession(sessionVerificationController: verificationController,
|
||||
clientProxy: MockClientProxy(userID: "@userid:example.com",
|
||||
deviceID: "AAAAAAAAAAA"),
|
||||
deviceID: "AAAAAAAAAAA",
|
||||
accountURL: "https://matrix.org/account"),
|
||||
mediaProvider: MockMediaProvider())
|
||||
ServiceLocator.shared.settings.notificationSettingsEnabled = true
|
||||
return SettingsScreenViewModel(userSession: userSession,
|
||||
|
||||
@@ -31,17 +31,17 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
|
||||
homeserverSubject = .init(LoginHomeserver(address: appSettings.defaultHomeserverAddress,
|
||||
loginMode: .unknown))
|
||||
|
||||
// let oidcConfiguration = OidcConfiguration(clientName: InfoPlistReader.main.bundleDisplayName,
|
||||
// redirectUri: settings.oidcRedirectURL.absoluteString,
|
||||
// clientUri: appSettings.oidcClientURL.absoluteString,
|
||||
// tosUri: appSettings.oidcTermsURL.absoluteString,
|
||||
// policyUri: appSettings.oidcPolicyURL.absoluteString,
|
||||
// staticRegistrations: appSettings.oidcStaticRegistrations.mapKeys { $0.absoluteString })
|
||||
let oidcConfiguration = OidcConfiguration(clientName: InfoPlistReader.main.bundleDisplayName,
|
||||
redirectUri: appSettings.oidcRedirectURL.absoluteString,
|
||||
clientUri: appSettings.oidcClientURL.absoluteString,
|
||||
tosUri: appSettings.oidcTermsURL.absoluteString,
|
||||
policyUri: appSettings.oidcPolicyURL.absoluteString,
|
||||
staticRegistrations: appSettings.oidcStaticRegistrations.mapKeys { $0.absoluteString })
|
||||
|
||||
authenticationService = AuthenticationService(basePath: userSessionStore.baseDirectory.path,
|
||||
passphrase: nil,
|
||||
userAgent: UserAgentBuilder.makeASCIIUserAgent(),
|
||||
// oidcConfiguration: oidcConfiguration,
|
||||
oidcConfiguration: oidcConfiguration,
|
||||
customSlidingSyncProxy: appSettings.slidingSyncProxyURL?.absoluteString)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
|
||||
}
|
||||
|
||||
if let details = authenticationService.homeserverDetails() {
|
||||
if details.authenticationIssuer() != nil {
|
||||
if details.supportsOidcLogin() {
|
||||
homeserver.loginMode = .oidc
|
||||
} else if details.supportsPasswordLogin() {
|
||||
homeserver.loginMode = .password
|
||||
@@ -77,31 +77,29 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
|
||||
}
|
||||
|
||||
func urlForOIDCLogin() async -> Result<OIDCAuthenticationDataProxy, AuthenticationServiceError> {
|
||||
.failure(.oidcError(.notSupported))
|
||||
// do {
|
||||
// let oidcData = try await Task.dispatch(on: .global()) {
|
||||
// try self.authenticationService.urlForOidcLogin()
|
||||
// }
|
||||
// return .success(OIDCAuthenticationDataProxy(underlyingData: oidcData))
|
||||
// } catch {
|
||||
// MXLog.error("Failed to get URL for OIDC login: \(error)")
|
||||
// return .failure(.oidcError(.urlFailure))
|
||||
// }
|
||||
do {
|
||||
let oidcData = try await Task.dispatch(on: .global()) {
|
||||
try self.authenticationService.urlForOidcLogin()
|
||||
}
|
||||
return .success(OIDCAuthenticationDataProxy(underlyingData: oidcData))
|
||||
} catch {
|
||||
MXLog.error("Failed to get URL for OIDC login: \(error)")
|
||||
return .failure(.oidcError(.urlFailure))
|
||||
}
|
||||
}
|
||||
|
||||
func loginWithOIDCCallback(_ callbackURL: URL, data: OIDCAuthenticationDataProxy) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
|
||||
.failure(.oidcError(.notSupported))
|
||||
// do {
|
||||
// let client = try await Task.dispatch(on: .global()) {
|
||||
// try self.authenticationService.loginWithOidcCallback(authenticationData: data.underlyingData, callbackUrl: callbackURL.absoluteString)
|
||||
// }
|
||||
// return await userSession(for: client)
|
||||
// } catch AuthenticationError.OidcCancelled {
|
||||
// return .failure(.oidcError(.userCancellation))
|
||||
// } catch {
|
||||
// MXLog.error("Login with OIDC failed: \(error)")
|
||||
// return .failure(.failedLoggingIn)
|
||||
// }
|
||||
do {
|
||||
let client = try await Task.dispatch(on: .global()) {
|
||||
try self.authenticationService.loginWithOidcCallback(authenticationData: data.underlyingData, callbackUrl: callbackURL.absoluteString)
|
||||
}
|
||||
return await userSession(for: client)
|
||||
} catch AuthenticationError.OidcCancelled {
|
||||
return .failure(.oidcError(.userCancellation))
|
||||
} catch {
|
||||
MXLog.error("Login with OIDC failed: \(error)")
|
||||
return .failure(.failedLoggingIn)
|
||||
}
|
||||
}
|
||||
|
||||
func login(username: String, password: String, initialDeviceName: String?, deviceID: String?) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
|
||||
|
||||
@@ -66,16 +66,18 @@ enum OIDCError: Error {
|
||||
}
|
||||
|
||||
struct OIDCAuthenticationDataProxy: Equatable {
|
||||
// let underlyingData: OidcAuthenticationData
|
||||
//
|
||||
// var url: URL {
|
||||
// URL(string: underlyingData.loginUrl())!
|
||||
// }
|
||||
let url: URL = "https://theroadtonowhere"
|
||||
let underlyingData: OidcAuthenticationData
|
||||
|
||||
var url: URL {
|
||||
guard let url = URL(string: underlyingData.loginUrl()) else {
|
||||
fatalError("OIDC login URL hasn't been validated.")
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// extension OidcAuthenticationData: Equatable {
|
||||
// public static func == (lhs: MatrixRustSDK.OidcAuthenticationData, rhs: MatrixRustSDK.OidcAuthenticationData) -> Bool {
|
||||
// lhs.loginUrl() == rhs.loginUrl()
|
||||
// }
|
||||
// }
|
||||
extension OidcAuthenticationData: Equatable {
|
||||
public static func == (lhs: MatrixRustSDK.OidcAuthenticationData, rhs: MatrixRustSDK.OidcAuthenticationData) -> Bool {
|
||||
lhs.loginUrl() == rhs.loginUrl()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,10 @@ class ClientProxy: ClientProxyProtocol {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var accountURL: URL? {
|
||||
client.accountUrl().flatMap(URL.init(string:))
|
||||
}
|
||||
|
||||
func startSync() {
|
||||
MXLog.info("Starting sync")
|
||||
@@ -305,7 +309,8 @@ class ClientProxy: ClientProxyProtocol {
|
||||
func logout() async {
|
||||
await Task.dispatch(on: clientQueue) {
|
||||
do {
|
||||
try self.client.logout()
|
||||
// We aren't currently handling the RP initiated sign out URL.
|
||||
_ = try self.client.logout()
|
||||
} catch {
|
||||
MXLog.error("Failed logging out with error: \(error)")
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
|
||||
|
||||
var restorationToken: RestorationToken? { get }
|
||||
|
||||
var accountURL: URL? { get }
|
||||
|
||||
var roomSummaryProvider: RoomSummaryProviderProtocol? { get }
|
||||
|
||||
var inviteSummaryProvider: RoomSummaryProviderProtocol? { get }
|
||||
|
||||
@@ -25,6 +25,7 @@ class MockClientProxy: ClientProxyProtocol {
|
||||
let deviceID: String?
|
||||
let homeserver = ""
|
||||
let restorationToken: RestorationToken? = nil
|
||||
let accountURL: URL?
|
||||
|
||||
var roomSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider()
|
||||
|
||||
@@ -32,9 +33,10 @@ class MockClientProxy: ClientProxyProtocol {
|
||||
|
||||
var avatarURLPublisher: AnyPublisher<URL?, Never> { Empty().eraseToAnyPublisher() }
|
||||
|
||||
init(userID: String, deviceID: String? = nil, roomSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider()) {
|
||||
init(userID: String, deviceID: String? = nil, accountURL: URL? = nil, roomSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider()) {
|
||||
self.userID = userID
|
||||
self.deviceID = deviceID
|
||||
self.accountURL = accountURL
|
||||
self.roomSummaryProvider = roomSummaryProvider
|
||||
}
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@ extension MatrixRustSDK.Session: Codable {
|
||||
userId: container.decode(String.self, forKey: .userId),
|
||||
deviceId: container.decode(String.self, forKey: .deviceId),
|
||||
homeserverUrl: container.decode(String.self, forKey: .homeserverUrl),
|
||||
oidcData: container.decodeIfPresent(String.self, forKey: .oidcData),
|
||||
slidingSyncProxy: container.decode(String.self, forKey: .slidingSyncProxy))
|
||||
// oidcData: container.decodeIfPresent(String.self, forKey: .oidcData)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
@@ -53,12 +53,11 @@ extension MatrixRustSDK.Session: Codable {
|
||||
try container.encode(userId, forKey: .userId)
|
||||
try container.encode(deviceId, forKey: .deviceId)
|
||||
try container.encode(homeserverUrl, forKey: .homeserverUrl)
|
||||
try container.encode(oidcData, forKey: .oidcData)
|
||||
try container.encode(slidingSyncProxy, forKey: .slidingSyncProxy)
|
||||
// try container.encode(oidcData, forKey: .oidcData)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken, refreshToken, userId, deviceId, homeserverUrl, slidingSyncProxy
|
||||
// case oidcData
|
||||
case accessToken, refreshToken, userId, deviceId, homeserverUrl, oidcData, slidingSyncProxy
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,10 +166,10 @@ class MockScreen: Identifiable {
|
||||
return navigationStackCoordinator
|
||||
case .settings:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let clientProxy = MockClientProxy(userID: "@mock:client.com", accountURL: "https://matrix.org/account")
|
||||
let coordinator = SettingsScreenCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator,
|
||||
userIndicatorController: nil,
|
||||
userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"),
|
||||
mediaProvider: MockMediaProvider()),
|
||||
userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()),
|
||||
bugReportService: BugReportServiceMock(),
|
||||
notificationSettings: NotificationSettingsProxyMock(with: .init())))
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
|
||||
Reference in New Issue
Block a user