From e0990e321a6d5e86e9f4fbc967bc9b8985c9cca7 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 31 Oct 2024 15:56:00 -0600 Subject: [PATCH] [iOS] Admin Dashboard - Users (#1287) --- Shared/Coordinators/SettingsCoordinator.swift | 33 ++- Shared/Extensions/FormatStyle.swift | 24 ++ .../Extensions/JellyfinAPI/DeviceInfo.swift | 2 +- Shared/Extensions/Sequence.swift | 2 + Shared/Extensions/URL.swift | 2 + Shared/Services/SwiftfinNotifications.swift | 2 + Shared/Strings/Strings.swift | 42 +++ .../ViewModels/AddServerUserViewModel.swift | 93 ++++++ Shared/ViewModels/DeviceDetailViewModel.swift | 102 +++++++ Shared/ViewModels/DevicesViewModel.swift | 109 ++----- .../ViewModels/ServerUserAdminViewModel.swift | 244 ++++++++++++++++ Shared/ViewModels/ServerUsersViewModel.swift | 203 +++++++++++++ Swiftfin.xcodeproj/project.pbxproj | 70 +++++ Swiftfin/Extensions/ButtonStyle-iOS.swift | 30 +- .../ActiveSessionDetailView.swift | 17 +- .../Components/ActiveSessionRow.swift | 2 +- .../AddServerUserView/AddServerUserView.swift | 152 ++++++++++ .../Components/UserSection.swift | 39 ++- .../DeviceDetailsView/DeviceDetailsView.swift | 32 ++- .../DevicesView/Components/DeviceRow.swift | 46 +-- .../DevicesView/DevicesView.swift | 132 ++++----- .../Components/Sections/LastRunSection.swift | 2 +- .../ServerUserDetailsView.swift | 44 +++ .../Components/ServerUsersRow.swift | 178 ++++++++++++ .../ServerUsersView/ServerUsersView.swift | 272 ++++++++++++++++++ .../UserDashboardView/UserDashboardView.swift | 6 +- Translations/en.lproj/Localizable.strings | 109 +++++++ 27 files changed, 1731 insertions(+), 258 deletions(-) create mode 100644 Shared/ViewModels/AddServerUserViewModel.swift create mode 100644 Shared/ViewModels/DeviceDetailViewModel.swift create mode 100644 Shared/ViewModels/ServerUserAdminViewModel.swift create mode 100644 Shared/ViewModels/ServerUsersViewModel.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/AddServerUserView/AddServerUserView.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/ServerUsersView/Components/ServerUsersRow.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/ServerUsersView/ServerUsersView.swift diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index 29512481..5a4d574c 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -77,6 +77,14 @@ final class SettingsCoordinator: NavigationCoordinatable { @Route(.push) var serverLogs = makeServerLogs @Route(.push) + var users = makeUsers + @Route(.push) + var userDetails = makeUserDetails + @Route(.push) + var userDevices = makeUserDevices + @Route(.modal) + var addServerUser = makeAddServerUser + @Route(.push) var apiKeys = makeAPIKeys // <- End of AdminDashboard Items @@ -118,7 +126,8 @@ final class SettingsCoordinator: NavigationCoordinatable { } func makeEditCustomDeviceProfile(profile: Binding) - -> NavigationViewCoordinator { + -> NavigationViewCoordinator + { NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile)) } @@ -232,6 +241,27 @@ final class SettingsCoordinator: NavigationCoordinatable { ServerLogsView() } + @ViewBuilder + func makeUsers() -> some View { + ServerUsersView() + } + + @ViewBuilder + func makeUserDetails(user: UserDto) -> some View { + ServerUserDetailsView(user: user) + } + + func makeAddServerUser() -> NavigationViewCoordinator { + NavigationViewCoordinator { + AddServerUserView() + } + } + + @ViewBuilder + func makeUserDevices() -> some View { + DevicesView() + } + @ViewBuilder func makeAPIKeys() -> some View { APIKeysView() @@ -245,7 +275,6 @@ final class SettingsCoordinator: NavigationCoordinatable { DebugSettingsView() } #endif - #endif #if os(tvOS) diff --git a/Shared/Extensions/FormatStyle.swift b/Shared/Extensions/FormatStyle.swift index a88d4a8b..82d2fb68 100644 --- a/Shared/Extensions/FormatStyle.swift +++ b/Shared/Extensions/FormatStyle.swift @@ -105,3 +105,27 @@ struct TimeIntervalFormatStyle: FormatStyle { ).format(t ..< t.addingTimeInterval(value)) } } + +struct LastSeenFormatStyle: FormatStyle { + + func format(_ value: Date?) -> String { + + guard let value else { + return L10n.never + } + + let timeInterval = Date.now.timeIntervalSince(value) + let twentyFourHours: TimeInterval = 24 * 60 * 60 + + if timeInterval <= twentyFourHours { + return value.formatted(.relative(presentation: .numeric, unitsStyle: .narrow)) + } else { + return value.formatted(Date.FormatStyle.dateTime.year().month().day()) + } + } +} + +extension FormatStyle where Self == LastSeenFormatStyle { + + static var lastSeen: LastSeenFormatStyle { LastSeenFormatStyle() } +} diff --git a/Shared/Extensions/JellyfinAPI/DeviceInfo.swift b/Shared/Extensions/JellyfinAPI/DeviceInfo.swift index 90b30aa6..9876bc15 100644 --- a/Shared/Extensions/JellyfinAPI/DeviceInfo.swift +++ b/Shared/Extensions/JellyfinAPI/DeviceInfo.swift @@ -11,7 +11,7 @@ import JellyfinAPI extension DeviceInfo { - var device: DeviceType { + var type: DeviceType { DeviceType( client: appName, deviceName: name diff --git a/Shared/Extensions/Sequence.swift b/Shared/Extensions/Sequence.swift index 05c9c920..afc8fee8 100644 --- a/Shared/Extensions/Sequence.swift +++ b/Shared/Extensions/Sequence.swift @@ -24,6 +24,8 @@ extension Sequence { sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] }) } + // TODO: a flipped version of `sorted` + /// Returns the elements of the sequence, sorted by comparing values /// at the given `KeyPath` of `Element`. /// diff --git a/Shared/Extensions/URL.swift b/Shared/Extensions/URL.swift index 02ee9143..33be35a6 100644 --- a/Shared/Extensions/URL.swift +++ b/Shared/Extensions/URL.swift @@ -37,6 +37,8 @@ extension URL { static let jellyfinDocsTasks: URL = URL(string: "https://jellyfin.org/docs/general/server/tasks")! + static let jellyfinDocsUsers: URL = URL(string: "https://jellyfin.org/docs/general/server/users")! + func isDirectoryAndReachable() throws -> Bool { guard try resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true else { return false diff --git a/Shared/Services/SwiftfinNotifications.swift b/Shared/Services/SwiftfinNotifications.swift index 08bfd64a..9c16a616 100644 --- a/Shared/Services/SwiftfinNotifications.swift +++ b/Shared/Services/SwiftfinNotifications.swift @@ -88,4 +88,6 @@ extension Notifications.Key { static let didChangeUserProfileImage = NotificationKey("didChangeUserProfileImage") static let didStartPlayback = NotificationKey("didStartPlayback") + + static let didAddServerUser = NotificationKey("didStartPlayback") } diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 2d948d4e..4271a54b 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -18,8 +18,12 @@ internal enum L10n { internal static let accentColorDescription = L10n.tr("Localizable", "accentColorDescription", fallback: "Some views may need an app restart to update.") /// Accessibility internal static let accessibility = L10n.tr("Localizable", "accessibility", fallback: "Accessibility") + /// Active + internal static let active = L10n.tr("Localizable", "active", fallback: "Active") /// ActiveSessionsView Header internal static let activeDevices = L10n.tr("Localizable", "activeDevices", fallback: "Active Devices") + /// Activity + internal static let activity = L10n.tr("Localizable", "activity", fallback: "Activity") /// Add internal static let add = L10n.tr("Localizable", "add", fallback: "Add") /// Add API key @@ -30,8 +34,12 @@ internal enum L10n { internal static let addTrigger = L10n.tr("Localizable", "addTrigger", fallback: "Add trigger") /// Add URL internal static let addURL = L10n.tr("Localizable", "addURL", fallback: "Add URL") + /// Add User + internal static let addUser = L10n.tr("Localizable", "addUser", fallback: "Add User") /// Administration Dashboard Section internal static let administration = L10n.tr("Localizable", "administration", fallback: "Administration") + /// Administrator + internal static let administrator = L10n.tr("Localizable", "administrator", fallback: "Administrator") /// Advanced internal static let advanced = L10n.tr("Localizable", "advanced", fallback: "Advanced") /// Airs %s @@ -46,6 +54,8 @@ internal enum L10n { internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media") /// Select Server View - Select All Servers internal static let allServers = L10n.tr("Localizable", "allServers", fallback: "All Servers") + /// View and manage all registered users on the server, including their permissions and activity status. + internal static let allUsersDescription = L10n.tr("Localizable", "allUsersDescription", fallback: "View and manage all registered users on the server, including their permissions and activity status.") /// TranscodeReason - Anamorphic Video Not Supported internal static let anamorphicVideoNotSupported = L10n.tr("Localizable", "anamorphicVideoNotSupported", fallback: "Anamorphic video is not supported") /// API Key Copied @@ -210,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 Password + internal static let confirmPassword = L10n.tr("Localizable", "confirmPassword", fallback: "Confirm Password") /// Connect internal static let connect = L10n.tr("Localizable", "connect", fallback: "Connect") /// Connect Manually @@ -290,14 +302,28 @@ internal enum L10n { internal static let deleteDeviceWarning = L10n.tr("Localizable", "deleteDeviceWarning", fallback: "Are you sure you wish to delete this device? This session will be logged out.") /// Delete Selected Devices internal static let deleteSelectedDevices = L10n.tr("Localizable", "deleteSelectedDevices", fallback: "Delete Selected Devices") + /// Delete Selected Users + internal static let deleteSelectedUsers = L10n.tr("Localizable", "deleteSelectedUsers", fallback: "Delete Selected Users") /// Are you sure you wish to delete all selected devices? All selected sessions will be logged out. internal static let deleteSelectionDevicesWarning = L10n.tr("Localizable", "deleteSelectionDevicesWarning", fallback: "Are you sure you wish to delete all selected devices? All selected sessions will be logged out.") + /// Are you sure you wish to delete all selected users? + internal static let deleteSelectionUsersWarning = L10n.tr("Localizable", "deleteSelectionUsersWarning", fallback: "Are you sure you wish to delete all selected users?") /// Server Detail View - Delete Server internal static let deleteServer = L10n.tr("Localizable", "deleteServer", fallback: "Delete Server") /// Delete Trigger internal static let deleteTrigger = L10n.tr("Localizable", "deleteTrigger", fallback: "Delete Trigger") /// Are you sure you want to delete this trigger? This action cannot be undone. internal static let deleteTriggerConfirmationMessage = L10n.tr("Localizable", "deleteTriggerConfirmationMessage", fallback: "Are you sure you want to delete this trigger? This action cannot be undone.") + /// Delete User + internal static let deleteUser = L10n.tr("Localizable", "deleteUser", fallback: "Delete User") + /// Failed to Delete User + internal static let deleteUserFailed = L10n.tr("Localizable", "deleteUserFailed", fallback: "Failed to Delete User") + /// Cannot delete a user from the same user (%1$@). + internal static func deleteUserSelfDeletion(_ p1: Any) -> String { + return L10n.tr("Localizable", "deleteUserSelfDeletion", String(describing: p1), fallback: "Cannot delete a user from the same user (%1$@).") + } + /// 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?") /// Delivery internal static let delivery = L10n.tr("Localizable", "delivery", fallback: "Delivery") /// Details @@ -338,6 +364,8 @@ internal enum L10n { internal static let editJumpLengths = L10n.tr("Localizable", "editJumpLengths", fallback: "Edit Jump Lengths") /// Select Server View - Edit an Existing Server internal static let editServer = L10n.tr("Localizable", "editServer", fallback: "Edit Server") + /// Edit Users + 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") /// Enabled @@ -392,6 +420,8 @@ internal enum L10n { internal static let grid = L10n.tr("Localizable", "grid", fallback: "Grid") /// Haptic Feedback internal static let hapticFeedback = L10n.tr("Localizable", "hapticFeedback", fallback: "Haptic Feedback") + /// Hidden + internal static let hidden = L10n.tr("Localizable", "hidden", fallback: "Hidden") /// Home internal static let home = L10n.tr("Localizable", "home", fallback: "Home") /// Hours @@ -522,6 +552,8 @@ internal enum L10n { internal static let neverRun = L10n.tr("Localizable", "neverRun", fallback: "Never run") /// News internal static let news = L10n.tr("Localizable", "news", fallback: "News") + /// New User + internal static let newUser = L10n.tr("Localizable", "newUser", fallback: "New User") /// Next internal static let next = L10n.tr("Localizable", "next", fallback: "Next") /// Next Item @@ -580,6 +612,8 @@ internal enum L10n { internal static let onNow = L10n.tr("Localizable", "onNow", fallback: "On Now") /// Operating System internal static let operatingSystem = L10n.tr("Localizable", "operatingSystem", fallback: "Operating System") + /// Options + internal static let options = L10n.tr("Localizable", "options", fallback: "Options") /// Orange internal static let orange = L10n.tr("Localizable", "orange", fallback: "Orange") /// Order @@ -602,6 +636,8 @@ 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") /// Video Player Settings View - Pause on Background internal static let pauseOnBackground = L10n.tr("Localizable", "pauseOnBackground", fallback: "Pause on background") /// People @@ -738,6 +774,8 @@ internal enum L10n { internal static let retry = L10n.tr("Localizable", "retry", fallback: "Retry") /// Right internal static let `right` = L10n.tr("Localizable", "right", fallback: "Right") + /// Role + internal static let role = L10n.tr("Localizable", "role", fallback: "Role") /// Button label to run a task internal static let run = L10n.tr("Localizable", "run", fallback: "Run") /// Status label for when a task is running @@ -1014,6 +1052,10 @@ internal enum L10n { } /// Username internal static let username = L10n.tr("Localizable", "username", fallback: "Username") + /// A username is required + internal static let usernameRequired = L10n.tr("Localizable", "usernameRequired", fallback: "A username is required") + /// Users + internal static let users = L10n.tr("Localizable", "users", fallback: "Users") /// Version internal static let version = L10n.tr("Localizable", "version", fallback: "Version") /// Video diff --git a/Shared/ViewModels/AddServerUserViewModel.swift b/Shared/ViewModels/AddServerUserViewModel.swift new file mode 100644 index 00000000..2ad50f1b --- /dev/null +++ b/Shared/ViewModels/AddServerUserViewModel.swift @@ -0,0 +1,93 @@ +// +// 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 Combine +import Foundation +import JellyfinAPI +import OrderedCollections +import SwiftUI + +final class AddServerUserViewModel: ViewModel, Eventful, Stateful, Identifiable { + + // MARK: Event + + enum Event { + case createdNewUser(UserDto) + case error(JellyfinAPIError) + } + + // MARK: Actions + + enum Action: Equatable { + case cancel + case createUser(username: String, password: String) + } + + // MARK: - State + + enum State: Hashable { + case initial + case creatingUser + case error(JellyfinAPIError) + } + + // MARK: Published Values + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + @Published + final var state: State = .initial + + private var userTask: AnyCancellable? + private var eventSubject: PassthroughSubject = .init() + + // MARK: - Respond to Action + + func respond(to action: Action) -> State { + switch action { + case .cancel: + userTask?.cancel() + return .initial + case let .createUser(username, password): + userTask?.cancel() + + userTask = Task { + do { + let newUser = try await createUser(username: username, password: password) + + await MainActor.run { + state = .initial + eventSubject.send(.createdNewUser(newUser)) + } + } catch { + await MainActor.run { + state = .error(.init(error.localizedDescription)) + eventSubject.send(.error(.init(error.localizedDescription))) + } + } + } + .asAnyCancellable() + + return .creatingUser + } + } + + // MARK: - Create User + + private func createUser(username: String, password: String) async throws -> UserDto { + let parameters = CreateUserByName(name: username, password: password) + let request = Paths.createUserByName(parameters) + let response = try await userSession.client.send(request) + + return response.value + } +} diff --git a/Shared/ViewModels/DeviceDetailViewModel.swift b/Shared/ViewModels/DeviceDetailViewModel.swift new file mode 100644 index 00000000..f1b41a33 --- /dev/null +++ b/Shared/ViewModels/DeviceDetailViewModel.swift @@ -0,0 +1,102 @@ +// +// 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 Combine +import Foundation +import JellyfinAPI +import OrderedCollections + +class DeviceDetailViewModel: ViewModel, Stateful, Eventful { + + enum Event { + case error(JellyfinAPIError) + case setCustomName + } + + enum Action: Equatable { + case setCustomName(String) + } + + enum BackgroundState: Hashable { + case updating + } + + enum State: Hashable { + case initial + } + + @Published + var backgroundStates: OrderedSet = [] + @Published + var state: State = .initial + + @Published + private(set) var device: DeviceInfo + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + private var eventSubject: PassthroughSubject = .init() + + init(device: DeviceInfo) { + self.device = device + } + + func respond(to action: Action) -> State { + switch action { + case let .setCustomName(newName): + cancellables = [] + + Task { + await MainActor.run { + _ = backgroundStates.append(.updating) + } + + do { + try await setCustomName(newName: newName) + + await MainActor.run { + self.eventSubject.send(.setCustomName) + } + } catch { + await MainActor.run { + self.eventSubject.send(.error(.init("Unable to update custom name"))) + } + } + + await MainActor.run { + _ = backgroundStates.remove(.updating) + } + } + .store(in: &cancellables) + + return .initial + } + } + + private func setCustomName(newName: String) async throws { + guard let id = device.id else { return } + + let request = Paths.updateDeviceOptions(id: id, .init(customName: newName)) + try await userSession.client.send(request) + } + + private func getDeviceInfo() async throws { + guard let id = device.id else { return } + + let request = Paths.getDeviceInfo(id: id) + let response = try await userSession.client.send(request) + + await MainActor.run { + self.device = response.value + } + } +} diff --git a/Shared/ViewModels/DevicesViewModel.swift b/Shared/ViewModels/DevicesViewModel.swift index 7cde03ce..c3561271 100644 --- a/Shared/ViewModels/DevicesViewModel.swift +++ b/Shared/ViewModels/DevicesViewModel.swift @@ -25,7 +25,6 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { enum Action: Equatable { case getDevices - case setCustomName(id: String, newName: String) case deleteDevices(ids: [String]) } @@ -47,31 +46,22 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { // MARK: Published Values + @Published + final var backgroundStates: OrderedSet = [] + @Published + final var devices: [DeviceInfo] = [] + @Published + final var state: State = .initial + var events: AnyPublisher { eventSubject .receive(on: RunLoop.main) .eraseToAnyPublisher() } - @Published - final var backgroundStates: OrderedSet = [] - @Published - final var devices: OrderedDictionary> = [:] - @Published - final var state: State = .initial - - @Published - private(set) var userID: String? - private var deviceTask: AnyCancellable? private var eventSubject: PassthroughSubject = .init() - // MARK: - Initializer - - init(_ userID: String? = nil) { - self.userID = userID - } - // MARK: - Respond to Action func respond(to action: Action) -> State { @@ -83,9 +73,8 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { deviceTask = Task { [weak self] in do { - try await self?.loadDevices( - userID: self?.userID - ) + try await self?.loadDevices() + await MainActor.run { self?.state = .content self?.eventSubject.send(.success) @@ -100,42 +89,12 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { } await MainActor.run { - let _ = self?.backgroundStates.remove(.gettingDevices) + _ = self?.backgroundStates.remove(.gettingDevices) } } .asAnyCancellable() return state - - case let .setCustomName(id, newName): - deviceTask?.cancel() - - backgroundStates.append(.settingCustomName) - - deviceTask = Task { [weak self] in - do { - try await self?.setCustomName(id: id, newName: newName) - await MainActor.run { - self?.state = .content - self?.eventSubject.send(.success) - } - } catch { - guard let self else { return } - await MainActor.run { - let jellyfinError = JellyfinAPIError(error.localizedDescription) - self.state = .error(jellyfinError) - self.eventSubject.send(.error(jellyfinError)) - } - } - - await MainActor.run { - let _ = self?.backgroundStates.remove(.settingCustomName) - } - } - .asAnyCancellable() - - return state - case let .deleteDevices(ids): deviceTask?.cancel() @@ -157,7 +116,7 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { } await MainActor.run { - let _ = self?.backgroundStates.remove(.deletingDevices) + _ = self?.backgroundStates.remove(.deletingDevices) } } .asAnyCancellable() @@ -168,8 +127,8 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { // MARK: - Load Devices - private func loadDevices(userID: String?) async throws { - let request = Paths.getDevices(userID: userID) + private func loadDevices() async throws { + let request = Paths.getDevices() let response = try await userSession.client.send(request) guard let devices = response.value.items else { @@ -177,36 +136,8 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { } await MainActor.run { - for device in devices { - guard let id = device.id else { continue } - - if let existingDevice = self.devices[id] { - existingDevice.value = device - } else { - self.devices[id] = BindingBox( - source: .init(get: { device }, set: { _ in }) - ) - } - } - - self.devices.sort { x, y in - let device0 = x.value.value - let device1 = y.value.value - return (device0?.dateLastActivity ?? Date()) > (device1?.dateLastActivity ?? Date()) - } - } - } - - // MARK: - Set Custom Name - - private func setCustomName(id: String, newName: String) async throws { - let request = Paths.updateDeviceOptions(id: id, DeviceOptionsDto(customName: newName)) - try await userSession.client.send(request) - - if let _ = devices[id]?.value { - await MainActor.run { - self.devices[id]?.value?.name = newName - } + self.devices = devices.sorted(using: \.dateLastActivity) + .reversed() } } @@ -221,9 +152,7 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { let request = Paths.deleteDevice(id: id) try await userSession.client.send(request) - await MainActor.run { - let _ = self.devices.removeValue(forKey: id) - } + try await loadDevices() } // MARK: - Delete Devices @@ -246,10 +175,6 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { try await group.waitForAll() } - await MainActor.run { - self.devices = self.devices.filter { - !deviceIdsToDelete.contains($0.key) - } - } + try await loadDevices() } } diff --git a/Shared/ViewModels/ServerUserAdminViewModel.swift b/Shared/ViewModels/ServerUserAdminViewModel.swift new file mode 100644 index 00000000..57361de5 --- /dev/null +++ b/Shared/ViewModels/ServerUserAdminViewModel.swift @@ -0,0 +1,244 @@ +// +// 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 Combine +import Foundation +import JellyfinAPI +import OrderedCollections + +final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiable { + + // MARK: Event + + enum Event { + case success + } + + // MARK: BackgroundState + + enum BackgroundState { + case updating + } + + // MARK: Action + + enum Action: Equatable { + case cancel + case loadDetails + case resetPassword + case updatePassword(password: String) + case updatePolicy(policy: UserPolicy) + case updateConfiguration(configuration: UserConfiguration) + } + + // MARK: State + + enum State: Hashable { + case error(JellyfinAPIError) + case initial + } + + // MARK: Published Values + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + @Published + final var backgroundStates: OrderedSet = [] + @Published + final var state: State = .initial + @Published + private(set) var user: UserDto + + private var resetTask: AnyCancellable? + private var eventSubject: PassthroughSubject = .init() + + // MARK: Initialize from UserDto + + init(user: UserDto) { + self.user = user + } + + // MARK: Respond + + func respond(to action: Action) -> State { + switch action { + case .cancel: + resetTask?.cancel() + return .initial + + case .resetPassword: + resetTask = Task { + await MainActor.run { + _ = self.backgroundStates.append(.updating) + } + + do { + try await resetPassword() + await MainActor.run { + self.state = .initial + self.eventSubject.send(.success) + } + } catch { + await MainActor.run { + let jellyfinError = JellyfinAPIError(error.localizedDescription) + self.state = .error(jellyfinError) + } + } + + await MainActor.run { + _ = self.backgroundStates.remove(.updating) + } + } + .asAnyCancellable() + + return .initial + + case .loadDetails: + resetTask = Task { + do { + try await loadDetails() + await MainActor.run { + self.state = .initial + self.eventSubject.send(.success) + } + } catch { + await MainActor.run { + let jellyfinError = JellyfinAPIError(error.localizedDescription) + self.state = .error(jellyfinError) + } + } + } + .asAnyCancellable() + + return .initial + + case let .updatePassword(password): + resetTask = Task { + do { + try await updatePassword(password: password) + await MainActor.run { + self.state = .initial + self.eventSubject.send(.success) + } + } catch { + await MainActor.run { + let jellyfinError = JellyfinAPIError(error.localizedDescription) + self.state = .error(jellyfinError) + } + } + } + .asAnyCancellable() + + return .initial + + case let .updatePolicy(policy): + resetTask = Task { + do { + try await updatePolicy(policy: policy) + await MainActor.run { + self.state = .initial + self.eventSubject.send(.success) + } + } catch { + await MainActor.run { + let jellyfinError = JellyfinAPIError(error.localizedDescription) + self.state = .error(jellyfinError) + } + } + } + .asAnyCancellable() + + return .initial + + case let .updateConfiguration(configuration): + resetTask = Task { + do { + try await updateConfiguration(configuration: configuration) + await MainActor.run { + self.state = .initial + self.eventSubject.send(.success) + } + } catch { + await MainActor.run { + let jellyfinError = JellyfinAPIError(error.localizedDescription) + self.state = .error(jellyfinError) + } + } + } + .asAnyCancellable() + + return .initial + } + } + + // MARK: - Reset Password + + private func resetPassword() async throws { + guard let userId = user.id else { return } + let parameters = UpdateUserPassword(isResetPassword: true) + let request = Paths.updateUserPassword(userID: userId, parameters) + try await userSession.client.send(request) + + await MainActor.run { + self.user.hasPassword = false + } + } + + // MARK: - Update Password + + private func updatePassword(password: String) async throws { + guard let userID = user.id else { return } + let parameters = UpdateUserPassword(newPw: password) + let request = Paths.updateUserPassword(userID: userID, parameters) + try await userSession.client.send(request) + + await MainActor.run { + self.user.hasPassword = (password != "") + } + } + + // MARK: - Update User Policy + + private func updatePolicy(policy: UserPolicy) async throws { + guard let userID = user.id else { return } + let request = Paths.updateUserPolicy(userID: userID, policy) + try await userSession.client.send(request) + + await MainActor.run { + self.user.policy = policy + } + } + + // MARK: - Update User Configuration + + private func updateConfiguration(configuration: UserConfiguration) async throws { + guard let userID = user.id else { return } + let request = Paths.updateUserConfiguration(userID: userID, configuration) + try await userSession.client.send(request) + + await MainActor.run { + self.user.configuration = configuration + } + } + + // MARK: - Load User + + private func loadDetails() async throws { + guard let userID = user.id else { return } + let request = Paths.getUserByID(userID: userID) + let response = try await userSession.client.send(request) + + await MainActor.run { + self.user = response.value + } + } +} diff --git a/Shared/ViewModels/ServerUsersViewModel.swift b/Shared/ViewModels/ServerUsersViewModel.swift new file mode 100644 index 00000000..7dab67a8 --- /dev/null +++ b/Shared/ViewModels/ServerUsersViewModel.swift @@ -0,0 +1,203 @@ +// +// 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 Combine +import Foundation +import JellyfinAPI +import OrderedCollections +import SwiftUI + +final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable { + + // MARK: Event + + enum Event { + case deleted + case error(JellyfinAPIError) + } + + // MARK: Actions + + enum Action: Equatable { + case getUsers(isHidden: Bool = false, isDisabled: Bool = false) + case deleteUsers([String]) + case appendUser(UserDto) + } + + // MARK: - BackgroundState + + enum BackgroundState: Hashable { + case gettingUsers + case deletingUsers + case appendingUsers + } + + // MARK: - State + + enum State: Hashable { + case content + case error(JellyfinAPIError) + case initial + } + + // MARK: Published Values + + @Published + final var backgroundStates: OrderedSet = [] + @Published + final var users: [UserDto] = [] + @Published + final var state: State = .initial + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + private var userTask: AnyCancellable? + private var eventSubject: PassthroughSubject = .init() + + // MARK: - Respond to Action + + func respond(to action: Action) -> State { + switch action { + case let .getUsers(isHidden, isDisabled): + userTask?.cancel() + backgroundStates.append(.gettingUsers) + + userTask = Task { + do { + try await loadUsers(isHidden: isHidden, isDisabled: isDisabled) + + await MainActor.run { + state = .content + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + self.eventSubject.send(.error(.init(error.localizedDescription))) + } + } + + await MainActor.run { + _ = self.backgroundStates.remove(.gettingUsers) + } + } + .asAnyCancellable() + + return state + + case let .deleteUsers(ids): + userTask?.cancel() + backgroundStates.append(.deletingUsers) + + userTask = Task { + do { + try await self.deleteUsers(ids: ids) + + await MainActor.run { + self.state = .content + self.eventSubject.send(.deleted) + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + self.eventSubject.send(.error(.init(error.localizedDescription))) + } + } + + await MainActor.run { + _ = self.backgroundStates.remove(.deletingUsers) + } + } + .asAnyCancellable() + + return state + + case let .appendUser(user): + userTask?.cancel() + backgroundStates.append(.appendingUsers) + + userTask = Task { + do { + await self.appendUser(user: user) + + await MainActor.run { + self.state = .content + self.eventSubject.send(.deleted) + } + } + + await MainActor.run { + _ = self.backgroundStates.remove(.appendingUsers) + } + } + .asAnyCancellable() + + return state + } + } + + // MARK: - Load Users + + private func loadUsers(isHidden: Bool, isDisabled: Bool) async throws { + let request = Paths.getUsers(isHidden: isHidden ? true : nil, isDisabled: isDisabled ? true : nil) + let response = try await userSession.client.send(request) + + let newUsers = response.value + .sorted(using: \.name) + + await MainActor.run { + self.users = newUsers + } + } + + // MARK: - Delete Users + + private func deleteUsers(ids: [String]) async throws { + guard ids.isNotEmpty else { + return + } + + // Don't allow self-deletion + let userIdsToDelete = ids.filter { $0 != userSession.user.id } + + try await withThrowingTaskGroup(of: Void.self) { group in + for userId in userIdsToDelete { + group.addTask { + try await self.deleteUser(id: userId) + } + } + + try await group.waitForAll() + } + + await MainActor.run { + self.users = self.users.filter { + !userIdsToDelete.contains($0.id ?? "") + } + } + } + + // MARK: - Delete User + + private func deleteUser(id: String) async throws { + let request = Paths.deleteUser(userID: id) + try await userSession.client.send(request) + } + + // MARK: - Append User + + private func appendUser(user: UserDto) async { + await MainActor.run { + users.append(user) + users = users.sorted(using: \.name) + } + } +} diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 6285d36e..a72476b8 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -85,6 +85,8 @@ 4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */; }; 4EA09DE12CC4E4F100CB27E4 /* APIKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */; }; 4EA09DE42CC4E85C00CB27E4 /* APIKeysRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */; }; + 4EA397462CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */; }; + 4EA397472CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */; }; 4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */; }; 4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */; }; 4EB1A8CC2C9B1BA200F43898 /* DestructiveServerTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */; }; @@ -92,6 +94,7 @@ 4EB4ECE32CBEFC4D002FF2FC /* SessionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */; }; 4EB4ECE42CBEFC4D002FF2FC /* SessionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */; }; 4EB7B33B2CBDE645004A342E /* ChevronAlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */; }; + 4EB7C8D52CCED6E7000CC011 /* AddServerUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB7C8D42CCED6E1000CC011 /* AddServerUserView.swift */; }; 4EBE06462C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */; }; 4EBE06472C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */; }; 4EBE064D2C7EB6D3004A6C03 /* VideoPlayerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */; }; @@ -103,6 +106,12 @@ 4EC1C8532C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8512C7FDFA300E2879E /* PlaybackDeviceProfile.swift */; }; 4EC1C8692C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8682C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift */; }; 4EC1C86D2C80903A00E2879E /* CustomProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C86C2C80903A00E2879E /* CustomProfileButton.swift */; }; + 4EC2B19B2CC96E7400D866BE /* ServerUsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC2B19A2CC96E7000D866BE /* ServerUsersView.swift */; }; + 4EC2B19E2CC96EAB00D866BE /* ServerUsersRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC2B19D2CC96EA300D866BE /* ServerUsersRow.swift */; }; + 4EC2B1A22CC96F6600D866BE /* ServerUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC2B1A12CC96F6000D866BE /* ServerUsersViewModel.swift */; }; + 4EC2B1A32CC96F6600D866BE /* ServerUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC2B1A12CC96F6000D866BE /* ServerUsersViewModel.swift */; }; + 4EC2B1A52CC96FA400D866BE /* ServerUserAdminViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC2B1A42CC96F9F00D866BE /* ServerUserAdminViewModel.swift */; }; + 4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC2B1A82CC97C0400D866BE /* ServerUserDetailsView.swift */; }; 4EC50D612C934B3A00FC3D0E /* ScheduledTasksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC50D602C934B3A00FC3D0E /* ScheduledTasksViewModel.swift */; }; 4EC50D622C934B3A00FC3D0E /* ScheduledTasksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC50D602C934B3A00FC3D0E /* ScheduledTasksViewModel.swift */; }; 4EC6C16B2C92999800FC904B /* TranscodeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */; }; @@ -279,6 +288,8 @@ E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfo.swift */; }; E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; }; E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* Algorithms */; }; + E101ECD52CD40489001EA89E /* DeviceDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */; }; + E101ECD62CD40489001EA89E /* DeviceDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */; }; E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231292BCF8A08009D71FC /* iOSLiveTVCoordinator.swift */; }; E102312F2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102312A2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift */; }; E10231392BCF8A3C009D71FC /* ProgramButtonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231302BCF8A3C009D71FC /* ProgramButtonContent.swift */; }; @@ -1115,12 +1126,14 @@ 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysView.swift; sourceTree = ""; }; 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysRow.swift; sourceTree = ""; }; + 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserViewModel.swift; sourceTree = ""; }; 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSection.swift; sourceTree = ""; }; 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = ""; }; 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestructiveServerTask.swift; sourceTree = ""; }; 4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionRow.swift; sourceTree = ""; }; 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = ""; }; 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronAlertButton.swift; sourceTree = ""; }; + 4EB7C8D42CCED6E1000CC011 /* AddServerUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserView.swift; sourceTree = ""; }; 4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackCompatibility.swift; sourceTree = ""; }; 4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerType.swift; sourceTree = ""; }; 4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceProfile.swift; sourceTree = ""; }; @@ -1128,6 +1141,11 @@ 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileCoordinator.swift; sourceTree = ""; }; 4EC1C8682C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileSettingsView.swift; sourceTree = ""; }; 4EC1C86C2C80903A00E2879E /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = ""; }; + 4EC2B19A2CC96E7000D866BE /* ServerUsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUsersView.swift; sourceTree = ""; }; + 4EC2B19D2CC96EA300D866BE /* ServerUsersRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUsersRow.swift; sourceTree = ""; }; + 4EC2B1A12CC96F6000D866BE /* ServerUsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUsersViewModel.swift; sourceTree = ""; }; + 4EC2B1A42CC96F9F00D866BE /* ServerUserAdminViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserAdminViewModel.swift; sourceTree = ""; }; + 4EC2B1A82CC97C0400D866BE /* ServerUserDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserDetailsView.swift; sourceTree = ""; }; 4EC50D602C934B3A00FC3D0E /* ScheduledTasksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTasksViewModel.swift; sourceTree = ""; }; 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeSection.swift; sourceTree = ""; }; 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeReason.swift; sourceTree = ""; }; @@ -1281,6 +1299,7 @@ C4E508172703E8190045C9AB /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = ""; }; DFB7C3DE2C7AA42700CE7CDC /* UserSignInState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInState.swift; sourceTree = ""; }; E1002B632793CEE700E47059 /* ChapterInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfo.swift; sourceTree = ""; }; + E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDetailViewModel.swift; sourceTree = ""; }; E10231292BCF8A08009D71FC /* iOSLiveTVCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iOSLiveTVCoordinator.swift; sourceTree = ""; }; E102312A2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVCoordinator.swift; sourceTree = ""; }; E10231302BCF8A3C009D71FC /* ProgramButtonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramButtonContent.swift; sourceTree = ""; }; @@ -1994,6 +2013,7 @@ 4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */, 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */, 4EA09DDF2CC4E4D000CB27E4 /* APIKeyView */, + 4EB7C8D32CCED318000CC011 /* AddServerUserView */, 4E35CE5B2CBED3F300DBD886 /* AddTaskTriggerView */, E1DE64902CC6F06C00E423B6 /* Components */, 4E10C80F2CC030B20012CC9F /* DeviceDetailsView */, @@ -2001,6 +2021,8 @@ 4E90F7622CC72B1F00417C31 /* EditServerTaskView */, 4E182C9A2C94991800FBEFD5 /* ServerTasksView */, 4E35CE622CBED3FF00DBD886 /* ServerLogsView */, + 4EC2B1A72CC9725400D866BE /* ServerUserDetailsView */, + 4EC2B1992CC96E5E00D866BE /* ServerUsersView */, 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */, ); path = UserDashboardView; @@ -2158,6 +2180,14 @@ path = Components; sourceTree = ""; }; + 4EB7C8D32CCED318000CC011 /* AddServerUserView */ = { + isa = PBXGroup; + children = ( + 4EB7C8D42CCED6E1000CC011 /* AddServerUserView.swift */, + ); + path = AddServerUserView; + sourceTree = ""; + }; 4EC1C86A2C80900B00E2879E /* CustomDeviceProfileSettingsView */ = { isa = PBXGroup; children = ( @@ -2176,6 +2206,31 @@ path = Components; sourceTree = ""; }; + 4EC2B1992CC96E5E00D866BE /* ServerUsersView */ = { + isa = PBXGroup; + children = ( + 4EC2B19C2CC96E9400D866BE /* Components */, + 4EC2B19A2CC96E7000D866BE /* ServerUsersView.swift */, + ); + path = ServerUsersView; + sourceTree = ""; + }; + 4EC2B19C2CC96E9400D866BE /* Components */ = { + isa = PBXGroup; + children = ( + 4EC2B19D2CC96EA300D866BE /* ServerUsersRow.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4EC2B1A72CC9725400D866BE /* ServerUserDetailsView */ = { + isa = PBXGroup; + children = ( + 4EC2B1A82CC97C0400D866BE /* ServerUserDetailsView.swift */, + ); + path = ServerUserDetailsView; + sourceTree = ""; + }; 4EED87472CBF824B002354D2 /* Components */ = { isa = PBXGroup; children = ( @@ -2229,9 +2284,11 @@ isa = PBXGroup; children = ( 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */, + 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */, 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */, E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, + E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */, 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */, E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */, E113133928BEB71D00930F75 /* FilterViewModel.swift */, @@ -2250,6 +2307,8 @@ E173DA5326D050F500CC4EB7 /* ServerConnectionViewModel.swift */, E1ED7FE12CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift */, E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */, + 4EC2B1A42CC96F9F00D866BE /* ServerUserAdminViewModel.swift */, + 4EC2B1A12CC96F6000D866BE /* ServerUsersViewModel.swift */, 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */, E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */, E14EA1682BF7330A00DE757A /* UserProfileImageViewModel.swift */, @@ -4724,6 +4783,7 @@ E1575E66293E77B5001665B1 /* Poster.swift in Sources */, E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */, E19D41B42BF2C0020082B8B2 /* StoredValues+Temp.swift in Sources */, + 4EC2B1A32CC96F6600D866BE /* ServerUsersViewModel.swift in Sources */, 4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */, E11BDF7B2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */, 4E204E592C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift in Sources */, @@ -4735,6 +4795,8 @@ E1549678296CB22B00C4EF88 /* InlineEnumToggle.swift in Sources */, E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */, E1DABAFA2A270E62008AC34A /* OverviewCard.swift in Sources */, + E101ECD62CD40489001EA89E /* DeviceDetailViewModel.swift in Sources */, + 4EA397462CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */, E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */, 4E2AC4CC2C6C494E00DD600D /* VideoCodec.swift in Sources */, 4EF18B262CB9934C00343666 /* LibraryRow.swift in Sources */, @@ -4889,6 +4951,7 @@ E1CB75732C80E71800217C76 /* DirectPlayProfile.swift in Sources */, E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */, 6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */, + 4EC2B1A22CC96F6600D866BE /* ServerUsersViewModel.swift in Sources */, E1B90C6A2BBE68D5007027C8 /* OffsetScrollView.swift in Sources */, E18E01DB288747230022598C /* iPadOSEpisodeItemView.swift in Sources */, E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */, @@ -4918,6 +4981,7 @@ 4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */, E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */, E1AEFA372BE317E200CFAFD8 /* ListRowButton.swift in Sources */, + 4EB7C8D52CCED6E7000CC011 /* AddServerUserView.swift in Sources */, E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */, E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */, E1803EA12BFBD6CF0039F90E /* Hashable.swift in Sources */, @@ -4969,6 +5033,7 @@ C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */, E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */, BD39577E2C1140810078CEF8 /* TransitionSection.swift in Sources */, + 4EC2B1A52CC96FA400D866BE /* ServerUserAdminViewModel.swift in Sources */, E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */, E11BDF772B8513B40045C54A /* ItemGenre.swift in Sources */, E16DEAC228EFCF590058F196 /* EnvironmentValue+Keys.swift in Sources */, @@ -5090,6 +5155,7 @@ E13F05F128BC9016003499D2 /* LibraryRow.swift in Sources */, 4E36395C2CC4DF0E00110EBC /* APIKeysViewModel.swift in Sources */, E168BD10289A4162001A6922 /* HomeView.swift in Sources */, + 4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */, E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */, 4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */, E1BE1CEA2BDB5AFE008176A9 /* UserGridButton.swift in Sources */, @@ -5136,6 +5202,7 @@ 4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */, E1A1528528FD191A00600579 /* TextPair.swift in Sources */, 6334175D287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift in Sources */, + 4EA397472CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */, 4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */, 4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */, E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */, @@ -5144,6 +5211,7 @@ E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */, E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */, + 4EC2B19E2CC96EAB00D866BE /* ServerUsersRow.swift in Sources */, E1C8CE7C28FF015000DF5D7B /* TrailingTimestampType.swift in Sources */, C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */, 4E17498E2CC00A3100DD07D1 /* DeviceInfo.swift in Sources */, @@ -5178,6 +5246,7 @@ E1921B7628E63306003A5238 /* GestureView.swift in Sources */, E18A8E8028D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */, E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */, + E101ECD52CD40489001EA89E /* DeviceDetailViewModel.swift in Sources */, E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */, 4E35CE5C2CBED3F300DBD886 /* TimeRow.swift in Sources */, 4E35CE5D2CBED3F300DBD886 /* TriggerTypeRow.swift in Sources */, @@ -5314,6 +5383,7 @@ E1CAF65D2BA345830087D991 /* MediaType.swift in Sources */, E1AD105F26D9ADDD003E4A08 /* NameGuidPair.swift in Sources */, E18A8E7D28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, + 4EC2B19B2CC96E7400D866BE /* ServerUsersView.swift in Sources */, E18E01F1288747230022598C /* PlayButton.swift in Sources */, E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */, E1F5CF092CB0A04500607465 /* Text.swift in Sources */, diff --git a/Swiftfin/Extensions/ButtonStyle-iOS.swift b/Swiftfin/Extensions/ButtonStyle-iOS.swift index 6cfdf19b..b582c463 100644 --- a/Swiftfin/Extensions/ButtonStyle-iOS.swift +++ b/Swiftfin/Extensions/ButtonStyle-iOS.swift @@ -12,41 +12,29 @@ import SwiftUI extension ButtonStyle where Self == ToolbarPillButtonStyle { static var toolbarPill: ToolbarPillButtonStyle { - ToolbarPillButtonStyle() + ToolbarPillButtonStyle(primary: Defaults[.accentColor], secondary: .secondary) + } + + static func toolbarPill(_ primary: Color, _ secondary: Color = Color.secondary) -> ToolbarPillButtonStyle { + ToolbarPillButtonStyle(primary: primary, secondary: secondary) } } struct ToolbarPillButtonStyle: ButtonStyle { - @Default(.accentColor) - private var accentColor - @Environment(\.isEnabled) private var isEnabled - private var foregroundStyle: some ShapeStyle { - if isEnabled { - accentColor.overlayColor - } else { - Color.secondary.overlayColor - } - } - - private var background: some ShapeStyle { - if isEnabled { - accentColor - } else { - Color.secondary - } - } + let primary: Color + let secondary: Color func makeBody(configuration: Configuration) -> some View { configuration.label - .foregroundStyle(foregroundStyle) + .foregroundStyle(isEnabled ? primary.overlayColor : secondary) .font(.headline) .padding(.vertical, 5) .padding(.horizontal, 10) - .background(background) + .background(isEnabled ? primary : secondary) .clipShape(RoundedRectangle(cornerRadius: 10)) .opacity(isEnabled && !configuration.isPressed ? 1 : 0.5) } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift index 5d1d56af..a9de34eb 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift @@ -25,10 +25,14 @@ struct ActiveSessionDetailView: View { private func idleContent(session: SessionInfo) -> some View { List { if let userID = session.userID { + let user = UserDto(id: userID, name: session.userName) + UserDashboardView.UserSection( - user: .init(id: userID, name: session.userName), + user: user, lastActivityDate: session.lastActivityDate - ) + ) { + router.route(to: \.userDetails, user) + } } UserDashboardView.DeviceSection( @@ -60,9 +64,14 @@ struct ActiveSessionDetailView: View { } if let userID = session.userID { + let user = UserDto(id: userID, name: session.userName) + UserDashboardView.UserSection( - user: .init(id: userID, name: session.userName) - ) + user: user, + lastActivityDate: session.lastPlaybackCheckIn + ) { + router.route(to: \.userDetails, user) + } } UserDashboardView.DeviceSection( diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift index 11dbb978..19c97f08 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift @@ -112,7 +112,7 @@ extension ActiveSessionsView { if let lastActivityDate = session.lastActivityDate { TextPairView( L10n.lastSeen, - value: Text(lastActivityDate, format: .relative(presentation: .numeric, unitsStyle: .narrow)) + value: Text(lastActivityDate, format: .lastSeen) ) .id(currentDate) .monospacedDigit() diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddServerUserView/AddServerUserView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddServerUserView/AddServerUserView.swift new file mode 100644 index 00000000..414f00dd --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/AddServerUserView/AddServerUserView.swift @@ -0,0 +1,152 @@ +// +// 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 Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct AddServerUserView: View { + + private enum Field: Hashable { + case username + case password + case confirmPassword + } + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var router: BasicNavigationViewCoordinator.Router + + @FocusState + private var focusedfield: Field? + + @State + private var username: String = "" + @State + private var password: String = "" + @State + private var confirmPassword: String = "" + + @State + private var error: Error? + @State + private var isPresentingError: Bool = false + @State + private var isPresentingSuccess: Bool = false + + @StateObject + private var viewModel = AddServerUserViewModel() + + private var isValid: Bool { + username.isNotEmpty && password == confirmPassword + } + + var body: some View { + List { + + Section { + TextField(L10n.username, text: $username) { + focusedfield = .password + } + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($focusedfield, equals: .username) + .disabled(viewModel.state == .creatingUser) + } header: { + Text(L10n.username) + } footer: { + if username.isEmpty { + Label(L10n.usernameRequired, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + } + + Section(L10n.password) { + UnmaskSecureField(L10n.password, text: $password) { + focusedfield = .confirmPassword + } + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($focusedfield, equals: .password) + .disabled(viewModel.state == .creatingUser) + } + + Section { + UnmaskSecureField(L10n.confirmPassword, text: $confirmPassword) {} + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($focusedfield, equals: .confirmPassword) + .disabled(viewModel.state == .creatingUser) + } header: { + Text(L10n.confirmPassword) + } footer: { + if password != confirmPassword { + Label(L10n.passwordsDoNotMatch, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + } + } + .animation(.linear(duration: 0.2), value: isValid) + .interactiveDismissDisabled(viewModel.state == .creatingUser) + .navigationTitle(L10n.newUser) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismissCoordinator() + } + .onFirstAppear { + focusedfield = .username + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + + error = eventError + isPresentingError = true + case let .createdNewUser(newUser): + UIDevice.feedback(.success) + + router.dismissCoordinator { + Notifications[.didAddServerUser].post(object: newUser) + } + } + } + .topBarTrailing { + if viewModel.state == .creatingUser { + ProgressView() + } + + if viewModel.state == .creatingUser { + Button(L10n.cancel) { + viewModel.send(.cancel) + } + .buttonStyle(.toolbarPill(.red)) + } else { + Button(L10n.save) { + viewModel.send(.createUser(username: username, password: password)) + } + .buttonStyle(.toolbarPill) + .disabled(!isValid) + } + } + .alert( + L10n.error, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .cancel) { + focusedfield = .username + } + } message: { error in + Text(error.localizedDescription) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/Components/UserSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/Components/UserSection.swift index c73d725b..2687ba73 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/Components/UserSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/Components/UserSection.swift @@ -9,8 +9,6 @@ import JellyfinAPI import SwiftUI -// TODO: if lastActivityDate not in same day, use date instead of relative - extension UserDashboardView { struct UserSection: View { @@ -20,26 +18,43 @@ extension UserDashboardView { private let user: UserDto private let lastActivityDate: Date? + private let action: (() -> Void)? - init(user: UserDto, lastActivityDate: Date? = nil) { + // MARK: - Initializer + + init(user: UserDto, lastActivityDate: Date? = nil, action: (() -> Void)? = nil) { self.user = user self.lastActivityDate = lastActivityDate + self.action = action } + // MARK: - Body + var body: some View { Section(L10n.user) { + profileView + TextPairView( + L10n.lastSeen, + value: Text(lastActivityDate, format: .lastSeen) + ) + .id(currentDate) + .monospacedDigit() + } + } + + // MARK: - Profile View + + private var profileView: some View { + if let onSelect = action { + SettingsView.UserProfileRow( + user: user + ) { + onSelect() + } + } else { SettingsView.UserProfileRow( user: user ) - - if let lastActivityDate { - TextPairView( - L10n.lastSeen, - value: Text(lastActivityDate, format: .relative(presentation: .numeric, unitsStyle: .narrow)) - ) - .id(currentDate) - .monospacedDigit() - } } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/DeviceDetailsView/DeviceDetailsView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/DeviceDetailsView/DeviceDetailsView.swift index bd68e269..85fbbfc4 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/DeviceDetailsView/DeviceDetailsView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/DeviceDetailsView/DeviceDetailsView.swift @@ -14,6 +14,9 @@ import SwiftUI struct DeviceDetailsView: View { + @EnvironmentObject + private var router: SettingsCoordinator.Router + @CurrentDate private var currentDate: Date @@ -27,28 +30,39 @@ struct DeviceDetailsView: View { private var isPresentingSuccess: Bool = false @StateObject - private var viewModel: DevicesViewModel + private var viewModel: DeviceDetailViewModel - private let device: DeviceInfo + private var device: DeviceInfo { + viewModel.device + } // MARK: - Initializer init(device: DeviceInfo) { - self.device = device + _viewModel = StateObject(wrappedValue: DeviceDetailViewModel(device: device)) + // TODO: Enable with SDK Change self.temporaryCustomName = device.name ?? "" // device.customName ?? device.name - _viewModel = StateObject(wrappedValue: DevicesViewModel(device.lastUserID)) + +// _viewModel = StateObject(wrappedValue: DevicesViewModel(device.lastUserID)) } // MARK: - Body var body: some View { List { - if let lastUserID = device.lastUserID { + if let userID = device.lastUserID, + let userName = device.lastUserName + { + + let user = UserDto(id: userID, name: userName) + UserDashboardView.UserSection( - user: .init(id: lastUserID, name: device.lastUserName), + user: user, lastActivityDate: device.dateLastActivity - ) + ) { + router.route(to: \.userDetails, user) + } } // TODO: Enable with SDK Change @@ -69,13 +83,13 @@ struct DeviceDetailsView: View { UIDevice.feedback(.error) error = eventError isPresentingError = true - case .success: + case .setCustomName: UIDevice.feedback(.success) isPresentingSuccess = true } } .topBarTrailing { - if viewModel.backgroundStates.contains(.settingCustomName) { + if viewModel.backgroundStates.contains(.updating) { ProgressView() // TODO: Enable with SDK Change diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift index fe43fc18..a8ccaea0 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift @@ -32,31 +32,9 @@ extension DevicesView { // MARK: - Observed Objects - @ObservedObject - private var box: BindingBox - - // MARK: - Actions - - private let onSelect: () -> Void - private let onDelete: () -> Void - - // MARK: - Device Mapping - - private var deviceInfo: DeviceInfo { - box.value ?? .init() - } - - // MARK: - Initializer - - init( - box: BindingBox, - onSelect: @escaping () -> Void, - onDelete: @escaping () -> Void - ) { - self.box = box - self.onSelect = onSelect - self.onDelete = onDelete - } + let device: DeviceInfo + let onSelect: () -> Void + let onDelete: () -> Void // MARK: - Label Styling @@ -71,9 +49,9 @@ extension DevicesView { @ViewBuilder private var deviceImage: some View { ZStack { - deviceInfo.device.clientColor + device.type.clientColor - Image(deviceInfo.device.image) + Image(device.type.image) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 40) @@ -95,30 +73,24 @@ extension DevicesView { HStack { VStack(alignment: .leading) { - Text(deviceInfo.name ?? L10n.unknown) + Text(device.name ?? L10n.unknown) .font(.headline) .lineLimit(2) .multilineTextAlignment(.leading) TextPairView( leading: L10n.user, - trailing: deviceInfo.lastUserName ?? L10n.unknown + trailing: device.lastUserName ?? L10n.unknown ) TextPairView( leading: L10n.client, - trailing: deviceInfo.appName ?? L10n.unknown + trailing: device.appName ?? L10n.unknown ) TextPairView( L10n.lastSeen, - value: { - if let dateLastActivity = deviceInfo.dateLastActivity { - Text(dateLastActivity, format: .relative(presentation: .numeric, unitsStyle: .narrow)) - } else { - Text(L10n.never) - } - }() + value: Text(device.dateLastActivity, format: .lastSeen) ) .id(currentDate) .monospacedDigit() diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift index cfbc2de0..276ae22c 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift @@ -8,6 +8,7 @@ import Defaults import JellyfinAPI +import OrderedCollections import SwiftUI // TODO: Replace with CustomName when Available @@ -29,13 +30,7 @@ struct DevicesView: View { private var isEditing: Bool = false @StateObject - private var viewModel: DevicesViewModel - - // MARK: - Initializer - - init(userID: String? = nil) { - _viewModel = StateObject(wrappedValue: DevicesViewModel(userID)) - } + private var viewModel = DevicesViewModel() // MARK: - Body @@ -43,11 +38,7 @@ struct DevicesView: View { ZStack { switch viewModel.state { case .content: - if viewModel.devices.isEmpty { - Text(L10n.none) - } else { - deviceListView - } + deviceListView case let .error(error): ErrorView(error: error) .onRetry { @@ -68,7 +59,19 @@ struct DevicesView: View { } } ToolbarItem(placement: .navigationBarTrailing) { - navigationBarEditView + if viewModel.devices.isNotEmpty { + navigationBarEditView + } + } + ToolbarItem(placement: .bottomBar) { + if isEditing { + Button(L10n.delete) { + isPresentingDeleteSelectionConfirmation = true + } + .buttonStyle(.toolbarPill(.red)) + .disabled(selectedDevices.isEmpty) + .frame(maxWidth: .infinity, alignment: .trailing) + } } } .onFirstAppear { @@ -103,76 +106,51 @@ struct DevicesView: View { @ViewBuilder private var deviceListView: some View { - VStack { - List { - InsetGroupedListHeader( - L10n.devices, - description: L10n.allDevicesDescription - ) { - UIApplication.shared.open(.jellyfinDocsDevices) - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .padding(.vertical, 24) + List { + InsetGroupedListHeader( + L10n.devices, + description: L10n.allDevicesDescription + ) { + UIApplication.shared.open(.jellyfinDocsDevices) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.vertical, 24) - ForEach(viewModel.devices.keys, id: \.self) { id in - if let deviceBox = viewModel.devices[id] { - DeviceRow(box: deviceBox) { - if isEditing { - if selectedDevices.contains(id) { - selectedDevices.remove(id) - } else { - selectedDevices.insert(id) - } - } else if let selectedDevice = deviceBox.value { - router.route(to: \.deviceDetails, selectedDevice) + if viewModel.devices.isEmpty { + Text(L10n.none) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .listRowSeparator(.hidden) + .listRowInsets(.zero) + } else { + ForEach(viewModel.devices, id: \.self) { device in + DeviceRow(device: device) { + guard let id = device.id else { return } + + if isEditing { + if selectedDevices.contains(id) { + selectedDevices.remove(id) + } else { + selectedDevices.insert(id) } - } onDelete: { - selectedDevices.removeAll() - selectedDevices.insert(id) - isPresentingDeleteConfirmation = true + } else { + router.route(to: \.deviceDetails, device) } - .environment(\.isEditing, isEditing) - .environment(\.isSelected, selectedDevices.contains(id)) - .listRowSeparator(.hidden) - .listRowInsets(.zero) + } onDelete: { + guard let id = device.id else { return } + + selectedDevices.removeAll() + selectedDevices.insert(id) + isPresentingDeleteConfirmation = true } + .environment(\.isEditing, isEditing) + .environment(\.isSelected, selectedDevices.contains(device.id ?? "")) + .listRowSeparator(.hidden) + .listRowInsets(.zero) } } - .listStyle(.plain) - - if isEditing { - deleteDevicesButton - .edgePadding([.bottom, .horizontal]) - } } - } - - // MARK: - Button to Delete Devices - - @ViewBuilder - private var deleteDevicesButton: some View { - Button { - isPresentingDeleteSelectionConfirmation = true - } label: { - ZStack { - Color.red - - Text(L10n.delete) - .font(.body.weight(.semibold)) - .foregroundStyle(selectedDevices.isNotEmpty ? .primary : .secondary) - - if selectedDevices.isEmpty { - Color.black - .opacity(0.5) - } - } - .clipShape(RoundedRectangle(cornerRadius: 10)) - .frame(height: 50) - .frame(maxWidth: 400) - } - .disabled(selectedDevices.isEmpty) - .buttonStyle(.plain) + .listStyle(.plain) } // MARK: - Navigation Bar Edit Content @@ -203,7 +181,7 @@ struct DevicesView: View { if isAllSelected { selectedDevices = [] } else { - selectedDevices = Set(viewModel.devices.keys) + selectedDevices = Set(viewModel.devices.compactMap(\.id)) } } .buttonStyle(.toolbarPill) diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/LastRunSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/LastRunSection.swift index e280ca13..c1c3a32d 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/LastRunSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/LastRunSection.swift @@ -29,7 +29,7 @@ extension EditServerTaskView { TextPairView( L10n.executed, - value: Text("\(endTime, format: .relative(presentation: .numeric, unitsStyle: .narrow))") + value: Text(endTime, format: .lastSeen) ) .id(currentDate) .monospacedDigit() diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift new file mode 100644 index 00000000..96fd47ac --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -0,0 +1,44 @@ +// +// 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 ServerUserDetailsView: View { + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @CurrentDate + private var currentDate: Date + + @StateObject + private var viewModel: ServerUserAdminViewModel + + // MARK: - Initializer + + init(user: UserDto) { + _viewModel = StateObject(wrappedValue: ServerUserAdminViewModel(user: user)) + } + + // MARK: - Body + + var body: some View { + List { + UserDashboardView.UserSection( + user: viewModel.user, + lastActivityDate: viewModel.user.lastActivityDate + ) + } + .navigationTitle(L10n.user) + .onAppear { + viewModel.send(.loadDetails) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ServerUsersView/Components/ServerUsersRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ServerUsersView/Components/ServerUsersRow.swift new file mode 100644 index 00000000..cae56d5f --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ServerUsersView/Components/ServerUsersRow.swift @@ -0,0 +1,178 @@ +// +// 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 Factory +import JellyfinAPI +import SwiftUI + +extension ServerUsersView { + + struct ServerUsersRow: View { + + @Injected(\.currentUserSession) + private var userSession + + @Default(.accentColor) + private var accentColor + + // MARK: - Environment Variables + + @Environment(\.colorScheme) + private var colorScheme + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + @CurrentDate + private var currentDate: Date + + private let user: UserDto + + // MARK: - Actions + + private let onSelect: () -> Void + private let onDelete: () -> Void + + // MARK: - User Status Mapping + + private var userActive: Bool { + if let isDisabled = user.policy?.isDisabled { + return !isDisabled + } else { + return false + } + } + + // MARK: - Initializer + + init( + user: UserDto, + onSelect: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + self.user = user + self.onSelect = onSelect + self.onDelete = onDelete + } + + // MARK: - Label Styling + + private var labelForegroundStyle: some ShapeStyle { + guard isEditing else { return userActive ? .primary : .secondary } + + return isSelected ? .primary : .secondary + } + + // MARK: - User Image View + + @ViewBuilder + private var userImage: some View { + ZStack { + ImageView(user.profileImageSource(client: userSession!.client)) + .pipeline(.Swiftfin.branding) + .placeholder { _ in + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + .failure { + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + .grayscale(userActive ? 0.0 : 1.0) + + if isEditing { + Color.black + .opacity(isSelected ? 0 : 0.5) + } + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + .posterShadow() + .frame(width: 60, height: 60) + } + + // MARK: - Row Content + + @ViewBuilder + private var rowContent: some View { + HStack { + VStack(alignment: .leading) { + + Text(user.name ?? L10n.unknown) + .font(.headline) + .lineLimit(2) + .multilineTextAlignment(.leading) + + TextPairView( + L10n.role, + value: { + if let isAdministrator = user.policy?.isAdministrator, + isAdministrator + { + Text(L10n.administrator) + } else { + Text(L10n.user) + } + }() + ) + + TextPairView( + L10n.lastSeen, + value: Text(user.lastActivityDate, format: .lastSeen) + ) + .id(currentDate) + .monospacedDigit() + } + .font(.subheadline) + .foregroundStyle(labelForegroundStyle, .secondary) + + Spacer() + + if isEditing, isSelected { + Image(systemName: "checkmark.circle.fill") + .resizable() + .backport + .fontWeight(.bold) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + + } else if isEditing { + Image(systemName: "circle") + .resizable() + .backport + .fontWeight(.bold) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Body + + var body: some View { + ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + userImage + } content: { + rowContent + .padding(.vertical, 8) + } + .onSelect(perform: onSelect) + .swipeActions { + Button( + L10n.delete, + systemImage: "trash", + action: onDelete + ) + .tint(.red) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ServerUsersView/ServerUsersView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ServerUsersView/ServerUsersView.swift new file mode 100644 index 00000000..b85fb5a9 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ServerUsersView/ServerUsersView.swift @@ -0,0 +1,272 @@ +// +// 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 CollectionVGrid +import Defaults +import JellyfinAPI +import SwiftUI + +struct ServerUsersView: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @State + private var isPresentingDeleteSelectionConfirmation = false + @State + private var isPresentingDeleteConfirmation = false + @State + private var isPresentingSelfDeleteError = false + @State + private var selectedUsers: Set = [] + @State + private var isEditing: Bool = false + + @State + private var isHiddenFilterActive: Bool = false + @State + private var isDisabledFilterActive: Bool = false + + @StateObject + private var viewModel = ServerUsersViewModel() + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + userListView + case let .error(error): + errorView(with: error) + case .initial: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .navigationTitle(L10n.users) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(isEditing) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if isEditing { + navigationBarSelectView + } + } + ToolbarItemGroup(placement: .topBarTrailing) { + navigationBarEditView + } + ToolbarItem(placement: .bottomBar) { + if isEditing { + Button(L10n.delete) { + isPresentingDeleteSelectionConfirmation = true + } + .buttonStyle(.toolbarPill(.red)) + .disabled(selectedUsers.isEmpty) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .onChange(of: isDisabledFilterActive) { newValue in + viewModel.send(.getUsers( + isHidden: isHiddenFilterActive, + isDisabled: newValue + )) + } + .onChange(of: isHiddenFilterActive) { newValue in + viewModel.send(.getUsers( + isHidden: newValue, + isDisabled: isDisabledFilterActive + )) + } + .onFirstAppear { + viewModel.send(.getUsers()) + } + .confirmationDialog( + L10n.deleteSelectedUsers, + isPresented: $isPresentingDeleteSelectionConfirmation, + titleVisibility: .visible + ) { + deleteSelectedUsersConfirmationActions + } message: { + Text(L10n.deleteSelectionUsersWarning) + } + .confirmationDialog( + L10n.deleteUser, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + deleteUserConfirmationActions + } message: { + Text(L10n.deleteUserWarning) + } + .alert(L10n.deleteUserFailed, isPresented: $isPresentingSelfDeleteError) { + Button(L10n.ok, role: .cancel) {} + } message: { + Text(L10n.deleteUserSelfDeletion(viewModel.userSession.user.username)) + } + .onNotification(.didAddServerUser) { notification in + let newUser = notification.object as! UserDto + viewModel.send(.appendUser(newUser)) + router.route(to: \.userDetails, newUser) + } + } + + // MARK: - User List View + + @ViewBuilder + private var userListView: some View { + List { + InsetGroupedListHeader( + L10n.users, + description: L10n.allUsersDescription + ) { + UIApplication.shared.open(.jellyfinDocsUsers) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.vertical, 24) + + if viewModel.users.isEmpty { + Text(L10n.none) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .listRowSeparator(.hidden) + .listRowInsets(.zero) + } else { + ForEach(viewModel.users, id: \.self) { user in + if let userID = user.id { + ServerUsersRow(user: user) { + if isEditing { + selectedUsers.toggle(value: userID) + } else { + router.route(to: \.userDetails, user) + } + } onDelete: { + selectedUsers.removeAll() + selectedUsers.insert(userID) + isPresentingDeleteConfirmation = true + } + .environment(\.isEditing, isEditing) + .environment(\.isSelected, selectedUsers.contains(userID)) + .listRowSeparator(.hidden) + .listRowInsets(.zero) + } + } + } + } + .listStyle(.plain) + } + + // MARK: - Error View + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.getUsers(isHidden: isHiddenFilterActive, isDisabled: isDisabledFilterActive)) + } + } + + // MARK: - Navigation Bar Edit Content + + @ViewBuilder + private var navigationBarEditView: some View { + if viewModel.backgroundStates.contains(.gettingUsers) { + ProgressView() + } + + if isEditing { + Button(isEditing ? L10n.cancel : L10n.edit) { + isEditing.toggle() + + UIDevice.impact(.light) + + if !isEditing { + selectedUsers.removeAll() + } + } + .buttonStyle(.toolbarPill) + .foregroundStyle(accentColor) + } else { + Menu(L10n.options, systemImage: "ellipsis.circle") { + Button(L10n.addUser, systemImage: "plus") { + router.route(to: \.addServerUser) + } + + if viewModel.users.isNotEmpty { + Button(L10n.editUsers, systemImage: "checkmark.circle") { + isEditing = true + } + } + + Divider() + + Section(L10n.filters) { + Toggle(L10n.hidden, systemImage: "eye.slash", isOn: $isHiddenFilterActive) + Toggle(L10n.disabled, systemImage: "person.slash", isOn: $isDisabledFilterActive) + } + } + .labelStyle(.iconOnly) + .backport + .fontWeight(.semibold) + } + } + + // MARK: - Navigation Bar Select/Remove All Content + + @ViewBuilder + private var navigationBarSelectView: some View { + + let isAllSelected: Bool = selectedUsers.count == viewModel.users.count + + Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { + if isAllSelected { + selectedUsers = [] + } else { + selectedUsers = Set(viewModel.users.compactMap(\.id)) + } + } + .buttonStyle(.toolbarPill) + .disabled(!isEditing) + .foregroundStyle(accentColor) + } + + // MARK: - Delete Selected Users Confirmation Actions + + @ViewBuilder + private var deleteSelectedUsersConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.confirm, role: .destructive) { + viewModel.send(.deleteUsers(Array(selectedUsers))) + isEditing = false + selectedUsers.removeAll() + } + } + + // MARK: - Delete User Confirmation Actions + + @ViewBuilder + private var deleteUserConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + if let userToDelete = selectedUsers.first, selectedUsers.count == 1 { + if userToDelete == viewModel.userSession.user.id { + isPresentingSelfDeleteError = true + } else { + viewModel.send(.deleteUsers([userToDelete])) + selectedUsers.removeAll() + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift index b13fbd5f..3670631d 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift @@ -28,11 +28,15 @@ struct UserDashboardView: View { router.route(to: \.activeSessions) } - Section("Activity") { + Section(L10n.activity) { ChevronButton(L10n.devices) .onSelect { router.route(to: \.devices) } + ChevronButton(L10n.users) + .onSelect { + router.route(to: \.users) + } } Section(L10n.advanced) { diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index c2c5dffe..d129671d 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -920,6 +920,41 @@ // Used to select all items in selection mode "selectAll" = "Select All"; +// Users - Section +// Admin Dashboard Section with all Server Users +// Used as a header and a button for All Users +"users" = "Users"; + +// Active - Label +// Indication whether an item is active or inactive +// Used as a User describer and a button for All Users +"active" = "Active"; + +// All Users Description - Section Description +// Description for the all users section in the Admin Dashboard +// Provides information about the users on the server +"allUsersDescription" = "View and manage all registered users on the server, including their permissions and activity status."; + +// Role - Label +// Represents the role of the user +// Shown in user information +"role" = "Role"; + +// Administrator - Title +// Label for administrator role +// Indicates the user is an admin +"administrator" = "Administrator"; + +// User - Title +// Label for non-administrator users +// Indicates the user is a standard user +"user" = "User"; + +// Activity - Label +// Represents user activity status +// Shown in user information +"activity" = "Activity"; + // Logs Description - View // Access the Jellyfin server logs for troubleshooting and monitoring purposes // Describes the logs view in settings @@ -1098,9 +1133,83 @@ // Appears in the views with eventful to indicate a task did not fail "success" = "Success"; + +// Trigger Already Exists - +// Message to indicate that a Task Trigger already exists +// Appears in AddServerTask when there is an existing task with the same configuration "triggerAlreadyExists" = "Trigger already exists"; // Add API Key - Button // Creates an API Key if there are no keys available // Appears in place of the API Key list if there are no API Keys "addAPIKey" = "Add API key"; + +// Hidden - Filter +// Users with a policy of isHidden == True +// Appears on the ServerUsersView to filter Hidden vs Non-Hidden Users +"hidden" = "Hidden"; + +// Delete Selected Users Warning - Warning Message +// Warning message displayed when deleting all users +// Informs the user about the consequences of deleting all users +"deleteSelectionUsersWarning" = "Are you sure you wish to delete all selected users?"; + +// Delete User Warning - Warning Message +// Warning message displayed when deleting a single user +// Informs the user about the consequences of deleting the user +"deleteUserWarning" = "Are you sure you wish to delete this user?"; + +// Delete User - Action +// Message for deleting a single device in the all users section +// Used in the confirmation dialog to delete a single user +"deleteUser" = "Delete User"; + +// Delete User Self-Deletion - Error Message +// Error message when attempting to delete the current session's user +// Used to inform the user that they cannot delete their own user +"deleteUserSelfDeletion" = "Cannot delete a user from the same user (%1$@)."; + +// Delete Selected Users - Button +// Button label for deleting all selected users +// Used in the all devices section to delete all selected users +"deleteSelectedUsers" = "Delete Selected Users"; + +// Delete User Failed - Error Title +// Title for the alert when users deletion fails +// Displayed when the system fails to delete a user +"deleteUserFailed" = "Failed to Delete User"; + +// Confirm Password - TextField +// Placeholder and label for confirming the password +// Used in the New User creation form +"confirmPassword" = "Confirm Password"; + +// A username is required - Footer +// Validation message shown when the username field is empty +// Used in the New User creation form +"usernameRequired" = "A username is required"; + +// New passwords do not match - Footer +// Validation message shown when the new password and confirm password fields do not match +// Used in the New User creation form +"passwordsDoNotMatch" = "New passwords do not match"; + +// New User - Title +// Title for the new user creation view +// Used as the navigation title when creating a new user +"newUser" = "New User"; + +// Options - Menu +// Menu title for additional actions +// Used as the label for the options menu in the navigation bar +"options" = "Options"; + +// Add User - Button +// Button title to add a new user +// Used as the button label in the options menu +"addUser" = "Add User"; + +// Edit Users - Button +// Button title to edit existing users +// Used as the button label in the options menu when there are users to edit +"editUsers" = "Edit Users";