Skip to content

Commit 39d1d6e

Browse files
authored
Show "Application Passwords" entry in the my site screen (#24695)
* Show "Application Passwords" entry in the Me screen * Use plain list style on the application passwords list * Add "Created" text to the application password row * Remove timestamp from application password name * Move the 'Application Passwords' row to My Site * Fix a compiling issue on the Reader app * Remove unused code * Fix a copy paste issue
1 parent e84dcd6 commit 39d1d6e

10 files changed

+182
-46
lines changed

WordPress/Classes/ApplicationToken/ApplicationTokenItemView.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ struct ApplicationTokenItemView: View {
1717
detailItem(title: Self.name, value: token.name)
1818
}
1919

20-
Section(Self.security) {
20+
Section {
2121
detailItem(title: Self.creationDate, value: token.createdAt.formatted())
2222

2323
if let lastUsed = token.lastUsed {
@@ -27,6 +27,18 @@ struct ApplicationTokenItemView: View {
2727
if let lastIpAddress = token.lastIpAddress {
2828
detailItem(title: Self.lastUsedIp, value: lastIpAddress)
2929
}
30+
} header: {
31+
Text(Self.security)
32+
} footer: {
33+
if token.isCurrent {
34+
HStack(alignment: .firstTextBaseline) {
35+
Circle()
36+
.fill(Color.accentColor)
37+
.frame(width: 8, height: 8)
38+
39+
Text(Self.currentlyInUse)
40+
}
41+
}
3042
}
3143
}
3244
.navigationTitle(token.name)
@@ -61,7 +73,9 @@ private extension ApplicationTokenItemView {
6173

6274
static var lastUsed: String { NSLocalizedString("applicationPassword.item.lastUsed", value: "Last Used", comment: "Title of row for displaying an application password's last used date") }
6375

64-
static var lastUsedIp: String { NSLocalizedString("applicationPassword.item.lastUsed", value: "Last IP Address", comment: "Title of row for displaying an application password's last used IP address") }
76+
static var lastUsedIp: String { NSLocalizedString("applicationPassword.item.lastUsedIp", value: "Last IP Address", comment: "Title of row for displaying an application password's last used IP address") }
77+
78+
static var currentlyInUse: String { NSLocalizedString("applicationPassword.item.currentlyInUse", value: "This application password is currently being used by the app.", comment: "Footer message indicating that this application password is currently active and being used by the app") }
6579
}
6680

6781
// MARK: - SwiftUI Preview

WordPress/Classes/ApplicationToken/ApplicationTokenListItemView.swift

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,26 @@ public struct ApplicationTokenListItemView: View {
1313
NavigationLink(destination: {
1414
ApplicationTokenItemView(token: item)
1515
}, label: {
16-
VStack(alignment: .leading) {
17-
Text(item.name)
18-
.font(.headline)
19-
.multilineTextAlignment(.leading)
20-
.lineLimit(2)
21-
.truncationMode(.middle)
22-
Text(lastUsedText)
23-
.font(.callout)
24-
.lineLimit(1)
16+
HStack {
17+
VStack(alignment: .leading) {
18+
Text(item.name)
19+
.font(.headline)
20+
.multilineTextAlignment(.leading)
21+
.lineLimit(2)
22+
.truncationMode(.middle)
23+
Text("\(createdDateText)\(lastUsedText)")
24+
.font(.footnote)
25+
.foregroundStyle(.secondary)
26+
.lineLimit(1)
27+
}
28+
29+
Spacer()
30+
31+
if item.isCurrent {
32+
Circle()
33+
.fill(Color.accentColor)
34+
.frame(width: 8, height: 8)
35+
}
2536
}
2637
})
2738
}
@@ -34,9 +45,15 @@ public struct ApplicationTokenListItemView: View {
3445
return String(format: Self.lastUsedFormat, lastUsed.toShortString())
3546
}
3647

48+
private var createdDateText: String {
49+
return String(format: Self.createdDateFormat, item.createdAt.toShortString())
50+
}
51+
3752
private static let unusedText: String = NSLocalizedString("applicationPassword.list.item.unused", value: "Not used yet.", comment: "Last used time of an application password if it's never been used")
3853

3954
private static let lastUsedFormat: String = NSLocalizedString("applicationPassword.list.item.last-used-format", value: "Last used %@", comment: "String format of last used time of an application password. There is one argument: the last used time relative to now (i.e. 5 days ago).")
55+
56+
private static let createdDateFormat: String = NSLocalizedString("applicationPassword.list.item.created-date-format", value: "Created %@", comment: "String format of creation date of an application password. There is one argument: the creation date relative to now (i.e. 5 days ago).")
4057
}
4158

4259
#Preview {

WordPress/Classes/ApplicationToken/ApplicationTokenListView.swift

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ struct ApplicationTokenListView: View {
88
@StateObject
99
private var viewModel: ApplicationTokenListViewModel
1010

11+
@State private var isShowingInfo = false
12+
1113
fileprivate init(tokens: [ApplicationTokenItem]) {
1214
let dataProvider = StaticTokenProvider(tokens: .success(tokens))
1315
self.init(dataProvider: dataProvider)
@@ -23,25 +25,39 @@ struct ApplicationTokenListView: View {
2325
}
2426

2527
var body: some View {
26-
ZStack {
27-
Color(.systemGroupedBackground)
28-
.ignoresSafeArea()
29-
30-
VStack {
31-
if viewModel.isLoadingData {
32-
ProgressView()
33-
} else if let error = viewModel.errorMessage {
34-
EmptyStateView(Self.errorTitle, systemImage: "exclamationmark.triangle", description: error)
35-
} else {
36-
List(viewModel.applicationTokens) { token in
37-
ApplicationTokenListItemView(item: token)
28+
VStack {
29+
if viewModel.isLoadingData {
30+
ProgressView()
31+
} else if let error = viewModel.errorMessage {
32+
EmptyStateView(Self.errorTitle, systemImage: "exclamationmark.triangle", description: error)
33+
} else {
34+
List {
35+
Section {
36+
ForEach(viewModel.applicationTokens) { token in
37+
ApplicationTokenListItemView(item: token)
38+
}
3839
}
39-
.listStyle(.insetGrouped)
40+
.listSectionSeparator(.hidden, edges: .top)
4041
}
42+
.listStyle(.plain)
4143
}
4244
}
4345
.navigationTitle(Self.title)
4446
.navigationBarTitleDisplayMode(.inline)
47+
.toolbar {
48+
ToolbarItem(placement: .navigationBarTrailing) {
49+
Button {
50+
isShowingInfo = true
51+
} label: {
52+
Image(systemName: "info.circle")
53+
}
54+
}
55+
}
56+
.sheet(isPresented: $isShowingInfo) {
57+
NavigationView {
58+
ApplicationPasswordsInfoView()
59+
}
60+
}
4561
.onAppear {
4662
if viewModel.applicationTokens.isEmpty {
4763
Task {
@@ -78,11 +94,14 @@ class ApplicationTokenListViewModel: ObservableObject {
7894
}
7995

8096
do {
81-
let tokens = try await self.dataProvider.loadApplicationTokens()
97+
var tokens = try await self.dataProvider.loadApplicationTokens()
8298
.sorted { lhs, rhs in
8399
// The most recently used/created is placed at the top.
84100
(lhs.lastUsed ?? .distantPast, lhs.createdAt) > (rhs.lastUsed ?? .distantPast, rhs.createdAt)
85101
}
102+
if let current = tokens.firstIndex(where: { $0.isCurrent }) {
103+
tokens.move(fromOffsets: IndexSet(integer: current), toOffset: 0)
104+
}
86105
self.applicationTokens = tokens
87106
} catch {
88107
self.errorMessage = error.localizedDescription

WordPress/Classes/ApplicationToken/Model/ApplicationTokenItem.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ public struct ApplicationTokenItem: Identifiable {
1313
public let lastUsed: Date?
1414
public let lastIpAddress: String?
1515

16+
public var isCurrent: Bool = false
17+
1618
public init(name: String, uuid: UUID, appId: String, createdAt: Date, lastUsed: Date?, lastIpAddress: String?) {
1719
self.name = name
1820
self.uuid = uuid

WordPress/Classes/Login/ApplicationPasswordRequiredView.swift

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,59 @@ import SwiftUI
33
import WordPressData
44
import WordPressCore
55
import WordPressShared
6+
import WordPressUI
67

78
struct ApplicationPasswordRequiredView<Content: View>: View {
89
private let blog: Blog
910
private let localizedFeatureName: String
1011
@State private var site: WordPressSite?
12+
@State private var showLoading: Bool = true
1113
private let builder: (WordPressClient) -> Content
1214

1315
weak var presentingViewController: UIViewController?
1416

1517
init(blog: Blog, localizedFeatureName: String, presentingViewController: UIViewController, @ViewBuilder content: @escaping (WordPressClient) -> Content) {
16-
wpAssert(blog.account == nil, "The Blog argument should be a self-hosted site")
17-
1818
self.blog = blog
1919
self.localizedFeatureName = localizedFeatureName
20-
self.site = try? WordPressSite(blog: blog)
2120
self.presentingViewController = presentingViewController
2221
self.builder = content
2322
}
2423

2524
var body: some View {
26-
if let site {
27-
builder(WordPressClient(site: site))
28-
} else {
29-
RestApiUpgradePrompt(localizedFeatureName: localizedFeatureName) {
30-
Task {
31-
await self.migrate()
25+
VStack {
26+
if blog.isHostedAtWPcom && !blog.isAtomic() {
27+
EmptyStateView(Strings.unsupported, systemImage: "exclamationmark.triangle.fill")
28+
} else if showLoading {
29+
ProgressView()
30+
} else if let site {
31+
builder(WordPressClient(site: site))
32+
} else {
33+
RestApiUpgradePrompt(localizedFeatureName: localizedFeatureName) {
34+
Task {
35+
await self.migrate()
36+
}
3237
}
3338
}
3439
}
40+
.task {
41+
showLoading = true
42+
defer { showLoading = false }
43+
44+
updateSite()
45+
await attemptToCreatePasswordIfNeeded()
46+
}
47+
}
48+
49+
private func attemptToCreatePasswordIfNeeded() async {
50+
guard self.site == nil else { return }
51+
52+
do {
53+
let repository = ApplicationPasswordRepository.shared
54+
try await repository.createPasswordIfNeeded(for: TaggedManagedObjectID(blog))
55+
updateSite()
56+
} catch {
57+
DDLogError("Failed to create an application password: \(error)")
58+
}
3559
}
3660

3761
@MainActor
@@ -46,16 +70,23 @@ struct ApplicationPasswordRequiredView<Content: View>: View {
4670
do {
4771
// Get an application password for the given site.
4872
let authenticator = SelfHostedSiteAuthenticator()
49-
let blogID = try await authenticator.signIn(site: url, from: presenter, context: .reauthentication(TaggedManagedObjectID(blog), username: blog.username))
73+
let _ = try await authenticator.signIn(site: url, from: presenter, context: .reauthentication(TaggedManagedObjectID(blog), username: blog.username))
5074

5175
// Modify the `site` variable to display the intended feature.
52-
let blog = try ContextManager.shared.mainContext.existingObject(with: blogID)
53-
self.site = try .init(blog: blog)
76+
updateSite()
5477
} catch {
5578
Notice(error: error).post()
5679
}
5780
}
5881

82+
private func updateSite() {
83+
// We check that the site is `selfHosted` to ensure an _Application Password_ is available. That's what this view
84+
// is for, after all.
85+
if let site = try? WordPressSite(blog: blog), case .selfHosted = site {
86+
self.site = site
87+
}
88+
}
89+
5990
enum Strings {
6091
static var siteUrlNotFoundError: String {
6192
NSLocalizedString("applicationPasswordMigration.error.siteUrlNotFound", value: "Cannot find the current site's url", comment: "Error message when the current site's url cannot be found")
@@ -65,5 +96,7 @@ struct ApplicationPasswordRequiredView<Content: View>: View {
6596
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")
6697
return String(format: format, expected)
6798
}
99+
100+
static var unsupported: String { NSLocalizedString("applicationPasswordMigration.error.unsupported", value: "This site does not support Application Passwords.", comment: "Error message shown when the site doesn't support Application Passwords feature") }
68101
}
69102
}

WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,7 @@ struct SelfHostedSiteAuthenticator {
3838
}
3939

4040
let deviceName = UIDevice.current.name
41-
let timestamp = ISO8601DateFormatter.string(from: .now, timeZone: .current, formatOptions: .withInternetDateTime)
42-
43-
return "\(appName) iOS app on \(deviceName) (\(timestamp))"
41+
return "\(appName) iOS app on \(deviceName)"
4442
}
4543

4644
static let applicationPasswordUpdated = Foundation.Notification.Name(rawValue: "SelfHostedSiteAuthenticator.applicationPasswordUpdated")

WordPress/Classes/Services/ApplicationPasswordRepository.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,13 @@ private extension ApplicationPasswordRepository {
182182
return try? blog.getApplicationToken()
183183
}
184184

185-
if let saved, !validPasswords.map(\.password).contains(saved), let newPassword = validPasswords.first {
185+
var shouldUpdate = saved == nil
186+
187+
if let saved, !validPasswords.map(\.password).contains(saved) {
188+
shouldUpdate = true
189+
}
190+
191+
if shouldUpdate, let newPassword = validPasswords.first {
186192
try await assign(newPassword, apiRootURL: apiRootURL, to: blogId)
187193
}
188194
}

WordPress/Classes/Services/ApplicationPasswordService.swift

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import WordPressCore
55
@objc class ApplicationPasswordService: NSObject {
66

77
private let apiClient: WordPressClient
8-
private let currentUserId: Int
8+
private var currentUserId: UserId?
9+
private var currentApplicationPasswordUUID: String?
910

10-
init(api: WordPressClient, currentUserId: Int) {
11+
init(api: WordPressClient, currentUserId: Int? = nil) {
1112
self.apiClient = api
12-
self.currentUserId = currentUserId
13+
self.currentUserId = currentUserId.flatMap(UserId.init)
1314
}
1415

1516
private func fetchTokens(forUserId userId: UserId) async throws -> [ApplicationPasswordWithEditContext] {
@@ -19,8 +20,28 @@ import WordPressCore
1920

2021
extension ApplicationPasswordService: ApplicationTokenListDataProvider {
2122
func loadApplicationTokens() async throws -> [ApplicationTokenItem] {
22-
try await fetchTokens(forUserId: UserId(currentUserId))
23-
.compactMap(ApplicationTokenItem.init)
23+
let userId: UserId
24+
if let currentUserId {
25+
userId = currentUserId
26+
} else {
27+
userId = try await apiClient.api.users.retrieveMeWithViewContext().data.id
28+
currentUserId = userId
29+
}
30+
31+
if self.currentApplicationPasswordUUID == nil {
32+
self.currentApplicationPasswordUUID = try? await apiClient.api.applicationPasswords.retrieveCurrentWithViewContext().data.uuid.uuid
33+
}
34+
35+
return try await fetchTokens(forUserId: userId)
36+
.compactMap { token -> ApplicationTokenItem? in
37+
guard var item = ApplicationTokenItem(token) else { return nil }
38+
39+
if let current = self.currentApplicationPasswordUUID {
40+
item.isCurrent = current.compare(item.uuid.uuidString, options: .caseInsensitive) == .orderedSame
41+
}
42+
43+
return item
44+
}
2445
}
2546
}
2647

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import UIKit
3+
import SwiftUI
34
import WordPressData
45
import WordPressShared
56
import WordPressAPI
@@ -344,6 +345,14 @@ extension BlogDetailsViewController {
344345
let controller = SiteMonitoringViewController(blog: blog, selectedTab: selectedTab)
345346
presentationDelegate?.presentBlogDetailsViewController(controller)
346347
}
348+
349+
@objc public func showApplicationPasswords() {
350+
let feature = NSLocalizedString("applicationPasswordRequired.feature.users", value: "Application Passwords Management", comment: "Feature name for managing application passwords in the app")
351+
let view = ApplicationPasswordRequiredView(blog: blog, localizedFeatureName: feature, presentingViewController: self) {
352+
ApplicationTokenListView(dataProvider: ApplicationPasswordService(api: $0))
353+
}
354+
presentationDelegate?.presentBlogDetailsViewController(UIHostingController(rootView: view))
355+
}
347356
}
348357

349358
// MARK: - BlogDetailsViewController (Tracking)

0 commit comments

Comments
 (0)