diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index d1ee8e3e..8ef6d5e1 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -12,7 +12,11 @@ createCoverProvidersQuery, updateCoverProvider, testCoverProvider, - type CoverProvider + updateSMTPConfig, + testSMTPConfig, + createSMTPConfigQuery, + type CoverProvider, + type SMTPConfig } from '$lib/api/admin'; import { qk } from '$lib/api/queries'; import type { LidarrConfig, LidarrTestResult } from '$lib/api/types'; @@ -226,10 +230,81 @@ coverTesting[provider.id] = false; } } + + // Toast ------------------------------------------------------------------- + + let toast = $state(null); + let toastTimer: ReturnType | undefined; + + function showToast(msg: string) { + toast = msg; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => { toast = null; }, 4000); + } + + // SMTP config ------------------------------------------------------------- + + const smtpStore = $derived(createSMTPConfigQuery()); + const smtpQ = $derived($smtpStore); + + let smtpForm = $state({ + enabled: false, host: '', port: 587, username: '', + password: '', from_address: '', from_name: 'Minstrel', use_tls: true, + }); + let smtpPasswordInput = $state(''); + let smtpSaving = $state(false); + let smtpTesting = $state(false); + + $effect(() => { + if (smtpQ.data) { + smtpForm = smtpQ.data; + smtpPasswordInput = ''; + } + }); + + async function onSaveSMTP() { + smtpSaving = true; + try { + await updateSMTPConfig({ ...smtpForm, password: smtpPasswordInput }); + await client.invalidateQueries({ queryKey: qk.smtpConfig() }); + smtpPasswordInput = ''; + showToast('SMTP config saved.'); + } catch (e: unknown) { + const code = (e as { code?: string })?.code; + if (code === 'missing_fields') showToast('Host and from address are required when enabled.'); + else showToast(`Save failed: ${code ?? 'unknown'}`); + } finally { + smtpSaving = false; + } + } + + async function onTestSMTP() { + smtpTesting = true; + try { + await testSMTPConfig(); + showToast('Test email sent. Check your inbox.'); + } catch (e: unknown) { + const code = (e as { code?: string })?.code; + const message = (e as { message?: string })?.message; + if (code === 'no_email_on_file') showToast('Set your email in /settings before testing.'); + else if (code === 'not_configured') showToast('Save the SMTP config first.'); + else if (code === 'send_failed') showToast(`Send failed: ${message || 'see server logs'}`); + else showToast(`Test failed: ${code ?? 'unknown'}`); + } finally { + smtpTesting = false; + } + } {pageTitle('Admin · Integrations')} +{#if toast} +
+ {toast} +
+{/if} +

Integrations

@@ -476,6 +551,67 @@

No providers registered.

{/if} + +
+
+

Email (SMTP)

+

+ Configure outgoing email for password resets. The "Send test email" + button verifies your config end-to-end. +

+
+ + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+
{#if modalOpen} diff --git a/web/src/routes/admin/integrations/integrations.test.ts b/web/src/routes/admin/integrations/integrations.test.ts index e8fc3a3e..f4dc9ab9 100644 --- a/web/src/routes/admin/integrations/integrations.test.ts +++ b/web/src/routes/admin/integrations/integrations.test.ts @@ -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).mockReturnValue( mockQuery({ data: opts.config ?? cfgConnected }) ); @@ -120,6 +148,9 @@ function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCov (createCoverProvidersQuery as ReturnType).mockReturnValue( mockQuery({ data: cpData, isPending: opts.coverProviders === null }) ); + (createSMTPConfigQuery as ReturnType).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).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).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).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).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() + ); + }); +}); diff --git a/web/src/routes/forgot-password/forgot-password.test.ts b/web/src/routes/forgot-password/forgot-password.test.ts new file mode 100644 index 00000000..6c2f2371 --- /dev/null +++ b/web/src/routes/forgot-password/forgot-password.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, fireEvent, screen, waitFor } from '@testing-library/svelte'; + +vi.mock('$lib/auth/store.svelte', () => ({ + forgotPassword: vi.fn(), +})); + +import { forgotPassword } from '$lib/auth/store.svelte'; +import ForgotPasswordPage from './+page.svelte'; + +describe('/forgot-password page', () => { + beforeEach(() => vi.clearAllMocks()); + + it('shows success message after submit regardless of whether email exists', async () => { + (forgotPassword as unknown as ReturnType).mockResolvedValue(undefined); + render(ForgotPasswordPage); + await fireEvent.input(screen.getByLabelText(/Email/i), { target: { value: 'test@example.com' } }); + await fireEvent.click(screen.getByRole('button', { name: /Send reset link/i })); + await waitFor(() => expect(screen.getByText(/If your email is on file/i)).toBeInTheDocument()); + expect(forgotPassword).toHaveBeenCalledWith('test@example.com'); + }); + + it('still shows success message when the API call rejects (no enumeration)', async () => { + (forgotPassword as unknown as ReturnType).mockRejectedValue({ code: 'server_error' }); + render(ForgotPasswordPage); + await fireEvent.input(screen.getByLabelText(/Email/i), { target: { value: 'test@example.com' } }); + await fireEvent.click(screen.getByRole('button', { name: /Send reset link/i })); + await waitFor(() => expect(screen.getByText(/If your email is on file/i)).toBeInTheDocument()); + }); +}); diff --git a/web/src/routes/reset-password/[token]/+page.svelte b/web/src/routes/reset-password/[token]/+page.svelte new file mode 100644 index 00000000..2854e3ed --- /dev/null +++ b/web/src/routes/reset-password/[token]/+page.svelte @@ -0,0 +1,86 @@ + + +{pageTitle('Reset password')} + +
+
+

Set a new password

+
+ + + {#if error} + + {/if} + +
+

+ Back to login +

+
+
diff --git a/web/src/routes/reset-password/[token]/reset-password.test.ts b/web/src/routes/reset-password/[token]/reset-password.test.ts new file mode 100644 index 00000000..f0c31409 --- /dev/null +++ b/web/src/routes/reset-password/[token]/reset-password.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, fireEvent, screen, waitFor } from '@testing-library/svelte'; + +vi.mock('$lib/auth/store.svelte', () => ({ + resetPassword: vi.fn(), +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn(), +})); + +vi.mock('$app/state', () => ({ + page: { params: { token: 'test-token-abc' } }, +})); + +import { resetPassword } from '$lib/auth/store.svelte'; +import { goto } from '$app/navigation'; +import ResetPasswordPage from './+page.svelte'; + +describe('/reset-password/[token] page', () => { + beforeEach(() => vi.clearAllMocks()); + + it('rejects mismatched passwords without calling resetPassword', async () => { + render(ResetPasswordPage); + await fireEvent.input(screen.getByLabelText(/New password/i), { target: { value: 'abcd1234' } }); + await fireEvent.input(screen.getByLabelText(/Confirm new password/i), { target: { value: 'differentpw' } }); + await fireEvent.click(screen.getByRole('button', { name: /Set password/i })); + expect(resetPassword).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/do not match/i)); + }); + + it('calls resetPassword with the URL token and new password, redirects on success', async () => { + (resetPassword as unknown as ReturnType).mockResolvedValue(undefined); + render(ResetPasswordPage); + await fireEvent.input(screen.getByLabelText(/New password/i), { target: { value: 'abcd1234' } }); + await fireEvent.input(screen.getByLabelText(/Confirm new password/i), { target: { value: 'abcd1234' } }); + await fireEvent.click(screen.getByRole('button', { name: /Set password/i })); + expect(resetPassword).toHaveBeenCalledWith('test-token-abc', 'abcd1234'); + await waitFor(() => expect(goto).toHaveBeenCalledWith('/login?reset=ok', expect.objectContaining({ replaceState: true }))); + }); + + it('shows clear error on invalid_token', async () => { + (resetPassword as unknown as ReturnType).mockRejectedValueOnce({ code: 'invalid_token' }); + render(ResetPasswordPage); + await fireEvent.input(screen.getByLabelText(/New password/i), { target: { value: 'abcd1234' } }); + await fireEvent.input(screen.getByLabelText(/Confirm new password/i), { target: { value: 'abcd1234' } }); + await fireEvent.click(screen.getByRole('button', { name: /Set password/i })); + await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/invalid, expired, or already used/i)); + }); +}); diff --git a/web/src/routes/settings/settings.test.ts b/web/src/routes/settings/settings.test.ts index f31a6296..1bd7d53b 100644 --- a/web/src/routes/settings/settings.test.ts +++ b/web/src/routes/settings/settings.test.ts @@ -11,6 +11,13 @@ vi.mock('$lib/api/listenbrainz', () => ({ setListenBrainzEnabled: vi.fn() })); +vi.mock('$lib/api/me', () => ({ + updateProfile: vi.fn(), + changePassword: vi.fn(), + getAPIToken: vi.fn(), + regenerateAPIToken: vi.fn() +})); + vi.mock('@tanstack/svelte-query', async (orig) => { const actual = (await orig()) as Record; return { @@ -27,6 +34,12 @@ import { setListenBrainzToken, setListenBrainzEnabled } from '$lib/api/listenbrainz'; +import { + updateProfile, + changePassword, + getAPIToken, + regenerateAPIToken +} from '$lib/api/me'; function mockStatusStore(data: LBStatus) { return readable({ data, isPending: false, isError: false }); @@ -100,3 +113,87 @@ describe('Settings page — ListenBrainz', () => { ); }); }); + +// Shared setup for cards that don't need LB state +function setupPage() { + (createLBStatusQuery as ReturnType).mockReturnValue( + mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null }) + ); + (createTokenMutation as ReturnType).mockReturnValue(mockMutationStore()); + (createEnabledMutation as ReturnType).mockReturnValue(mockMutationStore()); + (getAPIToken as ReturnType).mockResolvedValue({ api_token: 'tok_abc123' }); +} + +describe('Settings page — Profile card', () => { + test('Save profile calls updateProfile with form values', async () => { + setupPage(); + (updateProfile as ReturnType).mockResolvedValue({}); + render(SettingsPage); + await fireEvent.input(screen.getByLabelText(/display name/i), { target: { value: 'Alice' } }); + await fireEvent.input(screen.getByLabelText(/email/i), { target: { value: 'alice@example.com' } }); + await fireEvent.click(screen.getByRole('button', { name: /save profile/i })); + await waitFor(() => + expect(updateProfile).toHaveBeenCalledWith( + expect.objectContaining({ display_name: 'Alice', email: 'alice@example.com' }) + ) + ); + }); + + test('Save profile shows toast on success', async () => { + setupPage(); + (updateProfile as ReturnType).mockResolvedValue({}); + render(SettingsPage); + await fireEvent.click(screen.getByRole('button', { name: /save profile/i })); + await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/profile saved/i)); + }); +}); + +describe('Settings page — Password card', () => { + test('mismatched passwords show toast and do not call changePassword', async () => { + setupPage(); + render(SettingsPage); + await fireEvent.input(screen.getByLabelText(/current password/i), { target: { value: 'oldpw1234' } }); + await fireEvent.input(screen.getByLabelText(/new password/i), { target: { value: 'newpw1234' } }); + await fireEvent.input(screen.getByLabelText(/confirm/i), { target: { value: 'different1' } }); + await fireEvent.click(screen.getByRole('button', { name: /change password/i })); + expect(changePassword).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/do not match/i)); + }); + + test('matched passwords call changePassword(current, new)', async () => { + setupPage(); + (changePassword as ReturnType).mockResolvedValue(undefined); + render(SettingsPage); + await fireEvent.input(screen.getByLabelText(/current password/i), { target: { value: 'oldpw1234' } }); + await fireEvent.input(screen.getByLabelText(/new password/i), { target: { value: 'newpw1234' } }); + await fireEvent.input(screen.getByLabelText(/confirm/i), { target: { value: 'newpw1234' } }); + await fireEvent.click(screen.getByRole('button', { name: /change password/i })); + await waitFor(() => + expect(changePassword).toHaveBeenCalledWith('oldpw1234', 'newpw1234') + ); + }); +}); + +describe('Settings page — API Token card', () => { + test('first click on Regenerate shows "Click again to confirm"', async () => { + setupPage(); + render(SettingsPage); + await fireEvent.click(screen.getByRole('button', { name: /regenerate/i })); + await waitFor(() => + expect(screen.getByRole('button', { name: /click again to confirm/i })).toBeInTheDocument() + ); + expect(regenerateAPIToken).not.toHaveBeenCalled(); + }); + + test('second click within 5s calls regenerateAPIToken', async () => { + setupPage(); + (regenerateAPIToken as ReturnType).mockResolvedValue({ api_token: 'new_tok_xyz' }); + render(SettingsPage); + await fireEvent.click(screen.getByRole('button', { name: /regenerate/i })); + await waitFor(() => + expect(screen.getByRole('button', { name: /click again to confirm/i })).toBeInTheDocument() + ); + await fireEvent.click(screen.getByRole('button', { name: /click again to confirm/i })); + await waitFor(() => expect(regenerateAPIToken).toHaveBeenCalled()); + }); +});