🪻 distributed transcription service thistle.dunkirk.sh

feat: add email verification when changing the email

dunkirk.sh 0234abb6 4e60b3f4

verified
+15
scripts/clear-rate-limits.ts
···
+
#!/usr/bin/env bun
+
+
import db from "../src/db/schema";
+
+
console.log("🧹 Clearing all rate limit attempts...");
+
+
const result = db.run("DELETE FROM rate_limit_attempts");
+
+
const deletedCount = result.changes;
+
+
if (deletedCount === 0) {
+
console.log("ℹ️ No rate limit attempts to clear");
+
} else {
+
console.log(`✅ Successfully cleared ${deletedCount} rate limit attempt${deletedCount === 1 ? '' : 's'}`);
+
}
+12 -3
src/components/user-modal.ts
···
private async handleChangeEmail(e: Event) {
e.preventDefault();
const form = e.target as HTMLFormElement;
-
const input = form.querySelector("input") as HTMLInputElement;
+
const input = form.querySelector('input[type="email"]') as HTMLInputElement;
+
const checkbox = form.querySelector('input[type="checkbox"]') as HTMLInputElement;
const email = input.value.trim();
+
const skipVerification = checkbox?.checked || false;
if (!email || !email.includes("@")) {
alert("Please enter a valid email");
···
const res = await fetch(`/api/admin/users/${this.userId}/email`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({ email }),
+
body: JSON.stringify({ email, skipVerification }),
});
if (!res.ok) {
···
throw new Error(data.error || "Failed to update email");
}
-
alert("Email updated successfully");
+
const data = await res.json();
+
alert(data.message || "Email updated successfully");
await this.loadUserDetails();
this.dispatchEvent(
new CustomEvent("user-updated", { bubbles: true, composed: true }),
···
<div class="form-group">
<label class="form-label" for="new-email">New Email</label>
<input type="email" id="new-email" class="form-input" placeholder="Enter new email" value=${this.user.email}>
+
</div>
+
<div class="form-group" style="margin-top: 0.5rem;">
+
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-size: 0.875rem;">
+
<input type="checkbox" id="skip-verification" style="cursor: pointer;">
+
<span>Skip verification (use if user is locked out of email)</span>
+
</label>
</div>
<button type="submit" class="btn btn-primary">Update Email</button>
</form>
+50 -5
src/components/user-settings.ts
···
@state() addingPasskey = false;
@state() emailNotificationsEnabled = true;
@state() deletingAccount = false;
+
@state() emailChangeMessage = "";
+
@state() pendingEmailChange = "";
+
@state() updatingEmail = false;
static override styles = css`
:host {
···
opacity: 0.7;
margin-bottom: 1.5rem;
line-height: 1.5;
+
}
+
+
.success-message {
+
padding: 1rem;
+
background: rgba(76, 175, 80, 0.1);
+
border: 1px solid rgba(76, 175, 80, 0.3);
+
border-radius: 0.5rem;
+
color: var(--text);
+
}
+
+
.spinner {
+
display: inline-block;
+
width: 1rem;
+
height: 1rem;
+
border: 2px solid rgba(255, 255, 255, 0.3);
+
border-top-color: white;
+
border-radius: 50%;
+
animation: spin 0.6s linear infinite;
+
}
+
+
@keyframes spin {
+
to {
+
transform: rotate(360deg);
+
}
}
.session-list {
···
async handleUpdateEmail() {
this.error = "";
+
this.emailChangeMessage = "";
if (!this.newEmail) {
this.error = "Email required";
return;
}
+
this.updatingEmail = true;
try {
const response = await fetch("/api/user/email", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: this.newEmail }),
});
+
+
const data = await response.json();
if (!response.ok) {
-
const data = await response.json();
this.error = data.error || "Failed to update email";
return;
}
-
// Reload user data
-
await this.loadUser();
+
// Show success message with pending email
+
this.emailChangeMessage = data.message || "Verification email sent";
+
this.pendingEmailChange = data.pendingEmail || this.newEmail;
this.editingEmail = false;
this.newEmail = "";
} catch {
this.error = "Failed to update email";
+
} finally {
+
this.updatingEmail = false;
}
}
···
<div class="field-group">
<label class="field-label">Email</label>
${
-
this.editingEmail
+
this.emailChangeMessage
+
? html`
+
<div class="success-message" style="margin-bottom: 1rem;">
+
${this.emailChangeMessage}
+
${this.pendingEmailChange ? html`<br><strong>New email:</strong> ${this.pendingEmailChange}` : ''}
+
</div>
+
<div class="field-row">
+
<div class="field-value">${this.user.email}</div>
+
</div>
+
`
+
: this.editingEmail
? html`
<div style="display: flex; gap: 0.5rem; align-items: center;">
<input
···
<button
class="btn btn-affirmative btn-small"
@click=${this.handleUpdateEmail}
+
?disabled=${this.updatingEmail}
-
Save
+
${this.updatingEmail ? html`<span class="spinner"></span>` : 'Save'}
</button>
<button
class="btn btn-neutral btn-small"
···
@click=${() => {
this.editingEmail = true;
this.newEmail = this.user?.email ?? "";
+
this.emailChangeMessage = "";
}}
Change
+19
src/db/schema.ts
···
ALTER TABLE users ADD COLUMN email_notifications_enabled BOOLEAN DEFAULT 1;
`,
},
+
{
+
version: 9,
+
name: "Add email change tokens table",
+
sql: `
+
-- Email change tokens table
+
CREATE TABLE IF NOT EXISTS email_change_tokens (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
new_email TEXT NOT NULL,
+
token TEXT NOT NULL UNIQUE,
+
expires_at INTEGER NOT NULL,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_email_change_tokens_user_id ON email_change_tokens(user_id);
+
CREATE INDEX IF NOT EXISTS idx_email_change_tokens_token ON email_change_tokens(token);
+
`,
+
},
];
function getCurrentVersion(): number {
+80 -12
src/index.ts
···
createPasswordResetToken,
verifyPasswordResetToken,
consumePasswordResetToken,
+
createEmailChangeToken,
+
verifyEmailChangeToken,
+
consumeEmailChangeToken,
} from "./lib/auth";
import {
addToWaitlist,
···
import {
verifyEmailTemplate,
passwordResetTemplate,
+
emailChangeTemplate,
} from "./lib/email-templates";
import adminHTML from "./pages/admin.html";
import checkoutHTML from "./pages/checkout.html";
···
if (!email) {
return Response.json({ error: "Email required" }, { status: 400 });
+
+
// Check if email is already in use
+
const existingUser = getUserByEmail(email);
+
if (existingUser) {
+
return Response.json(
+
{ error: "Email already in use" },
+
{ status: 400 },
+
);
+
}
+
try {
-
updateUserEmail(user.id, email);
-
return Response.json({ success: true });
-
} catch (err: unknown) {
-
const error = err as { message?: string };
-
if (error.message?.includes("UNIQUE constraint failed")) {
-
return Response.json(
-
{ error: "Email already in use" },
-
{ status: 400 },
-
);
-
}
+
// Create email change token
+
const token = createEmailChangeToken(user.id, email);
+
+
// Send verification email to the CURRENT address
+
const origin = process.env.ORIGIN || "http://localhost:3000";
+
const verifyUrl = `${origin}/api/user/email/verify?token=${token}`;
+
+
await sendEmail({
+
to: user.email,
+
subject: "Verify your email change",
+
html: emailChangeTemplate({
+
name: user.name,
+
currentEmail: user.email,
+
newEmail: email,
+
verifyLink: verifyUrl,
+
}),
+
});
+
+
return Response.json({
+
success: true,
+
message: `Verification email sent to ${user.email}`,
+
pendingEmail: email
+
});
+
} catch (error) {
+
console.error("[Email] Failed to send email change verification:", error);
return Response.json(
-
{ error: "Failed to update email" },
+
{ error: "Failed to send verification email" },
{ status: 500 },
);
+
}
+
},
+
},
+
"/api/user/email/verify": {
+
GET: async (req) => {
+
try {
+
const url = new URL(req.url);
+
const token = url.searchParams.get("token");
+
+
if (!token) {
+
return Response.redirect("/settings?tab=account&error=invalid-token", 302);
+
}
+
+
const result = verifyEmailChangeToken(token);
+
+
if (!result) {
+
return Response.redirect("/settings?tab=account&error=expired-token", 302);
+
}
+
+
// Update the user's email
+
updateUserEmail(result.userId, result.newEmail);
+
+
// Consume the token
+
consumeEmailChangeToken(token);
+
+
// Redirect to settings with success message
+
return Response.redirect("/settings?tab=account&success=email-changed", 302);
+
} catch (error) {
+
console.error("[Email] Email change verification error:", error);
+
return Response.redirect("/settings?tab=account&error=verification-failed", 302);
},
},
···
const body = await req.json();
-
const { email } = body as { email: string };
+
const { email, skipVerification } = body as { email: string; skipVerification?: boolean };
if (!email || !email.includes("@")) {
return Response.json(
···
{ error: "Email already in use" },
{ status: 400 },
);
+
}
+
+
if (skipVerification) {
+
// Admin override: change email immediately without verification
+
updateUserEmailAddress(userId, email);
+
return Response.json({
+
success: true,
+
message: "Email updated immediately (verification skipped)"
+
});
updateUserEmailAddress(userId, email);
+38
src/lib/auth.ts
···
db.run("DELETE FROM password_reset_tokens WHERE token = ?", [token]);
}
+
/**
+
* Email change functions
+
*/
+
+
export function createEmailChangeToken(userId: number, newEmail: string): string {
+
const token = crypto.randomUUID();
+
const id = crypto.randomUUID();
+
const expiresAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; // 24 hours
+
+
// Delete any existing email change tokens for this user
+
db.run("DELETE FROM email_change_tokens WHERE user_id = ?", [userId]);
+
+
db.run(
+
"INSERT INTO email_change_tokens (id, user_id, new_email, token, expires_at) VALUES (?, ?, ?, ?, ?)",
+
[id, userId, newEmail, token, expiresAt],
+
);
+
+
return token;
+
}
+
+
export function verifyEmailChangeToken(token: string): { userId: number; newEmail: string } | null {
+
const now = Math.floor(Date.now() / 1000);
+
+
const result = db
+
.query<{ user_id: number; new_email: string }, [string, number]>(
+
"SELECT user_id, new_email FROM email_change_tokens WHERE token = ? AND expires_at > ?",
+
)
+
.get(token, now);
+
+
if (!result) return null;
+
+
return { userId: result.user_id, newEmail: result.new_email };
+
}
+
+
export function consumeEmailChangeToken(token: string): void {
+
db.run("DELETE FROM email_change_tokens WHERE token = ?", [token]);
+
}
+
export function isUserAdmin(userId: number): boolean {
const result = db
.query<{ role: UserRole }, [number]>("SELECT role FROM users WHERE id = ?")
+96
src/lib/email-change.test.ts
···
+
import { test, expect } from "bun:test";
+
import db from "../db/schema";
+
import {
+
createUser,
+
createEmailChangeToken,
+
verifyEmailChangeToken,
+
consumeEmailChangeToken,
+
updateUserEmail,
+
getUserByEmail,
+
} from "./auth";
+
+
test("email change token lifecycle", async () => {
+
// Create a test user with unique email
+
const timestamp = Date.now();
+
const user = await createUser(`test-email-change-${timestamp}@example.com`, "password123", "Test User");
+
+
// Create an email change token
+
const newEmail = `new-email-${timestamp}@example.com`;
+
const token = createEmailChangeToken(user.id, newEmail);
+
+
expect(token).toBeTruthy();
+
expect(token.length).toBeGreaterThan(0);
+
+
// Verify the token
+
const result = verifyEmailChangeToken(token);
+
expect(result).toBeTruthy();
+
expect(result?.userId).toBe(user.id);
+
expect(result?.newEmail).toBe(newEmail);
+
+
// Update the email
+
updateUserEmail(result!.userId, result!.newEmail);
+
+
// Consume the token
+
consumeEmailChangeToken(token);
+
+
// Verify the email was updated
+
const updatedUser = getUserByEmail(newEmail);
+
expect(updatedUser).toBeTruthy();
+
expect(updatedUser?.id).toBe(user.id);
+
expect(updatedUser?.email).toBe(newEmail);
+
+
// Verify the token can't be used again
+
const result2 = verifyEmailChangeToken(token);
+
expect(result2).toBeNull();
+
+
// Clean up
+
db.run("DELETE FROM users WHERE id = ?", [user.id]);
+
});
+
+
test("email change token expires", async () => {
+
// Create a test user with unique email
+
const timestamp = Date.now();
+
const user = await createUser(`test-expire-${timestamp}@example.com`, "password123", "Test User");
+
+
// Create an email change token
+
const newEmail = `new-expire-${timestamp}@example.com`;
+
const token = createEmailChangeToken(user.id, newEmail);
+
+
// Manually expire the token
+
db.run("UPDATE email_change_tokens SET expires_at = ? WHERE token = ?", [
+
Math.floor(Date.now() / 1000) - 1,
+
token,
+
]);
+
+
// Verify the token is expired
+
const result = verifyEmailChangeToken(token);
+
expect(result).toBeNull();
+
+
// Clean up
+
db.run("DELETE FROM users WHERE id = ?", [user.id]);
+
});
+
+
test("only one email change token per user", async () => {
+
// Create a test user with unique email
+
const timestamp = Date.now();
+
const user = await createUser(`test-single-token-${timestamp}@example.com`, "password123", "Test User");
+
+
// Create first token
+
const token1 = createEmailChangeToken(user.id, `email1-${timestamp}@example.com`);
+
+
// Create second token (should delete first)
+
const token2 = createEmailChangeToken(user.id, `email2-${timestamp}@example.com`);
+
+
// First token should be invalid
+
const result1 = verifyEmailChangeToken(token1);
+
expect(result1).toBeNull();
+
+
// Second token should work
+
const result2 = verifyEmailChangeToken(token2);
+
expect(result2).toBeTruthy();
+
expect(result2?.newEmail).toBe(`email2-${timestamp}@example.com`);
+
+
// Clean up
+
db.run("DELETE FROM users WHERE id = ?", [user.id]);
+
db.run("DELETE FROM email_change_tokens WHERE user_id = ?", [user.id]);
+
});
+61
src/lib/email-templates.ts
···
</html>
`.trim();
}
+
+
interface EmailChangeOptions {
+
name: string | null;
+
currentEmail: string;
+
newEmail: string;
+
verifyLink: string;
+
}
+
+
export function emailChangeTemplate(options: EmailChangeOptions): string {
+
const greeting = options.name ? `Hi ${options.name}` : "Hi there";
+
+
return `
+
<!DOCTYPE html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Verify Email Change - Thistle</title>
+
<style>${baseStyles}</style>
+
</head>
+
<body>
+
<div class="container">
+
<div class="header">
+
<h1>🪻 Thistle</h1>
+
</div>
+
<div class="content">
+
<h2>${greeting}!</h2>
+
<p>You requested to change your email address.</p>
+
+
<div class="info-box">
+
<p class="info-box-label">Current Email</p>
+
<p class="info-box-value">${options.currentEmail}</p>
+
<hr class="info-box-divider">
+
<p class="info-box-label">New Email</p>
+
<p class="info-box-value">${options.newEmail}</p>
+
</div>
+
+
<p>Click the button below to confirm this change:</p>
+
+
<p style="text-align: center; margin-top: 1.5rem; margin-bottom: 0;">
+
<a href="${options.verifyLink}" class="button" style="display: inline-block; background-color: #ef8354; color: #ffffff; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 6px; font-weight: 500; font-size: 1rem; margin: 0; border: 2px solid #ef8354;">Verify Email Change</a>
+
</p>
+
+
<p style="color: #4f5d75; font-size: 0.875rem; margin-top: 1.5rem;">
+
If the button doesn't work, copy and paste this link into your browser:<br>
+
<a href="${options.verifyLink}" style="color: #4f5d75; word-break: break-all;">${options.verifyLink}</a>
+
</p>
+
+
<p style="color: #4f5d75; font-size: 0.875rem;">
+
This link will expire in 24 hours.
+
</p>
+
</div>
+
<div class="footer">
+
<p>If you didn't request this change, please ignore this email and your email address will remain unchanged.</p>
+
</div>
+
</div>
+
</body>
+
</html>
+
`.trim();
+
}
+