From d63c84bcb5e9b88d64623211ed199c3728d4cf9c Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Mon, 16 Dec 2024 14:49:00 +0300 Subject: [PATCH 01/14] Migrate apikeys to React --- src/apps/dashboard/routes/_asyncRoutes.ts | 3 +- src/apps/dashboard/routes/_legacyRoutes.ts | 6 - src/apps/dashboard/routes/keys.tsx | 141 ++++++++++++++++++ .../dashboard/apikeys/ApiKeyCell.tsx | 46 ++++++ src/controllers/dashboard/apikeys.html | 26 ---- src/controllers/dashboard/apikeys.js | 89 ----------- src/elements/ButtonElement.tsx | 31 ++-- 7 files changed, 211 insertions(+), 131 deletions(-) create mode 100644 src/apps/dashboard/routes/keys.tsx create mode 100644 src/components/dashboard/apikeys/ApiKeyCell.tsx delete mode 100644 src/controllers/dashboard/apikeys.html delete mode 100644 src/controllers/dashboard/apikeys.js diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index bd7e264318b..424af0cf838 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -11,5 +11,6 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [ { path: 'users/add', type: AsyncRouteType.Dashboard }, { path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard }, { path: 'users/password', type: AsyncRouteType.Dashboard }, - { path: 'users/profile', type: AsyncRouteType.Dashboard } + { path: 'users/profile', type: AsyncRouteType.Dashboard }, + { path: 'keys', type: AsyncRouteType.Dashboard } ]; diff --git a/src/apps/dashboard/routes/_legacyRoutes.ts b/src/apps/dashboard/routes/_legacyRoutes.ts index 64911dc20a6..342a8ee65cd 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.tsx b/src/apps/dashboard/routes/keys.tsx new file mode 100644 index 00000000000..eaddfce2509 --- /dev/null +++ b/src/apps/dashboard/routes/keys.tsx @@ -0,0 +1,141 @@ +import Page from 'components/Page'; +import SectionTitleContainer from 'elements/SectionTitleContainer'; +import { useApi } from 'hooks/useApi'; +import globalize from 'lib/globalize'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { getApiKeyApi } from '@jellyfin/sdk/lib/utils/api/api-key-api'; +import type { AuthenticationInfo } from '@jellyfin/sdk/lib/generated-client/models/authentication-info'; +import Loading from 'components/loading/LoadingComponent'; +import { Api } from '@jellyfin/sdk'; +import confirm from 'components/confirm/confirm'; +import ApiKeyCell from 'components/dashboard/apikeys/ApiKeyCell'; + +const ApiKeys = () => { + const { api } = useApi(); + const [ keys, setKeys ] = useState([]); + const [ loading, setLoading ] = useState(true); + const element = useRef(null); + + const loadKeys = (currentApi: Api) => { + return getApiKeyApi(currentApi) + .getKeys() + .then(({ data }) => { + if (data.Items) { + setKeys(data.Items); + } + }) + .catch((err) => { + console.error('[apikeys] failed to load api keys', err); + }); + }; + + const revokeKey = useCallback((accessToken: string) => { + if (api) { + confirm(globalize.translate('MessageConfirmRevokeApiKey'), globalize.translate('HeaderConfirmRevokeApiKey')).then(function () { + setLoading(true); + getApiKeyApi(api) + .revokeKey({ key: accessToken }) + .then(() => loadKeys(api)) + .then(() => setLoading(false)) + .catch(err => { + console.error('[apikeys] failed to revoke key', err); + }); + }).catch(err => { + console.error('[apikeys] failed to show confirmation dialog', err); + }); + } + }, [api]); + + useEffect(() => { + if (!api) { + return; + } + + loadKeys(api).then(() => { + setLoading(false); + }).catch(err => { + console.error('[apikeys] failed to load api keys', err); + }); + + if (loading) { + return; + } + + const page = element.current; + + if (!page) { + console.error('[apikeys] Unexpected null page reference'); + return; + } + + const showNewKeyPopup = () => { + import('../../../components/prompt/prompt').then(({ default: prompt }) => { + prompt({ + title: globalize.translate('HeaderNewApiKey'), + label: globalize.translate('LabelAppName'), + description: globalize.translate('LabelAppNameExample') + }).then((value) => { + getApiKeyApi(api) + .createKey({ app: value }) + .then(() => loadKeys(api)) + .catch(err => { + console.error('[apikeys] failed to create api key', err); + }); + }).catch(() => { + // popup closed + }); + }).catch(err => { + console.error('[apikeys] failed to load api key popup', err); + }); + }; + + (page.querySelector('.btnNewKey') as HTMLButtonElement).addEventListener('click', showNewKeyPopup); + + return () => { + (page.querySelector('.btnNewKey') as HTMLButtonElement).removeEventListener('click', showNewKeyPopup); + }; + }, [api, loading]); + + if (loading) { + return ; + } + + return ( + +
+ +

{globalize.translate('HeaderApiKeysHelp')}

+
+ + + + + + + + + + + + {keys.map(key => { + return ; + })} + +
{globalize.translate('ApiKeysCaption')}
{globalize.translate('HeaderApiKey')}{globalize.translate('HeaderApp')}{globalize.translate('HeaderDateIssued')}
+
+
+ ); +}; + +export default ApiKeys; diff --git a/src/components/dashboard/apikeys/ApiKeyCell.tsx b/src/components/dashboard/apikeys/ApiKeyCell.tsx new file mode 100644 index 00000000000..6604b0dd1f1 --- /dev/null +++ b/src/components/dashboard/apikeys/ApiKeyCell.tsx @@ -0,0 +1,46 @@ +import React, { FunctionComponent, useCallback } from 'react'; +import type { AuthenticationInfo } from '@jellyfin/sdk/lib/generated-client/models/authentication-info'; +import ButtonElement from 'elements/ButtonElement'; +import datetime from 'scripts/datetime'; +import globalize from 'lib/globalize'; + +type ApiKeyCellProps = { + apiKey: AuthenticationInfo; + revokeKey?: (accessToken: string) => void; +}; + +const ApiKeyCell: FunctionComponent = ({ apiKey, revokeKey }: ApiKeyCellProps) => { + const getDate = (dateCreated: string | undefined) => { + const date = datetime.parseISO8601Date(dateCreated, true); + return datetime.toLocaleDateString(date) + ' ' + datetime.getDisplayTime(date); + }; + + const onClick = useCallback(() => { + if (apiKey?.AccessToken && revokeKey !== undefined) { + revokeKey(apiKey.AccessToken); + } + }, [apiKey, revokeKey]); + + return ( + + + + + + {apiKey.AccessToken} + + + {apiKey.AppName} + + + {getDate(apiKey.DateCreated)} + + + ); +}; + +export default ApiKeyCell; diff --git a/src/controllers/dashboard/apikeys.html b/src/controllers/dashboard/apikeys.html deleted file mode 100644 index fd8ade8bba6..00000000000 --- 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 3fc7e5fe7d1..00000000000 --- 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); -}); - diff --git a/src/elements/ButtonElement.tsx b/src/elements/ButtonElement.tsx index 379f4f7995c..ca6e92cbfd0 100644 --- a/src/elements/ButtonElement.tsx +++ b/src/elements/ButtonElement.tsx @@ -22,19 +22,32 @@ type IProps = { title?: string; leftIcon?: string; rightIcon?: string; + onClick?: () => void; }; -const ButtonElement: FunctionComponent = ({ type, id, className, title, leftIcon, rightIcon }: IProps) => { +const ButtonElement: FunctionComponent = ({ type, id, className, title, leftIcon, rightIcon, onClick }: IProps) => { + const button = createButtonElement({ + type: type, + id: id ? `id="${id}"` : '', + className: className, + title: globalize.translate(title), + leftIcon: leftIcon ? `` : '', + rightIcon: rightIcon ? `` : '' + }); + + if (onClick !== undefined) { + return ( + + ), + + renderRowActions: ({ row }) => { + return ( + + + row.original?.AccessToken && onRevokeKey(row.original.AccessToken)} + > + + + + + ); + } + }); + const onRevokeKey = useCallback((accessToken: string) => { if (!api) return; @@ -49,45 +122,34 @@ const ApiKeys = () => { }); }, [api, createKey]); - if (isLoading) { - return ; - } - return ( -
- -

{globalize.translate('HeaderApiKeysHelp')}

-
- - - - - - - - - - - - {keys?.Items?.map((key: AuthenticationInfo) => { - return ; - })} - -
{globalize.translate('ApiKeysCaption')}
{globalize.translate('HeaderApiKey')}{globalize.translate('HeaderApp')}{globalize.translate('HeaderDateIssued')}
-
+ + + + + {globalize.translate('HeaderApiKeys')} + + {globalize.translate('HeaderApiKeysHelp')} + + + +
); }; From 6a140f6ff7bbcc365a14305ed2e79a5fca16b28a Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Fri, 17 Jan 2025 00:40:48 +0300 Subject: [PATCH 11/14] Use direct imports for tree shaking --- src/apps/dashboard/routes/keys/index.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/apps/dashboard/routes/keys/index.tsx b/src/apps/dashboard/routes/keys/index.tsx index f1886712be8..bec98a7ad38 100644 --- a/src/apps/dashboard/routes/keys/index.tsx +++ b/src/apps/dashboard/routes/keys/index.tsx @@ -7,7 +7,12 @@ 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, Button, IconButton, Stack, Tooltip, Typography } from '@mui/material'; +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'; From 653d98eb42593ac9aa0ed6852c0e0d32fe4b2c32 Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Wed, 22 Jan 2025 23:18:53 +0300 Subject: [PATCH 12/14] Fix order --- src/apps/dashboard/routes/_asyncRoutes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index bb23063212d..f0c3e13586c 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -3,8 +3,8 @@ 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: 'logs', 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 }, { path: 'users', type: AsyncRouteType.Dashboard }, From 01d35388d229cbed06dc44bcea2f4497528ee46a Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Thu, 23 Jan 2025 21:43:14 +0300 Subject: [PATCH 13/14] Apply review feedback --- src/apps/dashboard/routes/keys/index.tsx | 7 +++++- src/elements/ButtonElement.tsx | 31 +++++++----------------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/apps/dashboard/routes/keys/index.tsx b/src/apps/dashboard/routes/keys/index.tsx index bec98a7ad38..daedaa74b4c 100644 --- a/src/apps/dashboard/routes/keys/index.tsx +++ b/src/apps/dashboard/routes/keys/index.tsx @@ -16,6 +16,7 @@ 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(); @@ -67,6 +68,7 @@ const ApiKeys = () => { // Enable (delete) row actions enableRowActions: true, + positionActionsColumn: 'last', displayColumnDefOptions: { 'mrt-row-actions': { header: '', @@ -75,7 +77,10 @@ const ApiKeys = () => { }, renderTopToolbarCustomActions: () => ( - + ), renderRowActions: ({ row }) => { diff --git a/src/elements/ButtonElement.tsx b/src/elements/ButtonElement.tsx index ca6e92cbfd0..379f4f7995c 100644 --- a/src/elements/ButtonElement.tsx +++ b/src/elements/ButtonElement.tsx @@ -22,32 +22,19 @@ type IProps = { title?: string; leftIcon?: string; rightIcon?: string; - onClick?: () => void; }; -const ButtonElement: FunctionComponent = ({ type, id, className, title, leftIcon, rightIcon, onClick }: IProps) => { - const button = createButtonElement({ - type: type, - id: id ? `id="${id}"` : '', - className: className, - title: globalize.translate(title), - leftIcon: leftIcon ? `` : '', - rightIcon: rightIcon ? `` : '' - }); - - if (onClick !== undefined) { - return ( -