[iOS] Admin Dashboard - User Passwords (#1312)
Some checks are pending
Build 🔨 / Build 🔨 (Swiftfin tvOS) (push) Waiting to run
Build 🔨 / Build 🔨 (Swiftfin) (push) Waiting to run

* 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
@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<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ResetUserPasswordView(userID: userID, requiresCurrentPassword: false)
}
}
@ViewBuilder

View File

@ -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<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ResetUserPasswordView(userID: userID, requiresCurrentPassword: true)
}
}
@ViewBuilder

View File

@ -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

View File

@ -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<Event, Never> {
eventSubject
@ -45,6 +48,12 @@ final class ResetUserPasswordViewModel: ViewModel, Eventful, Stateful {
private var resetTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .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)
}

View File

@ -2273,6 +2273,14 @@
path = DevicesView;
sourceTree = "<group>";
};
4EF10D4C2CE2EC5A000ED5F5 /* ResetUserPasswordView */ = {
isa = PBXGroup;
children = (
E1545BD72BDC55C300D9578F /* ResetUserPasswordView.swift */,
);
path = ResetUserPasswordView;
sourceTree = "<group>";
};
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 */,

View File

@ -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 {

View File

@ -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)
}
}
}

View File

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

View File

@ -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.";