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:
@@ -12,7 +12,11 @@
|
|||||||
createCoverProvidersQuery,
|
createCoverProvidersQuery,
|
||||||
updateCoverProvider,
|
updateCoverProvider,
|
||||||
testCoverProvider,
|
testCoverProvider,
|
||||||
type CoverProvider
|
updateSMTPConfig,
|
||||||
|
testSMTPConfig,
|
||||||
|
createSMTPConfigQuery,
|
||||||
|
type CoverProvider,
|
||||||
|
type SMTPConfig
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
|
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
|
||||||
@@ -226,10 +230,81 @@
|
|||||||
coverTesting[provider.id] = false;
|
coverTesting[provider.id] = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toast -------------------------------------------------------------------
|
||||||
|
|
||||||
|
let toast = $state<string | null>(null);
|
||||||
|
let toastTimer: ReturnType<typeof setTimeout> | 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<SMTPConfig>({
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{pageTitle('Admin · Integrations')}</title></svelte:head>
|
<svelte:head><title>{pageTitle('Admin · Integrations')}</title></svelte:head>
|
||||||
|
|
||||||
|
{#if toast}
|
||||||
|
<div data-testid="toast" role="status"
|
||||||
|
class="fixed bottom-4 right-4 z-50 rounded-md border border-border bg-surface px-4 py-2 text-sm text-text-primary shadow">
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<header class="flex items-center justify-between">
|
<header class="flex items-center justify-between">
|
||||||
<h2 class="font-display text-2xl font-medium text-text-primary">Integrations</h2>
|
<h2 class="font-display text-2xl font-medium text-text-primary">Integrations</h2>
|
||||||
@@ -476,6 +551,67 @@
|
|||||||
<p class="text-sm text-text-muted">No providers registered.</p>
|
<p class="text-sm text-text-muted">No providers registered.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
<!-- SMTP / email config -->
|
||||||
|
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-display text-lg font-medium text-text-primary">Email (SMTP)</h3>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
Configure outgoing email for password resets. The "Send test email"
|
||||||
|
button verifies your config end-to-end.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" bind:checked={smtpForm.enabled} />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
|
||||||
|
<label for="smtp-host" class="text-sm text-text-secondary">Host</label>
|
||||||
|
<input id="smtp-host" type="text" bind:value={smtpForm.host}
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" />
|
||||||
|
|
||||||
|
<label for="smtp-port" class="text-sm text-text-secondary">Port</label>
|
||||||
|
<input id="smtp-port" type="number" min="1" max="65535"
|
||||||
|
bind:value={smtpForm.port}
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" />
|
||||||
|
|
||||||
|
<label for="smtp-username" class="text-sm text-text-secondary">Username</label>
|
||||||
|
<input id="smtp-username" type="text" bind:value={smtpForm.username}
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" />
|
||||||
|
|
||||||
|
<label for="smtp-password" class="text-sm text-text-secondary">Password</label>
|
||||||
|
<input id="smtp-password" type="password"
|
||||||
|
placeholder={smtpForm.password === '***' ? 'Leave blank to keep current' : ''}
|
||||||
|
bind:value={smtpPasswordInput}
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" />
|
||||||
|
|
||||||
|
<label for="smtp-from" class="text-sm text-text-secondary">From address</label>
|
||||||
|
<input id="smtp-from" type="email" bind:value={smtpForm.from_address}
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" />
|
||||||
|
|
||||||
|
<label for="smtp-from-name" class="text-sm text-text-secondary">From name</label>
|
||||||
|
<input id="smtp-from-name" type="text" bind:value={smtpForm.from_name}
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" />
|
||||||
|
|
||||||
|
<label class="text-sm text-text-secondary">TLS</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" bind:checked={smtpForm.use_tls} />
|
||||||
|
Use STARTTLS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 pt-2">
|
||||||
|
<button type="button" disabled={smtpSaving} onclick={onSaveSMTP}
|
||||||
|
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
|
||||||
|
{smtpSaving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={smtpTesting} onclick={onTestSMTP}
|
||||||
|
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
||||||
|
{smtpTesting ? 'Sending…' : 'Send test email'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if modalOpen}
|
{#if modalOpen}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
|
||||||
import { mockQuery } from '../../../test-utils/query';
|
import { mockQuery } from '../../../test-utils/query';
|
||||||
import type { LidarrConfig } from '$lib/api/types';
|
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
|
// useQueryClient is wrapped so the page can call invalidateQueries() without
|
||||||
// a real QueryClient context. Everything else from svelte-query passes through.
|
// a real QueryClient context. Everything else from svelte-query passes through.
|
||||||
@@ -21,7 +21,10 @@ vi.mock('$lib/api/admin', () => ({
|
|||||||
testLidarrConnection: vi.fn(),
|
testLidarrConnection: vi.fn(),
|
||||||
createCoverProvidersQuery: vi.fn(),
|
createCoverProvidersQuery: vi.fn(),
|
||||||
updateCoverProvider: 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';
|
import IntegrationsPage from './+page.svelte';
|
||||||
@@ -34,7 +37,10 @@ import {
|
|||||||
testLidarrConnection,
|
testLidarrConnection,
|
||||||
createCoverProvidersQuery,
|
createCoverProvidersQuery,
|
||||||
updateCoverProvider,
|
updateCoverProvider,
|
||||||
testCoverProvider
|
testCoverProvider,
|
||||||
|
createSMTPConfigQuery,
|
||||||
|
updateSMTPConfig,
|
||||||
|
testSMTPConfig
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
|
|
||||||
const cfgConnected: LidarrConfig = {
|
const cfgConnected: LidarrConfig = {
|
||||||
@@ -88,12 +94,34 @@ const defaultCoverProviders = {
|
|||||||
sources_version: 1
|
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(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
invalidateQueries.mockReset();
|
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(
|
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
mockQuery({ data: opts.config ?? cfgConnected })
|
mockQuery({ data: opts.config ?? cfgConnected })
|
||||||
);
|
);
|
||||||
@@ -120,6 +148,9 @@ function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCov
|
|||||||
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
mockQuery({ data: cpData, isPending: opts.coverProviders === null })
|
mockQuery({ data: cpData, isPending: opts.coverProviders === null })
|
||||||
);
|
);
|
||||||
|
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockQuery({ data: opts.smtp ?? smtpUnset })
|
||||||
|
);
|
||||||
return render(IntegrationsPage);
|
return render(IntegrationsPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,3 +443,68 @@ describe('/admin/integrations — cover art providers', () => {
|
|||||||
expect(screen.getByText(/loading…/i)).toBeInTheDocument();
|
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()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { pageTitle } from '$lib/branding';
|
||||||
|
import { resetPassword } from '$lib/auth/store.svelte';
|
||||||
|
|
||||||
|
let newPassword = $state('');
|
||||||
|
let confirmPassword = $state('');
|
||||||
|
let submitting = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
const token = $derived(page.params.token);
|
||||||
|
|
||||||
|
async function onSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = null;
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
error = 'Passwords do not match.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting = true;
|
||||||
|
try {
|
||||||
|
await resetPassword(token, newPassword);
|
||||||
|
await goto('/login?reset=ok', { replaceState: true });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const code = (e as { code?: string })?.code;
|
||||||
|
if (code === 'invalid_token') {
|
||||||
|
error = 'This reset link is invalid, expired, or already used. Request a new one.';
|
||||||
|
} else if (code === 'password_too_short') {
|
||||||
|
error = 'Password must be at least 8 characters.';
|
||||||
|
} else {
|
||||||
|
error = `Reset failed: ${code ?? 'unknown'}`;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{pageTitle('Reset password')}</title></svelte:head>
|
||||||
|
|
||||||
|
<main class="flex min-h-screen items-center justify-center bg-background text-text-primary">
|
||||||
|
<div class="w-full max-w-sm rounded-lg border border-border bg-surface p-6 shadow">
|
||||||
|
<h1 class="mb-6 text-center text-2xl font-semibold">Set a new password</h1>
|
||||||
|
<form class="space-y-4" onsubmit={onSubmit}>
|
||||||
|
<label class="block">
|
||||||
|
<span class="mb-1 block text-sm text-text-secondary">New password</span>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autocomplete="new-password"
|
||||||
|
bind:value={newPassword}
|
||||||
|
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="mb-1 block text-sm text-text-secondary">Confirm new password</span>
|
||||||
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autocomplete="new-password"
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if error}
|
||||||
|
<p role="alert" class="text-sm text-danger">{error}</p>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
aria-busy={submitting ? 'true' : undefined}
|
||||||
|
class="w-full rounded bg-accent px-3 py-2 font-medium text-background disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{submitting ? 'Setting…' : 'Set password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-4 text-center text-sm text-text-secondary">
|
||||||
|
<a href="/login" class="text-accent hover:underline">Back to login</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,13 @@ vi.mock('$lib/api/listenbrainz', () => ({
|
|||||||
setListenBrainzEnabled: vi.fn()
|
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) => {
|
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||||
const actual = (await orig()) as Record<string, unknown>;
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
@@ -27,6 +34,12 @@ import {
|
|||||||
setListenBrainzToken,
|
setListenBrainzToken,
|
||||||
setListenBrainzEnabled
|
setListenBrainzEnabled
|
||||||
} from '$lib/api/listenbrainz';
|
} from '$lib/api/listenbrainz';
|
||||||
|
import {
|
||||||
|
updateProfile,
|
||||||
|
changePassword,
|
||||||
|
getAPIToken,
|
||||||
|
regenerateAPIToken
|
||||||
|
} from '$lib/api/me';
|
||||||
|
|
||||||
function mockStatusStore(data: LBStatus) {
|
function mockStatusStore(data: LBStatus) {
|
||||||
return readable({ data, isPending: false, isError: false });
|
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<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null })
|
||||||
|
);
|
||||||
|
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||||
|
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||||
|
(getAPIToken as ReturnType<typeof vi.fn>).mockResolvedValue({ api_token: 'tok_abc123' });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Settings page — Profile card', () => {
|
||||||
|
test('Save profile calls updateProfile with form values', async () => {
|
||||||
|
setupPage();
|
||||||
|
(updateProfile as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user