[iOS] Admin Dashboard - QuickConnect Other User (#1488)

* Allow other user authorization.

* Show user being logged in.

* Fix localizations & update screenshot

* Cleanup Locales

* mirror lable changes on tvOS

* cleanup

* fix strings

* adjust sizes

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe Kribs 2025-04-14 15:19:32 -06:00 committed by GitHub
parent 93033262d6
commit 8c4fde87f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 88 additions and 32 deletions

View File

@ -71,6 +71,8 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
var userLiveTVAccess = makeUserLiveTVAccess var userLiveTVAccess = makeUserLiveTVAccess
@Route(.modal) @Route(.modal)
var userPermissions = makeUserPermissions var userPermissions = makeUserPermissions
@Route(.push)
var quickConnectAuthorize = makeQuickConnectAuthorize
@Route(.modal) @Route(.modal)
var userParentalRatings = makeUserParentalRatings var userParentalRatings = makeUserParentalRatings
@Route(.modal) @Route(.modal)
@ -234,6 +236,11 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
} }
} }
@ViewBuilder
func makeQuickConnectAuthorize(user: UserDto) -> some View {
QuickConnectAuthorizeView(user: user)
}
// MARK: - Views: API Keys // MARK: - Views: API Keys
@ViewBuilder @ViewBuilder

View File

@ -112,8 +112,8 @@ final class SettingsCoordinator: NavigationCoordinatable {
} }
@ViewBuilder @ViewBuilder
func makeQuickConnectAuthorize() -> some View { func makeQuickConnectAuthorize(user: UserDto) -> some View {
QuickConnectAuthorizeView() QuickConnectAuthorizeView(user: user)
} }
func makeResetUserPassword(userID: String) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> { func makeResetUserPassword(userID: String) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {

View File

@ -94,14 +94,8 @@ internal enum L10n {
internal static let allLanguages = L10n.tr("Localizable", "allLanguages", fallback: "All languages") internal static let allLanguages = L10n.tr("Localizable", "allLanguages", fallback: "All languages")
/// All Media /// All Media
internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media") internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media")
/// Allow collection management
internal static let allowCollectionManagement = L10n.tr("Localizable", "allowCollectionManagement", fallback: "Allow collection management")
/// Allowed /// Allowed
internal static let allowed = L10n.tr("Localizable", "allowed", fallback: "Allowed") internal static let allowed = L10n.tr("Localizable", "allowed", fallback: "Allowed")
/// Allow media item deletion
internal static let allowItemDeletion = L10n.tr("Localizable", "allowItemDeletion", fallback: "Allow media item deletion")
/// Allow media item editing
internal static let allowItemEditing = L10n.tr("Localizable", "allowItemEditing", fallback: "Allow media item editing")
/// All Servers /// All Servers
internal static let allServers = L10n.tr("Localizable", "allServers", fallback: "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. /// View and manage all registered users on the server, including their permissions and activity status.
@ -440,6 +434,8 @@ internal enum L10n {
internal static let deleteItemConfirmation = L10n.tr("Localizable", "deleteItemConfirmation", fallback: "Are you sure you want to delete this item?") internal static let deleteItemConfirmation = L10n.tr("Localizable", "deleteItemConfirmation", fallback: "Are you sure you want to delete this item?")
/// Are you sure you want to delete this item? This action cannot be undone. /// Are you sure you want to delete this item? This action cannot be undone.
internal static let deleteItemConfirmationMessage = L10n.tr("Localizable", "deleteItemConfirmationMessage", fallback: "Are you sure you want to delete this item? This action cannot be undone.") internal static let deleteItemConfirmationMessage = L10n.tr("Localizable", "deleteItemConfirmationMessage", fallback: "Are you sure you want to delete this item? This action cannot be undone.")
/// Delete media
internal static let deleteMedia = L10n.tr("Localizable", "deleteMedia", fallback: "Delete media")
/// Delete Schedule /// Delete Schedule
internal static let deleteSchedule = L10n.tr("Localizable", "deleteSchedule", fallback: "Delete Schedule") internal static let deleteSchedule = L10n.tr("Localizable", "deleteSchedule", fallback: "Delete Schedule")
/// Are you sure you wish to delete this schedule? /// Are you sure you wish to delete this schedule?
@ -544,6 +540,10 @@ internal enum L10n {
internal static let dvd = L10n.tr("Localizable", "dvd", fallback: "DVD") internal static let dvd = L10n.tr("Localizable", "dvd", fallback: "DVD")
/// Edit /// Edit
internal static let edit = L10n.tr("Localizable", "edit", fallback: "Edit") internal static let edit = L10n.tr("Localizable", "edit", fallback: "Edit")
/// Edit Collections
internal static let editCollections = L10n.tr("Localizable", "editCollections", fallback: "Edit Collections")
/// Edit media
internal static let editMedia = L10n.tr("Localizable", "editMedia", fallback: "Edit media")
/// Editor /// Editor
internal static let editor = L10n.tr("Localizable", "editor", fallback: "Editor") internal static let editor = L10n.tr("Localizable", "editor", fallback: "Editor")
/// Edit Server /// Edit Server
@ -1056,6 +1056,8 @@ internal enum L10n {
internal static let quickConnectStep3 = L10n.tr("Localizable", "quickConnectStep3", fallback: "Enter the following code:") internal static let quickConnectStep3 = L10n.tr("Localizable", "quickConnectStep3", fallback: "Enter the following code:")
/// Authorizing Quick Connect successful. Please continue on your other device. /// Authorizing Quick Connect successful. Please continue on your other device.
internal static let quickConnectSuccessMessage = L10n.tr("Localizable", "quickConnectSuccessMessage", fallback: "Authorizing Quick Connect successful. Please continue on your other device.") internal static let quickConnectSuccessMessage = L10n.tr("Localizable", "quickConnectSuccessMessage", fallback: "Authorizing Quick Connect successful. Please continue on your other device.")
/// This user will be authenticated to the other device.
internal static let quickConnectUserDisclaimer = L10n.tr("Localizable", "quickConnectUserDisclaimer", fallback: "This user will be authenticated to the other device.")
/// Random /// Random
internal static let random = L10n.tr("Localizable", "random", fallback: "Random") internal static let random = L10n.tr("Localizable", "random", fallback: "Random")
/// Random image /// Random image

View File

@ -22,7 +22,7 @@ final class QuickConnectAuthorizeViewModel: ViewModel, Eventful, Stateful {
// MARK: Action // MARK: Action
enum Action: Equatable { enum Action: Equatable {
case authorize(String) case authorize(code: String)
case cancel case cancel
} }
@ -45,6 +45,12 @@ final class QuickConnectAuthorizeViewModel: ViewModel, Eventful, Stateful {
private var authorizeTask: AnyCancellable? private var authorizeTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .init() private var eventSubject: PassthroughSubject<Event, Never> = .init()
let user: UserDto
init(user: UserDto) {
self.user = user
}
func respond(to action: Action) -> State { func respond(to action: Action) -> State {
switch action { switch action {
case let .authorize(code): case let .authorize(code):
@ -53,7 +59,7 @@ final class QuickConnectAuthorizeViewModel: ViewModel, Eventful, Stateful {
try? await Task.sleep(nanoseconds: 10_000_000_000) try? await Task.sleep(nanoseconds: 10_000_000_000)
do { do {
try await authorize(code: code) try await authorize(code: code, userID: user.id)
await MainActor.run { await MainActor.run {
self.eventSubject.send(.authorized) self.eventSubject.send(.authorized)
@ -76,8 +82,8 @@ final class QuickConnectAuthorizeViewModel: ViewModel, Eventful, Stateful {
} }
} }
private func authorize(code: String) async throws { private func authorize(code: String, userID: String? = nil) async throws {
let request = Paths.authorize(code: code) let request = Paths.authorizeQuickConnect(code: code, userID: userID)
let response = try await userSession.client.send(request) let response = try await userSession.client.send(request)
let decoder = JSONDecoder() let decoder = JSONDecoder()

View File

@ -43,15 +43,15 @@ extension CustomizeViewsSettings {
/// Enable Refreshing Items from All Visible LIbraries /// Enable Refreshing Items from All Visible LIbraries
if userSession?.user.permissions.items.canEditMetadata ?? false { if userSession?.user.permissions.items.canEditMetadata ?? false {
Toggle(L10n.allowItemEditing, isOn: $enableItemEditing) Toggle(L10n.editMedia, isOn: $enableItemEditing)
} }
/// Enable Deleting Items from Approved Libraries /// Enable Deleting Items from Approved Libraries
if userSession?.user.permissions.items.canDelete ?? false { if userSession?.user.permissions.items.canDelete ?? false {
Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion) Toggle(L10n.deleteMedia, isOn: $enableItemDeletion)
} }
/// Enable Refreshing & Deleting Collections /// Enable Refreshing & Deleting Collections
if userSession?.user.permissions.items.canManageCollections ?? false { if userSession?.user.permissions.items.canManageCollections ?? false {
Toggle(L10n.allowCollectionManagement, isOn: $enableCollectionManagement) Toggle(L10n.editCollections, isOn: $enableCollectionManagement)
} }
} }
} }

View File

@ -44,7 +44,7 @@ private struct ListRowButtonStyle: ButtonStyle {
} }
private func secondaryStyle(configuration: Configuration) -> some ShapeStyle { private func secondaryStyle(configuration: Configuration) -> some ShapeStyle {
if configuration.role == .destructive { if configuration.role == .destructive || configuration.role == .cancel {
return AnyShapeStyle(Color.red.opacity(0.2)) return AnyShapeStyle(Color.red.opacity(0.2))
} else { } else {
return isEnabled ? AnyShapeStyle(HierarchicalShapeStyle.secondary) : AnyShapeStyle(Color.gray) return isEnabled ? AnyShapeStyle(HierarchicalShapeStyle.secondary) : AnyShapeStyle(Color.gray)

View File

@ -81,13 +81,16 @@ struct ServerUserDetailsView: View {
} }
} }
} }
ChevronButton(L10n.permissions) {
router.route(to: \.userPermissions, viewModel)
}
if let userId = viewModel.user.id { if let userId = viewModel.user.id {
ChevronButton(L10n.password) { ChevronButton(L10n.password) {
router.route(to: \.resetUserPassword, userId) router.route(to: \.resetUserPassword, userId)
} }
ChevronButton(L10n.quickConnect) {
router.route(to: \.quickConnectAuthorize, viewModel.user)
} }
ChevronButton(L10n.permissions) {
router.route(to: \.userPermissions, viewModel)
} }
} }

View File

@ -46,19 +46,19 @@ extension CustomizeViewsSettings {
/// Enable Editing Items from All Visible LIbraries /// Enable Editing Items from All Visible LIbraries
if userSession?.user.permissions.items.canEditMetadata ?? false { if userSession?.user.permissions.items.canEditMetadata ?? false {
Toggle(L10n.allowItemEditing, isOn: $enableItemEditing) Toggle(L10n.editMedia, isOn: $enableItemEditing)
} }
/// Enable Deleting Items from Approved Libraries /// Enable Deleting Items from Approved Libraries
if userSession?.user.permissions.items.canDelete ?? false { if userSession?.user.permissions.items.canDelete ?? false {
Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion) Toggle(L10n.deleteMedia, isOn: $enableItemDeletion)
} }
/// Enable Downloading All Items /// Enable Downloading All Items
/* if userSession?.user.permissions.items.canDownload ?? false { /* if userSession?.user.permissions.items.canDownload ?? false {
Toggle(L10n.allowItemDownloading, isOn: $enableItemDownloads) Toggle(L10n.itemDownloading, isOn: $enableItemDownloads)
} */ } */
/// Enable Deleting or Editing Collections /// Enable Deleting or Editing Collections
if userSession?.user.permissions.items.canManageCollections ?? false { if userSession?.user.permissions.items.canManageCollections ?? false {
Toggle(L10n.allowCollectionManagement, isOn: $enableCollectionManagement) Toggle(L10n.editCollections, isOn: $enableCollectionManagement)
} }
/// Manage Item Lyrics /// Manage Item Lyrics
/* if userSession?.user.permissions.items.canManageLyrics ?? false { /* if userSession?.user.permissions.items.canManageLyrics ?? false {

View File

@ -8,10 +8,16 @@
import Defaults import Defaults
import Foundation import Foundation
import JellyfinAPI
import SwiftUI import SwiftUI
struct QuickConnectAuthorizeView: View { struct QuickConnectAuthorizeView: View {
// MARK: - Dismiss Environment
@Environment(\.dismiss)
private var dismiss
// MARK: - Defaults // MARK: - Defaults
@Default(.accentColor) @Default(.accentColor)
@ -24,11 +30,8 @@ struct QuickConnectAuthorizeView: View {
// MARK: - State & Environment Objects // MARK: - State & Environment Objects
@EnvironmentObject
private var router: SettingsCoordinator.Router
@StateObject @StateObject
private var viewModel = QuickConnectAuthorizeViewModel() private var viewModel: QuickConnectAuthorizeViewModel
// MARK: - Quick Connect Variables // MARK: - Quick Connect Variables
@ -45,10 +48,46 @@ struct QuickConnectAuthorizeView: View {
@State @State
private var error: Error? = nil private var error: Error? = nil
// MARK: - Initialize
init(user: UserDto) {
self._viewModel = StateObject(wrappedValue: QuickConnectAuthorizeViewModel(user: user))
}
// MARK: Display the User Being Authenticated
@ViewBuilder
private var loginUserRow: some View {
HStack {
UserProfileImage(
userID: viewModel.user.id,
source: viewModel.user.profileImageSource(
client: viewModel.userSession.client,
maxWidth: 120
)
)
.frame(width: 50, height: 50)
Text(viewModel.user.name ?? L10n.unknown)
.fontWeight(.semibold)
.foregroundStyle(.primary)
Spacer()
}
}
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
Form { Form {
Section {
loginUserRow
} header: {
Text(L10n.user)
} footer: {
Text(L10n.quickConnectUserDisclaimer)
}
Section { Section {
TextField(L10n.quickConnectCode, text: $code) TextField(L10n.quickConnectCode, text: $code)
.keyboardType(.numberPad) .keyboardType(.numberPad)
@ -59,14 +98,13 @@ struct QuickConnectAuthorizeView: View {
} }
if viewModel.state == .authorizing { if viewModel.state == .authorizing {
ListRowButton(L10n.cancel) { ListRowButton(L10n.cancel, role: .cancel) {
viewModel.send(.cancel) viewModel.send(.cancel)
isCodeFocused = true isCodeFocused = true
} }
.foregroundStyle(.red, .red.opacity(0.2))
} else { } else {
ListRowButton(L10n.authorize) { ListRowButton(L10n.authorize) {
viewModel.send(.authorize(code)) viewModel.send(.authorize(code: code))
} }
.disabled(code.count != 6 || viewModel.state == .authorizing) .disabled(code.count != 6 || viewModel.state == .authorizing)
.foregroundStyle( .foregroundStyle(
@ -107,7 +145,7 @@ struct QuickConnectAuthorizeView: View {
isPresented: $isPresentingSuccess isPresented: $isPresentingSuccess
) { ) {
Button(L10n.dismiss, role: .cancel) { Button(L10n.dismiss, role: .cancel) {
router.pop() dismiss()
} }
} message: { } message: {
L10n.quickConnectSuccessMessage.text L10n.quickConnectSuccessMessage.text

View File

@ -45,7 +45,7 @@ struct UserProfileSettingsView: View {
Section { Section {
ChevronButton(L10n.quickConnect) { ChevronButton(L10n.quickConnect) {
router.route(to: \.quickConnect) router.route(to: \.quickConnect, viewModel.userSession.user.data)
} }
ChevronButton(L10n.password) { ChevronButton(L10n.password) {