From 128381a4391a48b546e7c32e70649d2291923358 Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 15 Nov 2024 15:14:59 -0700 Subject: [PATCH] [iOS] Admin Dashboard - User Passwords (#1312) * resetUserPassword Adjustments * Nest the Password in Advanced because I dunno it looks nicer. * Dismiss Coordinator instead of pop. * Build issues * Rename my local xcode to xcode_16??? * Build plz * Comments * clean up --------- Co-authored-by: Ethan Pippin --- .../AdminDashboardCoordinator.swift | 11 ++- Shared/Coordinators/SettingsCoordinator.swift | 9 +- Shared/Strings/Strings.swift | 14 ++- .../ResetUserPasswordViewModel.swift | 17 +++- Swiftfin.xcodeproj/project.pbxproj | 10 +- .../ServerUserDetailsView.swift | 9 ++ .../ResetUserPasswordView.swift | 91 +++++++++++++------ .../UserProfileSettingsView.swift | 2 +- Translations/en.lproj/Localizable.strings | 36 +++++++- 9 files changed, 153 insertions(+), 46 deletions(-) rename Swiftfin/Views/{SettingsView/UserProfileSettingsView => ResetUserPasswordView}/ResetUserPasswordView.swift (58%) diff --git a/Shared/Coordinators/AdminDashboardCoordinator.swift b/Shared/Coordinators/AdminDashboardCoordinator.swift index 1ae295be..b70f4092 100644 --- a/Shared/Coordinators/AdminDashboardCoordinator.swift +++ b/Shared/Coordinators/AdminDashboardCoordinator.swift @@ -37,8 +37,8 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { var users = makeUsers @Route(.push) var userDetails = makeUserDetails - @Route(.push) - var userDevices = makeUserDevices + @Route(.modal) + var resetUserPassword = makeResetUserPassword @Route(.modal) var addServerUser = makeAddServerUser @Route(.push) @@ -106,9 +106,10 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { } } - @ViewBuilder - func makeUserDevices() -> some View { - DevicesView() + func makeResetUserPassword(userID: String) -> NavigationViewCoordinator { + NavigationViewCoordinator { + ResetUserPasswordView(userID: userID, requiresCurrentPassword: false) + } } @ViewBuilder diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index a0991618..da42ffc7 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -26,7 +26,7 @@ final class SettingsCoordinator: NavigationCoordinatable { var playbackQualitySettings = makePlaybackQualitySettings @Route(.push) var quickConnect = makeQuickConnectAuthorize - @Route(.push) + @Route(.modal) var resetUserPassword = makeResetUserPassword @Route(.push) var localSecurity = makeLocalSecurity @@ -112,9 +112,10 @@ final class SettingsCoordinator: NavigationCoordinatable { QuickConnectAuthorizeView() } - @ViewBuilder - func makeResetUserPassword() -> some View { - ResetUserPasswordView() + func makeResetUserPassword(userID: String) -> NavigationViewCoordinator { + NavigationViewCoordinator { + ResetUserPasswordView(userID: userID, requiresCurrentPassword: true) + } } @ViewBuilder diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 4271a54b..0725790e 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -220,6 +220,8 @@ internal enum L10n { internal static let confirm = L10n.tr("Localizable", "confirm", fallback: "Confirm") /// Confirm Close internal static let confirmClose = L10n.tr("Localizable", "confirmClose", fallback: "Confirm Close") + /// Confirm New Password + internal static let confirmNewPassword = L10n.tr("Localizable", "confirmNewPassword", fallback: "Confirm New Password") /// Confirm Password internal static let confirmPassword = L10n.tr("Localizable", "confirmPassword", fallback: "Confirm Password") /// Connect @@ -250,6 +252,8 @@ internal enum L10n { internal static let createAPIKeyMessage = L10n.tr("Localizable", "createAPIKeyMessage", fallback: "Enter the application name for the new API key.") /// Current internal static let current = L10n.tr("Localizable", "current", fallback: "Current") + /// Current Password + internal static let currentPassword = L10n.tr("Localizable", "currentPassword", fallback: "Current Password") /// Current Position internal static let currentPosition = L10n.tr("Localizable", "currentPosition", fallback: "Current Position") /// PlaybackCompatibility Custom Category @@ -550,6 +554,8 @@ internal enum L10n { internal static let never = L10n.tr("Localizable", "never", fallback: "Never") /// Message shown when a task has never run internal static let neverRun = L10n.tr("Localizable", "neverRun", fallback: "Never run") + /// New Password + internal static let newPassword = L10n.tr("Localizable", "newPassword", fallback: "New Password") /// News internal static let news = L10n.tr("Localizable", "news", fallback: "News") /// New User @@ -636,8 +642,12 @@ internal enum L10n { } /// Password internal static let password = L10n.tr("Localizable", "password", fallback: "Password") - /// New passwords do not match - internal static let passwordsDoNotMatch = L10n.tr("Localizable", "passwordsDoNotMatch", fallback: "New passwords do not match") + /// User password has been changed. + internal static let passwordChangedMessage = L10n.tr("Localizable", "passwordChangedMessage", fallback: "User password has been changed.") + /// Changes the Jellyfin server user password. This does not change any Swiftfin settings. + internal static let passwordChangeWarning = L10n.tr("Localizable", "passwordChangeWarning", fallback: "Changes the Jellyfin server user password. This does not change any Swiftfin settings.") + /// New passwords do not match. + internal static let passwordsDoNotMatch = L10n.tr("Localizable", "passwordsDoNotMatch", fallback: "New passwords do not match.") /// Video Player Settings View - Pause on Background internal static let pauseOnBackground = L10n.tr("Localizable", "pauseOnBackground", fallback: "Pause on background") /// People diff --git a/Shared/ViewModels/ResetUserPasswordViewModel.swift b/Shared/ViewModels/ResetUserPasswordViewModel.swift index 0d00bf24..dd028605 100644 --- a/Shared/ViewModels/ResetUserPasswordViewModel.swift +++ b/Shared/ViewModels/ResetUserPasswordViewModel.swift @@ -12,29 +12,32 @@ import JellyfinAPI final class ResetUserPasswordViewModel: ViewModel, Eventful, Stateful { - // MARK: Event + // MARK: - Event enum Event { case error(JellyfinAPIError) case success } - // MARK: Action + // MARK: - Action enum Action: Equatable { case cancel case reset(current: String, new: String) } - // MARK: State + // MARK: - State enum State: Hashable { case initial case resetting } + // MARK: - Published Variables + @Published var state: State = .initial + let userID: String var events: AnyPublisher { eventSubject @@ -45,6 +48,12 @@ final class ResetUserPasswordViewModel: ViewModel, Eventful, Stateful { private var resetTask: AnyCancellable? private var eventSubject: PassthroughSubject = .init() + // MARK: - Initializer + + init(userID: String) { + self.userID = userID + } + func respond(to action: Action) -> State { switch action { case .cancel: @@ -79,7 +88,7 @@ final class ResetUserPasswordViewModel: ViewModel, Eventful, Stateful { private func reset(current: String, new: String) async throws { let body = UpdateUserPassword(currentPw: current, newPw: new) - let request = Paths.updateUserPassword(userID: userSession.user.id, body) + let request = Paths.updateUserPassword(userID: userID, body) try await userSession.client.send(request) } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 1b06d6c6..0ba90610 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -2273,6 +2273,14 @@ path = DevicesView; sourceTree = ""; }; + 4EF10D4C2CE2EC5A000ED5F5 /* ResetUserPasswordView */ = { + isa = PBXGroup; + children = ( + E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */, + ); + path = ResetUserPasswordView; + sourceTree = ""; + }; 4EF18B232CB9932F00343666 /* PagingLibraryView */ = { isa = PBXGroup; children = ( @@ -3295,6 +3303,7 @@ E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */, E10231342BCF8A3C009D71FC /* ProgramsView */, E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */, + 4EF10D4C2CE2EC5A000ED5F5 /* ResetUserPasswordView */, 53EE24E5265060780068F029 /* SearchView.swift */, E10B1EAF2BD9769500A92EAF /* SelectUserView */, E19D41AB2BF288110082B8B2 /* ServerCheckView.swift */, @@ -3387,7 +3396,6 @@ isa = PBXGroup; children = ( 6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */, - E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */, E1EA09872BEE9CF3004CDE76 /* UserLocalSecurityView.swift */, E14EA1612BF6FF8D00DE757A /* UserProfileImagePicker */, E1BE1CEF2BDB6C97008176A9 /* UserProfileSettingsView.swift */, diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift index 0d063d99..4a9367c1 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -35,6 +35,15 @@ struct ServerUserDetailsView: View { user: viewModel.user, lastActivityDate: viewModel.user.lastActivityDate ) + + Section(L10n.advanced) { + if let userId = viewModel.user.id { + ChevronButton(L10n.password) + .onSelect { + router.route(to: \.resetUserPassword, userId) + } + } + } } .navigationTitle(L10n.user) .onAppear { diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift b/Swiftfin/Views/ResetUserPasswordView/ResetUserPasswordView.swift similarity index 58% rename from Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift rename to Swiftfin/Views/ResetUserPasswordView/ResetUserPasswordView.swift index fb89622e..126ae8e4 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift +++ b/Swiftfin/Views/ResetUserPasswordView/ResetUserPasswordView.swift @@ -13,14 +13,25 @@ import SwiftUI struct ResetUserPasswordView: View { + private enum Field: Hashable { + case currentPassword + case newPassword + case confirmNewPassword + } + @Default(.accentColor) private var accentColor @EnvironmentObject - private var router: SettingsCoordinator.Router + private var router: BasicNavigationViewCoordinator.Router @FocusState - private var focusedPassword: Int? + private var focusedField: Field? + + @StateObject + private var viewModel: ResetUserPasswordViewModel + + // MARK: - Password Variables @State private var currentPassword: String = "" @@ -29,6 +40,8 @@ struct ResetUserPasswordView: View { @State private var confirmNewPassword: String = "" + // MARK: - State Variables + @State private var error: Error? = nil @State @@ -36,45 +49,54 @@ struct ResetUserPasswordView: View { @State private var isPresentingSuccess: Bool = false - @StateObject - private var viewModel = ResetUserPasswordViewModel() + private let requiresCurrentPassword: Bool + + // MARK: - Initializer + + init(userID: String, requiresCurrentPassword: Bool) { + self._viewModel = StateObject(wrappedValue: ResetUserPasswordViewModel(userID: userID)) + self.requiresCurrentPassword = requiresCurrentPassword + } + + // MARK: - Body var body: some View { List { - - Section("Current Password") { - UnmaskSecureField("Current Password", text: $currentPassword) { - focusedPassword = 1 + if requiresCurrentPassword { + Section(L10n.currentPassword) { + UnmaskSecureField(L10n.currentPassword, text: $currentPassword) { + focusedField = .newPassword + } + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($focusedField, equals: .currentPassword) + .disabled(viewModel.state == .resetting) } - .autocorrectionDisabled() - .textInputAutocapitalization(.none) - .focused($focusedPassword, equals: 0) - .disabled(viewModel.state == .resetting) } - Section("New Password") { - UnmaskSecureField("New Password", text: $newPassword) { - focusedPassword = 2 + Section(L10n.newPassword) { + UnmaskSecureField(L10n.newPassword, text: $newPassword) { + focusedField = .confirmNewPassword } .autocorrectionDisabled() .textInputAutocapitalization(.none) - .focused($focusedPassword, equals: 1) + .focused($focusedField, equals: .newPassword) .disabled(viewModel.state == .resetting) } Section { - UnmaskSecureField("Confirm New Password", text: $confirmNewPassword) { + UnmaskSecureField(L10n.confirmNewPassword, text: $confirmNewPassword) { viewModel.send(.reset(current: currentPassword, new: confirmNewPassword)) } .autocorrectionDisabled() .textInputAutocapitalization(.none) - .focused($focusedPassword, equals: 2) + .focused($focusedField, equals: .confirmNewPassword) .disabled(viewModel.state == .resetting) } header: { - Text("Confirm New Password") + Text(L10n.confirmNewPassword) } footer: { if newPassword != confirmNewPassword { - Label("New passwords to not match", systemImage: "exclamationmark.circle.fill") + Label(L10n.passwordsDoNotMatch, systemImage: "exclamationmark.circle.fill") .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) } } @@ -83,12 +105,17 @@ struct ResetUserPasswordView: View { if viewModel.state == .resetting { ListRowButton(L10n.cancel) { viewModel.send(.cancel) - focusedPassword = 0 + + if requiresCurrentPassword { + focusedField = .currentPassword + } else { + focusedField = .newPassword + } } .foregroundStyle(.red, .red.opacity(0.2)) } else { ListRowButton(L10n.save) { - focusedPassword = nil + focusedField = nil viewModel.send(.reset(current: currentPassword, new: confirmNewPassword)) } .disabled(newPassword != confirmNewPassword || viewModel.state == .resetting) @@ -96,14 +123,22 @@ struct ResetUserPasswordView: View { .opacity(newPassword != confirmNewPassword ? 0.5 : 1) } } footer: { - Text("Changes the Jellyfin server user password. This does not change any Swiftfin settings.") + Text(L10n.passwordChangeWarning) } } .interactiveDismissDisabled(viewModel.state == .resetting) .navigationBarBackButtonHidden(viewModel.state == .resetting) .navigationTitle(L10n.password) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismissCoordinator() + } .onFirstAppear { - focusedPassword = 0 + if requiresCurrentPassword { + focusedField = .currentPassword + } else { + focusedField = .newPassword + } } .onReceive(viewModel.events) { event in switch event { @@ -124,12 +159,12 @@ struct ResetUserPasswordView: View { } } .alert( - L10n.error.text, + L10n.error, isPresented: $isPresentingError, presenting: error ) { _ in Button(L10n.dismiss, role: .cancel) { - focusedPassword = 1 + focusedField = .newPassword } } message: { error in Text(error.localizedDescription) @@ -139,10 +174,10 @@ struct ResetUserPasswordView: View { isPresented: $isPresentingSuccess ) { Button(L10n.dismiss, role: .cancel) { - router.pop() + router.dismissCoordinator() } } message: { - Text("User password has been changed.") + Text(L10n.passwordChangedMessage) } } } diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift index 4d48cb5c..4e4ff71e 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift @@ -91,7 +91,7 @@ struct UserProfileSettingsView: View { ChevronButton("Password") .onSelect { - router.route(to: \.resetUserPassword) + router.route(to: \.resetUserPassword, viewModel.userSession.user.id) } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index d129671d..b7873745 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -37,7 +37,6 @@ "ok" = "Ok"; "otherUser" = "Other User"; "pageOfWithNumbers" = "Page %1$@ of %2$@"; -"password" = "Password"; "playNext" = "Play Next"; "play" = "Play"; "playback" = "Playback"; @@ -1213,3 +1212,38 @@ // Button title to edit existing users // Used as the button label in the options menu when there are users to edit "editUsers" = "Edit Users"; + +/// Current Password - Placeholder +/// Placeholder text for the current password input field +/// Used in the ResetUserPasswordView +"currentPassword" = "Current Password"; + +/// New Password - Placeholder +/// Placeholder text for the new password input field +/// Used in the ResetUserPasswordView +"newPassword" = "New Password"; + +/// Confirm New Password - Placeholder +/// Placeholder text for confirming the new password input field +/// Used in the ResetUserPasswordView +"confirmNewPassword" = "Confirm New Password"; + +/// Password - Navigation Title +/// Title for the password reset view +/// Used in the navigation bar +"password" = "Password"; + +/// Password Changed - Alert Message +/// Message displayed in the success alert after changing the password +/// Used in the ResetUserPasswordView +"passwordChangedMessage" = "User password has been changed."; + +/// Passwords Do Not Match - Footer +/// Error message displayed when new passwords do not match +/// Used in the ResetUserPasswordView +"passwordsDoNotMatch" = "New passwords do not match."; + +/// Password Change Warning - Message +/// Message displayed to alert the user what the password change does and does not do +/// Used in the ResetUserPasswordView +"passwordChangeWarning" = "Changes the Jellyfin server user password. This does not change any Swiftfin settings.";