[iOS] Admin Dashboard - Media Access / Deletion Settings (#1333)

* ServerUserAdminViewModel cleanup & testing. ServerUserAccessView.

* Change the enableAllLibraries to use the binding extensions

* Use coalesce for enableAllFolders & enableContentDeletion

* use contains binding

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe Kribs 2024-12-03 00:54:33 -05:00 committed by GitHub
parent da40f6a3b5
commit 2ac9283dfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 373 additions and 65 deletions

View File

@ -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<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ServerUserAccessView(viewModel: viewModel)
}
}
func makeUserPermissions(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ServerUserPermissionsView(viewModel: viewModel)

View File

@ -24,6 +24,19 @@ extension Binding {
)
}
func contains<E: Equatable>(_ value: E) -> Binding<Bool> where Value == [E] {
Binding<Bool>(
get: { wrappedValue.contains(value) },
set: { shouldBeContained in
if shouldBeContained {
wrappedValue.append(value)
} else {
wrappedValue.removeAll { $0 == value }
}
}
)
}
func map<V>(getter: @escaping (Value) -> V, setter: @escaping (V) -> Value) -> Binding<V> {
Binding<V>(
get: { getter(wrappedValue) },

View File

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

View File

@ -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<BackgroundState> = []
@Published
private(set) var user: UserDto
@Published
var libraries: [BaseItemDto] = []
private var userTaskCancellable: AnyCancellable?
private var eventSubject = PassthroughSubject<Event, Never>()
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
private var userTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .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

View File

@ -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 = "<group>"; };
4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListColumnsPickerView.swift; sourceTree = "<group>"; };
4EF18B292CB993AD00343666 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = "<group>"; };
4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserAccessView.swift; sourceTree = "<group>"; };
4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionMenu.swift; sourceTree = "<group>"; };
4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreButton.swift; sourceTree = "<group>"; };
531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
};
4EF3D80A2CF7D6670081AD20 /* ServerUserAccessView */ = {
isa = PBXGroup;
children = (
4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */,
);
path = ServerUserAccessView;
sourceTree = "<group>";
};
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 */,

View File

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

View File

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

View File

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