mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-14 12:28:18 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f27489463c | ||
|
|
990639d804 | ||
|
|
8108e62cf1 | ||
|
|
b0a3a1fcb8 | ||
|
|
c411975bb9 | ||
|
|
e08a4883f1 | ||
|
|
0cf34e631b | ||
|
|
9f8fe85c44 | ||
|
|
5ac7de7f78 | ||
|
|
5f6149e282 | ||
|
|
633727b58e | ||
|
|
3ab7c1fb53 |
@@ -44,3 +44,4 @@ todo.md
|
|||||||
|
|
||||||
# Worktrees
|
# Worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
package-lock.json
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ Write tests first or alongside features. Tests serve as living documentation of
|
|||||||
- Test database: `./data/test-crumbs.db` (cleaned via `global-setup.ts`)
|
- Test database: `./data/test-crumbs.db` (cleaned via `global-setup.ts`)
|
||||||
- Auth fixture: `tests/e2e/helpers/fixtures.ts` provides `authenticatedPage` (handles setup/login race conditions across parallel workers)
|
- Auth fixture: `tests/e2e/helpers/fixtures.ts` provides `authenticatedPage` (handles setup/login race conditions across parallel workers)
|
||||||
- Auth tests use `test.describe.serial` because they depend on sequential database state
|
- Auth tests use `test.describe.serial` because they depend on sequential database state
|
||||||
|
- **Offline/online tests**: After `setOffline(false)`, always manually dispatch the `online` event via `page.evaluate(() => window.dispatchEvent(new Event('online')))` — Playwright doesn't reliably emit it in headless/CI environments, causing flaky `waitForResponse` timeouts
|
||||||
|
|
||||||
### Gherkin-style Given/When/Then
|
### Gherkin-style Given/When/Then
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
crumbs:
|
crumbs:
|
||||||
image: ghcr.io/bretzel-app/crumbs:0.19.3
|
image: ghcr.io/bretzel-app/crumbs:0.21.0
|
||||||
container_name: crumbs
|
container_name: crumbs
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|||||||
Generated
+13070
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "crumbs",
|
"name": "crumbs",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.20.0",
|
"version": "0.21.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Bretzel (https://bretzel.app)",
|
"author": "Bretzel (https://bretzel.app)",
|
||||||
|
|||||||
@@ -170,36 +170,19 @@
|
|||||||
bgStyle = `background-color: ${colors.bg}`;
|
bgStyle = `background-color: ${colors.bg}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track whether mousedown started on the overlay (not inside the editor).
|
// Use pointer events to detect backdrop clicks. Unlike mouse events,
|
||||||
// Prevents closing when the user selects text inside the editor and
|
// pointer events are never synthetically re-dispatched from touch, so
|
||||||
// releases the mouse outside — the click event fires on the overlay,
|
// the tap that opened the editor on mobile cannot ghost-click the overlay.
|
||||||
// but we only want to close if the press also started there.
|
// Both pointerdown and pointerup must target the overlay itself to close.
|
||||||
let mousedownOnOverlay = false;
|
let pointerdownOnOverlay = false;
|
||||||
|
|
||||||
// On mobile, the tap that opens the editor produces synthetic mouse events
|
function handleOverlayPointerdown(e: PointerEvent) {
|
||||||
// (mousedown → mouseup → click) at the original touch coordinates. If those
|
pointerdownOnOverlay = e.target === e.currentTarget;
|
||||||
// coordinates land on the overlay backdrop, the editor would close instantly.
|
|
||||||
// Guard against this by ignoring mousedowns that arrive within the first
|
|
||||||
// animation frame after mount.
|
|
||||||
let overlayReady = false;
|
|
||||||
$effect(() => {
|
|
||||||
const id = requestAnimationFrame(() => {
|
|
||||||
overlayReady = true;
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
overlayReady = false;
|
|
||||||
cancelAnimationFrame(id);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleOverlayMousedown(e: MouseEvent) {
|
|
||||||
if (!overlayReady) return;
|
|
||||||
mousedownOnOverlay = e.target === e.currentTarget;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOverlayClick() {
|
function handleOverlayPointerup(e: PointerEvent) {
|
||||||
if (mousedownOnOverlay) saveAndClose();
|
if (pointerdownOnOverlay && e.target === e.currentTarget) saveAndClose();
|
||||||
mousedownOnOverlay = false;
|
pointerdownOnOverlay = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAndClose() {
|
async function saveAndClose() {
|
||||||
@@ -263,8 +246,8 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||||
onmousedown={handleOverlayMousedown}
|
onpointerdown={handleOverlayPointerdown}
|
||||||
onclick={handleOverlayClick}
|
onpointerup={handleOverlayPointerup}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
data-testid="note-editor-overlay"
|
data-testid="note-editor-overlay"
|
||||||
>
|
>
|
||||||
@@ -272,7 +255,6 @@
|
|||||||
<div
|
<div
|
||||||
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||||
style={bgStyle}
|
style={bgStyle}
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
|
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
|
||||||
data-testid="note-editor"
|
data-testid="note-editor"
|
||||||
>
|
>
|
||||||
|
|||||||
+252
-15
@@ -65,6 +65,125 @@ export async function sendShareNotification(
|
|||||||
await sendEmail(toEmail, subject, html);
|
await sendEmail(toEmail, subject, html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a welcome email to a newly created user.
|
||||||
|
*/
|
||||||
|
export async function sendWelcomeEmail(
|
||||||
|
toEmail: string,
|
||||||
|
toName: string,
|
||||||
|
appUrl: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = 'Welcome to Crumbs';
|
||||||
|
const html = buildWelcomeEmailHtml(toName, appUrl);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a password reset notification to a user (triggered by admin reset).
|
||||||
|
*/
|
||||||
|
export async function sendPasswordResetEmail(
|
||||||
|
toEmail: string,
|
||||||
|
toName: string,
|
||||||
|
appUrl: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = 'Your Crumbs password has been reset';
|
||||||
|
const html = buildPasswordResetEmailHtml(toName, appUrl);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an account deletion confirmation email.
|
||||||
|
*/
|
||||||
|
export async function sendAccountDeletedEmail(toEmail: string, toName: string): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = 'Your Crumbs account has been deleted';
|
||||||
|
const html = buildAccountDeletedEmailHtml(toName);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a notification to the old email when the account email address changes.
|
||||||
|
*/
|
||||||
|
export async function sendEmailChangedEmail(
|
||||||
|
oldEmail: string,
|
||||||
|
toName: string,
|
||||||
|
newEmail: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!oldEmail) return;
|
||||||
|
|
||||||
|
const subject = 'Your Crumbs email address has been changed';
|
||||||
|
const html = buildEmailChangedEmailHtml(toName, newEmail);
|
||||||
|
await sendEmail(oldEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a role change notification to a user.
|
||||||
|
*/
|
||||||
|
export async function sendRoleChangedEmail(
|
||||||
|
toEmail: string,
|
||||||
|
toName: string,
|
||||||
|
newRole: 'admin' | 'user',
|
||||||
|
appUrl: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = 'Your Crumbs account role has been updated';
|
||||||
|
const html = buildRoleChangedEmailHtml(toName, newRole, appUrl);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a notification to a collaborator that they have been removed from a note.
|
||||||
|
*/
|
||||||
|
export async function sendCollaboratorRemovedEmail(
|
||||||
|
toEmail: string,
|
||||||
|
toName: string,
|
||||||
|
ownerName: string,
|
||||||
|
noteTitle: string,
|
||||||
|
appUrl: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = `You've been removed from a shared note`;
|
||||||
|
const html = buildCollaboratorRemovedEmailHtml(toName, ownerName, noteTitle, appUrl);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a notification to collaborators that a shared note has been permanently deleted.
|
||||||
|
*/
|
||||||
|
export async function sendNotePermanentlyDeletedEmail(
|
||||||
|
toEmail: string,
|
||||||
|
toName: string,
|
||||||
|
noteTitle: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = `A shared note has been deleted`;
|
||||||
|
const html = buildNotePermanentlyDeletedEmailHtml(toName, noteTitle);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a security alert to a user when their account is locked due to too many failed logins.
|
||||||
|
*/
|
||||||
|
export async function sendAccountLockedEmail(
|
||||||
|
toEmail: string,
|
||||||
|
toName: string,
|
||||||
|
retryAfterMinutes: number
|
||||||
|
): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = 'Crumbs account temporarily locked';
|
||||||
|
const html = buildAccountLockedEmailHtml(toName, retryAfterMinutes);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
function buildShareEmailHtml(
|
function buildShareEmailHtml(
|
||||||
toName: string,
|
toName: string,
|
||||||
fromName: string,
|
fromName: string,
|
||||||
@@ -74,7 +193,18 @@ function buildShareEmailHtml(
|
|||||||
const displayName = toName || 'there';
|
const displayName = toName || 'there';
|
||||||
const escapedTitle = escapeHtml(noteTitle);
|
const escapedTitle = escapeHtml(noteTitle);
|
||||||
const escapedFrom = escapeHtml(fromName || 'Someone');
|
const escapedFrom = escapeHtml(fromName || 'Someone');
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Note shared with you</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${escapeHtml(displayName)},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
<strong>${escapedFrom}</strong> shared a note with you: <strong>“${escapedTitle}”</strong>
|
||||||
|
</p>
|
||||||
|
${buildOpenButton(appUrl)}`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this because someone shared a note with you on Crumbs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmailLayout(content: string, footer: string): string {
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head><meta charset="utf-8"></head>
|
<head><meta charset="utf-8"></head>
|
||||||
@@ -84,21 +214,8 @@ function buildShareEmailHtml(
|
|||||||
<table width="500" cellpadding="0" cellspacing="0" style="background-color:#faf5eb;border:1px solid #d4cabb;box-shadow:2px 2px 0px #d4cabb;">
|
<table width="500" cellpadding="0" cellspacing="0" style="background-color:#faf5eb;border:1px solid #d4cabb;box-shadow:2px 2px 0px #d4cabb;">
|
||||||
<tr><td style="padding:32px;">
|
<tr><td style="padding:32px;">
|
||||||
<p style="margin:0 0 8px;font-size:11px;color:#C8860A;text-transform:uppercase;letter-spacing:2px;">Crumbs</p>
|
<p style="margin:0 0 8px;font-size:11px;color:#C8860A;text-transform:uppercase;letter-spacing:2px;">Crumbs</p>
|
||||||
<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Note shared with you</h1>
|
${content}
|
||||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
<p style="margin:0;font-size:11px;color:#6b6272;line-height:1.5;">${footer}</p>
|
||||||
Hey ${escapeHtml(displayName)},
|
|
||||||
</p>
|
|
||||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
|
||||||
<strong>${escapedFrom}</strong> shared a note with you: <strong>“${escapedTitle}”</strong>
|
|
||||||
</p>
|
|
||||||
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
|
||||||
<tr><td style="background-color:#C8860A;box-shadow:2px 2px 0px #1a1a2e;">
|
|
||||||
<a href="${escapeHtml(appUrl)}" style="display:inline-block;padding:10px 24px;color:#faf5eb;font-size:13px;font-weight:bold;text-decoration:none;font-family:'JetBrains Mono',monospace;">Open Crumbs</a>
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin:0;font-size:11px;color:#6b6272;line-height:1.5;">
|
|
||||||
You’re receiving this because someone shared a note with you on Crumbs.
|
|
||||||
</p>
|
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
@@ -107,6 +224,126 @@ function buildShareEmailHtml(
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildOpenButton(appUrl: string, label = 'Open Crumbs'): string {
|
||||||
|
return `<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
||||||
|
<tr><td style="background-color:#C8860A;box-shadow:2px 2px 0px #1a1a2e;">
|
||||||
|
<a href="${escapeHtml(appUrl)}" style="display:inline-block;padding:10px 24px;color:#faf5eb;font-size:13px;font-weight:bold;text-decoration:none;font-family:'JetBrains Mono',monospace;">${escapeHtml(label)}</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWelcomeEmailHtml(toName: string, appUrl: string): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Welcome to Crumbs</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
An admin has created an account for you on Crumbs. You can log in and start taking notes right away.
|
||||||
|
</p>
|
||||||
|
${buildOpenButton(appUrl)}`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this because an admin created an account for you on Crumbs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPasswordResetEmailHtml(toName: string, appUrl: string): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Password reset</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
An admin has reset your Crumbs password. Please log in and change it to something only you know.
|
||||||
|
</p>
|
||||||
|
${buildOpenButton(appUrl)}`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this because an admin reset your password on Crumbs. If you didn’t expect this, please contact your admin.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAccountDeletedEmailHtml(toName: string): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Account deleted</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Your Crumbs account and all associated data have been permanently deleted.
|
||||||
|
</p>`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this as confirmation that your Crumbs account was deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmailChangedEmailHtml(toName: string, newEmail: string): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Email address changed</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Your Crumbs account email address has been changed to <strong>${escapeHtml(newEmail)}</strong>. If you did not make this change, please contact your admin immediately.
|
||||||
|
</p>`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this security notice because your Crumbs account email was updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoleChangedEmailHtml(toName: string, newRole: 'admin' | 'user', appUrl: string): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const roleLabel = newRole === 'admin' ? 'Admin' : 'User';
|
||||||
|
const detail = newRole === 'admin'
|
||||||
|
? 'You now have admin privileges, including the ability to manage users and settings.'
|
||||||
|
: 'Your account no longer has admin privileges.';
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Role updated: ${roleLabel}</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Your Crumbs account role has been updated to <strong>${escapeHtml(roleLabel)}</strong>. ${escapeHtml(detail)}
|
||||||
|
</p>
|
||||||
|
${buildOpenButton(appUrl)}`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this because your account role was changed on Crumbs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCollaboratorRemovedEmailHtml(
|
||||||
|
toName: string,
|
||||||
|
ownerName: string,
|
||||||
|
noteTitle: string,
|
||||||
|
appUrl: string
|
||||||
|
): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const escapedOwner = escapeHtml(ownerName || 'Someone');
|
||||||
|
const escapedTitle = escapeHtml(noteTitle);
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Removed from shared note</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
<strong>${escapedOwner}</strong> has removed you from the shared note <strong>“${escapedTitle}”</strong>. You no longer have access to this note.
|
||||||
|
</p>
|
||||||
|
${buildOpenButton(appUrl)}`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this because you were removed from a shared note on Crumbs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNotePermanentlyDeletedEmailHtml(toName: string, noteTitle: string): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const escapedTitle = escapeHtml(noteTitle);
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Shared note deleted</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
The shared note <strong>“${escapedTitle}”</strong> has been permanently deleted by its owner. You no longer have access to it.
|
||||||
|
</p>`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this because a shared note you had access to was deleted on Crumbs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAccountLockedEmailHtml(toName: string, retryAfterMinutes: number): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Account temporarily locked</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Too many failed login attempts were made on your Crumbs account. For your security, login has been temporarily blocked for <strong>${retryAfterMinutes} minute${retryAfterMinutes === 1 ? '' : 's'}</strong>. If this wasn’t you, please change your password once you regain access.
|
||||||
|
</p>`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this security alert because of repeated failed login attempts on your Crumbs account.');
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
return str
|
return str
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { createTestDb } from './db/test-helpers.js';
|
import { createTestDb } from './db/test-helpers.js';
|
||||||
import { notes, noteCollaborators, noteUserState, users } from './db/schema.js';
|
import { notes, noteCollaborators, noteUserState, noteVersions, users } from './db/schema.js';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import type { Db } from './db/index.js';
|
import type { Db } from './db/index.js';
|
||||||
import {
|
import {
|
||||||
@@ -233,6 +233,31 @@ describe('updateNote', () => {
|
|||||||
expect(updated!.version).toBe(2);
|
expect(updated!.version).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should create a snapshot when content changes', () => {
|
||||||
|
seedNote('n1', { title: 'Original', content: 'Hello', color: 'default', checklistMode: false });
|
||||||
|
updateNote(db, OWNER_ID, 'n1', { title: 'Changed' });
|
||||||
|
|
||||||
|
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||||
|
expect(snapshots).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create a snapshot when no content field actually changed', () => {
|
||||||
|
seedNote('n1', { title: 'Same', content: 'Same', color: 'default', checklistMode: false });
|
||||||
|
// Send the same values — nothing actually changes
|
||||||
|
updateNote(db, OWNER_ID, 'n1', { title: 'Same', content: 'Same' });
|
||||||
|
|
||||||
|
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||||
|
expect(snapshots).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create a snapshot when only non-content fields change (pinned, archived)', () => {
|
||||||
|
seedNote('n1', { title: 'Note', pinned: false });
|
||||||
|
updateNote(db, OWNER_ID, 'n1', { pinned: true });
|
||||||
|
|
||||||
|
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||||
|
expect(snapshots).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('should extract and sync tags on title/content change', () => {
|
it('should extract and sync tags on title/content change', () => {
|
||||||
seedNote('n1', { title: 'Original' });
|
seedNote('n1', { title: 'Original' });
|
||||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
|
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
|
||||||
|
|||||||
@@ -294,14 +294,14 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a version snapshot whenever content-related fields are saved
|
// Create a version snapshot only when at least one content field actually changed
|
||||||
const hasContentUpdate =
|
const hasActualContentChange =
|
||||||
input.title !== undefined ||
|
(input.title !== undefined && input.title !== existing.title) ||
|
||||||
input.content !== undefined ||
|
(input.content !== undefined && input.content !== existing.content) ||
|
||||||
input.color !== undefined ||
|
(input.color !== undefined && input.color !== existing.color) ||
|
||||||
input.checklistMode !== undefined;
|
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
|
||||||
|
|
||||||
if (hasContentUpdate) {
|
if (hasActualContentChange) {
|
||||||
const saved = db.select().from(notes).where(eq(notes.id, id)).get();
|
const saved = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||||
if (saved) {
|
if (saved) {
|
||||||
createSnapshot(db, id, {
|
createSnapshot(db, id, {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const CONTENT_PREVIEW_LENGTH = 80;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a snapshot of a note's current state.
|
* Create a snapshot of a note's current state.
|
||||||
* Called on every save. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
|
* Only called when content actually changed. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
|
||||||
*/
|
*/
|
||||||
export function createSnapshot(
|
export function createSnapshot(
|
||||||
db: Db,
|
db: Db,
|
||||||
|
|||||||
@@ -8,13 +8,17 @@ describe('preferences defaults', () => {
|
|||||||
defaultNoteColor: 'default',
|
defaultNoteColor: 'default',
|
||||||
hideFooter: false,
|
hideFooter: false,
|
||||||
sidebarDefaultState: 'open',
|
sidebarDefaultState: 'open',
|
||||||
notifyOnShare: true
|
notifyOnShare: true,
|
||||||
|
notifyOnCollabRemoved: true,
|
||||||
|
notifyOnNoteDeleted: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should identify boolean preference keys', () => {
|
it('should identify boolean preference keys', () => {
|
||||||
expect(BOOLEAN_PREF_KEYS.has('hideFooter')).toBe(true);
|
expect(BOOLEAN_PREF_KEYS.has('hideFooter')).toBe(true);
|
||||||
expect(BOOLEAN_PREF_KEYS.has('notifyOnShare')).toBe(true);
|
expect(BOOLEAN_PREF_KEYS.has('notifyOnShare')).toBe(true);
|
||||||
|
expect(BOOLEAN_PREF_KEYS.has('notifyOnCollabRemoved')).toBe(true);
|
||||||
|
expect(BOOLEAN_PREF_KEYS.has('notifyOnNoteDeleted')).toBe(true);
|
||||||
expect(BOOLEAN_PREF_KEYS.has('defaultNoteMode')).toBe(false);
|
expect(BOOLEAN_PREF_KEYS.has('defaultNoteMode')).toBe(false);
|
||||||
expect(BOOLEAN_PREF_KEYS.has('defaultNoteColor')).toBe(false);
|
expect(BOOLEAN_PREF_KEYS.has('defaultNoteColor')).toBe(false);
|
||||||
expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false);
|
expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface UserPreferences {
|
|||||||
hideFooter: boolean;
|
hideFooter: boolean;
|
||||||
sidebarDefaultState: 'open' | 'collapsed';
|
sidebarDefaultState: 'open' | 'collapsed';
|
||||||
notifyOnShare: boolean;
|
notifyOnShare: boolean;
|
||||||
|
notifyOnCollabRemoved: boolean;
|
||||||
|
notifyOnNoteDeleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||||
@@ -13,10 +15,14 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
|
|||||||
defaultNoteColor: 'default',
|
defaultNoteColor: 'default',
|
||||||
hideFooter: false,
|
hideFooter: false,
|
||||||
sidebarDefaultState: 'open',
|
sidebarDefaultState: 'open',
|
||||||
notifyOnShare: true
|
notifyOnShare: true,
|
||||||
|
notifyOnCollabRemoved: true,
|
||||||
|
notifyOnNoteDeleted: true
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BOOLEAN_PREF_KEYS: ReadonlySet<keyof UserPreferences> = new Set([
|
export const BOOLEAN_PREF_KEYS: ReadonlySet<keyof UserPreferences> = new Set([
|
||||||
'hideFooter',
|
'hideFooter',
|
||||||
'notifyOnShare'
|
'notifyOnShare',
|
||||||
|
'notifyOnCollabRemoved',
|
||||||
|
'notifyOnNoteDeleted'
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -66,15 +66,40 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Email notifications -->
|
<!-- Email notifications -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="space-y-3">
|
||||||
<input
|
<span class="block text-sm font-medium text-[var(--text)]">Email notifications</span>
|
||||||
type="checkbox"
|
<div class="flex items-center gap-3">
|
||||||
id="notify-on-share"
|
<input
|
||||||
checked={prefs.notifyOnShare}
|
type="checkbox"
|
||||||
onchange={() => updatePreference('notifyOnShare', !prefs.notifyOnShare)}
|
id="notify-on-share"
|
||||||
class="h-4 w-4 rounded-sm"
|
checked={prefs.notifyOnShare}
|
||||||
data-testid="pref-notify-on-share"
|
onchange={() => updatePreference('notifyOnShare', !prefs.notifyOnShare)}
|
||||||
/>
|
class="h-4 w-4 rounded-sm"
|
||||||
<label for="notify-on-share" class="text-sm text-[var(--text)]">Email me when someone shares a note with me</label>
|
data-testid="pref-notify-on-share"
|
||||||
|
/>
|
||||||
|
<label for="notify-on-share" class="text-sm text-[var(--text)]">Email me when someone shares a note with me</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="notify-on-collab-removed"
|
||||||
|
checked={prefs.notifyOnCollabRemoved}
|
||||||
|
onchange={() => updatePreference('notifyOnCollabRemoved', !prefs.notifyOnCollabRemoved)}
|
||||||
|
class="h-4 w-4 rounded-sm"
|
||||||
|
data-testid="pref-notify-on-collab-removed"
|
||||||
|
/>
|
||||||
|
<label for="notify-on-collab-removed" class="text-sm text-[var(--text)]">Email me when I'm removed from a shared note</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="notify-on-note-deleted"
|
||||||
|
checked={prefs.notifyOnNoteDeleted}
|
||||||
|
onchange={() => updatePreference('notifyOnNoteDeleted', !prefs.notifyOnNoteDeleted)}
|
||||||
|
class="h-4 w-4 rounded-sm"
|
||||||
|
data-testid="pref-notify-on-note-deleted"
|
||||||
|
/>
|
||||||
|
<label for="notify-on-note-deleted" class="text-sm text-[var(--text)]">Email me when a shared note I have access to is deleted</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types.js';
|
import type { RequestHandler } from './$types.js';
|
||||||
import { requireAdmin } from '$lib/server/api-utils.js';
|
import { requireAdmin } from '$lib/server/api-utils.js';
|
||||||
import { listUsers, createUser } from '$lib/server/auth.js';
|
import { listUsers, createUser } from '$lib/server/auth.js';
|
||||||
|
import { isEmailConfigured, sendWelcomeEmail } from '$lib/server/email.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin(event);
|
requireAdmin(event);
|
||||||
@@ -21,5 +22,12 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await createUser(email, displayName || email.split('@')[0], password || null, role || 'user');
|
const user = await createUser(email, displayName || email.split('@')[0], password || null, role || 'user');
|
||||||
|
|
||||||
|
// Send welcome email (fire-and-forget)
|
||||||
|
if (isEmailConfigured() && user.email) {
|
||||||
|
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||||
|
sendWelcomeEmail(user.email, user.displayName, origin).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return json(user, { status: 201 });
|
return json(user, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types.js';
|
import type { RequestHandler } from './$types.js';
|
||||||
import { requireAdmin } from '$lib/server/api-utils.js';
|
import { requireAdmin } from '$lib/server/api-utils.js';
|
||||||
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions } from '$lib/server/auth.js';
|
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions } from '$lib/server/auth.js';
|
||||||
|
import { isEmailConfigured, sendPasswordResetEmail, sendRoleChangedEmail } from '$lib/server/email.js';
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||||
const admin = requireAdmin(event);
|
const admin = requireAdmin(event);
|
||||||
@@ -11,12 +12,19 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
|||||||
const user = getUser(userId);
|
const user = getUser(userId);
|
||||||
if (!user) throw error(404, 'User not found');
|
if (!user) throw error(404, 'User not found');
|
||||||
|
|
||||||
|
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||||
|
|
||||||
if (body.role !== undefined) {
|
if (body.role !== undefined) {
|
||||||
// Prevent admin from demoting themselves
|
// Prevent admin from demoting themselves
|
||||||
if (userId === admin.id && body.role !== 'admin') {
|
if (userId === admin.id && body.role !== 'admin') {
|
||||||
throw error(400, 'Cannot change your own role');
|
throw error(400, 'Cannot change your own role');
|
||||||
}
|
}
|
||||||
updateUserRole(userId, body.role);
|
updateUserRole(userId, body.role);
|
||||||
|
|
||||||
|
// Notify user of role change (fire-and-forget)
|
||||||
|
if (isEmailConfigured() && user.email) {
|
||||||
|
sendRoleChangedEmail(user.email, user.displayName, body.role, origin).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.newPassword !== undefined) {
|
if (body.newPassword !== undefined) {
|
||||||
@@ -24,6 +32,11 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
|||||||
throw error(400, 'Password must be at least 8 characters');
|
throw error(400, 'Password must be at least 8 characters');
|
||||||
}
|
}
|
||||||
await resetPassword(userId, body.newPassword);
|
await resetPassword(userId, body.newPassword);
|
||||||
|
|
||||||
|
// Notify user of password reset (fire-and-forget)
|
||||||
|
if (isEmailConfigured() && user.email) {
|
||||||
|
sendPasswordResetEmail(user.email, user.displayName, origin).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.revokeSessions) {
|
if (body.revokeSessions) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types.js';
|
import type { RequestHandler } from './$types.js';
|
||||||
import { getUserId } from '$lib/server/api-utils.js';
|
import { getUserId } from '$lib/server/api-utils.js';
|
||||||
import { verifyPassword, deleteUser, deleteSession, getUser } from '$lib/server/auth.js';
|
import { verifyPassword, deleteUser, deleteSession, getUser } from '$lib/server/auth.js';
|
||||||
|
import { isEmailConfigured, sendAccountDeletedEmail } from '$lib/server/email.js';
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => {
|
export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => {
|
||||||
const userId = getUserId(event);
|
const userId = getUserId(event);
|
||||||
@@ -17,6 +18,9 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
|
|||||||
if (!verified) throw error(401, 'Invalid password');
|
if (!verified) throw error(401, 'Invalid password');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture email/name before deletion
|
||||||
|
const { email: userEmail, displayName: userName } = user;
|
||||||
|
|
||||||
// Delete user and all associated data
|
// Delete user and all associated data
|
||||||
await deleteUser(userId);
|
await deleteUser(userId);
|
||||||
|
|
||||||
@@ -27,5 +31,10 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
|
|||||||
cookies.delete('session', { path: '/' });
|
cookies.delete('session', { path: '/' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send deletion confirmation email (fire-and-forget)
|
||||||
|
if (isEmailConfigured() && userEmail) {
|
||||||
|
sendAccountDeletedEmail(userEmail, userName).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return json({ success: true });
|
return json({ success: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { RequestHandler } from './$types.js';
|
|||||||
import { verifyPassword, createSession } from '$lib/server/auth.js';
|
import { verifyPassword, createSession } from '$lib/server/auth.js';
|
||||||
import { checkRateLimit, recordLoginAttempt } from '$lib/server/rate-limit.js';
|
import { checkRateLimit, recordLoginAttempt } from '$lib/server/rate-limit.js';
|
||||||
import { db } from '$lib/server/db/index.js';
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
import { users } from '$lib/server/db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { isEmailConfigured, sendAccountLockedEmail } from '$lib/server/email.js';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
|
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
|
||||||
const ip = getClientAddress();
|
const ip = getClientAddress();
|
||||||
@@ -23,6 +26,27 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
|
|||||||
const user = await verifyPassword(email, password);
|
const user = await verifyPassword(email, password);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
recordLoginAttempt(db, ip, email, false);
|
recordLoginAttempt(db, ip, email, false);
|
||||||
|
|
||||||
|
// Check if this failure just triggered a lockout — if so, notify the user
|
||||||
|
if (isEmailConfigured()) {
|
||||||
|
const newCheck = checkRateLimit(db, ip, email);
|
||||||
|
if (!newCheck.allowed) {
|
||||||
|
const retryAfterMinutes = Math.ceil((newCheck.retryAfter ?? 900) / 60);
|
||||||
|
const accountUser = db
|
||||||
|
.select({ email: users.email, displayName: users.displayName })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.email, email))
|
||||||
|
.get();
|
||||||
|
if (accountUser?.email) {
|
||||||
|
sendAccountLockedEmail(
|
||||||
|
accountUser.email,
|
||||||
|
accountUser.displayName,
|
||||||
|
retryAfterMinutes
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
throw error(401, 'Invalid email or password');
|
throw error(401, 'Invalid email or password');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types.js';
|
import type { RequestHandler } from './$types.js';
|
||||||
import { getUserId } from '$lib/server/api-utils.js';
|
import { getUserId } from '$lib/server/api-utils.js';
|
||||||
import { updateUserProfile } from '$lib/server/auth.js';
|
import { updateUserProfile, getUser } from '$lib/server/auth.js';
|
||||||
|
import { isEmailConfigured, sendEmailChangedEmail } from '$lib/server/email.js';
|
||||||
|
|
||||||
export const PUT: RequestHandler = async ({ request, ...event }) => {
|
export const PUT: RequestHandler = async ({ request, ...event }) => {
|
||||||
const userId = getUserId(event);
|
const userId = getUserId(event);
|
||||||
@@ -11,6 +12,15 @@ export const PUT: RequestHandler = async ({ request, ...event }) => {
|
|||||||
throw error(400, 'Email cannot be empty');
|
throw error(400, 'Email cannot be empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture old email before update to notify on change
|
||||||
|
const existingUser = email !== undefined ? getUser(userId) : null;
|
||||||
|
|
||||||
updateUserProfile(userId, { displayName, email });
|
updateUserProfile(userId, { displayName, email });
|
||||||
|
|
||||||
|
// Send notification to old email when email address changes
|
||||||
|
if (isEmailConfigured() && existingUser?.email && email && existingUser.email !== email) {
|
||||||
|
sendEmailChangedEmail(existingUser.email, existingUser.displayName, email).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return json({ success: true });
|
return json({ success: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { RequestHandler } from './$types.js';
|
|||||||
import { db } from '$lib/server/db/index.js';
|
import { db } from '$lib/server/db/index.js';
|
||||||
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
|
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
|
||||||
import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js';
|
import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js';
|
||||||
|
import { fetchCollaboratorsForNotes } from '$lib/server/collaborators.js';
|
||||||
|
import { isEmailConfigured, sendNotePermanentlyDeletedEmail } from '$lib/server/email.js';
|
||||||
|
import { getPreferences } from '$lib/server/preferences.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, ...event }) => {
|
export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||||
const userId = getUserId(event);
|
const userId = getUserId(event);
|
||||||
@@ -25,8 +28,32 @@ export const DELETE: RequestHandler = async ({ params, ...event }) => {
|
|||||||
const userId = getUserId(event);
|
const userId = getUserId(event);
|
||||||
requireNoteOwnership(db, params.id, userId);
|
requireNoteOwnership(db, params.id, userId);
|
||||||
|
|
||||||
|
// Fetch note and collaborators before deletion to send notifications
|
||||||
|
let collaboratorsToNotify: Array<{ email: string; displayName: string }> = [];
|
||||||
|
let noteTitle = 'Untitled';
|
||||||
|
|
||||||
|
if (isEmailConfigured()) {
|
||||||
|
const note = getNote(db, userId, params.id);
|
||||||
|
if (note) {
|
||||||
|
noteTitle = note.title || 'Untitled';
|
||||||
|
const collabMap = fetchCollaboratorsForNotes(db, [params.id]);
|
||||||
|
const collaborators = collabMap.get(params.id) ?? [];
|
||||||
|
for (const collab of collaborators) {
|
||||||
|
const prefs = getPreferences(db, collab.userId);
|
||||||
|
if (prefs.notifyOnNoteDeleted !== 'false' && collab.email) {
|
||||||
|
collaboratorsToNotify.push({ email: collab.email, displayName: collab.displayName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const deleted = deleteNote(db, userId, params.id);
|
const deleted = deleteNote(db, userId, params.id);
|
||||||
if (!deleted) throw error(404, 'Note not found');
|
if (!deleted) throw error(404, 'Note not found');
|
||||||
|
|
||||||
|
// Notify collaborators about the deleted note (fire-and-forget)
|
||||||
|
for (const collab of collaboratorsToNotify) {
|
||||||
|
sendNotePermanentlyDeletedEmail(collab.email, collab.displayName, noteTitle).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return json({ success: true });
|
return json({ success: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { addCollaborator, removeCollaborator, fetchCollaboratorsForNotes } from
|
|||||||
import { db } from '$lib/server/db/index.js';
|
import { db } from '$lib/server/db/index.js';
|
||||||
import { users, notes, noteCollaborators } from '$lib/server/db/schema.js';
|
import { users, notes, noteCollaborators } from '$lib/server/db/schema.js';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { isEmailConfigured, sendShareNotification } from '$lib/server/email.js';
|
import {
|
||||||
|
isEmailConfigured,
|
||||||
|
sendShareNotification,
|
||||||
|
sendCollaboratorRemovedEmail
|
||||||
|
} from '$lib/server/email.js';
|
||||||
import { getPreferences } from '$lib/server/preferences.js';
|
import { getPreferences } from '$lib/server/preferences.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, ...event }) => {
|
export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||||
@@ -102,7 +106,50 @@ export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
|
|||||||
throw error(403, 'Only the owner can remove other collaborators');
|
throw error(403, 'Only the owner can remove other collaborators');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch collaborator info and note title before removal for email notification
|
||||||
|
let removedUserEmail: string | undefined;
|
||||||
|
let removedUserName: string | undefined;
|
||||||
|
let noteTitle: string | undefined;
|
||||||
|
let ownerName: string | undefined;
|
||||||
|
|
||||||
|
if (isEmailConfigured() && isOwner && targetUserId !== userId) {
|
||||||
|
const targetPrefs = getPreferences(db, targetUserId);
|
||||||
|
if (targetPrefs.notifyOnCollabRemoved !== 'false') {
|
||||||
|
const target = db
|
||||||
|
.select({ email: users.email, displayName: users.displayName })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, targetUserId))
|
||||||
|
.get();
|
||||||
|
const noteData = db
|
||||||
|
.select({ title: notes.title })
|
||||||
|
.from(notes)
|
||||||
|
.where(eq(notes.id, params.id))
|
||||||
|
.get();
|
||||||
|
const owner = db
|
||||||
|
.select({ displayName: users.displayName })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.get();
|
||||||
|
removedUserEmail = target?.email ?? undefined;
|
||||||
|
removedUserName = target?.displayName ?? undefined;
|
||||||
|
noteTitle = noteData?.title ?? undefined;
|
||||||
|
ownerName = owner?.displayName ?? undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
removeCollaborator(db, params.id, targetUserId);
|
removeCollaborator(db, params.id, targetUserId);
|
||||||
|
|
||||||
|
// Send notification to removed collaborator (fire-and-forget)
|
||||||
|
if (removedUserEmail) {
|
||||||
|
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||||
|
sendCollaboratorRemovedEmail(
|
||||||
|
removedUserEmail,
|
||||||
|
removedUserName || '',
|
||||||
|
ownerName || 'Someone',
|
||||||
|
noteTitle || 'Untitled',
|
||||||
|
origin
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return json({ success: true });
|
return json({ success: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,8 +62,11 @@ test.describe('Offline Support', () => {
|
|||||||
await expect(page.getByText('Survive Reload')).toBeVisible();
|
await expect(page.getByText('Survive Reload')).toBeVisible();
|
||||||
|
|
||||||
// When the network is restored and sync completes
|
// When the network is restored and sync completes
|
||||||
const syncResponse = page.waitForResponse('**/api/sync');
|
const syncResponse = page.waitForResponse((resp) => resp.url().includes('/api/sync'));
|
||||||
await page.context().setOffline(false);
|
await page.context().setOffline(false);
|
||||||
|
// Manually dispatch the online event as a safety net — in CI/headless
|
||||||
|
// environments, setOffline(false) doesn't always emit it reliably
|
||||||
|
await page.evaluate(() => window.dispatchEvent(new Event('online')));
|
||||||
await syncResponse;
|
await syncResponse;
|
||||||
|
|
||||||
// And the user reloads the page
|
// And the user reloads the page
|
||||||
|
|||||||
+1
-1
@@ -253,7 +253,7 @@
|
|||||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<span class="footer-version">v0.20.0</span>
|
<span class="footer-version">v0.21.1</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user