mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cf34e631b | ||
|
|
9f8fe85c44 | ||
|
|
5ac7de7f78 | ||
|
|
5f6149e282 | ||
|
|
633727b58e | ||
|
|
3ab7c1fb53 |
@@ -44,3 +44,4 @@ todo.md
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
package-lock.json
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.19.3
|
||||
image: ghcr.io/bretzel-app/crumbs:0.20.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
Generated
+13070
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
|
||||
+252
-15
@@ -65,6 +65,125 @@ export async function sendShareNotification(
|
||||
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(
|
||||
toName: string,
|
||||
fromName: string,
|
||||
@@ -74,7 +193,18 @@ function buildShareEmailHtml(
|
||||
const displayName = toName || 'there';
|
||||
const escapedTitle = escapeHtml(noteTitle);
|
||||
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>
|
||||
<html>
|
||||
<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;">
|
||||
<tr><td style="padding:32px;">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
${content}
|
||||
<p style="margin:0;font-size:11px;color:#6b6272;line-height:1.5;">${footer}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@@ -107,6 +224,126 @@ function buildShareEmailHtml(
|
||||
</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 {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
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 type { Db } from './db/index.js';
|
||||
import {
|
||||
@@ -233,6 +233,31 @@ describe('updateNote', () => {
|
||||
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', () => {
|
||||
seedNote('n1', { title: 'Original' });
|
||||
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
|
||||
const hasContentUpdate =
|
||||
input.title !== undefined ||
|
||||
input.content !== undefined ||
|
||||
input.color !== undefined ||
|
||||
input.checklistMode !== undefined;
|
||||
// Create a version snapshot only when at least one content field actually changed
|
||||
const hasActualContentChange =
|
||||
(input.title !== undefined && input.title !== existing.title) ||
|
||||
(input.content !== undefined && input.content !== existing.content) ||
|
||||
(input.color !== undefined && input.color !== existing.color) ||
|
||||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
|
||||
|
||||
if (hasContentUpdate) {
|
||||
if (hasActualContentChange) {
|
||||
const saved = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
if (saved) {
|
||||
createSnapshot(db, id, {
|
||||
|
||||
@@ -9,7 +9,7 @@ const CONTENT_PREVIEW_LENGTH = 80;
|
||||
|
||||
/**
|
||||
* 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(
|
||||
db: Db,
|
||||
|
||||
@@ -8,13 +8,17 @@ describe('preferences defaults', () => {
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
sidebarDefaultState: 'open',
|
||||
notifyOnShare: true
|
||||
notifyOnShare: true,
|
||||
notifyOnCollabRemoved: true,
|
||||
notifyOnNoteDeleted: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should identify boolean preference keys', () => {
|
||||
expect(BOOLEAN_PREF_KEYS.has('hideFooter')).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('defaultNoteColor')).toBe(false);
|
||||
expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false);
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface UserPreferences {
|
||||
hideFooter: boolean;
|
||||
sidebarDefaultState: 'open' | 'collapsed';
|
||||
notifyOnShare: boolean;
|
||||
notifyOnCollabRemoved: boolean;
|
||||
notifyOnNoteDeleted: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
@@ -13,10 +15,14 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
sidebarDefaultState: 'open',
|
||||
notifyOnShare: true
|
||||
notifyOnShare: true,
|
||||
notifyOnCollabRemoved: true,
|
||||
notifyOnNoteDeleted: true
|
||||
};
|
||||
|
||||
export const BOOLEAN_PREF_KEYS: ReadonlySet<keyof UserPreferences> = new Set([
|
||||
'hideFooter',
|
||||
'notifyOnShare'
|
||||
'notifyOnShare',
|
||||
'notifyOnCollabRemoved',
|
||||
'notifyOnNoteDeleted'
|
||||
]);
|
||||
|
||||
@@ -66,15 +66,40 @@
|
||||
</div>
|
||||
|
||||
<!-- Email notifications -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="notify-on-share"
|
||||
checked={prefs.notifyOnShare}
|
||||
onchange={() => updatePreference('notifyOnShare', !prefs.notifyOnShare)}
|
||||
class="h-4 w-4 rounded-sm"
|
||||
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 class="space-y-3">
|
||||
<span class="block text-sm font-medium text-[var(--text)]">Email notifications</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="notify-on-share"
|
||||
checked={prefs.notifyOnShare}
|
||||
onchange={() => updatePreference('notifyOnShare', !prefs.notifyOnShare)}
|
||||
class="h-4 w-4 rounded-sm"
|
||||
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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { requireAdmin } from '$lib/server/api-utils.js';
|
||||
import { listUsers, createUser } from '$lib/server/auth.js';
|
||||
import { isEmailConfigured, sendWelcomeEmail } from '$lib/server/email.js';
|
||||
|
||||
export const GET: RequestHandler = async (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');
|
||||
|
||||
// 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 });
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { requireAdmin } from '$lib/server/api-utils.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 }) => {
|
||||
const admin = requireAdmin(event);
|
||||
@@ -11,12 +12,19 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const user = getUser(userId);
|
||||
if (!user) throw error(404, 'User not found');
|
||||
|
||||
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||
|
||||
if (body.role !== undefined) {
|
||||
// Prevent admin from demoting themselves
|
||||
if (userId === admin.id && body.role !== 'admin') {
|
||||
throw error(400, 'Cannot change your own 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) {
|
||||
@@ -24,6 +32,11 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
throw error(400, 'Password must be at least 8 characters');
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { getUserId } from '$lib/server/api-utils.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 }) => {
|
||||
const userId = getUserId(event);
|
||||
@@ -17,6 +18,9 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
|
||||
if (!verified) throw error(401, 'Invalid password');
|
||||
}
|
||||
|
||||
// Capture email/name before deletion
|
||||
const { email: userEmail, displayName: userName } = user;
|
||||
|
||||
// Delete user and all associated data
|
||||
await deleteUser(userId);
|
||||
|
||||
@@ -27,5 +31,10 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
|
||||
cookies.delete('session', { path: '/' });
|
||||
}
|
||||
|
||||
// Send deletion confirmation email (fire-and-forget)
|
||||
if (isEmailConfigured() && userEmail) {
|
||||
sendAccountDeletedEmail(userEmail, userName).catch(() => {});
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { RequestHandler } from './$types.js';
|
||||
import { verifyPassword, createSession } from '$lib/server/auth.js';
|
||||
import { checkRateLimit, recordLoginAttempt } from '$lib/server/rate-limit.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 }) => {
|
||||
const ip = getClientAddress();
|
||||
@@ -23,6 +26,27 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
|
||||
const user = await verifyPassword(email, password);
|
||||
if (!user) {
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.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 }) => {
|
||||
const userId = getUserId(event);
|
||||
@@ -11,6 +12,15 @@ export const PUT: RequestHandler = async ({ request, ...event }) => {
|
||||
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 });
|
||||
|
||||
// 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 });
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.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 }) => {
|
||||
const userId = getUserId(event);
|
||||
@@ -25,8 +28,32 @@ export const DELETE: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
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);
|
||||
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 });
|
||||
};
|
||||
|
||||
@@ -5,7 +5,11 @@ import { addCollaborator, removeCollaborator, fetchCollaboratorsForNotes } from
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { users, notes, noteCollaborators } from '$lib/server/db/schema.js';
|
||||
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';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// 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 });
|
||||
};
|
||||
|
||||
+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/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||
</ul>
|
||||
<span class="footer-version">v0.20.0</span>
|
||||
<span class="footer-version">v0.21.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user