Compare commits
11 Commits
ef5f570dbf
...
5164801676
| Author | SHA1 | Date | |
|---|---|---|---|
| 5164801676 | |||
| 69882ecbea | |||
| 2422f3a28f | |||
| b65339ae13 | |||
| 194f144797 | |||
| 9fce824a8e | |||
| 18c2ed0bfe | |||
| f938ee9880 | |||
| f9149eba13 | |||
| 30fd1ff53b | |||
| 45f1925917 |
@ -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, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, 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 prisma.pond.deleteMany({ where: { ownerId: { in: all } } });
|
await deletePondsWhere(prisma, { 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();
|
||||||
|
|||||||
@ -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, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, 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 prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
await deletePondsWhere(prisma, { 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();
|
||||||
|
|||||||
@ -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, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, 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 prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
await deletePondsWhere(prisma, { 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();
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
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';
|
||||||
|
|
||||||
@ -17,8 +19,8 @@ export const apiI18n: I18n = createInstance();
|
|||||||
|
|
||||||
void apiI18n.init({
|
void apiI18n.init({
|
||||||
resources: {
|
resources: {
|
||||||
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks },
|
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks, ponds: enPonds },
|
||||||
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks },
|
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks, ponds: dePonds },
|
||||||
},
|
},
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
supportedLngs: ['de', 'en'],
|
supportedLngs: ['de', 'en'],
|
||||||
|
|||||||
@ -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, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, 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 prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
await deletePondsWhere(prisma, { 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();
|
||||||
|
|||||||
@ -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, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, 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 prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
|
await deletePondsWhere(prisma, { 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();
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
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';
|
||||||
|
|
||||||
@ -10,7 +9,7 @@ import { PluginApiController } from './plugin-api.controller';
|
|||||||
import { TasksService } from './tasks.service';
|
import { TasksService } from './tasks.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PondsModule, SearchModule, WatchesModule],
|
imports: [SearchModule, WatchesModule],
|
||||||
controllers: [PagesController, PluginApiController],
|
controllers: [PagesController, PluginApiController],
|
||||||
providers: [PagesService, TasksService],
|
providers: [PagesService, TasksService],
|
||||||
exports: [PagesService, TasksService],
|
exports: [PagesService, TasksService],
|
||||||
|
|||||||
@ -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, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -23,6 +23,7 @@ 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;
|
||||||
|
|
||||||
@ -71,6 +72,10 @@ 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`)
|
||||||
@ -128,10 +133,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 prisma.pond.deleteMany({ where: { id: pondId } });
|
await deletePondsWhere(prisma, { 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 prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
|
await deletePondsWhere(prisma, { 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();
|
||||||
@ -144,7 +149,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(
|
||||||
[openPageId, secretPageId].sort(),
|
[startPageId, 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).
|
||||||
|
|||||||
@ -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, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, 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 prisma.pond.deleteMany({ where: { id: pondId } });
|
await deletePondsWhere(prisma, { 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 prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
|
await deletePondsWhere(prisma, { 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();
|
||||||
|
|||||||
@ -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, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, 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 prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
await deletePondsWhere(prisma, { 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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, 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 prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
await deletePondsWhere(prisma, { 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,6 +106,107 @@ 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()
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
|
|
||||||
@ -8,7 +9,7 @@ import { PondsController } from './ponds.controller';
|
|||||||
import { PondsService } from './ponds.service';
|
import { PondsService } from './ponds.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [QuotasModule, SearchModule],
|
imports: [PagesModule, QuotasModule, SearchModule],
|
||||||
controllers: [PondsController],
|
controllers: [PondsController],
|
||||||
providers: [PondsService, PondAccessNotifier],
|
providers: [PondsService, PondAccessNotifier],
|
||||||
exports: [PondsService, PondAccessNotifier],
|
exports: [PondsService, PondAccessNotifier],
|
||||||
|
|||||||
@ -9,6 +9,8 @@ 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';
|
||||||
@ -23,11 +25,52 @@ 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,
|
||||||
@ -105,7 +148,12 @@ 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');
|
||||||
return this.viewOf(pond);
|
await this.createStartPage(owner, pond.id);
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -128,6 +176,7 @@ 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[]> {
|
||||||
@ -157,7 +206,8 @@ 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
|
||||||
: {
|
: {
|
||||||
@ -169,6 +219,7 @@ 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 },
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { Prisma, 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,3 +40,27 @@ 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 } } });
|
||||||
|
}
|
||||||
|
|||||||
@ -231,7 +231,10 @@ 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();
|
||||||
expect(audit!.details).toMatchObject({ trigger: 'manual', pages: 2, attachments: 1 });
|
// Two pages created here plus the pond's own start page (issue #302) —
|
||||||
|
// 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 () => {
|
||||||
|
|||||||
@ -93,11 +93,19 @@ 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();
|
||||||
await expect(page.locator('.sidebar__page:text-is("Startseite")')).toBeVisible();
|
// Scoped to the mount: the pond has its own "Startseite" since issue #302,
|
||||||
|
// 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.
|
// display text still reads like the original note name. Reached through the
|
||||||
await page.goto(`/p/${pond.slug}/startseite`);
|
// sidebar rather than by slug — `/startseite` belongs to the pond's own
|
||||||
|
// 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$`));
|
||||||
|
|
||||||
|
|||||||
@ -173,10 +173,14 @@ test('page read & edit — the 404-vs-403 policy holds per subject', async () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('sidebar list is filtered to each subject’s visible pages', async () => {
|
test('sidebar list is filtered to each subject’s visible pages', async () => {
|
||||||
expect(await listCount(f.admin.request, f.pondId)).toBe(3);
|
// Three fixture pages plus the pond's own start page (issue #302), which
|
||||||
expect(await listCount(f.owner.request, f.pondId)).toBe(3);
|
// every pond created through the api now carries. It is an ordinary page
|
||||||
expect(await listCount(f.reader.request, f.pondId)).toBe(3);
|
// with no grant of its own, so it follows the pond-wide permissions: the
|
||||||
expect(await listCount(f.editor.request, f.pondId)).toBe(2); // secret hidden
|
// outsider, who reaches only the explicitly public page, still sees one.
|
||||||
|
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);
|
||||||
|
|||||||
@ -14,6 +14,7 @@ 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';
|
||||||
@ -41,6 +42,7 @@ 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';
|
||||||
@ -85,6 +87,7 @@ 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,
|
||||||
@ -114,6 +117,7 @@ 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,
|
||||||
|
|||||||
@ -31,10 +31,19 @@ export function PondHomePage(): React.JSX.Element {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pages.data && pages.data.length > 0) {
|
if (!pages.data || pages.data.length === 0) return;
|
||||||
navigate(`/p/${pondSlug}/${pages.data[0]!.slug}`, { replace: true });
|
// The pond's chosen start page wins (issue #302), but only when it is in
|
||||||
}
|
// this user's page list. That list already holds just what they may see,
|
||||||
}, [pages.data, pondSlug, navigate]);
|
// so a start page hidden from them by a page-scoped grant falls back
|
||||||
|
// 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 <></>;
|
||||||
|
|||||||
@ -17,6 +17,7 @@ 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';
|
||||||
@ -44,6 +45,7 @@ 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();
|
||||||
|
|
||||||
@ -115,6 +117,16 @@ 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>
|
||||||
|
|||||||
84
apps/web/src/ponds/StartPageSetting.tsx
Normal file
84
apps/web/src/ponds/StartPageSetting.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
packages/shared/i18n/de/ponds.json
Normal file
10
packages/shared/i18n/de/ponds.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/shared/i18n/en/ponds.json
Normal file
10
packages/shared/i18n/en/ponds.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -60,6 +60,12 @@ 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):
|
||||||
@ -103,6 +109,7 @@ 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>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user