Skip to content

[iOS] Admin Dashboard - User Access Tags #1377

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Shared/Coordinators/AdminDashboardCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
22 changes: 20 additions & 2 deletions Shared/Strings/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
60 changes: 60 additions & 0 deletions Swiftfin.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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>"; };
Expand Down Expand Up @@ -2373,6 +2383,7 @@
4EED87492CBF824B002354D2 /* DevicesView */,
4E35CE622CBED3FF00DBD886 /* ServerLogsView */,
4ECF5D8C2D0A780F00F066B1 /* ServerTasks */,
4EFAC12A2D1E253300E40880 /* ServerUserAccessTags */,
4EC2B1A72CC9725400D866BE /* ServerUserDetailsView */,
4E2470072D078DD7009139D8 /* ServerUserParentalRatingView */,
4E537A822D03D0FA00659A1A /* ServerUserDeviceAccessView */,
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ struct AddAccessScheduleView: View {

var body: some View {
contentView
.navigationTitle(L10n.addAccessSchedule)
.navigationTitle(L10n.addAccessSchedule.localizedCapitalized)
.navigationBarTitleDisplayMode(.inline)
.navigationBarCloseButton {
router.dismissCoordinator()
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
)
}
}
}
Loading