From 56fa03257e60cdcd3f863b6b9db7dffed3096269 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 24 Oct 2024 20:07:49 -0600 Subject: [PATCH] [iOS] Admin Dashboard - API Keys (#1284) * API Keys * Switch Deletion Alert for a Confirmation Dialog * Migrate from a list to a Collection VGrid. * Convert back to List. Also, now using my events! So, there is a confirmation and a failure message for both delete & create API. * want vs wish * Merge Issue Fixes * Review Changes * Reset newAPIName after creating a new API * cleanup --------- Co-authored-by: Ethan Pippin --- Shared/Coordinators/SettingsCoordinator.swift | 7 + Shared/Strings/Strings.swift | 28 +++- Shared/ViewModels/APIKeysViewModel.swift | 119 +++++++++++++++++ Swiftfin.xcodeproj/project.pbxproj | 30 +++++ .../APIKeyView/APIKeysView.swift | 120 ++++++++++++++++++ .../APIKeyView/Components/APIKeysRow.swift | 72 +++++++++++ .../UserDashboardView/UserDashboardView.swift | 5 + Translations/en.lproj/Localizable.strings | 80 ++++++++++++ 8 files changed, 458 insertions(+), 3 deletions(-) create mode 100644 Shared/ViewModels/APIKeysViewModel.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/APIKeyView/APIKeysView.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/APIKeyView/Components/APIKeysRow.swift diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index dcb1bab43..295124813 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -76,6 +76,8 @@ final class SettingsCoordinator: NavigationCoordinatable { var addServerTaskTrigger = makeAddServerTaskTrigger @Route(.push) var serverLogs = makeServerLogs + @Route(.push) + var apiKeys = makeAPIKeys // <- End of AdminDashboard Items #if DEBUG @@ -230,6 +232,11 @@ final class SettingsCoordinator: NavigationCoordinatable { ServerLogsView() } + @ViewBuilder + func makeAPIKeys() -> some View { + APIKeysView() + } + // <- End of AdminDashboard Items #if DEBUG diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index d550efeee..6af95ad21 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -22,6 +22,8 @@ internal enum L10n { internal static let activeDevices = L10n.tr("Localizable", "activeDevices", fallback: "Active Devices") /// Add internal static let add = L10n.tr("Localizable", "add", fallback: "Add") + /// Add API key + internal static let addAPIKey = L10n.tr("Localizable", "addAPIKey", fallback: "Add API key") /// Select Server View - Add Server internal static let addServer = L10n.tr("Localizable", "addServer", fallback: "Add Server") /// Add trigger @@ -46,10 +48,22 @@ internal enum L10n { internal static let allServers = L10n.tr("Localizable", "allServers", fallback: "All Servers") /// TranscodeReason - Anamorphic Video Not Supported internal static let anamorphicVideoNotSupported = L10n.tr("Localizable", "anamorphicVideoNotSupported", fallback: "Anamorphic video is not supported") + /// API Key Copied + internal static let apiKeyCopied = L10n.tr("Localizable", "apiKeyCopied", fallback: "API Key Copied") + /// Your API Key was copied to your clipboard! + internal static let apiKeyCopiedMessage = L10n.tr("Localizable", "apiKeyCopiedMessage", fallback: "Your API Key was copied to your clipboard!") + /// API Keys + internal static let apiKeys = L10n.tr("Localizable", "apiKeys", fallback: "API Keys") + /// External applications require an API key to communicate with your server. + internal static let apiKeysDescription = L10n.tr("Localizable", "apiKeysDescription", fallback: "External applications require an API key to communicate with your server.") + /// API Keys + internal static let apiKeysTitle = L10n.tr("Localizable", "apiKeysTitle", fallback: "API Keys") /// Represents the Appearance setting label internal static let appearance = L10n.tr("Localizable", "appearance", fallback: "Appearance") /// App Icon internal static let appIcon = L10n.tr("Localizable", "appIcon", fallback: "App Icon") + /// Application Name + internal static let applicationName = L10n.tr("Localizable", "applicationName", fallback: "Application Name") /// Apply internal static let apply = L10n.tr("Localizable", "apply", fallback: "Apply") /// Aspect Fill @@ -218,6 +232,10 @@ internal enum L10n { internal static let `continue` = L10n.tr("Localizable", "continue", fallback: "Continue") /// Continue Watching internal static let continueWatching = L10n.tr("Localizable", "continueWatching", fallback: "Continue Watching") + /// Create API Key + internal static let createAPIKey = L10n.tr("Localizable", "createAPIKey", fallback: "Create API Key") + /// Enter the application name for the new API key. + internal static let createAPIKeyMessage = L10n.tr("Localizable", "createAPIKeyMessage", fallback: "Enter the application name for the new API key.") /// Current internal static let current = L10n.tr("Localizable", "current", fallback: "Current") /// Current Position @@ -248,14 +266,18 @@ internal enum L10n { internal static let dashboard = L10n.tr("Localizable", "dashboard", fallback: "Dashboard") /// Description for the dashboard section internal static let dashboardDescription = L10n.tr("Localizable", "dashboardDescription", fallback: "Perform administrative tasks for your Jellyfin server.") + /// Date Created + internal static let dateCreated = L10n.tr("Localizable", "dateCreated", fallback: "Date Created") /// Day of Week internal static let dayOfWeek = L10n.tr("Localizable", "dayOfWeek", fallback: "Day of Week") /// Time Interval Help Text - Days internal static let days = L10n.tr("Localizable", "days", fallback: "Days") /// Default Scheme internal static let defaultScheme = L10n.tr("Localizable", "defaultScheme", fallback: "Default Scheme") - /// Server Detail View - Delete + /// Delete internal static let delete = L10n.tr("Localizable", "delete", fallback: "Delete") + /// Are you sure you want to permanently delete this key? + internal static let deleteAPIKeyMessage = L10n.tr("Localizable", "deleteAPIKeyMessage", fallback: "Are you sure you want to permanently delete this key?") /// Delete Device internal static let deleteDevice = L10n.tr("Localizable", "deleteDevice", fallback: "Delete Device") /// Failed to Delete Device @@ -544,8 +566,8 @@ internal enum L10n { internal static let noTitle = L10n.tr("Localizable", "noTitle", fallback: "No title") /// Video Player Settings View - Offset internal static let offset = L10n.tr("Localizable", "offset", fallback: "Offset") - /// Ok - internal static let ok = L10n.tr("Localizable", "ok", fallback: "Ok") + /// OK + internal static let ok = L10n.tr("Localizable", "ok", fallback: "OK") /// On application startup internal static let onApplicationStartup = L10n.tr("Localizable", "onApplicationStartup", fallback: "On application startup") /// 1 user diff --git a/Shared/ViewModels/APIKeysViewModel.swift b/Shared/ViewModels/APIKeysViewModel.swift new file mode 100644 index 000000000..f191e291b --- /dev/null +++ b/Shared/ViewModels/APIKeysViewModel.swift @@ -0,0 +1,119 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import Foundation +import JellyfinAPI +import OrderedCollections + +// TODO: for APIKey updating, could temp set new APIKeys + +final class APIKeysViewModel: ViewModel, Stateful { + + // MARK: Action + + enum Action: Equatable { + case getAPIKeys + case createAPIKey(name: String) + case deleteAPIKey(key: String) + } + + // MARK: State + + enum State: Hashable { + case initial + case error(JellyfinAPIError) + case content + } + + // MARK: Published Variables + + @Published + final var apiKeys: [AuthenticationInfo] = [] + @Published + final var state: State = .initial + + // MARK: Action Responses + + func respond(to action: Action) -> State { + switch action { + case .getAPIKeys: + Task { + do { + try await getAPIKeys() + + await MainActor.run { + self.state = .content + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .store(in: &cancellables) + case let .createAPIKey(name): + Task { + do { + try await createAPIKey(name: name) + + await MainActor.run { + self.state = .content + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .store(in: &cancellables) + case let .deleteAPIKey(key): + Task { + do { + try await deleteAPIKey(key: key) + + await MainActor.run { + self.state = .content + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .store(in: &cancellables) + } + + return state + } + + private func getAPIKeys() async throws { + let request = Paths.getKeys + let response = try await userSession.client.send(request) + + guard let items = response.value.items else { return } + + await MainActor.run { + self.apiKeys = items + } + } + + private func createAPIKey(name: String) async throws { + let request = Paths.createKey(app: name) + try await userSession.client.send(request).value + + try await getAPIKeys() + } + + private func deleteAPIKey(key: String) async throws { + let request = Paths.revokeKey(key: key) + try await userSession.client.send(request) + + try await getAPIKeys() + } +} diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 99cacb0c7..ab4ebe636 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -56,6 +56,8 @@ 4E35CE6A2CBED95F00DBD886 /* DayOfWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE682CBED95F00DBD886 /* DayOfWeek.swift */; }; 4E35CE6C2CBEDB7600DBD886 /* TaskState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */; }; 4E35CE6D2CBEDB7600DBD886 /* TaskState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */; }; + 4E36395B2CC4DF0E00110EBC /* APIKeysViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */; }; + 4E36395C2CC4DF0E00110EBC /* APIKeysViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */; }; 4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */; }; 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; }; 4E63B9FA2C8A5BEF00C25378 /* UserDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */; }; @@ -81,6 +83,8 @@ 4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; }; 4E9A24EB2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */; }; 4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */; }; + 4EA09DE12CC4E4F100CB27E4 /* APIKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */; }; + 4EA09DE42CC4E85C00CB27E4 /* APIKeysRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */; }; 4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */; }; 4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */; }; 4EB1A8CC2C9B1BA200F43898 /* DestructiveServerTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */; }; @@ -1090,6 +1094,7 @@ 4E35CE652CBED8B300DBD886 /* ServerTicks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTicks.swift; sourceTree = ""; }; 4E35CE682CBED95F00DBD886 /* DayOfWeek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeek.swift; sourceTree = ""; }; 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskState.swift; sourceTree = ""; }; + 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysViewModel.swift; sourceTree = ""; }; 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserDashboardView.swift; sourceTree = ""; }; 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveSessionsViewModel.swift; sourceTree = ""; }; @@ -1110,6 +1115,8 @@ 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = ""; }; 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileCoordinator.swift; sourceTree = ""; }; 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; + 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysView.swift; sourceTree = ""; }; + 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysRow.swift; sourceTree = ""; }; 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSection.swift; sourceTree = ""; }; 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = ""; }; 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestructiveServerTask.swift; sourceTree = ""; }; @@ -1988,6 +1995,7 @@ children = ( 4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */, 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */, + 4EA09DDF2CC4E4D000CB27E4 /* APIKeyView */, 4E35CE5B2CBED3F300DBD886 /* AddTaskTriggerView */, E1DE64902CC6F06C00E423B6 /* Components */, 4E10C80F2CC030B20012CC9F /* DeviceDetailsView */, @@ -2108,6 +2116,23 @@ path = Components; sourceTree = ""; }; + 4EA09DDF2CC4E4D000CB27E4 /* APIKeyView */ = { + isa = PBXGroup; + children = ( + 4EA09DE22CC4E7BE00CB27E4 /* Components */, + 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */, + ); + path = APIKeyView; + sourceTree = ""; + }; + 4EA09DE22CC4E7BE00CB27E4 /* Components */ = { + isa = PBXGroup; + children = ( + 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */, + ); + path = Components; + sourceTree = ""; + }; 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */ = { isa = PBXGroup; children = ( @@ -2206,6 +2231,7 @@ isa = PBXGroup; children = ( 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */, + 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */, E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */, @@ -4767,6 +4793,7 @@ E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */, E1153D9C2BBA3E9D00424D36 /* LoadingCard.swift in Sources */, + 4E36395B2CC4DF0E00110EBC /* APIKeysViewModel.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, E102312F2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift in Sources */, E1575E68293E77B5001665B1 /* LibraryParent.swift in Sources */, @@ -5064,6 +5091,7 @@ 6334175B287DDFB9000603CE /* QuickConnectAuthorizeView.swift in Sources */, 4E0A8FFB2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */, E13F05F128BC9016003499D2 /* LibraryRow.swift in Sources */, + 4E36395C2CC4DF0E00110EBC /* APIKeysViewModel.swift in Sources */, E168BD10289A4162001A6922 /* HomeView.swift in Sources */, E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */, 4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */, @@ -5267,6 +5295,7 @@ E1D8429329340B8300D1041A /* Utilities.swift in Sources */, E18CE0B428A22EDA0092E7F1 /* RepeatingTimer.swift in Sources */, E1D5C39628DF90C100CDBEFB /* Slider.swift in Sources */, + 4EA09DE42CC4E85C00CB27E4 /* APIKeysRow.swift in Sources */, E187A60229AB28F0008387E6 /* RotateContentView.swift in Sources */, BD3957792C113EC40078CEF8 /* SubtitleSection.swift in Sources */, 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */, @@ -5343,6 +5372,7 @@ 4E73E2A62C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */, E172D3B22BACA569007B4647 /* EpisodeContent.swift in Sources */, 4EC1C8522C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */, + 4EA09DE12CC4E4F100CB27E4 /* APIKeysView.swift in Sources */, DFB7C3DF2C7AA43A00CE7CDC /* UserSignInState.swift in Sources */, E13F05EC28BC9000003499D2 /* LibraryDisplayType.swift in Sources */, 4E16FD572C01A32700110147 /* LetterPickerOrientation.swift in Sources */, diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/APIKeyView/APIKeysView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/APIKeyView/APIKeysView.swift new file mode 100644 index 000000000..b57408226 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/APIKeyView/APIKeysView.swift @@ -0,0 +1,120 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct APIKeysView: View { + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @State + private var showCopiedAlert = false + @State + private var showDeleteConfirmation = false + @State + private var showCreateAPIAlert = false + @State + private var newAPIName: String = "" + @State + private var deleteAPI: AuthenticationInfo? + + @StateObject + private var viewModel = APIKeysViewModel() + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + contentView + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.getAPIKeys) + } + case .initial: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .animation(.linear(duration: 0.1), value: viewModel.apiKeys) + .navigationTitle(L10n.apiKeys) + .onFirstAppear { + viewModel.send(.getAPIKeys) + } + .topBarTrailing { + if viewModel.apiKeys.isNotEmpty { + Button(L10n.add) { + showCreateAPIAlert = true + UIDevice.impact(.light) + } + .buttonStyle(.toolbarPill) + } + } + .alert(L10n.apiKeyCopied, isPresented: $showCopiedAlert) { + Button(L10n.ok, role: .cancel) {} + } message: { + Text(L10n.apiKeyCopiedMessage) + } + .confirmationDialog( + L10n.delete, + isPresented: $showDeleteConfirmation, + titleVisibility: .visible + ) { + Button(L10n.delete, role: .destructive) { + if let key = deleteAPI?.accessToken { + viewModel.send(.deleteAPIKey(key: key)) + } + } + Button(L10n.cancel, role: .cancel) {} + } message: { + Text(L10n.deleteAPIKeyMessage) + } + .alert(L10n.createAPIKey, isPresented: $showCreateAPIAlert) { + TextField(L10n.applicationName, text: $newAPIName) + Button(L10n.cancel, role: .cancel) {} + Button(L10n.save) { + viewModel.send(.createAPIKey(name: newAPIName)) + newAPIName = "" + } + } message: { + Text(L10n.createAPIKeyMessage) + } + } + + // MARK: - API Key Content + + private var contentView: some View { + List { + ListTitleSection( + L10n.apiKeysTitle, + description: L10n.apiKeysDescription + ) + + if viewModel.apiKeys.isNotEmpty { + ForEach(viewModel.apiKeys, id: \.accessToken) { apiKey in + APIKeysRow(apiKey: apiKey) { + UIPasteboard.general.string = apiKey.accessToken + showCopiedAlert = true + } onDelete: { + deleteAPI = apiKey + showDeleteConfirmation = true + } + } + } else { + Button(L10n.addAPIKey) { + showCreateAPIAlert = true + } + .foregroundStyle(Color.accentColor) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/APIKeyView/Components/APIKeysRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/APIKeyView/Components/APIKeysRow.swift new file mode 100644 index 000000000..44549e1a1 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/APIKeyView/Components/APIKeysRow.swift @@ -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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Factory +import JellyfinAPI +import SwiftUI + +extension APIKeysView { + + struct APIKeysRow: View { + + // MARK: - Actions + + let apiKey: AuthenticationInfo + + // MARK: - Actions + + let onSelect: () -> Void + let onDelete: () -> Void + + // MARK: - Row Content + + @ViewBuilder + private var rowContent: some View { + VStack(alignment: .leading, spacing: 4) { + Text(apiKey.appName ?? L10n.unknown) + .fontWeight(.semibold) + .lineLimit(2) + + Text(apiKey.accessToken ?? L10n.unknown) + .lineLimit(2) + + TextPairView( + L10n.dateCreated, + value: { + if let creationDate = apiKey.dateCreated { + Text(creationDate, format: .dateTime) + } else { + Text(L10n.unknown) + } + }() + ) + .monospacedDigit() + } + .font(.subheadline) + .multilineTextAlignment(.leading) + } + + // MARK: - Body + + var body: some View { + Button(action: onSelect) { + rowContent + } + .foregroundStyle(.primary, .secondary) + .swipeActions { + Button( + L10n.delete, + systemImage: "trash", + action: onDelete + ) + .tint(.red) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift index de263e3ca..b13fbd5fd 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift @@ -37,6 +37,11 @@ struct UserDashboardView: View { Section(L10n.advanced) { + ChevronButton(L10n.apiKeys) + .onSelect { + router.route(to: \.apiKeys) + } + ChevronButton(L10n.logs) .onSelect { router.route(to: \.serverLogs) diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index cb6ec7a7b..4dd50f81a 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -739,6 +739,81 @@ /* Section Title for Column Configuration */ "columns" = "Columns"; +// Date Created - Label +// Label for displaying the date an API key was created +// Appears in the API key details +"dateCreated" = "Date Created"; + +// API Keys - Title +// Section Title for displaying API keys in the list +// Displays the title of the API key section +"apiKeysTitle" = "API Keys"; + +// API Keys - Description +// Explains the usage of API keys in external applications +// Displays below the title in the API key section +"apiKeysDescription" = "External applications require an API key to communicate with your server."; + +// Add - Button +// Adds a new record +// Appears in the toolbar +"add" = "Add"; + +// API Key Copied - Alert +// Informs the user that the API key was copied to the clipboard +// Displays an alert after the user copies the key +"apiKeyCopied" = "API Key Copied"; + +// API Key Copied - Alert Message +// Informs the user that the key was copied successfully +// Appears as a message in the alert +"apiKeyCopiedMessage" = "Your API Key was copied to your clipboard!"; + +// OK - Button +// Acknowledges an action +// Used to dismiss the alert +"ok" = "OK"; + +// Delete API Key - Confirmation Message +// Warns the user that deletion is permanent +// Displays a warning message before deletion +"deleteAPIKeyMessage" = "Are you sure you want to permanently delete this key?"; + +// Cancel - Button +// Cancels the current action +// Appears in dialogs and alerts +"cancel" = "Cancel"; + +// Delete - Button +// Confirms the deletion of an API key +// Appears in the delete confirmation dialog +"delete" = "Delete"; + +// Create API Key - Alert +// Prompts the user to enter an app name to create an API key +// Appears when creating a new API key +"createAPIKey" = "Create API Key"; + +// Create API Key - Message +// Asks the user to enter the name of the application for the new API key +// Displays in the create API key dialog +"createAPIKeyMessage" = "Enter the application name for the new API key."; + +// Application Name - Text Field +// Placeholder text for entering the name of the application +// Appears in the create API key dialog +"applicationName" = "Application Name"; + +// Save - Button +// Confirms the creation of the new API key +// Appears in the create API key dialog +"save" = "Save"; + +// API Keys - Screen Title +// Title for the API keys management screen +// Appears in the navigation bar +"apiKeys" = "API Keys"; + // Devices - Section Header // Title for the devices section in the Admin Dashboard // Used as the header for the devices section @@ -1023,3 +1098,8 @@ "success" = "Success"; "triggerAlreadyExists" = "Trigger already exists"; + +// Add API Key - Button +// Creates an API Key if there are no keys available +// Appears in place of the API Key list if there are no API Keys +"addAPIKey" = "Add API key";