mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
feat(ui): add app password manager
Adds a per-user table for generating, listing, and revoking named app passwords from the user edit screen. The plaintext secret is revealed once in a copy-to-clipboard dialog on creation; subsequent reads return only metadata (created / last used / expires).
This commit is contained in:
parent
42e4068c52
commit
47c3e2fea3
237
ui/src/user/AppPasswordManager.jsx
Normal file
237
ui/src/user/AppPasswordManager.jsx
Normal file
@ -0,0 +1,237 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardActions,
|
||||
CardContent,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@material-ui/core'
|
||||
import DeleteIcon from '@material-ui/icons/Delete'
|
||||
import FileCopyIcon from '@material-ui/icons/FileCopy'
|
||||
import { useNotify } from 'react-admin'
|
||||
import httpClient from '../dataProvider/httpClient'
|
||||
import { REST_URL } from '../consts'
|
||||
|
||||
// AppPasswordManager renders a simple per-user table of long-lived app
|
||||
// passwords used for Subsonic clients that cannot speak OIDC. The plaintext
|
||||
// secret is shown exactly once on creation; afterwards only metadata
|
||||
// (created/last used/expires) is visible.
|
||||
const AppPasswordManager = ({ userId }) => {
|
||||
const notify = useNotify()
|
||||
const [rows, setRows] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newExpiresAt, setNewExpiresAt] = useState('')
|
||||
const [createdSecret, setCreatedSecret] = useState(null)
|
||||
|
||||
const baseURL = `${REST_URL}/user/${userId}/app-password`
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true)
|
||||
httpClient(baseURL)
|
||||
.then((response) => {
|
||||
const data = response.json
|
||||
setRows(Array.isArray(data) ? data : [])
|
||||
})
|
||||
.catch((error) => notify(error.message || 'Failed to load app passwords', 'warning'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [baseURL, notify])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
const handleCreate = () => {
|
||||
const body = { name: newName }
|
||||
if (newExpiresAt) {
|
||||
body.expiresAt = new Date(newExpiresAt).toISOString()
|
||||
}
|
||||
httpClient(baseURL, { method: 'POST', body: JSON.stringify(body) })
|
||||
.then((response) => {
|
||||
setCreatedSecret(response.json)
|
||||
setNewName('')
|
||||
setNewExpiresAt('')
|
||||
setCreateOpen(false)
|
||||
refresh()
|
||||
})
|
||||
.catch((error) =>
|
||||
notify(error.message || 'Failed to create app password', 'warning'),
|
||||
)
|
||||
}
|
||||
|
||||
const handleDelete = (id) => {
|
||||
if (!window.confirm('Delete this app password? Clients using it will stop working.')) {
|
||||
return
|
||||
}
|
||||
httpClient(`${baseURL}/${id}`, { method: 'DELETE' })
|
||||
.then(() => refresh())
|
||||
.catch((error) =>
|
||||
notify(error.message || 'Failed to delete app password', 'warning'),
|
||||
)
|
||||
}
|
||||
|
||||
const copySecret = () => {
|
||||
if (!createdSecret?.secret) return
|
||||
navigator.clipboard
|
||||
?.writeText(createdSecret.secret)
|
||||
.then(() => notify('Secret copied to clipboard', 'info'))
|
||||
.catch(() => notify('Could not copy to clipboard', 'warning'))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card style={{ marginTop: 24 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6">App passwords (Subsonic clients)</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Generate a dedicated password for each Subsonic-compatible app. The
|
||||
secret is shown only once.
|
||||
</Typography>
|
||||
<Table size="small" style={{ marginTop: 16 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell>Last used</TableCell>
|
||||
<TableCell>Expires</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.name}</TableCell>
|
||||
<TableCell>
|
||||
{row.createdAt ? new Date(row.createdAt).toLocaleString() : ''}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.lastUsedAt
|
||||
? new Date(row.lastUsedAt).toLocaleString()
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.expiresAt
|
||||
? new Date(row.expiresAt).toLocaleString()
|
||||
: 'Never'}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => handleDelete(row.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!loading && rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="center">
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
No app passwords yet.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button color="primary" onClick={() => setCreateOpen(true)}>
|
||||
Generate new
|
||||
</Button>
|
||||
</CardActions>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
>
|
||||
<DialogTitle>New app password</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
label="Name"
|
||||
fullWidth
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
helperText="Friendly label, e.g. 'DSub on phone'"
|
||||
/>
|
||||
<TextField
|
||||
label="Expires"
|
||||
type="datetime-local"
|
||||
fullWidth
|
||||
value={newExpiresAt}
|
||||
onChange={(e) => setNewExpiresAt(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
helperText="Leave blank for no expiry"
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button color="primary" disabled={!newName} onClick={handleCreate}>
|
||||
Generate
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={createdSecret !== null}
|
||||
onClose={() => setCreatedSecret(null)}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
>
|
||||
<DialogTitle>Copy this secret now</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This secret is shown only once. Configure your Subsonic client with
|
||||
this username and the secret below — Navidrome cannot retrieve it
|
||||
again.
|
||||
</DialogContentText>
|
||||
{createdSecret && (
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={createdSecret.secret || ''}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
endAdornment: (
|
||||
<IconButton onClick={copySecret} size="small">
|
||||
<FileCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button color="primary" onClick={() => setCreatedSecret(null)}>
|
||||
Done
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
AppPasswordManager.propTypes = {
|
||||
userId: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
export default AppPasswordManager
|
||||
108
ui/src/user/AppPasswordManager.test.jsx
Normal file
108
ui/src/user/AppPasswordManager.test.jsx
Normal file
@ -0,0 +1,108 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import AppPasswordManager from './AppPasswordManager.jsx'
|
||||
|
||||
const notify = vi.fn()
|
||||
vi.mock('react-admin', () => ({
|
||||
useNotify: () => notify,
|
||||
}))
|
||||
|
||||
const httpClient = vi.fn()
|
||||
vi.mock('../dataProvider/httpClient', () => ({
|
||||
default: (...args) => httpClient(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../consts', () => ({
|
||||
REST_URL: '/api',
|
||||
}))
|
||||
|
||||
const password = (overrides = {}) => ({
|
||||
id: 'ap1',
|
||||
name: 'DSub',
|
||||
createdAt: '2026-05-01T10:00:00Z',
|
||||
lastUsedAt: null,
|
||||
expiresAt: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('<AppPasswordManager />', () => {
|
||||
beforeEach(() => {
|
||||
httpClient.mockReset()
|
||||
notify.mockReset()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('shows the empty state when the user has no app passwords', async () => {
|
||||
httpClient.mockResolvedValueOnce({ json: [] })
|
||||
|
||||
render(<AppPasswordManager userId="u1" />)
|
||||
|
||||
expect(await screen.findByText('No app passwords yet.')).toBeInTheDocument()
|
||||
expect(httpClient).toHaveBeenCalledWith('/api/user/u1/app-password')
|
||||
})
|
||||
|
||||
it('renders a row for each existing app password', async () => {
|
||||
httpClient.mockResolvedValueOnce({
|
||||
json: [password({ name: 'DSub' }), password({ id: 'ap2', name: 'Symfonium' })],
|
||||
})
|
||||
|
||||
render(<AppPasswordManager userId="u1" />)
|
||||
|
||||
expect(await screen.findByText('DSub')).toBeInTheDocument()
|
||||
expect(screen.getByText('Symfonium')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates a password and reveals the secret exactly once', async () => {
|
||||
httpClient
|
||||
.mockResolvedValueOnce({ json: [] }) // initial list
|
||||
.mockResolvedValueOnce({ json: { id: 'ap1', name: 'CLI', secret: 's3cret' } }) // create
|
||||
.mockResolvedValueOnce({ json: [password({ name: 'CLI' })] }) // refresh
|
||||
|
||||
render(<AppPasswordManager userId="u1" />)
|
||||
await screen.findByText('No app passwords yet.')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate new/i }))
|
||||
const nameInput = screen.getAllByRole('textbox')[0]
|
||||
fireEvent.change(nameInput, { target: { value: 'CLI' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^generate$/i }))
|
||||
|
||||
expect(await screen.findByDisplayValue('s3cret')).toBeInTheDocument()
|
||||
expect(httpClient).toHaveBeenCalledWith('/api/user/u1/app-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: 'CLI' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a password only after the user confirms', async () => {
|
||||
httpClient
|
||||
.mockResolvedValueOnce({ json: [password({ id: 'ap1', name: 'DSub' })] }) // initial list
|
||||
.mockResolvedValueOnce({ json: {} }) // delete
|
||||
.mockResolvedValueOnce({ json: [] }) // refresh
|
||||
|
||||
render(<AppPasswordManager userId="u1" />)
|
||||
const row = await screen.findByText('DSub')
|
||||
|
||||
fireEvent.click(row.closest('tr').querySelector('button'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(httpClient).toHaveBeenCalledWith('/api/user/u1/app-password/ap1', {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not delete when the user cancels the confirmation', async () => {
|
||||
window.confirm.mockReturnValue(false)
|
||||
httpClient.mockResolvedValueOnce({
|
||||
json: [password({ id: 'ap1', name: 'DSub' })],
|
||||
})
|
||||
|
||||
render(<AppPasswordManager userId="u1" />)
|
||||
const row = await screen.findByText('DSub')
|
||||
|
||||
fireEvent.click(row.closest('tr').querySelector('button'))
|
||||
|
||||
expect(httpClient).toHaveBeenCalledTimes(1) // only the initial list
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user