Skip to content

Commit 2d76a25

Browse files
authored
Self-hosted site user management (#23768)
* Add site users list to self-hosted sites This code is taken from PR #23572, with minimal changes. * Remove static variable `UserObjectResolver` * Add a feature flag for self-hosted site user management * Add a release note
1 parent 67d9464 commit 2d76a25

21 files changed

+1070
-1
lines changed

Modules/Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ let package = Package(
6060
.target(name: "WordPressFlux"),
6161
.target(name: "WordPressSharedObjC", resources: [.process("Resources")]),
6262
.target(name: "WordPressShared", dependencies: [.target(name: "WordPressSharedObjC")], resources: [.process("Resources")]),
63-
.target(name: "WordPressUI", resources: [.process("Resources")]),
63+
.target(name: "WordPressUI", dependencies: [.target(name: "WordPressShared")], resources: [.process("Resources")]),
6464
.testTarget(name: "JetpackStatsWidgetsCoreTests", dependencies: [.target(name: "JetpackStatsWidgetsCore")]),
6565
.testTarget(name: "DesignSystemTests", dependencies: [.target(name: "DesignSystem")]),
6666
.testTarget(name: "WordPressFluxTests", dependencies: ["WordPressFlux"]),

Modules/Sources/WordPressShared/Utility/StringRankedSearch.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,14 @@ extension StringRankedSearch {
120120
.map(\.0)
121121
}
122122
}
123+
124+
/// Objects conforming to `StringRankedSearchable` can be searched by calling `search(query:)` on a collection of them
125+
public protocol StringRankedSearchable {
126+
var searchString: String { get }
127+
}
128+
129+
public extension Collection where Iterator.Element: StringRankedSearchable {
130+
func search(query: String, minScore: Double = 0.7) -> [Iterator.Element] {
131+
StringRankedSearch(searchTerm: query).search(in: self, minScore: minScore) { $0.searchString }
132+
}
133+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import SwiftUI
2+
3+
public extension EdgeInsets {
4+
static let zero = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)
5+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import SwiftUI
2+
3+
struct UserListItem: View {
4+
5+
@ScaledMetric(relativeTo: .headline)
6+
var height: CGFloat = 48
7+
8+
@Environment(\.sizeCategory)
9+
var sizeCategory
10+
11+
private let user: DisplayUser
12+
private let userProvider: UserDataProvider
13+
private let actionDispatcher: UserManagementActionDispatcher
14+
15+
init(user: DisplayUser, userProvider: UserDataProvider, actionDispatcher: UserManagementActionDispatcher) {
16+
self.user = user
17+
self.userProvider = userProvider
18+
self.actionDispatcher = actionDispatcher
19+
}
20+
21+
var body: some View {
22+
NavigationLink {
23+
UserDetailView(user: user, userProvider: userProvider, actionDispatcher: actionDispatcher)
24+
} label: {
25+
HStack(alignment: .top) {
26+
if !sizeCategory.isAccessibilityCategory {
27+
if let profilePhotoUrl = user.profilePhotoUrl {
28+
UserProfileImage(size: CGSize(width: height, height: height), url: profilePhotoUrl)
29+
}
30+
}
31+
VStack(alignment: .leading) {
32+
Text(user.displayName).font(.headline)
33+
Text(user.handle).font(.body).foregroundStyle(.secondary)
34+
}
35+
}
36+
}
37+
}
38+
}
39+
40+
#Preview {
41+
UserListItem(user: DisplayUser.MockUser, userProvider: MockUserProvider(), actionDispatcher: UserManagementActionDispatcher())
42+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import SwiftUI
2+
3+
struct UserProfileImage: View {
4+
5+
private let size: CGSize
6+
7+
private let url: URL
8+
9+
init(size: CGSize, email: String) {
10+
self.size = size
11+
self.url = URL(string: "https://gravatar.com/avatar/58fc51586c9a1f9895ac70e3ca60886e?size=256")!
12+
}
13+
14+
init(size: CGSize, url: URL) {
15+
self.size = size
16+
self.url = url
17+
}
18+
19+
var body: some View {
20+
AsyncImage(
21+
url: self.url,
22+
content: { image in
23+
image.resizable()
24+
.frame(width: size.height, height: size.height)
25+
.aspectRatio(contentMode: .fit)
26+
.clipShape(.rect(cornerRadius: 4.0))
27+
},
28+
placeholder: {
29+
ProgressView().frame(width: size.height, height: size.height)
30+
}
31+
)
32+
}
33+
}
34+
35+
#Preview {
36+
UserProfileImage(size: CGSize(width: 64, height: 64), email: "[email protected]")
37+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Foundation
2+
3+
public protocol UserDataProvider {
4+
5+
typealias CachedUserListCallback = ([WordPressUI.DisplayUser]) async -> Void
6+
7+
func fetchCurrentUserCan(_ capability: String) async throws -> Bool
8+
func fetchUsers(cachedResults: CachedUserListCallback?) async throws -> [WordPressUI.DisplayUser]
9+
10+
func invalidateCaches() async throws
11+
}
12+
13+
/// Subclass this and register it with the SwiftUI `.environmentObject` method
14+
/// to perform user management actions.
15+
///
16+
/// The default implementation is set up for testing with SwiftUI Previews
17+
open class UserManagementActionDispatcher: ObservableObject {
18+
public init() {}
19+
20+
open func setNewPassword(id: Int32, newPassword: String) async throws {
21+
try await Task.sleep(for: .seconds(2))
22+
}
23+
24+
open func deleteUser(id: Int32, reassigningPostsTo userId: Int32) async throws {
25+
try await Task.sleep(for: .seconds(2))
26+
}
27+
}
28+
29+
package struct MockUserProvider: UserDataProvider {
30+
31+
let dummyDataUrl = URL(string: "https://my.api.mockaroo.com/users.json?key=067c9730")!
32+
33+
package func fetchUsers(cachedResults: CachedUserListCallback? = nil) async throws -> [WordPressUI.DisplayUser] {
34+
let response = try await URLSession.shared.data(from: dummyDataUrl)
35+
return try JSONDecoder().decode([DisplayUser].self, from: response.0)
36+
}
37+
38+
package func fetchCurrentUserCan(_ capability: String) async throws -> Bool {
39+
true
40+
}
41+
42+
package func invalidateCaches() async throws {
43+
// Do nothing
44+
}
45+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import Foundation
2+
import WordPressShared
3+
4+
public struct DisplayUser: Identifiable, Codable {
5+
public let id: Int32
6+
public let handle: String
7+
public let username: String
8+
public let firstName: String
9+
public let lastName: String
10+
public let displayName: String
11+
public let profilePhotoUrl: URL?
12+
public let role: String
13+
14+
public let emailAddress: String
15+
public let websiteUrl: String?
16+
17+
public let biography: String?
18+
19+
public init(
20+
id: Int32,
21+
handle: String,
22+
username: String,
23+
firstName: String,
24+
lastName: String,
25+
displayName: String,
26+
profilePhotoUrl: URL?,
27+
role: String,
28+
emailAddress: String,
29+
websiteUrl: String?,
30+
biography: String?
31+
) {
32+
self.id = id
33+
self.handle = handle
34+
self.username = username
35+
self.firstName = firstName
36+
self.lastName = lastName
37+
self.displayName = displayName
38+
self.profilePhotoUrl = profilePhotoUrl
39+
self.role = role
40+
self.emailAddress = emailAddress
41+
self.websiteUrl = websiteUrl
42+
self.biography = biography
43+
}
44+
45+
static package let MockUser = DisplayUser(
46+
id: 16,
47+
handle: "@person",
48+
username: "example",
49+
firstName: "John",
50+
lastName: "Smith",
51+
displayName: "John Smith",
52+
profilePhotoUrl: URL(string: "https://gravatar.com/avatar/58fc51586c9a1f9895ac70e3ca60886e?size=256"),
53+
role: "administrator",
54+
emailAddress: "[email protected]",
55+
websiteUrl: "",
56+
biography: ""
57+
)
58+
}
59+
60+
extension DisplayUser: StringRankedSearchable {
61+
public var searchString: String {
62+
63+
// These are in ranked order – the higher something is in the list, the more heavily it's weighted
64+
[
65+
handle,
66+
username,
67+
firstName,
68+
lastName,
69+
emailAddress,
70+
displayName,
71+
emailAddress,
72+
]
73+
.compactMap { $0 }
74+
.joined(separator: " ")
75+
}
76+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import SwiftUI
2+
3+
public class UserChangePasswordViewModel: ObservableObject {
4+
5+
public enum Errors: LocalizedError {
6+
case passwordMustNotBeEmpty
7+
8+
public var errorDescription: String? {
9+
switch self {
10+
case .passwordMustNotBeEmpty: NSLocalizedString(
11+
"userchangepassword.error.empty",
12+
value: "Password must not be empty",
13+
comment: "An error message that appears when an empty password has been entered"
14+
)
15+
}
16+
}
17+
}
18+
19+
@Published
20+
var password: String = ""
21+
22+
@Published
23+
var isChangingPassword: Bool = false
24+
25+
@Published
26+
var error: Error? = nil
27+
28+
@Environment(\.dismiss)
29+
var dismissAction
30+
31+
private let actionDispatcher: UserManagementActionDispatcher
32+
let user: DisplayUser
33+
34+
init(user: DisplayUser, actionDispatcher: UserManagementActionDispatcher) {
35+
self.user = user
36+
self.actionDispatcher = actionDispatcher
37+
}
38+
39+
func didTapChangePassword(callback: @escaping () -> Void) {
40+
41+
if password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
42+
withAnimation {
43+
error = Errors.passwordMustNotBeEmpty
44+
}
45+
return
46+
}
47+
48+
withAnimation {
49+
error = nil
50+
}
51+
52+
Task {
53+
await MainActor.run {
54+
withAnimation {
55+
self.isChangingPassword = true
56+
}
57+
}
58+
59+
do {
60+
try await actionDispatcher.setNewPassword(id: user.id, newPassword: password)
61+
} catch {
62+
self.error = error
63+
}
64+
65+
await MainActor.run {
66+
withAnimation {
67+
self.isChangingPassword = false
68+
}
69+
}
70+
71+
await MainActor.run {
72+
callback()
73+
}
74+
}
75+
}
76+
}

0 commit comments

Comments
 (0)