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:
zkvvoob 2026-05-20 13:53:20 +03:00
parent 42e4068c52
commit 47c3e2fea3
No known key found for this signature in database
GPG Key ID: 3CBAEDB5B3509ECE
2 changed files with 345 additions and 0 deletions

View 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

View 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
})
})