[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 <ethanpippin2343@gmail.com>
This commit is contained in:
Joe 2024-11-15 15:14:59 -07:00 committed by GitHub
parent 4dc8a31d6d
commit 128381a439
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 153 additions and 46 deletions

View File

@ -37,8 +37,8 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
var users = makeUsers var users = makeUsers
@Route(.push) @Route(.push)
var userDetails = makeUserDetails var userDetails = makeUserDetails
@Route(.push) @Route(.modal)
var userDevices = makeUserDevices var resetUserPassword = makeResetUserPassword
@Route(.modal) @Route(.modal)
var addServerUser = makeAddServerUser var addServerUser = makeAddServerUser
@Route(.push) @Route(.push)
@ -106,9 +106,10 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
} }
} }
@ViewBuilder func makeResetUserPassword(userID: String) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
func makeUserDevices() -> some View { NavigationViewCoordinator {
DevicesView() ResetUserPasswordView(userID: userID, requiresCurrentPassword: false)
}
} }
@ViewBuilder @ViewBuilder

View File

@ -26,7 +26,7 @@ final class SettingsCoordinator: NavigationCoordinatable {
var playbackQualitySettings = makePlaybackQualitySettings var playbackQualitySettings = makePlaybackQualitySettings
@Route(.push) @Route(.push)
var quickConnect = makeQuickConnectAuthorize var quickConnect = makeQuickConnectAuthorize
@Route(.push) @Route(.modal)
var resetUserPassword = makeResetUserPassword var resetUserPassword = makeResetUserPassword
@Route(.push) @Route(.push)
var localSecurity = makeLocalSecurity var localSecurity = makeLocalSecurity
@ -112,9 +112,10 @@ final class SettingsCoordinator: NavigationCoordinatable {
QuickConnectAuthorizeView() QuickConnectAuthorizeView()
} }
@ViewBuilder func makeResetUserPassword(userID: String) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
func makeResetUserPassword() -> some View { NavigationViewCoordinator {
ResetUserPasswordView() ResetUserPasswordView(userID: userID, requiresCurrentPassword: true)
}
} }
@ViewBuilder @ViewBuilder

View File

@ -220,6 +220,8 @@ internal enum L10n {
internal static let confirm = L10n.tr("Localizable", "confirm", fallback: "Confirm") internal static let confirm = L10n.tr("Localizable", "confirm", fallback: "Confirm")
/// Confirm Close /// Confirm Close
internal static let confirmClose = L10n.tr("Localizable", "confirmClose", fallback: "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 /// Confirm Password
internal static let confirmPassword = L10n.tr("Localizable", "confirmPassword", fallback: "Confirm Password") internal static let confirmPassword = L10n.tr("Localizable", "confirmPassword", fallback: "Confirm Password")
/// Connect /// 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.") internal static let createAPIKeyMessage = L10n.tr("Localizable", "createAPIKeyMessage", fallback: "Enter the application name for the new API key.")
/// Current /// Current
internal static let current = L10n.tr("Localizable", "current", fallback: "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 /// Current Position
internal static let currentPosition = L10n.tr("Localizable", "currentPosition", fallback: "Current Position") internal static let currentPosition = L10n.tr("Localizable", "currentPosition", fallback: "Current Position")
/// PlaybackCompatibility Custom Category /// PlaybackCompatibility Custom Category
@ -550,6 +554,8 @@ internal enum L10n {
internal static let never = L10n.tr("Localizable", "never", fallback: "Never") internal static let never = L10n.tr("Localizable", "never", fallback: "Never")
/// Message shown when a task has never run /// Message shown when a task has never run
internal static let neverRun = L10n.tr("Localizable", "neverRun", fallback: "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 /// News
internal static let news = L10n.tr("Localizable", "news", fallback: "News") internal static let news = L10n.tr("Localizable", "news", fallback: "News")
/// New User /// New User
@ -636,8 +642,12 @@ internal enum L10n {
} }
/// Password /// Password
internal static let password = L10n.tr("Localizable", "password", fallback: "Password") internal static let password = L10n.tr("Localizable", "password", fallback: "Password")
/// New passwords do not match /// User password has been changed.
internal static let passwordsDoNotMatch = L10n.tr("Localizable", "passwordsDoNotMatch", fallback: "New passwords do not match") 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 /// Video Player Settings View - Pause on Background
internal static let pauseOnBackground = L10n.tr("Localizable", "pauseOnBackground", fallback: "Pause on background") internal static let pauseOnBackground = L10n.tr("Localizable", "pauseOnBackground", fallback: "Pause on background")
/// People /// People

View File

@ -12,29 +12,32 @@ import JellyfinAPI
final class ResetUserPasswordViewModel: ViewModel, Eventful, Stateful { final class ResetUserPasswordViewModel: ViewModel, Eventful, Stateful {
// MARK: Event // MARK: - Event
enum Event { enum Event {
case error(JellyfinAPIError) case error(JellyfinAPIError)
case success case success
} }
// MARK: Action // MARK: - Action
enum Action: Equatable { enum Action: Equatable {
case cancel case cancel
case reset(current: String, new: String) case reset(current: String, new: String)
} }
// MARK: State // MARK: - State
enum State: Hashable { enum State: Hashable {
case initial case initial
case resetting case resetting
} }
// MARK: - Published Variables
@Published @Published
var state: State = .initial var state: State = .initial
let userID: String
var events: AnyPublisher<Event, Never> { var events: AnyPublisher<Event, Never> {
eventSubject eventSubject
@ -45,6 +48,12 @@ final class ResetUserPasswordViewModel: ViewModel, Eventful, Stateful {
private var resetTask: AnyCancellable? private var resetTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .init() private var eventSubject: PassthroughSubject<Event, Never> = .init()
// MARK: - Initializer
init(userID: String) {
self.userID = userID
}
func respond(to action: Action) -> State { func respond(to action: Action) -> State {
switch action { switch action {
case .cancel: case .cancel:
@ -79,7 +88,7 @@ final class ResetUserPasswordViewModel: ViewModel, Eventful, Stateful {
private func reset(current: String, new: String) async throws { private func reset(current: String, new: String) async throws {
let body = UpdateUserPassword(currentPw: current, newPw: new) 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) try await userSession.client.send(request)
} }

View File

@ -2273,6 +2273,14 @@
path = DevicesView; path = DevicesView;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
4EF10D4C2CE2EC5A000ED5F5 /* ResetUserPasswordView */ = {
isa = PBXGroup;
children = (
E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */,
);
path = ResetUserPasswordView;
sourceTree = "<group>";
};
4EF18B232CB9932F00343666 /* PagingLibraryView */ = { 4EF18B232CB9932F00343666 /* PagingLibraryView */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -3295,6 +3303,7 @@
E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */, E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */,
E10231342BCF8A3C009D71FC /* ProgramsView */, E10231342BCF8A3C009D71FC /* ProgramsView */,
E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */, E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */,
4EF10D4C2CE2EC5A000ED5F5 /* ResetUserPasswordView */,
53EE24E5265060780068F029 /* SearchView.swift */, 53EE24E5265060780068F029 /* SearchView.swift */,
E10B1EAF2BD9769500A92EAF /* SelectUserView */, E10B1EAF2BD9769500A92EAF /* SelectUserView */,
E19D41AB2BF288110082B8B2 /* ServerCheckView.swift */, E19D41AB2BF288110082B8B2 /* ServerCheckView.swift */,
@ -3387,7 +3396,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */, 6334175A287DDFB9000603CE /* QuickConnectAuthorizeView.swift */,
E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */,
E1EA09872BEE9CF3004CDE76 /* UserLocalSecurityView.swift */, E1EA09872BEE9CF3004CDE76 /* UserLocalSecurityView.swift */,
E14EA1612BF6FF8D00DE757A /* UserProfileImagePicker */, E14EA1612BF6FF8D00DE757A /* UserProfileImagePicker */,
E1BE1CEF2BDB6C97008176A9 /* UserProfileSettingsView.swift */, E1BE1CEF2BDB6C97008176A9 /* UserProfileSettingsView.swift */,

View File

@ -35,6 +35,15 @@ struct ServerUserDetailsView: View {
user: viewModel.user, user: viewModel.user,
lastActivityDate: viewModel.user.lastActivityDate 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) .navigationTitle(L10n.user)
.onAppear { .onAppear {

View File

@ -13,14 +13,25 @@ import SwiftUI
struct ResetUserPasswordView: View { struct ResetUserPasswordView: View {
private enum Field: Hashable {
case currentPassword
case newPassword
case confirmNewPassword
}
@Default(.accentColor) @Default(.accentColor)
private var accentColor private var accentColor
@EnvironmentObject @EnvironmentObject
private var router: SettingsCoordinator.Router private var router: BasicNavigationViewCoordinator.Router
@FocusState @FocusState
private var focusedPassword: Int? private var focusedField: Field?
@StateObject
private var viewModel: ResetUserPasswordViewModel
// MARK: - Password Variables
@State @State
private var currentPassword: String = "" private var currentPassword: String = ""
@ -29,6 +40,8 @@ struct ResetUserPasswordView: View {
@State @State
private var confirmNewPassword: String = "" private var confirmNewPassword: String = ""
// MARK: - State Variables
@State @State
private var error: Error? = nil private var error: Error? = nil
@State @State
@ -36,45 +49,54 @@ struct ResetUserPasswordView: View {
@State @State
private var isPresentingSuccess: Bool = false private var isPresentingSuccess: Bool = false
@StateObject private let requiresCurrentPassword: Bool
private var viewModel = ResetUserPasswordViewModel()
// MARK: - Initializer
init(userID: String, requiresCurrentPassword: Bool) {
self._viewModel = StateObject(wrappedValue: ResetUserPasswordViewModel(userID: userID))
self.requiresCurrentPassword = requiresCurrentPassword
}
// MARK: - Body
var body: some View { var body: some View {
List { List {
if requiresCurrentPassword {
Section("Current Password") { Section(L10n.currentPassword) {
UnmaskSecureField("Current Password", text: $currentPassword) { UnmaskSecureField(L10n.currentPassword, text: $currentPassword) {
focusedPassword = 1 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") { Section(L10n.newPassword) {
UnmaskSecureField("New Password", text: $newPassword) { UnmaskSecureField(L10n.newPassword, text: $newPassword) {
focusedPassword = 2 focusedField = .confirmNewPassword
} }
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.none) .textInputAutocapitalization(.none)
.focused($focusedPassword, equals: 1) .focused($focusedField, equals: .newPassword)
.disabled(viewModel.state == .resetting) .disabled(viewModel.state == .resetting)
} }
Section { Section {
UnmaskSecureField("Confirm New Password", text: $confirmNewPassword) { UnmaskSecureField(L10n.confirmNewPassword, text: $confirmNewPassword) {
viewModel.send(.reset(current: currentPassword, new: confirmNewPassword)) viewModel.send(.reset(current: currentPassword, new: confirmNewPassword))
} }
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.none) .textInputAutocapitalization(.none)
.focused($focusedPassword, equals: 2) .focused($focusedField, equals: .confirmNewPassword)
.disabled(viewModel.state == .resetting) .disabled(viewModel.state == .resetting)
} header: { } header: {
Text("Confirm New Password") Text(L10n.confirmNewPassword)
} footer: { } footer: {
if newPassword != confirmNewPassword { if newPassword != confirmNewPassword {
Label("New passwords to not match", systemImage: "exclamationmark.circle.fill") Label(L10n.passwordsDoNotMatch, systemImage: "exclamationmark.circle.fill")
.labelStyle(.sectionFooterWithImage(imageStyle: .orange)) .labelStyle(.sectionFooterWithImage(imageStyle: .orange))
} }
} }
@ -83,12 +105,17 @@ struct ResetUserPasswordView: View {
if viewModel.state == .resetting { if viewModel.state == .resetting {
ListRowButton(L10n.cancel) { ListRowButton(L10n.cancel) {
viewModel.send(.cancel) viewModel.send(.cancel)
focusedPassword = 0
if requiresCurrentPassword {
focusedField = .currentPassword
} else {
focusedField = .newPassword
}
} }
.foregroundStyle(.red, .red.opacity(0.2)) .foregroundStyle(.red, .red.opacity(0.2))
} else { } else {
ListRowButton(L10n.save) { ListRowButton(L10n.save) {
focusedPassword = nil focusedField = nil
viewModel.send(.reset(current: currentPassword, new: confirmNewPassword)) viewModel.send(.reset(current: currentPassword, new: confirmNewPassword))
} }
.disabled(newPassword != confirmNewPassword || viewModel.state == .resetting) .disabled(newPassword != confirmNewPassword || viewModel.state == .resetting)
@ -96,14 +123,22 @@ struct ResetUserPasswordView: View {
.opacity(newPassword != confirmNewPassword ? 0.5 : 1) .opacity(newPassword != confirmNewPassword ? 0.5 : 1)
} }
} footer: { } footer: {
Text("Changes the Jellyfin server user password. This does not change any Swiftfin settings.") Text(L10n.passwordChangeWarning)
} }
} }
.interactiveDismissDisabled(viewModel.state == .resetting) .interactiveDismissDisabled(viewModel.state == .resetting)
.navigationBarBackButtonHidden(viewModel.state == .resetting) .navigationBarBackButtonHidden(viewModel.state == .resetting)
.navigationTitle(L10n.password) .navigationTitle(L10n.password)
.navigationBarTitleDisplayMode(.inline)
.navigationBarCloseButton {
router.dismissCoordinator()
}
.onFirstAppear { .onFirstAppear {
focusedPassword = 0 if requiresCurrentPassword {
focusedField = .currentPassword
} else {
focusedField = .newPassword
}
} }
.onReceive(viewModel.events) { event in .onReceive(viewModel.events) { event in
switch event { switch event {
@ -124,12 +159,12 @@ struct ResetUserPasswordView: View {
} }
} }
.alert( .alert(
L10n.error.text, L10n.error,
isPresented: $isPresentingError, isPresented: $isPresentingError,
presenting: error presenting: error
) { _ in ) { _ in
Button(L10n.dismiss, role: .cancel) { Button(L10n.dismiss, role: .cancel) {
focusedPassword = 1 focusedField = .newPassword
} }
} message: { error in } message: { error in
Text(error.localizedDescription) Text(error.localizedDescription)
@ -139,10 +174,10 @@ struct ResetUserPasswordView: View {
isPresented: $isPresentingSuccess isPresented: $isPresentingSuccess
) { ) {
Button(L10n.dismiss, role: .cancel) { Button(L10n.dismiss, role: .cancel) {
router.pop() router.dismissCoordinator()
} }
} message: { } message: {
Text("User password has been changed.") Text(L10n.passwordChangedMessage)
} }
} }
} }

View File

@ -91,7 +91,7 @@ struct UserProfileSettingsView: View {
ChevronButton("Password") ChevronButton("Password")
.onSelect { .onSelect {
router.route(to: \.resetUserPassword) router.route(to: \.resetUserPassword, viewModel.userSession.user.id)
} }
} }

View File

@ -37,7 +37,6 @@
"ok" = "Ok"; "ok" = "Ok";
"otherUser" = "Other User"; "otherUser" = "Other User";
"pageOfWithNumbers" = "Page %1$@ of %2$@"; "pageOfWithNumbers" = "Page %1$@ of %2$@";
"password" = "Password";
"playNext" = "Play Next"; "playNext" = "Play Next";
"play" = "Play"; "play" = "Play";
"playback" = "Playback"; "playback" = "Playback";
@ -1213,3 +1212,38 @@
// Button title to edit existing users // Button title to edit existing users
// Used as the button label in the options menu when there are users to edit // Used as the button label in the options menu when there are users to edit
"editUsers" = "Edit Users"; "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.";