mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Migrate apikeys to React (#6390)
This commit is contained in:
parent
4e750711b7
commit
fa749e4d45
8 changed files with 241 additions and 121 deletions
27
src/apps/dashboard/features/keys/api/useApiKeys.ts
Normal file
27
src/apps/dashboard/features/keys/api/useApiKeys.ts
Normal file
|
@ -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
|
||||||
|
});
|
||||||
|
};
|
23
src/apps/dashboard/features/keys/api/useCreateKey.ts
Normal file
23
src/apps/dashboard/features/keys/api/useCreateKey.ts
Normal file
|
@ -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 ]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
23
src/apps/dashboard/features/keys/api/useRevokeKey.ts
Normal file
23
src/apps/dashboard/features/keys/api/useRevokeKey.ts
Normal file
|
@ -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 ]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
|
@ -3,6 +3,7 @@ import { AsyncRouteType, type AsyncRoute } from 'components/router/AsyncRoute';
|
||||||
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||||
{ path: 'activity', type: AsyncRouteType.Dashboard },
|
{ path: 'activity', type: AsyncRouteType.Dashboard },
|
||||||
{ path: 'branding', type: AsyncRouteType.Dashboard },
|
{ path: 'branding', type: AsyncRouteType.Dashboard },
|
||||||
|
{ path: 'keys', type: AsyncRouteType.Dashboard },
|
||||||
{ path: 'logs', type: AsyncRouteType.Dashboard },
|
{ path: 'logs', type: AsyncRouteType.Dashboard },
|
||||||
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard },
|
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard },
|
||||||
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AsyncRouteType.Dashboard },
|
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AsyncRouteType.Dashboard },
|
||||||
|
|
|
@ -121,12 +121,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||||
controller: 'dashboard/scheduledtasks/scheduledtasks',
|
controller: 'dashboard/scheduledtasks/scheduledtasks',
|
||||||
view: 'dashboard/scheduledtasks/scheduledtasks.html'
|
view: 'dashboard/scheduledtasks/scheduledtasks.html'
|
||||||
}
|
}
|
||||||
}, {
|
|
||||||
path: 'keys',
|
|
||||||
pageProps: {
|
|
||||||
controller: 'dashboard/apikeys',
|
|
||||||
view: 'dashboard/apikeys.html'
|
|
||||||
}
|
|
||||||
}, {
|
}, {
|
||||||
path: 'playback/streaming',
|
path: 'playback/streaming',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
|
167
src/apps/dashboard/routes/keys/index.tsx
Normal file
167
src/apps/dashboard/routes/keys/index.tsx
Normal file
|
@ -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<MRT_ColumnDef<AuthenticationInfo>[]>(() => [
|
||||||
|
{
|
||||||
|
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<Date>()) + ' ' + getDisplayTime(cell.getValue<Date>()),
|
||||||
|
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: () => (
|
||||||
|
<Button onClick={showNewKeyPopup}>
|
||||||
|
<AddIcon />
|
||||||
|
{globalize.translate('HeaderNewApiKey')}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
|
||||||
|
renderRowActions: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<Tooltip title={globalize.translate('ButtonRevoke')}>
|
||||||
|
<IconButton
|
||||||
|
color='error'
|
||||||
|
// eslint-disable-next-line react/jsx-no-bind
|
||||||
|
onClick={() => row.original?.AccessToken && onRevokeKey(row.original.AccessToken)}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Page
|
||||||
|
id='apiKeysPage'
|
||||||
|
title={globalize.translate('HeaderApiKeys')}
|
||||||
|
className='mainAnimatedPage type-interior'
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className='content-primary'
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
marginBottom: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant='h2'>
|
||||||
|
{globalize.translate('HeaderApiKeys')}
|
||||||
|
</Typography>
|
||||||
|
<Typography>{globalize.translate('HeaderApiKeysHelp')}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<MaterialReactTable table={table} />
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiKeys;
|
|
@ -1,26 +0,0 @@
|
||||||
<div id="apiKeysPage" data-role="page" class="page type-interior advancedConfigurationPage fullWidthContent" data-title="${HeaderApiKeys}">
|
|
||||||
<div>
|
|
||||||
<div class="content-primary">
|
|
||||||
<div class="detailSectionHeader">
|
|
||||||
<h2 style="margin:.6em 0;vertical-align:middle;display:inline-block;">${HeaderApiKeys}</h2>
|
|
||||||
<button is="emby-button" type="button" class="fab btnNewKey submit" style="margin-left:1em;" title="${Add}">
|
|
||||||
<span class="material-icons add" aria-hidden="true"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p>${HeaderApiKeysHelp}</p>
|
|
||||||
<br />
|
|
||||||
<table class="tblApiKeys detailTable">
|
|
||||||
<caption class="clipForScreenReader">${ApiKeysCaption}</caption>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col" class="detailTableHeaderCell"></th>
|
|
||||||
<th scope="col" class="detailTableHeaderCell">${HeaderApiKey}</th>
|
|
||||||
<th scope="col" class="detailTableHeaderCell">${HeaderApp}</th>
|
|
||||||
<th scope="col" class="detailTableHeaderCell">${HeaderDateIssued}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="resultBody"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -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 += '<tr class="detailTableBodyRow detailTableBodyRow-shaded">';
|
|
||||||
html += '<td class="detailTableBodyCell">';
|
|
||||||
html += '<button type="button" is="emby-button" data-token="' + escapeHTML(item.AccessToken) + '" class="raised raised-mini btnRevoke" data-mini="true" title="' + globalize.translate('ButtonRevoke') + '" style="margin:0;">' + globalize.translate('ButtonRevoke') + '</button>';
|
|
||||||
html += '</td>';
|
|
||||||
html += '<td class="detailTableBodyCell" style="vertical-align:middle;">';
|
|
||||||
html += escapeHTML(item.AccessToken);
|
|
||||||
html += '</td>';
|
|
||||||
html += '<td class="detailTableBodyCell" style="vertical-align:middle;">';
|
|
||||||
html += escapeHTML(item.AppName) || '';
|
|
||||||
html += '</td>';
|
|
||||||
html += '<td class="detailTableBodyCell" style="vertical-align:middle;">';
|
|
||||||
const date = datetime.parseISO8601Date(item.DateCreated, true);
|
|
||||||
html += datetime.toLocaleDateString(date) + ' ' + datetime.getDisplayTime(date);
|
|
||||||
html += '</td>';
|
|
||||||
html += '</tr>';
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue