🪻 distributed transcription service thistle.dunkirk.sh

feat: make a better reset password flow

dunkirk.sh bbd5c004 6789cc46

verified
+324
src/components/reset-password-form.ts
···
+
import { css, html, LitElement } from "lit";
+
import { customElement, property, state } from "lit/decorators.js";
+
import { hashPasswordClient } from "../lib/client-auth";
+
+
@customElement("reset-password-form")
+
export class ResetPasswordForm extends LitElement {
+
@property({ type: String }) token: string | null = null;
+
@state() private email: string | null = null;
+
@state() private password = "";
+
@state() private confirmPassword = "";
+
@state() private error = "";
+
@state() private isSubmitting = false;
+
@state() private isSuccess = false;
+
@state() private isLoadingEmail = false;
+
+
static override styles = css`
+
:host {
+
display: block;
+
}
+
+
.reset-card {
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 12px;
+
padding: 2.5rem;
+
max-width: 25rem;
+
width: 100%;
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
+
}
+
+
.reset-title {
+
margin-top: 0;
+
margin-bottom: 2rem;
+
color: var(--text);
+
text-align: center;
+
font-size: 1.75rem;
+
}
+
+
.form-group {
+
margin-bottom: 1.5rem;
+
}
+
+
label {
+
display: block;
+
margin-bottom: 0.25rem;
+
font-weight: 500;
+
color: var(--text);
+
font-size: 0.875rem;
+
}
+
+
input {
+
width: 100%;
+
padding: 0.75rem;
+
border: 2px solid var(--secondary);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-family: inherit;
+
background: var(--background);
+
color: var(--text);
+
transition: all 0.2s;
+
box-sizing: border-box;
+
}
+
+
input::placeholder {
+
color: var(--secondary);
+
opacity: 1;
+
}
+
+
input:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
.error-banner {
+
background: #fecaca;
+
border: 2px solid rgba(220, 38, 38, 0.8);
+
border-radius: 6px;
+
padding: 1rem;
+
margin-bottom: 1rem;
+
color: #dc2626;
+
font-weight: 500;
+
}
+
+
.btn-primary {
+
width: 100%;
+
padding: 0.75rem 1.5rem;
+
border: 2px solid var(--primary);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
background: var(--primary);
+
color: white;
+
margin-top: 0.5rem;
+
}
+
+
.btn-primary:hover:not(:disabled) {
+
background: transparent;
+
color: var(--primary);
+
}
+
+
.btn-primary:disabled {
+
opacity: 0.6;
+
cursor: not-allowed;
+
}
+
+
.back-link {
+
display: block;
+
text-align: center;
+
margin-top: 1.5rem;
+
color: var(--primary);
+
text-decoration: none;
+
font-weight: 500;
+
font-size: 0.875rem;
+
transition: all 0.2s;
+
}
+
+
.back-link:hover {
+
color: var(--accent);
+
}
+
+
.success-message {
+
text-align: center;
+
}
+
+
.success-icon {
+
font-size: 3rem;
+
margin-bottom: 1rem;
+
}
+
+
.success-text {
+
color: var(--primary);
+
font-size: 1.25rem;
+
font-weight: 500;
+
margin-bottom: 1.5rem;
+
}
+
+
.success-link {
+
display: inline-block;
+
padding: 0.75rem 1.5rem;
+
background: var(--accent);
+
color: white;
+
text-decoration: none;
+
border-radius: 6px;
+
font-weight: 500;
+
transition: all 0.2s;
+
}
+
+
.success-link:hover {
+
background: var(--primary);
+
}
+
`;
+
+
override async updated(changedProperties: Map<string, unknown>) {
+
super.updated(changedProperties);
+
+
// When token property changes and we don't have email yet, load it
+
if (changedProperties.has('token') && this.token && !this.email && !this.isLoadingEmail) {
+
await this.loadEmail();
+
}
+
}
+
+
private async loadEmail() {
+
this.isLoadingEmail = true;
+
this.error = "";
+
+
try {
+
const url = `/api/auth/reset-password?token=${encodeURIComponent(this.token || "")}`;
+
const response = await fetch(url);
+
const data = await response.json();
+
+
if (!response.ok) {
+
throw new Error(data.error || "Invalid or expired reset token");
+
}
+
+
this.email = data.email;
+
} catch (err) {
+
this.error = err instanceof Error ? err.message : "Failed to verify reset token";
+
} finally {
+
this.isLoadingEmail = false;
+
}
+
}
+
+
override render() {
+
if (!this.token) {
+
return html`
+
<div class="reset-card">
+
<h1 class="reset-title">Reset Password</h1>
+
<div class="error-banner">Invalid or missing reset token</div>
+
<a href="/" class="back-link">Back to home</a>
+
</div>
+
`;
+
}
+
+
if (this.isLoadingEmail) {
+
return html`
+
<div class="reset-card">
+
<h1 class="reset-title">Reset Password</h1>
+
<p style="text-align: center; color: var(--text);">Verifying reset token...</p>
+
</div>
+
`;
+
}
+
+
if (this.error && !this.email) {
+
return html`
+
<div class="reset-card">
+
<h1 class="reset-title">Reset Password</h1>
+
<div class="error-banner">${this.error}</div>
+
<a href="/" class="back-link">Back to home</a>
+
</div>
+
`;
+
}
+
+
if (this.isSuccess) {
+
return html`
+
<div class="reset-card">
+
<div class="success-message">
+
<div class="success-icon">✓</div>
+
<div class="success-text">Password reset successfully!</div>
+
<a href="/" class="success-link">Go to home</a>
+
</div>
+
</div>
+
`;
+
}
+
+
return html`
+
<div class="reset-card">
+
<h1 class="reset-title">Reset Password</h1>
+
+
<form @submit=${this.handleSubmit}>
+
${this.error
+
? html`<div class="error-banner">${this.error}</div>`
+
: ""}
+
+
<div class="form-group">
+
<label for="password">New Password</label>
+
<input
+
type="password"
+
id="password"
+
.value=${this.password}
+
@input=${(e: Event) => {
+
this.password = (e.target as HTMLInputElement).value;
+
}}
+
required
+
minlength="8"
+
placeholder="Enter new password (min 8 characters)"
+
>
+
</div>
+
+
<div class="form-group">
+
<label for="confirm-password">Confirm Password</label>
+
<input
+
type="password"
+
id="confirm-password"
+
.value=${this.confirmPassword}
+
@input=${(e: Event) => {
+
this.confirmPassword = (e.target as HTMLInputElement).value;
+
}}
+
required
+
minlength="8"
+
placeholder="Confirm new password"
+
>
+
</div>
+
+
<button type="submit" class="btn-primary" ?disabled=${this.isSubmitting}>
+
${this.isSubmitting ? "Resetting..." : "Reset Password"}
+
</button>
+
</form>
+
+
<a href="/" class="back-link">Back to home</a>
+
</div>
+
`;
+
}
+
+
private async handleSubmit(e: Event) {
+
e.preventDefault();
+
this.error = "";
+
+
// Validate passwords match
+
if (this.password !== this.confirmPassword) {
+
this.error = "Passwords do not match";
+
return;
+
}
+
+
// Validate password length
+
if (this.password.length < 8) {
+
this.error = "Password must be at least 8 characters";
+
return;
+
}
+
+
this.isSubmitting = true;
+
+
try {
+
if (!this.email) {
+
throw new Error("Email not loaded");
+
}
+
+
// Hash password client-side with user's email
+
const hashedPassword = await hashPasswordClient(this.password, this.email);
+
+
const response = await fetch("/api/auth/reset-password", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({ token: this.token, password: hashedPassword }),
+
});
+
+
const data = await response.json();
+
+
if (!response.ok) {
+
throw new Error(data.error || "Failed to reset password");
+
}
+
+
// Show success message
+
this.isSuccess = true;
+
} catch (err) {
+
this.error =
+
err instanceof Error ? err.message : "Failed to reset password";
+
} finally {
+
this.isSubmitting = false;
+
}
+
}
+
}
+22 -26
src/components/user-modal.ts
···
color: #991b1b;
}
+
.info-text {
+
color: var(--text);
+
font-size: 0.875rem;
+
margin: 0 0 1rem 0;
+
line-height: 1.5;
+
opacity: 0.8;
+
}
+
.session-list, .passkey-list {
list-style: none;
padding: 0;
···
private async handleChangePassword(e: Event) {
e.preventDefault();
-
const form = e.target as HTMLFormElement;
-
const input = form.querySelector("input") as HTMLInputElement;
-
const password = input.value;
-
-
if (password.length < 8) {
-
alert("Password must be at least 8 characters");
-
return;
-
}
if (
!confirm(
-
"Are you sure you want to change this user's password? This will log them out of all devices.",
+
"Send a password reset email to this user? They will receive a link to set a new password.",
)
) {
return;
}
+
const form = e.target as HTMLFormElement;
const submitBtn = form.querySelector(
'button[type="submit"]',
) as HTMLButtonElement;
submitBtn.disabled = true;
-
submitBtn.textContent = "Updating...";
+
submitBtn.textContent = "Sending...";
try {
-
const res = await fetch(`/api/admin/users/${this.userId}/password`, {
-
method: "PUT",
+
const res = await fetch(`/api/admin/users/${this.userId}/password-reset`, {
+
method: "POST",
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({ password }),
});
if (!res.ok) {
-
throw new Error("Failed to update password");
+
const data = await res.json();
+
throw new Error(data.error || "Failed to send password reset email");
}
alert(
-
"Password updated successfully. User has been logged out of all devices.",
+
"Password reset email sent successfully. The user will receive a link to set a new password.",
);
-
input.value = "";
-
await this.loadUserDetails();
-
} catch {
-
alert("Failed to update password");
+
} catch (err) {
+
this.error = err instanceof Error ? err.message : "Failed to send password reset email";
} finally {
submitBtn.disabled = false;
-
submitBtn.textContent = "Update Password";
+
submitBtn.textContent = "Send Reset Email";
}
}
···
</div>
<div class="detail-section">
-
<h3 class="detail-section-title">Change Password</h3>
+
<h3 class="detail-section-title">Password Reset</h3>
+
<p class="info-text">Send a password reset email to this user. They will receive a secure link to set a new password.</p>
<form @submit=${this.handleChangePassword}>
-
<div class="form-group">
-
<label class="form-label" for="new-password">New Password</label>
-
<input type="password" id="new-password" class="form-input" placeholder="Enter new password (min 8 characters)">
-
</div>
-
<button type="submit" class="btn btn-primary">Update Password</button>
+
<button type="submit" class="btn btn-primary">Send Reset Email</button>
</form>
</div>
+1 -1
src/index.test.README.md
···
- `PUT /api/admin/users/:id/role` - Update user role
- `PUT /api/admin/users/:id/name` - Update user name
- `PUT /api/admin/users/:id/email` - Update user email
-
- `PUT /api/admin/users/:id/password` - Update user password
+
- `POST /api/admin/users/:id/password-reset` - Send password reset email
- `GET /api/admin/users/:id/sessions` - List user sessions
- `DELETE /api/admin/users/:id/sessions` - Delete all user sessions
- `DELETE /api/admin/users/:id/sessions/:sessionId` - Delete specific session
+69 -11
src/index.ts
···
},
},
"/api/auth/reset-password": {
+
GET: async (req) => {
+
try {
+
const url = new URL(req.url);
+
const token = url.searchParams.get("token");
+
+
if (!token) {
+
return Response.json(
+
{ error: "Token required" },
+
{ status: 400 },
+
);
+
}
+
+
const userId = verifyPasswordResetToken(token);
+
if (!userId) {
+
return Response.json(
+
{ error: "Invalid or expired reset token" },
+
{ status: 400 },
+
);
+
}
+
+
// Get user's email for client-side password hashing
+
const user = db
+
.query<{ email: string }, [number]>("SELECT email FROM users WHERE id = ?")
+
.get(userId);
+
+
if (!user) {
+
return Response.json({ error: "User not found" }, { status: 404 });
+
}
+
+
return Response.json({ email: user.email });
+
} catch (error) {
+
console.error("[Email] Get reset token info error:", error);
+
return Response.json(
+
{ error: "Failed to verify token" },
+
{ status: 500 },
+
);
+
}
+
},
POST: async (req) => {
try {
const body = await req.json();
···
},
},
-
"/api/admin/users/:id/password": {
-
PUT: async (req) => {
+
"/api/admin/users/:id/password-reset": {
+
POST: async (req) => {
try {
requireAdmin(req);
const userId = Number.parseInt(req.params.id, 10);
···
return Response.json({ error: "Invalid user ID" }, { status: 400 });
-
const body = await req.json();
-
const { password } = body as { password: string };
+
// Get user details
+
const user = db
+
.query<
+
{ id: number; email: string; name: string | null },
+
[number]
+
>("SELECT id, email, name FROM users WHERE id = ?")
+
.get(userId);
-
if (!password || password.length < 8) {
-
return Response.json(
-
{ error: "Password must be at least 8 characters" },
-
{ status: 400 },
-
);
+
if (!user) {
+
return Response.json({ error: "User not found" }, { status: 404 });
-
await updateUserPassword(userId, password);
-
return Response.json({ success: true });
+
// Create password reset token
+
const origin = req.headers.get("origin") || "http://localhost:3000";
+
const resetToken = createPasswordResetToken(user.id);
+
const resetLink = `${origin}/reset-password?token=${resetToken}`;
+
+
// Send password reset email
+
await sendEmail({
+
to: user.email,
+
subject: "Reset your password - Thistle",
+
html: passwordResetTemplate({
+
name: user.name,
+
resetLink,
+
}),
+
});
+
+
return Response.json({
+
success: true,
+
message: "Password reset email sent"
+
});
} catch (error) {
+
console.error("[Admin] Password reset error:", error);
return handleError(error);
},
+31 -124
src/pages/reset-password.html
···
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Password - Thistle</title>
-
<link rel="icon"
-
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
+
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
+
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
+
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
+
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
+
<style>
+
main {
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
padding: 4rem 1rem;
+
}
+
</style>
</head>
<body>
-
<auth-component></auth-component>
-
+
<header>
+
<div class="header-content">
+
<a href="/" class="site-title">
+
<img src="../../public/favicon/favicon-32x32.png" alt="Thistle logo">
+
<span>Thistle</span>
+
</a>
+
<auth-component></auth-component>
+
</div>
+
</header>
+
<main>
-
<div class="container" style="max-width: 28rem; margin: 4rem auto; padding: 0 1rem;">
-
<div class="reset-card" style="background: var(--white); border: 1px solid var(--silver); border-radius: 0.5rem; padding: 2rem;">
-
<h1 style="margin-top: 0; text-align: center;">🪻 Reset Password</h1>
-
-
<div id="form-container">
-
<form id="reset-form">
-
<div style="margin-bottom: 1.5rem;">
-
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">New Password</label>
-
<input
-
type="password"
-
id="password"
-
required
-
minlength="8"
-
style="width: 100%; padding: 0.75rem; border: 1px solid var(--silver); border-radius: 0.25rem; font-size: 1rem;"
-
>
-
</div>
-
-
<div style="margin-bottom: 1.5rem;">
-
<label for="confirm-password" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Confirm Password</label>
-
<input
-
type="password"
-
id="confirm-password"
-
required
-
minlength="8"
-
style="width: 100%; padding: 0.75rem; border: 1px solid var(--silver); border-radius: 0.25rem; font-size: 1rem;"
-
>
-
</div>
-
-
<div id="error-message" style="display: none; color: var(--coral); margin-bottom: 1rem; padding: 0.75rem; background: #fef2f2; border-radius: 0.25rem;"></div>
-
-
<button
-
type="submit"
-
id="submit-btn"
-
style="width: 100%; padding: 0.75rem; background: var(--accent); color: var(--white); border: none; border-radius: 0.25rem; font-size: 1rem; font-weight: 500; cursor: pointer;"
-
>
-
Reset Password
-
</button>
-
</form>
-
-
<div style="text-align: center; margin-top: 1.5rem;">
-
<a href="/" style="color: var(--primary); text-decoration: none;">Back to home</a>
-
</div>
-
</div>
-
-
<div id="success-message" style="display: none; text-align: center;">
-
<div style="color: var(--primary); margin-bottom: 1rem;">
-
✓ Password reset successfully!
-
</div>
-
<a href="/" style="color: var(--accent); font-weight: 500; text-decoration: none;">Go to home</a>
-
</div>
-
</div>
-
</div>
+
<reset-password-form id="reset-form"></reset-password-form>
</main>
<script type="module" src="../components/auth.ts"></script>
+
<script type="module" src="../components/reset-password-form.ts"></script>
<script type="module">
-
import { hashPasswordClient } from '../lib/client-auth.ts';
-
-
const form = document.getElementById('reset-form');
-
const passwordInput = document.getElementById('password');
-
const confirmPasswordInput = document.getElementById('confirm-password');
-
const submitBtn = document.getElementById('submit-btn');
-
const errorMessage = document.getElementById('error-message');
-
const formContainer = document.getElementById('form-container');
-
const successMessage = document.getElementById('success-message');
-
-
// Get token from URL
+
// Wait for component to be defined before setting token
+
await customElements.whenDefined('reset-password-form');
+
+
// Get token from URL and pass to component
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
-
-
if (!token) {
-
errorMessage.textContent = 'Invalid or missing reset token';
-
errorMessage.style.display = 'block';
-
form.style.display = 'none';
+
const resetForm = document.getElementById('reset-form');
+
if (resetForm) {
+
resetForm.token = token;
}
-
-
form?.addEventListener('submit', async (e) => {
-
e.preventDefault();
-
-
const password = passwordInput.value;
-
const confirmPassword = confirmPasswordInput.value;
-
-
// Validate passwords match
-
if (password !== confirmPassword) {
-
errorMessage.textContent = 'Passwords do not match';
-
errorMessage.style.display = 'block';
-
return;
-
}
-
-
// Validate password length
-
if (password.length < 8) {
-
errorMessage.textContent = 'Password must be at least 8 characters';
-
errorMessage.style.display = 'block';
-
return;
-
}
-
-
errorMessage.style.display = 'none';
-
submitBtn.disabled = true;
-
submitBtn.textContent = 'Resetting...';
-
-
try {
-
// Hash password client-side
-
const hashedPassword = await hashPasswordClient(password);
-
-
const response = await fetch('/api/auth/reset-password', {
-
method: 'POST',
-
headers: { 'Content-Type': 'application/json' },
-
body: JSON.stringify({ token, password: hashedPassword }),
-
});
-
-
const data = await response.json();
-
-
if (!response.ok) {
-
throw new Error(data.error || 'Failed to reset password');
-
}
-
-
// Show success message
-
formContainer.style.display = 'none';
-
successMessage.style.display = 'block';
-
-
} catch (error) {
-
errorMessage.textContent = error.message || 'Failed to reset password';
-
errorMessage.style.display = 'block';
-
submitBtn.disabled = false;
-
submitBtn.textContent = 'Reset Password';
-
}
-
});
</script>
</body>