feat(web/m7-user-mgmt): /reset-password page + admin SMTP card + tests (U3-T5b)

Completes the U3 frontend that T5a's crashed dispatch left half-done.

- /reset-password/[token] — public; new password + confirm. Calls
  POST /api/auth/reset-password with the URL's token. invalid_token,
  password_too_short, and mismatched-passwords surface as inline
  errors. Success redirects to /login?reset=ok.
- /admin/integrations gains an SMTP card (alongside Lidarr) with
  enabled toggle + all fields + Save and Send-test-email buttons.
  Password input is a separate state from the loaded form, so empty
  submission preserves the stored server-side value (matches the
  backend's empty-password-preserves contract). Test button error
  codes (no_email_on_file / not_configured / send_failed) surface
  as actionable toasts.

Tests cover both new pages plus extensions to settings + integrations
test files for the cards landed in T5a.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 17:43:47 -04:00
co-authored by Claude Sonnet 4.6
parent 72f46a885b
commit 5da2c85f70
6 changed files with 500 additions and 5 deletions
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { LidarrConfig } from '$lib/api/types';
import type { CoverProvider } from '$lib/api/admin';
import type { CoverProvider, SMTPConfig } from '$lib/api/admin';
// useQueryClient is wrapped so the page can call invalidateQueries() without
// a real QueryClient context. Everything else from svelte-query passes through.
@@ -21,7 +21,10 @@ vi.mock('$lib/api/admin', () => ({
testLidarrConnection: vi.fn(),
createCoverProvidersQuery: vi.fn(),
updateCoverProvider: vi.fn(),
testCoverProvider: vi.fn()
testCoverProvider: vi.fn(),
createSMTPConfigQuery: vi.fn(),
updateSMTPConfig: vi.fn(),
testSMTPConfig: vi.fn()
}));
import IntegrationsPage from './+page.svelte';
@@ -34,7 +37,10 @@ import {
testLidarrConnection,
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider
testCoverProvider,
createSMTPConfigQuery,
updateSMTPConfig,
testSMTPConfig
} from '$lib/api/admin';
const cfgConnected: LidarrConfig = {
@@ -88,12 +94,34 @@ const defaultCoverProviders = {
sources_version: 1
};
const smtpUnset: SMTPConfig = {
enabled: false,
host: '',
port: 587,
username: '',
password: '',
from_address: '',
from_name: 'Minstrel',
use_tls: true
};
const smtpConfigured: SMTPConfig = {
enabled: true,
host: 'smtp.example.com',
port: 587,
username: 'user@example.com',
password: '***',
from_address: 'noreply@example.com',
from_name: 'Minstrel',
use_tls: true
};
afterEach(() => {
vi.clearAllMocks();
invalidateQueries.mockReset();
});
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null } = {}) {
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; smtp?: SMTPConfig } = {}) {
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.config ?? cfgConnected })
);
@@ -120,6 +148,9 @@ function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCov
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: cpData, isPending: opts.coverProviders === null })
);
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.smtp ?? smtpUnset })
);
return render(IntegrationsPage);
}
@@ -412,3 +443,68 @@ describe('/admin/integrations — cover art providers', () => {
expect(screen.getByText(/loading…/i)).toBeInTheDocument();
});
});
function smtpSection(): HTMLElement {
return screen.getByRole('heading', { name: /email \(smtp\)/i, level: 3 }).closest('section') as HTMLElement;
}
describe('/admin/integrations — SMTP card', () => {
test('renders "Email (SMTP)" heading', () => {
setup();
expect(screen.getByRole('heading', { name: /email \(smtp\)/i, level: 3 })).toBeInTheDocument();
});
test('Save calls updateSMTPConfig with form values; empty password preserves stored value', async () => {
setup({ smtp: smtpConfigured });
(updateSMTPConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
// password input starts empty — should send '' which tells backend to preserve
const section = smtpSection();
await fireEvent.click(within(section).getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(updateSMTPConfig).toHaveBeenCalledWith(
expect.objectContaining({ password: '' })
)
);
});
test('Save with a typed password sends the typed value', async () => {
setup({ smtp: smtpConfigured });
(updateSMTPConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const section = smtpSection();
const pwInput = within(section).getByLabelText(/password/i);
await fireEvent.input(pwInput, { target: { value: 'newsecret' } });
await fireEvent.click(within(section).getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(updateSMTPConfig).toHaveBeenCalledWith(
expect.objectContaining({ password: 'newsecret' })
)
);
});
test('Test button calls testSMTPConfig', async () => {
setup();
(testSMTPConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const section = smtpSection();
await fireEvent.click(within(section).getByRole('button', { name: /send test email/i }));
await waitFor(() => expect(testSMTPConfig).toHaveBeenCalled());
});
test('no_email_on_file error from testSMTPConfig surfaces actionable toast', async () => {
setup();
(testSMTPConfig as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ code: 'no_email_on_file' });
const section = smtpSection();
await fireEvent.click(within(section).getByRole('button', { name: /send test email/i }));
await waitFor(() =>
expect(screen.getByTestId('toast').textContent).toMatch(/set your email in \/settings/i)
);
});
test('password input placeholder shows hint when existing password is masked', async () => {
setup({ smtp: smtpConfigured });
// smtpConfigured has password: '***' — placeholder should show "Leave blank to keep current"
const section = smtpSection();
await waitFor(() =>
expect(within(section).getByPlaceholderText(/leave blank to keep current/i)).toBeInTheDocument()
);
});
});