Skip to content

Commit 9358451

Browse files
authored
Add prompt to authenticate with application passwords (#23726)
* Add a side-effect-free SelfHostedSiteAuthenticator.authentication * Add a RestApiUpgradePrompt for migrating site to application passwords * Localize the "Get Started" button * Add a WordPressAppUI target to workaround an Xcode/SPM issue * Extract localized strings to an enum * Remove WordPressAppUI
1 parent 5a19927 commit 9358451

File tree

5 files changed

+186
-21
lines changed

5 files changed

+186
-21
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import SwiftUI
2+
3+
public struct RestApiUpgradePrompt: View {
4+
5+
private let localizedFeatureName: String
6+
private var didTapGetStarted: () -> Void
7+
8+
public init(localizedFeatureName: String, didTapGetStarted: @escaping () -> Void) {
9+
self.localizedFeatureName = localizedFeatureName
10+
self.didTapGetStarted = didTapGetStarted
11+
}
12+
13+
public var body: some View {
14+
VStack {
15+
let scrollView = ScrollView {
16+
VStack(alignment: .leading) {
17+
Text(Strings.title)
18+
.font(.largeTitle)
19+
.fontWeight(.semibold)
20+
.padding(.bottom)
21+
22+
Text(Strings.description(localizedFeatureName: localizedFeatureName))
23+
.font(.body)
24+
}.padding()
25+
}
26+
27+
if #available(iOS 16.4, *) {
28+
scrollView.scrollBounceBehavior(.basedOnSize, axes: [.vertical])
29+
}
30+
31+
Spacer()
32+
VStack {
33+
Button(action: didTapGetStarted, label: {
34+
HStack {
35+
Spacer()
36+
Text(Strings.getStarted)
37+
.font(.headline)
38+
.padding(4)
39+
Spacer()
40+
}
41+
}).buttonStyle(.borderedProminent)
42+
}.padding()
43+
}
44+
}
45+
46+
private enum Strings {
47+
static var title: String {
48+
NSLocalizedString("applicationPasswordRequired.title", value: "Application Password Required", comment: "Title for the prompt to upgrade to Application Passwords")
49+
}
50+
51+
static func description(localizedFeatureName: String) -> String {
52+
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.")
53+
return String(format: format, localizedFeatureName)
54+
}
55+
56+
static var getStarted: String {
57+
NSLocalizedString("applicationPasswordRequired.getStartedButton", value: "Get Started", comment: "Title for the button to authenticate with Application Passwords")
58+
}
59+
}
60+
}
61+
62+
#Preview {
63+
RestApiUpgradePrompt(localizedFeatureName: "User Management") {
64+
debugPrint("Tapped Get Started")
65+
}
66+
}

Modules/Tests/DesignSystemTests/IconTests.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import SwiftUI
44

55
final class IconTests: XCTestCase {
66

7+
// This test will fail if DesignSystem is built as a dynamic library. For some reason, Xcode can't locate
8+
// the library's resource bundle.
9+
//
10+
// DesignSystem will be built as a dynamic library if it's a dependency of a dynamic library, such as
11+
// the WordPressAuthenticator target.
712
func testCanLoadAllIconsAsUIImage() throws {
813
for icon in IconName.allCases {
914
let _ = try XCTUnwrap(UIImage.DS.icon(named: icon))

WordPress/Classes/Login/LoginWithUrlView.swift

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,19 +112,30 @@ private extension SelfHostedSiteAuthenticator.SignInError {
112112

113113
var errorMessage: String? {
114114
switch self {
115-
case let .authentication(.invalidSiteAddress(error)):
115+
case let .authentication(error):
116116
return error.errorMessage
117-
case .authentication(.missingLoginUrl):
118-
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")
119-
case .authentication(.cancelled):
120-
return nil
121-
case .authentication(.authenticationError), .authentication(.invalidApplicationPasswordCallback):
122-
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")
123117
case .loadingSiteInfoFailure:
124118
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")
125119
case .savingSiteFailure:
126120
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")
127-
case .authentication(.unknown):
121+
}
122+
}
123+
124+
}
125+
126+
extension WordPressLoginClient.Error {
127+
128+
var errorMessage: String? {
129+
switch self {
130+
case let .invalidSiteAddress(error):
131+
return error.errorMessage
132+
case .missingLoginUrl:
133+
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")
134+
case .cancelled:
135+
return nil
136+
case .authenticationError, .invalidApplicationPasswordCallback:
137+
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")
138+
case .unknown:
128139
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")
129140
}
130141
}

WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ final actor SelfHostedSiteAuthenticator {
5151

5252
@MainActor
5353
private func _signIn(site: String, from anchor: ASPresentationAnchor?) async throws(SignInError) -> WordPressOrgCredentials {
54+
let success: WpApiApplicationPasswordDetails
55+
do {
56+
success = try await authentication(site: site, from: anchor)
57+
} catch {
58+
throw .authentication(error)
59+
}
60+
61+
return try await handleSuccess(success)
62+
}
63+
64+
@MainActor
65+
func authentication(site: String, from anchor: ASPresentationAnchor?) async throws(WordPressLoginClient.Error) -> WpApiApplicationPasswordDetails {
5466
let appId: WpUuid
5567
let appName: String
5668

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

76-
switch result {
77-
case let .failure(error):
78-
throw .authentication(error)
79-
case let .success(success):
80-
return try await handleSuccess(success)
81-
}
88+
return try result.get()
8289
}
8390

84-
func handleSuccess(_ success: WpApiApplicationPasswordDetails) async throws(SignInError) -> WordPressOrgCredentials {
91+
private func handleSuccess(_ success: WpApiApplicationPasswordDetails) async throws(SignInError) -> WordPressOrgCredentials {
8592
let xmlrpc: String
8693
let blogOptions: [AnyHashable: Any]
8794
do {

WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import Foundation
22
import UIKit
33
import SwiftUI
4+
import WordPressUI
5+
import WordPressAPI
46

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

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

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

141143
@objc func showApplicationPasswordManagement() {
142-
guard let presentationDelegate, let service = createApplicationPasswordService() else {
144+
guard let presentationDelegate, let userId = self.blog.userID?.intValue else {
143145
return
144146
}
145147

146-
let viewModel = ApplicationTokenListViewModel(dataProvider: service)
147-
let viewController = UIHostingController(rootView: ApplicationTokenListView(viewModel: viewModel))
148-
presentationDelegate.presentBlogDetailsViewController(viewController)
148+
let feature = NSLocalizedString("applicationPasswordRequired.feature.plugins", value: "Application Passwords Management", comment: "Feature name for managing Application Passwords in the app")
149+
let rootView = ApplicationPasswordRequiredView(blog: self.blog, localizedFeatureName: feature) { client in
150+
let service = ApplicationPasswordService(api: client, currentUserId: userId)
151+
let viewModel = ApplicationTokenListViewModel(dataProvider: service)
152+
return ApplicationTokenListView(viewModel: viewModel)
153+
}
154+
presentationDelegate.presentBlogDetailsViewController(UIHostingController(rootView: rootView))
149155
}
150156

151157
@objc func showManagePluginsScreen() {
@@ -157,4 +163,74 @@ extension BlogDetailsViewController {
157163
let listViewController = PluginListViewController(site: site, query: query)
158164
presentationDelegate?.presentBlogDetailsViewController(listViewController)
159165
}
160-
}
166+
}
167+
168+
struct ApplicationPasswordRequiredView<Content: View>: View {
169+
private let blog: Blog
170+
private let localizedFeatureName: String
171+
@State private var site: WordPressSite?
172+
private let builder: (WordPressClient) -> Content
173+
174+
init(blog: Blog, localizedFeatureName: String, @ViewBuilder content: @escaping (WordPressClient) -> Content) {
175+
wpAssert(blog.account == nil, "The Blog argument should be a self-hosted site")
176+
177+
self.blog = blog
178+
self.localizedFeatureName = localizedFeatureName
179+
self.site = try? WordPressSite(blog: blog)
180+
self.builder = content
181+
}
182+
183+
var body: some View {
184+
if let site {
185+
builder(WordPressClient(site: site))
186+
} else {
187+
RestApiUpgradePrompt(localizedFeatureName: localizedFeatureName) {
188+
Task {
189+
await self.migrate()
190+
}
191+
}
192+
}
193+
}
194+
195+
@MainActor
196+
private func migrate() async {
197+
guard let url = try? blog.getUrlString() else {
198+
Notice(title: Strings.siteUrlNotFoundError).post()
199+
return
200+
}
201+
202+
do {
203+
// Get an application password for the given site.
204+
let authenticator = SelfHostedSiteAuthenticator(session: .shared)
205+
let success = try await authenticator.authentication(site: url, from: nil)
206+
207+
// Ensure the application password belongs to the current signed in user
208+
if let username = blog.username, success.userLogin != username {
209+
Notice(title: Strings.userNameMismatch(expected: username)).post()
210+
return
211+
}
212+
213+
try blog.setApplicationToken(success.password)
214+
215+
// Modify the `site` variable to display the intended feature.
216+
self.site = try .init(baseUrl: ParsedUrl.parse(input: success.siteUrl), type: .selfHosted(username: success.userLogin, authToken: success.password))
217+
} catch let error as WordPressLoginClient.Error {
218+
if let message = error.errorMessage {
219+
Notice(title: message).post()
220+
}
221+
} catch {
222+
Notice(title: SharedStrings.Error.generic).post()
223+
}
224+
}
225+
226+
enum Strings {
227+
static var siteUrlNotFoundError: String {
228+
NSLocalizedString("applicationPasswordMigration.error.siteUrlNotFound", value: "Cannot find the current site's url", comment: "Error message when the current site's url cannot be found")
229+
}
230+
231+
static func userNameMismatch(expected: String) -> String {
232+
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")
233+
return String(format: format, expected)
234+
}
235+
}
236+
}

0 commit comments

Comments
 (0)