From 47c3e2fea3fb17f31cf263e659d8a2e502fc1843 Mon Sep 17 00:00:00 2001 From: zkvvoob Date: Wed, 20 May 2026 13:53:20 +0300 Subject: [PATCH] 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). --- ui/src/user/AppPasswordManager.jsx | 237 ++++++++++++++++++++++++ ui/src/user/AppPasswordManager.test.jsx | 108 +++++++++++ 2 files changed, 345 insertions(+) create mode 100644 ui/src/user/AppPasswordManager.jsx create mode 100644 ui/src/user/AppPasswordManager.test.jsx diff --git a/ui/src/user/AppPasswordManager.jsx b/ui/src/user/AppPasswordManager.jsx new file mode 100644 index 000000000..168ebdf5e --- /dev/null +++ b/ui/src/user/AppPasswordManager.jsx @@ -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 ( + + + App passwords (Subsonic clients) + + Generate a dedicated password for each Subsonic-compatible app. The + secret is shown only once. + + + + + Name + Created + Last used + Expires + + + + + {rows.map((row) => ( + + {row.name} + + {row.createdAt ? new Date(row.createdAt).toLocaleString() : ''} + + + {row.lastUsedAt + ? new Date(row.lastUsedAt).toLocaleString() + : '—'} + + + {row.expiresAt + ? new Date(row.expiresAt).toLocaleString() + : 'Never'} + + + + handleDelete(row.id)}> + + + + + + ))} + {!loading && rows.length === 0 && ( + + + + No app passwords yet. + + + + )} + +
+
+ + + + + setCreateOpen(false)} + fullWidth + maxWidth="xs" + > + New app password + + setNewName(e.target.value)} + helperText="Friendly label, e.g. 'DSub on phone'" + /> + setNewExpiresAt(e.target.value)} + InputLabelProps={{ shrink: true }} + helperText="Leave blank for no expiry" + style={{ marginTop: 16 }} + /> + + + + + + + + setCreatedSecret(null)} + fullWidth + maxWidth="sm" + > + Copy this secret now + + + This secret is shown only once. Configure your Subsonic client with + this username and the secret below — Navidrome cannot retrieve it + again. + + {createdSecret && ( + + + + ), + }} + style={{ marginTop: 16 }} + /> + )} + + + + + +
+ ) +} + +AppPasswordManager.propTypes = { + userId: PropTypes.string.isRequired, +} + +export default AppPasswordManager diff --git a/ui/src/user/AppPasswordManager.test.jsx b/ui/src/user/AppPasswordManager.test.jsx new file mode 100644 index 000000000..e2ee97049 --- /dev/null +++ b/ui/src/user/AppPasswordManager.test.jsx @@ -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('', () => { + 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() + + 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() + + 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() + 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() + 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() + const row = await screen.findByText('DSub') + + fireEvent.click(row.closest('tr').querySelector('button')) + + expect(httpClient).toHaveBeenCalledTimes(1) // only the initial list + }) +})