mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
760235e667 | ||
|
|
0cc64fd8f3 | ||
|
|
dbade3595e | ||
|
|
585be91be5 | ||
|
|
c2bb6ef75a | ||
|
|
2fb1237f04 | ||
|
|
808217eb55 | ||
|
|
ea403dcbee | ||
|
|
5719fc3459 | ||
|
|
b2e7b2c96d |
@@ -2,7 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'claude/**']
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
@@ -19,8 +19,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -37,8 +35,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -68,8 +64,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
@@ -14,8 +14,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
@@ -146,7 +146,7 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
|
||||
|
||||
GitHub Actions with two workflows:
|
||||
|
||||
**CI** (`.github/workflows/ci.yml`) — runs on push to `main`/`claude/**` and PRs to `main`:
|
||||
**CI** (`.github/workflows/ci.yml`) — runs on push to `main` and PRs to `main` (branch work is validated once via the PR event, avoiding duplicate push+PR runs):
|
||||
1. **Lint & Type Check** — `pnpm check`
|
||||
2. **Unit Tests** — `pnpm test:unit`
|
||||
3. **Build** — `pnpm build` (depends on steps 1+2)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.28.1
|
||||
image: ghcr.io/bretzel-app/crumbs:0.28.3
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
+11
-7
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.28.2",
|
||||
"version": "0.28.4",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
"homepage": "https://crumbs.bretzel.app",
|
||||
@@ -51,7 +52,7 @@
|
||||
"arctic": "^3.7.0",
|
||||
"argon2": "^0.43.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
@@ -66,7 +67,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/kit": "^2.53.4",
|
||||
"@sveltejs/kit": "^2.63.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
@@ -76,7 +77,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.6",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"jsdom": "^29.0.1",
|
||||
"nyc": "^18.0.0",
|
||||
@@ -85,10 +86,10 @@
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.5",
|
||||
"vite-plugin-istanbul": "^7.2.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^3.2.4",
|
||||
"vitest": "^3.2.6",
|
||||
"workbox-window": "^7.4.0",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
@@ -102,7 +103,10 @@
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"esbuild@<0.25.0": "0.25.12",
|
||||
"cookie": ">=0.7.0",
|
||||
"hono": ">=4.12.7"
|
||||
"hono": ">=4.12.7",
|
||||
"devalue": ">=5.8.1",
|
||||
"path-to-regexp@8": ">=8.4.0",
|
||||
"fast-uri": ">=3.1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1546
-1004
File diff suppressed because it is too large
Load Diff
+47
-23
@@ -227,29 +227,53 @@ export function getUser(userId: number): User | null {
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: number): Promise<void> {
|
||||
// Cascade: delete user's data in dependency order
|
||||
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM note_user_state WHERE user_id = ?').run(userId);
|
||||
sqlite
|
||||
.prepare('DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ?')
|
||||
.run(userId, userId);
|
||||
sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(userId);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(userId);
|
||||
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
db.delete(users).where(eq(users.id, userId)).run();
|
||||
// Atomic cascade: all-or-nothing. Also clear rows from other users that
|
||||
// reference the deleted user's notes (e.g. sync_log, note_user_state,
|
||||
// note_collaborators) so foreign-key checks don't block the final delete.
|
||||
const tx = sqlite.transaction((uid: number) => {
|
||||
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(uid);
|
||||
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_user_state WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid, uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid, uid, uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM sync_log WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid, uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM attachments WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid, uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_versions WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid);
|
||||
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(uid);
|
||||
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(uid);
|
||||
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(uid);
|
||||
sqlite.prepare('DELETE FROM users WHERE id = ?').run(uid);
|
||||
});
|
||||
tx(userId);
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
|
||||
@@ -70,9 +70,16 @@
|
||||
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
users = users.filter((u) => u.id !== userId);
|
||||
createMsg = 'User deleted';
|
||||
createError = false;
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
createMsg = d.message || 'Failed to delete user';
|
||||
createError = true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
createMsg = 'Connection error';
|
||||
createError = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +174,10 @@
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each users as user (user.id)}
|
||||
<div class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
data-testid="user-row-{user.id}"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-medium text-[var(--text)]">
|
||||
{user.displayName || user.email}
|
||||
@@ -195,6 +205,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => deleteUser(user.id)}
|
||||
data-testid="delete-user-btn-{user.id}"
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
|
||||
>
|
||||
Delete
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { TEST_CREDENTIALS_FILE } from './global-setup';
|
||||
|
||||
const { userPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TEST_IMAGE_PATH = join(__dirname, 'helpers', 'test-image.png');
|
||||
|
||||
/** Create a victim user via the admin API, returning the user's id. */
|
||||
async function createVictim(
|
||||
page: import('@playwright/test').Page,
|
||||
email: string,
|
||||
displayName = email.split('@')[0]
|
||||
): Promise<number> {
|
||||
const res = await page.request.post('/api/admin/users', {
|
||||
data: { email, displayName, password: userPassword, role: 'user' }
|
||||
});
|
||||
expect(res.status()).toBe(201);
|
||||
const user = await res.json();
|
||||
return user.id;
|
||||
}
|
||||
|
||||
test.describe.serial('Admin — User Deletion', () => {
|
||||
test('Scenario: Admin deletes a user from the users list', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a disposable user exists
|
||||
const victimId = await createVictim(page, 'delete-me@test.com', 'Delete Me');
|
||||
|
||||
// When the admin navigates to the users settings page
|
||||
await page.goto('/settings/users');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
|
||||
|
||||
// And accepts the confirmation dialog for deletion
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(res) => res.url().endsWith(`/api/admin/users/${victimId}`) && res.request().method() === 'DELETE'
|
||||
);
|
||||
await page.getByTestId(`delete-user-btn-${victimId}`).click();
|
||||
const res = await deleteResponse;
|
||||
|
||||
// Then the delete request succeeds
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
// And the user disappears from the list
|
||||
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
|
||||
|
||||
// And the list is reloaded correctly on refresh (backend is consistent)
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Scenario: Deletion is cancelled when the admin dismisses the confirmation', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a user exists
|
||||
const victimId = await createVictim(page, 'keep-me@test.com', 'Keep Me');
|
||||
|
||||
// When the admin navigates to the users settings page
|
||||
await page.goto('/settings/users');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
|
||||
|
||||
// And dismisses the confirmation dialog
|
||||
page.once('dialog', (dialog) => dialog.dismiss());
|
||||
await page.getByTestId(`delete-user-btn-${victimId}`).click();
|
||||
|
||||
// Then the user remains in the list
|
||||
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
|
||||
|
||||
// Clean up
|
||||
await page.request.delete(`/api/admin/users/${victimId}`);
|
||||
});
|
||||
|
||||
test('Scenario: Deleting a user with notes, tags, and attachments succeeds', async ({
|
||||
authenticatedPage: page,
|
||||
browser
|
||||
}) => {
|
||||
// Given a user with full data exists (note, tag, attachment, preferences, api key)
|
||||
const email = 'heavy-user@test.com';
|
||||
const victimId = await createVictim(page, email, 'Heavy User');
|
||||
|
||||
// Log in as the victim in a fresh context so they create their own data
|
||||
const ctx = await browser.newContext();
|
||||
const victimPage = await ctx.newPage();
|
||||
await victimPage.goto('/login');
|
||||
await victimPage.getByTestId('email-input').fill(email);
|
||||
await victimPage.getByTestId('password-input').fill(userPassword);
|
||||
await victimPage.getByTestId('login-btn').click();
|
||||
await victimPage.waitForURL('/');
|
||||
|
||||
// Create a note with a tag
|
||||
const noteRes = await victimPage.request.post('/api/notes', {
|
||||
data: { title: 'Victim Note #todo', content: 'content #work' }
|
||||
});
|
||||
expect(noteRes.ok()).toBe(true);
|
||||
const note = await noteRes.json();
|
||||
|
||||
// Upload an attachment on the note. Use a browser-context fetch so the
|
||||
// Origin header is set correctly for SvelteKit's CSRF check.
|
||||
const imageBase64 = readFileSync(TEST_IMAGE_PATH).toString('base64');
|
||||
const attachmentStatus = await victimPage.evaluate(
|
||||
async ({ noteId, base64 }) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([bytes], { type: 'image/png' }), 'test-image.png');
|
||||
const r = await fetch(`/api/notes/${noteId}/attachments`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
});
|
||||
return r.status;
|
||||
},
|
||||
{ noteId: note.id, base64: imageBase64 }
|
||||
);
|
||||
expect(attachmentStatus).toBe(201);
|
||||
|
||||
// Write a preference and create an API key to exercise every table
|
||||
await victimPage.request.put('/api/preferences', {
|
||||
data: { defaultNoteMode: 'markdown' }
|
||||
});
|
||||
await victimPage.request.post('/api/settings/api-keys', { data: { name: 'test-key' } });
|
||||
|
||||
await ctx.close();
|
||||
|
||||
// When the admin deletes the user with all their data
|
||||
await page.goto('/settings/users');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(res) => res.url().endsWith(`/api/admin/users/${victimId}`) && res.request().method() === 'DELETE'
|
||||
);
|
||||
await page.getByTestId(`delete-user-btn-${victimId}`).click();
|
||||
const res = await deleteResponse;
|
||||
|
||||
// Then the delete succeeds atomically (no partial state)
|
||||
expect(res.status()).toBe(200);
|
||||
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
|
||||
|
||||
// And the user cannot log in afterwards
|
||||
const loginRes = await page.request.post('/api/auth/login', {
|
||||
data: { email, password: userPassword }
|
||||
});
|
||||
expect(loginRes.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -292,7 +292,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.28.2</span>
|
||||
<span class="footer-version">v0.28.4</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user