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:
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user