diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 46c70b78950d..cbe798cb19c7 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2f50dc7601f992ac0e22d0e32a759ef5d14289c7cc5f8b887c46073f7d50391b", + "originHash" : "43fbce57dedae27ed83f0ebd59ff2f81e3f0deffd1a163762504793d8642905b", "pins" : [ { "identity" : "alamofire", @@ -380,8 +380,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Automattic/wordpress-rs", "state" : { - "branch" : "alpha-20250520", - "revision" : "a1960ad79ecec20a8b18deda447f2f45630b14ce" + "branch" : "alpha-20250523", + "revision" : "ed3784376b8efd1eafcf85caa9055b1c3b1f80da" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 23c706dad519..ef20d1152e0f 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -54,7 +54,7 @@ let package = Package( ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. - .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250520"), + .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250523"), .package(url: "https://github.com/wordpress-mobile/GutenbergKit", revision: "fdfe788530bbff864ce7147b5a68608d7025e078"), .package( url: "https://github.com/Automattic/color-studio", diff --git a/Sources/WordPressData/Swift/Blog+SelfHosted.swift b/Sources/WordPressData/Swift/Blog+SelfHosted.swift index 13e7b67a03cf..060b8c98c651 100644 --- a/Sources/WordPressData/Swift/Blog+SelfHosted.swift +++ b/Sources/WordPressData/Swift/Blog+SelfHosted.swift @@ -20,12 +20,18 @@ public extension Blog { with details: WpApiApplicationPasswordDetails, restApiRootURL: URL, xmlrpcEndpointURL: URL, + blogID: TaggedManagedObjectID?, in contextManager: ContextManager, using keychainImplementation: KeychainAccessible = KeychainUtils() ) async throws -> TaggedManagedObjectID { try await contextManager.performAndSave { context in - let blog = Blog.lookup(username: details.userLogin, xmlrpc: xmlrpcEndpointURL.absoluteString, in: context) - ?? Blog.createBlankBlog(in: context) + let blog = if let blogID { + try context.existingObject(with: blogID) + } else { + Blog.lookup(username: details.userLogin, xmlrpc: xmlrpcEndpointURL.absoluteString, in: context) + ?? Blog.createBlankBlog(in: context) + } + blog.url = details.siteUrl blog.username = details.userLogin blog.restApiRootURL = restApiRootURL.absoluteString @@ -183,13 +189,41 @@ public enum WordPressSite { case selfHosted(blogId: TaggedManagedObjectID, apiRootURL: ParsedUrl, username: String, authToken: String) public init(blog: Blog) throws { - if let _ = blog.account { - // WP.com support is not ready yet. - throw NSError(domain: "WordPressAPI", code: 0) + // Directly access the site content when available. + if let restApiRootURL = blog.restApiRootURL, + let restApiRootURL = try? ParsedUrl.parse(input: restApiRootURL), + let username = blog.username, + let authToken = try? blog.getApplicationToken() { + self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: restApiRootURL, username: username, authToken: authToken) + } else if let account = blog.account, let siteId = blog.dotComID?.intValue { + // When the site is added via a WP.com account, access the site via WP.com + let authToken = try account.authToken ?? WPAccount.token(forUsername: account.username) + self = .dotCom(siteId: siteId, authToken: authToken) } else { + // In theory, this branch should never run, because the two if statements above should have covered all paths. + // But we'll keep it here as the fallback. let url = try blog.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString let apiRootURL = try ParsedUrl.parse(input: url) self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken()) } } + + public static func throughDotCom(blog: Blog) -> Self? { + guard + let account = blog.account, + let siteId = blog.dotComID?.intValue, + let authToken = try? account.authToken ?? WPAccount.token(forUsername: account.username) + else { return nil } + + return .dotCom(siteId: siteId, authToken: authToken) + } + + public func blog(in context: NSManagedObjectContext) throws -> Blog? { + switch self { + case let .dotCom(siteId, _): + return try Blog.lookup(withID: siteId, in: context) + case let .selfHosted(blogId, _, _, _): + return try context.existingObject(with: blogId) + } + } } diff --git a/Tests/KeystoneTests/Tests/Models/Blog+RestAPITests.swift b/Tests/KeystoneTests/Tests/Models/Blog+RestAPITests.swift index d0eb511ddaec..103d065e6ea1 100644 --- a/Tests/KeystoneTests/Tests/Models/Blog+RestAPITests.swift +++ b/Tests/KeystoneTests/Tests/Models/Blog+RestAPITests.swift @@ -19,6 +19,7 @@ final class Blog_RestAPITests: CoreDataTestCase { with: loginDetails, restApiRootURL: URL(string: "https://example.com/wp-json")!, xmlrpcEndpointURL: URL(string: "https://example.com/dir/xmlrpc.php")!, + blogID: nil, in: contextManager, using: testKeychain ) diff --git a/WordPress/Classes/Login/ApplicationPasswordReAuthenticationView.swift b/WordPress/Classes/Login/ApplicationPasswordReAuthenticationView.swift index 847892f50455..7dba1ba920f3 100644 --- a/WordPress/Classes/Login/ApplicationPasswordReAuthenticationView.swift +++ b/WordPress/Classes/Login/ApplicationPasswordReAuthenticationView.swift @@ -37,7 +37,7 @@ struct ApplicationPasswordReAuthenticationView: View { .signIn( site: blog.getUrl().absoluteString, from: presenter, - context: .reauthentication(username: blog.getUsername()) + context: .reauthentication(TaggedManagedObjectID(blog), username: blog.getUsername()) ) // Automatically dismiss this view upon a successful re-authentication. diff --git a/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift b/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift index 22bade4108c6..2ec6666953c3 100644 --- a/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift +++ b/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift @@ -45,7 +45,7 @@ struct ApplicationPasswordRequiredView: View { do { // Get an application password for the given site. let authenticator = SelfHostedSiteAuthenticator() - let blogID = try await authenticator.signIn(site: url, from: presenter, context: .reauthentication(username: blog.username)) + let blogID = try await authenticator.signIn(site: url, from: presenter, context: .reauthentication(TaggedManagedObjectID(blog), username: blog.username)) // Modify the `site` variable to display the intended feature. let blog = try ContextManager.shared.mainContext.existingObject(with: blogID) diff --git a/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift b/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift index f63eea5f7fd2..a7a9bb63d3db 100644 --- a/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift +++ b/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift @@ -16,7 +16,16 @@ struct SelfHostedSiteAuthenticator { // Sign in to a self-hosted site. Using this context results in automatically reloading the app to display the site dashboard. case `default` // Sign in to a site that's alredy added to the app. This is typically used when the app needs to get a new application password. - case reauthentication(username: String?) + case reauthentication(TaggedManagedObjectID, username: String?) + + var blogID: TaggedManagedObjectID? { + switch self { + case .default: + return nil + case let .reauthentication(blogID, _): + return blogID + } + } } private static let callbackURL = URL(string: "x-wordpress-app://login-callback")! @@ -78,30 +87,32 @@ struct SelfHostedSiteAuthenticator { @MainActor func signIn(site: String, from viewController: UIViewController, context: SignInContext) async throws(SignInError) -> TaggedManagedObjectID { + let details: AutoDiscoveryAttemptSuccess do { - let result = try await _signIn(site: site, from: viewController, context: context) - trackSuccess(url: site) - return result + details = try await internalClient.details(ofSite: site) } catch { - trackTypedError(error, url: site) - throw error + trackTypedError(.authentication(error), url: site) + throw .authentication(error) } + + return try await signIn(details: details, from: viewController, context: context) } @MainActor - private func _signIn(site: String, from viewController: UIViewController, context: SignInContext) async throws(SignInError) -> TaggedManagedObjectID { + func signIn(details: AutoDiscoveryAttemptSuccess, from viewController: UIViewController, context: SignInContext) async throws(SignInError) -> TaggedManagedObjectID { do { - let (apiRootURL, credentials) = try await authenticate(site: site, from: viewController) - return try await handle(credentials: credentials, apiRootURL: apiRootURL, context: context) - } catch let error as SignInError { - throw error + let (apiRootURL, credentials) = try await authenticate(details: details, from: viewController) + let result = try await handle(credentials: credentials, apiRootURL: apiRootURL, context: context) + trackSuccess(url: details.parsedSiteUrl.url()) + return result } catch { - throw .authentication(error) + trackTypedError(error, url: details.parsedSiteUrl.url()) + throw error } } @MainActor - private func authenticate(site: String, from viewController: UIViewController) async throws -> (apiRootURL: URL, credentials: WpApiApplicationPasswordDetails) { + private func authenticate(details: AutoDiscoveryAttemptSuccess, from viewController: UIViewController) async throws(SignInError) -> (apiRootURL: URL, credentials: WpApiApplicationPasswordDetails) { let appId: WpUuid let appName: String @@ -117,10 +128,13 @@ struct SelfHostedSiteAuthenticator { let timestamp = ISO8601DateFormatter.string(from: .now, timeZone: .current, formatOptions: .withInternetDateTime) let appNameValue = "\(appName) - \(deviceName) (\(timestamp))" - let details = try await internalClient.details(ofSite: site) - let loginURL = try details.loginURL(for: .init(id: appId, name: appNameValue, callbackUrl: SelfHostedSiteAuthenticator.callbackURL.absoluteString)) - let callback = try await authorize(url: loginURL, callbackURL: SelfHostedSiteAuthenticator.callbackURL, from: viewController) - return (details.apiRootUrl.asURL(), try internalClient.credentials(from: callback)) + do { + let loginURL = details.loginURL(for: .init(id: appId, name: appNameValue, callbackUrl: SelfHostedSiteAuthenticator.callbackURL.absoluteString)) + let callback = try await authorize(url: loginURL, callbackURL: SelfHostedSiteAuthenticator.callbackURL, from: viewController) + return (details.apiRootUrl.asURL(), try internalClient.credentials(from: callback)) + } catch { + throw .authentication(error) + } } @MainActor @@ -150,7 +164,7 @@ struct SelfHostedSiteAuthenticator { SVProgressHUD.dismiss() } - if case let .reauthentication(username) = context, let username, username != credentials.userLogin { + if case let .reauthentication(_, username) = context, let username, username != credentials.userLogin { throw .mismatchedUser(expectedUsername: username) } @@ -165,7 +179,13 @@ struct SelfHostedSiteAuthenticator { // Only store the new site after credentials are validated. let blog: TaggedManagedObjectID do { - blog = try await Blog.createRestApiBlog(with: credentials, restApiRootURL: apiRootURL, xmlrpcEndpointURL: xmlrpc, in: ContextManager.shared) + blog = try await Blog.createRestApiBlog( + with: credentials, + restApiRootURL: apiRootURL, + xmlrpcEndpointURL: xmlrpc, + blogID: context.blogID, + in: ContextManager.shared + ) } catch { throw .savingSiteFailure } diff --git a/WordPress/Classes/Networking/WordPressClient.swift b/WordPress/Classes/Networking/WordPressClient.swift index 5f86f53fd818..6a0e0427145c 100644 --- a/WordPress/Classes/Networking/WordPressClient.swift +++ b/WordPress/Classes/Networking/WordPressClient.swift @@ -21,22 +21,28 @@ extension WordPressClient { // rather than using the shared one on disk). let session = URLSession(configuration: .ephemeral) + let notifier = AppNotifier() + let provider = WpAuthenticationProvider.dynamic( + dynamicAuthenticationProvider: AutoUpdateAuthenticationProvider(site: site, coreDataStack: ContextManager.shared) + ) + let apiRootURL: ParsedUrl + let resolver: ApiUrlResolver switch site { - case let .dotCom(siteId, authToken): - let apiRootURL = try! ParsedUrl.parse(input: "https://public-api.wordpress.com/wpcom/v2/site/\(siteId)") - let api = WordPressAPI(urlSession: session, apiRootUrl: apiRootURL, authentication: .bearer(token: authToken)) - self.init(api: api, rootUrl: apiRootURL) - case let .selfHosted(blogId, apiRootURL, username, authToken): - let provider = AutoUpdateAuthenticationProvider( - authentication: .init(username: username, password: authToken), - blogId: blogId, - coreDataStack: ContextManager.shared - ) - let notifier = AppNotifier() - let api = WordPressAPI(urlSession: session, apiRootUrl: apiRootURL, authenticationProvider: .dynamic(dynamicAuthenticationProvider: provider), appNotifier: notifier) - notifier.api = api - self.init(api: api, rootUrl: apiRootURL) + case let .dotCom(siteId, _): + apiRootURL = try! ParsedUrl.parse(input: "https://public-api.wordpress.com/wp/v2/site/\(siteId)") + resolver = WpComDotOrgApiUrlResolver(siteUrl: "\(siteId)") + case let .selfHosted(_, url, _, _): + apiRootURL = url + resolver = WpOrgSiteApiUrlResolver(apiRootUrl: url) } + let api = WordPressAPI( + urlSession: session, + apiUrlResolver: resolver, + authenticationProvider: provider, + appNotifier: notifier + ) + notifier.api = api + self.init(api: api, rootUrl: apiRootURL) } func installJetpack() async throws -> PluginWithEditContext { @@ -57,23 +63,44 @@ extension PluginWpOrgDirectorySlug: @retroactive ExpressibleByStringLiteral { private final class AutoUpdateAuthenticationProvider: @unchecked Sendable, WpDynamicAuthenticationProvider { private let lock = NSLock() + private let site: WordPressSite + private let coreDataStack: CoreDataStack private var authentication: WpAuthentication private var cancellable: AnyCancellable? - init(authentication: WpAuthentication, blogId: TaggedManagedObjectID, coreDataStack: CoreDataStack) { - self.authentication = authentication + init(site: WordPressSite, coreDataStack: CoreDataStack) { + self.site = site + self.coreDataStack = coreDataStack + self.authentication = switch site { + case let .dotCom(_, authToken): + .bearer(token: authToken) + case let .selfHosted(_, _, username, authToken): + .init(username: username, password: authToken) + } + self.cancellable = NotificationCenter.default.publisher(for: SelfHostedSiteAuthenticator.applicationPasswordUpdated).sink { [weak self] _ in - guard let self else { return } + self?.update() + } + } - self.lock.lock() - defer { - self.lock.unlock() - } + func update() { + self.lock.lock() + defer { + self.lock.unlock() + } - self.authentication = coreDataStack.performQuery { context in + self.authentication = coreDataStack.performQuery { [site] context in + switch site { + case let .dotCom(siteId, _): + guard let blog = try? Blog.lookup(withID: siteId, in: context), + let token = blog.authToken else { + return WpAuthentication.none + } + return WpAuthentication.bearer(token: token) + case let .selfHosted(blogId, _, _, _): guard let blog = try? context.existingObject(with: blogId), - let username = try? blog.getUsername(), - let password = try? blog.getApplicationToken() + let username = try? blog.getUsername(), + let password = try? blog.getApplicationToken() else { return WpAuthentication.none } diff --git a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift index db3f6a0e0a59..c636d735e523 100644 --- a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift +++ b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift @@ -15,6 +15,7 @@ import WordPressKit return CommentServiceRemoteREST(wordPressComRestApi: api, siteID: dotComID) } + // The REST API does not have information about comment "likes". We'll continue to use WordPress.com API for now. if let site = try? WordPressSite(blog: blog) { return CommentServiceRemoteCoreRESTAPI(client: .init(site: site)) } diff --git a/WordPress/Classes/Services/MediaRepository.swift b/WordPress/Classes/Services/MediaRepository.swift index 68cd314089f7..109bef7983b6 100644 --- a/WordPress/Classes/Services/MediaRepository.swift +++ b/WordPress/Classes/Services/MediaRepository.swift @@ -94,6 +94,9 @@ private extension MediaRepository { return MediaServiceRemoteREST(wordPressComRestApi: api, siteID: dotComID) } + // We use WordPress.com API for media management instead of WordPress core REST API to ensure + // compatibility with WordPress.com-specific features such as video upload restrictions + // and storage limits based on the site's plan. if let site = try? WordPressSite(blog: blog) { return MediaServiceRemoteCoreREST(client: .init(site: site)) } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift new file mode 100644 index 000000000000..77b9df279d9e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift @@ -0,0 +1,47 @@ +import Foundation +import WordPressUI +import SwiftUI + +public class ApplicationPasswordAuthenticationCardCell: UITableViewCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + let view = UIHostingView(view: ApplicationPasswordAuthenticationCard()) + self.contentView.addSubview(view) + view.pinEdges() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +struct ApplicationPasswordAuthenticationCard: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + Image(systemName: "lock.circle.fill") + .foregroundColor(.red) + + Text(Strings.newTitle) + .foregroundColor(.primary) + } + .font(.headline) + + Text(Strings.description) + .font(.callout) + .foregroundColor(.primary) + + Text(Strings.authorize) + .font(.callout.bold()) + .foregroundStyle(Color.accentColor) + } + .padding() + } +} + +private enum Strings { + static let newTitle = NSLocalizedString("application.password.new.title", value: "New: Application Passwords", comment: "Title for the new application passwords feature") + static let description = NSLocalizedString("application.password.description", value: "You can now grant the app permission to use application passwords for quick and secure access to your site content.", comment: "Description for the application passwords feature") + static let authorize = NSLocalizedString("application.password.authorize", value: "Authorize", comment: "Button label to authorize application passwords") +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 85e96caeb018..93c0a121b08f 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -1,5 +1,7 @@ import Foundation import WordPressShared +import WordPressAPI +import WordPressCore // MARK: - BlogDetailsViewController (Misc) @@ -345,6 +347,74 @@ extension BlogDetailsViewController { let controller = SiteMonitoringViewController(blog: blog, selectedTab: selectedTab) presentationDelegate?.presentBlogDetailsViewController(controller) } + + @objc public func checkApplicationPasswordEligibility() { + guard FeatureFlag.authenticateUsingApplicationPassword.enabled else { return } + + // We have already got an application token for this site, no need to ask for another one. + guard (try? blog.getApplicationToken()) == nil, let url = blog.url else { return } + + Task { @MainActor in + // If the auto disocvery process is successful, we consider the site supports application password. + let siteDetails = try await WordPressLoginClient(urlSession: URLSession(configuration: .ephemeral)).details(ofSite: url) + + // Since the site is already added to the app, we need to find the current user/account in the site itself, + // not the one in WP.com. + // + // The "site username" could be + // - When the site is added via the signed in WP.com account, the username of the self-hosted user that + // connected the site to the signed in WP.com account. Or, + // - When the site is added as a self-hosted site, the username of the logged in site: `blog.username`. + let siteUsername: String + if let site = WordPressSite.throughDotCom(blog: blog) { + // For sites that are added to the app via a WP.com account, we can find out the user using the core + // REST API via WP.com: https://public-api.wordpress.com/wp/v2/sites/$site_id/users/me + let client = WordPressClient(site: site) + siteUsername = try await client.api.users.retrieveMeWithEditContext().data.username + } else if let username = blog.username { + // For sites that are added as self-hosted sites, the `username` is what we need. + siteUsername = username + } else { + return + } + + self.applicationPasswordAuthenticationInfo = .init(siteAddress: url, siteDetails: siteDetails, siteUsername: siteUsername) + } + } + + @objc public func applicationPasswordAuthenticationSectionViewModel() -> BlogDetailsSection { + let row = BlogDetailsRow() + row.callback = { [weak self] in + Task { + await self?.startApplicationPasswordAuthenticationFlow() + } + } + return BlogDetailsSection( + title: nil, + rows: [row], + footerTitle: nil, + category: .applicationPasswordAuthentication + ) + } + + @MainActor + private func startApplicationPasswordAuthenticationFlow() async { + guard let info = self.applicationPasswordAuthenticationInfo else { + return + } + + let authenticator = SelfHostedSiteAuthenticator() + do { + let _ = try await authenticator.signIn( + details: info.siteDetails, + from: self, + context: .reauthentication(TaggedManagedObjectID(blog), username: info.siteUsername) + ) + self.applicationPasswordAuthenticationInfo = nil + } catch { + DDLogError("Application password authentication failed: \(error)") + } + } } // MARK: - BlogDetailsViewController (Tracking) @@ -391,3 +461,16 @@ private enum Strings { static let users = NSLocalizedString("mySite.menu.users", value: "Users", comment: "Title for the menu item") static let subscribers = NSLocalizedString("mySite.menu.subscribers", value: "Subscribers", comment: "Title for the menu item") } + +// Necessary data that's required to get an application application from a given site. +@objc public class ApplicationPasswordAuthenticationInfo: NSObject { + public let siteAddress: String + public let siteDetails: AutoDiscoveryAttemptSuccess + public let siteUsername: String + + public init(siteAddress: String, siteDetails: AutoDiscoveryAttemptSuccess, siteUsername: String) { + self.siteAddress = siteAddress + self.siteDetails = siteDetails + self.siteUsername = siteUsername + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h index 8217b864e03c..aaef1239df64 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h @@ -5,6 +5,7 @@ @class CreateButtonCoordinator; @class IntrinsicTableView; @class MeViewController; +@class ApplicationPasswordAuthenticationInfo; @protocol BlogDetailHeader; typedef NS_ENUM(NSUInteger, BlogDetailsSectionCategory) { @@ -23,7 +24,8 @@ typedef NS_ENUM(NSUInteger, BlogDetailsSectionCategory) { BlogDetailsSectionCategorySotW2023Card, BlogDetailsSectionCategoryContent, BlogDetailsSectionCategoryTraffic, - BlogDetailsSectionCategoryMaintenance + BlogDetailsSectionCategoryMaintenance, + BlogDetailsSectionCategoryApplicationPasswordAuthentication }; typedef NS_ENUM(NSUInteger, BlogDetailsSubsection) { @@ -127,6 +129,10 @@ typedef NS_ENUM(NSUInteger, BlogDetailsSubsection) { /// A new display mode for the displaying it as part of the site menu. @property (nonatomic) BOOL isSidebarModeEnabled; +/// When this property value is not nil, the view controller shows an "Application Passwords" to allow users to +/// grant the app an application password. +@property (nonatomic) ApplicationPasswordAuthenticationInfo *applicationPasswordAuthenticationInfo; + @property (nonatomic, weak) UIViewController *presentedSiteSettingsViewController; - (id _Nonnull)init; diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m index be6741b5c272..b72d6de6d315 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m @@ -28,6 +28,7 @@ static NSString *const BlogDetailsJetpackBrandingCardCellIdentifier = @"BlogDetailsJetpackBrandingCardCellIdentifier"; static NSString *const BlogDetailsJetpackInstallCardCellIdentifier = @"BlogDetailsJetpackInstallCardCellIdentifier"; static NSString *const BlogDetailsSotWCardCellIdentifier = @"BlogDetailsSotWCardCellIdentifier"; +static NSString *const BlogDetailsApplicationPasswordAuthenticationCardCellIdentifier = @"BlogDetailsApplicationPasswordAuthenticationCardCellIdentifier"; CGFloat const BlogDetailGridiconSize = 24.0; CGFloat const BlogDetailGridiconAccessorySize = 17.0; @@ -307,6 +308,7 @@ - (void)viewDidLoad [self.tableView registerClass:[JetpackBrandingMenuCardCell class] forCellReuseIdentifier:BlogDetailsJetpackBrandingCardCellIdentifier]; [self.tableView registerClass:[JetpackRemoteInstallTableViewCell class] forCellReuseIdentifier:BlogDetailsJetpackInstallCardCellIdentifier]; [self.tableView registerClass:[SotWTableViewCell class] forCellReuseIdentifier:BlogDetailsSotWCardCellIdentifier]; + [self.tableView registerClass:[ApplicationPasswordAuthenticationCardCell class] forCellReuseIdentifier:BlogDetailsApplicationPasswordAuthenticationCardCellIdentifier]; self.tableView.cellLayoutMarginsFollowReadableWidth = YES; @@ -345,6 +347,7 @@ - (void)viewWillAppear:(BOOL)animated [self reloadTableViewPreservingSelection]; [self preloadBlogData]; + [self checkApplicationPasswordEligibility]; } - (void)viewDidAppear:(BOOL)animated @@ -987,6 +990,10 @@ - (void)configureTableViewData [marr addNullableObject:[self homeSectionViewModel]]; } + if (self.applicationPasswordAuthenticationInfo != nil) { + [marr addNullableObject:[self applicationPasswordAuthenticationSectionViewModel]]; + } + if (ObjCBridge.isWordPress) { if ([self shouldAddJetpackSection]) { [marr addNullableObject:[self jetpackSectionViewModel]]; @@ -1474,6 +1481,15 @@ - (void)showInitialDetailsForBlog } } +- (void)setApplicationPasswordAuthenticationInfo:(ApplicationPasswordAuthenticationInfo *)applicationPasswordAuthenticationInfo { + if (_applicationPasswordAuthenticationInfo != applicationPasswordAuthenticationInfo) { + _applicationPasswordAuthenticationInfo = applicationPasswordAuthenticationInfo; + + [self configureTableViewData]; + [self reloadTableViewPreservingSelection]; + } +} + #pragma mark - Table view data source - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView @@ -1537,6 +1553,10 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N return cell; } + if (section.category == BlogDetailsSectionCategoryApplicationPasswordAuthentication) { + return [tableView dequeueReusableCellWithIdentifier:BlogDetailsApplicationPasswordAuthenticationCardCellIdentifier]; + } + BlogDetailsRow *row = [section.rows objectAtIndex:indexPath.row]; UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.identifier];