Compare commits

..

8 Commits

Author SHA1 Message Date
ef5f570dbf #301: reset the login rate limit before the VS-NfD packs
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m45s
CI / Build container images (pull_request) Successful in 1m18s
CI / Auth e2e pack (pull_request) Successful in 8m37s
CI / Import/export fidelity gate (pull_request) Successful in 1m0s
CI 665: the reflow guard itself passed; the run died two packs later on
`fixture login for fixture-admin failed: 429`.

The a11y pack costs one more login since this branch added the reflow
test, and that was enough to exhaust the budget before the VS-NfD packs.
Same trap the workflow already documents for the content and collab
packs — it just needed one more reset, in the place the extra login
pushed it over.
2026-08-01 12:14:15 +02:00
70685968fc #301: the token tables need the same scroll wrapper
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m26s
CI / Build container images (pull_request) Successful in 1m13s
CI / Auth e2e pack (pull_request) Failing after 8m24s
CI / Import/export fidelity gate (pull_request) Has been skipped
The sorted report finally named it: `table.api-tokens__table` at 833px
wide, with its `.visually-hidden` heading reaching right=737 — exactly
the document's scrollWidth. Same mechanism as the sessions table, a
second table I had not wrapped.

Locally the API-tokens table was empty and therefore narrow, which is why
this only ever appeared in CI. With a token present it reproduces:
without the wrapper 345px of page overflow, with it none.

The feed-token table gets the same treatment — it is built the same way
and would fail as soon as someone holds a feed token with a long name.

The "[in fitting scroller]" marker in the report is misleading for these:
`main.main` is a scroller, but it is `position: static`, so it never
clipped the absolutely positioned heading. Only a positioned ancestor
does — which is what `.table-scroll` now is.

Verified locally against a real stack, with a wide token table present:
reflow guard green, whole a11y pack green in both colour schemes.
2026-08-01 11:34:13 +02:00
0420f97c42 #301: sort the reflow report so the culprit cannot be buried
Some checks failed
CI / Build container images (pull_request) Successful in 1m13s
CI / Auth e2e pack (pull_request) Failing after 8m18s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Lint, typecheck, test (pull_request) Successful in 6m23s
CI still reports 737 while the local stack is now clean, and the box list
was capped at 15 entries — all of them nav links clipped by their own
scroller. Whatever pushes the page in CI sits past that cap.

The list is now sorted by reach, marks each entry as either clipped by a
fitting scroller or actually pushing the page, and shows 40.
2026-08-01 11:15:36 +02:00
27038a1f27 #301: the overflow was an escaping visually-hidden heading
Some checks failed
CI / Build container images (pull_request) Successful in 1m13s
CI / Auth e2e pack (pull_request) Failing after 8m14s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Lint, typecheck, test (pull_request) Successful in 6m21s
Found by standing up the local stack instead of guessing through CI.
The DOM tree under `.app-body` shows it in one line:

  span.visually-hidden rect=[342,343] pos=absolute

Its right edge is 343, and `.app-body` reports scrollWidth 343 against a
320 client. The table's actions column carries a `.visually-hidden`
heading, which is `position: absolute`. `.table-scroll` was `position:
static`, so it was NOT that span's containing block — the span escaped
the scroller's clipping, kept its static position out at the table's
right edge, and pushed the page.

`position: relative` on the wrapper makes it the containing block, and
the span is clipped like the rest of the table.

This is one cause behind both numbers: 23px locally, matching the
original report, and 417px in CI, where different font metrics make the
table wider and carry the span further out. Chasing them as separate
problems is what cost three CI rounds.

Verified locally against a real stack: the reflow guard passes and the
whole a11y pack is green, 11 tests in both colour schemes.
2026-08-01 08:50:02 +02:00
9c87a14f51 #301: dump raw box metrics from the reflow guard
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m33s
CI / Auth e2e pack (pull_request) Failing after 8m16s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Successful in 1m15s
Two rounds now reported no element past the viewport edge while the
document still claimed 417px of overflow — a combination that rules out
every hypothesis I had, including my own filter.

So stop inferring. The guard now prints the html/body metrics, every
element whose own content is wider than its box (with its overflow-x, so
the intentional scrollers are distinguishable), and every box reaching
past the edge with no filtering at all. Diagnostics ride in the assertion
message, not the compared value, so they show up even when they match.
2026-08-01 08:10:31 +02:00
55932b0828 #301: make the reflow guard report the ancestor chain
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Build container images (pull_request) Successful in 1m13s
CI / Auth e2e pack (pull_request) Failing after 8m14s
CI / Import/export fidelity gate (pull_request) Has been skipped
The previous run came back with an empty offender list and an unchanged
417px overflow: the filter treated everything under a scroll container as
innocent, including the container that was itself too wide. A scroller
only absolves its children when the scroller fits.

It now reports the chain from body down to the widest offender with each
box's width, so the first element wider than the viewport is visible
instead of inferred.
2026-08-01 07:53:34 +02:00
3c1f211f44 #301: the real culprit was the jump nav, not the wide content
Some checks failed
CI / Build container images (pull_request) Successful in 1m12s
CI / Auth e2e pack (pull_request) Failing after 8m10s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
The first attempt fixed plausible suspects. CI measured the actual page
and named something else: six `.settings-nav__link` buttons, 417px of
page-level overflow at 320px.

`.settings-nav` already had `overflow-x: auto`, but as a flex child it
also had the default `min-width: auto` — the min-content width of the
whole jump strip. That forced the column wider than the viewport, so its
own overflow rule never had anything to scroll. `min-width: 0` is exactly
the case CLAUDE.md warns about under Reflow.

The guard now ignores elements that sit inside a scroll container. Such
content is *meant* to be wider than the viewport — reporting it buried
the one finding that mattered under twelve lines of noise, and the cap
truncated the list before it could show anything else.

The table wrapper and the wrapping settings rows from the first commit
stay. Neither was the cause here, but a table cannot shrink below its
min-content width and those rows cannot wrap on their own, so both are
hardening that holds regardless of content.
2026-08-01 07:37:05 +02:00
e48b9dd7df #301: stop /settings scrolling horizontally at 320px
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Auth e2e pack (pull_request) Failing after 8m10s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Successful in 1m11s
WCAG 2.1 SC 1.4.10 asks for no two-dimensional scrolling down to 320px,
which is also what 400% zoom on a 1280px screen produces. The layout
skeleton was already hardened for this in #165; the overflow came from
content inside the sections.

- The sessions table cannot shrink below its min-content width — four
  columns, one of them the full user-agent string. It now scrolls inside
  its own container rather than pushing the page. The container is
  focusable with a role and a name, because a scroll area that only a
  mouse can reach trades one barrier for another.
- `.settings-checkbox` rows may wrap. The accent swatches have a fixed
  size and cannot shrink, so an unwrappable row set a floor for the whole
  page width.

Adds a reflow guard to the a11y pack. axe does not cover 1.4.10 — the
criterion is not derivable from the DOM — so this is a separate check,
and it names the overflowing elements when it trips instead of only
reporting that something overflows.
2026-08-01 07:17:19 +02:00
24 changed files with 41 additions and 375 deletions

View File

@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondsService } from '../ponds/ponds.service'; import { PondsService } from '../ponds/ponds.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
/** /**
@ -62,7 +62,7 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
afterAll(async () => { afterAll(async () => {
const all = Object.values(ids); const all = Object.values(ids);
await prisma.session.deleteMany({ where: { userId: { in: all } } }); await prisma.session.deleteMany({ where: { userId: { in: all } } });
await deletePondsWhere(prisma, { ownerId: { in: all } }); await prisma.pond.deleteMany({ where: { ownerId: { in: all } } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } }); await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
await prisma.user.deleteMany({ where: { id: { in: all } } }); await prisma.user.deleteMany({ where: { id: { in: all } } });
await prisma.$disconnect(); await prisma.$disconnect();

View File

@ -4,7 +4,7 @@ import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
describe.skipIf(!hasTestDb)('auth flows (e2e)', () => { describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
let app: INestApplication; let app: INestApplication;
@ -46,7 +46,7 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
afterAll(async () => { afterAll(async () => {
// Verified users own a personal pond (#21) — remove it before them. // Verified users own a personal pond (#21) — remove it before them.
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } }); await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
await prisma.$disconnect(); await prisma.$disconnect();

View File

@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service'; import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp } from '../testing/test-app'; import { createTestApp } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { FileStorageService } from './file-storage.service'; import { FileStorageService } from './file-storage.service';
@ -74,7 +74,7 @@ describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
await prisma.attachment.deleteMany({ where: { pondId } }); await prisma.attachment.deleteMany({ where: { pondId } });
const where = { pond: { owner: { username: { contains: suffix } } } }; const where = { pond: { owner: { username: { contains: suffix } } } };
await prisma.roleGrant.deleteMany({ where }); await prisma.roleGrant.deleteMany({ where });
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect(); await prisma.$disconnect();
await app.close(); await app.close();

View File

@ -1,12 +1,10 @@
import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deLegal from '@dorfteich/shared/i18n/de/legal.json'; import deLegal from '@dorfteich/shared/i18n/de/legal.json';
import deMails from '@dorfteich/shared/i18n/de/mails.json'; import deMails from '@dorfteich/shared/i18n/de/mails.json';
import dePonds from '@dorfteich/shared/i18n/de/ponds.json';
import deTasks from '@dorfteich/shared/i18n/de/tasks.json'; import deTasks from '@dorfteich/shared/i18n/de/tasks.json';
import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json';
import enLegal from '@dorfteich/shared/i18n/en/legal.json'; import enLegal from '@dorfteich/shared/i18n/en/legal.json';
import enMails from '@dorfteich/shared/i18n/en/mails.json'; import enMails from '@dorfteich/shared/i18n/en/mails.json';
import enPonds from '@dorfteich/shared/i18n/en/ponds.json';
import enTasks from '@dorfteich/shared/i18n/en/tasks.json'; import enTasks from '@dorfteich/shared/i18n/en/tasks.json';
import { createInstance, type i18n as I18n } from 'i18next'; import { createInstance, type i18n as I18n } from 'i18next';
@ -19,8 +17,8 @@ export const apiI18n: I18n = createInstance();
void apiI18n.init({ void apiI18n.init({
resources: { resources: {
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks, ponds: enPonds }, en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks },
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks, ponds: dePonds }, de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks },
}, },
fallbackLng: 'en', fallbackLng: 'en',
supportedLngs: ['de', 'en'], supportedLngs: ['de', 'en'],

View File

@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service'; import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { ConversionJobService } from './conversion-job.service'; import { ConversionJobService } from './conversion-job.service';
@ -108,7 +108,7 @@ describe.skipIf(!hasTestDb)('conversion job queue (e2e, issue #62)', () => {
// grant); clear those before the users they reference. // grant); clear those before the users they reference.
const where = { pond: { owner: { username: { contains: suffix } } } }; const where = { pond: { owner: { username: { contains: suffix } } } };
await prisma.roleGrant.deleteMany({ where }); await prisma.roleGrant.deleteMany({ where });
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect(); await prisma.$disconnect();
await app.close(); await app.close();

View File

@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondsService } from '../ponds/ponds.service'; import { PondsService } from '../ponds/ponds.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
/** /**
@ -97,7 +97,7 @@ describe.skipIf(!hasTestDb)('pond members (e2e, issue #54)', () => {
const ids = Object.values(userIds); const ids = Object.values(userIds);
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...ids, pondId] } } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...ids, pondId] } } });
await deletePondsWhere(prisma, { ownerId: { in: ids } }); await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect(); await prisma.$disconnect();
await app.close(); await app.close();

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PondsModule } from '../ponds/ponds.module';
import { WatchesModule } from '../watches/watches.module'; import { WatchesModule } from '../watches/watches.module';
import { SearchModule } from '../search/search.module'; import { SearchModule } from '../search/search.module';
@ -9,7 +10,7 @@ import { PluginApiController } from './plugin-api.controller';
import { TasksService } from './tasks.service'; import { TasksService } from './tasks.service';
@Module({ @Module({
imports: [SearchModule, WatchesModule], imports: [PondsModule, SearchModule, WatchesModule],
controllers: [PagesController, PluginApiController], controllers: [PagesController, PluginApiController],
providers: [PagesService, TasksService], providers: [PagesService, TasksService],
exports: [PagesService, TasksService], exports: [PagesService, TasksService],

View File

@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondsService } from '../ponds/ponds.service'; import { PondsService } from '../ponds/ponds.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
/** /**
@ -23,7 +23,6 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
const userIds: Record<string, string> = {}; const userIds: Record<string, string> = {};
const cookies: Record<string, string> = {}; const cookies: Record<string, string> = {};
let pondId: string; let pondId: string;
let startPageId: string;
let openPageId: string; let openPageId: string;
let secretPageId: string; let secretPageId: string;
@ -72,10 +71,6 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
.send({ name: `PlugApi Pond ${suffix}` }) .send({ name: `PlugApi Pond ${suffix}` })
.expect(201); .expect(201);
pondId = pond.body.id; pondId = pond.body.id;
// Every pond created through the api starts with a page (issue #302);
// a "full reader sees everything" assertion has to include it rather
// than pretend the pond began empty.
startPageId = pond.body.settings.startPageId as string;
const open = await api() const open = await api()
.post(`/api/v1/ponds/${pondId}/pages`) .post(`/api/v1/ponds/${pondId}/pages`)
@ -133,10 +128,10 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } }); await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } }); await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } }); await prisma.page.deleteMany({ where: { pondId } });
await deletePondsWhere(prisma, { id: pondId }); await prisma.pond.deleteMany({ where: { id: pondId } });
const ids = Object.values(userIds); const ids = Object.values(userIds);
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
await deletePondsWhere(prisma, { ownerId: { in: ids } }); await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect(); await prisma.$disconnect();
@ -149,7 +144,7 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
.set('Cookie', cookies.owner!) .set('Cookie', cookies.owner!)
.expect(200); .expect(200);
expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual( expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual(
[startPageId, openPageId, secretPageId].sort(), [openPageId, secretPageId].sort(),
); );
expect(res.body[0]).toMatchObject({ title: expect.any(String), slug: expect.any(String) }); expect(res.body[0]).toMatchObject({ title: expect.any(String), slug: expect.any(String) });
// Label *names* travel with each summary (issue #77, page-index filter). // Label *names* travel with each summary (issue #77, page-index filter).

View File

@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondsService } from '../ponds/ponds.service'; import { PondsService } from '../ponds/ponds.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
/** /**
@ -120,11 +120,11 @@ describe.skipIf(!hasTestDb)('permission enforcement (e2e, issue #52)', () => {
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } }); await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } }); await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } }); await prisma.page.deleteMany({ where: { pondId } });
await deletePondsWhere(prisma, { id: pondId }); await prisma.pond.deleteMany({ where: { id: pondId } });
// Personal ponds (and their grants) before their users. // Personal ponds (and their grants) before their users.
const ids = Object.values(userIds); const ids = Object.values(userIds);
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
await deletePondsWhere(prisma, { ownerId: { in: ids } }); await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect(); await prisma.$disconnect();

View File

@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service'; import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { PluginStorageService } from './plugin-storage.service'; import { PluginStorageService } from './plugin-storage.service';
@ -456,7 +456,7 @@ describe.skipIf(!hasTestDb)('plugins kill switch (e2e, issue #200)', () => {
await prisma.plugin.deleteMany({ where: { id: pluginId } }); await prisma.plugin.deleteMany({ where: { id: pluginId } });
const where = { pond: { owner: { username: { contains: suffix } } } }; const where = { pond: { owner: { username: { contains: suffix } } } };
await prisma.roleGrant.deleteMany({ where }); await prisma.roleGrant.deleteMany({ where });
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await app.close(); await app.close();
}); });

View File

@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service'; import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { PondAccessNotifier } from './pond-access-notifier.service'; import { PondAccessNotifier } from './pond-access-notifier.service';
@ -81,7 +81,7 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
await prisma.quotaOverride.deleteMany({ await prisma.quotaOverride.deleteMany({
where: { subjectId: { in: users.map((u) => u.id) } }, where: { subjectId: { in: users.map((u) => u.id) } },
}); });
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect(); await prisma.$disconnect();
await app.close(); await app.close();
@ -106,107 +106,6 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
expect(res.body.filter((p: { type: string }) => p.type === 'personal')).toHaveLength(1); expect(res.body.filter((p: { type: string }) => p.type === 'personal')).toHaveLength(1);
}); });
it('gives a new shared pond a start page and points settings at it (issue #302)', async () => {
const created = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name: `Startseitenteich ${suffix}` })
.expect(201);
const pages = await api()
.get(`/api/v1/ponds/${created.body.id}/pages`)
.set('Cookie', ownerCookie)
.expect(200);
expect(pages.body).toHaveLength(1);
// The title follows the creator's stored locale — this owner is 'de'.
expect(pages.body[0].title).toBe('Startseite');
const pond = await api()
.get(`/api/v1/ponds/${created.body.slug}`)
.set('Cookie', ownerCookie)
.expect(200);
expect(pond.body.settings.startPageId).toBe(pages.body[0].id);
});
it("titles the start page in the creator's locale (issue #302)", async () => {
// The owner is 'de' and got "Startseite" above; an 'en' account must get
// the English title. Without both halves the test would pass on a
// hardcoded string just as happily.
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
const username = `ellie-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `Ellie English ${suffix}`,
password,
locale: 'en',
});
const token = await tokens.issue(user.id, 'EMAIL_VERIFICATION', 600);
await api().post('/api/v1/auth/verify-email').send({ token }).expect(204);
const pond = await prisma.pond.findFirstOrThrow({
where: { ownerId: user.id, type: 'PERSONAL' },
});
const pages = await prisma.page.findMany({ where: { pondId: pond.id } });
expect(pages.map((page) => page.title)).toEqual(['Home']);
});
it('gives the personal pond a start page too (issue #302)', async () => {
const res = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
const personal = res.body.find((p: { type: string }) => p.type === 'personal');
const pages = await api()
.get(`/api/v1/ponds/${personal.id}/pages`)
.set('Cookie', ownerCookie)
.expect(200);
expect(pages.body).toHaveLength(1);
expect(personal.settings.startPageId).toBe(pages.body[0].id);
});
it('changes the start page without losing other settings (issue #302)', async () => {
const created = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name: `Wechselteich ${suffix}` })
.expect(201);
await api()
.patch(`/api/v1/ponds/${created.body.id}`)
.set('Cookie', ownerCookie)
.send({ commentPolicy: 'editors' })
.expect(200);
const second = await api()
.post(`/api/v1/ponds/${created.body.id}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Zweite ${suffix}` })
.expect(201);
const updated = await api()
.patch(`/api/v1/ponds/${created.body.id}`)
.set('Cookie', ownerCookie)
.send({ startPageId: second.body.id })
.expect(200);
expect(updated.body.settings.startPageId).toBe(second.body.id);
// The neighbouring key must survive the merge — settings hold only
// deviations, so an assigning write would silently reset it.
expect(updated.body.settings.commentPolicy).toBe('editors');
});
it('clears the start page back to the sort-order default (issue #302)', async () => {
const created = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name: `Leerteich ${suffix}` })
.expect(201);
const cleared = await api()
.patch(`/api/v1/ponds/${created.body.id}`)
.set('Cookie', ownerCookie)
.send({ startPageId: null })
.expect(200);
expect(cleared.body.settings.startPageId).toBeNull();
});
it('creates shared ponds with deterministic slug suffixes', async () => { it('creates shared ponds with deterministic slug suffixes', async () => {
const name = `Gartenteich ${suffix}`; const name = `Gartenteich ${suffix}`;
const first = await api() const first = await api()

View File

@ -1,6 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PagesModule } from '../pages/pages.module';
import { QuotasModule } from '../quotas/quotas.module'; import { QuotasModule } from '../quotas/quotas.module';
import { SearchModule } from '../search/search.module'; import { SearchModule } from '../search/search.module';
@ -9,7 +8,7 @@ import { PondsController } from './ponds.controller';
import { PondsService } from './ponds.service'; import { PondsService } from './ponds.service';
@Module({ @Module({
imports: [PagesModule, QuotasModule, SearchModule], imports: [QuotasModule, SearchModule],
controllers: [PondsController], controllers: [PondsController],
providers: [PondsService, PondAccessNotifier], providers: [PondsService, PondAccessNotifier],
exports: [PondsService, PondAccessNotifier], exports: [PondsService, PondAccessNotifier],

View File

@ -9,8 +9,6 @@ import {
import { Pond, Prisma, User } from '@prisma/client'; import { Pond, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { apiI18n } from '../i18n/api-i18n';
import { PagesService } from '../pages/pages.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service'; import { QuotaService } from '../quotas/quota.service';
@ -25,52 +23,11 @@ export class PondsService {
private readonly quotas: QuotaService, private readonly quotas: QuotaService,
private readonly accessNotifier: PondAccessNotifier, private readonly accessNotifier: PondAccessNotifier,
private readonly search: SearchProvider, private readonly search: SearchProvider,
private readonly pages: PagesService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(PondsService.name); this.logger.setContext(PondsService.name);
} }
/**
* Every new pond opens on a start page instead of the empty-pond hint
* (issue #302). Deliberately AFTER the creating transaction commits: the
* owner's POND_ADMIN grant is written inside it and the permission layer
* caches per pond, so creating the page in the same transaction would ask
* about rights the grant has not yet published.
*
* Goes through PagesService so the page carries every invariant a page
* needs unique slug, appended sort key, derived content cache, search
* indexing, and an `emptyPageState()` the collab server can bind to. A
* hand-rolled insert here would produce a page the editor cannot open.
*
* Failure is logged, not fatal: a pond without a start page simply falls
* back to the historical behaviour, which is a working state. Losing the
* whole pond over its first page would not be.
*/
private async createStartPage(owner: User, pondId: string): Promise<void> {
try {
const title = apiI18n.t('ponds:startPage.title', {
lng: owner.locale === 'de' ? 'de' : 'en',
});
const page = await this.pages.create(owner, pondId, { title });
const pond = await this.prisma.pond.findUniqueOrThrow({
where: { id: pondId },
select: { settings: true },
});
// Merge rather than assign: stored settings hold only deviations from
// the defaults, and overwriting the object would drop them.
await this.prisma.pond.update({
where: { id: pondId },
data: { settings: { ...(pond.settings as object), startPageId: page.id } },
});
} catch (error) {
this.logger.error(
{ pondId, err: error instanceof Error ? error.message : String(error) },
'start page for new pond could not be created',
);
}
}
viewOf(pond: Pond): PondView { viewOf(pond: Pond): PondView {
return { return {
id: pond.id, id: pond.id,
@ -148,12 +105,7 @@ export class PondsService {
return created; return created;
}); });
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created'); this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created');
await this.createStartPage(owner, pond.id); return this.viewOf(pond);
// Re-read: the row captured in the transaction predates the start page,
// so returning it would hand the caller `startPageId: null` for a pond
// that has one.
const withStartPage = await this.prisma.pond.findUniqueOrThrow({ where: { id: pond.id } });
return this.viewOf(withStartPage);
} }
/** /**
@ -176,7 +128,6 @@ export class PondsService {
return created; return created;
}); });
this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond created'); this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond created');
await this.createStartPage(user, pond.id);
} }
async listVisible(user: User): Promise<PondView[]> { async listVisible(user: User): Promise<PondView[]> {
@ -206,8 +157,7 @@ export class PondsService {
input.commentPolicy !== undefined || input.commentPolicy !== undefined ||
input.apiEnabled !== undefined || input.apiEnabled !== undefined ||
input.mcpEnabled !== undefined || input.mcpEnabled !== undefined ||
input.theme !== undefined || input.theme !== undefined;
input.startPageId !== undefined;
const settings = !settingsChanged const settings = !settingsChanged
? undefined ? undefined
: { : {
@ -219,7 +169,6 @@ export class PondsService {
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}), ...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}), ...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}),
...(input.theme !== undefined ? { theme: input.theme } : {}), ...(input.theme !== undefined ? { theme: input.theme } : {}),
...(input.startPageId !== undefined ? { startPageId: input.startPageId } : {}),
}; };
const updated = await this.prisma.pond.update({ const updated = await this.prisma.pond.update({
where: { id }, where: { id },

View File

@ -1,4 +1,4 @@
import { Prisma, PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
/** True when database-backed tests can run (see vitest.global-setup.ts). */ /** True when database-backed tests can run (see vitest.global-setup.ts). */
export const hasTestDb = Boolean(process.env.TEST_DATABASE_URL); export const hasTestDb = Boolean(process.env.TEST_DATABASE_URL);
@ -40,27 +40,3 @@ export async function grantOwnerAdmin(
}, },
}); });
} }
/**
* Deletes the ponds matching `where`, their pages first.
*
* `Page.pond` deliberately carries no `onDelete: Cascade` a real purge
* (TrashService) removes a pond's contents explicitly and audits it, and a
* silent database cascade would hide that. Since issue #302 every pond
* created through the api starts with a page, so teardowns that went
* straight for `pond.deleteMany` now hit the foreign key.
*
* Page-owned rows (updates, comments, links, ) do cascade from the page.
*/
export async function deletePondsWhere(
prisma: PrismaClient,
where: Prisma.PondWhereInput,
): Promise<void> {
const pondIds = (await prisma.pond.findMany({ where, select: { id: true } })).map(
(pond) => pond.id,
);
if (pondIds.length === 0) return;
await prisma.attachment.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.pond.deleteMany({ where: { id: { in: pondIds } } });
}

View File

@ -231,10 +231,7 @@ describe.skipIf(!hasTestDb)('pond purge (e2e, issue #193)', () => {
where: { action: 'pond.purged', targetId: pondId }, where: { action: 'pond.purged', targetId: pondId },
}); });
expect(audit).not.toBeNull(); expect(audit).not.toBeNull();
// Two pages created here plus the pond's own start page (issue #302) — expect(audit!.details).toMatchObject({ trigger: 'manual', pages: 2, attachments: 1 });
// the audit records what was actually removed, so the count moves with
// the pond's real contents rather than with what the test typed out.
expect(audit!.details).toMatchObject({ trigger: 'manual', pages: 3, attachments: 1 });
}); });
it('purges due ponds on the retention path with an audit event', async () => { it('purges due ponds on the retention path with an audit event', async () => {

View File

@ -93,19 +93,11 @@ test('a pond admin imports an Obsidian vault through the settings dialog', async
await expect( await expect(
mountItem.locator('.sidebar__tree-children .sidebar__page', { hasText: 'Projekte' }), mountItem.locator('.sidebar__tree-children .sidebar__page', { hasText: 'Projekte' }),
).toBeVisible(); ).toBeVisible();
// Scoped to the mount: the pond has its own "Startseite" since issue #302, await expect(page.locator('.sidebar__page:text-is("Startseite")')).toBeVisible();
// so an unscoped title match now finds two entries.
const importedHome = mountItem
.locator('.sidebar__tree-children .sidebar__page')
.filter({ hasText: /^Startseite$/ })
.first();
await expect(importedHome).toBeVisible();
// A rewritten Obsidian link navigates to the right imported page, and the // A rewritten Obsidian link navigates to the right imported page, and the
// display text still reads like the original note name. Reached through the // display text still reads like the original note name.
// sidebar rather than by slug — `/startseite` belongs to the pond's own await page.goto(`/p/${pond.slug}/startseite`);
// start page, so the imported note landed on a suffixed slug.
await importedHome.click();
await page.locator('.editor-content a.wikilink', { hasText: 'Projekt A' }).click(); await page.locator('.editor-content a.wikilink', { hasText: 'Projekt A' }).click();
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/projekt-a$`)); await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/projekt-a$`));

View File

@ -173,14 +173,10 @@ test('page read & edit — the 404-vs-403 policy holds per subject', async () =>
}); });
test('sidebar list is filtered to each subjects visible pages', async () => { test('sidebar list is filtered to each subjects visible pages', async () => {
// Three fixture pages plus the pond's own start page (issue #302), which expect(await listCount(f.admin.request, f.pondId)).toBe(3);
// every pond created through the api now carries. It is an ordinary page expect(await listCount(f.owner.request, f.pondId)).toBe(3);
// with no grant of its own, so it follows the pond-wide permissions: the expect(await listCount(f.reader.request, f.pondId)).toBe(3);
// outsider, who reaches only the explicitly public page, still sees one. expect(await listCount(f.editor.request, f.pondId)).toBe(2); // secret hidden
expect(await listCount(f.admin.request, f.pondId)).toBe(4);
expect(await listCount(f.owner.request, f.pondId)).toBe(4);
expect(await listCount(f.reader.request, f.pondId)).toBe(4);
expect(await listCount(f.editor.request, f.pondId)).toBe(3); // secret hidden
expect(await listCount(f.outsider.request, f.pondId)).toBe(1); // only the public page expect(await listCount(f.outsider.request, f.pondId)).toBe(1); // only the public page
// Anonymous cannot hit the authenticated list endpoint at all. // Anonymous cannot hit the authenticated list endpoint at all.
expect(await listCount(f.anon, f.pondId)).toBe(401); expect(await listCount(f.anon, f.pondId)).toBe(401);

View File

@ -14,7 +14,6 @@ import deLegal from '@dorfteich/shared/i18n/de/legal.json';
import deLinks from '@dorfteich/shared/i18n/de/links.json'; import deLinks from '@dorfteich/shared/i18n/de/links.json';
import deMembers from '@dorfteich/shared/i18n/de/members.json'; import deMembers from '@dorfteich/shared/i18n/de/members.json';
import deNotifications from '@dorfteich/shared/i18n/de/notifications.json'; import deNotifications from '@dorfteich/shared/i18n/de/notifications.json';
import dePonds from '@dorfteich/shared/i18n/de/ponds.json';
import dePlugins from '@dorfteich/shared/i18n/de/plugins.json'; import dePlugins from '@dorfteich/shared/i18n/de/plugins.json';
import dePublic from '@dorfteich/shared/i18n/de/public.json'; import dePublic from '@dorfteich/shared/i18n/de/public.json';
import deQuotas from '@dorfteich/shared/i18n/de/quotas.json'; import deQuotas from '@dorfteich/shared/i18n/de/quotas.json';
@ -42,7 +41,6 @@ import enLegal from '@dorfteich/shared/i18n/en/legal.json';
import enLinks from '@dorfteich/shared/i18n/en/links.json'; import enLinks from '@dorfteich/shared/i18n/en/links.json';
import enMembers from '@dorfteich/shared/i18n/en/members.json'; import enMembers from '@dorfteich/shared/i18n/en/members.json';
import enNotifications from '@dorfteich/shared/i18n/en/notifications.json'; import enNotifications from '@dorfteich/shared/i18n/en/notifications.json';
import enPonds from '@dorfteich/shared/i18n/en/ponds.json';
import enPlugins from '@dorfteich/shared/i18n/en/plugins.json'; import enPlugins from '@dorfteich/shared/i18n/en/plugins.json';
import enPublic from '@dorfteich/shared/i18n/en/public.json'; import enPublic from '@dorfteich/shared/i18n/en/public.json';
import enQuotas from '@dorfteich/shared/i18n/en/quotas.json'; import enQuotas from '@dorfteich/shared/i18n/en/quotas.json';
@ -87,7 +85,6 @@ void i18n
links: enLinks, links: enLinks,
members: enMembers, members: enMembers,
notifications: enNotifications, notifications: enNotifications,
ponds: enPonds,
plugins: enPlugins, plugins: enPlugins,
public: enPublic, public: enPublic,
quotas: enQuotas, quotas: enQuotas,
@ -117,7 +114,6 @@ void i18n
links: deLinks, links: deLinks,
members: deMembers, members: deMembers,
notifications: deNotifications, notifications: deNotifications,
ponds: dePonds,
plugins: dePlugins, plugins: dePlugins,
public: dePublic, public: dePublic,
quotas: deQuotas, quotas: deQuotas,

View File

@ -31,19 +31,10 @@ export function PondHomePage(): React.JSX.Element {
}); });
useEffect(() => { useEffect(() => {
if (!pages.data || pages.data.length === 0) return; if (pages.data && pages.data.length > 0) {
// The pond's chosen start page wins (issue #302), but only when it is in navigate(`/p/${pondSlug}/${pages.data[0]!.slug}`, { replace: true });
// this user's page list. That list already holds just what they may see, }
// so a start page hidden from them by a page-scoped grant falls back }, [pages.data, pondSlug, navigate]);
// silently instead of landing them on a 404 — and it costs no extra
// request. A trashed start page is a stale id, not an error: it simply
// is not in the list either.
const startPageId = pond.data?.settings.startPageId ?? null;
const target =
(startPageId ? pages.data.find((page) => page.id === startPageId) : undefined) ??
pages.data[0]!;
navigate(`/p/${pondSlug}/${target.slug}`, { replace: true });
}, [pages.data, pond.data, pondSlug, navigate]);
if (pond.error || pages.error) return <FormError error={pond.error ?? pages.error} />; if (pond.error || pages.error) return <FormError error={pond.error ?? pages.error} />;
if (!pond.data || !pages.data || pages.data.length > 0) return <></>; if (!pond.data || !pages.data || pages.data.length > 0) return <></>;

View File

@ -17,7 +17,6 @@ import { EffectivePermissionsInspector } from '../access/EffectivePermissionsIns
import { PondFileManager } from '../files/PondFileManager'; import { PondFileManager } from '../files/PondFileManager';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
import { VaultImportSection } from '../import/VaultImportSection'; import { VaultImportSection } from '../import/VaultImportSection';
import { StartPageSetting } from '../ponds/StartPageSetting';
import { SidebarViewSetting } from '../layout/SidebarViewSetting'; import { SidebarViewSetting } from '../layout/SidebarViewSetting';
import { MemberManager } from '../members/MemberManager'; import { MemberManager } from '../members/MemberManager';
import { DeletePondSection } from '../ponds/DeletePondSection'; import { DeletePondSection } from '../ponds/DeletePondSection';
@ -45,7 +44,6 @@ export function PondSettingsPage(): React.JSX.Element {
const { t: tFont } = useTranslation('font'); const { t: tFont } = useTranslation('font');
const { t: tCommon } = useTranslation(); const { t: tCommon } = useTranslation();
const { t: tImport } = useTranslation('import'); const { t: tImport } = useTranslation('import');
const { t: tPonds } = useTranslation('ponds');
const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { pondSlug = '' } = useParams<{ pondSlug: string }>();
const { user } = useAuth(); const { user } = useAuth();
@ -117,16 +115,6 @@ export function PondSettingsPage(): React.JSX.Element {
</section> </section>
)} )}
{canModify && <PondPluginSettings pondId={pond.data.id} />} {canModify && <PondPluginSettings pondId={pond.data.id} />}
{canModify && (
<section>
<h2>{tPonds('startPage.label')}</h2>
<StartPageSetting
pondId={pond.data.id}
pondSlug={pondSlug}
value={pond.data.settings.startPageId}
/>
</section>
)}
{canModify && ( {canModify && (
<section> <section>
<h2>{tCommon('layout.sidebar.view.defaultTitle')}</h2> <h2>{tCommon('layout.sidebar.view.defaultTitle')}</h2>

View File

@ -1,84 +0,0 @@
import type { PageView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FormError, FormSuccess } from '../components/forms';
import { apiGet, apiPatch } from '../lib/api';
/**
* Which page the pond opens on (issue #302). Before this, `/p/:pondSlug`
* always redirected to the first page in the sidebar's sort order stable,
* but a rule nobody could see, and one whose target moved as soon as someone
* added a page that sorted ahead of it.
*
* The empty option is a real choice, not a blank entry: it restores exactly
* that historical behaviour. A pond whose stored start page has since been
* trashed shows the same empty state, because the id no longer resolves
* `PondHomePage` falls back for the same reason.
*/
export function StartPageSetting({
pondId,
pondSlug,
value,
}: {
pondId: string;
pondSlug: string;
value: string | null;
}): React.JSX.Element {
const { t } = useTranslation('ponds');
const queryClient = useQueryClient();
const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false);
const pages = useQuery({
queryKey: ['pages', pondId],
queryFn: () => apiGet<PageView[]>(`/ponds/${pondId}/pages`),
});
const save = async (next: string): Promise<void> => {
setError(null);
setSaved(false);
try {
await apiPatch(`/ponds/${pondId}`, { startPageId: next === '' ? null : next });
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
setSaved(true);
} catch (err) {
setError(err);
}
};
// A stored id that is not among the pond's pages (trashed, or not visible
// to this user) must not silently select the first option — show the
// fallback state instead, which is what the pond actually does.
const known = (pages.data ?? []).some((page) => page.id === value);
const selected = value && known ? value : '';
return (
<div className="start-page-setting">
<FormError error={error} />
<p className="start-page-setting__hint">{t('startPage.hint')}</p>
<label>
{t('startPage.label')}{' '}
<select
value={selected}
disabled={pages.isLoading}
onChange={(event) => void save(event.target.value)}
>
<option value="">{t('startPage.none')}</option>
{(pages.data ?? []).map((page) => (
<option key={page.id} value={page.id}>
{page.title}
</option>
))}
</select>
</label>
{value && !known && !pages.isLoading && (
<p className="start-page-setting__hint" role="status">
{t('startPage.missing')}
</p>
)}
{saved && <FormSuccess message={t('startPage.saved')} />}
</div>
);
}

View File

@ -1,10 +0,0 @@
{
"startPage": {
"title": "Startseite",
"label": "Seite beim Öffnen des Teichs",
"hint": "Legt fest, welche Seite erscheint, wenn der Teich ausgewählt wird.",
"none": "Keine — erste Seite der Sortierung",
"missing": "Die gespeicherte Startseite gibt es nicht mehr. Der Teich öffnet die erste Seite der Sortierung.",
"saved": "Startseite gespeichert."
}
}

View File

@ -1,10 +0,0 @@
{
"startPage": {
"title": "Home",
"label": "Page shown when the pond opens",
"hint": "Decides which page appears when the pond is selected.",
"none": "None — first page by sort order",
"missing": "The saved start page no longer exists. The pond opens the first page by sort order.",
"saved": "Start page saved."
}
}

View File

@ -60,12 +60,6 @@ export const pondSettingsSchema = z.object({
* every member can override it locally (`ui.sidebar.view.<pondId>`). */ * every member can override it locally (`ui.sidebar.view.<pondId>`). */
sidebarView: z.enum(SIDEBAR_VIEW_MODES).default('folders'), sidebarView: z.enum(SIDEBAR_VIEW_MODES).default('folders'),
fonts: pondFontsSchema.default({}), fonts: pondFontsSchema.default({}),
/** Which page the pond opens on (issue #302). `null` keeps the historical
* behaviour the first page in the sidebar's sort order, which is stable
* but invisible to the user and moves when a page sorts ahead of it. Held
* as an id, not a slug, so renaming or moving the page does not break it;
* a dangling id (page trashed) falls back rather than erroring. */
startPageId: z.string().uuid().nullable().default(null),
/** Who may write comments (issue #91): every reader, or editors only. */ /** Who may write comments (issue #91): every reader, or editors only. */
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'), commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
/** Per-pond opt-in to the public REST API (issue #104, default off): /** Per-pond opt-in to the public REST API (issue #104, default off):
@ -109,7 +103,6 @@ export const updatePondInputSchema = z
apiEnabled: z.boolean(), apiEnabled: z.boolean(),
mcpEnabled: z.boolean(), mcpEnabled: z.boolean(),
theme: pondThemeSchema, theme: pondThemeSchema,
startPageId: z.string().uuid().nullable(),
}) })
.partial(); .partial();
export type UpdatePondInput = z.infer<typeof updatePondInputSchema>; export type UpdatePondInput = z.infer<typeof updatePondInputSchema>;