[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:
Joe Kribs 2025-01-02 23:47:20 -07:00 committed by GitHub
parent adec8de122
commit a13f604be0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 727 additions and 17 deletions

View File

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

View File

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

View File

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

View File

@ -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 */,

View File

@ -92,7 +92,7 @@ struct AddAccessScheduleView: View {
var body: some View {
contentView
.navigationTitle(L10n.addAccessSchedule)
.navigationTitle(L10n.addAccessSchedule.localizedCapitalized)
.navigationBarTitleDisplayMode(.inline)
.navigationBarCloseButton {
router.dismissCoordinator()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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")
.onSelect {
router.route(to: \.userAllowedTags, viewModel)
}
// TODO: Block items - blockedTags
ChevronButton("Block items")
.onSelect {
router.route(to: \.userBlockedTags, viewModel)
} */
ChevronButton(L10n.ratings)
ChevronButton(L10n.accessTags)
.onSelect {
router.route(to: \.userParentalRatings, viewModel)
router.route(to: \.userEditAccessTags, viewModel)
}
}
}

View File

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