diff --git a/src/apps/dashboard/features/keys/api/useApiKeys.ts b/src/apps/dashboard/features/keys/api/useApiKeys.ts new file mode 100644 index 0000000000..a6422ffb7b --- /dev/null +++ b/src/apps/dashboard/features/keys/api/useApiKeys.ts @@ -0,0 +1,27 @@ +import { Api } from '@jellyfin/sdk'; +import { getApiKeyApi } from '@jellyfin/sdk/lib/utils/api/api-key-api'; +import { useQuery } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; + +export const QUERY_KEY = 'ApiKeys'; + +const fetchApiKeys = async (api?: Api) => { + if (!api) { + console.error('[useApiKeys] Failed to create Api instance'); + return; + } + + const response = await getApiKeyApi(api).getKeys(); + + return response.data; +}; + +export const useApiKeys = () => { + const { api } = useApi(); + + return useQuery({ + queryKey: [ QUERY_KEY ], + queryFn: () => fetchApiKeys(api), + enabled: !!api + }); +}; diff --git a/src/apps/dashboard/features/keys/api/useCreateKey.ts b/src/apps/dashboard/features/keys/api/useCreateKey.ts new file mode 100644 index 0000000000..d5f90be47c --- /dev/null +++ b/src/apps/dashboard/features/keys/api/useCreateKey.ts @@ -0,0 +1,23 @@ +import { ApiKeyApiCreateKeyRequest } from '@jellyfin/sdk/lib/generated-client/api/api-key-api'; +import { getApiKeyApi } from '@jellyfin/sdk/lib/utils/api/api-key-api'; +import { useMutation } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; +import { queryClient } from 'utils/query/queryClient'; +import { QUERY_KEY } from './useApiKeys'; + +export const useCreateKey = () => { + const { api } = useApi(); + + return useMutation({ + mutationFn: (params: ApiKeyApiCreateKeyRequest) => ( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + getApiKeyApi(api!) + .createKey(params) + ), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [ QUERY_KEY ] + }); + } + }); +}; diff --git a/src/apps/dashboard/features/keys/api/useRevokeKey.ts b/src/apps/dashboard/features/keys/api/useRevokeKey.ts new file mode 100644 index 0000000000..2f2f1b54b6 --- /dev/null +++ b/src/apps/dashboard/features/keys/api/useRevokeKey.ts @@ -0,0 +1,23 @@ +import { ApiKeyApiRevokeKeyRequest } from '@jellyfin/sdk/lib/generated-client'; +import { getApiKeyApi } from '@jellyfin/sdk/lib/utils/api/api-key-api'; +import { useMutation } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; +import { queryClient } from 'utils/query/queryClient'; +import { QUERY_KEY } from './useApiKeys'; + +export const useRevokeKey = () => { + const { api } = useApi(); + + return useMutation({ + mutationFn: (params: ApiKeyApiRevokeKeyRequest) => ( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + getApiKeyApi(api!) + .revokeKey(params) + ), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [ QUERY_KEY ] + }); + } + }); +}; diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index bd7e264318..f0c3e13586 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -3,6 +3,7 @@ import { AsyncRouteType, type AsyncRoute } from 'components/router/AsyncRoute'; export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [ { path: 'activity', type: AsyncRouteType.Dashboard }, { path: 'branding', type: AsyncRouteType.Dashboard }, + { path: 'keys', type: AsyncRouteType.Dashboard }, { path: 'logs', type: AsyncRouteType.Dashboard }, { path: 'playback/trickplay', type: AsyncRouteType.Dashboard }, { path: 'plugins/:pluginId', page: 'plugins/plugin', type: AsyncRouteType.Dashboard }, diff --git a/src/apps/dashboard/routes/_legacyRoutes.ts b/src/apps/dashboard/routes/_legacyRoutes.ts index 64911dc20a..342a8ee65c 100644 --- a/src/apps/dashboard/routes/_legacyRoutes.ts +++ b/src/apps/dashboard/routes/_legacyRoutes.ts @@ -121,12 +121,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [ controller: 'dashboard/scheduledtasks/scheduledtasks', view: 'dashboard/scheduledtasks/scheduledtasks.html' } - }, { - path: 'keys', - pageProps: { - controller: 'dashboard/apikeys', - view: 'dashboard/apikeys.html' - } }, { path: 'playback/streaming', pageProps: { diff --git a/src/apps/dashboard/routes/keys/index.tsx b/src/apps/dashboard/routes/keys/index.tsx new file mode 100644 index 0000000000..daedaa74b4 --- /dev/null +++ b/src/apps/dashboard/routes/keys/index.tsx @@ -0,0 +1,167 @@ +import Page from 'components/Page'; +import { useApi } from 'hooks/useApi'; +import globalize from 'lib/globalize'; +import React, { useCallback, useMemo } from 'react'; +import type { AuthenticationInfo } from '@jellyfin/sdk/lib/generated-client/models/authentication-info'; +import confirm from 'components/confirm/confirm'; +import { useApiKeys } from 'apps/dashboard/features/keys/api/useApiKeys'; +import { useRevokeKey } from 'apps/dashboard/features/keys/api/useRevokeKey'; +import { useCreateKey } from 'apps/dashboard/features/keys/api/useCreateKey'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; +import { getDisplayTime, parseISO8601Date, toLocaleDateString } from 'scripts/datetime'; +import DeleteIcon from '@mui/icons-material/Delete'; +import AddIcon from '@mui/icons-material/Add'; + +const ApiKeys = () => { + const { api } = useApi(); + const { data: keys, isLoading } = useApiKeys(); + const revokeKey = useRevokeKey(); + const createKey = useCreateKey(); + + const columns = useMemo[]>(() => [ + { + id: 'ApiKey', + accessorKey: 'AccessToken', + header: globalize.translate('HeaderApiKey'), + size: 300 + }, + { + id: 'AppName', + accessorKey: 'AppName', + header: globalize.translate('HeaderApp') + }, + { + id: 'DateIssued', + accessorFn: item => parseISO8601Date(item.DateCreated), + Cell: ({ cell }) => toLocaleDateString(cell.getValue()) + ' ' + getDisplayTime(cell.getValue()), + header: globalize.translate('HeaderDateIssued'), + filterVariant: 'datetime-range' + } + ], []); + + const table = useMaterialReactTable({ + columns, + data: keys?.Items || [], + + state: { + isLoading + }, + + rowCount: keys?.TotalRecordCount || 0, + + enableColumnPinning: true, + enableColumnResizing: true, + + enableStickyFooter: true, + enableStickyHeader: true, + muiTableContainerProps: { + sx: { + maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer + } + }, + + // Enable (delete) row actions + enableRowActions: true, + positionActionsColumn: 'last', + displayColumnDefOptions: { + 'mrt-row-actions': { + header: '', + size: 25 + } + }, + + renderTopToolbarCustomActions: () => ( + + ), + + renderRowActions: ({ row }) => { + return ( + + + row.original?.AccessToken && onRevokeKey(row.original.AccessToken)} + > + + + + + ); + } + }); + + const onRevokeKey = useCallback((accessToken: string) => { + if (!api) return; + + confirm(globalize.translate('MessageConfirmRevokeApiKey'), globalize.translate('HeaderConfirmRevokeApiKey')).then(function () { + revokeKey.mutate({ + key: accessToken + }); + }).catch(err => { + console.error('[apikeys] failed to show confirmation dialog', err); + }); + }, [api, revokeKey]); + + const showNewKeyPopup = useCallback(() => { + if (!api) return; + + import('../../../../components/prompt/prompt').then(({ default: prompt }) => { + prompt({ + title: globalize.translate('HeaderNewApiKey'), + label: globalize.translate('LabelAppName'), + description: globalize.translate('LabelAppNameExample') + }).then((value) => { + createKey.mutate({ + app: value + }); + }).catch(() => { + // popup closed + }); + }).catch(err => { + console.error('[apikeys] failed to load api key popup', err); + }); + }, [api, createKey]); + + return ( + + + + + + {globalize.translate('HeaderApiKeys')} + + {globalize.translate('HeaderApiKeysHelp')} + + + + + + ); +}; + +export default ApiKeys; diff --git a/src/controllers/dashboard/apikeys.html b/src/controllers/dashboard/apikeys.html deleted file mode 100644 index fd8ade8bba..0000000000 --- a/src/controllers/dashboard/apikeys.html +++ /dev/null @@ -1,26 +0,0 @@ -
-
-
-
-

${HeaderApiKeys}

- -
-

${HeaderApiKeysHelp}

-
- - - - - - - - - - - -
${ApiKeysCaption}
${HeaderApiKey}${HeaderApp}${HeaderDateIssued}
-
-
-
diff --git a/src/controllers/dashboard/apikeys.js b/src/controllers/dashboard/apikeys.js deleted file mode 100644 index 3fc7e5fe7d..0000000000 --- a/src/controllers/dashboard/apikeys.js +++ /dev/null @@ -1,89 +0,0 @@ -import escapeHTML from 'escape-html'; - -import datetime from '../../scripts/datetime'; -import loading from '../../components/loading/loading'; -import dom from '../../scripts/dom'; -import globalize from '../../lib/globalize'; -import '../../elements/emby-button/emby-button'; -import confirm from '../../components/confirm/confirm'; -import { pageIdOn } from '../../utils/dashboard'; - -function revoke(page, key) { - confirm(globalize.translate('MessageConfirmRevokeApiKey'), globalize.translate('HeaderConfirmRevokeApiKey')).then(function () { - loading.show(); - ApiClient.ajax({ - type: 'DELETE', - url: ApiClient.getUrl('Auth/Keys/' + key) - }).then(function () { - loadData(page); - }); - }); -} - -function renderKeys(page, keys) { - const rows = keys.map(function (item) { - let html = ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += escapeHTML(item.AccessToken); - html += ''; - html += ''; - html += escapeHTML(item.AppName) || ''; - html += ''; - html += ''; - const date = datetime.parseISO8601Date(item.DateCreated, true); - html += datetime.toLocaleDateString(date) + ' ' + datetime.getDisplayTime(date); - html += ''; - html += ''; - return html; - }).join(''); - page.querySelector('.resultBody').innerHTML = rows; - loading.hide(); -} - -function loadData(page) { - loading.show(); - ApiClient.getJSON(ApiClient.getUrl('Auth/Keys')).then(function (result) { - renderKeys(page, result.Items); - }); -} - -function showNewKeyPrompt(page) { - import('../../components/prompt/prompt').then(({ default: prompt }) => { - prompt({ - title: globalize.translate('HeaderNewApiKey'), - label: globalize.translate('LabelAppName'), - description: globalize.translate('LabelAppNameExample') - }).then(function (value) { - ApiClient.ajax({ - type: 'POST', - url: ApiClient.getUrl('Auth/Keys', { - App: value - }) - }).then(function () { - loadData(page); - }); - }); - }); -} - -pageIdOn('pageinit', 'apiKeysPage', function () { - const page = this; - page.querySelector('.btnNewKey').addEventListener('click', function () { - showNewKeyPrompt(page); - }); - page.querySelector('.tblApiKeys').addEventListener('click', function (e) { - const btnRevoke = dom.parentWithClass(e.target, 'btnRevoke'); - - if (btnRevoke) { - revoke(page, btnRevoke.getAttribute('data-token')); - } - }); -}); -pageIdOn('pagebeforeshow', 'apiKeysPage', function () { - loadData(this); -}); -