Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions Modules/Sources/WordPressUI/Components/RestApiUpgradePrompt.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import SwiftUI

public struct RestApiUpgradePrompt: View {

private let localizedFeatureName: String
private var didTapGetStarted: () -> Void

public init(localizedFeatureName: String, didTapGetStarted: @escaping () -> Void) {
self.localizedFeatureName = localizedFeatureName
self.didTapGetStarted = didTapGetStarted
}

public var body: some View {
VStack {
let scrollView = ScrollView {
VStack(alignment: .leading) {
Text(Strings.title)
.font(.largeTitle)
.fontWeight(.semibold)
.padding(.bottom)

Text(Strings.description(localizedFeatureName: localizedFeatureName))
.font(.body)
}.padding()
}

if #available(iOS 16.4, *) {
scrollView.scrollBounceBehavior(.basedOnSize, axes: [.vertical])
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jkmassel I didn't use the AccessibilityScrollView in your PR, because dynamic size seems working fine without it. Let me know if I missed anything.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a problem – we have a lot more room to work with here


Spacer()
VStack {
Button(action: didTapGetStarted, label: {
HStack {
Spacer()
Text(Strings.getStarted)
.font(.headline)
.padding(4)
Spacer()
}
}).buttonStyle(.borderedProminent)
}.padding()
}
}

private enum Strings {
static var title: String {
NSLocalizedString("applicationPasswordRequired.title", value: "Application Password Required", comment: "Title for the prompt to upgrade to Application Passwords")
}

static func description(localizedFeatureName: String) -> String {
let format = NSLocalizedString("applicationPasswordRequired.description", value: "Application passwords are a more secure way to connect to your self-hosted site, and enable support for features like %@.", comment: "Description for the prompt to upgrade to Application Passwords. The first argument is the name of the feature that requires Application Passwords.")
return String(format: format, localizedFeatureName)
}

static var getStarted: String {
NSLocalizedString("applicationPasswordRequired.getStartedButton", value: "Get Started", comment: "Title for the button to authenticate with Application Passwords")
}
}
}

#Preview {
RestApiUpgradePrompt(localizedFeatureName: "User Management") {
debugPrint("Tapped Get Started")
}
}
5 changes: 5 additions & 0 deletions Modules/Tests/DesignSystemTests/IconTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import SwiftUI

final class IconTests: XCTestCase {

// This test will fail if DesignSystem is built as a dynamic library. For some reason, Xcode can't locate
// the library's resource bundle.
//
// DesignSystem will be built as a dynamic library if it's a dependency of a dynamic library, such as
// the WordPressAuthenticator target.
func testCanLoadAllIconsAsUIImage() throws {
for icon in IconName.allCases {
let _ = try XCTUnwrap(UIImage.DS.icon(named: icon))
Expand Down
27 changes: 19 additions & 8 deletions WordPress/Classes/Login/LoginWithUrlView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,30 @@ private extension SelfHostedSiteAuthenticator.SignInError {

var errorMessage: String? {
switch self {
case let .authentication(.invalidSiteAddress(error)):
case let .authentication(error):
return error.errorMessage
case .authentication(.missingLoginUrl):
return NSLocalizedString("addSite.selfHosted.noLoginUrlFound", value: "Application Password authentication needs to be enabled on the WordPress site.", comment: "Error message shown when application-password authentication is disabled on a self-hosted WordPress site")
case .authentication(.cancelled):
return nil
case .authentication(.authenticationError), .authentication(.invalidApplicationPasswordCallback):
return NSLocalizedString("addSite.selfHosted.authenticationFailed", value: "Cannot login using Application Password authentication.", comment: "Error message shown when an receiving an invalid application-password authentication result from a self-hosted WordPress site")
case .loadingSiteInfoFailure:
return NSLocalizedString("addSite.selfHosted.loadingSiteInfoFailure", value: "Cannot load the WordPress site details", comment: "Error message shown when failing to load details from a self-hosted WordPress site")
case .savingSiteFailure:
return NSLocalizedString("addSite.selfHosted.savingSiteFailure", value: "Cannot save the WordPress site, please try again later.", comment: "Error message shown when failing to save a self-hosted site to user's device")
case .authentication(.unknown):
}
}

}

extension WordPressLoginClient.Error {

var errorMessage: String? {
switch self {
case let .invalidSiteAddress(error):
return error.errorMessage
case .missingLoginUrl:
return NSLocalizedString("addSite.selfHosted.noLoginUrlFound", value: "Application Password authentication needs to be enabled on the WordPress site.", comment: "Error message shown when application-password authentication is disabled on a self-hosted WordPress site")
case .cancelled:
return nil
case .authenticationError, .invalidApplicationPasswordCallback:
return NSLocalizedString("addSite.selfHosted.authenticationFailed", value: "Cannot login using Application Password authentication.", comment: "Error message shown when an receiving an invalid application-password authentication result from a self-hosted WordPress site")
case .unknown:
return NSLocalizedString("addSite.selfHosted.unknownError", value: "Something went wrong. Please try again.", comment: "Error message when an unknown error occurred when adding a self-hosted site")
}
}
Expand Down
21 changes: 14 additions & 7 deletions WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ final actor SelfHostedSiteAuthenticator {

@MainActor
private func _signIn(site: String, from anchor: ASPresentationAnchor?) async throws(SignInError) -> WordPressOrgCredentials {
let success: WpApiApplicationPasswordDetails
do {
success = try await authentication(site: site, from: anchor)
} catch {
throw .authentication(error)
}

return try await handleSuccess(success)
}

@MainActor
func authentication(site: String, from anchor: ASPresentationAnchor?) async throws(WordPressLoginClient.Error) -> WpApiApplicationPasswordDetails {
let appId: WpUuid
let appName: String

Expand All @@ -73,15 +85,10 @@ final actor SelfHostedSiteAuthenticator {
contextProvider: WebAuthenticationPresentationAnchorProvider(anchor: anchor ?? ASPresentationAnchor())
)

switch result {
case let .failure(error):
throw .authentication(error)
case let .success(success):
return try await handleSuccess(success)
}
return try result.get()
}

func handleSuccess(_ success: WpApiApplicationPasswordDetails) async throws(SignInError) -> WordPressOrgCredentials {
private func handleSuccess(_ success: WpApiApplicationPasswordDetails) async throws(SignInError) -> WordPressOrgCredentials {
let xmlrpc: String
let blogOptions: [AnyHashable: Any]
do {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import Foundation
import UIKit
import SwiftUI
import WordPressUI
import WordPressAPI

extension Array where Element: BlogDetailsSection {
fileprivate func findSectionIndex(of category: BlogDetailsSectionCategory) -> Int? {
Expand Down Expand Up @@ -120,7 +122,7 @@ extension BlogDetailsViewController {

@objc func shouldShowApplicationPasswordRow() -> Bool {
// Only available for application-password authenticated self-hosted sites.
return self.blog.account == nil && self.blog.userID != nil && (try? WordPressSite(blog: self.blog)) != nil
return self.blog.account == nil && self.blog.userID != nil
}

private func createApplicationPasswordService() -> ApplicationPasswordService? {
Expand All @@ -139,13 +141,17 @@ extension BlogDetailsViewController {
}

@objc func showApplicationPasswordManagement() {
guard let presentationDelegate, let service = createApplicationPasswordService() else {
guard let presentationDelegate, let userId = self.blog.userID?.intValue else {
return
}

let viewModel = ApplicationTokenListViewModel(dataProvider: service)
let viewController = UIHostingController(rootView: ApplicationTokenListView(viewModel: viewModel))
presentationDelegate.presentBlogDetailsViewController(viewController)
let feature = NSLocalizedString("applicationPasswordRequired.feature.plugins", value: "Application Passwords Management", comment: "Feature name for managing Application Passwords in the app")
let rootView = ApplicationPasswordRequiredView(blog: self.blog, localizedFeatureName: feature) { client in
let service = ApplicationPasswordService(api: client, currentUserId: userId)
let viewModel = ApplicationTokenListViewModel(dataProvider: service)
return ApplicationTokenListView(viewModel: viewModel)
}
presentationDelegate.presentBlogDetailsViewController(UIHostingController(rootView: rootView))
}

@objc func showManagePluginsScreen() {
Expand All @@ -157,4 +163,74 @@ extension BlogDetailsViewController {
let listViewController = PluginListViewController(site: site, query: query)
presentationDelegate?.presentBlogDetailsViewController(listViewController)
}
}
}

struct ApplicationPasswordRequiredView<Content: View>: View {
private let blog: Blog
private let localizedFeatureName: String
@State private var site: WordPressSite?
private let builder: (WordPressClient) -> Content

init(blog: Blog, localizedFeatureName: String, @ViewBuilder content: @escaping (WordPressClient) -> Content) {
wpAssert(blog.account == nil, "The Blog argument should be a self-hosted site")

self.blog = blog
self.localizedFeatureName = localizedFeatureName
self.site = try? WordPressSite(blog: blog)
self.builder = content
}

var body: some View {
if let site {
builder(WordPressClient(site: site))
} else {
RestApiUpgradePrompt(localizedFeatureName: localizedFeatureName) {
Task {
await self.migrate()
}
}
}
}

@MainActor
private func migrate() async {
guard let url = try? blog.getUrlString() else {
Notice(title: Strings.siteUrlNotFoundError).post()
return
}

do {
// Get an application password for the given site.
let authenticator = SelfHostedSiteAuthenticator(session: .shared)
let success = try await authenticator.authentication(site: url, from: nil)

// Ensure the application password belongs to the current signed in user
if let username = blog.username, success.userLogin != username {
Notice(title: Strings.userNameMismatch(expected: username)).post()
return
}

try blog.setApplicationToken(success.password)

// Modify the `site` variable to display the intended feature.
self.site = try .init(baseUrl: ParsedUrl.parse(input: success.siteUrl), type: .selfHosted(username: success.userLogin, authToken: success.password))
} catch let error as WordPressLoginClient.Error {
if let message = error.errorMessage {
Notice(title: message).post()
}
} catch {
Notice(title: SharedStrings.Error.generic).post()
}
}

enum Strings {
static var siteUrlNotFoundError: String {
NSLocalizedString("applicationPasswordMigration.error.siteUrlNotFound", value: "Cannot find the current site's url", comment: "Error message when the current site's url cannot be found")
}

static func userNameMismatch(expected: String) -> String {
let format = NSLocalizedString("applicationPasswordMigration.error.usernameMismatch", value: "You need to sign in with user \"%@\"", comment: "Error message when the username does not match the signed-in user. The first argument is the currently signed in user's user login name")
return String(format: format, expected)
}
}
}