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
parent 72f46a885b
commit 5da2c85f70
6 changed files with 500 additions and 5 deletions
+137 -1
View File
@@ -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<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>
<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">
<header class="flex items-center justify-between">
<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>
{/if}
</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>
{#if modalOpen}