[iOS] Admin Dashboard - User Access Tags (#1377)
* Edit View. Still need to make an Add View * Finished with EditPage. Need labels tho * Deletion deletes TOO many records. Also, need to search existing tags * Fin * Fix merge issues * Check for exisitng Access Tags before allowing saving * 2025 Disclaimer / Build Fixes * update * Update EditServerUserAccessTagsView.swift --------- Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
parent
adec8de122
commit
a13f604be0
|
@ -72,6 +72,10 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
|
|||
var userEditAccessSchedules = makeUserEditAccessSchedules
|
||||
@Route(.modal)
|
||||
var userAddAccessSchedule = makeUserAddAccessSchedule
|
||||
@Route(.push)
|
||||
var userEditAccessTags = makeUserEditAccessTags
|
||||
@Route(.modal)
|
||||
var userAddAccessTag = makeUserAddAccessTag
|
||||
@Route(.modal)
|
||||
var userPhotoPicker = makeUserPhotoPicker
|
||||
|
||||
|
@ -182,6 +186,17 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
|
|||
EditAccessScheduleView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeUserEditAccessTags(viewModel: ServerUserAdminViewModel) -> some View {
|
||||
EditServerUserAccessTagsView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func makeUserAddAccessTag(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
AddServerUserAccessTagsView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
func makeUserAddAccessSchedule(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
AddAccessScheduleView(viewModel: viewModel)
|
||||
|
|
|
@ -26,6 +26,16 @@ internal enum L10n {
|
|||
internal static let accessSchedules = L10n.tr("Localizable", "accessSchedules", fallback: "Access Schedules")
|
||||
/// Define the allowed hours for usage and restrict access outside those times.
|
||||
internal static let accessSchedulesDescription = L10n.tr("Localizable", "accessSchedulesDescription", fallback: "Define the allowed hours for usage and restrict access outside those times.")
|
||||
/// User will have access to no media unless it contains at least one allowed tag.
|
||||
internal static let accessTagAllowDescription = L10n.tr("Localizable", "accessTagAllowDescription", fallback: "User will have access to no media unless it contains at least one allowed tag.")
|
||||
/// Access tag already exists
|
||||
internal static let accessTagAlreadyExists = L10n.tr("Localizable", "accessTagAlreadyExists", fallback: "Access tag already exists")
|
||||
/// User will have access to all media except when it contains any blocked tag.
|
||||
internal static let accessTagBlockDescription = L10n.tr("Localizable", "accessTagBlockDescription", fallback: "User will have access to all media except when it contains any blocked tag.")
|
||||
/// Access Tags
|
||||
internal static let accessTags = L10n.tr("Localizable", "accessTags", fallback: "Access Tags")
|
||||
/// Use tags to grant or restrict this user's access to media.
|
||||
internal static let accessTagsDescription = L10n.tr("Localizable", "accessTagsDescription", fallback: "Use tags to grant or restrict this user's access to media.")
|
||||
/// Active
|
||||
internal static let active = L10n.tr("Localizable", "active", fallback: "Active")
|
||||
/// Activity
|
||||
|
@ -34,8 +44,10 @@ internal enum L10n {
|
|||
internal static let actor = L10n.tr("Localizable", "actor", fallback: "Actor")
|
||||
/// Add
|
||||
internal static let add = L10n.tr("Localizable", "add", fallback: "Add")
|
||||
/// Add Access Schedule
|
||||
internal static let addAccessSchedule = L10n.tr("Localizable", "addAccessSchedule", fallback: "Add Access Schedule")
|
||||
/// Add access schedule
|
||||
internal static let addAccessSchedule = L10n.tr("Localizable", "addAccessSchedule", fallback: "Add access schedule")
|
||||
/// Add access tag
|
||||
internal static let addAccessTag = L10n.tr("Localizable", "addAccessTag", fallback: "Add access tag")
|
||||
/// Add API key
|
||||
internal static let addAPIKey = L10n.tr("Localizable", "addAPIKey", fallback: "Add API key")
|
||||
/// Additional security access for users signed in to this device. This does not change any Jellyfin server user settings.
|
||||
|
@ -74,6 +86,8 @@ internal enum L10n {
|
|||
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
|
||||
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
|
||||
|
@ -198,6 +212,8 @@ internal enum L10n {
|
|||
internal static let bitrateTestDisclaimer = L10n.tr("Localizable", "bitrateTestDisclaimer", fallback: "Longer tests are more accurate but may result in a delayed playback.")
|
||||
/// bps
|
||||
internal static let bitsPerSecond = L10n.tr("Localizable", "bitsPerSecond", fallback: "bps")
|
||||
/// Blocked
|
||||
internal static let blocked = L10n.tr("Localizable", "blocked", fallback: "Blocked")
|
||||
/// Block unrated items
|
||||
internal static let blockUnratedItems = L10n.tr("Localizable", "blockUnratedItems", fallback: "Block unrated items")
|
||||
/// Block items from this user with no or unrecognized rating information.
|
||||
|
@ -1202,6 +1218,8 @@ internal enum L10n {
|
|||
internal static let syncPlay = L10n.tr("Localizable", "syncPlay", fallback: "SyncPlay")
|
||||
/// System
|
||||
internal static let system = L10n.tr("Localizable", "system", fallback: "System")
|
||||
/// Tag
|
||||
internal static let tag = L10n.tr("Localizable", "tag", fallback: "Tag")
|
||||
/// Tagline
|
||||
internal static let tagline = L10n.tr("Localizable", "tagline", fallback: "Tagline")
|
||||
/// Taglines
|
||||
|
|
|
@ -279,6 +279,8 @@ class ItemEditorViewModel<Element: Equatable>: ViewModel, Stateful, Eventful {
|
|||
|
||||
// MARK: - Reorder Elements (To Be Overridden)
|
||||
|
||||
// TODO: should instead move to an index-based self insertion
|
||||
// instead of replacement
|
||||
func reorderComponents(_ tags: [Element]) async throws {
|
||||
fatalError("This method should be overridden in subclasses")
|
||||
}
|
||||
|
|
|
@ -232,6 +232,11 @@
|
|||
4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B292CB993AD00343666 /* ListRow.swift */; };
|
||||
4EF3D80B2CF7D6670081AD20 /* ServerUserAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */; };
|
||||
4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */; };
|
||||
4EFAC12C2D1E255900E40880 /* EditServerUserAccessTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFAC12B2D1E255600E40880 /* EditServerUserAccessTagsView.swift */; };
|
||||
4EFAC1302D1E2EB900E40880 /* EditAccessTagRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFAC12E2D1E2EB900E40880 /* EditAccessTagRow.swift */; };
|
||||
4EFAC1332D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFAC1322D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift */; };
|
||||
4EFAC1362D1FB1A100E40880 /* AccessTagSearchResultsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFAC1352D1FB1A100E40880 /* AccessTagSearchResultsSection.swift */; };
|
||||
4EFAC1382D1FB26600E40880 /* TagInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFAC1372D1FB26600E40880 /* TagInput.swift */; };
|
||||
4EFD172E2CE4182200A4BAC5 /* LearnMoreButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */; };
|
||||
4EFE0C7D2D0156A900D4834D /* PersonKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */; };
|
||||
4EFE0C7E2D0156A900D4834D /* PersonKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */; };
|
||||
|
@ -1361,6 +1366,11 @@
|
|||
4EF18B292CB993AD00343666 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = "<group>"; };
|
||||
4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserAccessView.swift; sourceTree = "<group>"; };
|
||||
4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionMenu.swift; sourceTree = "<group>"; };
|
||||
4EFAC12B2D1E255600E40880 /* EditServerUserAccessTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServerUserAccessTagsView.swift; sourceTree = "<group>"; };
|
||||
4EFAC12E2D1E2EB900E40880 /* EditAccessTagRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessTagRow.swift; sourceTree = "<group>"; };
|
||||
4EFAC1322D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserAccessTagsView.swift; sourceTree = "<group>"; };
|
||||
4EFAC1352D1FB1A100E40880 /* AccessTagSearchResultsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessTagSearchResultsSection.swift; sourceTree = "<group>"; };
|
||||
4EFAC1372D1FB26600E40880 /* TagInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagInput.swift; sourceTree = "<group>"; };
|
||||
4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreButton.swift; sourceTree = "<group>"; };
|
||||
4EFE0C7C2D0156A500D4834D /* PersonKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonKind.swift; sourceTree = "<group>"; };
|
||||
4EFE0C7F2D02054300D4834D /* ItemArrayElements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemArrayElements.swift; sourceTree = "<group>"; };
|
||||
|
@ -2373,6 +2383,7 @@
|
|||
4EED87492CBF824B002354D2 /* DevicesView */,
|
||||
4E35CE622CBED3FF00DBD886 /* ServerLogsView */,
|
||||
4ECF5D8C2D0A780F00F066B1 /* ServerTasks */,
|
||||
4EFAC12A2D1E253300E40880 /* ServerUserAccessTags */,
|
||||
4EC2B1A72CC9725400D866BE /* ServerUserDetailsView */,
|
||||
4E2470072D078DD7009139D8 /* ServerUserParentalRatingView */,
|
||||
4E537A822D03D0FA00659A1A /* ServerUserDeviceAccessView */,
|
||||
|
@ -2861,6 +2872,50 @@
|
|||
path = ServerUserAccessView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EFAC12A2D1E253300E40880 /* ServerUserAccessTags */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EFAC1312D1E373B00E40880 /* AddServerUserAccessTagsView */,
|
||||
4EFAC12D2D1E2C4700E40880 /* EditServerUserAccessTagsView */,
|
||||
);
|
||||
path = ServerUserAccessTags;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EFAC12D2D1E2C4700E40880 /* EditServerUserAccessTagsView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EFAC12F2D1E2EB900E40880 /* Components */,
|
||||
4EFAC12B2D1E255600E40880 /* EditServerUserAccessTagsView.swift */,
|
||||
);
|
||||
path = EditServerUserAccessTagsView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EFAC12F2D1E2EB900E40880 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EFAC12E2D1E2EB900E40880 /* EditAccessTagRow.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EFAC1312D1E373B00E40880 /* AddServerUserAccessTagsView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EFAC1342D1FB19700E40880 /* Components */,
|
||||
4EFAC1322D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift */,
|
||||
);
|
||||
path = AddServerUserAccessTagsView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EFAC1342D1FB19700E40880 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EFAC1352D1FB1A100E40880 /* AccessTagSearchResultsSection.swift */,
|
||||
4EFAC1372D1FB26600E40880 /* TagInput.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5310694F2684E7EE00CFFDBA /* VideoPlayer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -5632,6 +5687,7 @@
|
|||
E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */,
|
||||
E146A9D82BE6E9830034DA1E /* StoredValue.swift in Sources */,
|
||||
6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */,
|
||||
4EFAC1362D1FB1A100E40880 /* AccessTagSearchResultsSection.swift in Sources */,
|
||||
E17AC96D2954E9CA003D2BC2 /* DownloadListView.swift in Sources */,
|
||||
4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */,
|
||||
4E2470082D078DD7009139D8 /* ServerUserParentalRatingView.swift in Sources */,
|
||||
|
@ -5766,12 +5822,14 @@
|
|||
4E2AC4C82C6C493C00DD600D /* SubtitleFormat.swift in Sources */,
|
||||
E19D41B02BF2B7540082B8B2 /* URLSessionConfiguration.swift in Sources */,
|
||||
4E661A2E2CEFE77700025C99 /* MetadataField.swift in Sources */,
|
||||
4EFAC1332D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift in Sources */,
|
||||
E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */,
|
||||
4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */,
|
||||
536D3D78267BD5C30004248C /* ViewModel.swift in Sources */,
|
||||
E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */,
|
||||
E175AFF3299AC117004DCF52 /* DebugSettingsView.swift in Sources */,
|
||||
E1A5056A2D0B733F007EE305 /* Optional.swift in Sources */,
|
||||
4EFAC1382D1FB26600E40880 /* TagInput.swift in Sources */,
|
||||
E102314D2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */,
|
||||
E12CC1BB28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */,
|
||||
E1BE1CEE2BDB68CD008176A9 /* UserProfileRow.swift in Sources */,
|
||||
|
@ -5974,6 +6032,7 @@
|
|||
E1CAF6622BA363840087D991 /* UIHostingController.swift in Sources */,
|
||||
E11895AC289383EE0042947B /* NavigationBarOffsetModifier.swift in Sources */,
|
||||
E1CD13EF28EF364100CB46CA /* DetectOrientationModifier.swift in Sources */,
|
||||
4EFAC12C2D1E255900E40880 /* EditServerUserAccessTagsView.swift in Sources */,
|
||||
E157563029355B7900976E1F /* UpdateView.swift in Sources */,
|
||||
E1D8424F2932F7C400D1041A /* OverviewView.swift in Sources */,
|
||||
E113133628BE98AA00930F75 /* FilterDrawerButton.swift in Sources */,
|
||||
|
@ -6090,6 +6149,7 @@
|
|||
E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */,
|
||||
E17DC74D2BE7601E00B42379 /* SettingsBarButton.swift in Sources */,
|
||||
E190704F2C8592B40004600E /* PlaybackCompatibility+Video.swift in Sources */,
|
||||
4EFAC1302D1E2EB900E40880 /* EditAccessTagRow.swift in Sources */,
|
||||
E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */,
|
||||
E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */,
|
||||
E1ED7FDC2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift in Sources */,
|
||||
|
|
|
@ -92,7 +92,7 @@ struct AddAccessScheduleView: View {
|
|||
|
||||
var body: some View {
|
||||
contentView
|
||||
.navigationTitle(L10n.addAccessSchedule)
|
||||
.navigationTitle(L10n.addAccessSchedule.localizedCapitalized)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarCloseButton {
|
||||
router.dismissCoordinator()
|
||||
|
|
|
@ -0,0 +1,151 @@
|
|||
//
|
||||
// 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) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct AddServerUserAccessTagsView: View {
|
||||
|
||||
// MARK: - Observed & Environment Objects
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: BasicNavigationViewCoordinator.Router
|
||||
|
||||
@ObservedObject
|
||||
private var viewModel: ServerUserAdminViewModel
|
||||
|
||||
@StateObject
|
||||
private var tagViewModel: TagEditorViewModel
|
||||
|
||||
// MARK: - Access Tag Variables
|
||||
|
||||
@State
|
||||
private var tempPolicy: UserPolicy
|
||||
@State
|
||||
private var tempTag: String = ""
|
||||
@State
|
||||
private var access: Bool = false
|
||||
|
||||
// MARK: - Error State
|
||||
|
||||
@State
|
||||
private var error: Error?
|
||||
|
||||
// MARK: - Name is Valid
|
||||
|
||||
private var isValid: Bool {
|
||||
tempTag.isNotEmpty && !tagIsDuplicate
|
||||
}
|
||||
|
||||
// MARK: - Tag is Already Blocked/Allowed
|
||||
|
||||
private var tagIsDuplicate: Bool {
|
||||
viewModel.user.policy!.blockedTags!.contains(tempTag) // &&
|
||||
//! viewModel.user.policy!.allowedTags!.contains(tempTag)
|
||||
}
|
||||
|
||||
// MARK: - Tag Already Exists on Jellyfin
|
||||
|
||||
private var tagAlreadyExists: Bool {
|
||||
tagViewModel.trie.contains(key: tempTag.localizedLowercase)
|
||||
}
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(viewModel: ServerUserAdminViewModel) {
|
||||
self.viewModel = viewModel
|
||||
self.tempPolicy = viewModel.user.policy!
|
||||
self._tagViewModel = StateObject(wrappedValue: TagEditorViewModel(item: .init()))
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
contentView
|
||||
.navigationTitle(L10n.addAccessTag.localizedCapitalized)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarCloseButton {
|
||||
router.dismissCoordinator()
|
||||
}
|
||||
.topBarTrailing {
|
||||
if viewModel.backgroundStates.contains(.refreshing) {
|
||||
ProgressView()
|
||||
}
|
||||
if viewModel.backgroundStates.contains(.updating) {
|
||||
Button(L10n.cancel) {
|
||||
viewModel.send(.cancel)
|
||||
}
|
||||
.buttonStyle(.toolbarPill(.red))
|
||||
} else {
|
||||
Button(L10n.save) {
|
||||
if access {
|
||||
// TODO: Enable on 10.10
|
||||
/* tempPolicy.allowedTags = tempPolicy.allowedTags
|
||||
.appendedOrInit(tempTag) */
|
||||
} else {
|
||||
tempPolicy.blockedTags = tempPolicy.blockedTags
|
||||
.appendedOrInit(tempTag)
|
||||
}
|
||||
|
||||
viewModel.send(.updatePolicy(tempPolicy))
|
||||
}
|
||||
.buttonStyle(.toolbarPill)
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
.onFirstAppear {
|
||||
tagViewModel.send(.load)
|
||||
}
|
||||
.onChange(of: tempTag) { _ in
|
||||
if !tagViewModel.backgroundStates.contains(.loading) {
|
||||
tagViewModel.send(.search(tempTag))
|
||||
}
|
||||
}
|
||||
.onReceive(viewModel.events) { event in
|
||||
switch event {
|
||||
case let .error(eventError):
|
||||
UIDevice.feedback(.error)
|
||||
error = eventError
|
||||
case .updated:
|
||||
UIDevice.feedback(.success)
|
||||
router.dismissCoordinator()
|
||||
}
|
||||
}
|
||||
.onReceive(tagViewModel.events) { event in
|
||||
switch event {
|
||||
case .updated:
|
||||
break
|
||||
case .loaded:
|
||||
tagViewModel.send(.search(tempTag))
|
||||
case let .error(eventError):
|
||||
UIDevice.feedback(.error)
|
||||
error = eventError
|
||||
}
|
||||
}
|
||||
.errorMessage($error)
|
||||
}
|
||||
|
||||
// MARK: - Content View
|
||||
|
||||
private var contentView: some View {
|
||||
Form {
|
||||
TagInput(
|
||||
access: $access,
|
||||
tag: $tempTag,
|
||||
tagIsDuplicate: tagIsDuplicate,
|
||||
tagAlreadyExists: tagAlreadyExists
|
||||
)
|
||||
|
||||
SearchResultsSection(
|
||||
tag: $tempTag,
|
||||
tags: tagViewModel.matches,
|
||||
isSearching: tagViewModel.backgroundStates.contains(.searching)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
//
|
||||
// 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) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension AddServerUserAccessTagsView {
|
||||
|
||||
struct SearchResultsSection: View {
|
||||
|
||||
// MARK: - Element Variables
|
||||
|
||||
@Binding
|
||||
var tag: String
|
||||
|
||||
// MARK: - Element Search Variables
|
||||
|
||||
let tags: [String]
|
||||
let isSearching: Bool
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
if tag.isNotEmpty {
|
||||
Section {
|
||||
if tags.isNotEmpty {
|
||||
resultsView
|
||||
} else if !isSearching {
|
||||
noResultsView
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text(L10n.existingItems)
|
||||
|
||||
if isSearching {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("-")
|
||||
|
||||
Text(tags.count, format: .number)
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.linear(duration: 0.2), value: tags)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - No Results View
|
||||
|
||||
private var noResultsView: some View {
|
||||
Text(L10n.none)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// MARK: - Results View
|
||||
|
||||
private var resultsView: some View {
|
||||
ForEach(tags, id: \.self) { result in
|
||||
Button(result) {
|
||||
tag = result
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.disabled(tag == result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
//
|
||||
// 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) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension AddServerUserAccessTagsView {
|
||||
|
||||
struct TagInput: View {
|
||||
|
||||
// MARK: - Element Variables
|
||||
|
||||
@FocusState
|
||||
private var isTagFocused: Bool
|
||||
|
||||
@Binding
|
||||
var access: Bool
|
||||
@Binding
|
||||
var tag: String
|
||||
|
||||
let tagIsDuplicate: Bool
|
||||
let tagAlreadyExists: Bool
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
// TODO: Enable on 10.10
|
||||
// Section {
|
||||
// Picker(L10n.access, selection: $access) {
|
||||
// Text(L10n.allowed).tag(true)
|
||||
// Text(L10n.blocked).tag(false)
|
||||
// }
|
||||
// .disabled(true)
|
||||
// } header: {
|
||||
// Text(L10n.access)
|
||||
// } footer: {
|
||||
// LearnMoreButton(L10n.accessTags) {
|
||||
// TextPair(
|
||||
// title: L10n.allowed,
|
||||
// subtitle: L10n.accessTagAllowDescription
|
||||
// )
|
||||
// TextPair(
|
||||
// title: L10n.blocked,
|
||||
// subtitle: L10n.accessTagBlockDescription
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
|
||||
Section {
|
||||
TextField(L10n.name, text: $tag)
|
||||
.autocorrectionDisabled()
|
||||
.focused($isTagFocused)
|
||||
} footer: {
|
||||
if tag.isEmpty {
|
||||
Label(
|
||||
L10n.required,
|
||||
systemImage: "exclamationmark.circle.fill"
|
||||
)
|
||||
.labelStyle(.sectionFooterWithImage(imageStyle: .orange))
|
||||
} else if tagIsDuplicate {
|
||||
Label(
|
||||
L10n.accessTagAlreadyExists,
|
||||
systemImage: "exclamationmark.circle.fill"
|
||||
)
|
||||
.labelStyle(.sectionFooterWithImage(imageStyle: .orange))
|
||||
} else {
|
||||
if tagAlreadyExists {
|
||||
Label(
|
||||
L10n.existsOnServer,
|
||||
systemImage: "checkmark.circle.fill"
|
||||
)
|
||||
.labelStyle(.sectionFooterWithImage(imageStyle: .green))
|
||||
} else {
|
||||
Label(
|
||||
L10n.willBeCreatedOnServer,
|
||||
systemImage: "checkmark.seal.fill"
|
||||
)
|
||||
.labelStyle(.sectionFooterWithImage(imageStyle: .blue))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFirstAppear {
|
||||
isTagFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
//
|
||||
// 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) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension EditServerUserAccessTagsView {
|
||||
|
||||
struct EditAccessTagRow: View {
|
||||
|
||||
// MARK: - Metadata Variables
|
||||
|
||||
let tag: String
|
||||
|
||||
// MARK: - Row Actions
|
||||
|
||||
let onSelect: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
HStack {
|
||||
Text(tag)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
ListRowCheckbox()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.swipeActions {
|
||||
Button(
|
||||
L10n.delete,
|
||||
systemImage: "trash",
|
||||
action: onDelete
|
||||
)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,231 @@
|
|||
//
|
||||
// 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) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct EditServerUserAccessTagsView: View {
|
||||
|
||||
private struct TagWithAccess: Hashable {
|
||||
let tag: String
|
||||
let access: Bool
|
||||
}
|
||||
|
||||
// MARK: - Observed, State, & Environment Objects
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: AdminDashboardCoordinator.Router
|
||||
|
||||
@StateObject
|
||||
private var viewModel: ServerUserAdminViewModel
|
||||
|
||||
// MARK: - Dialog States
|
||||
|
||||
@State
|
||||
private var isPresentingDeleteConfirmation = false
|
||||
|
||||
// MARK: - Editing States
|
||||
|
||||
@State
|
||||
private var selectedTags: Set<TagWithAccess> = []
|
||||
@State
|
||||
private var isEditing: Bool = false
|
||||
|
||||
// MARK: - Error State
|
||||
|
||||
@State
|
||||
private var error: Error?
|
||||
|
||||
private var blockedTags: [TagWithAccess] {
|
||||
viewModel.user.policy?.blockedTags?
|
||||
.sorted()
|
||||
.map { TagWithAccess(tag: $0, access: false) } ?? []
|
||||
}
|
||||
|
||||
// private var allowedTags: [TagWithAccess] {
|
||||
// viewModel.user.policy?.allowedTags?
|
||||
// .sorted()
|
||||
// .map { TagWithAccess(tag: $0, access: true) } ?? []
|
||||
// }
|
||||
|
||||
// MARK: - Initializera
|
||||
|
||||
init(viewModel: ServerUserAdminViewModel) {
|
||||
self._viewModel = StateObject(wrappedValue: viewModel)
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch viewModel.state {
|
||||
case .initial, .content:
|
||||
contentView
|
||||
case let .error(error):
|
||||
errorView(with: error)
|
||||
}
|
||||
}
|
||||
.navigationTitle(L10n.accessTags)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(isEditing)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if isEditing {
|
||||
navigationBarSelectView
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if isEditing {
|
||||
Button(L10n.cancel) {
|
||||
isEditing = false
|
||||
UIDevice.impact(.light)
|
||||
selectedTags.removeAll()
|
||||
}
|
||||
.buttonStyle(.toolbarPill)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
if isEditing {
|
||||
Button(L10n.delete) {
|
||||
isPresentingDeleteConfirmation = true
|
||||
}
|
||||
.buttonStyle(.toolbarPill(.red))
|
||||
.disabled(selectedTags.isEmpty)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarMenuButton(
|
||||
isLoading: viewModel.backgroundStates.contains(.refreshing),
|
||||
isHidden: isEditing || (
|
||||
viewModel.user.policy?.blockedTags?.isEmpty == true
|
||||
)
|
||||
) {
|
||||
Button(L10n.add, systemImage: "plus") {
|
||||
router.route(to: \.userAddAccessTag, viewModel)
|
||||
}
|
||||
|
||||
if viewModel.user.policy?.blockedTags?.isNotEmpty == true {
|
||||
Button(L10n.edit, systemImage: "checkmark.circle") {
|
||||
isEditing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(viewModel.events) { event in
|
||||
switch event {
|
||||
case let .error(eventError):
|
||||
error = eventError
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
L10n.delete,
|
||||
isPresented: $isPresentingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
deleteSelectedConfirmationActions
|
||||
} message: {
|
||||
Text(L10n.deleteSelectedConfirmation)
|
||||
}
|
||||
.errorMessage($error)
|
||||
}
|
||||
|
||||
// MARK: - ErrorView
|
||||
|
||||
@ViewBuilder
|
||||
private func errorView(with error: some Error) -> some View {
|
||||
ErrorView(error: error)
|
||||
.onRetry {
|
||||
viewModel.send(.refresh)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeRow(tag: TagWithAccess) -> some View {
|
||||
EditAccessTagRow(tag: tag.tag) {
|
||||
if isEditing {
|
||||
selectedTags.toggle(value: tag)
|
||||
}
|
||||
} onDelete: {
|
||||
selectedTags = [tag]
|
||||
isPresentingDeleteConfirmation = true
|
||||
}
|
||||
.environment(\.isEditing, isEditing)
|
||||
.environment(\.isSelected, selectedTags.contains(tag))
|
||||
}
|
||||
|
||||
// MARK: - Content View
|
||||
|
||||
@ViewBuilder
|
||||
private var contentView: some View {
|
||||
List {
|
||||
ListTitleSection(
|
||||
L10n.accessTags,
|
||||
description: L10n.accessTagsDescription
|
||||
) {
|
||||
UIApplication.shared.open(.jellyfinDocsManagingUsers)
|
||||
}
|
||||
|
||||
if blockedTags.isEmpty {
|
||||
Button(L10n.add) {
|
||||
router.route(to: \.userAddAccessTag, viewModel)
|
||||
}
|
||||
} else {
|
||||
|
||||
// TODO: with allowed, use `DisclosureGroup` instead
|
||||
Section(L10n.blocked) {
|
||||
ForEach(
|
||||
blockedTags,
|
||||
id: \.self,
|
||||
content: makeRow
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: allowed with 10.10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Select/Remove All Button
|
||||
|
||||
@ViewBuilder
|
||||
private var navigationBarSelectView: some View {
|
||||
let isAllSelected = selectedTags.count == blockedTags.count
|
||||
|
||||
Button(isAllSelected ? L10n.removeAll : L10n.selectAll) {
|
||||
selectedTags = isAllSelected ? [] : Set(blockedTags)
|
||||
}
|
||||
.buttonStyle(.toolbarPill)
|
||||
.disabled(!isEditing)
|
||||
}
|
||||
|
||||
// MARK: - Delete Selected Confirmation Actions
|
||||
|
||||
@ViewBuilder
|
||||
private var deleteSelectedConfirmationActions: some View {
|
||||
Button(L10n.cancel, role: .cancel) {}
|
||||
|
||||
Button(L10n.delete, role: .destructive) {
|
||||
var tempPolicy = viewModel.user.policy ?? UserPolicy()
|
||||
|
||||
for tag in selectedTags {
|
||||
if tag.access {
|
||||
// tempPolicy.allowedTags?.removeAll { $0 == tag.tag }
|
||||
} else {
|
||||
tempPolicy.blockedTags?.removeAll { $0 == tag.tag }
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.send(.updatePolicy(tempPolicy))
|
||||
selectedTags.removeAll()
|
||||
isEditing = false
|
||||
}
|
||||
}
|
||||
}
|
|
@ -109,23 +109,17 @@ struct ServerUserDetailsView: View {
|
|||
}
|
||||
|
||||
Section(L10n.parentalControls) {
|
||||
ChevronButton(L10n.ratings)
|
||||
.onSelect {
|
||||
router.route(to: \.userParentalRatings, viewModel)
|
||||
}
|
||||
ChevronButton(L10n.accessSchedules)
|
||||
.onSelect {
|
||||
router.route(to: \.userEditAccessSchedules, viewModel)
|
||||
}
|
||||
// TODO: Allow items SDK 10.10 - allowedTags
|
||||
/* ChevronButton("Allow items")
|
||||
ChevronButton(L10n.accessTags)
|
||||
.onSelect {
|
||||
router.route(to: \.userAllowedTags, viewModel)
|
||||
}
|
||||
// TODO: Block items - blockedTags
|
||||
ChevronButton("Block items")
|
||||
.onSelect {
|
||||
router.route(to: \.userBlockedTags, viewModel)
|
||||
} */
|
||||
ChevronButton(L10n.ratings)
|
||||
.onSelect {
|
||||
router.route(to: \.userParentalRatings, viewModel)
|
||||
router.route(to: \.userEditAccessTags, viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,21 @@
|
|||
/// Define the allowed hours for usage and restrict access outside those times.
|
||||
"accessSchedulesDescription" = "Define the allowed hours for usage and restrict access outside those times.";
|
||||
|
||||
/// User will have access to no media unless it contains at least one allowed tag.
|
||||
"accessTagAllowDescription" = "User will have access to no media unless it contains at least one allowed tag.";
|
||||
|
||||
/// Access tag already exists
|
||||
"accessTagAlreadyExists" = "Access tag already exists";
|
||||
|
||||
/// User will have access to all media except when it contains any blocked tag.
|
||||
"accessTagBlockDescription" = "User will have access to all media except when it contains any blocked tag.";
|
||||
|
||||
/// Access Tags
|
||||
"accessTags" = "Access Tags";
|
||||
|
||||
/// Use tags to grant or restrict this user's access to media.
|
||||
"accessTagsDescription" = "Use tags to grant or restrict this user's access to media.";
|
||||
|
||||
/// Active
|
||||
"active" = "Active";
|
||||
|
||||
|
@ -34,8 +49,11 @@
|
|||
/// Add
|
||||
"add" = "Add";
|
||||
|
||||
/// Add Access Schedule
|
||||
"addAccessSchedule" = "Add Access Schedule";
|
||||
/// Add access schedule
|
||||
"addAccessSchedule" = "Add access schedule";
|
||||
|
||||
/// Add access tag
|
||||
"addAccessTag" = "Add access tag";
|
||||
|
||||
/// Add API key
|
||||
"addAPIKey" = "Add API key";
|
||||
|
@ -88,6 +106,9 @@
|
|||
/// Allow collection management
|
||||
"allowCollectionManagement" = "Allow collection management";
|
||||
|
||||
/// Allowed
|
||||
"allowed" = "Allowed";
|
||||
|
||||
/// Allow media item deletion
|
||||
"allowItemDeletion" = "Allow media item deletion";
|
||||
|
||||
|
@ -271,6 +292,9 @@
|
|||
/// bps
|
||||
"bitsPerSecond" = "bps";
|
||||
|
||||
/// Blocked
|
||||
"blocked" = "Blocked";
|
||||
|
||||
/// Block unrated items
|
||||
"blockUnratedItems" = "Block unrated items";
|
||||
|
||||
|
@ -1717,6 +1741,9 @@
|
|||
/// System
|
||||
"system" = "System";
|
||||
|
||||
/// Tag
|
||||
"tag" = "Tag";
|
||||
|
||||
/// Tagline
|
||||
"tagline" = "Tagline";
|
||||
|
||||
|
|
Loading…
Reference in New Issue