[iOS] Admin Dashboard - Users (#1287)
Some checks failed
Build 🔨 / Build 🔨 (Swiftfin tvOS) (push) Has been cancelled
Build 🔨 / Build 🔨 (Swiftfin) (push) Has been cancelled

This commit is contained in:
Joe 2024-10-31 15:56:00 -06:00 committed by GitHub
parent 9e119017db
commit e0990e321a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1731 additions and 258 deletions

View File

@ -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<CustomDeviceProfile>)
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator>
{
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<BasicNavigationViewCoordinator> {
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)

View File

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

View File

@ -11,7 +11,7 @@ import JellyfinAPI
extension DeviceInfo {
var device: DeviceType {
var type: DeviceType {
DeviceType(
client: appName,
deviceName: name

View File

@ -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`.
///

View File

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

View File

@ -88,4 +88,6 @@ extension Notifications.Key {
static let didChangeUserProfileImage = NotificationKey("didChangeUserProfileImage")
static let didStartPlayback = NotificationKey("didStartPlayback")
static let didAddServerUser = NotificationKey("didStartPlayback")
}

View File

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

View File

@ -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<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
@Published
final var state: State = .initial
private var userTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .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
}
}

View File

@ -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<BackgroundState> = []
@Published
var state: State = .initial
@Published
private(set) var device: DeviceInfo
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
private var eventSubject: PassthroughSubject<Event, Never> = .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
}
}
}

View File

@ -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<BackgroundState> = []
@Published
final var devices: [DeviceInfo] = []
@Published
final var state: State = .initial
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
@Published
final var backgroundStates: OrderedSet<BackgroundState> = []
@Published
final var devices: OrderedDictionary<String, BindingBox<DeviceInfo?>> = [:]
@Published
final var state: State = .initial
@Published
private(set) var userID: String?
private var deviceTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .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<DeviceInfo?>(
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()
}
}

View File

@ -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<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
@Published
final var backgroundStates: OrderedSet<BackgroundState> = []
@Published
final var state: State = .initial
@Published
private(set) var user: UserDto
private var resetTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .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
}
}
}

View File

@ -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<BackgroundState> = []
@Published
final var users: [UserDto] = []
@Published
final var state: State = .initial
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
private var userTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .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)
}
}
}

View File

@ -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 = "<group>"; };
4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysView.swift; sourceTree = "<group>"; };
4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysRow.swift; sourceTree = "<group>"; };
4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserViewModel.swift; sourceTree = "<group>"; };
4EB1404B2C8E45B1008691F3 /* StreamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSection.swift; sourceTree = "<group>"; };
4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = "<group>"; };
4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestructiveServerTask.swift; sourceTree = "<group>"; };
4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionRow.swift; sourceTree = "<group>"; };
4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = "<group>"; };
4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronAlertButton.swift; sourceTree = "<group>"; };
4EB7C8D42CCED6E1000CC011 /* AddServerUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserView.swift; sourceTree = "<group>"; };
4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackCompatibility.swift; sourceTree = "<group>"; };
4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerType.swift; sourceTree = "<group>"; };
4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceProfile.swift; sourceTree = "<group>"; };
@ -1128,6 +1141,11 @@
4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileCoordinator.swift; sourceTree = "<group>"; };
4EC1C8682C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileSettingsView.swift; sourceTree = "<group>"; };
4EC1C86C2C80903A00E2879E /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = "<group>"; };
4EC2B19A2CC96E7000D866BE /* ServerUsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUsersView.swift; sourceTree = "<group>"; };
4EC2B19D2CC96EA300D866BE /* ServerUsersRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUsersRow.swift; sourceTree = "<group>"; };
4EC2B1A12CC96F6000D866BE /* ServerUsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUsersViewModel.swift; sourceTree = "<group>"; };
4EC2B1A42CC96F9F00D866BE /* ServerUserAdminViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserAdminViewModel.swift; sourceTree = "<group>"; };
4EC2B1A82CC97C0400D866BE /* ServerUserDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserDetailsView.swift; sourceTree = "<group>"; };
4EC50D602C934B3A00FC3D0E /* ScheduledTasksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTasksViewModel.swift; sourceTree = "<group>"; };
4EC6C16A2C92999800FC904B /* TranscodeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeSection.swift; sourceTree = "<group>"; };
4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeReason.swift; sourceTree = "<group>"; };
@ -1281,6 +1299,7 @@
C4E508172703E8190045C9AB /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = "<group>"; };
DFB7C3DE2C7AA42700CE7CDC /* UserSignInState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInState.swift; sourceTree = "<group>"; };
E1002B632793CEE700E47059 /* ChapterInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfo.swift; sourceTree = "<group>"; };
E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDetailViewModel.swift; sourceTree = "<group>"; };
E10231292BCF8A08009D71FC /* iOSLiveTVCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iOSLiveTVCoordinator.swift; sourceTree = "<group>"; };
E102312A2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVCoordinator.swift; sourceTree = "<group>"; };
E10231302BCF8A3C009D71FC /* ProgramButtonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramButtonContent.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
};
4EB7C8D32CCED318000CC011 /* AddServerUserView */ = {
isa = PBXGroup;
children = (
4EB7C8D42CCED6E1000CC011 /* AddServerUserView.swift */,
);
path = AddServerUserView;
sourceTree = "<group>";
};
4EC1C86A2C80900B00E2879E /* CustomDeviceProfileSettingsView */ = {
isa = PBXGroup;
children = (
@ -2176,6 +2206,31 @@
path = Components;
sourceTree = "<group>";
};
4EC2B1992CC96E5E00D866BE /* ServerUsersView */ = {
isa = PBXGroup;
children = (
4EC2B19C2CC96E9400D866BE /* Components */,
4EC2B19A2CC96E7000D866BE /* ServerUsersView.swift */,
);
path = ServerUsersView;
sourceTree = "<group>";
};
4EC2B19C2CC96E9400D866BE /* Components */ = {
isa = PBXGroup;
children = (
4EC2B19D2CC96EA300D866BE /* ServerUsersRow.swift */,
);
path = Components;
sourceTree = "<group>";
};
4EC2B1A72CC9725400D866BE /* ServerUserDetailsView */ = {
isa = PBXGroup;
children = (
4EC2B1A82CC97C0400D866BE /* ServerUserDetailsView.swift */,
);
path = ServerUserDetailsView;
sourceTree = "<group>";
};
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 */,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,31 +32,9 @@ extension DevicesView {
// MARK: - Observed Objects
@ObservedObject
private var box: BindingBox<DeviceInfo?>
// 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<DeviceInfo?>,
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()

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String> = []
@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()
}
}
}
}
}

View File

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

View File

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