🐐
This commit is contained in:
151
frontend/src/routes/forgot-password/+page.svelte
Normal file
151
frontend/src/routes/forgot-password/+page.svelte
Normal file
@@ -0,0 +1,151 @@
|
||||
<script>
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
let email = $state('');
|
||||
let submitted = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
async function submit() {
|
||||
error = '';
|
||||
if (!email.trim()) { error = 'Email is required'; return; }
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { error = data.detail; return; }
|
||||
submitted = true;
|
||||
} catch {
|
||||
error = 'Something went wrong. Please try again.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="card">
|
||||
{#if submitted}
|
||||
<h1 class="title">Check Your Email</h1>
|
||||
<p class="hint">If that email address is registered, you'll receive a password reset link shortly.</p>
|
||||
{:else}
|
||||
<h1 class="title">Forgot Password</h1>
|
||||
<p class="hint">Enter your email address and we'll send you a reset link.</p>
|
||||
|
||||
<input type="email" placeholder="Email address" bind:value={email} />
|
||||
<p class="error">{error}</p>
|
||||
<button class="btn" onclick={submit} disabled={loading}>
|
||||
{loading ? 'Sending...' : 'Send Reset Link'}
|
||||
</button>
|
||||
|
||||
<a href="/auth" class="back-link">← Back to login</a>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 340px;
|
||||
background: #2e1c05;
|
||||
border: 2px solid #6b4c1e;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
color: #f0d080;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: rgba(245, 208, 96, 0.7);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: #1a1008;
|
||||
border: 1.5px solid #8b6420;
|
||||
border-radius: 6px;
|
||||
color: #f0d080;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(240, 180, 80, 0.4);
|
||||
}
|
||||
|
||||
input:focus { border-color: #f5d060; }
|
||||
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #6b4c1e;
|
||||
color: #f0d080;
|
||||
border: 1.5px solid #8b6420;
|
||||
border-radius: 6px;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #8b6420;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: rgba(245, 208, 96, 0.5);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.back-link:hover { color: #f5d060; }
|
||||
|
||||
.error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #f06060;
|
||||
margin: 0;
|
||||
min-height: 1.4em;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
172
frontend/src/routes/forgot-password/reset/+page.svelte
Normal file
172
frontend/src/routes/forgot-password/reset/+page.svelte
Normal file
@@ -0,0 +1,172 @@
|
||||
<script>
|
||||
import { API_URL, WS_URL } from '$lib/api.js';
|
||||
import { apiFetch } from '$lib/api.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state('');
|
||||
let success = $state(false);
|
||||
let loading = $state(false);
|
||||
|
||||
let token = $derived($page.url.searchParams.get('token') ?? '');
|
||||
|
||||
function validate() {
|
||||
if (newPassword.length < 8) return 'Password must be at least 8 characters';
|
||||
if (newPassword.length > 256) return 'Password must be 256 characters or fewer';
|
||||
if (newPassword !== confirmPassword) return 'Passwords do not match';
|
||||
return null;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error = '';
|
||||
const validationError = validate();
|
||||
if (validationError) { error = validationError; return; }
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/auth/reset-password-with-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, new_password: newPassword }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { error = data.detail; return; }
|
||||
success = true;
|
||||
} catch {
|
||||
error = 'Something went wrong. Please try again.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="card">
|
||||
{#if !token}
|
||||
<h1 class="title">Invalid Link</h1>
|
||||
<p class="hint">This reset link is invalid. Please request a new one.</p>
|
||||
<a href="/forgot-password" class="btn" style="text-align:center; text-decoration:none;">Request New Link</a>
|
||||
{:else if success}
|
||||
<h1 class="title">Password Updated</h1>
|
||||
<p class="hint">Your password has been changed. You can now log in.</p>
|
||||
<button class="btn" onclick={() => goto('/auth')}>Go to Login</button>
|
||||
{:else}
|
||||
<h1 class="title">Set New Password</h1>
|
||||
|
||||
<div class="fields">
|
||||
<label class="field-label" for="new">New Password</label>
|
||||
<input id="new" type="password" placeholder="At least 8 characters" bind:value={newPassword} />
|
||||
<label class="field-label" for="confirm">Confirm Password</label>
|
||||
<input id="confirm" type="password" placeholder="Repeat new password" bind:value={confirmPassword} />
|
||||
</div>
|
||||
|
||||
<p class="error">{error}</p>
|
||||
|
||||
<button class="btn" onclick={submit} disabled={loading}>
|
||||
{loading ? 'Updating...' : 'Set New Password'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: #0d0a04;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 380px;
|
||||
background: #3d2507;
|
||||
border: 2px solid #c8861a;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #f5d060;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
color: rgba(245, 208, 96, 0.7);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(245, 208, 96, 0.5);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: #221508;
|
||||
border: 1.5px solid #c8861a;
|
||||
border-radius: 6px;
|
||||
color: #f5d060;
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 15px;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input:focus { border-color: #f5d060; }
|
||||
input::placeholder { color: rgba(245, 208, 96, 0.35); }
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #c8861a;
|
||||
color: #fff8e0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) { background: #e09820; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.error {
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 14px;
|
||||
color: #f06060;
|
||||
margin: 0;
|
||||
min-height: 1.4em;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user