forked from wrenn/wrenn
Add settings page, forgot/reset password flows, and me API client
Adds /dashboard/settings route with profile/password/OAuth/account-deletion management. Adds /forgot-password and /reset-password routes. Enables sidebar settings link. Adds typed me.ts API client.
This commit is contained in:
42
frontend/src/lib/api/me.ts
Normal file
42
frontend/src/lib/api/me.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { apiFetch, type ApiResult } from '$lib/api/client';
|
||||||
|
import type { AuthResponse } from '$lib/api/auth';
|
||||||
|
|
||||||
|
export type MeResponse = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
has_password: boolean;
|
||||||
|
providers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChangePasswordBody = {
|
||||||
|
current_password?: string;
|
||||||
|
new_password: string;
|
||||||
|
confirm_password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMe = (): Promise<ApiResult<MeResponse>> =>
|
||||||
|
apiFetch('GET', '/api/v1/me');
|
||||||
|
|
||||||
|
export const updateName = (name: string): Promise<ApiResult<AuthResponse>> =>
|
||||||
|
apiFetch('PATCH', '/api/v1/me', { name });
|
||||||
|
|
||||||
|
export const changePassword = (body: ChangePasswordBody): Promise<ApiResult<void>> =>
|
||||||
|
apiFetch('POST', '/api/v1/me/password', body);
|
||||||
|
|
||||||
|
export const requestPasswordReset = (email: string): Promise<ApiResult<void>> =>
|
||||||
|
apiFetch('POST', '/api/v1/me/password/reset', { email });
|
||||||
|
|
||||||
|
export const confirmPasswordReset = (
|
||||||
|
token: string,
|
||||||
|
new_password: string
|
||||||
|
): Promise<ApiResult<void>> =>
|
||||||
|
apiFetch('POST', '/api/v1/me/password/reset/confirm', { token, new_password });
|
||||||
|
|
||||||
|
export const getProviderConnectURL = (provider: string): Promise<ApiResult<{ auth_url: string }>> =>
|
||||||
|
apiFetch('GET', `/api/v1/me/providers/${provider}/connect`);
|
||||||
|
|
||||||
|
export const disconnectProvider = (provider: string): Promise<ApiResult<void>> =>
|
||||||
|
apiFetch('DELETE', `/api/v1/me/providers/${provider}`);
|
||||||
|
|
||||||
|
export const deleteAccount = (confirmation: string): Promise<ApiResult<void>> =>
|
||||||
|
apiFetch('DELETE', '/api/v1/me', { confirmation });
|
||||||
@ -280,13 +280,21 @@
|
|||||||
<IconBell size={16} class="shrink-0" />
|
<IconBell size={16} class="shrink-0" />
|
||||||
{#if !collapsed}<span class="text-ui">Notifications</span>{/if}
|
{#if !collapsed}<span class="text-ui">Notifications</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<a
|
||||||
class="flex cursor-not-allowed items-center rounded-[var(--radius-input)] px-2.5 py-2.5 opacity-35 {collapsed ? 'justify-center px-2' : 'gap-3'}"
|
href="/dashboard/settings"
|
||||||
title={collapsed ? 'Settings (coming soon)' : 'Coming soon'}
|
class="group relative flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {collapsed ? 'justify-center px-2' : 'gap-3'} {isActive('/dashboard/settings') ? (collapsed ? 'bg-[var(--color-accent-glow-mid)]' : 'bg-[var(--color-accent)]/[0.12]') : ''}"
|
||||||
|
title={collapsed ? 'Settings' : undefined}
|
||||||
>
|
>
|
||||||
<IconSettings size={16} class="shrink-0" />
|
{#if isActive('/dashboard/settings') && !collapsed}
|
||||||
{#if !collapsed}<span class="text-ui">Settings</span>{/if}
|
<div class="absolute left-0 top-1/2 h-6 w-1 -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"></div>
|
||||||
</div>
|
{/if}
|
||||||
|
<IconSettings size={16} class="shrink-0 {isActive('/dashboard/settings') ? 'text-[var(--color-accent-bright)]' : 'opacity-50 transition-opacity duration-150 group-hover:opacity-100'}" />
|
||||||
|
{#if !collapsed}
|
||||||
|
<span class="text-ui transition-colors duration-150 {isActive('/dashboard/settings') ? 'font-semibold text-[var(--color-accent-bright)]' : 'text-[var(--color-text-primary)] group-hover:text-[var(--color-text-bright)]'}">
|
||||||
|
Settings
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User footer -->
|
<!-- User footer -->
|
||||||
|
|||||||
673
frontend/src/routes/dashboard/settings/+page.svelte
Normal file
673
frontend/src/routes/dashboard/settings/+page.svelte
Normal file
@ -0,0 +1,673 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
import { toast } from '$lib/toast.svelte';
|
||||||
|
import {
|
||||||
|
getMe,
|
||||||
|
updateName,
|
||||||
|
changePassword,
|
||||||
|
requestPasswordReset,
|
||||||
|
getProviderConnectURL,
|
||||||
|
disconnectProvider,
|
||||||
|
deleteAccount,
|
||||||
|
type MeResponse
|
||||||
|
} from '$lib/api/me';
|
||||||
|
|
||||||
|
let collapsed = $state(
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? localStorage.getItem('wrenn_sidebar_collapsed') === 'true'
|
||||||
|
: false
|
||||||
|
);
|
||||||
|
|
||||||
|
let me = $state<MeResponse | null>(null);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
|
||||||
|
let initials = $derived(
|
||||||
|
me?.name
|
||||||
|
? me.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
|
||||||
|
: me?.email?.[0]?.toUpperCase() ?? '?'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
let editName = $state('');
|
||||||
|
let savingName = $state(false);
|
||||||
|
let nameError = $state<string | null>(null);
|
||||||
|
let nameSaved = $state(false);
|
||||||
|
let nameSavedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// Password
|
||||||
|
let currentPassword = $state('');
|
||||||
|
let newPassword = $state('');
|
||||||
|
let confirmPassword = $state('');
|
||||||
|
let savingPassword = $state(false);
|
||||||
|
let passwordError = $state<string | null>(null);
|
||||||
|
let sendingReset = $state(false);
|
||||||
|
let passwordSaved = $state(false);
|
||||||
|
let passwordSavedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// GitHub connect/disconnect
|
||||||
|
let connectingGitHub = $state(false);
|
||||||
|
let disconnectingGitHub = $state(false);
|
||||||
|
let showDisconnectConfirm = $state(false);
|
||||||
|
let disconnectError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Delete account
|
||||||
|
let showDeleteConfirm = $state(false);
|
||||||
|
let deleteConfirmation = $state('');
|
||||||
|
let deleting = $state(false);
|
||||||
|
let deleteError = $state<string | null>(null);
|
||||||
|
|
||||||
|
const connectErrors: Record<string, string> = {
|
||||||
|
already_linked: 'This GitHub account is already connected to another Wrenn account.',
|
||||||
|
db_error: 'Something went wrong — please try again.',
|
||||||
|
invalid_state: 'The connection attempt expired — please try again.',
|
||||||
|
access_denied: 'GitHub access was denied.',
|
||||||
|
exchange_failed: 'Authentication failed — please try again.'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchMe() {
|
||||||
|
const result = await getMe();
|
||||||
|
if (result.ok) {
|
||||||
|
me = result.data;
|
||||||
|
editName = result.data.name;
|
||||||
|
} else {
|
||||||
|
loadError = result.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveName() {
|
||||||
|
if (!editName.trim() || editName.trim() === me?.name) return;
|
||||||
|
savingName = true;
|
||||||
|
nameError = null;
|
||||||
|
const result = await updateName(editName.trim());
|
||||||
|
if (result.ok) {
|
||||||
|
auth.login(result.data);
|
||||||
|
me = { ...me!, name: result.data.name };
|
||||||
|
editName = result.data.name;
|
||||||
|
toast.success('Name updated.');
|
||||||
|
nameSaved = true;
|
||||||
|
if (nameSavedTimer) clearTimeout(nameSavedTimer);
|
||||||
|
nameSavedTimer = setTimeout(() => (nameSaved = false), 1500);
|
||||||
|
} else {
|
||||||
|
nameError = result.error;
|
||||||
|
}
|
||||||
|
savingName = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendPasswordReset() {
|
||||||
|
if (!me) return;
|
||||||
|
sendingReset = true;
|
||||||
|
const result = await requestPasswordReset(me.email);
|
||||||
|
sendingReset = false;
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success('Password reset link sent to your email.');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChangePassword() {
|
||||||
|
savingPassword = true;
|
||||||
|
passwordError = null;
|
||||||
|
|
||||||
|
const body = me?.has_password
|
||||||
|
? { current_password: currentPassword, new_password: newPassword }
|
||||||
|
: { new_password: newPassword, confirm_password: confirmPassword };
|
||||||
|
|
||||||
|
const result = await changePassword(body);
|
||||||
|
if (result.ok) {
|
||||||
|
currentPassword = '';
|
||||||
|
newPassword = '';
|
||||||
|
confirmPassword = '';
|
||||||
|
const wasPasswordSet = !!me?.has_password;
|
||||||
|
if (me) me = { ...me, has_password: true };
|
||||||
|
toast.success(wasPasswordSet ? 'Password updated.' : 'Password added.');
|
||||||
|
passwordSaved = true;
|
||||||
|
if (passwordSavedTimer) clearTimeout(passwordSavedTimer);
|
||||||
|
passwordSavedTimer = setTimeout(() => (passwordSaved = false), 1500);
|
||||||
|
} else {
|
||||||
|
passwordError = result.error;
|
||||||
|
}
|
||||||
|
savingPassword = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConnectGitHub() {
|
||||||
|
connectingGitHub = true;
|
||||||
|
const result = await getProviderConnectURL('github');
|
||||||
|
if (result.ok) {
|
||||||
|
window.location.href = result.data.auth_url;
|
||||||
|
} else {
|
||||||
|
toast.error(result.error);
|
||||||
|
connectingGitHub = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisconnectGitHub() {
|
||||||
|
disconnectingGitHub = true;
|
||||||
|
disconnectError = null;
|
||||||
|
const result = await disconnectProvider('github');
|
||||||
|
if (result.ok) {
|
||||||
|
me = { ...me!, providers: me!.providers.filter((p) => p !== 'github') };
|
||||||
|
showDisconnectConfirm = false;
|
||||||
|
toast.success('GitHub disconnected.');
|
||||||
|
} else {
|
||||||
|
disconnectError = result.error;
|
||||||
|
}
|
||||||
|
disconnectingGitHub = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAccount() {
|
||||||
|
deleting = true;
|
||||||
|
deleteError = null;
|
||||||
|
const result = await deleteAccount(deleteConfirmation);
|
||||||
|
if (result.ok) {
|
||||||
|
auth.logout();
|
||||||
|
} else {
|
||||||
|
deleteError = result.error;
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await fetchMe();
|
||||||
|
|
||||||
|
// Read OAuth callback params and clean URL immediately,
|
||||||
|
// regardless of whether fetchMe succeeds.
|
||||||
|
const connected = $page.url.searchParams.get('connected');
|
||||||
|
const connectErr = $page.url.searchParams.get('connect_error');
|
||||||
|
if (connected || connectErr) {
|
||||||
|
goto('/dashboard/settings', { replaceState: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connected) {
|
||||||
|
if (me) me = { ...me, providers: [...new Set([...me.providers, connected])] };
|
||||||
|
toast.success(`${connected.charAt(0).toUpperCase() + connected.slice(1)} connected successfully.`);
|
||||||
|
} else if (connectErr) {
|
||||||
|
toast.error(connectErrors[connectErr] ?? 'Failed to connect account.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Wrenn — Settings</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex h-screen overflow-hidden">
|
||||||
|
<Sidebar bind:collapsed />
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<main class="flex-1 overflow-y-auto bg-[var(--color-bg-0)]">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="px-7 pt-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-serif text-page text-[var(--color-text-bright)]">Settings</h1>
|
||||||
|
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
|
||||||
|
Manage your account details and security.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 border-b border-[var(--color-border)]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-8">
|
||||||
|
{#if loadError}
|
||||||
|
<div class="rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]" style="animation: fadeUp 0.35s ease both">
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
|
{:else if me}
|
||||||
|
<div class="mx-auto max-w-[560px] space-y-8">
|
||||||
|
|
||||||
|
<!-- ── Profile ── -->
|
||||||
|
<section style="animation: fadeUp 0.35s ease both">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="avatar-ring flex h-14 w-14 shrink-0 items-center justify-center rounded-full border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]">
|
||||||
|
<span class="font-serif text-heading leading-none text-[var(--color-text-bright)]">{initials}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Profile</h2>
|
||||||
|
<p class="mt-0.5 text-ui text-[var(--color-text-tertiary)]">How you appear across Wrenn.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="display-name"
|
||||||
|
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
|
||||||
|
>
|
||||||
|
Display name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="display-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={editName}
|
||||||
|
disabled={savingName}
|
||||||
|
placeholder="Your name"
|
||||||
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:shadow-[0_0_0_2px_var(--color-accent-glow)] disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">
|
||||||
|
Email
|
||||||
|
</span>
|
||||||
|
<div class="rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-2 font-mono text-ui text-[var(--color-text-secondary)]">
|
||||||
|
{me.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if nameError}
|
||||||
|
<p class="text-ui text-[var(--color-red)]">{nameError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
onclick={handleSaveName}
|
||||||
|
disabled={savingName || nameSaved || !editName.trim() || editName.trim() === me.name}
|
||||||
|
class="flex items-center gap-2 rounded-[var(--radius-button)] px-4 py-2 text-ui font-semibold text-white transition-all duration-150 hover:-translate-y-px active:translate-y-0 disabled:hover:translate-y-0 {nameSaved ? 'bg-[var(--color-accent-bright)]' : 'bg-[var(--color-accent)] hover:brightness-115 disabled:opacity-50'}"
|
||||||
|
>
|
||||||
|
{#if savingName}
|
||||||
|
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||||
|
Saving…
|
||||||
|
{:else if nameSaved}
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="check-draw"><polyline points="20 6 9 17 4 12" /></svg>
|
||||||
|
Saved
|
||||||
|
{:else}
|
||||||
|
Save
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="border-t border-[var(--color-border)]"></div>
|
||||||
|
|
||||||
|
<!-- ── Security ── -->
|
||||||
|
<section style="animation: fadeUp 0.35s ease both; animation-delay: 60ms">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-avatar)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-text-tertiary)]"><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">
|
||||||
|
{me.has_password ? 'Change password' : 'Add a password'}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-0.5 text-ui text-[var(--color-text-tertiary)]">
|
||||||
|
{me.has_password
|
||||||
|
? 'Use a strong, unique password you don\'t use elsewhere.'
|
||||||
|
: 'Set a password so you can sign in with your email.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 space-y-4">
|
||||||
|
{#if me.has_password}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="current-password"
|
||||||
|
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
|
||||||
|
>
|
||||||
|
Current password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="current-password"
|
||||||
|
type="password"
|
||||||
|
bind:value={currentPassword}
|
||||||
|
disabled={savingPassword}
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:shadow-[0_0_0_2px_var(--color-accent-glow)] disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="new-password"
|
||||||
|
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
|
||||||
|
>
|
||||||
|
New password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
bind:value={newPassword}
|
||||||
|
disabled={savingPassword}
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="Min. 8 characters"
|
||||||
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:shadow-[0_0_0_2px_var(--color-accent-glow)] disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !me.has_password}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="confirm-password"
|
||||||
|
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
|
||||||
|
>
|
||||||
|
Confirm password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
disabled={savingPassword}
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:shadow-[0_0_0_2px_var(--color-accent-glow)] disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if passwordError}
|
||||||
|
<p class="text-ui text-[var(--color-red)]">{passwordError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
{#if me.has_password}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleSendPasswordReset}
|
||||||
|
disabled={sendingReset}
|
||||||
|
class="text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:text-[var(--color-text-secondary)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sendingReset ? 'Sending…' : 'Forgot password?'}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span></span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={handleChangePassword}
|
||||||
|
disabled={savingPassword || passwordSaved || !newPassword || (me.has_password && !currentPassword) || (!me.has_password && !confirmPassword)}
|
||||||
|
class="flex items-center gap-2 rounded-[var(--radius-button)] px-4 py-2 text-ui font-semibold text-white transition-all duration-150 hover:-translate-y-px active:translate-y-0 disabled:hover:translate-y-0 {passwordSaved ? 'bg-[var(--color-accent-bright)]' : 'bg-[var(--color-accent)] hover:brightness-115 disabled:opacity-50'}"
|
||||||
|
>
|
||||||
|
{#if savingPassword}
|
||||||
|
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||||
|
Saving…
|
||||||
|
{:else if passwordSaved}
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="check-draw"><polyline points="20 6 9 17 4 12" /></svg>
|
||||||
|
Saved
|
||||||
|
{:else}
|
||||||
|
{me.has_password ? 'Update password' : 'Set password'}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="border-t border-[var(--color-border)]"></div>
|
||||||
|
|
||||||
|
<!-- ── Connected Accounts ── -->
|
||||||
|
<section style="animation: fadeUp 0.35s ease both; animation-delay: 120ms">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-avatar)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-text-tertiary)]"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Connected accounts</h2>
|
||||||
|
<p class="mt-0.5 text-ui text-[var(--color-text-tertiary)]">
|
||||||
|
Sign in with a linked account instead of your password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<!-- GitHub row -->
|
||||||
|
<div class="flex items-center justify-between rounded-[var(--radius-card)] border px-4 py-3 transition-colors duration-200 {me.providers.includes('github') ? 'border-[var(--color-accent)]/30 bg-[var(--color-accent-glow)]' : 'border-[var(--color-border)] bg-[var(--color-bg-1)]'}">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- GitHub icon -->
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" class="{me.providers.includes('github') ? 'text-[var(--color-text-bright)]' : 'text-[var(--color-text-secondary)]'}">
|
||||||
|
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div class="text-ui font-medium text-[var(--color-text-primary)]">GitHub</div>
|
||||||
|
{#if me.providers.includes('github')}
|
||||||
|
<div class="flex items-center gap-1 text-meta text-[var(--color-accent)]">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="check-draw"><polyline points="20 6 9 17 4 12" /></svg>
|
||||||
|
Connected
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-meta text-[var(--color-text-muted)]">Not connected</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if me.providers.includes('github')}
|
||||||
|
<button
|
||||||
|
onclick={() => { showDisconnectConfirm = true; disconnectError = null; }}
|
||||||
|
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-3 py-1.5 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-red)]/50 hover:text-[var(--color-red)]"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={handleConnectGitHub}
|
||||||
|
disabled={connectingGitHub}
|
||||||
|
class="flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-border)] px-3 py-1.5 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{#if connectingGitHub}
|
||||||
|
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||||
|
{/if}
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="border-t border-[var(--color-border)]"></div>
|
||||||
|
|
||||||
|
<!-- ── Danger Zone ── -->
|
||||||
|
<section style="animation: fadeUp 0.35s ease both; animation-delay: 180ms">
|
||||||
|
<h2 class="font-serif text-heading text-[var(--color-red)]">Danger zone</h2>
|
||||||
|
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
|
||||||
|
Deleting your account is irreversible.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-5 rounded-[var(--radius-card)] border border-[var(--color-red)]/25 border-l-[2px] border-l-[var(--color-red)]/40 bg-[var(--color-red)]/[0.03] px-4 py-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-ui font-medium text-[var(--color-text-primary)]">Delete account</div>
|
||||||
|
<div class="mt-0.5 text-meta text-[var(--color-text-muted)]">
|
||||||
|
Your account will be deactivated immediately and permanently removed after 15 days.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => { showDeleteConfirm = true; deleteConfirmation = ''; deleteError = null; }}
|
||||||
|
class="shrink-0 rounded-[var(--radius-button)] border border-[var(--color-red)]/30 px-3 py-1.5 text-ui text-[var(--color-red)] transition-colors duration-150 hover:bg-[var(--color-red)]/10"
|
||||||
|
>
|
||||||
|
Delete account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Loading skeleton -->
|
||||||
|
<div class="mx-auto max-w-[560px] space-y-6">
|
||||||
|
<div class="flex items-center gap-4" style="animation: fadeUp 0.35s ease both">
|
||||||
|
<div class="h-14 w-14 shrink-0 animate-pulse rounded-full bg-[var(--color-bg-3)]"></div>
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="h-4 w-24 animate-pulse rounded bg-[var(--color-bg-3)]"></div>
|
||||||
|
<div class="h-3 w-40 animate-pulse rounded bg-[var(--color-bg-2)]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#each [140, 180, 100] as h, i}
|
||||||
|
<div style="animation: fadeUp 0.35s ease both; animation-delay: {(i + 1) * 60}ms">
|
||||||
|
<div class="animate-pulse rounded-[var(--radius-card)] bg-[var(--color-bg-2)]" style="height: {h}px"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer class="h-px shrink-0 bg-[var(--color-border)]"></footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disconnect GitHub dialog -->
|
||||||
|
{#if showDisconnectConfirm}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/60 backdrop-fade"
|
||||||
|
onclick={() => { if (!disconnectingGitHub) showDisconnectConfirm = false; }}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape' && !disconnectingGitHub) showDisconnectConfirm = false; }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
|
||||||
|
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
|
||||||
|
>
|
||||||
|
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Disconnect GitHub</h2>
|
||||||
|
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
|
||||||
|
You won't be able to sign in with GitHub. You can reconnect it later.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if disconnectError}
|
||||||
|
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
|
||||||
|
{disconnectError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onclick={() => showDisconnectConfirm = false}
|
||||||
|
disabled={disconnectingGitHub}
|
||||||
|
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleDisconnectGitHub}
|
||||||
|
disabled={disconnectingGitHub}
|
||||||
|
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||||
|
>
|
||||||
|
{#if disconnectingGitHub}
|
||||||
|
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||||
|
{/if}
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete account dialog -->
|
||||||
|
{#if showDeleteConfirm}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/60 backdrop-fade"
|
||||||
|
onclick={() => { if (!deleting) showDeleteConfirm = false; }}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape' && !deleting) showDeleteConfirm = false; }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
|
||||||
|
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
|
||||||
|
>
|
||||||
|
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Delete account</h2>
|
||||||
|
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
|
||||||
|
Your account will be deactivated immediately and permanently deleted after 15 days. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if deleteError}
|
||||||
|
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
|
||||||
|
{deleteError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<label
|
||||||
|
for="delete-confirm"
|
||||||
|
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
|
||||||
|
>
|
||||||
|
Type your email to confirm
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="delete-confirm"
|
||||||
|
type="email"
|
||||||
|
bind:value={deleteConfirmation}
|
||||||
|
disabled={deleting}
|
||||||
|
placeholder={me?.email ?? ''}
|
||||||
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-red)] focus:shadow-[0_0_0_2px_rgba(207,129,114,0.1)] disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onclick={() => showDeleteConfirm = false}
|
||||||
|
disabled={deleting}
|
||||||
|
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleDeleteAccount}
|
||||||
|
disabled={deleting || deleteConfirmation !== me?.email}
|
||||||
|
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||||
|
>
|
||||||
|
{#if deleting}
|
||||||
|
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||||
|
{/if}
|
||||||
|
Delete account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Checkmark draw animation (mirrors CopyButton pattern) ── */
|
||||||
|
.check-draw {
|
||||||
|
animation: checkScale 0.3s cubic-bezier(0.25, 1, 0.5, 1) both;
|
||||||
|
}
|
||||||
|
:global(.check-draw polyline) {
|
||||||
|
stroke-dasharray: 24;
|
||||||
|
stroke-dashoffset: 24;
|
||||||
|
animation: checkStroke 0.3s cubic-bezier(0.25, 1, 0.5, 1) 0.05s forwards;
|
||||||
|
}
|
||||||
|
@keyframes checkScale {
|
||||||
|
0% { transform: scale(0.6); opacity: 0; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes checkStroke {
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Avatar hover ring ── */
|
||||||
|
.avatar-ring {
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.avatar-ring:hover {
|
||||||
|
border-color: var(--color-accent-mid);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-accent-glow-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dialog backdrop fade ── */
|
||||||
|
.backdrop-fade {
|
||||||
|
animation: backdropIn 0.2s ease both;
|
||||||
|
}
|
||||||
|
@keyframes backdropIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Respect reduced motion ── */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.check-draw,
|
||||||
|
.avatar-ring,
|
||||||
|
.backdrop-fade {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
:global(.check-draw polyline) {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
99
frontend/src/routes/forgot-password/+page.svelte
Normal file
99
frontend/src/routes/forgot-password/+page.svelte
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { requestPasswordReset } from '$lib/api/me';
|
||||||
|
|
||||||
|
let email = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
let submitted = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = '';
|
||||||
|
loading = true;
|
||||||
|
await requestPasswordReset(email.trim().toLowerCase());
|
||||||
|
// Always show success to avoid leaking account existence
|
||||||
|
submitted = true;
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Wrenn — Reset password</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-[var(--color-bg-0)] px-4">
|
||||||
|
<div class="w-full max-w-[400px]" style="animation: fadeUp 0.35s ease both">
|
||||||
|
<!-- Brand -->
|
||||||
|
<div class="mb-8 flex items-center gap-3">
|
||||||
|
<img src="/logo.svg" alt="Wrenn" class="h-8 w-8 rounded-[var(--radius-logo)]" />
|
||||||
|
<span class="font-brand text-[1.5rem] text-[var(--color-text-bright)]">Wrenn</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if submitted}
|
||||||
|
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
|
||||||
|
<h1 class="font-serif text-heading text-[var(--color-text-bright)]">Check your email</h1>
|
||||||
|
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
|
||||||
|
If an account exists for <span class="font-mono text-[var(--color-text-primary)]">{email}</span>, you'll receive a reset link shortly. The link expires in 15 minutes.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="mt-6 block text-center text-ui text-[var(--color-text-tertiary)] transition-colors duration-150 hover:text-[var(--color-text-secondary)]"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
|
||||||
|
<h1 class="font-serif text-heading text-[var(--color-text-bright)]">Reset your password</h1>
|
||||||
|
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
|
||||||
|
Enter your email and we'll send you a reset link.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="mt-4 text-ui text-[var(--color-red)]">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="mt-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="email"
|
||||||
|
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
bind:value={email}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autocomplete="email"
|
||||||
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !email.trim()}
|
||||||
|
class="flex w-full items-center justify-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] py-2.5 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||||
|
Sending…
|
||||||
|
{:else}
|
||||||
|
Send reset link
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="mt-5 block text-center text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:text-[var(--color-text-secondary)]"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -285,12 +285,12 @@
|
|||||||
|
|
||||||
{#if mode === 'signin'}
|
{#if mode === 'signin'}
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<button
|
<a
|
||||||
type="button"
|
href="/forgot-password"
|
||||||
class="text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:text-[var(--color-accent-mid)]"
|
class="text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:text-[var(--color-accent-mid)]"
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
138
frontend/src/routes/reset-password/+page.svelte
Normal file
138
frontend/src/routes/reset-password/+page.svelte
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { confirmPasswordReset } from '$lib/api/me';
|
||||||
|
import { IconLock } from '$lib/components/icons';
|
||||||
|
|
||||||
|
let token = $state('');
|
||||||
|
let newPassword = $state('');
|
||||||
|
let confirmPassword = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let done = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
token = $page.url.searchParams.get('token') ?? '';
|
||||||
|
if (!token) {
|
||||||
|
goto('/forgot-password');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
error = 'Passwords do not match.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
error = 'Password must be at least 8 characters.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
const result = await confirmPasswordReset(token, newPassword);
|
||||||
|
if (result.ok) {
|
||||||
|
done = true;
|
||||||
|
} else {
|
||||||
|
error = result.error;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Wrenn — Set new password</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-[var(--color-bg-0)] px-4">
|
||||||
|
<div class="w-full max-w-[400px]" style="animation: fadeUp 0.35s ease both">
|
||||||
|
<!-- Brand -->
|
||||||
|
<div class="mb-8 flex items-center gap-3">
|
||||||
|
<img src="/logo.svg" alt="Wrenn" class="h-10 w-10 rounded-[var(--radius-logo)]" />
|
||||||
|
<span class="font-brand text-page text-[var(--color-text-bright)]">Wrenn</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if done}
|
||||||
|
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6" style="animation: fadeUp 0.3s ease both">
|
||||||
|
<h1 class="font-serif text-display text-[var(--color-text-bright)]">All set</h1>
|
||||||
|
<p class="mt-1 text-ui text-[var(--color-text-secondary)]">
|
||||||
|
Your password has been updated. Sign in to continue.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="mt-6 flex w-full items-center justify-center rounded-[var(--radius-button)] bg-[var(--color-accent)] py-3 text-body font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
|
||||||
|
<h1 class="font-serif text-display text-[var(--color-text-bright)]">Set new password</h1>
|
||||||
|
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">Must be at least 8 characters.</p>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="mt-4 text-ui text-[var(--color-red)]">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="mt-6 space-y-3">
|
||||||
|
<div class="group relative">
|
||||||
|
<div class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]">
|
||||||
|
<IconLock size={14} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
bind:value={newPassword}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="New password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-3 pl-9 pr-3 text-body text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-all duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group relative">
|
||||||
|
<div class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]">
|
||||||
|
<IconLock size={14} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Confirm password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-3 pl-9 pr-3 text-body text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-all duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !newPassword || !confirmPassword}
|
||||||
|
class="!mt-5 w-full rounded-[var(--radius-button)] bg-[var(--color-accent)] py-3 text-body font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<span class="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 border-t-white"></span>
|
||||||
|
Updating…
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
Set password
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="mt-5 block text-center text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:text-[var(--color-text-secondary)]"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user