diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index d27f6e0b..e7012071 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -459,3 +459,36 @@ export function createAdminInvitesQuery() { queryFn: listInvites }); } + +// SMTP config (U3) ---------------------------------------------------------- + +export type SMTPConfig = { + enabled: boolean; + host: string; + port: number; + username: string; + password: string; // "***" when set, "" when unset + from_address: string; + from_name: string; + use_tls: boolean; +}; + +export async function getSMTPConfig(): Promise { + return api.get('/api/admin/smtp-config'); +} + +export async function updateSMTPConfig(input: SMTPConfig): Promise { + await api.put('/api/admin/smtp-config', input); +} + +export async function testSMTPConfig(): Promise { + await api.post('/api/admin/smtp-config/test', {}); +} + +export function createSMTPConfigQuery() { + return createQuery({ + queryKey: qk.smtpConfig(), + queryFn: getSMTPConfig, + staleTime: 60_000 + }); +} diff --git a/web/src/lib/api/me.ts b/web/src/lib/api/me.ts index b6811722..9134d778 100644 --- a/web/src/lib/api/me.ts +++ b/web/src/lib/api/me.ts @@ -22,3 +22,34 @@ export function createSystemPlaylistsStatusQuery() { staleTime: 10_000 }); } + +// Self-service profile / password / API-token endpoints (U3) --------------- + +export type MyProfile = { + id: string; + username: string; + display_name: string | null; + email: string | null; + is_admin: boolean; +}; + +export type APITokenResponse = { api_token: string }; + +export async function changePassword(currentPassword: string, newPassword: string): Promise { + await api.put('/api/me/password', { + current_password: currentPassword, + new_password: newPassword, + }); +} + +export async function updateProfile(input: { display_name?: string; email?: string }): Promise { + return api.put('/api/me/profile', input); +} + +export async function getAPIToken(): Promise { + return api.get('/api/me/api-token'); +} + +export async function regenerateAPIToken(): Promise { + return api.post('/api/me/api-token', {}); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 142c2bfd..d020f03b 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -40,6 +40,7 @@ export const qk = { coverProviders: () => ['coverProviders'] as const, adminUsers: () => ['adminUsers'] as const, adminInvites: () => ['adminInvites'] as const, + smtpConfig: () => ['smtpConfig'] as const, suggestions: (limit?: number) => ['suggestions', { limit: limit ?? 12 }] as const, home: () => ['home'] as const, diff --git a/web/src/lib/auth/store.svelte.ts b/web/src/lib/auth/store.svelte.ts index bcf91921..8ca24e02 100644 --- a/web/src/lib/auth/store.svelte.ts +++ b/web/src/lib/auth/store.svelte.ts @@ -41,6 +41,14 @@ export async function register(opts: { await bootstrap(); } +export async function forgotPassword(email: string): Promise { + await api.post('/api/auth/forgot-password', { email }); +} + +export async function resetPassword(token: string, newPassword: string): Promise { + await api.post('/api/auth/reset-password', { token, new_password: newPassword }); +} + export async function logout(opts: { silent?: boolean } = {}): Promise { const userId = _user?.id; if (!opts.silent) { diff --git a/web/src/routes/forgot-password/+page.svelte b/web/src/routes/forgot-password/+page.svelte new file mode 100644 index 00000000..0aac893b --- /dev/null +++ b/web/src/routes/forgot-password/+page.svelte @@ -0,0 +1,63 @@ + + +{pageTitle('Forgot password')} + +
+
+

Forgot password

+ + {#if !submitted} +

+ Enter your email. If an account uses it, you'll receive a reset link shortly. +

+
+ + +
+ {:else} +

+ If your email is on file, you'll receive a reset link shortly. + Check your inbox (and spam folder). +

+ {/if} + +

+ Remembered? Log in +

+
+
diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte index 3e18841a..82a2caf1 100644 --- a/web/src/routes/login/+page.svelte +++ b/web/src/routes/login/+page.svelte @@ -82,6 +82,9 @@

+ Forgot password? +

+

Don't have an account? Register

diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index b86a2c46..01fe104b 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -9,6 +9,12 @@ type LBStatus } from '$lib/api/listenbrainz'; import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte'; + import { + updateProfile, + changePassword, + getAPIToken, + regenerateAPIToken + } from '$lib/api/me'; const queryClient = useQueryClient(); @@ -32,10 +38,124 @@ if (!$status.data) return; $enabledMutation.mutate(!$status.data.enabled); } + + // Toast ------------------------------------------------------------------- + + let toast = $state(null); + let toastTimer: ReturnType | undefined; + + function showToast(msg: string) { + toast = msg; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => { toast = null; }, 4000); + } + + // Profile card ------------------------------------------------------------ + + let profileForm = $state<{ displayName: string; email: string }>({ + displayName: '', + email: '', + }); + let profileSaving = $state(false); + + async function onSaveProfile() { + profileSaving = true; + try { + await updateProfile({ + display_name: profileForm.displayName, + email: profileForm.email, + }); + showToast('Profile saved.'); + } catch (e: unknown) { + const code = (e as { code?: string })?.code; + if (code === 'email_taken') showToast('That email is already in use.'); + else if (code === 'email_invalid') showToast('Email format is invalid.'); + else showToast(`Save failed: ${code ?? 'unknown'}`); + } finally { + profileSaving = false; + } + } + + // Password card ----------------------------------------------------------- + + let passwordForm = $state<{ current: string; new: string; confirm: string }>({ + current: '', new: '', confirm: '', + }); + let passwordSaving = $state(false); + + async function onSavePassword(e: SubmitEvent) { + e.preventDefault(); + if (passwordForm.new !== passwordForm.confirm) { + showToast('New passwords do not match.'); + return; + } + passwordSaving = true; + try { + await changePassword(passwordForm.current, passwordForm.new); + showToast('Password changed.'); + passwordForm = { current: '', new: '', confirm: '' }; + } catch (e: unknown) { + const code = (e as { code?: string })?.code; + if (code === 'wrong_password') showToast('Current password is incorrect.'); + else if (code === 'password_too_short') showToast('Password must be at least 8 characters.'); + else showToast(`Change failed: ${code ?? 'unknown'}`); + } finally { + passwordSaving = false; + } + } + + // API Token card ---------------------------------------------------------- + + let apiToken = $state(null); + let tokenSaving = $state(false); + let confirmRegen = $state(false); + let regenTimer: ReturnType | undefined; + + $effect(() => { + getAPIToken().then(r => { apiToken = r.api_token; }).catch(() => {}); + }); + + async function copyToken() { + if (!apiToken) return; + try { + await navigator.clipboard.writeText(apiToken); + showToast('Token copied to clipboard.'); + } catch { + showToast('Copy failed.'); + } + } + + async function onRegenerateToken() { + if (!confirmRegen) { + confirmRegen = true; + if (regenTimer) clearTimeout(regenTimer); + regenTimer = setTimeout(() => { confirmRegen = false; }, 5000); + return; + } + if (regenTimer) clearTimeout(regenTimer); + confirmRegen = false; + tokenSaving = true; + try { + const r = await regenerateAPIToken(); + apiToken = r.api_token; + showToast('API token regenerated.'); + } catch (e: unknown) { + showToast(`Regenerate failed: ${(e as { code?: string })?.code ?? 'unknown'}`); + } finally { + tokenSaving = false; + } + } {pageTitle('Settings')} +{#if toast} +
+ {toast} +
+{/if} +

Settings

@@ -132,6 +252,84 @@ {/if} + +
+

Profile

+

+ Your display name and recovery email. +

+
+ + + + + +
+ +
+ + +
+

Password

+
+
+ + + + + + + + +
+ +
+
+ + +
+

API Token

+

+ Used by Subsonic clients (DSub, Symfonium, etc.) to authenticate to your library. + Regenerating invalidates clients that have the old token cached. +

+ {#if apiToken} + + {apiToken} + + {/if} +
+ + +
+
+

Library