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.
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+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
+ })
+})