mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-13 12:57:10 +02:00
feat(subsonic): OpenSubsonic API key authentication (#6219)
* feat(persistence): store hashed API keys on players * feat(core): refresh key-bound players without renaming them Add Players.Touch, which records usage for a player already identified by an API key without guessing its identity or overwriting its name. Register also stops renaming players that have an API key. Register no longer returns player save errors (or a stale FindMatch ErrNotFound when the save is rate-limited); save failures are only logged, and only the transcoding lookup error is returned, same as Touch. * feat(subsonic): authenticate with OpenSubsonic API keys Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com> * feat(subsonic): add tokenInfo and advertise apiKeyAuthentication * feat(server): add endpoints to generate and revoke player API keys * feat(ui): manage player API keys Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com> * fix(subsonic): throttle API keys per key and IP A stale key on one device exhausted the shared per-IP bucket and locked out every valid key from the same IP. The limiter only stores a hash of the bucket string, so the key is not retained. Also adds e2e coverage of API key auth through the real repository, and clarifies the player resolution log message. * fix(ui): keep the new API key dialog open until closed The key is shown only once, so Escape and backdrop clicks no longer dismiss it. Also clarifies when the key can be used as a password. * refactor: simplify API key code paths Share the player refresh tail between Register and Touch, fold the ownership-filtered write tail into execOwned, parse the query once for apiKey conflicts, derive HasAPIKey in the player mock, share the player form inputs between create and edit, and pick the delete button by key state instead of spreading conditional props. * feat(players): set API keys through the player record The key is a write-only apiKey field applied on save: required and owner-only on create, optional on edit, empty to revoke. Replaces the generate/revoke endpoints. * fix(players): reject API keys already in use Creating or editing a player with a key another player already has now returns a validation error instead of a 500, and a create that loses the race no longer leaves a keyless player behind. Ownership is checked before the key on create. * feat(ui): edit player API keys as a form field Replaces the show-once dialog, whose icon-less Close button was invisible on mobile. The key is generated in the browser, required and pre-filled on create. * fix(ui): keep new player API keys out of the record cache The json-server create response echoes the request body, and undoable edits merge the payload into the cache, so the key could reappear on the edit page. Strip it from the create result and save player edits pessimistically. Also fall back to a prompt when the clipboard write fails. * fix(ui): polish player API key field Set userId on the created player record so owner actions show immediately, and show a neutral no-key message to non-owners. * refactor: simplify player API key create and field Write the key hash in the create INSERT so the unique index settles races, re-read the created player instead of hand-building the cached record, reuse isWritable for the revoke check, and collapse the key field's derived state and generate/regenerate buttons. * fix(ui): let the API key field size like other inputs fullWidth is now opt-in instead of forced. * fix(ui): align the API key field with other player inputs Apply react-admin's input className, move the actions (now including Copy) below the field, and use a monospace font so the whole key fits. * fix(ui): redirect to the player list after create Matches the other create pages. * refactor(persistence): name the write-access rule for owned rows Owned-row writes now say which row they target and who may write it: ownedRow(rowID, ownerOrAdmin|ownerOnly) builds the WHERE, updateOwnedRow applies it, and SetAPIKey uses ownerOnly instead of a hand-built user_id filter. updateOwned/deleteOwned keep their signatures. * fix(players): apply an edit's key change and fields atomically Update now runs SetAPIKey and the column update in one transaction. Also shares the key format check, drops FindByAPIKey's unneeded empty-key guard, and sets the context username only on the apiKey path. * fix(subsonic): treat any credential param sent with apiKey as a conflict The spec requires error 43 when u, p, t or s is present with apiKey, even with an empty value. * refactor(subsonic): leave the player cookie code unchanged for key-bound requests Return early instead of wrapping the cookie block, so the diff (and CodeQL's view of it) matches master. * fix(subsonic): don't count key lookup errors as failed logins A database error while checking a key sent as the password now surfaces as a server error instead of a bad password, so it no longer feeds the failed-login limiter. * feat(players): use nds_ as the API key prefix Part of a Navidrome secret prefix family (nd + a letter for the kind), alongside ndg_ for API v1 grants. * feat(ui): make player API keys easier to find Label the Settings menu entry "Players & API keys", add an API key filter to the player list, show the key icon in the mobile list, and add Brazilian Portuguese translations for the new player strings. Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): always show the player API key filter Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): hide the unset Last Seen date in the player list Players created by hand have no last_seen yet, which showed as 12/31/1. Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>
This commit is contained in:
parent
ce484083bf
commit
4cdffd5633
42 changed files with 1582 additions and 100 deletions
|
|
@ -141,7 +141,7 @@ const Admin = (props) => {
|
|||
<Resource
|
||||
name="player"
|
||||
{...player}
|
||||
options={{ subMenu: 'settings' }}
|
||||
options={{ subMenu: 'settings', label: 'resources.player.menuName' }}
|
||||
/>,
|
||||
permissions === 'admin' ? (
|
||||
<Resource
|
||||
|
|
|
|||
|
|
@ -148,6 +148,12 @@ const updateUser = async (params) => {
|
|||
return userResponse
|
||||
}
|
||||
|
||||
// ra-data-json-server merges the request body into the result; re-read so the plaintext key is never cached
|
||||
const createPlayer = async (resource, params) => {
|
||||
const { data } = await dataProvider.create(resource, params)
|
||||
return dataProvider.getOne(resource, { id: data.id })
|
||||
}
|
||||
|
||||
const wrapperDataProvider = {
|
||||
...dataProvider,
|
||||
getList: (resource, params) => {
|
||||
|
|
@ -194,6 +200,9 @@ const wrapperDataProvider = {
|
|||
return createUser(params)
|
||||
}
|
||||
const [r, p] = mapResource(resource, params)
|
||||
if (resource === 'player') {
|
||||
return createPlayer(r, p)
|
||||
}
|
||||
return dataProvider.create(r, p)
|
||||
},
|
||||
delete: (resource, params) => {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,21 @@ describe('wrapperDataProvider', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('create player', () => {
|
||||
it('returns the server record, never the plaintext API key', async () => {
|
||||
const data = { name: 'Phone', apiKey: 'nds_0123456789abcdefghijkl' }
|
||||
const saved = { id: 'p1', name: 'Phone', hasApiKey: true, userId: 'u1' }
|
||||
mockProvider.create.mockResolvedValue({ data: { ...data, id: 'p1' } })
|
||||
mockProvider.getOne.mockResolvedValue({ data: saved })
|
||||
|
||||
const result = await wrapperDataProvider.create('player', { data })
|
||||
|
||||
expect(mockProvider.create).toHaveBeenCalledWith('player', { data })
|
||||
expect(mockProvider.getOne).toHaveBeenCalledWith('player', { id: 'p1' })
|
||||
expect(result.data).toEqual(saved)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshMetadata', () => {
|
||||
it('posts to the album metadata refresh endpoint', () => {
|
||||
mockHttpClient.mockResolvedValue({ json: {} })
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@
|
|||
},
|
||||
"player": {
|
||||
"name": "Player |||| Players",
|
||||
"menuName": "Players & API keys",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"transcodingId": "Transcoding",
|
||||
|
|
@ -190,7 +191,29 @@
|
|||
"userName": "Username",
|
||||
"lastSeen": "Last Seen At",
|
||||
"reportRealPath": "Report Real Path",
|
||||
"scrobbleEnabled": "Send Scrobbles to external services"
|
||||
"scrobbleEnabled": "Send Scrobbles to external services",
|
||||
"hasApiKey": "API Key"
|
||||
},
|
||||
"actions": {
|
||||
"generateApiKey": "Generate API key",
|
||||
"regenerateApiKey": "Regenerate",
|
||||
"revokeApiKey": "Revoke",
|
||||
"copyApiKey": "Copy"
|
||||
},
|
||||
"message": {
|
||||
"apiKeyActive": "This player has an API key. Use it in your app as the API key, or as the password if the app does not use token authentication.",
|
||||
"apiKeyNone": "No API key. Generate one to connect an app to this player.",
|
||||
"apiKeyNoneOther": "No API key.",
|
||||
"apiKeyPending": "Copy this key now. It is saved when you click Save and will not be shown again.",
|
||||
"apiKeyRevokePending": "The API key will be removed when you save.",
|
||||
"deleteWithKeyTitle": "Delete player",
|
||||
"deleteWithKeyContent": "This player has an API key. Apps using it will stop working."
|
||||
},
|
||||
"notifications": {
|
||||
"apiKeyCopied": "API key copied to clipboard"
|
||||
},
|
||||
"validation": {
|
||||
"apiKeyFormat": "Invalid API key format"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
|
|
|
|||
|
|
@ -102,9 +102,11 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
|
|||
}
|
||||
|
||||
const renderSettingsMenuItemLink = (resource, id) => {
|
||||
const label = translate(`resources.${resource.name}.name`, {
|
||||
smart_count: id ? 1 : 2,
|
||||
})
|
||||
const label = resource.options.label
|
||||
? translate(resource.options.label)
|
||||
: translate(`resources.${resource.name}.name`, {
|
||||
smart_count: id ? 1 : 2,
|
||||
})
|
||||
const link = id ? `/${resource.name}/${id}` : `/${resource.name}`
|
||||
return (
|
||||
<MenuItemLink
|
||||
|
|
|
|||
|
|
@ -9,11 +9,14 @@ import config from '../config'
|
|||
|
||||
let store
|
||||
|
||||
const mocks = vi.hoisted(() => ({ resources: [] }))
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
AppBar: ({ userMenu }) => <div data-testid="appbar">{userMenu}</div>,
|
||||
MenuItemLink: ({ primaryText }) => <div>{primaryText}</div>,
|
||||
useTranslate: () => (x) => x,
|
||||
usePermissions: () => ({ permissions: 'admin' }),
|
||||
getResources: () => [],
|
||||
getResources: () => mocks.resources,
|
||||
}))
|
||||
|
||||
vi.mock('./NowPlayingPanel', () => ({
|
||||
|
|
@ -41,6 +44,7 @@ describe('<AppBar />', () => {
|
|||
config.devActivityPanel = true
|
||||
config.enableNowPlaying = true
|
||||
config.enableQuickConnect = false
|
||||
mocks.resources = []
|
||||
store = createStore(combineReducers({ activity: activityReducer }), {
|
||||
activity: { nowPlayingCount: 0 },
|
||||
})
|
||||
|
|
@ -84,4 +88,22 @@ describe('<AppBar />', () => {
|
|||
expect(screen.queryAllByText('menu.quickConnect.name')).toHaveLength(0)
|
||||
expect(screen.queryAllByText('menu.about')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses the resource label for settings items when set', () => {
|
||||
mocks.resources = [
|
||||
{
|
||||
name: 'player',
|
||||
hasList: true,
|
||||
options: { subMenu: 'settings', label: 'resources.player.menuName' },
|
||||
},
|
||||
{ name: 'transcoding', hasList: true, options: { subMenu: 'settings' } },
|
||||
]
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.getByText('resources.player.menuName')).toBeInTheDocument()
|
||||
expect(screen.getByText('resources.transcoding.name')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
107
ui/src/player/ApiKeyInput.jsx
Normal file
107
ui/src/player/ApiKeyInput.jsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useInput, useNotify, useTranslate } from 'react-admin'
|
||||
import { Button, TextField } from '@material-ui/core'
|
||||
import { FaKey } from 'react-icons/fa'
|
||||
import { MdContentCopy, MdDelete, MdRefresh } from 'react-icons/md'
|
||||
import { isWritable } from '../common/playlistUtils'
|
||||
import { generateApiKey } from './apiKey'
|
||||
|
||||
const identity = (v) => v
|
||||
const MASK = '•'.repeat(26)
|
||||
|
||||
const ApiKeyInput = ({ record, isCreate, fullWidth, className, ...props }) => {
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
// Identity format/parse keep "" (revoke) distinct from undefined (untouched)
|
||||
const {
|
||||
input: { value, onChange },
|
||||
meta: { error, touched },
|
||||
} = useInput({ ...props, format: identity, parse: identity })
|
||||
|
||||
const isOwner = isCreate || record?.userId === localStorage.getItem('userId')
|
||||
const pending = !!value
|
||||
const revoking = value === '' && !!record?.hasApiKey
|
||||
const saved = value == null && !!record?.hasApiKey
|
||||
const hasKey = pending || saved
|
||||
|
||||
const copy = () => {
|
||||
const fallback = () =>
|
||||
prompt(translate('message.shareCopyToClipboard'), value)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard
|
||||
.writeText(value)
|
||||
.then(
|
||||
() => notify('resources.player.notifications.apiKeyCopied'),
|
||||
fallback,
|
||||
)
|
||||
} else {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
|
||||
const helperText = pending
|
||||
? 'resources.player.message.apiKeyPending'
|
||||
: revoking
|
||||
? 'resources.player.message.apiKeyRevokePending'
|
||||
: saved
|
||||
? 'resources.player.message.apiKeyActive'
|
||||
: isOwner
|
||||
? 'resources.player.message.apiKeyNone'
|
||||
: 'resources.player.message.apiKeyNoneOther'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TextField
|
||||
label={translate('resources.player.fields.hasApiKey')}
|
||||
value={pending ? value : saved ? MASK : ''}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
fullWidth={fullWidth}
|
||||
className={className}
|
||||
// Monospace keeps the whole key visible in a standard-width input
|
||||
inputProps={{
|
||||
style: { fontFamily: 'monospace', fontSize: '0.875rem' },
|
||||
}}
|
||||
InputProps={{ readOnly: true }}
|
||||
error={!!(touched && error)}
|
||||
helperText={translate(touched && error ? error : helperText)}
|
||||
/>
|
||||
<div>
|
||||
{pending && (
|
||||
<Button startIcon={<MdContentCopy />} onClick={copy}>
|
||||
{translate('resources.player.actions.copyApiKey')}
|
||||
</Button>
|
||||
)}
|
||||
{isOwner && (
|
||||
<Button
|
||||
startIcon={hasKey ? <MdRefresh /> : <FaKey />}
|
||||
onClick={() => onChange(generateApiKey())}
|
||||
>
|
||||
{translate(
|
||||
hasKey
|
||||
? 'resources.player.actions.regenerateApiKey'
|
||||
: 'resources.player.actions.generateApiKey',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{saved && isWritable(record?.userId) && (
|
||||
<Button startIcon={<MdDelete />} onClick={() => onChange('')}>
|
||||
{translate('resources.player.actions.revokeApiKey')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ApiKeyInput.propTypes = {
|
||||
source: PropTypes.string.isRequired,
|
||||
record: PropTypes.object,
|
||||
isCreate: PropTypes.bool,
|
||||
fullWidth: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
validate: PropTypes.oneOfType([PropTypes.func, PropTypes.array]),
|
||||
}
|
||||
|
||||
export default ApiKeyInput
|
||||
172
ui/src/player/ApiKeyInput.test.jsx
Normal file
172
ui/src/player/ApiKeyInput.test.jsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import * as React from 'react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { Form } from 'react-final-form'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import ApiKeyInput from './ApiKeyInput'
|
||||
|
||||
const hooks = vi.hoisted(() => ({ notify: vi.fn() }))
|
||||
const KEY = 'nds_0123456789abcdefghijkl'
|
||||
const KEY_FORMAT = /^nds_[0-9A-Za-z]{22}$/
|
||||
|
||||
vi.mock('react-admin', async () => {
|
||||
const actual = await vi.importActual('react-admin')
|
||||
return {
|
||||
...actual,
|
||||
useNotify: () => hooks.notify,
|
||||
useTranslate: () => (key) => key,
|
||||
}
|
||||
})
|
||||
|
||||
const renderInput = ({
|
||||
record,
|
||||
initialValues = {},
|
||||
isCreate = false,
|
||||
fullWidth,
|
||||
}) => {
|
||||
let values
|
||||
const utils = render(
|
||||
<Form
|
||||
onSubmit={() => {}}
|
||||
initialValues={initialValues}
|
||||
render={({ values: v }) => {
|
||||
values = v
|
||||
return (
|
||||
<ApiKeyInput
|
||||
source="apiKey"
|
||||
record={record}
|
||||
isCreate={isCreate}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
return { ...utils, values: () => values }
|
||||
}
|
||||
|
||||
const text = (key) => screen.queryByText(key)
|
||||
|
||||
describe('ApiKeyInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.setItem('userId', 'owner')
|
||||
localStorage.setItem('role', 'regular')
|
||||
})
|
||||
|
||||
it('shows a pending key with copy and regenerate on create', () => {
|
||||
const { values } = renderInput({
|
||||
record: {},
|
||||
isCreate: true,
|
||||
initialValues: { apiKey: KEY },
|
||||
})
|
||||
expect(screen.getByDisplayValue(KEY)).toBeInTheDocument()
|
||||
expect(text('resources.player.message.apiKeyPending')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'resources.player.actions.copyApiKey',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
text('resources.player.actions.revokeApiKey'),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByText('resources.player.actions.regenerateApiKey'),
|
||||
)
|
||||
expect(values().apiKey).toMatch(KEY_FORMAT)
|
||||
expect(values().apiKey).not.toBe(KEY)
|
||||
})
|
||||
|
||||
it('masks a saved key and lets the owner regenerate or revoke', () => {
|
||||
const { values } = renderInput({
|
||||
record: { id: 'p1', userId: 'owner', hasApiKey: true },
|
||||
})
|
||||
expect(screen.queryByDisplayValue(/^nds_/)).not.toBeInTheDocument()
|
||||
expect(text('resources.player.message.apiKeyActive')).toBeInTheDocument()
|
||||
expect(
|
||||
text('resources.player.actions.regenerateApiKey'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('resources.player.actions.revokeApiKey'))
|
||||
expect(values().apiKey).toBe('')
|
||||
expect(
|
||||
text('resources.player.message.apiKeyRevokePending'),
|
||||
).toBeInTheDocument()
|
||||
expect(text('resources.player.actions.generateApiKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets the owner generate a key when there is none', () => {
|
||||
const { values } = renderInput({
|
||||
record: { id: 'p1', userId: 'owner', hasApiKey: false },
|
||||
})
|
||||
expect(values().apiKey).toBeUndefined()
|
||||
expect(text('resources.player.message.apiKeyNone')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('resources.player.actions.generateApiKey'))
|
||||
expect(values().apiKey).toMatch(KEY_FORMAT)
|
||||
expect(text('resources.player.message.apiKeyPending')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets an admin revoke but not set a key on another user player', () => {
|
||||
localStorage.setItem('role', 'admin')
|
||||
renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: true } })
|
||||
expect(
|
||||
text('resources.player.actions.regenerateApiKey'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(text('resources.player.actions.revokeApiKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to a prompt when the clipboard write fails', async () => {
|
||||
vi.stubGlobal('isSecureContext', true)
|
||||
vi.stubGlobal('prompt', vi.fn())
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
})
|
||||
try {
|
||||
renderInput({
|
||||
record: {},
|
||||
isCreate: true,
|
||||
initialValues: { apiKey: KEY },
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'resources.player.actions.copyApiKey',
|
||||
}),
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(window.prompt).toHaveBeenCalledWith(
|
||||
'message.shareCopyToClipboard',
|
||||
KEY,
|
||||
),
|
||||
)
|
||||
expect(hooks.notify).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
delete navigator.clipboard
|
||||
}
|
||||
})
|
||||
|
||||
it('shows a neutral message to an admin viewing another user player with no key', () => {
|
||||
localStorage.setItem('role', 'admin')
|
||||
renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: false } })
|
||||
expect(text('resources.player.message.apiKeyNoneOther')).toBeInTheDocument()
|
||||
expect(text('resources.player.message.apiKeyNone')).not.toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows no actions to another regular user', () => {
|
||||
renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: true } })
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is not full width unless asked', () => {
|
||||
const record = { id: 'p1', userId: 'owner', hasApiKey: true }
|
||||
const { container, unmount } = renderInput({ record })
|
||||
expect(container.querySelector('.MuiFormControl-fullWidth')).toBeNull()
|
||||
unmount()
|
||||
|
||||
const { container: wide } = renderInput({ record, fullWidth: true })
|
||||
expect(wide.querySelector('.MuiFormControl-fullWidth')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
33
ui/src/player/PlayerCreate.jsx
Normal file
33
ui/src/player/PlayerCreate.jsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React, { useMemo } from 'react'
|
||||
import { Create, SimpleForm, required, useTranslate } from 'react-admin'
|
||||
import { Title } from '../common'
|
||||
import { playerInputs } from './playerInputs'
|
||||
import ApiKeyInput from './ApiKeyInput'
|
||||
import { generateApiKey } from './apiKey'
|
||||
|
||||
const PlayerCreateTitle = () => {
|
||||
const translate = useTranslate()
|
||||
const resourceName = translate('resources.player.name', { smart_count: 1 })
|
||||
return (
|
||||
<Title subTitle={translate('ra.page.create', { name: resourceName })} />
|
||||
)
|
||||
}
|
||||
|
||||
const PlayerCreate = (props) => {
|
||||
// Memoized so re-renders don't swap the key the user may have already copied
|
||||
const initialValues = useMemo(() => ({ apiKey: generateApiKey() }), [])
|
||||
return (
|
||||
<Create title={<PlayerCreateTitle />} {...props}>
|
||||
<SimpleForm
|
||||
variant="outlined"
|
||||
redirect="list"
|
||||
initialValues={initialValues}
|
||||
>
|
||||
{playerInputs()}
|
||||
<ApiKeyInput source="apiKey" isCreate validate={required()} />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlayerCreate
|
||||
47
ui/src/player/PlayerCreate.test.jsx
Normal file
47
ui/src/player/PlayerCreate.test.jsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import * as React from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import PlayerCreate from './PlayerCreate'
|
||||
import ApiKeyInput from './ApiKeyInput'
|
||||
|
||||
const hooks = vi.hoisted(() => ({ forms: [] }))
|
||||
|
||||
vi.mock('react-admin', async () => {
|
||||
const actual = await vi.importActual('react-admin')
|
||||
return {
|
||||
...actual,
|
||||
Create: ({ children }) => children,
|
||||
SimpleForm: (props) => {
|
||||
hooks.forms.push(props)
|
||||
return null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('PlayerCreate', () => {
|
||||
beforeEach(() => {
|
||||
hooks.forms = []
|
||||
})
|
||||
|
||||
it('pre-fills one generated API key that survives re-renders', () => {
|
||||
const { rerender } = render(<PlayerCreate resource="player" />)
|
||||
rerender(<PlayerCreate resource="player" />)
|
||||
|
||||
const [first, second] = hooks.forms.map((f) => f.initialValues)
|
||||
expect(hooks.forms).toHaveLength(2)
|
||||
expect(first.apiKey).toMatch(/^nds_[0-9A-Za-z]{22}$/)
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('requires the API key', () => {
|
||||
render(<PlayerCreate resource="player" />)
|
||||
|
||||
const input = React.Children.toArray(hooks.forms[0].children).find(
|
||||
(child) => child.type === ApiKeyInput,
|
||||
)
|
||||
expect(input.props.source).toBe('apiKey')
|
||||
expect(input.props.isCreate).toBe(true)
|
||||
expect(input.props.validate('')).toBeTruthy()
|
||||
expect(input.props.validate('nds_0123456789abcdefghijkl')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
import {
|
||||
TextInput,
|
||||
BooleanInput,
|
||||
TextField,
|
||||
Edit,
|
||||
required,
|
||||
SimpleForm,
|
||||
SelectInput,
|
||||
ReferenceInput,
|
||||
useTranslate,
|
||||
DeleteButton,
|
||||
DeleteWithConfirmButton,
|
||||
SaveButton,
|
||||
Toolbar,
|
||||
} from 'react-admin'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { Title } from '../common'
|
||||
import config from '../config'
|
||||
import { BITRATE_CHOICES } from '../consts'
|
||||
import ApiKeyInput from './ApiKeyInput'
|
||||
import { playerInputs } from './playerInputs'
|
||||
|
||||
const PlayerTitle = ({ record }) => {
|
||||
const translate = useTranslate()
|
||||
|
|
@ -19,24 +19,35 @@ const PlayerTitle = ({ record }) => {
|
|||
return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} />
|
||||
}
|
||||
|
||||
const useToolbarStyles = makeStyles({
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
})
|
||||
|
||||
const PlayerEditToolbar = (props) => (
|
||||
<Toolbar {...props} classes={useToolbarStyles()}>
|
||||
<SaveButton />
|
||||
{props.record?.hasApiKey ? (
|
||||
<DeleteWithConfirmButton
|
||||
mutationMode="pessimistic"
|
||||
confirmTitle="resources.player.message.deleteWithKeyTitle"
|
||||
confirmContent="resources.player.message.deleteWithKeyContent"
|
||||
/>
|
||||
) : (
|
||||
<DeleteButton />
|
||||
)}
|
||||
</Toolbar>
|
||||
)
|
||||
|
||||
const PlayerEdit = (props) => (
|
||||
<Edit title={<PlayerTitle />} {...props}>
|
||||
<SimpleForm variant={'outlined'}>
|
||||
<TextInput source="name" validate={[required()]} />
|
||||
<ReferenceInput
|
||||
source="transcodingId"
|
||||
reference="transcoding"
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
>
|
||||
<SelectInput source="name" resettable />
|
||||
</ReferenceInput>
|
||||
<SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} />
|
||||
<BooleanInput source="reportRealPath" fullWidth />
|
||||
{(config.lastFMEnabled || config.listenBrainzEnabled) && (
|
||||
<BooleanInput source="scrobbleEnabled" fullWidth />
|
||||
)}
|
||||
<Edit title={<PlayerTitle />} mutationMode="pessimistic" {...props}>
|
||||
<SimpleForm variant={'outlined'} toolbar={<PlayerEditToolbar />}>
|
||||
{playerInputs()}
|
||||
<TextField source="client" />
|
||||
<TextField source="userName" />
|
||||
<ApiKeyInput source="apiKey" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
)
|
||||
|
|
|
|||
25
ui/src/player/PlayerEdit.test.jsx
Normal file
25
ui/src/player/PlayerEdit.test.jsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import * as React from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import PlayerEdit from './PlayerEdit'
|
||||
|
||||
const hooks = vi.hoisted(() => ({ editProps: null }))
|
||||
|
||||
vi.mock('react-admin', async () => {
|
||||
const actual = await vi.importActual('react-admin')
|
||||
return {
|
||||
...actual,
|
||||
Edit: (props) => {
|
||||
hooks.editProps = props
|
||||
return null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('PlayerEdit', () => {
|
||||
// An optimistic or undoable save would put the new key in react-admin's cache
|
||||
it('saves pessimistically', () => {
|
||||
render(<PlayerEdit resource="player" id="p1" />)
|
||||
expect(hooks.editProps.mutationMode).toBe('pessimistic')
|
||||
})
|
||||
})
|
||||
|
|
@ -2,18 +2,20 @@ import React from 'react'
|
|||
import {
|
||||
Datagrid,
|
||||
TextField,
|
||||
DateField,
|
||||
FunctionField,
|
||||
ReferenceField,
|
||||
Filter,
|
||||
SearchInput,
|
||||
NullableBooleanInput,
|
||||
} from 'react-admin'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { SimpleList, List } from '../common'
|
||||
import { FaKey } from 'react-icons/fa'
|
||||
import { SimpleList, List, DateField } from '../common'
|
||||
|
||||
const PlayerFilter = (props) => (
|
||||
<Filter {...props} variant={'outlined'}>
|
||||
<SearchInput id="search" source="name" alwaysOn />
|
||||
<NullableBooleanInput source="hasApiKey" alwaysOn />
|
||||
</Filter>
|
||||
)
|
||||
|
||||
|
|
@ -30,7 +32,11 @@ const PlayerList = ({ permissions, ...props }) => {
|
|||
<SimpleList
|
||||
primaryText={(r) => r.name}
|
||||
secondaryText={(r) => r.userName}
|
||||
tertiaryText={(r) => (r.maxBitRate ? r.maxBitRate : '-')}
|
||||
tertiaryText={(r) => (
|
||||
<>
|
||||
{r.hasApiKey && <FaKey />} {r.maxBitRate ? r.maxBitRate : '-'}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Datagrid rowClick="edit">
|
||||
|
|
@ -43,6 +49,11 @@ const PlayerList = ({ permissions, ...props }) => {
|
|||
source="maxBitRate"
|
||||
render={(r) => (r.maxBitRate ? r.maxBitRate : '-')}
|
||||
/>
|
||||
<FunctionField
|
||||
source="hasApiKey"
|
||||
sortable={false}
|
||||
render={(r) => (r.hasApiKey ? <FaKey /> : null)}
|
||||
/>
|
||||
<DateField source="lastSeen" showTime sortByOrder={'DESC'} />
|
||||
</Datagrid>
|
||||
)}
|
||||
|
|
|
|||
15
ui/src/player/apiKey.js
Normal file
15
ui/src/player/apiKey.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
const API_KEY_PREFIX = 'nds_'
|
||||
const ALPHABET =
|
||||
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
const KEY_LENGTH = 22
|
||||
|
||||
// Bytes >= 248 are dropped so every character is equally likely (248 = 4 * 62).
|
||||
export const generateApiKey = () => {
|
||||
let key = ''
|
||||
while (key.length < KEY_LENGTH) {
|
||||
for (const b of window.crypto.getRandomValues(new Uint8Array(32))) {
|
||||
if (b < 248 && key.length < KEY_LENGTH) key += ALPHABET[b % 62]
|
||||
}
|
||||
}
|
||||
return API_KEY_PREFIX + key
|
||||
}
|
||||
15
ui/src/player/apiKey.test.js
Normal file
15
ui/src/player/apiKey.test.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { generateApiKey } from './apiKey'
|
||||
|
||||
describe('generateApiKey', () => {
|
||||
it('returns the prefix plus 22 base62 characters', () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(generateApiKey()).toMatch(/^nds_[0-9A-Za-z]{22}$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns a different key each time', () => {
|
||||
const keys = new Set(Array.from({ length: 100 }, generateApiKey))
|
||||
expect(keys.size).toBe(100)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { BsFillMusicPlayerFill } from 'react-icons/bs'
|
||||
import PlayerList from './PlayerList'
|
||||
import PlayerEdit from './PlayerEdit'
|
||||
import PlayerCreate from './PlayerCreate'
|
||||
|
||||
export default {
|
||||
list: PlayerList,
|
||||
edit: PlayerEdit,
|
||||
create: PlayerCreate,
|
||||
icon: BsFillMusicPlayerFill,
|
||||
}
|
||||
|
|
|
|||
34
ui/src/player/playerInputs.jsx
Normal file
34
ui/src/player/playerInputs.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
BooleanInput,
|
||||
ReferenceInput,
|
||||
SelectInput,
|
||||
TextInput,
|
||||
required,
|
||||
} from 'react-admin'
|
||||
import config from '../config'
|
||||
import { BITRATE_CHOICES } from '../consts'
|
||||
|
||||
// Returned as an array, not a component, so SimpleForm still injects its props into each input.
|
||||
export const playerInputs = () =>
|
||||
[
|
||||
<TextInput key="name" source="name" validate={[required()]} />,
|
||||
<ReferenceInput
|
||||
key="transcodingId"
|
||||
source="transcodingId"
|
||||
reference="transcoding"
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
>
|
||||
<SelectInput source="name" resettable />
|
||||
</ReferenceInput>,
|
||||
<SelectInput
|
||||
key="maxBitRate"
|
||||
source="maxBitRate"
|
||||
resettable
|
||||
choices={BITRATE_CHOICES}
|
||||
/>,
|
||||
<BooleanInput key="reportRealPath" source="reportRealPath" fullWidth />,
|
||||
(config.lastFMEnabled || config.listenBrainzEnabled) && (
|
||||
<BooleanInput key="scrobbleEnabled" source="scrobbleEnabled" fullWidth />
|
||||
),
|
||||
].filter(Boolean)
|
||||
Loading…
Add table
Add a link
Reference in a new issue