diff --git a/Shared/Coordinators/AdminDashboardCoordinator.swift b/Shared/Coordinators/AdminDashboardCoordinator.swift index 356b3b6a..11924134 100644 --- a/Shared/Coordinators/AdminDashboardCoordinator.swift +++ b/Shared/Coordinators/AdminDashboardCoordinator.swift @@ -52,6 +52,8 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { @Route(.push) var userDetails = makeUserDetails @Route(.modal) + var userMediaAccess = makeUserMediaAccess + @Route(.modal) var userPermissions = makeUserPermissions @Route(.modal) var resetUserPassword = makeResetUserPassword @@ -130,6 +132,12 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { } } + func makeUserMediaAccess(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator { + ServerUserAccessView(viewModel: viewModel) + } + } + func makeUserPermissions(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator { NavigationViewCoordinator { ServerUserPermissionsView(viewModel: viewModel) diff --git a/Shared/Extensions/Binding.swift b/Shared/Extensions/Binding.swift index f4f652f3..06a00e35 100644 --- a/Shared/Extensions/Binding.swift +++ b/Shared/Extensions/Binding.swift @@ -24,6 +24,19 @@ extension Binding { ) } + func contains(_ value: E) -> Binding where Value == [E] { + Binding( + get: { wrappedValue.contains(value) }, + set: { shouldBeContained in + if shouldBeContained { + wrappedValue.append(value) + } else { + wrappedValue.removeAll { $0 == value } + } + } + ) + } + func map(getter: @escaping (Value) -> V, setter: @escaping (V) -> Value) -> Binding { Binding( get: { getter(wrappedValue) }, diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index e46e111b..8a71b2aa 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -18,6 +18,8 @@ internal enum L10n { internal static let accentColor = L10n.tr("Localizable", "accentColor", fallback: "Accent Color") /// Some views may need an app restart to update. internal static let accentColorDescription = L10n.tr("Localizable", "accentColorDescription", fallback: "Some views may need an app restart to update.") + /// Access + internal static let access = L10n.tr("Localizable", "access", fallback: "Access") /// Accessibility internal static let accessibility = L10n.tr("Localizable", "accessibility", fallback: "Accessibility") /// Active @@ -400,6 +402,8 @@ internal enum L10n { } /// Are you sure you wish to delete this user? internal static let deleteUserWarning = L10n.tr("Localizable", "deleteUserWarning", fallback: "Are you sure you wish to delete this user?") + /// Deletion + internal static let deletion = L10n.tr("Localizable", "deletion", fallback: "Deletion") /// Delivery internal static let delivery = L10n.tr("Localizable", "delivery", fallback: "Delivery") /// Details @@ -452,6 +456,8 @@ internal enum L10n { internal static let editUsers = L10n.tr("Localizable", "editUsers", fallback: "Edit Users") /// Empty Next Up internal static let emptyNextUp = L10n.tr("Localizable", "emptyNextUp", fallback: "Empty Next Up") + /// Enable all libraries + internal static let enableAllLibraries = L10n.tr("Localizable", "enableAllLibraries", fallback: "Enable all libraries") /// Enabled internal static let enabled = L10n.tr("Localizable", "enabled", fallback: "Enabled") /// End Date @@ -692,6 +698,8 @@ internal enum L10n { internal static let mayResultInPlaybackFailure = L10n.tr("Localizable", "mayResultInPlaybackFailure", fallback: "This setting may result in media failing to start playback.") /// Media internal static let media = L10n.tr("Localizable", "media", fallback: "Media") + /// Media Access + internal static let mediaAccess = L10n.tr("Localizable", "mediaAccess", fallback: "Media Access") /// Media downloads internal static let mediaDownloads = L10n.tr("Localizable", "mediaDownloads", fallback: "Media downloads") /// Media playback diff --git a/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift b/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift index 61d956e7..ccb3ceca 100644 --- a/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift +++ b/Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift @@ -13,30 +13,32 @@ import OrderedCollections final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiable { - // MARK: Event + // MARK: - Event enum Event { case error(JellyfinAPIError) case updated } - // MARK: Action + // MARK: - Action enum Action: Equatable { case cancel case loadDetails + case loadLibraries(isHidden: Bool? = false) case updatePolicy(UserPolicy) case updateConfiguration(UserConfiguration) case updateUsername(String) } - // MARK: Background State + // MARK: - Background State enum BackgroundState: Hashable { case updating + case refreshing } - // MARK: State + // MARK: - State enum State: Hashable { case initial @@ -45,112 +47,208 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl case error(JellyfinAPIError) } - // MARK: Published Values + // MARK: - Published Values @Published final var state: State = .initial @Published final var backgroundStates: OrderedSet = [] + @Published private(set) var user: UserDto + @Published + var libraries: [BaseItemDto] = [] + + private var userTaskCancellable: AnyCancellable? + private var eventSubject = PassthroughSubject() + var events: AnyPublisher { eventSubject .receive(on: RunLoop.main) .eraseToAnyPublisher() } - private var userTask: AnyCancellable? - private var eventSubject: PassthroughSubject = .init() - - // MARK: Initialize from UserDto + // MARK: - Initialize init(user: UserDto) { self.user = user } - // MARK: Respond + // MARK: - Respond func respond(to action: Action) -> State { switch action { case .cancel: - userTask?.cancel() return .initial case .loadDetails: - return performAction { - try await self.loadDetails() + userTaskCancellable?.cancel() + + userTaskCancellable = Task { + do { + await MainActor.run { + _ = backgroundStates.append(.refreshing) + } + + try await loadDetails() + + await MainActor.run { + state = .content + _ = backgroundStates.remove(.refreshing) + } + } catch { + await MainActor.run { + state = .error(.init(error.localizedDescription)) + eventSubject.send(.error(.init(error.localizedDescription))) + _ = backgroundStates.remove(.refreshing) + } + } } + .asAnyCancellable() + + return state + + case let .loadLibraries(isHidden): + userTaskCancellable?.cancel() + + userTaskCancellable = Task { + do { + await MainActor.run { + _ = backgroundStates.append(.refreshing) + } + + try await loadLibraries(isHidden: isHidden) + + await MainActor.run { + state = .content + _ = backgroundStates.remove(.refreshing) + } + } catch { + await MainActor.run { + state = .error(.init(error.localizedDescription)) + eventSubject.send(.error(.init(error.localizedDescription))) + _ = backgroundStates.remove(.refreshing) + } + } + } + .asAnyCancellable() + + return state case let .updatePolicy(policy): - return performAction { - try await self.updatePolicy(policy: policy) + userTaskCancellable?.cancel() + + userTaskCancellable = Task { + do { + await MainActor.run { + _ = backgroundStates.append(.updating) + } + + try await updatePolicy(policy: policy) + + await MainActor.run { + state = .content + eventSubject.send(.updated) + _ = backgroundStates.remove(.updating) + } + } catch { + await MainActor.run { + state = .error(.init(error.localizedDescription)) + eventSubject.send(.error(.init(error.localizedDescription))) + _ = backgroundStates.remove(.updating) + } + } } + .asAnyCancellable() + + return state case let .updateConfiguration(configuration): - return performAction { - try await self.updateConfiguration(configuration: configuration) + userTaskCancellable?.cancel() + + userTaskCancellable = Task { + do { + await MainActor.run { + _ = backgroundStates.append(.updating) + } + + try await updateConfiguration(configuration: configuration) + + await MainActor.run { + state = .content + eventSubject.send(.updated) + _ = backgroundStates.remove(.updating) + } + } catch { + await MainActor.run { + state = .error(.init(error.localizedDescription)) + eventSubject.send(.error(.init(error.localizedDescription))) + _ = backgroundStates.remove(.updating) + } + } } + .asAnyCancellable() + + return state case let .updateUsername(username): - return performAction { - try await self.updateUsername(username: username) + userTaskCancellable?.cancel() + + userTaskCancellable = Task { + do { + await MainActor.run { + _ = backgroundStates.append(.updating) + } + + try await updateUsername(username: username) + + await MainActor.run { + state = .content + eventSubject.send(.updated) + _ = backgroundStates.remove(.updating) + } + } catch { + await MainActor.run { + state = .error(.init(error.localizedDescription)) + eventSubject.send(.error(.init(error.localizedDescription))) + _ = backgroundStates.remove(.updating) + } + } } + .asAnyCancellable() + + return state } } - // MARK: - Perform Action - - private func performAction(action: @escaping () async throws -> Void) -> State { - userTask?.cancel() - - userTask = Task { - do { - await MainActor.run { - _ = self.backgroundStates.append(.updating) - } - - try await action() - - await MainActor.run { - self.state = .content - self.eventSubject.send(.updated) - } - - await MainActor.run { - _ = self.backgroundStates.remove(.updating) - } - } catch { - let jellyfinError = JellyfinAPIError(error.localizedDescription) - await MainActor.run { - self.state = .error(jellyfinError) - self.backgroundStates.remove(.updating) - self.eventSubject.send(.error(jellyfinError)) - } - } - } - .asAnyCancellable() - - return .updating - } - - // MARK: - Load User + // MARK: - Load User Details private func loadDetails() async throws { - guard let userID = user.id else { return } + guard let userID = user.id else { throw JellyfinAPIError("User ID is missing") } let request = Paths.getUserByID(userID: userID) let response = try await userSession.client.send(request) await MainActor.run { self.user = response.value - self.state = .content + } + } + + // MARK: - Load Libraries + + private func loadLibraries(isHidden: Bool?) async throws { + let request = Paths.getMediaFolders(isHidden: isHidden) + let response = try await userSession.client.send(request) + + await MainActor.run { + self.libraries = response.value.items ?? [] } } // MARK: - Update User Policy private func updatePolicy(policy: UserPolicy) async throws { - guard let userID = user.id else { return } + guard let userID = user.id else { throw JellyfinAPIError("User ID is missing") } let request = Paths.updateUserPolicy(userID: userID, policy) try await userSession.client.send(request) @@ -162,7 +260,7 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl // MARK: - Update User Configuration private func updateConfiguration(configuration: UserConfiguration) async throws { - guard let userID = user.id else { return } + guard let userID = user.id else { throw JellyfinAPIError("User ID is missing") } let request = Paths.updateUserConfiguration(userID: userID, configuration) try await userSession.client.send(request) @@ -171,10 +269,10 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl } } - // MARK: - Update User Name + // MARK: - Update Username private func updateUsername(username: String) async throws { - guard let userID = user.id else { return } + guard let userID = user.id else { throw JellyfinAPIError("User ID is missing") } var updatedUser = user updatedUser.name = username diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 184f58ec..5d8282a7 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -182,6 +182,7 @@ 4EF18B262CB9934C00343666 /* LibraryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B252CB9934700343666 /* LibraryRow.swift */; }; 4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */; }; 4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B292CB993AD00343666 /* ListRow.swift */; }; + 4EF3D80B2CF7D6670081AD20 /* ServerUserAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */; }; 4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */; }; 4EFD172E2CE4182200A4BAC5 /* LearnMoreButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */; }; 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; }; @@ -1261,6 +1262,7 @@ 4EF18B252CB9934700343666 /* LibraryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryRow.swift; sourceTree = ""; }; 4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListColumnsPickerView.swift; sourceTree = ""; }; 4EF18B292CB993AD00343666 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = ""; }; + 4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserAccessView.swift; sourceTree = ""; }; 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionMenu.swift; sourceTree = ""; }; 4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreButton.swift; sourceTree = ""; }; 531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; @@ -2169,6 +2171,7 @@ 4E90F7622CC72B1F00417C31 /* EditServerTaskView */, 4E35CE622CBED3FF00DBD886 /* ServerLogsView */, 4E182C9A2C94991800FBEFD5 /* ServerTasksView */, + 4EF3D80A2CF7D6670081AD20 /* ServerUserAccessView */, 4EC2B1A72CC9725400D866BE /* ServerUserDetailsView */, 4EB538B32CE3C75900EB72D5 /* ServerUserPermissionsView */, 4EC2B1992CC96E5E00D866BE /* ServerUsersView */, @@ -2527,6 +2530,14 @@ path = Components; sourceTree = ""; }; + 4EF3D80A2CF7D6670081AD20 /* ServerUserAccessView */ = { + isa = PBXGroup; + children = ( + 4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */, + ); + path = ServerUserAccessView; + sourceTree = ""; + }; 5310694F2684E7EE00CFFDBA /* VideoPlayer */ = { isa = PBXGroup; children = ( @@ -5718,6 +5729,7 @@ E1ED7FE32CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift in Sources */, E1ED91182B95993300802036 /* TitledLibraryParent.swift in Sources */, E13DD3F92717E961009D4DAF /* SelectUserViewModel.swift in Sources */, + 4EF3D80B2CF7D6670081AD20 /* ServerUserAccessView.swift in Sources */, E1194F502BEB1E3000888DB6 /* StoredValues+Temp.swift in Sources */, E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */, E133328F2953B71000EE76AB /* DownloadTaskView.swift in Sources */, diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserAccessView/ServerUserAccessView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserAccessView/ServerUserAccessView.swift new file mode 100644 index 00000000..c389eb9b --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUserAccessView/ServerUserAccessView.swift @@ -0,0 +1,146 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +struct ServerUserAccessView: View { + + // MARK: - Environment + + @EnvironmentObject + private var router: BasicNavigationViewCoordinator.Router + + // MARK: - ViewModel + + @ObservedObject + private var viewModel: ServerUserAdminViewModel + + // MARK: - State Variables + + @State + private var tempPolicy: UserPolicy + @State + private var error: Error? + @State + private var isPresentingError: Bool = false + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self.viewModel = viewModel + self.tempPolicy = viewModel.user.policy ?? UserPolicy() + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.mediaAccess) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismissCoordinator() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.updating) { + ProgressView() + } + Button(L10n.save) { + if tempPolicy != viewModel.user.policy { + viewModel.send(.updatePolicy(tempPolicy)) + } + } + .buttonStyle(.toolbarPill) + .disabled(viewModel.user.policy == tempPolicy) + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + isPresentingError = true + case .updated: + UIDevice.feedback(.success) + router.dismissCoordinator() + } + } + .alert( + L10n.error.text, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .cancel) {} + } message: { error in + Text(error.localizedDescription) + } + .onFirstAppear { + viewModel.send(.loadLibraries(isHidden: false)) + } + } + + // MARK: - Content View + + @ViewBuilder + var contentView: some View { + List { + accessView + deletionView + } + } + + // MARK: - Media Access View + + @ViewBuilder + var accessView: some View { + Section(L10n.access) { + Toggle( + L10n.enableAllLibraries, + isOn: $tempPolicy.enableAllFolders.coalesce(false) + ) + } + + if tempPolicy.enableAllFolders == false { + Section { + ForEach(viewModel.libraries, id: \.id) { library in + Toggle( + library.displayTitle, + isOn: $tempPolicy.enabledFolders + .coalesce([]) + .contains(library.id!) + ) + } + } + } + } + + // MARK: - Media Deletion View + + @ViewBuilder + var deletionView: some View { + Section(L10n.deletion) { + Toggle( + L10n.enableAllLibraries, + isOn: $tempPolicy.enableContentDeletion.coalesce(false) + ) + } + + if tempPolicy.enableContentDeletion == false { + Section { + ForEach(viewModel.libraries, id: \.id) { library in + Toggle( + library.displayTitle, + isOn: $tempPolicy.enableContentDeletionFromFolders + .coalesce([]) + .contains(library.id!) + ) + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift index 123dd481..6c5b52d8 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -31,20 +31,27 @@ struct ServerUserDetailsView: View { var body: some View { List { + + // TODO: Replace with Update Profile Picture & Username AdminDashboardView.UserSection( user: viewModel.user, lastActivityDate: viewModel.user.lastActivityDate - ) { - // TODO: Update Profile Picture & Username - } + ) - Section(L10n.advanced) { + Section { if let userId = viewModel.user.id { ChevronButton(L10n.password) .onSelect { router.route(to: \.resetUserPassword, userId) } } + } + + Section { + ChevronButton(L10n.mediaAccess) + .onSelect { + router.route(to: \.userMediaAccess, viewModel) + } ChevronButton(L10n.permissions) .onSelect { diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index dd88b59e..cd4dbef0 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -1881,3 +1881,19 @@ // Custom Connections - Description // Explanation of custom connections policy "customConnectionsDescription" = "Manually set the maximum number of connections a user can have to the server."; + +// Enable All Libraries - Toggle +// Toggle to enable a setting for all Libraries +"enableAllLibraries" = "Enable all libraries"; + +// Media Access - Section Title +// Section Title for Server User Media Access Editing +"mediaAccess" = "Media Access"; + +// Deletion - Section Description +// Section Title for Media Deletion +"deletion" = "Deletion"; + +// Access - Section Description +// Section Title for Media Access +"access" = "Access";